feat: add asset import services
This commit is contained in:
1 parent
4b596226e0
commit
3497df9dfd
46 files changed
+5383
No files matched your search
@@ -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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export * from './catalog-categories/index'
|
||||||
|
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -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]
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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
@@ -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
@@ -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
@@ -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`
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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
@@ -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`
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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 })
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
@@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
@@ -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
@@ -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
@@ -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
@@ -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`
|
||||||
|
}
|
||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|
||||||
Reference in new issue
Block a user