feat: add asset import services

This commit is contained in:
Simo committed 2026-07-12 19:12:25 +02:00
1 parent 4b596226e0
commit 3497df9dfd
46 files changed
+5383

No files matched your search

+38
View File
@@ -0,0 +1,38 @@
import type { NextRequest } from 'next/server'
import { NextResponse } from 'next/server'
import { logAuthorizationEvent } from '@/lib/admin/authorization-events'
import { canAccess, getApiAdminContext } from '@/lib/permissions'
import { logServerError } from '@/lib/server-log'
type AdminContext = NonNullable<Awaited<ReturnType<typeof getApiAdminContext>>>
type RouteContext = { params?: Promise<Record<string, string | string[]>> }
type AdminHandler = (
request: NextRequest,
context: AdminContext,
routeContext: RouteContext,
) => Promise<Response> | Response
export function withAdmin(options: { permission?: string }, handler: AdminHandler) {
return async (request: NextRequest, routeContext: RouteContext = {}) => {
const context = await getApiAdminContext()
if (!context) return NextResponse.json({ ok: false, error: 'Unauthorized' }, { status: 401 })
if (options.permission && !canAccess(context.permissions, options.permission, context.session.user.rank)) {
await logAuthorizationEvent({
kind: 'permission.denied',
userId: context.session.user.id,
username: context.session.user.username,
rank: context.session.user.rank,
permission: options.permission,
source: request.nextUrl.pathname,
reason: 'API permission check denied',
})
return NextResponse.json({ ok: false, error: 'Forbidden' }, { status: 403 })
}
try {
return await handler(request, context, routeContext)
} catch (error) {
logServerError('admin.api_failed', error, { path: request.nextUrl.pathname, userId: context.session.user.id })
return NextResponse.json({ ok: false, error: 'Internal server error' }, { status: 500 })
}
}
}
+52
View File
@@ -0,0 +1,52 @@
import { NextResponse } from 'next/server'
import { ZodError, type z } from 'zod'
/** Throwable API error with HTTP status code */
export class ApiError extends Error {
status: number
constructor(message: string, status: number = 400) {
super(message)
this.name = 'ApiError'
this.status = status
}
}
/** Standard success response: { ok: true, ...data } */
export function apiOk(data?: Record<string, unknown>) {
return NextResponse.json({ ok: true, ...data })
}
/** Standard error response: { error: message } with given status */
export function apiError(message: string, status: number = 400) {
return NextResponse.json({ error: message }, { status })
}
/** Validation error from Zod: { error: fieldErrors } with 400 */
export function apiValidationError(zodError: z.ZodError) {
return NextResponse.json({ error: zodError.flatten().fieldErrors }, { status: 400 })
}
/** Centralized error handler for API routes */
export function handleApiError(error: unknown): Response {
if (error instanceof ApiError) {
return apiError(error.message, error.status)
}
if (error instanceof ZodError) {
return apiValidationError(error)
}
// Prisma P2025 "Record not found"
if (
error instanceof Error &&
(error.constructor.name === 'PrismaClientKnownRequestError' ||
error.name === 'PrismaClientKnownRequestError') &&
(error as Error & { code?: string }).code === 'P2025'
) {
return apiError('Not found', 404)
}
console.error(
'[API error]',
error instanceof Error ? { message: error.message, name: error.name } : error,
)
return apiError('Internal server error', 500)
}
+2
View File
@@ -0,0 +1,2 @@
export * from './catalog-categories/index'
File diff suppressed because it is too large. Load diff
+64
View File
@@ -0,0 +1,64 @@
/**
* Release eras for organizing Habbo furniture by revision number.
*/
// ── Release Eras ───────────────────────────────────────────────────
export interface ReleaseEra {
key: string
label: string
labelEn: string
revisionMin: number
revisionMax: number
}
export const RELEASE_ERAS: ReleaseEra[] = [
{
key: 'classic',
label: 'Classici 2000-2003',
labelEn: 'Classic 2000-2003',
revisionMin: 0,
revisionMax: 19999,
},
{
key: 'mid',
label: 'Era HC 2004-2006',
labelEn: 'HC Era 2004-2006',
revisionMin: 20000,
revisionMax: 39999,
},
{
key: 'modern',
label: 'Moderni 2007-2009',
labelEn: 'Modern 2007-2009',
revisionMin: 40000,
revisionMax: 59999,
},
{
key: 'recent',
label: 'Recenti 2010-2012',
labelEn: 'Recent 2010-2012',
revisionMin: 60000,
revisionMax: 79999,
},
{
key: 'latest',
label: 'Ultimi 2013+',
labelEn: 'Latest 2013+',
revisionMin: 80000,
revisionMax: Infinity,
},
]
/**
* Match a revision number to a release era.
*/
export function matchEra(revision: number): ReleaseEra {
for (const era of RELEASE_ERAS) {
if (revision >= era.revisionMin && revision <= era.revisionMax) {
return era
}
}
return RELEASE_ERAS[RELEASE_ERAS.length - 1]
}
+97
View File
@@ -0,0 +1,97 @@
/**
* Supreme catalog categories + release eras for organizing Habbo furniture.
*
* Matching priority (first match wins within each level):
* 1. Interaction type (most specific)
* 2. Keywords anywhere in item name (normalized, *N stripped)
* 3. Regex prefix patterns (on normalized name)
* 4. Auto-prefix grouping (handled in route.ts)
* 5. Uncategorized fallback
*
* Each category belongs to a root: furni, costruire, animali, vestiti, or staff.
*/
export * from './categories'
export * from './eras'
import { SUPREME_CATEGORIES, type SupremeCategory } from './categories'
// ── Name Normalization ──────────────────────────────────────────
/**
* Normalize a Habbo item classname for matching.
* Strips the *N color suffix (e.g. "chair_plasto*5" -> "chair_plasto")
* and lowercases the result.
*/
export function normalizeName(itemName: string): string {
return itemName.replace(/\*\d+$/, '').toLowerCase()
}
// ── Category Detection ──────────────────────────────────────────
export const UNCATEGORIZED_KEY = 'uncategorized'
export const AUTO_PREFIX_MIN_ITEMS = 5
/**
* Find the supreme category for an item by classname and interaction type.
*
* Priority: interaction type -> keywords (anywhere in normalized name) -> regex patterns.
* First match wins within each priority level.
* Item names are normalized (strip *N suffix, lowercase) before matching.
*/
export function matchCategory(itemName: string, interactionType?: string): SupremeCategory {
const lower = normalizeName(itemName)
// Pass 1: Interaction type (most specific)
for (const cat of SUPREME_CATEGORIES) {
if (interactionType && cat.interactionTypes?.includes(interactionType)) {
return cat
}
}
// Pass 2: Keywords anywhere in name (user priority)
for (const cat of SUPREME_CATEGORIES) {
if (cat.key === UNCATEGORIZED_KEY) continue
for (const kw of cat.keywords) {
if (lower.includes(kw)) return cat
}
}
// Pass 3: Regex prefix patterns (fallback)
for (const cat of SUPREME_CATEGORIES) {
if (cat.key === UNCATEGORIZED_KEY) continue
for (const pattern of cat.patterns) {
if (pattern.test(lower)) return cat
}
}
return SUPREME_CATEGORIES[SUPREME_CATEGORIES.length - 1]
}
// ── Auto-Prefix Grouping ────────────────────────────────────────────
export interface AutoDetectedCategory {
key: string
prefix: string
label: string
labelEn: string
icon: number
layout: string
autoDetected: true
}
/**
* Extract prefix from an item name for auto-grouping.
* - Strips *N color suffix first ("chair_plasto*5" -> "chair_plasto")
* - If name contains '_', take everything before the first '_'
* - If no underscore, take the alphabetic prefix (letters before first digit)
* - Lowercase the result; return null if prefix is empty or 1 character
*/
export function extractPrefix(itemName: string): string | null {
const normalized = normalizeName(itemName)
const idx = normalized.indexOf('_')
if (idx > 1) return normalized.substring(0, idx)
const match = normalized.match(/^([a-z]{2,})/)
return match ? match[1] : null
}
+64
View File
@@ -0,0 +1,64 @@
import { cropRgba, decodePng } from '@/lib/services/imager/png-decode'
import { encodePng, parseNitroBundle } from '@/lib/services/swf/nitro-builder'
interface FurniAsset {
x?: number
y?: number
source?: string
}
interface FurniFrame {
frame: { x: number; y: number; w: number; h: number }
rotated?: boolean
}
interface FurniNitroJson {
name?: string
assets?: Record<string, FurniAsset>
spritesheet?: { frames?: Record<string, FurniFrame> }
}
/**
* Extract the furni catalog icon — the `{name}_icon_a` sprite — from a Nitro
* bundle and return it as a standalone PNG, or null if the bundle has no icon
* asset / usable frame. Used for sources that embed furni icons inside the
* `.nitro` instead of serving a separate `{classname}_icon.png`.
*/
export function extractFurniIconPng(nitro: Buffer): Buffer | null {
let json: FurniNitroJson
let png: Buffer
try {
const parsed = parseNitroBundle(nitro)
json = parsed.json as FurniNitroJson
png = parsed.png
} catch {
return null
}
const name = json.name
if (!name || !json.assets) return null
// The icon asset is conventionally `${name}_icon_a`; fall back to any
// `${name}_icon*` the bundle exposes.
const iconKey = json.assets[`${name}_icon_a`]
? `${name}_icon_a`
: Object.keys(json.assets).find((k) => k.startsWith(`${name}_icon`))
if (!iconKey) return null
const asset = json.assets[iconKey]
// An asset may alias another's pixels via `source`; the frame key is the
// bundle name prefixed onto the asset name. Different converters pack the
// key with or without a trailing `.png`, so try both.
const pixelAsset = asset.source ?? iconKey
const frames = json.spritesheet?.frames ?? {}
const frame = frames[`${name}_${pixelAsset}`] ?? frames[`${name}_${pixelAsset}.png`]
if (!frame || frame.rotated) return null
const { x, y, w, h } = frame.frame
if (w <= 0 || h <= 0) return null
try {
const sheet = decodePng(png)
const px = cropRgba(sheet.rgba, sheet.width, x, y, w, h)
return encodePng(w, h, px)
} catch {
return null
}
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+49
View File
@@ -0,0 +1,49 @@
import { randomUUID } from 'node:crypto'
import { siteSettings } from '@/lib/services/site-settings'
export interface CloneSource {
id: string
name: string
furnidataUrl: string
nitroBaseUrl: string
iconBaseUrl: string
}
const KEY = 'clone_sources'
export async function listSources(): Promise<CloneSource[]> {
const raw = (await siteSettings.get(KEY, '[]')) ?? '[]'
try {
const arr = JSON.parse(raw)
return Array.isArray(arr) ? arr : []
} catch {
return []
}
}
export async function upsertSource(
src: Omit<CloneSource, 'id'> & { id?: string },
): Promise<CloneSource> {
const sources = await listSources()
const entry: CloneSource = {
id: src.id || randomUUID(),
name: src.name.trim(),
furnidataUrl: src.furnidataUrl.trim(),
nitroBaseUrl: src.nitroBaseUrl.trim().replace(/\/+$/, ''),
iconBaseUrl: src.iconBaseUrl.trim().replace(/\/+$/, ''),
}
const idx = sources.findIndex((s) => s.id === entry.id)
if (idx >= 0) sources[idx] = entry
else sources.push(entry)
await siteSettings.update(KEY, JSON.stringify(sources))
return entry
}
export async function deleteSource(id: string): Promise<void> {
const sources = (await listSources()).filter((s) => s.id !== id)
await siteSettings.update(KEY, JSON.stringify(sources))
}
export async function getSource(id: string): Promise<CloneSource | null> {
return (await listSources()).find((s) => s.id === id) ?? null
}
@@ -0,0 +1,66 @@
import { describe, expect, it, vi } from 'vitest'
const mergeSet = vi.fn(async () => {})
const mergePalette = vi.fn(async () => {})
const mergeLibrary = vi.fn(async () => {})
const importSingleFigure = vi.fn(async () => ({ ok: true, lib: '', warnings: [] }))
vi.mock('@/lib/services/figure-official', () => ({
fetchOfficialFigureData: vi.fn(async () => ({
palettes: [{ id: 3, colors: [] }],
setTypes: [
{
type: 'ch',
paletteId: 3,
sets: [
{
id: 5000,
gender: 'M',
club: 0,
colorable: true,
selectable: true,
preselectable: false,
sellable: true,
parts: [{ id: 4316, type: 'ch', colorable: true, index: 0, colorindex: 1 }],
},
],
},
],
})),
fetchOfficialFigureMap: vi.fn(async () => [
{ id: 'shirt_M_x', revision: 1, parts: [{ id: 4316, type: 'ch' }] },
]),
}))
vi.mock('@/lib/services/figuredata', () => ({
mergeSet: (...a: unknown[]) => mergeSet(...(a as Parameters<typeof mergeSet>)),
mergePalette: (...a: unknown[]) => mergePalette(...(a as Parameters<typeof mergePalette>)),
listLocalSetIds: vi.fn(async () => new Set<number>()),
}))
vi.mock('@/lib/services/figuremap', () => ({
mergeLibrary: (...a: unknown[]) => mergeLibrary(...(a as Parameters<typeof mergeLibrary>)),
listFigureLibraries: vi.fn(async () => []),
}))
vi.mock('@/lib/services/figure-import', () => ({
importSingleFigure: (...a: unknown[]) =>
importSingleFigure(...(a as Parameters<typeof importSingleFigure>)),
ensureFigureDirs: vi.fn(async () => {}),
}))
import { importClothingSet } from './clothing-set-import'
describe('importClothingSet', () => {
it('imports the libraries for the set parts, then merges set + palette', async () => {
const r = await importClothingSet({ setType: 'ch', setId: 5000 })
expect(r.ok).toBe(true)
expect(importSingleFigure).toHaveBeenCalledWith(expect.objectContaining({ lib: 'shirt_M_x' }))
expect(mergeLibrary).toHaveBeenCalledWith(expect.objectContaining({ id: 'shirt_M_x' }))
expect(mergeSet).toHaveBeenCalledWith('ch', 3, expect.objectContaining({ id: 5000 }))
expect(mergePalette).toHaveBeenCalledWith(expect.objectContaining({ id: 3 }))
})
it('fails when the set is not in official figuredata', async () => {
const r = await importClothingSet({ setType: 'ch', setId: 999999 })
expect(r.ok).toBe(false)
})
})
File diff suppressed because it is too large. Load diff
+56
View File
@@ -0,0 +1,56 @@
// @vitest-environment node
import { describe, expect, it, vi } from 'vitest'
vi.mock('@/lib/services/import/core/download', () => ({
downloadFile: vi.fn(async () => ({ ok: true, size: 100 })),
}))
vi.mock('@/lib/services/effect-source', () => ({
resolveEffectSwfUrl: vi.fn(async (lib: string) => `https://x/${lib}.swf`),
listOfficialEffects: vi.fn(async () => []),
}))
const mergeEffect = vi.fn(async () => {})
vi.mock('@/lib/services/effectmap', () => ({
mergeEffect: (...a: unknown[]) => mergeEffect(...(a as Parameters<typeof mergeEffect>)),
removeEffect: vi.fn(async () => true),
listLocalEffects: vi.fn(async () => []),
}))
const convertSwfToNitro = vi.fn(() => ({ bundle: Buffer.from('nitro'), warnings: [] }))
vi.mock('@/lib/services/swf-to-nitro', () => ({
convertSwfToNitro: (...a: unknown[]) =>
convertSwfToNitro(...(a as Parameters<typeof convertSwfToNitro>)),
}))
vi.mock('node:fs', async (orig) => {
const real = (await orig()) as typeof import('node:fs')
return {
...real,
existsSync: vi.fn(() => true),
promises: {
...real.promises,
readFile: vi.fn(async () => Buffer.from('CWS')),
writeFile: vi.fn(async () => {}),
mkdir: vi.fn(async () => {}),
},
}
})
import { importSingleEffect } from './effect-import'
describe('effect-import.importSingleEffect', () => {
it('downloads, converts, writes .nitro, merges EffectMap', async () => {
const r = await importSingleEffect({ id: '5', lib: 'Torch', type: 'fx', revision: 59220 })
expect(r.ok).toBe(true)
expect(convertSwfToNitro).toHaveBeenCalled()
expect(mergeEffect).toHaveBeenCalledWith({ id: '5', lib: 'Torch', type: 'fx', revision: 59220 })
})
it('does NOT merge EffectMap when conversion throws (no orphan entry)', async () => {
mergeEffect.mockClear()
convertSwfToNitro.mockImplementationOnce(() => {
throw new Error('bad swf')
})
const r = await importSingleEffect({ id: '6', lib: 'Jetpack', type: 'fx', revision: 59200 })
expect(r.ok).toBe(false)
expect(mergeEffect).not.toHaveBeenCalled()
})
})
File diff suppressed because it is too large. Load diff
+48
View File
@@ -0,0 +1,48 @@
import { resolveGordonBuildUrl } from '@/lib/services/import/core/gordon'
import { siteSettings } from '@/lib/services/site-settings'
import type { EffectMapEntry } from '@/types/effects'
// Defaults to VALIDATE against a live fetch (spec Open Item #2). Override the
// SWF source with the `effect_swf_base_url` site setting if these drift.
const OFFICIAL_EFFECTMAP_URL = 'https://www.habbo.com/gamedata/effectmap/1'
const CACHE_TTL = 30 * 60 * 1000
let effectCache: { list: EffectMapEntry[]; ts: number } | null = null
export function __clearEffectSourceCache(): void {
effectCache = null
}
function parseEffectMapXml(xml: string): EffectMapEntry[] {
const out: EffectMapEntry[] = []
const re = /<effect\b([^>]*)\/?>/g
for (const m of xml.matchAll(re)) {
const attrs = m[1]
const id = /\bid="([^"]*)"/.exec(attrs)?.[1]
const lib = /\blib="([^"]*)"/.exec(attrs)?.[1]
const type = /\btype="([^"]*)"/.exec(attrs)?.[1] ?? 'fx'
const revision = Number(/\brevision="([^"]*)"/.exec(attrs)?.[1] ?? '0')
if (id && lib) out.push({ id, lib, type, revision: Number.isFinite(revision) ? revision : 0 })
}
return out
}
export async function listOfficialEffects(): Promise<EffectMapEntry[]> {
const now = Date.now()
if (effectCache && now - effectCache.ts < CACHE_TTL) return effectCache.list
const res = await fetch(OFFICIAL_EFFECTMAP_URL, { signal: AbortSignal.timeout(20000) })
if (!res.ok) {
console.warn(`[effect-source] effectmap fetch failed: ${res.status}`)
return effectCache?.list ?? []
}
const list = parseEffectMapXml(await res.text())
effectCache = { list, ts: now }
return list
}
export async function resolveEffectSwfUrl(lib: string): Promise<string> {
const override = ((await siteSettings.get('effect_swf_base_url', '')) ?? '').trim()
if (override) return `${override.replace(/\/+$/, '')}/${lib}.swf`
const gordon = await resolveGordonBuildUrl()
return `${gordon}${lib}.swf`
}
+58
View File
@@ -0,0 +1,58 @@
import { resolveGamedataFile } from '@/lib/services/import/core/asset-paths'
import {
readGamedataJson,
withGamedataLock,
writeGamedataJsonAtomic,
} from '@/lib/services/import/core/gamedata-json'
import { siteSettings } from '@/lib/services/site-settings'
import type { EffectMapEntry } from '@/types/effects'
interface EffectMapFile {
effects: EffectMapEntry[]
}
// Test seam: allows the suite to point the resolver at a temp file.
let pathOverride: string | null = null
export function __setEffectMapPathForTests(p: string | null): void {
pathOverride = p
}
export async function getEffectMapPath(): Promise<string> {
if (pathOverride) return pathOverride
const url = await siteSettings.get('effectmap_url', '/nitro-assets/gamedata/EffectMap.json')
return resolveGamedataFile(url ?? '/nitro-assets/gamedata/EffectMap.json', 'EffectMap.json')
}
export async function readEffectMap(): Promise<EffectMapFile> {
const file = await getEffectMapPath()
const data = await readGamedataJson<Partial<EffectMapFile>>(file)
return { effects: Array.isArray(data.effects) ? data.effects : [] }
}
export async function listLocalEffects(): Promise<EffectMapEntry[]> {
return (await readEffectMap()).effects
}
export async function mergeEffect(entry: EffectMapEntry): Promise<void> {
const file = await getEffectMapPath()
await withGamedataLock(file, async () => {
const data = await readGamedataJson<Partial<EffectMapFile>>(file)
const effects = Array.isArray(data.effects) ? data.effects : []
const idx = effects.findIndex((e) => e.id === entry.id)
if (idx >= 0) effects[idx] = entry
else effects.push(entry)
await writeGamedataJsonAtomic(file, { ...data, effects })
})
}
export async function removeEffect(idOrLib: string): Promise<boolean> {
const file = await getEffectMapPath()
return withGamedataLock(file, async () => {
const data = await readGamedataJson<Partial<EffectMapFile>>(file)
const effects = Array.isArray(data.effects) ? data.effects : []
const next = effects.filter((e) => e.id !== idOrLib && e.lib !== idOrLib)
const removed = next.length < effects.length
if (removed) await writeGamedataJsonAtomic(file, { ...data, effects: next })
return removed
})
}
+68
View File
@@ -0,0 +1,68 @@
// @vitest-environment node
import { describe, expect, it, vi } from 'vitest'
vi.mock('@/lib/services/import/core/download', () => ({
downloadFile: vi.fn(async () => ({ ok: true, size: 100 })),
}))
vi.mock('@/lib/services/figure-source', () => ({
resolveFigureSwfUrl: vi.fn(async (lib: string) => `https://x/${lib}.swf`),
}))
vi.mock('@/lib/services/figuremap', () => ({
listFigureLibraries: vi.fn(async () => [
{ id: 'Hair_F_Bob', revision: 1 },
{ id: 'shirt_M_x', revision: 2 },
{ id: 'Hat_U_y', revision: 3 },
]),
}))
const convertSwfToNitro = vi.fn(() => ({ bundle: Buffer.from('nitro'), warnings: [] }))
vi.mock('@/lib/services/swf-to-nitro', () => ({
convertSwfToNitro: (...a: unknown[]) =>
convertSwfToNitro(...(a as Parameters<typeof convertSwfToNitro>)),
}))
vi.mock('node:fs', async (orig) => {
const real = (await orig()) as typeof import('node:fs')
return {
...real,
existsSync: vi.fn(() => false),
promises: {
...real.promises,
readFile: vi.fn(async () => Buffer.from('CWS')),
writeFile: vi.fn(async () => {}),
mkdir: vi.fn(async () => {}),
unlink: vi.fn(async () => {}),
},
}
})
import { getFigureList, importSingleFigure } from './figure-import'
describe('figure-import', () => {
it('paginates the library list with present flags', async () => {
const { items, meta } = await getFigureList({ search: '', page: 1, perPage: 2 })
expect(items).toHaveLength(2)
expect(meta).toMatchObject({ page: 1, perPage: 2, total: 3 })
expect(items[0]).toMatchObject({ lib: 'Hair_F_Bob', nitroExists: false })
})
it('filters by search', async () => {
const { items, meta } = await getFigureList({ search: 'shirt', page: 1, perPage: 50 })
expect(meta.total).toBe(1)
expect(items[0].lib).toBe('shirt_M_x')
})
it('importSingleFigure downloads + converts + writes', async () => {
const r = await importSingleFigure({ lib: 'Hair_F_Bob' })
expect(r.ok).toBe(true)
expect(convertSwfToNitro).toHaveBeenCalled()
})
it('fails cleanly when conversion throws', async () => {
convertSwfToNitro.mockImplementationOnce(() => {
throw new Error('bad swf')
})
const r = await importSingleFigure({ lib: 'shirt_M_x' })
expect(r.ok).toBe(false)
expect(r.error).toContain('conversion')
})
})
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+9
View File
@@ -0,0 +1,9 @@
import { resolveGordonBuildUrl } from '@/lib/services/import/core/gordon'
import { siteSettings } from '@/lib/services/site-settings'
export async function resolveFigureSwfUrl(lib: string): Promise<string> {
const override = ((await siteSettings.get('figure_swf_base_url', '')) ?? '').trim()
if (override) return `${override.replace(/\/+$/, '')}/${lib}.swf`
const gordon = await resolveGordonBuildUrl()
return `${gordon}${lib}.swf`
}
+81
View File
@@ -0,0 +1,81 @@
import { resolveGamedataFile } from '@/lib/services/import/core/asset-paths'
import {
readGamedataJson,
withGamedataLock,
writeGamedataJsonAtomic,
} from '@/lib/services/import/core/gamedata-json'
import { siteSettings } from '@/lib/services/site-settings'
interface FdSet {
id: number
[k: string]: unknown
}
interface FdSetType {
type: string
paletteId: number
sets: FdSet[]
[k: string]: unknown
}
interface FdPalette {
id: number
colors: unknown[]
}
interface FigureDataFile {
palettes: FdPalette[]
setTypes: FdSetType[]
}
let pathOverride: string | null = null
export function __setFigureDataPathForTests(p: string | null): void {
pathOverride = p
}
export async function getFigureDataPath(): Promise<string> {
if (pathOverride) return pathOverride
const url = await siteSettings.get('figuredata_url', '/nitro-assets/gamedata/FigureData.json')
return resolveGamedataFile(url ?? '/nitro-assets/gamedata/FigureData.json', 'FigureData.json')
}
export async function readFigureData(): Promise<FigureDataFile> {
const data = await readGamedataJson<Partial<FigureDataFile>>(await getFigureDataPath())
return { palettes: data.palettes ?? [], setTypes: data.setTypes ?? [] }
}
export async function listLocalSetIds(setType: string): Promise<Set<number>> {
const fd = await readFigureData()
const st = fd.setTypes.find((s) => s.type === setType)
return new Set((st?.sets ?? []).map((s) => s.id))
}
export async function mergeSet(setType: string, paletteId: number, set: FdSet): Promise<void> {
const file = await getFigureDataPath()
await withGamedataLock(file, async () => {
const fd = await readGamedataJson<FigureDataFile>(file)
fd.setTypes ??= []
let st = fd.setTypes.find((s) => s.type === setType)
if (!st) {
st = { type: setType, paletteId, sets: [] }
fd.setTypes.push(st)
}
st.sets ??= []
const idx = st.sets.findIndex((s) => s.id === set.id)
if (idx >= 0) {
st.sets[idx] = set
} else {
st.sets.push(set)
}
await writeGamedataJsonAtomic(file, fd)
})
}
export async function mergePalette(palette: FdPalette): Promise<void> {
const file = await getFigureDataPath()
await withGamedataLock(file, async () => {
const fd = await readGamedataJson<FigureDataFile>(file)
fd.palettes ??= []
if (!fd.palettes.some((p) => p.id === palette.id)) {
fd.palettes.push(palette)
await writeGamedataJsonAtomic(file, fd)
}
})
}
+49
View File
@@ -0,0 +1,49 @@
import { resolveGamedataFile } from '@/lib/services/import/core/asset-paths'
import {
readGamedataJson,
withGamedataLock,
writeGamedataJsonAtomic,
} from '@/lib/services/import/core/gamedata-json'
import { siteSettings } from '@/lib/services/site-settings'
export interface FigureLibrary {
id: string
revision: number
parts?: Array<{ id: number; type: string }>
}
interface FigureMapFile {
libraries: FigureLibrary[]
}
let pathOverride: string | null = null
export function __setFigureMapPathForTests(p: string | null): void {
pathOverride = p
}
export async function getFigureMapPath(): Promise<string> {
if (pathOverride) return pathOverride
const url = await siteSettings.get('figuremap_url', '/nitro-assets/gamedata/FigureMap.json')
return resolveGamedataFile(url ?? '/nitro-assets/gamedata/FigureMap.json', 'FigureMap.json')
}
export async function listFigureLibraries(): Promise<FigureLibrary[]> {
const file = await getFigureMapPath()
const data = await readGamedataJson<Partial<FigureMapFile>>(file)
return Array.isArray(data.libraries) ? data.libraries : []
}
export async function mergeLibrary(lib: FigureLibrary): Promise<void> {
const file = await getFigureMapPath()
await withGamedataLock(file, async () => {
const data = await readGamedataJson<{ libraries?: FigureLibrary[] }>(file)
const libraries = Array.isArray(data.libraries) ? data.libraries : []
const idx = libraries.findIndex((l) => l.id === lib.id)
if (idx >= 0) {
libraries[idx] = lib
} else {
libraries.push(lib)
}
await writeGamedataJsonAtomic(file, { ...data, libraries })
})
}
+47
View File
@@ -0,0 +1,47 @@
// @vitest-environment node
import { describe, expect, it, vi } from 'vitest'
vi.mock('@/lib/prisma', () => ({
prisma: {},
}))
vi.mock('@/lib/services/furni-data', () => ({
appendFurniEntry: vi.fn(),
buildFurniEntry: vi.fn(),
removeFurniEntry: vi.fn(),
}))
vi.mock('@/lib/services/habbo-furnidata-cache', () => ({
lookupHabboIt: vi.fn(),
}))
vi.mock('@/lib/services/import/core/download', () => ({
downloadFile: vi.fn(),
validatePngBytes: vi.fn(),
validateSwfBytes: vi.fn(),
}))
vi.mock('@/lib/services/swf-to-nitro', () => ({
convertSwfToNitro: vi.fn(),
extractIconFromSwf: vi.fn(),
}))
import { buildItemsBaseEnrichmentUpdate } from './furni-import'
describe('buildItemsBaseEnrichmentUpdate', () => {
it('only updates columns present in the live Arcturus items_base schema', () => {
const update = buildItemsBaseEnrichmentUpdate({
id: 123,
dims: { x: 2, y: 3, z: 1.5 },
cansiton: true,
canlayon: false,
canstandon: true,
allowStack: '1',
interactionType: 'default',
})
expect(update.sql).not.toContain('specialtype')
expect(update.values).toEqual([2, 3, 1.5, '1', '0', '1', '1', 'default', 123])
})
})
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,47 @@
import { describe, expect, it } from 'vitest'
import { encodePng } from '@/lib/services/swf/nitro-builder'
import { cropRgba, decodePng } from './png-decode'
describe('png-decode', () => {
it('round-trips an RGBA image through encodePng → decodePng', () => {
const w = 3
const h = 2
const rgba = Buffer.from([
// row 0
255, 0, 0, 255, 0, 255, 0, 255, 0, 0, 255, 255,
// row 1
10, 20, 30, 40, 50, 60, 70, 80, 90, 100, 110, 120,
])
const png = encodePng(w, h, rgba)
const dec = decodePng(png)
expect(dec.width).toBe(w)
expect(dec.height).toBe(h)
expect(Buffer.compare(dec.rgba, rgba)).toBe(0)
})
it('decodes a larger gradient losslessly', () => {
const w = 16
const h = 16
const rgba = Buffer.alloc(w * h * 4)
for (let i = 0; i < w * h; i++) {
rgba[i * 4] = i % 256
rgba[i * 4 + 1] = (i * 3) % 256
rgba[i * 4 + 2] = (i * 7) % 256
rgba[i * 4 + 3] = 255
}
const dec = decodePng(encodePng(w, h, rgba))
expect(Buffer.compare(dec.rgba, rgba)).toBe(0)
})
it('crops a sub-region', () => {
// 2x2 sheet, crop the bottom-right 1x1 pixel
const sheet = Buffer.from([1, 1, 1, 1, 2, 2, 2, 2, 3, 3, 3, 3, 4, 4, 4, 4])
const cropped = cropRgba(sheet, 2, 1, 1, 1, 1)
expect([...cropped]).toEqual([4, 4, 4, 4])
})
it('rejects non-PNG input', () => {
expect(() => decodePng(Buffer.from([1, 2, 3, 4, 5, 6, 7, 8]))).toThrow('not a PNG')
})
})
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,22 @@
import path from 'node:path'
import { describe, expect, it } from 'vitest'
import { resolveGamedataFile } from './asset-paths'
describe('resolveGamedataFile', () => {
it('uses absolute external paths as-is', () => {
const absolutePath = path.win32.join(
'E:\\',
'Users',
'simol',
'Desktop',
'DEV',
'Nitro-Files',
'nitro-assets',
'gamedata',
'FigureMap.json',
)
expect(resolveGamedataFile(absolutePath, 'FigureMap.json')).toBe(absolutePath)
})
})
@@ -0,0 +1,29 @@
import { existsSync } from 'node:fs'
import path from 'node:path'
// The CMS default gamedata JSON paths point under public/nitro-assets/gamedata/,
// but this deployment keeps the gamedata JSON (FigureMap/FigureData/EffectMap…)
// under public/Gamedata/config/. `resolveGamedataFile` falls back there when the
// configured path is absent, so importers read AND write the real file — no
// manual setting required. (Bundled .nitro stay under public/nitro-assets/bundled,
// the client's served asset dir — see *_NITRO_DIR consts in the import services.)
function pub(...segs: string[]): string {
return path.join(/*turbopackIgnore: true*/ process.cwd(), 'public', ...segs)
}
/**
* Resolve a gamedata JSON file from a `*_url` setting value (e.g.
* `/nitro-assets/gamedata/FigureMap.json`). Falls back to
* `public/Gamedata/config/<fallbackName>` if the configured path is absent.
* Returns the configured path when neither exists (write target for fresh setups).
*/
export function resolveGamedataFile(url: string, fallbackName: string): string {
if (path.win32.isAbsolute(url) || (path.isAbsolute(url) && !url.startsWith('/'))) return url
const configured = pub(url.replace(/^\/+/, ''))
if (existsSync(/*turbopackIgnore: true*/ configured)) return configured
const gamedata = pub('Gamedata', 'config', fallbackName)
if (existsSync(/*turbopackIgnore: true*/ gamedata)) return gamedata
return configured
}
@@ -0,0 +1,21 @@
import { describe, expect, it } from 'vitest'
import { validatePngBytes, validateSwfBytes } from './download'
describe('import/core/download validators', () => {
it('accepts FWS/CWS/ZWS swf magic', () => {
expect(validateSwfBytes(Buffer.from('FWS\x06\x00\x00\x00\x00'))).toBe(true)
expect(validateSwfBytes(Buffer.from('CWS\x06\x00\x00\x00\x00'))).toBe(true)
expect(validateSwfBytes(Buffer.from('ZWS\x06\x00\x00\x00\x00'))).toBe(true)
})
it('rejects non-swf and too-short buffers', () => {
expect(validateSwfBytes(Buffer.from('PNG\x00\x00\x00\x00\x00'))).toBe(false)
expect(validateSwfBytes(Buffer.from('FW'))).toBe(false)
})
it('accepts png magic and rejects others', () => {
expect(validatePngBytes(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]))).toBe(true)
expect(validatePngBytes(Buffer.from([1, 2, 3, 4, 5, 6, 7, 8]))).toBe(false)
})
})
+68
View File
@@ -0,0 +1,68 @@
import { promises as fs } from 'node:fs'
export function validateSwfBytes(buffer: Buffer): boolean {
if (buffer.length < 8) return false
const sig = buffer.toString('ascii', 0, 3)
return sig === 'FWS' || sig === 'CWS' || sig === 'ZWS'
}
export function validatePngBytes(buffer: Buffer): boolean {
if (buffer.length < 8) return false
return buffer[0] === 137 && buffer[1] === 80 && buffer[2] === 78 && buffer[3] === 71
}
export async function downloadFile(
url: string,
destPath: string,
options?: { maxRetries?: number; validate?: 'swf' | 'png' },
): Promise<{ ok: boolean; size: number }> {
const maxRetries = options?.maxRetries ?? 3
const baseDelay = 1000
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
if (attempt > 0) {
await new Promise((r) => setTimeout(r, baseDelay * 2 ** (attempt - 1)))
}
const res = await fetch(url, { signal: AbortSignal.timeout(15000) })
if (!res.ok) {
const deterministic = res.status === 404 || res.status === 410 || res.status === 403
if (deterministic || attempt === maxRetries) {
console.warn(
`[import-download] Download failed ${url}: ${res.status}${deterministic ? ' (deterministic, not retrying)' : ` after ${maxRetries + 1} attempts`}`,
)
return { ok: false, size: 0 }
}
continue
}
const buffer = Buffer.from(await res.arrayBuffer())
if (buffer.length < 8) {
if (attempt === maxRetries) return { ok: false, size: 0 }
continue
}
if (options?.validate === 'swf' && !validateSwfBytes(buffer)) {
if (attempt === maxRetries) {
console.warn(`[import-download] Invalid SWF magic bytes from ${url}`)
return { ok: false, size: 0 }
}
continue
}
if (options?.validate === 'png' && !validatePngBytes(buffer)) {
if (attempt === maxRetries) {
console.warn(`[import-download] Invalid PNG magic bytes from ${url}`)
return { ok: false, size: 0 }
}
continue
}
await fs.writeFile(/*turbopackIgnore: true*/ destPath, buffer)
return { ok: true, size: buffer.length }
} catch (err) {
if (attempt === maxRetries) {
console.warn(`[import-download] Download error ${url}:`, (err as Error).message)
return { ok: false, size: 0 }
}
}
}
return { ok: false, size: 0 }
}
@@ -0,0 +1,44 @@
import { promises as fs } from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { readGamedataJson, withGamedataLock, writeGamedataJsonAtomic } from './gamedata-json'
let dir: string
let file: string
beforeEach(async () => {
dir = await fs.mkdtemp(path.join(os.tmpdir(), 'gamedata-'))
file = path.join(dir, 'EffectMap.json')
await fs.writeFile(file, JSON.stringify({ effects: [{ id: '1', lib: 'A' }] }))
})
afterEach(async () => {
await fs.rm(dir, { recursive: true, force: true })
})
describe('import/core/gamedata-json', () => {
it('reads parsed JSON', async () => {
const data = await readGamedataJson<{ effects: unknown[] }>(file)
expect(data.effects).toHaveLength(1)
})
it('writes atomically (round-trips)', async () => {
await writeGamedataJsonAtomic(file, { effects: [{ id: '9', lib: 'Z' }] })
const data = await readGamedataJson<{ effects: Array<{ id: string }> }>(file)
expect(data.effects[0].id).toBe('9')
})
it('serializes concurrent writers (no lost update)', async () => {
const bump = (n: number) =>
withGamedataLock(file, async () => {
const d = await readGamedataJson<{ effects: unknown[] }>(file)
d.effects.push({ id: String(n), lib: `L${n}` })
await writeGamedataJsonAtomic(file, d)
})
await Promise.all([bump(2), bump(3), bump(4)])
const d = await readGamedataJson<{ effects: unknown[] }>(file)
expect(d.effects).toHaveLength(4) // 1 seed + 3 appended, none lost
})
})
@@ -0,0 +1,64 @@
import { promises as fs } from 'node:fs'
const LOCK_STALE_MS = 60_000
const LOCK_ACQUIRE_TIMEOUT_MS = 30_000
// In-process serialization, keyed by absolute file path, layered on top of the
// on-disk lock so multiple awaiters in the same Node process queue cleanly.
const chains = new Map<string, Promise<void>>()
async function acquireDiskLock(lockPath: string): Promise<void> {
const deadline = Date.now() + LOCK_ACQUIRE_TIMEOUT_MS
while (true) {
try {
const fh = await fs.open(/*turbopackIgnore: true*/ 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(/*turbopackIgnore: true*/ lockPath)
if (Date.now() - stat.mtimeMs > LOCK_STALE_MS) {
await fs.unlink(/*turbopackIgnore: true*/ lockPath).catch(() => {})
continue
}
} catch {
/* lock vanished between checks */
}
if (Date.now() > deadline) throw new Error(`gamedata lock timeout: ${lockPath}`)
await new Promise((r) => setTimeout(r, 100))
}
}
}
export async function withGamedataLock<T>(filePath: string, fn: () => Promise<T>): Promise<T> {
let release!: () => void
const acquired = new Promise<void>((r) => {
release = r
})
const prev = chains.get(filePath) ?? Promise.resolve()
chains.set(filePath, acquired)
await prev
const lockPath = `${filePath}.lock`
await acquireDiskLock(lockPath)
try {
return await fn()
} finally {
await fs.unlink(/*turbopackIgnore: true*/ lockPath).catch(() => {})
release()
}
}
export async function readGamedataJson<T>(filePath: string): Promise<T> {
const raw = await fs.readFile(/*turbopackIgnore: true*/ filePath, 'utf-8')
return JSON.parse(raw) as T
}
export async function writeGamedataJsonAtomic(filePath: string, data: unknown): Promise<void> {
const tmp = `${filePath}.tmp`
await fs.writeFile(/*turbopackIgnore: true*/ tmp, JSON.stringify(data, null, 2), 'utf-8')
await fs.rename(/*turbopackIgnore: true*/ tmp, filePath)
}
@@ -0,0 +1,32 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { __clearGordonCache, resolveGordonBuildUrl } from './gordon'
beforeEach(() => {
__clearGordonCache()
vi.restoreAllMocks()
})
afterEach(() => vi.restoreAllMocks())
describe('import/core/gordon', () => {
it('parses flash.client.url from external_variables and normalizes', async () => {
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
new Response('flash.client.url=//images.habbo.com/gordon/PRODUCTION-XYZ/\n', { status: 200 }),
)
expect(await resolveGordonBuildUrl()).toBe('https://images.habbo.com/gordon/PRODUCTION-XYZ/')
})
it('caches the resolved build (second call does not refetch)', async () => {
const spy = vi
.spyOn(globalThis, 'fetch')
.mockResolvedValue(new Response('flash.client.url=//x/gordon/B/\n', { status: 200 }))
await resolveGordonBuildUrl()
await resolveGordonBuildUrl()
expect(spy).toHaveBeenCalledTimes(1)
})
it('throws when flash.client.url is absent', async () => {
vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response('other=1\n', { status: 200 }))
await expect(resolveGordonBuildUrl()).rejects.toThrow()
})
})
+23
View File
@@ -0,0 +1,23 @@
const EXTERNAL_VARIABLES_URL = 'https://www.habbo.com/gamedata/external_variables/1'
const CACHE_TTL = 30 * 60 * 1000
let gordonCache: { url: string; ts: number } | null = null
export function __clearGordonCache(): void {
gordonCache = null
}
/** Resolve the current Habbo "gordon" asset build URL (trailing slash) from external_variables. */
export async function resolveGordonBuildUrl(): Promise<string> {
const now = Date.now()
if (gordonCache && now - gordonCache.ts < CACHE_TTL) return gordonCache.url
const res = await fetch(EXTERNAL_VARIABLES_URL, { signal: AbortSignal.timeout(20000) })
if (!res.ok) throw new Error(`external_variables fetch failed: ${res.status}`)
const text = await res.text()
const line = /^flash\.client\.url=(.+)$/m.exec(text)?.[1]?.trim()
if (!line) throw new Error('flash.client.url not found in external_variables')
const normalized = (line.startsWith('//') ? `https:${line}` : line).replace(/\/?$/, '/')
gordonCache = { url: normalized, ts: now }
return normalized
}
@@ -0,0 +1,29 @@
import { describe, expect, it } from 'vitest'
import { runSseBatch } from './sse-batch'
async function collect(res: Response): Promise<Array<Record<string, unknown>>> {
const text = await res.text()
return text
.split('\n\n')
.filter((l) => l.startsWith('data: '))
.map((l) => JSON.parse(l.slice(6)))
}
describe('import/core/sse-batch', () => {
it('emits batch_start, per-item, and batch_complete with correct counts', async () => {
const res = runSseBatch({
items: ['a', 'b', 'c'],
concurrency: 2,
worker: async (item) => ({ ok: item !== 'b', error: item === 'b' ? 'boom' : undefined }),
labelOf: (item) => item,
})
const events = await collect(res)
expect(events[0]).toMatchObject({ type: 'batch_start', total: 3, concurrency: 2 })
const done = events.filter((e) => e.type === 'item_progress' && e.status === 'done')
const failed = events.filter((e) => e.type === 'item_progress' && e.status === 'failed')
expect(done).toHaveLength(2)
expect(failed).toHaveLength(1)
expect(events.at(-1)).toMatchObject({ type: 'batch_complete', succeeded: 2, failed: 1 })
})
})
File diff suppressed because it is too large. Load diff
+75
View File
@@ -0,0 +1,75 @@
import { promises as fs } from 'node:fs'
import path from 'node:path'
import { matchCategory, normalizeName } from '@/lib/catalog-categories'
import { getAncestors } from '@/lib/services/catalog-tree'
import { getFurniAssetDirs } from '@/lib/services/furni-asset-dirs'
/**
* Categorize a file using matchCategory logic.
* Returns the category key.
*/
export function categorizeFile(filename: string): string {
const name = filename.replace(/\.nitro$/, '')
const cat = matchCategory(normalizeName(name))
return cat.key
}
/**
* Build a nested folder path from catalog page ancestors.
* Example: page "HC Rares" under "Rares" under "Furni" → "furni/rares/hc-rares"
* Includes the root page in the path.
*/
export async function buildCatalogPath(pageId: number): Promise<string> {
const ancestors = await getAncestors(pageId)
if (ancestors.length === 0) return 'uncategorized'
return ancestors.map((a) => slugifyPath(a.caption)).join('/')
}
/** Sanitize a catalog page name into a valid folder segment */
export function slugifyPath(name: string): string {
return (
name
.toLowerCase()
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '') || 'uncategorized'
)
}
/**
* Scan the flat furniture folder for .nitro files.
*/
export async function scanFlatFolder(): Promise<string[]> {
try {
const { nitroDir } = await getFurniAssetDirs()
const entries = await fs.readdir(nitroDir)
return entries.filter((e) => e.endsWith('.nitro'))
} catch {
return []
}
}
/**
* Scan ALL .nitro files recursively (flat + subfolders).
* Returns a Set of classnames (filename without .nitro extension).
*/
export async function scanAllNitroFiles(): Promise<Set<string>> {
try {
const { nitroDir } = await getFurniAssetDirs()
const entries = await fs.readdir(nitroDir, { recursive: true })
const classnames = new Set<string>()
for (const entry of entries) {
const name = typeof entry === 'string' ? entry : String(entry)
if (name.endsWith('.nitro')) {
const basename = path.basename(name, '.nitro')
classnames.add(basename)
}
}
return classnames
} catch {
return new Set()
}
}
File diff suppressed because it is too large. Load diff
+49
View File
@@ -0,0 +1,49 @@
// @vitest-environment node
import { describe, expect, it, vi } from 'vitest'
vi.mock('@/lib/services/import/core/download', () => ({
downloadFile: vi.fn(async () => ({ ok: true, size: 100 })),
}))
vi.mock('@/lib/services/pet-source', () => ({
CANONICAL_PETS: ['dog', 'cat'],
resolvePetSwfUrl: vi.fn(async (lib: string) => `https://x/${lib}.swf`),
}))
const convertSwfToNitro = vi.fn(() => ({ bundle: Buffer.from('nitro'), warnings: [] }))
vi.mock('@/lib/services/swf-to-nitro', () => ({
convertSwfToNitro: (...a: unknown[]) =>
convertSwfToNitro(...(a as Parameters<typeof convertSwfToNitro>)),
}))
vi.mock('node:fs', async (orig) => {
const real = (await orig()) as typeof import('node:fs')
return {
...real,
existsSync: vi.fn(() => false),
promises: {
...real.promises,
readFile: vi.fn(async () => Buffer.from('CWS')),
writeFile: vi.fn(async () => {}),
mkdir: vi.fn(async () => {}),
unlink: vi.fn(async () => {}),
},
}
})
import { importSinglePet } from './pet-import'
describe('pet-import.importSinglePet', () => {
it('downloads, converts, writes .nitro', async () => {
const r = await importSinglePet({ lib: 'dog' })
expect(r.ok).toBe(true)
expect(convertSwfToNitro).toHaveBeenCalled()
})
it('fails cleanly when conversion throws', async () => {
convertSwfToNitro.mockImplementationOnce(() => {
throw new Error('bad swf')
})
const r = await importSinglePet({ lib: 'cat' })
expect(r.ok).toBe(false)
expect(r.error).toContain('conversion')
})
})
File diff suppressed because it is too large. Load diff
+52
View File
@@ -0,0 +1,52 @@
import { resolveGordonBuildUrl } from '@/lib/services/import/core/gordon'
import { siteSettings } from '@/lib/services/site-settings'
/**
* The canonical Habbo pet set (matches renderer-config.json `pet.types`).
* The lib/filename for each is the name itself → `{name}.nitro`.
*/
export const CANONICAL_PETS: string[] = [
'dog',
'cat',
'croco',
'terrier',
'bear',
'pig',
'lion',
'rhino',
'spider',
'turtle',
'chicken',
'frog',
'dragon',
'monster',
'monkey',
'horse',
'monsterplant',
'bunnyeaster',
'bunnyevil',
'bunnydepressed',
'bunnylove',
'pigeongood',
'pigeonevil',
'demonmonkey',
'bearbaby',
'terrierbaby',
'gnome',
'kittenbaby',
'puppybaby',
'pigletbaby',
'haloompa',
'fools',
'pterosaur',
'velociraptor',
'cow',
'elephants',
]
export async function resolvePetSwfUrl(lib: string): Promise<string> {
const override = ((await siteSettings.get('pet_swf_base_url', '')) ?? '').trim()
if (override) return `${override.replace(/\/+$/, '')}/${lib}.swf`
const gordon = await resolveGordonBuildUrl()
return `${gordon}${lib}.swf`
}
+9
View File
@@ -70,6 +70,15 @@ class SiteSettings {
return s === "1" || s === "true"; return s === "1" || s === "true";
} }
async update(key: string, value: string): Promise<void> {
await prisma.websiteSetting.upsert({
where: { key },
create: { key, value },
update: { value },
});
this.reload();
}
reload(): void { reload(): void {
this.cache = null; this.cache = null;
if (redis) { if (redis) {
+19
View File
@@ -0,0 +1,19 @@
export interface EffectMapEntry {
id: string
lib: string
type: string // 'fx' | 'dance'
revision: number
}
export interface EffectListItem extends EffectMapEntry {
present: boolean
nitroExists: boolean
}
export interface ImportEffectResult {
ok: boolean
lib: string
warnings: string[]
error?: string
}