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'
+653
View File
@@ -0,0 +1,653 @@
/**
* Supreme catalog categories for organizing Habbo furniture.
*
* Each category belongs to a root: furni, costruire, animali, vestiti, or staff.
*/
// ── Root Categories ──────────────────────────────────────────────
export type RootCategory = 'furni' | 'costruire' | 'animali' | 'vestiti' | 'staff'
export const ROOT_PAGES: Record<RootCategory, { label: string; labelEn: string; icon: number }> = {
furni: { label: 'Mobili', labelEn: 'Furniture', icon: 263 },
costruire: { label: 'Costruire', labelEn: 'Building', icon: 69991 },
animali: { label: 'Animali', labelEn: 'Pets', icon: 8 },
vestiti: { label: 'Vestiti', labelEn: 'Clothing', icon: 369 },
staff: { label: 'Staff', labelEn: 'Staff', icon: 65 },
}
// ── Supreme Categories ─────────────────────────────────────────────
export interface SupremeCategory {
key: string
label: string
labelEn: string
icon: number
root: RootCategory
keywords: string[]
patterns: RegExp[]
interactionTypes?: string[]
layout: string
}
export const SUPREME_CATEGORIES: SupremeCategory[] = [
// ─── Staff ───
{
key: 'staff_items',
label: 'Staff',
labelEn: 'Staff',
icon: 65,
root: 'staff',
keywords: ['staff_', 'admin_', 'mod_', 'moderator_'],
patterns: [/^staff_/, /^admin_/, /^mod_/],
layout: 'default_3x3',
},
// ─── Vestiti ───
{
key: 'fashion',
label: 'Moda',
labelEn: 'Fashion',
icon: 74,
root: 'vestiti',
keywords: ['clothing_', 'shirt_', 'hat_', 'dress_', 'skirt_', 'shoes_'],
patterns: [/^clothing_/, /^shirt_/, /^hat_/],
interactionTypes: ['clothing'],
layout: 'default_3x3',
},
// ─── Animali ───
{
key: 'pets',
label: 'Animali',
labelEn: 'Pets',
icon: 8,
root: 'animali',
keywords: [
'pet_',
'petfood',
'pet_toy',
'pet_nest',
'pet_breeding',
'horse_',
'monsterplant',
'waterbowl',
'petfood_',
],
patterns: [
/^pet_/,
/^petfood/,
/^pet_toy/,
/^pet_nest/,
/^pet_breeding/,
/^horse_/,
/^monsterplant_/,
/^waterbowl/,
],
interactionTypes: ['pet_food', 'pet_toy', 'pet_breeding_box', 'pet_nest', 'pet_drink'],
layout: 'pets',
},
// ─── Furni ───
{
key: 'rare',
label: 'Rari',
labelEn: 'Rares',
icon: 145,
root: 'furni',
keywords: ['rare_', 'ultra_rare', 'super_rare', 'ltd_', 'bonusrare'],
patterns: [/^rare_/, /^ultra_rare/, /^super_rare/, /^ltd_/, /^bonusrare/],
layout: 'default_3x3',
},
{
key: 'wired',
label: 'Wired',
labelEn: 'Wired',
icon: 5,
root: 'furni',
keywords: ['wf_', 'wired_'],
patterns: [/^wf_/, /^wired_/, /^wf_act_/, /^wf_cnd_/, /^wf_trg_/, /^wf_xtra/],
interactionTypes: [
'wired_condition',
'wired_trigger',
'wired_effect',
'wired_extra',
'wired_addon',
],
layout: 'default_3x3',
},
{
key: 'teleports',
label: 'Teletrasporti',
labelEn: 'Teleports',
icon: 120,
root: 'furni',
keywords: ['teleport'],
patterns: [/^teleport_/, /_teleport$/],
interactionTypes: ['teleport', 'teleport_tile'],
layout: 'default_3x3',
},
{
key: 'rollers',
label: 'Rulli',
labelEn: 'Rollers',
icon: 222,
root: 'furni',
keywords: ['roller_', 'queue_tile'],
patterns: [/^roller_/, /^queue_tile/],
interactionTypes: ['roller'],
layout: 'default_3x3',
},
{
key: 'games',
label: 'Giochi',
labelEn: 'Games',
icon: 78,
root: 'furni',
keywords: ['freeze_', 'banzai', 'football_', 'snowb_', 'bb_gate', 'bb_patch', 'sporttrack'],
patterns: [
/^bb_/,
/^freeze_/,
/^football_/,
/^snowb_/,
/^banzai_/,
/^battle_banzai/,
/^game_/,
/^sporttrack/,
],
interactionTypes: [
'battle_banzai_tile',
'battle_banzai_scoreboard',
'battle_banzai_puck',
'battle_banzai_sphere',
'battle_banzai_teleport',
'freeze_tile',
'freeze_exit',
'football_gate',
'football_goal',
'football',
],
layout: 'default_3x3',
},
{
key: 'music',
label: 'Musica',
labelEn: 'Music',
icon: 11,
root: 'furni',
keywords: ['sound_', 'jukebox', 'trax_', 'song_disk'],
patterns: [/^sound_/, /^jukebox/, /^trax_/, /^song_disk/],
interactionTypes: ['jukebox', 'soundmachine', 'music_disc'],
layout: 'soundmachine',
},
{
key: 'trophies',
label: 'Trofei',
labelEn: 'Trophies',
icon: 221,
root: 'furni',
keywords: ['trophy', 'prizetrophy'],
patterns: [/^trophy_/, /^prizetrophy/, /^gold_trophy/, /^silver_trophy/, /^bronze_trophy/],
interactionTypes: ['trophy'],
layout: 'trophies',
},
{
key: 'effects',
label: 'Effetti',
labelEn: 'Effects',
icon: 61,
root: 'furni',
keywords: ['effect_', 'fx_', 'avatar_effect'],
patterns: [/^effect_/, /^fx_/, /^avatar_effect/],
layout: 'default_3x3',
},
{
key: 'credits',
label: 'Crediti',
labelEn: 'Credits',
icon: 27,
root: 'furni',
keywords: ['cf_', 'credit_furni', 'deal_'],
patterns: [/^cf_/, /^credit_furni/, /^deal_/],
interactionTypes: ['exchange'],
layout: 'default_3x3',
},
{
key: 'hc',
label: 'Club Habbo',
labelEn: 'Habbo Club',
icon: 172,
root: 'furni',
keywords: ['hc_', 'club_', 'vip_', 'hcsohva', 'hcamme', 'edicehc', 'mocchamaster'],
patterns: [/^hc_/, /^club_/, /^vip_/],
layout: 'default_3x3',
},
{
key: 'bots',
label: 'Bot',
labelEn: 'Bots',
icon: 36,
root: 'furni',
keywords: ['bot_', 'rentable_bot'],
patterns: [/^bot_/, /^rentable_bot/],
layout: 'default_3x3',
},
{
key: 'groups',
label: 'Gruppi',
labelEn: 'Groups',
icon: 203,
root: 'furni',
keywords: ['guild_', 'group_'],
patterns: [/^guild_/, /^group_/],
interactionTypes: ['guild_gate', 'guild_forum'],
layout: 'guilds',
},
// ─── Furniture Lines (alphabetical) ───
{
key: 'area',
label: 'Area',
labelEn: 'Area',
icon: 14,
root: 'furni',
keywords: ['area_'],
patterns: [/^area_/],
layout: 'default_3x3',
},
{
key: 'asian',
label: 'Asiatico',
labelEn: 'Asian',
icon: 15,
root: 'furni',
keywords: ['asian_', 'china_', 'jade_', 'jp_', 'wooden_screen'],
patterns: [/^jp_/, /^asian_/, /^china_/, /^jade_/, /^wooden_screen/],
layout: 'default_3x3',
},
{
key: 'bathroom',
label: 'Bagno',
labelEn: 'Bathroom',
icon: 17,
root: 'furni',
keywords: ['bath_', 'shower_', 'toilet_'],
patterns: [/^bath_/, /^shower_/, /^toilet_/],
layout: 'default_3x3',
},
{
key: 'candy',
label: 'Caramelle',
labelEn: 'Candy',
icon: 19,
root: 'furni',
keywords: ['candy_', 'sweet_'],
patterns: [/^candy_/, /^sweet_/],
layout: 'default_3x3',
},
{
key: 'country',
label: 'Country',
labelEn: 'Country',
icon: 21,
root: 'furni',
keywords: ['country_', 'barn_', 'hay_'],
patterns: [/^country_/, /^barn_/, /^hay_/],
layout: 'default_3x3',
},
{
key: 'cubie',
label: 'Cubie',
labelEn: 'Cubie',
icon: 100,
root: 'furni',
keywords: ['cubie_'],
patterns: [/^cubie_/],
layout: 'default_3x3',
},
{
key: 'diner',
label: 'Diner',
labelEn: 'Diner',
icon: 204,
root: 'furni',
keywords: ['diner_', 'dsk_'],
patterns: [/^diner_/, /^dsk_/],
layout: 'default_3x3',
},
{
key: 'executive',
label: 'Executive',
labelEn: 'Executive',
icon: 27,
root: 'furni',
keywords: ['executive_', 'office_'],
patterns: [/^exe_/, /^executive_/, /^office_/],
layout: 'default_3x3',
},
{
key: 'gothic',
label: 'Gotico',
labelEn: 'Gothic',
icon: 30,
root: 'furni',
keywords: ['gothic_', 'goth_'],
patterns: [/^gothic_/, /^goth_/],
layout: 'default_3x3',
},
{
key: 'iced',
label: 'Ghiacciato',
labelEn: 'Iced',
icon: 72,
root: 'furni',
keywords: ['iced_', 'norja'],
patterns: [/^iced_/, /^norja/],
layout: 'default_3x3',
},
{
key: 'kitchen',
label: 'Cucina',
labelEn: 'Kitchen',
icon: 217,
root: 'furni',
keywords: ['kitchen_', 'fridge_', 'stove_', 'oven_', 'sink_'],
patterns: [/^kitchen_/, /^fridge_/, /^stove_/, /^oven_/, /^sink_/],
layout: 'default_3x3',
},
{
key: 'lodge',
label: 'Baita',
labelEn: 'Lodge',
icon: 37,
root: 'furni',
keywords: ['lodge_'],
patterns: [/^lodge_/],
layout: 'default_3x3',
},
{
key: 'maya',
label: 'Maya',
labelEn: 'Maya',
icon: 171,
root: 'furni',
keywords: ['maya_'],
patterns: [/^maya_/],
layout: 'default_3x3',
},
{
key: 'medieval',
label: 'Medievale',
labelEn: 'Medieval',
icon: 39,
root: 'furni',
keywords: ['medieval', 'castle_', 'throne_', 'knight_', 'dragon_'],
patterns: [/^medieval_/, /^castle_/, /^throne_/],
layout: 'default_3x3',
},
{
key: 'mode',
label: 'Mode',
labelEn: 'Mode',
icon: 39,
root: 'furni',
keywords: ['polyfon', 'bardesk'],
patterns: [/^bardesk/, /polyfon/],
layout: 'default_3x3',
},
{
key: 'mystics',
label: 'Mistici',
labelEn: 'Mystics',
icon: 185,
root: 'furni',
keywords: ['mystic'],
patterns: [/^mystic/],
layout: 'default_3x3',
},
{
key: 'plastic',
label: 'Plastica',
labelEn: 'Plastic',
icon: 46,
root: 'furni',
keywords: ['plasto', 'plasty', 'plastic_'],
patterns: [/plasto/, /plasty/, /^plastic_/],
layout: 'default_3x3',
},
{
key: 'pura',
label: 'Pura',
labelEn: 'Pura',
icon: 48,
root: 'furni',
keywords: ['pura_'],
patterns: [/^pura_/],
layout: 'default_3x3',
},
{
key: 'relax',
label: 'Relax',
labelEn: 'Relax',
icon: 49,
root: 'furni',
keywords: ['relax_'],
patterns: [/^relax_/],
layout: 'default_3x3',
},
{
key: 'romantique',
label: 'Romantique',
labelEn: 'Romantique',
icon: 50,
root: 'furni',
keywords: ['romantique', 'romant_'],
patterns: [/^romantique/, /^romant_/],
layout: 'default_3x3',
},
{
key: 'scifi',
label: 'Sci-Fi',
labelEn: 'Sci-Fi',
icon: 53,
root: 'furni',
keywords: ['scifi'],
patterns: [/^scifi/],
layout: 'default_3x3',
},
{
key: 'steampunk',
label: 'Steampunk',
labelEn: 'Steampunk',
icon: 180,
root: 'furni',
keywords: ['steampunk'],
patterns: [/^steampunk/],
layout: 'default_3x3',
},
{
key: 'tropical',
label: 'Tropicale',
labelEn: 'Tropical',
icon: 38,
root: 'furni',
keywords: ['tropic', 'tropical', 'palm_', 'tiki_', 'coconut'],
patterns: [/^tropic_/, /^tropical_/, /^palm_/, /^tiki_/],
layout: 'default_3x3',
},
{
key: 'usva',
label: 'USVA',
labelEn: 'USVA',
icon: 129,
root: 'furni',
keywords: ['usva_'],
patterns: [/^usva_/],
layout: 'default_3x3',
},
{
key: 'waasa',
label: 'Waasa',
labelEn: 'Waasa',
icon: 103,
root: 'furni',
keywords: ['waasa_'],
patterns: [/^waasa_/],
layout: 'default_3x3',
},
// ─── Seasonal ───
{
key: 'christmas',
label: 'Natale',
labelEn: 'Christmas',
icon: 168,
root: 'furni',
keywords: [
'xmas',
'christmas',
'santa',
'snowman',
'advent',
'jouluank',
'joulutahti',
'hyasintti',
'wcandle',
],
patterns: [/^xmas_/, /^christmas_/, /^xmas\d/, /^santai/, /^snow_/],
layout: 'default_3x3',
},
{
key: 'halloween',
label: 'Halloween',
labelEn: 'Halloween',
icon: 34,
root: 'furni',
keywords: [
'halloween',
'hween',
'ghost_',
'skull_',
'pumpkin',
'zombie',
'skeleton',
'spider_',
'witch_',
'deadduck',
'kallo',
],
patterns: [/^hween_/, /^halloween_/, /^ghost_/, /^skull_/, /^pumpkin/],
layout: 'default_3x3',
},
{
key: 'valentine',
label: 'San Valentino',
labelEn: 'Valentine',
icon: 62,
root: 'furni',
keywords: ['valentine', 'heart_', 'love_', 'cupid', 'cupido'],
patterns: [/^val_/, /^valentine_/, /^heart_/, /^love_/],
layout: 'default_3x3',
},
{
key: 'easter',
label: 'Pasqua',
labelEn: 'Easter',
icon: 181,
root: 'furni',
keywords: ['easter', 'bunny_', 'easterduck'],
patterns: [/^easter_/, /^egg_/],
layout: 'default_3x3',
},
// ─── Lighting ───
{
key: 'lighting',
label: 'Illuminazione',
labelEn: 'Lighting',
icon: 115,
root: 'furni',
keywords: ['lamp_', 'light_', 'candle_', 'torch_', 'neon_'],
patterns: [/^lamp_/, /^light_/, /^candle_/, /^torch_/, /^neon_/],
interactionTypes: ['dimmer'],
layout: 'default_3x3',
},
// ─── Costruire ───
{
key: 'builders_club',
label: 'Builders Club',
labelEn: 'Builders Club',
icon: 28,
root: 'costruire',
keywords: ['bc_'],
patterns: [/^bc_/],
layout: 'default_3x3',
},
{
key: 'walls',
label: 'Pareti',
labelEn: 'Walls',
icon: 122,
root: 'costruire',
keywords: ['wallpaper', 'poster_'],
patterns: [/^wall_/, /^wallpaper/, /^poster_/],
layout: 'default_3x3',
},
{
key: 'floors',
label: 'Pavimenti',
labelEn: 'Floors',
icon: 41,
root: 'costruire',
keywords: ['carpet_', 'rug_', 'doormat', 'floortile'],
patterns: [/^floor_/, /^carpet_/, /^rug_/, /^doormat/, /^floortile/],
layout: 'default_3x3',
},
{
key: 'landscapes',
label: 'Paesaggi',
labelEn: 'Landscapes',
icon: 206,
root: 'costruire',
keywords: ['landscape_'],
patterns: [/^landscape_/],
layout: 'default_3x3',
},
{
key: 'windows',
label: 'Finestre',
labelEn: 'Windows',
icon: 218,
root: 'costruire',
keywords: ['window_', 'curtain_'],
patterns: [/^window_/, /^curtain_/],
layout: 'default_3x3',
},
{
key: 'doors_gates',
label: 'Porte e Cancelli',
labelEn: 'Doors & Gates',
icon: 55,
root: 'costruire',
keywords: ['door_', 'gate_', 'marquee'],
patterns: [/^door_/, /^gate_/, /^marquee/],
interactionTypes: ['gate'],
layout: 'default_3x3',
},
{
key: 'plants',
label: 'Piante',
labelEn: 'Plants',
icon: 220,
root: 'costruire',
keywords: ['plant_', 'tree_', 'flower_', 'bush_', 'cactus'],
patterns: [/^plant_/, /^tree_/, /^flower_/, /^bush_/, /^cactus/],
layout: 'default_3x3',
},
// Fallback
{
key: 'uncategorized',
label: 'Non Categorizzati',
labelEn: 'Uncategorized',
icon: 1,
root: 'furni',
keywords: [],
patterns: [/.*/],
layout: 'default_3x3',
},
]
+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
}
}
+152
View File
@@ -0,0 +1,152 @@
// @vitest-environment node
import { describe, expect, it, vi } from 'vitest'
type AnyFn = (...args: any[]) => any
const {
downloadFile,
appendFurniEntry,
parseNitroBundle,
execRaw,
queryRaw,
fsUnlink,
fsReadFile,
} = vi.hoisted(() => ({
downloadFile: vi.fn<AnyFn>(async () => ({ ok: true, size: 200 })),
appendFurniEntry: vi.fn<AnyFn>(async () => {}),
parseNitroBundle: vi.fn<AnyFn>(() => ({ json: {}, png: Buffer.alloc(0) })),
execRaw: vi.fn<AnyFn>(async () => 1),
queryRaw: vi.fn<AnyFn>(async () => [] as unknown[]),
fsUnlink: vi.fn<AnyFn>(async () => {}),
fsReadFile: vi.fn<AnyFn>(async () => Buffer.from('NITRO')),
}))
vi.mock('@/lib/services/import/core/download', () => ({ downloadFile }))
vi.mock('@/lib/services/furni-data', () => ({ appendFurniEntry }))
vi.mock('@/lib/services/furni-import', () => ({
ensureDirectories: vi.fn<AnyFn>(async () => {}),
getOrCreateCategoryPage: vi.fn<AnyFn>(async () => 99),
autoPriceFurni: vi.fn<AnyFn>(() => ({ credits: 5, points: 0, pointsType: 0 })),
allocateCatalogItemId: vi.fn<AnyFn>(async (fn: (n: number) => Promise<unknown>) => fn(1000)),
}))
vi.mock('@/lib/services/furni-asset-dirs', () => ({
getFurniAssetDirs: vi.fn<AnyFn>(async () => ({
nitroDir: '/tmp/nitro',
iconDir: '/tmp/icons',
swfDir: '/tmp/swf',
})),
}))
vi.mock('@/lib/services/swf/nitro-builder', () => ({ parseNitroBundle }))
vi.mock('node:fs', async (orig) => {
const real = (await orig()) as typeof import('node:fs')
return {
...real,
promises: { ...real.promises, readFile: fsReadFile as AnyFn, unlink: fsUnlink as AnyFn },
}
})
vi.mock('@/lib/prisma', () => ({
prisma: {
$executeRaw: execRaw as AnyFn,
$queryRaw: queryRaw as AnyFn,
$executeRawUnsafe: execRaw as AnyFn,
$queryRawUnsafe: queryRaw as AnyFn,
},
}))
import { cloneSingleFurni, parseFurnidata } from './clone-import'
const SOURCE = {
id: 's',
name: 'X',
furnidataUrl: 'https://x/fd.json',
nitroBaseUrl: 'https://x/furni',
iconBaseUrl: 'https://x/icons',
}
const ENTRY = {
id: 5,
classname: 'bc_sofa',
name: 'BC Sofa',
description: 'd',
xdim: 2,
ydim: 1,
canstandon: false,
cansiton: true,
canlayon: false,
customparams: '',
}
describe('clone-import', () => {
it('parseFurnidata normalizes room + wall items with itemType', () => {
const list = parseFurnidata({
roomitemtypes: { furnitype: [ENTRY] },
wallitemtypes: { furnitype: [{ ...ENTRY, classname: 'wall_x' }] },
})
expect(list.find((e) => e.classname === 'bc_sofa')?.itemType).toBe('s')
expect(list.find((e) => e.classname === 'wall_x')?.itemType).toBe('i')
})
it('cloneSingleFurni downloads .nitro+icon, inserts items_base, appends FurnitureData, makes catalog item', async () => {
// Reset mocks to clear state from other tests.
downloadFile.mockReset()
downloadFile.mockResolvedValue({ ok: true, size: 200 })
execRaw.mockReset()
execRaw.mockResolvedValue(1)
queryRaw.mockReset()
// First call: dedup check (no existing row); second call: allocateItemsBaseId MAX(id)+1.
queryRaw.mockResolvedValueOnce([]).mockResolvedValueOnce([{ next: 42 }])
appendFurniEntry.mockReset()
appendFurniEntry.mockResolvedValue(undefined)
const r = await cloneSingleFurni({ source: SOURCE, entry: { ...ENTRY, itemType: 's' } })
expect(r.ok).toBe(true)
expect(downloadFile).toHaveBeenCalledWith(
'https://x/furni/bc_sofa.nitro',
expect.any(String),
expect.any(Object),
)
expect(downloadFile).toHaveBeenCalledWith(
'https://x/icons/bc_sofa_icon.png',
expect.any(String),
expect.objectContaining({ validate: 'png' }),
)
expect(appendFurniEntry).toHaveBeenCalled()
expect(execRaw).toHaveBeenCalled() // items_base insert + catalog
})
it('skips when classname already exists (dedup)', async () => {
queryRaw.mockReset()
queryRaw.mockResolvedValueOnce([{ id: 5 }]) // existing items_base row
const r = await cloneSingleFurni({ source: SOURCE, entry: { ...ENTRY, itemType: 's' } })
expect(r.ok).toBe(false)
expect(r.skipped).toBe(true)
})
it('rolls back: unlinks .nitro and icon, skips FurnitureData when items_base INSERT rejects', async () => {
downloadFile.mockReset()
downloadFile.mockResolvedValue({ ok: true, size: 200 })
fsUnlink.mockReset()
fsUnlink.mockResolvedValue(undefined)
appendFurniEntry.mockReset()
queryRaw.mockReset()
execRaw.mockReset()
// Dedup query: no existing row.
queryRaw.mockResolvedValueOnce([])
// allocateItemsBaseId: MAX(id)+1 query returns next id.
queryRaw.mockResolvedValueOnce([{ next: 7 }])
// items_base INSERT rejects.
execRaw.mockRejectedValueOnce(new Error('Duplicate entry'))
const r = await cloneSingleFurni({ source: SOURCE, entry: { ...ENTRY, itemType: 's' } })
// (a) .nitro must be unlinked.
expect(fsUnlink).toHaveBeenCalledWith(expect.stringContaining('bc_sofa.nitro'))
// (b) icon must also be unlinked.
expect(fsUnlink).toHaveBeenCalledWith(expect.stringContaining('bc_sofa_icon.png'))
// (c) appendFurniEntry must NOT have been called.
expect(appendFurniEntry).not.toHaveBeenCalled()
// (d) result must be ok === false.
expect(r.ok).toBe(false)
expect(r.error).toMatch(/items_base insert failed/)
})
})
+326
View File
@@ -0,0 +1,326 @@
import { promises as fs } from 'node:fs'
import path from 'node:path'
import { prisma } from '@/lib/prisma'
import { extractFurniIconPng } from '@/lib/services/clone-icon'
import type { CloneSource } from '@/lib/services/clone-sources'
import { getFurniAssetDirs } from '@/lib/services/furni-asset-dirs'
import { appendFurniEntry } from '@/lib/services/furni-data'
import {
allocateCatalogItemId,
autoPriceFurni,
ensureDirectories,
getOrCreateCategoryPage,
} from '@/lib/services/furni-import'
import { downloadFile } from '@/lib/services/import/core/download'
import { parseNitroBundle } from '@/lib/services/swf/nitro-builder'
export interface SourceFurni {
id: number
classname: string
name: string
description: string
xdim: number
ydim: number
canstandon: boolean
cansiton: boolean
canlayon: boolean
customparams: string
itemType: 's' | 'i'
[k: string]: unknown
}
export interface CloneResult {
ok: boolean
classname: string
skipped?: boolean
warnings: string[]
error?: string
}
interface FurniType {
furnitype?: Array<Record<string, unknown>>
}
interface RemoteFurnidata {
roomitemtypes?: FurniType
wallitemtypes?: FurniType
furnitype?: Array<Record<string, unknown>>
}
// Spread ...raw first so the coerced typed fields always win over raw values.
function toSourceFurni(raw: Record<string, unknown>, itemType: 's' | 'i'): SourceFurni {
return {
...raw,
id: Number(raw.id ?? 0),
classname: String(raw.classname ?? ''),
name: String(raw.name ?? raw.classname ?? ''),
description: String(raw.description ?? ''),
xdim: Number(raw.xdim ?? 1),
ydim: Number(raw.ydim ?? 1),
canstandon: raw.canstandon === true,
cansiton: raw.cansiton === true,
canlayon: raw.canlayon === true,
customparams: String(raw.customparams ?? ''),
itemType,
}
}
export function parseFurnidata(data: RemoteFurnidata): SourceFurni[] {
const out: SourceFurni[] = []
for (const r of data.roomitemtypes?.furnitype ?? data.furnitype ?? [])
out.push(toSourceFurni(r, 's'))
for (const r of data.wallitemtypes?.furnitype ?? []) out.push(toSourceFurni(r, 'i'))
return out.filter((e) => e.classname)
}
const cache = new Map<string, { list: SourceFurni[]; ts: number }>()
const TTL = 5 * 60 * 1000
export async function fetchSourceFurnidata(url: string, now = Date.now()): Promise<SourceFurni[]> {
const hit = cache.get(url)
if (hit && now && now - hit.ts < TTL) return hit.list
const res = await fetch(url, { signal: AbortSignal.timeout(30000) })
if (!res.ok) throw new Error(`furnidata fetch failed: ${res.status} ${url}`)
let list: SourceFurni[]
try {
const json = await res.json()
list = parseFurnidata(json)
} catch (err) {
throw new Error(`furnidata parse failed for ${url}: ${(err as Error).message}`)
}
cache.set(url, { list, ts: now })
return list
}
/**
* Allocate the next `items_base.id` through a process-level async mutex so
* concurrent clones in the same Node process cannot race on MAX(id)+1.
* Mirrors the `allocateCatalogItemId` pattern from furni-import.ts.
*/
let itemsBaseIdAllocChain: Promise<unknown> = Promise.resolve()
async function allocateItemsBaseId<T>(insertFn: (nextId: number) => Promise<T>): Promise<T> {
const prev = itemsBaseIdAllocChain
let settle!: () => void
itemsBaseIdAllocChain = new Promise<void>((r) => {
settle = r
})
await prev.catch(() => {})
try {
const idRow = await prisma.$queryRaw<Array<{ next: number }>>`
SELECT COALESCE(MAX(id), 0) + 1 AS next FROM items_base`
const nextId = Number(idRow[0]?.next ?? 1)
return await insertFn(nextId)
} finally {
settle()
}
}
export async function cloneSingleFurni(params: {
source: CloneSource
entry: SourceFurni
onProgress?: (status: string) => void
}): Promise<CloneResult> {
const { source, entry, onProgress } = params
const { classname, itemType } = entry
const warnings: string[] = []
// Path-traversal guard: classname comes from remote furnidata and is used to
// build file paths — reject anything that doesn't look like a safe furni name.
if (!/^[\w\-.*]+$/.test(classname)) {
return { ok: false, classname, warnings, error: 'invalid classname' }
}
// Dedup by classname.
const existing = await prisma.$queryRaw<Array<{ id: number }>>`
SELECT id FROM items_base WHERE item_name = ${classname} LIMIT 1`
if (existing.length > 0) {
return { ok: false, classname, skipped: true, warnings, error: 'Already present' }
}
await ensureDirectories()
const { iconDir, nitroDir } = await getFurniAssetDirs()
// Download .nitro + icon directly from the source hotel.
onProgress?.('downloading')
const nitroPath = path.join(/*turbopackIgnore: true*/ nitroDir, `${classname}.nitro`)
const iconPath = path.join(/*turbopackIgnore: true*/ iconDir, `${classname}_icon.png`)
const dl = await downloadFile(`${source.nitroBaseUrl}/${classname}.nitro`, nitroPath, {
maxRetries: 2,
})
if (!dl.ok) {
console.warn('[clone-import] nitro download failed for', classname)
return { ok: false, classname, warnings, error: 'nitro download failed' }
}
// Validate it is a real Nitro bundle.
try {
parseNitroBundle(await fs.readFile(/*turbopackIgnore: true*/ nitroPath))
} catch {
await fs.unlink(/*turbopackIgnore: true*/ nitroPath).catch(() => {})
console.warn('[clone-import] invalid .nitro bundle for', classname)
return { ok: false, classname, warnings, error: 'invalid .nitro bundle' }
}
const iconDl = await downloadFile(`${source.iconBaseUrl}/${classname}_icon.png`, iconPath, {
maxRetries: 1,
validate: 'png',
})
if (!iconDl.ok) {
// Source serves no standalone icon (e.g. icons embedded in the .nitro) —
// extract the catalog icon from the bundle we just downloaded.
try {
const icon = extractFurniIconPng(await fs.readFile(/*turbopackIgnore: true*/ nitroPath))
if (icon) {
await fs.writeFile(/*turbopackIgnore: true*/ iconPath, icon)
} else {
warnings.push('no icon (not in source or bundle)')
}
} catch {
warnings.push('icon extraction failed')
}
}
onProgress?.('writing_db')
const stackHeight = entry.canlayon || entry.cansiton ? 1.0 : entry.canstandon ? 1.0 : 0.0
// allow_stack: derived from stackHeight (mirrors furni-import.ts `dims.z > 0`).
// allow_walk = canstandon, allow_sit = cansiton, allow_lay = canlayon.
const allowStack = stackHeight > 0 ? '1' : '0'
let newId: number
try {
newId = await allocateItemsBaseId(async (nextId) => {
await prisma.$executeRaw`
INSERT INTO items_base
(id, sprite_id, public_name, item_name, type, width, length, stack_height,
allow_stack, allow_sit, allow_lay, allow_walk, interaction_type, customparams)
VALUES
(${nextId}, ${nextId}, ${entry.name}, ${classname}, ${itemType}, ${entry.xdim}, ${entry.ydim}, ${stackHeight},
${allowStack}, ${entry.cansiton ? '1' : '0'}, ${entry.canlayon ? '1' : '0'}, ${entry.canstandon ? '1' : '0'}, 'default', ${entry.customparams})`
return nextId
})
} catch (err) {
// Rollback: remove both downloaded files so we don't leave orphaned assets.
await fs.unlink(/*turbopackIgnore: true*/ nitroPath).catch(() => {})
await fs.unlink(/*turbopackIgnore: true*/ iconPath).catch(() => {})
console.warn('[clone-import] items_base insert failed for', classname, (err as Error).message)
return {
ok: false,
classname,
warnings,
error: `items_base insert failed: ${(err as Error).message}`,
}
}
// FurnitureData entry — reuse the source's furnitype object, with our id.
onProgress?.('writing_furnidata')
try {
await appendFurniEntry({ ...entry, id: newId } as Record<string, unknown>, itemType)
} catch (err) {
console.warn(
'[clone-import] FurnitureData append failed for',
classname,
(err as Error).message,
)
warnings.push(`FurnitureData append failed: ${(err as Error).message}`)
}
// Catalog entry (category sub-page + auto price), serialized id allocation.
try {
const pageId = await getOrCreateCategoryPage(classname, itemType)
const price = autoPriceFurni(classname)
await allocateCatalogItemId(async (nextCatalogId) => {
await prisma.$executeRaw`
INSERT INTO catalog_items (id, page_id, item_ids, catalog_name, cost_credits, cost_points, points_type, amount, order_number, offer_id, extradata)
VALUES (${nextCatalogId}, ${String(pageId)}, ${String(newId)}, ${classname}, ${price.credits}, ${price.points}, ${price.pointsType}, 1, 1, '-1', '')`
return nextCatalogId
})
} catch (err) {
console.warn('[clone-import] catalog entry failed for', classname, (err as Error).message)
warnings.push(`catalog entry failed: ${(err as Error).message}`)
}
return { ok: true, classname, warnings }
}
export async function getCloneList(params: {
source: CloneSource
search: string
page: number
perPage: number
filter?: 'all' | 'missing' | 'present'
}): Promise<{
items: Array<SourceFurni & { present: boolean }>
meta: { page: number; perPage: number; total: number }
}> {
const { source, search, page, perPage, filter = 'all' } = params
const all = await fetchSourceFurnidata(source.furnidataUrl)
const term = search.trim().toLowerCase()
const searched = term
? all.filter(
(e) => e.classname.toLowerCase().includes(term) || e.name.toLowerCase().includes(term),
)
: all
// Resolve present status across the whole searched list (not just the page),
// so the missing/present filter and the page total stay correct.
const allNames = searched.map((e) => e.classname)
const present = new Set<string>()
if (allNames.length) {
const placeholders = allNames.map(() => '?').join(',')
const rows = await prisma.$queryRawUnsafe<Array<{ item_name: string }>>(
`SELECT item_name FROM items_base WHERE item_name IN (${placeholders})`,
...allNames,
)
for (const r of rows) present.add(r.item_name)
}
const filtered =
filter === 'missing'
? searched.filter((e) => !present.has(e.classname))
: filter === 'present'
? searched.filter((e) => present.has(e.classname))
: searched
const total = filtered.length
const start = (Math.max(page, 1) - 1) * perPage
const slice = filtered.slice(start, start + perPage)
return {
items: slice.map((e) => ({ ...e, present: present.has(e.classname) })),
meta: { page: Math.max(page, 1), perPage, total },
}
}
export async function getCloneStats(
source: CloneSource,
): Promise<{ total: number; present: number; clonable: number }> {
const all = await fetchSourceFurnidata(source.furnidataUrl)
const classnames = all.map((e) => e.classname)
const have = new Set<string>()
if (classnames.length) {
const placeholders = classnames.map(() => '?').join(',')
const rows = await prisma.$queryRawUnsafe<Array<{ item_name: string }>>(
`SELECT item_name FROM items_base WHERE item_name IN (${placeholders})`,
...classnames,
)
for (const r of rows) have.add(r.item_name)
}
const present = all.filter((e) => have.has(e.classname)).length
return { total: all.length, present, clonable: all.length - present }
}
/**
* Return the classnames of every furni in the source that is NOT yet present
* in our items_base — used by the "clone all" action (cloned in chunks by the UI).
*/
export async function getClonableClassnames(source: CloneSource): Promise<string[]> {
const all = await fetchSourceFurnidata(source.furnidataUrl)
const classnames = all.map((e) => e.classname)
const have = new Set<string>()
if (classnames.length) {
const placeholders = classnames.map(() => '?').join(',')
const rows = await prisma.$queryRawUnsafe<Array<{ item_name: string }>>(
`SELECT item_name FROM items_base WHERE item_name IN (${placeholders})`,
...classnames,
)
for (const r of rows) have.add(r.item_name)
}
return all.filter((e) => !have.has(e.classname)).map((e) => e.classname)
}
+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)
})
})
+205
View File
@@ -0,0 +1,205 @@
import { ensureFigureDirs, importSingleFigure } from '@/lib/services/figure-import'
import {
fetchOfficialFigureData,
fetchOfficialFigureMap,
type OfficialFigureData,
type OfficialLibrary,
type OfficialSet,
} from '@/lib/services/figure-official'
import { listLocalSetIds, mergePalette, mergeSet } from '@/lib/services/figuredata'
import { listFigureLibraries, mergeLibrary } from '@/lib/services/figuremap'
export interface ClothingSetItem {
setType: string
setId: number
gender: string
club: number
sellable: boolean
present: boolean
}
export interface ClothingSetListResult {
items: ClothingSetItem[]
meta: { page: number; perPage: number; total: number }
}
export interface ClothingSetStats {
total: number
present: number
missing: number
}
export interface ImportClothingSetResult {
ok: boolean
setId?: number
warnings: string[]
error?: string
}
function partKey(type: string, id: number): string {
return `${type}:${id}`
}
function buildPartIndex(officialLibs: OfficialLibrary[]): Map<string, OfficialLibrary> {
const index = new Map<string, OfficialLibrary>()
for (const lib of officialLibs) {
for (const part of lib.parts) {
const key = partKey(part.type, part.id)
if (!index.has(key)) {
index.set(key, lib)
}
}
}
return index
}
function mapOfficialSet(set: OfficialSet): Record<string, unknown> {
const result: Record<string, unknown> = {
id: set.id,
gender: set.gender,
club: set.club,
colorable: set.colorable,
selectable: set.selectable,
preselectable: set.preselectable,
sellable: set.sellable,
parts: set.parts.map((p) => ({
id: p.id,
type: p.type,
colorable: p.colorable,
index: p.index,
colorindex: p.colorindex,
})),
}
if (set.hiddenLayers?.length) {
result.hiddenLayers = set.hiddenLayers
}
return result
}
export async function importClothingSet(params: {
setType: string
setId: number
onProgress?: (status: string) => void
}): Promise<ImportClothingSetResult> {
const { setType, setId, onProgress } = params
const warnings: string[] = []
const [officialFd, officialLibs] = await Promise.all([
fetchOfficialFigureData(),
fetchOfficialFigureMap(),
])
const st = officialFd.setTypes.find((s) => s.type === setType)
if (!st) {
return { ok: false, warnings, error: `setType "${setType}" not found in official figuredata` }
}
const set = st.sets.find((s) => s.id === setId)
if (!set) {
return {
ok: false,
warnings,
error: `set id ${setId} not found in setType "${setType}" in official figuredata`,
}
}
const palette = officialFd.palettes.find((p) => p.id === st.paletteId)
const partIndex = buildPartIndex(officialLibs)
const requiredLibIds = new Set<string>()
for (const part of set.parts) {
const lib = partIndex.get(partKey(part.type, part.id))
if (lib) {
requiredLibIds.add(lib.id)
} else {
warnings.push(`No library found for part ${part.type}:${part.id}`)
}
}
const localLibs = await listFigureLibraries()
const localLibIds = new Set(localLibs.map((l) => l.id))
await ensureFigureDirs()
for (const libId of requiredLibIds) {
const officialLib = officialLibs.find((l) => l.id === libId)
if (!officialLib) continue
if (!localLibIds.has(libId)) {
onProgress?.(`importing library ${libId}`)
const result = await importSingleFigure({ lib: libId, onProgress })
if (!result.ok) {
warnings.push(`Library import failed for ${libId}: ${result.error ?? 'unknown error'}`)
}
if (result.warnings.length) {
warnings.push(...result.warnings)
}
}
await mergeLibrary(officialLib)
}
const mappedSet = mapOfficialSet(set)
await mergeSet(setType, st.paletteId, mappedSet as never)
if (palette) {
await mergePalette(palette as never)
}
return { ok: true, setId, warnings }
}
export async function getClothingSetList(params: {
search: string
page: number
perPage: number
setType?: string
}): Promise<ClothingSetListResult> {
const { search, page, perPage, setType } = params
const officialFd: OfficialFigureData = await fetchOfficialFigureData()
const allItems: ClothingSetItem[] = []
for (const st of officialFd.setTypes) {
if (setType && st.type !== setType) continue
const presentIds = await listLocalSetIds(st.type)
for (const set of st.sets) {
allItems.push({
setType: st.type,
setId: set.id,
gender: set.gender,
club: set.club,
sellable: set.sellable,
present: presentIds.has(set.id),
})
}
}
const term = search.trim().toLowerCase()
const filtered = term
? allItems.filter(
(it) => String(it.setId).includes(term) || it.setType.toLowerCase().includes(term),
)
: allItems
const total = filtered.length
const start = (Math.max(page, 1) - 1) * perPage
const items = filtered.slice(start, start + perPage)
return { items, meta: { page: Math.max(page, 1), perPage, total } }
}
export async function getClothingSetStats(): Promise<ClothingSetStats> {
const officialFd: OfficialFigureData = await fetchOfficialFigureData()
let total = 0
let present = 0
for (const st of officialFd.setTypes) {
const presentIds = await listLocalSetIds(st.type)
total += st.sets.length
present += st.sets.filter((s) => presentIds.has(s.id)).length
}
return { total, present, missing: total - present }
}
+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()
})
})
+138
View File
@@ -0,0 +1,138 @@
import { existsSync, promises as fs } from 'node:fs'
import path from 'node:path'
import { listOfficialEffects, resolveEffectSwfUrl } from '@/lib/services/effect-source'
import { listLocalEffects, mergeEffect, removeEffect } from '@/lib/services/effectmap'
import { downloadFile } from '@/lib/services/import/core/download'
import { convertSwfToNitro } from '@/lib/services/swf-to-nitro'
import type { EffectListItem, EffectMapEntry, ImportEffectResult } from '@/types/effects'
export const EFFECT_NITRO_DIR = path.join(
/*turbopackIgnore: true*/ process.cwd(),
'public/nitro-assets/bundled/effect',
)
export const EFFECT_SWF_DIR = path.join(
/*turbopackIgnore: true*/ process.cwd(),
'public/swf/gordon',
)
export async function ensureEffectDirs(): Promise<void> {
await Promise.all(
[EFFECT_NITRO_DIR, EFFECT_SWF_DIR]
.filter((dir) => !existsSync(/*turbopackIgnore: true*/ dir))
.map((dir) => fs.mkdir(/*turbopackIgnore: true*/ dir, { recursive: true })),
)
}
function nitroPathFor(lib: string): string {
return path.join(/*turbopackIgnore: true*/ EFFECT_NITRO_DIR, `${lib}.nitro`)
}
export async function importSingleEffect(params: {
id: string
lib: string
type: string
revision: number
onProgress?: (status: string) => void
}): Promise<ImportEffectResult> {
const { id, lib, type, revision, onProgress } = params
const warnings: string[] = []
await ensureEffectDirs()
const nitroPath = nitroPathFor(lib)
// Download SWF
onProgress?.('downloading')
const swfPath = path.join(/*turbopackIgnore: true*/ EFFECT_SWF_DIR, `${lib}.swf`)
let swfUrl: string
try {
swfUrl = await resolveEffectSwfUrl(lib)
} catch (err) {
return {
ok: false,
lib,
warnings,
error: `Could not resolve SWF URL: ${(err as Error).message}`,
}
}
const dl = await downloadFile(swfUrl, swfPath, { validate: 'swf' })
if (!dl.ok) return { ok: false, lib, warnings, error: `SWF download failed: ${swfUrl}` }
// Convert SWF → Nitro
onProgress?.('converting')
try {
const swfBuffer = await fs.readFile(/*turbopackIgnore: true*/ swfPath)
const result = convertSwfToNitro(swfBuffer, lib)
await fs.writeFile(/*turbopackIgnore: true*/ nitroPath, result.bundle)
if (result.warnings?.length) warnings.push(...result.warnings)
} catch (err) {
return {
ok: false,
lib,
warnings,
error: `SWF→Nitro conversion failed: ${(err as Error).message}`,
}
}
// Merge EffectMap.json (last step — no orphan entry if anything above failed)
onProgress?.('writing_map')
try {
const entry: EffectMapEntry = { id, lib, type, revision }
await mergeEffect(entry)
} catch (err) {
warnings.push(`EffectMap.json merge failed: ${(err as Error).message}`)
return { ok: false, lib, warnings, error: 'EffectMap.json merge failed' }
}
return { ok: true, lib, warnings }
}
export async function deleteImportedEffect(
idOrLib: string,
): Promise<{ removed: boolean; deletedFile: boolean }> {
const local = await listLocalEffects()
const entry = local.find((e) => e.id === idOrLib || e.lib === idOrLib)
const removed = await removeEffect(idOrLib)
let deletedFile = false
if (entry) {
const p = nitroPathFor(entry.lib)
if (existsSync(/*turbopackIgnore: true*/ p)) {
await fs.unlink(/*turbopackIgnore: true*/ p).catch(() => {})
deletedFile = true
}
}
return { removed, deletedFile }
}
export async function getEffectList(search: string): Promise<EffectListItem[]> {
const [official, local] = await Promise.all([listOfficialEffects(), listLocalEffects()])
const localByLib = new Map(local.map((e) => [e.lib, e]))
const term = search.trim().toLowerCase()
const merged = official.map((e) => {
const present = localByLib.has(e.lib)
return {
...e,
present,
nitroExists: existsSync(/*turbopackIgnore: true*/ nitroPathFor(e.lib)),
}
})
return term
? merged.filter(
(e) =>
e.lib.toLowerCase().includes(term) ||
e.id.toLowerCase().includes(term) ||
e.type.includes(term),
)
: merged
}
export async function getEffectStats(): Promise<{
official: number
imported: number
missing: number
}> {
const [official, local] = await Promise.all([listOfficialEffects(), listLocalEffects()])
const localLibs = new Set(local.map((e) => e.lib))
const imported = official.filter((e) => localLibs.has(e.lib)).length
return { official: official.length, imported, missing: official.length - imported }
}
+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')
})
})
+129
View File
@@ -0,0 +1,129 @@
import { existsSync, promises as fs } from 'node:fs'
import path from 'node:path'
import { resolveFigureSwfUrl } from '@/lib/services/figure-source'
import { listFigureLibraries } from '@/lib/services/figuremap'
import { downloadFile } from '@/lib/services/import/core/download'
import { convertSwfToNitro } from '@/lib/services/swf-to-nitro'
export interface FigureListItem {
lib: string
revision: number
nitroExists: boolean
// First part the library provides ({type,id}) — used to render an avatar
// preview wearing it. Undefined if the library declares no parts.
previewType?: string
previewId?: number
}
export interface ImportFigureResult {
ok: boolean
lib: string
warnings: string[]
error?: string
}
export const FIGURE_NITRO_DIR = path.join(
/*turbopackIgnore: true*/ process.cwd(),
'public/nitro-assets/bundled/figure',
)
export const FIGURE_SWF_DIR = path.join(
/*turbopackIgnore: true*/ process.cwd(),
'public/swf/gordon',
)
export async function ensureFigureDirs(): Promise<void> {
await Promise.all(
[FIGURE_NITRO_DIR, FIGURE_SWF_DIR]
.filter((dir) => !existsSync(/*turbopackIgnore: true*/ dir))
.map((dir) => fs.mkdir(/*turbopackIgnore: true*/ dir, { recursive: true })),
)
}
function nitroPathFor(lib: string): string {
return path.join(/*turbopackIgnore: true*/ FIGURE_NITRO_DIR, `${lib}.nitro`)
}
export async function importSingleFigure(params: {
lib: string
onProgress?: (status: string) => void
}): Promise<ImportFigureResult> {
const { lib, onProgress } = params
const warnings: string[] = []
await ensureFigureDirs()
onProgress?.('downloading')
const swfPath = path.join(/*turbopackIgnore: true*/ FIGURE_SWF_DIR, `${lib}.swf`)
let swfUrl: string
try {
swfUrl = await resolveFigureSwfUrl(lib)
} catch (err) {
return {
ok: false,
lib,
warnings,
error: `Could not resolve SWF URL: ${(err as Error).message}`,
}
}
const dl = await downloadFile(swfUrl, swfPath, { validate: 'swf' })
if (!dl.ok) return { ok: false, lib, warnings, error: `SWF download failed: ${swfUrl}` }
onProgress?.('converting')
try {
const swfBuffer = await fs.readFile(/*turbopackIgnore: true*/ swfPath)
const result = convertSwfToNitro(swfBuffer, lib)
await fs.writeFile(/*turbopackIgnore: true*/ nitroPathFor(lib), result.bundle)
if (result.warnings?.length) warnings.push(...result.warnings)
} catch (err) {
return {
ok: false,
lib,
warnings,
error: `SWF→Nitro conversion failed: ${(err as Error).message}`,
}
}
return { ok: true, lib, warnings }
}
export async function deleteImportedFigure(lib: string): Promise<{ deletedFile: boolean }> {
const p = nitroPathFor(lib)
if (existsSync(/*turbopackIgnore: true*/ p)) {
await fs.unlink(/*turbopackIgnore: true*/ p).catch(() => {})
return { deletedFile: true }
}
return { deletedFile: false }
}
export async function getFigureList(params: {
search: string
page: number
perPage: number
}): Promise<{ items: FigureListItem[]; meta: { page: number; perPage: number; total: number } }> {
const { search, page, perPage } = params
const libs = await listFigureLibraries()
const term = search.trim().toLowerCase()
const filtered = term ? libs.filter((l) => l.id.toLowerCase().includes(term)) : libs
const total = filtered.length
const start = (Math.max(page, 1) - 1) * perPage
const items = filtered.slice(start, start + perPage).map((l) => ({
lib: l.id,
revision: l.revision,
nitroExists: existsSync(/*turbopackIgnore: true*/ nitroPathFor(l.id)),
previewType: l.parts?.[0]?.type,
previewId: l.parts?.[0]?.id,
}))
return { items, meta: { page: Math.max(page, 1), perPage, total } }
}
export async function getFigureStats(): Promise<{
total: number
imported: number
missing: number
}> {
const libs = await listFigureLibraries()
const imported = libs.filter((l) =>
existsSync(/*turbopackIgnore: true*/ nitroPathFor(l.id)),
).length
return { total: libs.length, imported, missing: libs.length - imported }
}
+161
View File
@@ -0,0 +1,161 @@
import { resolveGordonBuildUrl } from '@/lib/services/import/core/gordon'
import { siteSettings } from '@/lib/services/site-settings'
export interface OfficialColor {
id: number
index: number
club: number
selectable: boolean
hexCode: string
}
export interface OfficialPalette {
id: number
colors: OfficialColor[]
}
export interface OfficialPart {
id: number
type: string
colorable: boolean
index: number
colorindex: number
}
export interface OfficialSet {
id: number
gender: string
club: number
colorable: boolean
selectable: boolean
preselectable: boolean
sellable: boolean
parts: OfficialPart[]
hiddenLayers?: { partType: string }[]
}
export interface OfficialSetType {
type: string
paletteId: number
mandM0: boolean
mandF0: boolean
mandM1: boolean
mandF1: boolean
sets: OfficialSet[]
}
export interface OfficialFigureData {
palettes: OfficialPalette[]
setTypes: OfficialSetType[]
}
export interface OfficialLibrary {
id: string
revision: number
parts: { id: number; type: string }[]
}
const CACHE_TTL = 30 * 60 * 1000
const DEFAULT_FIGUREDATA_URL = 'https://www.habbo.com/gamedata/figuredata/1'
let fdCache: { data: OfficialFigureData; ts: number } | null = null
let fmCache: { libs: OfficialLibrary[]; ts: number } | null = null
export function __clearFigureOfficialCache(): void {
fdCache = null
fmCache = null
}
const attr = (tag: string, name: string): string | undefined =>
new RegExp(`\\b${name}="([^"]*)"`).exec(tag)?.[1]
const bool = (v: string | undefined): boolean => v === '1' || v === 'true'
const num = (v: string | undefined): number => {
const n = Number(v)
return Number.isFinite(n) ? n : 0
}
export async function fetchOfficialFigureData(): Promise<OfficialFigureData> {
const now = Date.now()
if (fdCache && now - fdCache.ts < CACHE_TTL) return fdCache.data
const url = await siteSettings.get('figuredata_source_url', DEFAULT_FIGUREDATA_URL)
const res = await fetch(url ?? DEFAULT_FIGUREDATA_URL, { signal: AbortSignal.timeout(30000) })
if (!res.ok) throw new Error(`figuredata fetch failed: ${res.status}`)
const xml = await res.text()
const palettes: OfficialPalette[] = []
for (const pm of xml.matchAll(/<palette\b([^>]*)>([\s\S]*?)<\/palette>/g)) {
const id = num(attr(pm[1], 'id'))
const colors: OfficialColor[] = []
for (const cm of pm[2].matchAll(/<color\b([^>]*)>([^<]*)<\/color>/g)) {
colors.push({
id: num(attr(cm[1], 'id')),
index: num(attr(cm[1], 'index')),
club: num(attr(cm[1], 'club')),
selectable: bool(attr(cm[1], 'selectable')),
hexCode: cm[2].trim(),
})
}
palettes.push({ id, colors })
}
const setTypes: OfficialSetType[] = []
for (const sm of xml.matchAll(/<settype\b([^>]*)>([\s\S]*?)<\/settype>/g)) {
const a = sm[1]
const sets: OfficialSet[] = []
for (const setM of sm[2].matchAll(/<set\b([^>]*)>([\s\S]*?)<\/set>/g)) {
const sa = setM[1]
const parts: OfficialPart[] = []
for (const pm of setM[2].matchAll(/<part\b([^>]*)\/?>/g)) {
parts.push({
id: num(attr(pm[1], 'id')),
type: attr(pm[1], 'type') ?? '',
colorable: bool(attr(pm[1], 'colorable')),
index: num(attr(pm[1], 'index')),
colorindex: num(attr(pm[1], 'colorindex')),
})
}
const hiddenLayers: { partType: string }[] = []
for (const hm of setM[2].matchAll(/<layer\b([^>]*)\/?>/g)) {
const pt = attr(hm[1], 'parttype')
if (pt) hiddenLayers.push({ partType: pt })
}
sets.push({
id: num(attr(sa, 'id')),
gender: attr(sa, 'gender') ?? 'U',
club: num(attr(sa, 'club')),
colorable: bool(attr(sa, 'colorable')),
selectable: bool(attr(sa, 'selectable')),
preselectable: bool(attr(sa, 'preselectable')),
sellable: bool(attr(sa, 'sellable')),
parts,
hiddenLayers: hiddenLayers.length ? hiddenLayers : undefined,
})
}
setTypes.push({
type: attr(a, 'type') ?? '',
paletteId: num(attr(a, 'paletteid')),
mandM0: bool(attr(a, 'mand_m_0')),
mandF0: bool(attr(a, 'mand_f_0')),
mandM1: bool(attr(a, 'mand_m_1')),
mandF1: bool(attr(a, 'mand_f_1')),
sets,
})
}
const data = { palettes, setTypes }
fdCache = { data, ts: now }
return data
}
export async function fetchOfficialFigureMap(): Promise<OfficialLibrary[]> {
const now = Date.now()
if (fmCache && now - fmCache.ts < CACHE_TTL) return fmCache.libs
const override = ((await siteSettings.get('figure_swf_base_url', '')) ?? '').trim()
const base = override ? `${override.replace(/\/+$/, '')}/` : await resolveGordonBuildUrl()
const res = await fetch(`${base}figuremap.xml`, { signal: AbortSignal.timeout(30000) })
if (!res.ok) throw new Error(`figuremap fetch failed: ${res.status}`)
const xml = await res.text()
const libs: OfficialLibrary[] = []
for (const lm of xml.matchAll(/<lib\b([^>]*)>([\s\S]*?)<\/lib>/g)) {
const parts: { id: number; type: string }[] = []
for (const pm of lm[2].matchAll(/<part\b([^>]*)\/?>/g)) {
parts.push({ id: num(attr(pm[1], 'id')), type: attr(pm[1], 'type') ?? '' })
}
libs.push({ id: attr(lm[1], 'id') ?? '', revision: num(attr(lm[1], 'revision')), parts })
}
fmCache = { libs, ts: now }
return libs
}
+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])
})
})
+743
View File
@@ -0,0 +1,743 @@
import { existsSync, promises as fs } from 'node:fs'
import path from 'node:path'
import { prisma } from '@/lib/prisma'
import { appendFurniEntry, buildFurniEntry, removeFurniEntry } from '@/lib/services/furni-data'
import type { HabboItFurniEntry } from '@/lib/services/habbo-furnidata-cache'
import { lookupHabboIt } from '@/lib/services/habbo-furnidata-cache'
// Re-export the download helpers now owned by the shared import core.
// Existing furni callers (and furni-repair) keep importing them from here.
export {
downloadFile,
validatePngBytes,
validateSwfBytes,
} from '@/lib/services/import/core/download'
import {
DEFAULT_FURNI_ICON_DIR,
DEFAULT_FURNI_NITRO_DIR,
DEFAULT_FURNI_SWF_DIR,
getFurniAssetDirs,
getFurniAssetWriteTargets,
} from '@/lib/services/furni-asset-dirs'
import { downloadFile } from '@/lib/services/import/core/download'
import type { ConversionResult } from '@/lib/services/swf-to-nitro'
import { convertSwfToNitro, extractIconFromSwf } from '@/lib/services/swf-to-nitro'
import type { ImportSingleResult } from '@/types/furni'
// ── Constants ────────────────────────────────────────────────────────
// turbopackIgnore hints: this module is reached by App Router routes; without
// them Turbopack's NFT tracer marks the whole project as a dependency because
// it can't prove the dynamic suffixes (file names) are bounded to the prefix.
export const FURNI_ICON_DIR = DEFAULT_FURNI_ICON_DIR
export const FURNI_SWF_DIR = DEFAULT_FURNI_SWF_DIR
export const FURNI_NITRO_DIR = DEFAULT_FURNI_NITRO_DIR
export const IMPORTED_PAGE_CAPTION_SAVE = 'imported_furni'
export const IMPORTED_PAGE_CAPTION = 'Imported Furniture'
// Catalog sub-page definitions (key = internal id used for caption_save `imp_<key>`)
export const CATEGORY_PAGE: Record<string, { label: string; icon: number; order: number }> = {
credits: { label: 'Credits & Exchange', icon: 263, order: 1 },
rares: { label: 'Rares', icon: 11, order: 2 },
wired: { label: 'Wired', icon: 1, order: 3 },
nft: { label: 'NFT', icon: 12, order: 4 },
seasonal: { label: 'Seasonal', icon: 10, order: 5 },
music: { label: 'Music & Sound', icon: 13, order: 6 },
clothing: { label: 'Clothing', icon: 3, order: 7 },
builders: { label: 'Builders Club', icon: 7, order: 8 },
walls: { label: 'Wall Items', icon: 5, order: 9 },
promo: { label: 'Promo & Custom', icon: 2, order: 10 },
trophies: { label: 'Trophies', icon: 6, order: 11 },
pets: { label: 'Pets', icon: 4, order: 12 },
room: { label: 'Room & Building', icon: 8, order: 13 },
other: { label: 'Other', icon: 1, order: 99 },
}
// Classname prefix → category key (order matters: first match wins)
export const CLASSNAME_RULES: Array<{ test: (cn: string, type: string) => boolean; cat: string }> =
[
{ test: (cn) => /^CF_/i.test(cn) || /^exe_/i.test(cn) || cn === 'credit', cat: 'credits' },
{ test: (cn) => /^rare_/i.test(cn), cat: 'rares' },
{ test: (cn) => /^wf_/i.test(cn), cat: 'wired' },
{ test: (cn) => /^nft_/i.test(cn), cat: 'nft' },
{
test: (cn) => /^(xmas|hween|easter|val_|valentine|hw_|newyear|summer|carnival)/i.test(cn),
cat: 'seasonal',
},
{ test: (cn) => /^(trax|jukebox|sound|music)/i.test(cn), cat: 'music' },
{ test: (cn) => /^(clothing|clothes)/i.test(cn), cat: 'clothing' },
{ test: (cn) => /^bc_/i.test(cn), cat: 'builders' },
{ test: (cn) => /^(poster|wall_|window_|wallpaper)/i.test(cn), cat: 'walls' },
{ test: (_cn, type) => type === 'wallitem' || type === 'i', cat: 'walls' },
{ test: (cn) => /^(ads_|cstm_)/i.test(cn), cat: 'promo' },
{ test: (cn) => /^(trophy|gold_|silver_|bronze_)/i.test(cn), cat: 'trophies' },
{
test: (cn) =>
/^(pet|dog|cat_|bird|horse|pig|monkey|frog|dragon|bear_|spider|bunny)/i.test(cn),
cat: 'pets',
},
{ test: (cn) => /^(room_|floor_|tile_|carpet|rug|door_|gate_|stair)/i.test(cn), cat: 'room' },
]
// ── Helpers ──────────────────────────────────────────────────────────
export async function ensureDirectories(): Promise<void> {
const targets = await getFurniAssetWriteTargets()
const dirs = [
targets.swfDir,
targets.iconDir,
targets.nitroDir,
...targets.mirrorDirs.flatMap((d) => [d.swfDir, d.iconDir, d.nitroDir]),
]
await Promise.all(
dirs
.filter((dir) => !existsSync(/*turbopackIgnore: true*/ dir))
.map((dir) => fs.mkdir(/*turbopackIgnore: true*/ dir, { recursive: true })),
)
}
async function mirrorFileToDirs(
sourcePath: string,
fileName: string,
dirs: string[],
warnings?: string[],
): Promise<void> {
if (!existsSync(/*turbopackIgnore: true*/ sourcePath)) return
for (const dir of dirs) {
const targetPath = path.join(/*turbopackIgnore: true*/ dir, fileName)
const sourceKey = process.platform === 'win32' ? sourcePath.toLowerCase() : sourcePath
const targetKey = process.platform === 'win32' ? targetPath.toLowerCase() : targetPath
if (sourceKey === targetKey) continue
try {
await fs.mkdir(/*turbopackIgnore: true*/ dir, { recursive: true })
await fs.copyFile(/*turbopackIgnore: true*/ sourcePath, /*turbopackIgnore: true*/ targetPath)
} catch (err) {
warnings?.push(`Mirror copy failed for ${fileName}: ${(err as Error).message}`)
}
}
}
export function buildItemsBaseEnrichmentUpdate(params: {
id: number
dims: { x: number; y: number; z: number }
cansiton: boolean
canlayon: boolean
canstandon: boolean
allowStack: string
interactionType: string
}): { sql: string; values: Array<number | string> } {
return {
sql: `
UPDATE items_base
SET width = ?, length = ?, stack_height = ?,
allow_sit = ?,
allow_lay = ?,
allow_walk = ?,
allow_stack = ?,
interaction_type = ?
WHERE id = ?
`,
values: [
params.dims.x,
params.dims.y,
params.dims.z,
params.cansiton ? '1' : '0',
params.canlayon ? '1' : '0',
params.canstandon ? '1' : '0',
params.allowStack,
params.interactionType,
params.id,
],
}
}
/** Get or create the root "Imported Furniture" parent page (no items directly on this page). */
export async function getOrCreateImportedParentPage(): Promise<number> {
const existing = await prisma.catalogPages.findFirst({
where: { captionSave: IMPORTED_PAGE_CAPTION_SAVE },
select: { id: true },
})
if (existing) return existing.id
// Use raw SQL to avoid ENUM mismatch (page_layout, visible, enabled)
// All string columns must be '' (not NULL) or the Java emulator may skip the page
await prisma.$executeRaw`
INSERT INTO catalog_pages (caption_save, caption, page_layout, parent_id, min_rank, order_num, icon_image, enabled, visible, includes, page_headline, page_teaser, page_special, page_text1, page_text2, page_text_details, page_text_teaser)
VALUES (${IMPORTED_PAGE_CAPTION_SAVE}, ${IMPORTED_PAGE_CAPTION}, 'default_3x3', -1, '1', 9999, 263, '1', '1', '', '', '', '', '', '', '', '')
`
const created = await prisma.catalogPages.findFirst({
where: { captionSave: IMPORTED_PAGE_CAPTION_SAVE },
select: { id: true },
})
if (!created) {
throw new Error('Failed to create "Imported Furniture" parent catalog page')
}
return created.id
}
/** Determine the catalog category for a furniture item based on its classname and type. */
export function classifyFurni(classname: string, itemType: string): string {
for (const rule of CLASSNAME_RULES) {
if (rule.test(classname, itemType)) return rule.cat
}
return 'other'
}
/**
* Auto-detect catalog price from classname.
* - CF_750_xxx → cost_credits = 750 (credit furni = the credit value it represents)
* - exe_50, exe_100, exe_200 etc. → cost_credits based on exchange value
* - rare_xxx → cost_credits = 25 (rares are expensive)
* - wf_xxx → cost_credits = 5 (wired components)
* - everything else → cost_credits = 3 (default)
*
* Returns { credits, points, pointsType }
*/
export function autoPriceFurni(classname: string): {
credits: number
points: number
pointsType: number
} {
// CF_<number>_xxx → credit furniture, price = the credit value
const cfMatch = classname.match(/^CF_(\d+)/i)
if (cfMatch) {
return { credits: parseInt(cfMatch[1], 10), points: 0, pointsType: 0 }
}
// exe_<number> → exchange items (diamonds/duckets)
const exeMatch = classname.match(/^exe_(\d+)/i)
if (exeMatch) {
return { credits: parseInt(exeMatch[1], 10), points: 0, pointsType: 0 }
}
// Rares
if (/^rare_/i.test(classname)) {
return { credits: 25, points: 0, pointsType: 0 }
}
// Wired
if (/^wf_/i.test(classname)) {
return { credits: 5, points: 0, pointsType: 0 }
}
// NFT items
if (/^nft_/i.test(classname)) {
return { credits: 10, points: 0, pointsType: 0 }
}
// Trophies
if (/^(trophy|gold_|silver_|bronze_)/i.test(classname)) {
return { credits: 10, points: 0, pointsType: 0 }
}
// Default
return { credits: 3, points: 0, pointsType: 0 }
}
/**
* Allocate the next `catalog_items.id` and run the INSERT through a
* process-level async mutex so concurrent imports in the same Node process
* cannot race on MAX(id). A DB-level lock would require pinning a single
* Prisma connection for the whole operation; the in-process mutex is enough
* for the single-server deployment and avoids pool-connection gymnastics.
*/
let catalogIdAllocChain: Promise<unknown> = Promise.resolve()
export async function allocateCatalogItemId<T>(
insertFn: (nextId: number) => Promise<T>,
): Promise<T> {
const prev = catalogIdAllocChain
let settle!: () => void
catalogIdAllocChain = new Promise<void>((r) => {
settle = r
})
await prev.catch(() => {})
try {
const maxIdResult = await prisma.$queryRaw<Array<{ maxId: number | bigint | null }>>`
SELECT MAX(id) as maxId FROM catalog_items
`
const nextId = Number(maxIdResult[0]?.maxId ?? 0) + 1
return await insertFn(nextId)
} finally {
settle()
}
}
/** Get or create a category sub-page under the imported parent page. */
export async function getOrCreateCategoryPage(
classname: string,
itemType: string,
): Promise<number> {
const parentId = await getOrCreateImportedParentPage()
const catKey = classifyFurni(classname, itemType)
const captionSave = `imp_${catKey}`
const catInfo = CATEGORY_PAGE[catKey] || CATEGORY_PAGE.other
const existing = await prisma.catalogPages.findFirst({
where: { captionSave, parentId },
select: { id: true },
})
if (existing) return existing.id
await prisma.$executeRaw`
INSERT INTO catalog_pages (caption_save, caption, page_layout, parent_id, min_rank, order_num, icon_image, enabled, visible, includes, page_headline, page_teaser, page_special, page_text1, page_text2, page_text_details, page_text_teaser)
VALUES (${captionSave}, ${catInfo.label}, 'default_3x3', ${parentId}, '1', ${catInfo.order}, ${catInfo.icon}, '1', '1', '', '', '', '', '', '', '', '')
`
const created = await prisma.catalogPages.findFirst({
where: { captionSave, parentId },
select: { id: true },
})
if (!created) {
throw new Error(`Failed to create catalog sub-page "${captionSave}" under parent ${parentId}`)
}
return created.id
}
// ── Import Single Furni ─────────────────────────────────────────────
export async function importSingleFurni(params: {
id: number
classname: string
name: string
description: string
type: string
revision: number
category: string
skipFurniDataWrite?: boolean
onProgress?: (status: string) => void
}): Promise<ImportSingleResult> {
const {
id: originalId,
classname,
name,
description,
type,
revision,
category,
skipFurniDataWrite,
onProgress,
} = params
const warnings: string[] = []
// Check if already exists by classname OR by spriteId (primary key collision).
const existsByName = await prisma.itemsBase.findFirst({
where: { itemName: classname },
select: { id: true },
})
if (existsByName) {
return {
ok: false,
warnings,
error: `Item "${classname}" already exists (id: ${existsByName.id})`,
}
}
const existsById = await prisma.itemsBase.findUnique({
where: { id: originalId },
select: { itemName: true },
})
if (existsById) {
return {
ok: false,
warnings,
error: `spriteId ${originalId} already used by "${existsById.itemName}"`,
}
}
const itemType = type === 'wallitem' ? 'i' : 's'
const spriteId = originalId
// ── Cross-reference with habbo.it furnidata for enrichment ────────
let habboIt: HabboItFurniEntry | null = null
try {
habboIt = await lookupHabboIt(classname)
if (habboIt) {
warnings.push(`Enriched from habbo.it: "${habboIt.name}"`)
}
} catch {
// Non-blocking: habbo.it enrichment is optional
}
// Best values: use habbo.it furnidata for enrichment
const bestName = name || habboIt?.name || classname
const bestDescription = description || habboIt?.description || ''
const bestRevision = revision || habboIt?.revision || 0
const bestCategory = category || habboIt?.category || 'unknown'
// ── Insert into DB with enriched data ──────────────────────────────
onProgress?.('writing_db')
try {
// NOTE: live Arcturus `items_base` has no description/revision/rare columns —
// those metadata fields are persisted in FurnitureData.json (see entry below),
// not in the DB. Inserting them here throws "Unknown column 'description'".
await prisma.$executeRaw`
INSERT INTO items_base (id, sprite_id, public_name, item_name, type)
VALUES (${spriteId}, ${spriteId}, ${bestName}, ${classname}, ${itemType})
`
} catch (err) {
return { ok: false, warnings, error: `DB insert failed: ${(err as Error).message}` }
}
// ── Derive file-safe names ────────────────────────────────────────
const rev = revision || 0
const assetTargets = await getFurniAssetWriteTargets()
const { swfDir, iconDir, nitroDir } = assetTargets
const starIdx = classname.indexOf('*')
const hasColor = starIdx !== -1
const baseClassname = hasColor ? classname.substring(0, starIdx) : classname
const _colorNum = hasColor ? classname.substring(starIdx + 1) : '2'
const iconFileName = classname.replace(/\*/g, '_')
const safeSwfName = baseClassname
const safeNitroName = baseClassname
const swfPath = path.join(/*turbopackIgnore: true*/ swfDir, `${safeSwfName}.swf`)
const nitroPath = path.join(/*turbopackIgnore: true*/ nitroDir, `${safeNitroName}.nitro`)
const iconPath = path.join(/*turbopackIgnore: true*/ iconDir, `${iconFileName}_icon.png`)
const mirrorSwfDirs = assetTargets.mirrorDirs.map((d) => d.swfDir)
const mirrorIconDirs = assetTargets.mirrorDirs.map((d) => d.iconDir)
const mirrorNitroDirs = assetTargets.mirrorDirs.map((d) => d.nitroDir)
// ── Download assets in parallel (with retry + validation) ────────
// Icon strategy matches Nitro's URL template:
// `${hof.furni.url}/icons/%libname%%param%_icon.png`
// For color variants (classname has `*`): try color-specific first,
// fall back to base-classname icon (Nitro reuses base for all colors
// when no color-specific icon exists).
onProgress?.('downloading')
const iconColorAware = classname.replace(/\*/g, '_')
const iconUrl = hasColor
? `https://images.habbo.com/dcr/hof_furni/${rev}/${encodeURIComponent(iconColorAware)}_icon.png`
: `https://images.habbo.com/dcr/hof_furni/${rev}/${encodeURIComponent(baseClassname)}_icon.png`
const downloadResults = await Promise.allSettled([
downloadFile(iconUrl, iconPath, { validate: 'png' }),
downloadFile(
`https://images.habbo.com/dcr/hof_furni/${rev}/${encodeURIComponent(safeSwfName)}.swf`,
swfPath,
{ validate: 'swf' },
),
])
let iconOk = downloadResults[0].status === 'fulfilled' && downloadResults[0].value.ok
const swfOk = downloadResults[1].status === 'fulfilled' && downloadResults[1].value.ok
// If color-specific icon 404'd, try the base-classname icon.
if (!iconOk && hasColor) {
const dl = await downloadFile(
`https://images.habbo.com/dcr/hof_furni/${rev}/${encodeURIComponent(baseClassname)}_icon.png`,
iconPath,
{ validate: 'png' },
)
if (dl.ok) iconOk = true
}
// ── Icon fallback: extract from SWF if download failed ────────────
if (!iconOk && (swfOk || existsSync(/*turbopackIgnore: true*/ swfPath))) {
try {
const swfBuf = await fs.readFile(/*turbopackIgnore: true*/ swfPath)
const iconPng = extractIconFromSwf(swfBuf, baseClassname)
if (iconPng) {
await fs.writeFile(/*turbopackIgnore: true*/ iconPath, iconPng)
warnings.push('Icon extracted from SWF (external download failed)')
} else {
warnings.push('Icon download failed and SWF icon extraction returned nothing')
}
} catch {
warnings.push('Icon download failed and SWF icon extraction also failed')
}
} else if (!iconOk) {
warnings.push('Icon image download failed')
}
if (!swfOk) warnings.push('SWF download failed')
// ── Convert SWF to Nitro ──────────────────────────────────────────
onProgress?.('converting')
let conversionResult: ConversionResult | null = null
if (existsSync(/*turbopackIgnore: true*/ nitroPath)) {
warnings.push('Nitro file already exists (shared across color variants)')
} else {
try {
if (existsSync(/*turbopackIgnore: true*/ swfPath)) {
const swfBuffer = await fs.readFile(/*turbopackIgnore: true*/ swfPath)
conversionResult = convertSwfToNitro(swfBuffer, baseClassname)
await fs.writeFile(/*turbopackIgnore: true*/ nitroPath, conversionResult.bundle)
if (conversionResult.warnings.length > 0) {
warnings.push(...conversionResult.warnings)
}
} else {
warnings.push('SWF not available for Nitro conversion')
}
} catch (err) {
console.warn('[import-furni] SWF to Nitro conversion failed:', (err as Error).message)
warnings.push(`SWF to Nitro conversion failed: ${(err as Error).message}`)
}
}
// ── Rollback: if conversion failed AND no .nitro exists, remove DB record ──
if (!existsSync(/*turbopackIgnore: true*/ nitroPath) && !conversionResult) {
try {
await prisma.$executeRaw`DELETE FROM items_base WHERE id = ${spriteId} AND item_name = ${classname}`
} catch {
/* best effort cleanup */
}
return { ok: false, warnings, error: 'Import rolled back: no .nitro file could be created' }
}
await Promise.all([
mirrorFileToDirs(swfPath, `${safeSwfName}.swf`, mirrorSwfDirs, warnings),
mirrorFileToDirs(iconPath, `${iconFileName}_icon.png`, mirrorIconDirs, warnings),
mirrorFileToDirs(nitroPath, `${safeNitroName}.nitro`, mirrorNitroDirs, warnings),
])
// ── Build enriched FurnitureData entry ────────────────────────────
const dims =
conversionResult?.dimensions ||
(habboIt ? { x: habboIt.xdim, y: habboIt.ydim, z: 0 } : { x: 1, y: 1, z: 0 })
const meta = conversionResult?.metadata ?? null
// Merge interaction flags: prefer .nitro extraction, fallback to habbo.it
const cansiton = meta?.cansiton ?? habboIt?.cansiton ?? false
const canlayon = meta?.canlayon ?? habboIt?.canlayon ?? false
const canstandon = meta?.canstandon ?? habboIt?.canstandon ?? false
// Derive interaction_type. Priority:
// 1. Wired LOGIC items (wf_act_/wf_trg_/wf_cnd_/wf_slc_/wf_xtra_/wf_var_)
// — use the FULL classname so Arcturus can route to the specific
// InteractionWired* handler. Collapsing to wf_act/wf_trg/wf_cnd
// breaks every wired because the emulator looks up the exact type.
// 2. .nitro logicType match for known mechanics.
// 3. Non-logic wired (wf_firegate, wf_pressureplate, wf_pyramid,
// wf_floor_switch, …) that didn't match step 2 — use the full
// classname as a safe fallback instead of 'default'.
// 4. Final fallback: default.
const logicType = meta?.logicType || ''
let interactionType = 'default'
if (/^wf_(act|trg|cnd|slc|xtra|var)_/.test(classname)) {
interactionType = classname
} else if (logicType.includes('multistate')) interactionType = 'default'
else if (logicType.includes('gate')) interactionType = 'gate'
else if (logicType.includes('teleport')) interactionType = 'teleport'
else if (logicType.includes('roller')) interactionType = 'roller'
else if (logicType.includes('dice')) interactionType = 'dice'
else if (logicType.includes('vendingmachine')) interactionType = 'vendingmachine'
else if (logicType.includes('firework')) interactionType = 'fireworks'
else if (logicType.includes('trophy')) interactionType = 'trophy'
else if (logicType.includes('exchange')) interactionType = 'exchange'
else if (logicType.includes('habbowheel')) interactionType = 'habbowheel'
else if (logicType.includes('soundmachine')) interactionType = 'jukebox'
if (interactionType === 'default' && /^wf_/.test(classname)) {
interactionType = classname
warnings.push(`Wired fallback: using full classname "${classname}" as interaction_type`)
}
// allow_stack: if it has height and you can put things on it
const allowStack = dims.z > 0 ? '1' : '0'
// Update items_base with ALL enriched data
try {
const update = buildItemsBaseEnrichmentUpdate({
id: spriteId,
dims,
cansiton,
canlayon,
canstandon,
allowStack,
interactionType,
})
await prisma.$executeRawUnsafe(update.sql, ...update.values)
} catch (err) {
warnings.push(`items_base dimension update failed: ${(err as Error).message}`)
}
// Build enriched FurnitureData.json entry using best available data
const furniEntry = buildFurniEntry({
id: spriteId,
classname,
revision: bestRevision,
category: bestCategory,
name: bestName,
description: bestDescription,
spriteId,
dims,
metadata: meta,
habboIt,
})
// ── Add to FurnitureData.json (or defer for batch) ────────────────
if (skipFurniDataWrite) {
// Batch mode: return the entry so the caller writes once at the end
} else {
try {
await appendFurniEntry(furniEntry, itemType)
} catch (err) {
console.warn('[import-furni] Failed to update FurnitureData.json:', (err as Error).message)
warnings.push('FurnitureData.json update failed')
}
}
// ── Create catalog entry (with duplicate check + category sub-page) ──
let catalogItemId: number | null = null
try {
const itemIdStr = String(spriteId)
// Check if this item already exists anywhere in the catalog
const existingCatalog = await prisma.$queryRaw<Array<{ id: number; page_id: string }>>`
SELECT id, page_id FROM catalog_items WHERE item_ids = ${itemIdStr} LIMIT 1
`
if (existingCatalog.length > 0) {
catalogItemId = existingCatalog[0].id
warnings.push(
`Item already in catalog (catalog_item #${catalogItemId}, page ${existingCatalog[0].page_id}) — skipped duplicate`,
)
} else {
// Get or create the right category sub-page based on classname
const categoryPageId = await getOrCreateCategoryPage(classname, itemType)
const pageIdStr = String(categoryPageId)
const price = autoPriceFurni(classname)
// catalog_items.id has no AUTO_INCREMENT — serialize ID allocation
// across concurrent imports via a MySQL named lock to avoid MAX(id)
// TOCTOU that would cause duplicate-key failures.
catalogItemId = await allocateCatalogItemId(async (nextCatalogId) => {
await prisma.$executeRaw`
INSERT INTO catalog_items (id, page_id, item_ids, catalog_name, cost_credits, cost_points, points_type, amount, order_number, offer_id, extradata)
VALUES (${nextCatalogId}, ${pageIdStr}, ${itemIdStr}, ${classname}, ${price.credits}, ${price.points}, ${price.pointsType}, 1, 1, '-1', '')
`
return nextCatalogId
})
}
} catch (err) {
console.warn('[import-furni] Failed to create catalog entry:', (err as Error).message)
warnings.push('Catalog entry creation failed')
}
return {
ok: true,
itemId: spriteId,
catalogItemId,
dimensions: dims,
spriteCount: conversionResult?.spriteCount,
warnings,
furniDataEntry: skipFurniDataWrite ? { entry: furniEntry, itemType } : undefined,
}
}
// ── Delete helpers ───────────────────────────────────────────────────
/** Delete a single imported item: DB records, FurnitureData.json entry, and asset files. */
export async function deleteImportedItem(classname: string): Promise<{
spriteId: number
deletedFiles: string[]
errors: string[]
}> {
const errors: string[] = []
// Look up item in DB
const item = await prisma.itemsBase.findFirst({
where: { itemName: classname },
select: { id: true, type: true },
})
if (!item) {
throw new Error(`Item "${classname}" not found in database`)
}
const spriteId = item.id
const starIdx = classname.indexOf('*')
const assetTargets = await getFurniAssetWriteTargets()
const { swfDir, iconDir, nitroDir } = assetTargets
const baseClassname = starIdx !== -1 ? classname.substring(0, starIdx) : classname
const iconFileName = classname.replace(/\*/g, '_')
// 1. Delete catalog_items
try {
const itemIdStr = String(spriteId)
await prisma.$executeRaw`DELETE FROM catalog_items WHERE item_ids = ${itemIdStr} AND catalog_name = ${classname}`
} catch (err) {
errors.push(`catalog_items: ${(err as Error).message}`)
}
// 2. Delete items_base
try {
await prisma.$executeRaw`DELETE FROM items_base WHERE id = ${spriteId} AND item_name = ${classname}`
} catch (err) {
errors.push(`items_base: ${(err as Error).message}`)
}
// 3. Remove from FurnitureData.json
try {
await removeFurniEntry(classname)
} catch (err) {
errors.push(`FurnitureData.json: ${(err as Error).message}`)
}
// 4. Delete files (.nitro, .swf, icon)
const filesToDelete = [
path.join(/*turbopackIgnore: true*/ nitroDir, `${baseClassname}.nitro`),
path.join(/*turbopackIgnore: true*/ swfDir, `${baseClassname}.swf`),
path.join(/*turbopackIgnore: true*/ iconDir, `${iconFileName}_icon.png`),
...assetTargets.mirrorDirs.flatMap((dirs) => [
path.join(/*turbopackIgnore: true*/ dirs.nitroDir, `${baseClassname}.nitro`),
path.join(/*turbopackIgnore: true*/ dirs.swfDir, `${baseClassname}.swf`),
path.join(/*turbopackIgnore: true*/ dirs.iconDir, `${iconFileName}_icon.png`),
]),
]
const deletedFiles: string[] = []
for (const filePath of filesToDelete) {
if (existsSync(/*turbopackIgnore: true*/ filePath)) {
try {
await fs.unlink(/*turbopackIgnore: true*/ filePath)
deletedFiles.push(path.basename(filePath))
} catch (err) {
errors.push(`${path.basename(filePath)}: ${(err as Error).message}`)
}
}
}
return { spriteId, deletedFiles, errors }
}
/** Clean up SWF files that have already been converted to Nitro. */
export async function cleanupConvertedSwfs(): Promise<{
deleted: number
total: number
remaining: number
errors: string[]
}> {
const { swfDir, nitroDir } = await getFurniAssetDirs()
if (!existsSync(swfDir)) {
return { deleted: 0, total: 0, remaining: 0, errors: [] }
}
const swfFiles = (await fs.readdir(swfDir)).filter((f) => f.endsWith('.swf'))
let deleted = 0
const errors: string[] = []
for (const swfFile of swfFiles) {
const cn = swfFile.replace('.swf', '')
const nitroFilePath = path.join(/*turbopackIgnore: true*/ nitroDir, `${cn}.nitro`)
if (existsSync(/*turbopackIgnore: true*/ nitroFilePath)) {
try {
await fs.unlink(path.join(/*turbopackIgnore: true*/ swfDir, swfFile))
deleted++
} catch (err) {
errors.push(`Failed to delete ${swfFile}: ${(err as Error).message}`)
}
}
}
return {
deleted,
total: swfFiles.length,
remaining: swfFiles.length - deleted,
errors,
}
}
+424
View File
@@ -0,0 +1,424 @@
/**
* Furni Repair Service
*
* Audits and repairs missing assets (.nitro bundles + icon PNGs) for items
* that were imported but whose files were not downloaded successfully
* (network errors, habbo.com 404s, classname URL-encoding issues, etc.).
*
* Reuses the download/convert helpers from furni-import.ts.
*/
import { existsSync, promises as fs } from 'node:fs'
import path from 'node:path'
import { prisma } from '@/lib/prisma'
import { getFurniAssetDirs } from '@/lib/services/furni-asset-dirs'
import { appendFurniEntry, buildFurniEntry } from '@/lib/services/furni-data'
import { downloadFile, ensureDirectories } from '@/lib/services/furni-import'
import { getHabboItFurnidata, lookupHabboIt } from '@/lib/services/habbo-furnidata-cache'
import { convertSwfToNitro, extractIconFromSwf } from '@/lib/services/swf-to-nitro'
// ── Types ────────────────────────────────────────────────────────────
export type MissingKind = 'nitro' | 'icon' | 'both'
export interface MissingItem {
id: number
classname: string
baseClassname: string
revision: number
type: 's' | 'i'
publicName: string
hasSwf: boolean
hasIcon: boolean
hasNitro: boolean
missing: MissingKind
/** Pre-flagged as impossible to repair from habbo.com (whitespace, invalid chars). */
unrepairable: boolean
}
export interface AuditSummary {
total: number
missingBoth: number
missingNitro: number
missingIcon: number
unrepairable: number
items: MissingItem[]
}
export interface RepairResult {
classname: string
ok: boolean
iconRepaired: boolean
nitroRepaired: boolean
warnings: string[]
error?: string
/** True when habbo.com will never serve this asset (bad classname or 404 across all known revisions). */
unrepairable?: boolean
}
/**
* Checks whether a classname has a shape habbo.com can never serve.
* Currently: whitespace or control chars (habbo.com only uses [A-Za-z0-9_*.-]).
*/
export function isStructurallyImpossible(classname: string): boolean {
// Whitespace or ASCII control chars — habbo.com URL would 404.
if (/\s/.test(classname)) return true
for (let i = 0; i < classname.length; i++) {
const code = classname.charCodeAt(i)
if (code < 0x20) return true
}
return false
}
// ── Audit ────────────────────────────────────────────────────────────
/**
* Scans every row in `items_base` and reports which ones are missing
* their `.nitro` bundle and/or icon PNG on disk.
*/
export async function auditImportedAssets(): Promise<AuditSummary> {
const { swfDir, iconDir, nitroDir } = await getFurniAssetDirs()
const rows = await prisma.itemsBase.findMany({
select: {
id: true,
itemName: true,
publicName: true,
type: true,
},
})
const items: MissingItem[] = []
let missingBoth = 0
let missingNitro = 0
let missingIcon = 0
let unrepairable = 0
for (const row of rows) {
const classname = row.itemName
if (!classname) continue
const starIdx = classname.indexOf('*')
const baseClassname = starIdx !== -1 ? classname.substring(0, starIdx) : classname
const iconFileName = classname.replace(/\*/g, '_')
const nitroPath = path.join(/*turbopackIgnore: true*/ nitroDir, `${baseClassname}.nitro`)
const swfPath = path.join(/*turbopackIgnore: true*/ swfDir, `${baseClassname}.swf`)
const iconPath = path.join(/*turbopackIgnore: true*/ iconDir, `${iconFileName}_icon.png`)
const hasNitro = existsSync(/*turbopackIgnore: true*/ nitroPath)
const hasIcon = existsSync(/*turbopackIgnore: true*/ iconPath)
const hasSwf = existsSync(/*turbopackIgnore: true*/ swfPath)
if (hasNitro && hasIcon) continue
let missing: MissingKind
if (!hasNitro && !hasIcon) {
missing = 'both'
missingBoth++
} else if (!hasNitro) {
missing = 'nitro'
missingNitro++
} else {
missing = 'icon'
missingIcon++
}
const unrep = isStructurallyImpossible(classname)
if (unrep) unrepairable++
items.push({
id: row.id,
classname,
baseClassname,
revision: 0,
type: (row.type === 'i' ? 'i' : 's') as 's' | 'i',
publicName: row.publicName ?? classname,
hasSwf,
hasIcon,
hasNitro,
missing,
unrepairable: unrep,
})
}
// Stable sort: missing both first, then nitro, then icon; alphabetical within group.
// Unrepairable items sink to the bottom of their group so fixable ones surface first.
const order: Record<MissingKind, number> = { both: 0, nitro: 1, icon: 2 }
items.sort(
(a, b) =>
order[a.missing] - order[b.missing] ||
Number(a.unrepairable) - Number(b.unrepairable) ||
a.classname.localeCompare(b.classname),
)
return { total: rows.length, missingBoth, missingNitro, missingIcon, unrepairable, items }
}
// ── Repair ───────────────────────────────────────────────────────────
/**
* Attempts to restore missing assets for a single item.
*
* Strategy:
* 1. Resolve candidate revisions: habbo.it furnidata, DB.revision, DB.spriteId.
* 2. If `.nitro` is missing and SWF is missing: try each revision → download SWF.
* 3. If SWF now exists: convert → write `.nitro`.
* 4. If icon PNG is missing: download from habbo.com; on failure, extract from SWF.
* 5. If `.nitro` was (re)generated, update items_base dims / interaction flags.
*/
export async function repairItem(classname: string): Promise<RepairResult> {
const warnings: string[] = []
await ensureDirectories()
const { swfDir, iconDir, nitroDir } = await getFurniAssetDirs()
const row = await prisma.itemsBase.findFirst({
where: { itemName: classname },
select: {
id: true,
spriteId: true,
type: true,
publicName: true,
},
})
if (!row) {
return {
classname,
ok: false,
iconRepaired: false,
nitroRepaired: false,
warnings,
error: 'Item not found in DB',
}
}
// Fast path: shapes habbo.com can never serve (whitespace, control chars).
// Skip every network call — safe us ~60s of useless 15s-timeout retries.
if (isStructurallyImpossible(classname)) {
return {
classname,
ok: false,
iconRepaired: false,
nitroRepaired: false,
warnings: ['Classname contains whitespace / control chars — habbo.com URL would 404'],
error: 'Unrepairable classname',
unrepairable: true,
}
}
const starIdx = classname.indexOf('*')
const baseClassname = starIdx !== -1 ? classname.substring(0, starIdx) : classname
const iconFileName = classname.replace(/\*/g, '_')
const nitroPath = path.join(/*turbopackIgnore: true*/ nitroDir, `${baseClassname}.nitro`)
const swfPath = path.join(/*turbopackIgnore: true*/ swfDir, `${baseClassname}.swf`)
const iconPath = path.join(/*turbopackIgnore: true*/ iconDir, `${iconFileName}_icon.png`)
const needsNitro = !existsSync(/*turbopackIgnore: true*/ nitroPath)
const needsIcon = !existsSync(/*turbopackIgnore: true*/ iconPath)
if (!needsNitro && !needsIcon) {
return {
classname,
ok: true,
iconRepaired: false,
nitroRepaired: false,
warnings: ['Nothing to repair'],
}
}
// ── Resolve candidate revisions ─────────────────────────────────────
// Priority order:
// 1. habbo.it furnidata — authoritative for current live assets
// 2. items_base.revision (what we originally recorded at import)
// 3. items_base.spriteId (some emulators reuse sprite id as revision)
const revsToTry: number[] = []
const pushRev = (r?: number | null) => {
if (r && r > 0 && !revsToTry.includes(r)) revsToTry.push(r)
}
try {
const furnidata = await getHabboItFurnidata()
const fd = furnidata.get(classname) || furnidata.get(baseClassname)
if (fd) pushRev(fd.revision)
} catch {
// non-fatal
}
pushRev(row.spriteId)
// Fallback: revision 0 is valid for many habbo.com paths
if (revsToTry.length === 0) revsToTry.push(0)
// ── Ensure SWF exists ───────────────────────────────────────────────
// We also download the SWF when only the icon is missing, so we can
// fall back to SWF-embedded icon extraction when habbo.com 404s the PNG.
let swfOk = existsSync(swfPath)
const wantSwf = needsNitro || needsIcon
if (wantSwf && !swfOk) {
for (const tryRev of revsToTry) {
const dl = await downloadFile(
`https://images.habbo.com/dcr/hof_furni/${tryRev}/${encodeURIComponent(baseClassname)}.swf`,
swfPath,
{ validate: 'swf' },
)
if (dl.ok) {
swfOk = true
break
}
}
if (!swfOk && needsNitro) {
warnings.push(`SWF not found on habbo.com (tried revisions: ${revsToTry.join(', ')})`)
}
}
// ── Regenerate .nitro from SWF ──────────────────────────────────────
let nitroRepaired = false
if (needsNitro && swfOk) {
try {
const swfBuffer = await fs.readFile(swfPath)
const conversion = convertSwfToNitro(swfBuffer, baseClassname)
await fs.writeFile(nitroPath, conversion.bundle)
nitroRepaired = true
// Sync dims + interaction flags back to items_base (best effort)
const dims = conversion.dimensions || { x: 1, y: 1, z: 0 }
const meta = conversion.metadata || null
const cansiton = meta?.cansiton ?? false
const canlayon = meta?.canlayon ?? false
const canstandon = meta?.canstandon ?? false
const allowStack = (dims.z ?? 0) > 0 ? '1' : '0'
try {
await prisma.$executeRaw`
UPDATE items_base
SET width = ${dims.x}, length = ${dims.y}, stack_height = ${dims.z ?? 0},
allow_sit = ${cansiton ? '1' : '0'},
allow_lay = ${canlayon ? '1' : '0'},
allow_walk = ${canstandon ? '1' : '0'},
allow_stack = ${allowStack},
canlayon = ${canlayon ? '1' : '0'}
WHERE id = ${row.id}
`
} catch (err) {
warnings.push(`items_base dim update failed: ${(err as Error).message}`)
}
// Keep FurnitureData.json in sync with the regenerated metadata so the
// Nitro client sees up-to-date dims/flags without a full re-import.
try {
let habboIt = null
try {
habboIt = await lookupHabboIt(classname)
} catch {
/* optional */
}
const entry = buildFurniEntry({
id: row.id,
classname,
revision: habboIt?.revision ?? 0,
category: habboIt?.category || 'unknown',
name: row.publicName || habboIt?.name || classname,
description: habboIt?.description || '',
spriteId: row.spriteId,
dims: { x: dims.x, y: dims.y, z: dims.z ?? 0 },
metadata: meta,
habboIt,
})
await appendFurniEntry(entry, row.type)
} catch (err) {
warnings.push(`FurnitureData.json sync failed: ${(err as Error).message}`)
}
} catch (err) {
warnings.push(`SWF → Nitro conversion failed: ${(err as Error).message}`)
}
}
// ── Repair icon (Nitro-style cascade) ───────────────────────────────
//
// Nitro client icon URL template: `${hof.furni.url}/icons/%libname%%param%_icon.png`
// - %libname% = base classname (before `*`)
// - %param% = `_N` for color variants, empty otherwise
//
// So for `bonusrare18_1*0` habbo.com can host either:
// - bonusrare18_1_0_icon.png (color-specific) ← try first
// - bonusrare18_1_icon.png (base fallback, Nitro reuses for all colors)
//
// Cascade per revision:
// 1. color-specific URL (only if classname has `*`)
// 2. base URL (always)
// After exhausting the network: extract from SWF (embedded icon).
let iconRepaired = false
let iconSource: 'habbo-color' | 'habbo-base' | 'swf' | null = null
if (needsIcon) {
const hasColor = starIdx !== -1
const colorAwareName = classname.replace(/\*/g, '_')
for (const tryRev of revsToTry) {
// 1. color-specific icon (authoritative for color variants)
if (hasColor) {
const dl = await downloadFile(
`https://images.habbo.com/dcr/hof_furni/${tryRev}/${encodeURIComponent(colorAwareName)}_icon.png`,
iconPath,
{ validate: 'png' },
)
if (dl.ok) {
iconRepaired = true
iconSource = 'habbo-color'
break
}
}
// 2. base icon (also used by Nitro when no color-specific one exists)
const dlBase = await downloadFile(
`https://images.habbo.com/dcr/hof_furni/${tryRev}/${encodeURIComponent(baseClassname)}_icon.png`,
iconPath,
{ validate: 'png' },
)
if (dlBase.ok) {
iconRepaired = true
iconSource = 'habbo-base'
break
}
}
// 3. Fallback: extract from SWF (works offline + covers items with no public PNG)
if (!iconRepaired && existsSync(swfPath)) {
try {
const swfBuf = await fs.readFile(swfPath)
const iconPng = extractIconFromSwf(swfBuf, baseClassname)
if (iconPng) {
await fs.writeFile(iconPath, iconPng)
iconRepaired = true
iconSource = 'swf'
warnings.push('Icon extracted from SWF (no PNG on habbo.com)')
}
} catch (err) {
warnings.push(`SWF icon extraction failed: ${(err as Error).message}`)
}
}
if (!iconRepaired) {
warnings.push(
existsSync(swfPath)
? 'Icon not on habbo.com and SWF has no embedded icon'
: `Icon not found on habbo.com (tried revisions: ${revsToTry.join(', ')})`,
)
} else if (iconSource === 'habbo-base' && hasColor) {
warnings.push('Used base-classname icon (color-specific not published)')
}
}
const ok = (!needsNitro || nitroRepaired) && (!needsIcon || iconRepaired)
// Flag items for which every external revision returned 404 → no point retrying later.
// Criteria: we tried to download something and nothing (not even the SWF extraction)
// produced a result.
const unrepairable =
!ok &&
!iconRepaired &&
!nitroRepaired &&
// At least one outbound call was attempted and failed with 404-style errors
(warnings.some((w) => w.startsWith('SWF not found on habbo.com')) ||
warnings.some((w) => w.startsWith('Icon not found on habbo.com')))
return { classname, ok, iconRepaired, nitroRepaired, warnings, unrepairable }
}
+161
View File
@@ -0,0 +1,161 @@
/**
* Badge search — powered by official Habbo external texts.
*
* Uses habbo.it/gamedata/external_flash_texts as the single source.
* Badge images from images.habbo.com/c_images/album1584/.
* No third-party APIs (habboassets.com) needed.
*/
export type { HabboAssetBadge } from '@/types/furni'
import type { HabboAssetBadge } from '@/types/furni'
const EXTERNAL_TEXTS_URL = 'https://www.habbo.it/gamedata/external_flash_texts/1'
const BADGE_IMAGE_BASE = 'https://images.habbo.com/c_images/album1584'
const CACHE_TTL = 30 * 60 * 1000 // 30 minutes
interface BadgeEntry {
code: string
name: string
description: string
}
let badgeCache: BadgeEntry[] | null = null
let cacheTimestamp = 0
let loadingPromise: Promise<BadgeEntry[]> | null = null
/**
* Fetch and parse all badges from official Habbo external texts.
* Cached in memory for 30 minutes.
*/
async function loadBadges(): Promise<BadgeEntry[]> {
const now = Date.now()
if (badgeCache && now - cacheTimestamp < CACHE_TTL) return badgeCache
if (loadingPromise) return loadingPromise
loadingPromise = (async () => {
try {
if (!EXTERNAL_TEXTS_URL) {
console.warn('[badges] HABBO_EXTERNAL_TEXTS_URL not configured')
return badgeCache || []
}
const res = await fetch(EXTERNAL_TEXTS_URL, {
signal: AbortSignal.timeout(20000),
})
if (!res.ok) {
console.warn(`[badges] Fetch failed: ${res.status}`)
return badgeCache || []
}
const text = await res.text()
const lines = text.split('\n')
// Parse key=value format, extract badge_name_CODE and badge_desc_CODE
const nameMap = new Map<string, string>()
const descMap = new Map<string, string>()
for (const line of lines) {
const eqIdx = line.indexOf('=')
if (eqIdx === -1) continue
const key = line.substring(0, eqIdx).trim()
const value = line.substring(eqIdx + 1).trim()
// Format: badge_name_CODE or CODE_badge_name
let match = key.match(/^badge_name_(.+)$/)
if (match) {
nameMap.set(match[1], value)
continue
}
match = key.match(/^(.+)_badge_name$/)
if (match) {
nameMap.set(match[1], value)
continue
}
match = key.match(/^badge_desc_(.+)$/)
if (match) {
descMap.set(match[1], value)
continue
}
match = key.match(/^(.+)_badge_desc$/)
if (match) {
descMap.set(match[1], value)
}
}
// Merge into badge entries
const allCodes = new Set([...nameMap.keys(), ...descMap.keys()])
const badges: BadgeEntry[] = []
for (const code of allCodes) {
badges.push({
code,
name: nameMap.get(code) || '',
description: descMap.get(code) || '',
})
}
// Sort alphabetically by code
badges.sort((a, b) => a.code.localeCompare(b.code))
console.debug(`[badges] Loaded ${badges.length} badges from external texts`)
badgeCache = badges
cacheTimestamp = now
return badges
} catch (err) {
console.warn('[badges] Load error:', (err as Error).message)
return badgeCache || []
} finally {
loadingPromise = null
}
})()
return loadingPromise
}
/**
* Search badges from official Habbo external texts.
* Returns badges with image URLs from images.habbo.com.
*/
export async function searchBadges(
term: string,
limit = 100,
offset = 0,
_hotel: string | null = null, // kept for API compat, ignored (single source)
): Promise<HabboAssetBadge[]> {
const badges = await loadBadges()
const search = term.toLowerCase().trim()
let filtered = badges
if (search) {
filtered = badges.filter(
(b) =>
b.code.toLowerCase().includes(search) ||
b.name.toLowerCase().includes(search) ||
b.description.toLowerCase().includes(search),
)
} else {
// No search term: return newest (reverse sorted, last entries are newest)
filtered = [...badges].reverse()
}
const page = filtered.slice(offset, offset + limit)
return page.map((b) => ({
id: 0,
code: b.code,
name: b.name,
description: b.description,
hotel: 'it',
url_habbo: `${BADGE_IMAGE_BASE}/${b.code}.gif`,
url_habboassets: `${BADGE_IMAGE_BASE}/${b.code}.gif`, // same source, no fallback needed
}))
}
/** Clear the badge cache */
export function clearBadgeCache(): void {
badgeCache = null
cacheTimestamp = 0
}
+160
View File
@@ -0,0 +1,160 @@
/**
* Furniture search & listing — powered by official Habbo furnidata.
*
* Uses habbo.it/gamedata/furnidata_json as the single source of truth.
* No third-party APIs (habbofurni.com) needed.
*/
import type { FurniListResult, HabboFurniItem } from '@/types/furni'
import { getHabboItFurnidata } from './habbo-furnidata-cache'
export type { FurniListResult, HabboFurniItem }
// ── List / Browse endpoint ────────────────────────────────────────
/**
* List furniture from official Habbo furnidata with optional search and pagination.
* Data comes from habbo.it/gamedata/furnidata_json/1 (cached 30 min).
*/
export async function listFurniture(
options: {
search?: string
page?: number
perPage?: number
/**
* Optional set of already-imported classnames. When provided, non-imported
* items are sorted to the top so the user finds them on page 1 instead of
* paging through 15+ pages of already-imported newest items.
*/
importedSet?: Set<string>
} = {},
): Promise<FurniListResult> {
const empty: FurniListResult = {
items: [],
meta: { currentPage: 1, lastPage: 1, total: 0, perPage: 50 },
}
const perPage = Math.min(options.perPage || 50, 100)
const page = options.page || 1
const search = options.search?.toLowerCase().trim() || ''
const importedSet = options.importedSet
try {
const map = await getHabboItFurnidata()
if (map.size === 0) return empty
// Convert to array and optionally filter by search
let items = Array.from(map.values())
if (search) {
items = items.filter(
(item) =>
item.classname.toLowerCase().includes(search) ||
item.name.toLowerCase().includes(search) ||
item.description.toLowerCase().includes(search),
)
}
if (importedSet) {
// Non-imported first (id desc), then imported (id desc).
items.sort((a, b) => {
const aImp = importedSet.has(a.classname) ? 1 : 0
const bImp = importedSet.has(b.classname) ? 1 : 0
if (aImp !== bImp) return aImp - bImp
return b.id - a.id
})
} else {
// Sort by id descending (newest first)
items.sort((a, b) => b.id - a.id)
}
const total = items.length
const lastPage = Math.max(Math.ceil(total / perPage), 1)
const start = (page - 1) * perPage
const pageItems = items.slice(start, start + perPage)
return {
items: pageItems.map((item) => ({
id: item.id,
classname: item.classname,
name: item.name || item.classname,
description: item.description || '',
type: item.itemType === 'i' ? 'wallitem' : 'flooritem',
revision: item.revision,
category: item.category || item.furniline || '',
})),
meta: { currentPage: page, lastPage, total, perPage },
}
} catch (err) {
console.warn('[furni] List error:', (err as Error).message)
return empty
}
}
// ── Search endpoint ────────────────────────────────────────────────
/**
* Search furniture by query string.
*/
export async function searchFurniture(query: string, perPage = 50): Promise<HabboFurniItem[]> {
const result = await listFurniture({ search: query, perPage })
return result.items
}
// ── Direct lookup by classname ─────────────────────────────────────
/**
* Get a single furniture item by classname.
* Uses the cached habbo.it furnidata.
*/
export async function getFurnitureByClassname(classname: string): Promise<HabboFurniItem | null> {
const map = await getHabboItFurnidata()
// Try exact match
let entry = map.get(classname)
// Try without color variant
if (!entry && classname.includes('*')) {
const base = classname.split('*')[0]
entry = map.get(`${base}*0`) || map.get(base)
}
// Try with *0
if (!entry && !classname.includes('*')) {
entry = map.get(`${classname}*0`)
}
if (!entry) return null
return {
id: entry.id,
classname: entry.classname,
name: entry.name || entry.classname,
description: entry.description || '',
type: entry.itemType === 'i' ? 'wallitem' : 'flooritem',
revision: entry.revision,
category: entry.category || entry.furniline || '',
}
}
// ── Batch lookup ───────────────────────────────────────────────────
/**
* Batch lookup: get data for a list of classnames.
* Much faster than the old habbofurni.com API — single cache lookup, no network.
*/
export async function batchLookupByClassnames(
classnames: string[],
): Promise<Map<string, HabboFurniItem>> {
const result = new Map<string, HabboFurniItem>()
if (classnames.length === 0) return result
for (const cn of classnames) {
const item = await getFurnitureByClassname(cn)
if (item?.name) {
result.set(cn, item)
}
}
return result
}
@@ -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')
})
})
+136
View File
@@ -0,0 +1,136 @@
import { inflateSync } from 'node:zlib'
export interface DecodedPng {
width: number
height: number
/** RGBA8888, row-major, length = width * height * 4 */
rgba: Buffer
}
function paeth(a: number, b: number, c: number): number {
const p = a + b - c
const pa = Math.abs(p - a)
const pb = Math.abs(p - b)
const pc = Math.abs(p - c)
if (pa <= pb && pa <= pc) return a
if (pb <= pc) return b
return c
}
function unfilter(filter: number, cur: Buffer, prev: Buffer, bpp: number): void {
const n = cur.length
switch (filter) {
case 0:
break
case 1:
for (let i = 0; i < n; i++) cur[i] = (cur[i] + (i >= bpp ? cur[i - bpp] : 0)) & 0xff
break
case 2:
for (let i = 0; i < n; i++) cur[i] = (cur[i] + prev[i]) & 0xff
break
case 3:
for (let i = 0; i < n; i++) {
const a = i >= bpp ? cur[i - bpp] : 0
cur[i] = (cur[i] + ((a + prev[i]) >> 1)) & 0xff
}
break
case 4:
for (let i = 0; i < n; i++) {
const a = i >= bpp ? cur[i - bpp] : 0
const c = i >= bpp ? prev[i - bpp] : 0
cur[i] = (cur[i] + paeth(a, prev[i], c)) & 0xff
}
break
default:
throw new Error(`unknown PNG filter ${filter}`)
}
}
/**
* Minimal PNG decoder for 8-bit non-interlaced images (color type 6/RGBA,
* 2/RGB, 0/grayscale). Enough to read Nitro spritesheet PNGs (RGBA8888).
* Returns RGBA pixels. Throws on unsupported formats.
*/
export function decodePng(buf: Buffer): DecodedPng {
if (buf.length < 8 || buf[0] !== 0x89 || buf[1] !== 0x50 || buf[2] !== 0x4e || buf[3] !== 0x47) {
throw new Error('not a PNG')
}
let off = 8
let width = 0
let height = 0
let bitDepth = 0
let colorType = 0
let interlace = 0
const idat: Buffer[] = []
while (off + 8 <= buf.length) {
const len = buf.readUInt32BE(off)
const type = buf.toString('ascii', off + 4, off + 8)
const data = buf.subarray(off + 8, off + 8 + len)
off += 12 + len // length(4) + type(4) + data + crc(4)
if (type === 'IHDR') {
width = data.readUInt32BE(0)
height = data.readUInt32BE(4)
bitDepth = data[8]
colorType = data[9]
interlace = data[12]
} else if (type === 'IDAT') {
idat.push(data)
} else if (type === 'IEND') {
break
}
}
if (bitDepth !== 8) throw new Error(`unsupported PNG bit depth ${bitDepth}`)
if (interlace !== 0) throw new Error('interlaced PNG not supported')
const channels = colorType === 6 ? 4 : colorType === 2 ? 3 : colorType === 0 ? 1 : -1
if (channels === -1) throw new Error(`unsupported PNG color type ${colorType}`)
const raw = inflateSync(Buffer.concat(idat))
const stride = width * channels
const planar = Buffer.alloc(height * stride)
let prev = Buffer.alloc(stride)
let rp = 0
for (let y = 0; y < height; y++) {
const filter = raw[rp++]
const cur = planar.subarray(y * stride, y * stride + stride)
raw.copy(cur, 0, rp, rp + stride)
rp += stride
unfilter(filter, cur, prev, channels)
prev = cur
}
if (channels === 4) return { width, height, rgba: planar }
const rgba = Buffer.alloc(width * height * 4)
for (let i = 0, j = 0; i < width * height; i++) {
if (channels === 3) {
rgba[j++] = planar[i * 3]
rgba[j++] = planar[i * 3 + 1]
rgba[j++] = planar[i * 3 + 2]
} else {
const g = planar[i]
rgba[j++] = g
rgba[j++] = g
rgba[j++] = g
}
rgba[j++] = 255
}
return { width, height, rgba }
}
/** Crop a w×h RGBA region at (x,y) out of a sheetW-wide RGBA buffer. */
export function cropRgba(
rgba: Buffer,
sheetW: number,
x: number,
y: number,
w: number,
h: number,
): Buffer {
const out = Buffer.alloc(w * h * 4)
for (let row = 0; row < h; row++) {
const src = ((y + row) * sheetW + x) * 4
rgba.copy(out, row * w * 4, src, src + w * 4)
}
return out
}
@@ -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 })
})
})
+106
View File
@@ -0,0 +1,106 @@
export interface SseWorkerResult {
ok: boolean
warnings?: string[]
error?: string
}
export interface RunSseBatchOptions<T> {
items: T[]
concurrency: number
/** Label used as the `classname` field on item_progress (kept for the existing client parser). */
labelOf: (item: T) => string
/** Per-item worker. `report(status)` streams intermediate progress (e.g. 'downloading'). */
worker: (item: T, index: number, report: (status: string) => void) => Promise<SseWorkerResult>
}
/**
* Generic SSE batch runner. Emits the same event shape the furni client
* parser consumes: batch_start / item_progress / batch_complete.
*/
export function runSseBatch<T>(opts: RunSseBatchOptions<T>): Response {
const { items, labelOf, worker } = opts
const concurrency = Math.min(Math.max(opts.concurrency || 3, 1), 5)
const encoder = new TextEncoder()
const stream = new ReadableStream({
async start(controller) {
const send = (data: unknown) => {
try {
controller.enqueue(encoder.encode(`data: ${JSON.stringify(data)}\n\n`))
} catch {
/* stream closed by client */
}
}
const startTime = Date.now()
send({ type: 'batch_start', total: items.length, concurrency })
let succeeded = 0
let failed = 0
let withWarnings = 0
for (let i = 0; i < items.length; i += concurrency) {
const chunk = items.slice(i, i + concurrency)
await Promise.allSettled(
chunk.map(async (item, chunkIdx) => {
const index = i + chunkIdx
const classname = labelOf(item)
send({ type: 'item_progress', classname, status: 'started', index })
try {
const result = await worker(item, index, (status) =>
send({ type: 'item_progress', classname, status, index }),
)
if (result.ok) {
succeeded++
if (result.warnings?.length) withWarnings++
send({
type: 'item_progress',
classname,
status: 'done',
index,
warnings: result.warnings?.length ? result.warnings : undefined,
})
} else {
failed++
send({
type: 'item_progress',
classname,
status: 'failed',
index,
message: result.error,
})
}
} catch (err) {
failed++
send({
type: 'item_progress',
classname,
status: 'failed',
index,
message: (err as Error).message,
})
}
}),
)
}
send({
type: 'batch_complete',
succeeded,
failed,
warnings: withWarnings,
duration: Date.now() - startTime,
})
controller.close()
},
})
return new Response(stream, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
},
})
}
+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()
}
}
+355
View File
@@ -0,0 +1,355 @@
import { existsSync, readFileSync } from 'node:fs'
import path from 'node:path'
import { cropRgba, decodePng } from '@/lib/services/imager/png-decode'
import { encodePng, parseNitroBundle } from '@/lib/services/swf/nitro-builder'
// Pet .nitro can live in the CMS import target or the bundled asset set.
const PET_DIRS = [
path.join(/*turbopackIgnore: true*/ process.cwd(), 'public/nitro-assets/bundled/pet'),
path.join(/*turbopackIgnore: true*/ process.cwd(), 'public/Gamedata/bundled/pet'),
]
interface Rect {
x: number
y: number
w: number
h: number
}
interface Asset {
x?: number
y?: number
source?: string
}
interface AnimLayer {
frameSequences?: Record<string, { frames?: Record<string, { id?: number }> }>
}
interface Visualization {
size?: number
layerCount?: number
animations?: Record<string, { layers?: Record<string, AnimLayer> }>
}
interface NitroJson {
name?: string
assets?: Record<string, Asset>
spritesheet?: { frames?: Record<string, { frame: Rect; rotated?: boolean }> }
visualizations?: Visualization[]
}
interface LayerSpec {
letter: string
frameId: number
}
// A pet is rendered as several stacked layers (a,b,c,d = body parts; sd = shadow).
// Directions present on pets are a subset of 0..7; 2 reads best as a portrait.
const DIR_PREFERENCE = [2, 0, 4, 3, 1, 7, 6, 5]
// Generated PNG icons are tiny; cache them in-process keyed by lib.
const iconCache = new Map<string, Buffer | null>()
function findNitro(lib: string): string | null {
for (const dir of PET_DIRS) {
const p = path.join(/*turbopackIgnore: true*/ dir, `${lib}.nitro`)
if (existsSync(/*turbopackIgnore: true*/ p)) return p
}
return null
}
/**
* Which layers (and at which frame id) make up the pet's IDLE pose, read from
* the size-64 visualization's animation 0. Layers with an explicitly empty
* frameSequences are NOT shown when idle (they belong to other animations —
* e.g. dragon wings/fire) and must be skipped, otherwise the composite turns
* into a junk pile. Falls back to layers a–d at frame 0 when no visualization.
*/
function idleLayerSpecs(json: NitroJson): LayerSpec[] {
const viz = json.visualizations?.find((v) => v.size === 64)
if (!viz || !viz.layerCount) {
return ['a', 'b', 'c', 'd'].map((letter) => ({ letter, frameId: 0 }))
}
const anim0 = viz.animations?.['0']
const raw: { letter: string; alPresent: boolean; hasSeq: boolean; frameId: number }[] = []
let anyWithSeq = false
for (let i = 0; i < viz.layerCount && i < 4; i++) {
const letter = String.fromCharCode(97 + i) // a, b, c, d
const al = anim0?.layers?.[String(i)]
const seqKeys = al ? Object.keys(al.frameSequences ?? {}) : []
const hasSeq = seqKeys.length > 0
if (hasSeq) anyWithSeq = true
const frameId = hasSeq
? Number(al?.frameSequences?.[seqKeys[0]]?.frames?.['0']?.id ?? 0) || 0
: 0
raw.push({ letter, alPresent: !!al, hasSeq, frameId })
}
// No idle animation defined anywhere → render every layer's static frame 0.
if (!anyWithSeq) return raw.map((r) => ({ letter: r.letter, frameId: 0 }))
// Idle animation exists → include only layers it drives (or layers with no
// entry); skip layers explicitly muted in idle (empty frameSequences).
return raw
.filter((r) => r.hasSeq || !r.alPresent)
.map((r) => ({ letter: r.letter, frameId: r.frameId }))
}
/** Resolve a layer asset to its spritesheet rect + placement offset. */
function resolveSprite(
json: NitroJson,
lib: string,
layer: string,
dir: number,
frameId: number,
): { rect: Rect; ox: number; oy: number } | null {
const assetName = `${lib}_64_${layer}_${dir}_${frameId}`
const asset = json.assets?.[assetName]
if (!asset) return null
// `source` aliases another asset's pixels; offset stays on this asset.
const pixelAsset = asset.source ?? assetName
const frameKey = `${lib}_${pixelAsset}`
const f = json.spritesheet?.frames?.[frameKey]
if (!f || f.rotated || f.frame.w <= 0 || f.frame.h <= 0) return null
return { rect: f.frame, ox: asset.x ?? 0, oy: asset.y ?? 0 }
}
/** Alpha-over `src` (w×h RGBA) onto `dst` at (dx,dy) within a dstW-wide canvas. */
function blit(
dst: Buffer,
dstW: number,
src: Buffer,
w: number,
h: number,
dx: number,
dy: number,
): void {
for (let y = 0; y < h; y++) {
for (let x = 0; x < w; x++) {
const s = (y * w + x) * 4
const sa = src[s + 3]
if (sa === 0) continue
const d = ((dy + y) * dstW + (dx + x)) * 4
if (sa === 255) {
src.copy(dst, d, s, s + 4)
continue
}
const ia = 255 - sa
dst[d] = (src[s] * sa + dst[d] * ia) / 255
dst[d + 1] = (src[s + 1] * sa + dst[d + 1] * ia) / 255
dst[d + 2] = (src[s + 2] * sa + dst[d + 2] * ia) / 255
dst[d + 3] = sa + (dst[d + 3] * ia) / 255
}
}
}
// Real per-breed pet colour is applied at runtime (the .nitro stores white
// palettes), so recolorable regions are baked as a saturated magenta/red
// placeholder. We tint those placeholder pixels to a sensible default colour
// per breed (an estimate, not the exact in-game value). Naturally-coloured
// pixels (white dog, brown horse, green plant) are left untouched.
const DEFAULT_PET_COLOR: Record<string, [number, number, number]> = {
lion: [216, 166, 72],
haloompa: [216, 166, 72],
pig: [240, 168, 178],
pigletbaby: [240, 168, 178],
bunnylove: [240, 168, 190],
dog: [196, 160, 120],
terrier: [196, 160, 120],
terrierbaby: [196, 160, 120],
puppybaby: [196, 160, 120],
bunnydepressed: [196, 160, 120],
gnome: [196, 160, 120],
fools: [196, 160, 120],
cat: [156, 152, 158],
kittenbaby: [156, 152, 158],
monkey: [150, 108, 74],
demonmonkey: [150, 108, 74],
bear: [150, 112, 80],
bearbaby: [150, 112, 80],
horse: [162, 122, 86],
rhino: [150, 150, 158],
elephants: [150, 150, 158],
spider: [96, 84, 96],
bunnyevil: [120, 120, 128],
pigeonevil: [120, 120, 130],
pigeongood: [176, 176, 182],
bunnyeaster: [220, 214, 214],
cow: [206, 186, 162],
chicken: [238, 226, 176],
frog: [120, 184, 92],
dragon: [120, 172, 110],
croco: [128, 176, 108],
turtle: [130, 172, 120],
monster: [128, 176, 100],
monsterplant: [120, 170, 96],
pterosaur: [128, 176, 108],
velociraptor: [128, 176, 108],
}
const FALLBACK_PET_COLOR: [number, number, number] = [190, 158, 120]
function recolorMask(rgba: Buffer, [tr, tg, tb]: [number, number, number]): void {
for (let i = 0; i < rgba.length; i += 4) {
if (rgba[i + 3] === 0) continue
const r = rgba[i]
const g = rgba[i + 1]
const b = rgba[i + 2]
// Placeholder mask: highly saturated toward magenta/red (green far below
// red/blue). Tint to the breed colour, preserving the mask's shading.
if (Math.max(r, b) - g > 60) {
const l = Math.max(r, g, b) / 255
rgba[i] = Math.round(tr * l)
rgba[i + 1] = Math.round(tg * l)
rgba[i + 2] = Math.round(tb * l)
}
}
}
/**
* Keep only the largest connected (8-neighbour) opaque blob — the pet body —
* and drop detached fragments (mis-posed heads/hats, stray specks from layers
* that don't align at this frame), then tight-crop to it. Turns "body + floating
* junk" into a clean centered icon.
*/
function keepLargestBlob(
rgba: Buffer,
w: number,
h: number,
): { rgba: Buffer; w: number; h: number } {
const n = w * h
const label = new Int32Array(n).fill(-1)
const opaque = (i: number) => rgba[i * 4 + 3] > 30
const comps: { id: number; size: number }[] = []
for (let start = 0; start < n; start++) {
if (label[start] !== -1 || !opaque(start)) continue
const id = comps.length
let size = 0
const stack = [start]
label[start] = id
while (stack.length) {
const p = stack.pop() as number
size++
const x = p % w
const y = (p / w) | 0
for (let dy = -1; dy <= 1; dy++) {
for (let dx = -1; dx <= 1; dx++) {
const nx = x + dx
const ny = y + dy
if (nx < 0 || ny < 0 || nx >= w || ny >= h) continue
const np = ny * w + nx
if (label[np] === -1 && opaque(np)) {
label[np] = id
stack.push(np)
}
}
}
}
comps.push({ id, size })
}
if (comps.length === 0) return { rgba, w, h }
let main = comps[0]
for (const c of comps) if (c.size > main.size) main = c
// Tight-crop bbox of the main blob.
let minX = w
let minY = h
let maxX = -1
let maxY = -1
for (let p = 0; p < n; p++) {
if (label[p] !== main.id) continue
const x = p % w
const y = (p / w) | 0
if (x < minX) minX = x
if (x > maxX) maxX = x
if (y < minY) minY = y
if (y > maxY) maxY = y
}
const nw = maxX - minX + 1
const nh = maxY - minY + 1
const out = Buffer.alloc(nw * nh * 4)
for (let y = 0; y < nh; y++) {
for (let x = 0; x < nw; x++) {
const sp = (minY + y) * w + (minX + x)
if (label[sp] === main.id) {
const o = (y * nw + x) * 4
const si = sp * 4
rgba.copy(out, o, si, si + 4)
}
}
}
return { rgba: out, w: nw, h: nh }
}
/** Composite all non-shadow body layers of one direction's base pose into a PNG. */
function composite(json: NitroJson, sheet: { width: number; rgba: Buffer }): Buffer | null {
const lib = (json.name ?? '').toString()
if (!lib) return null
type Sprite = { rect: Rect; ox: number; oy: number }
const bestDir = (specs: LayerSpec[]): Sprite[] => {
let best: Sprite[] = []
for (const dir of DIR_PREFERENCE) {
const resolved = specs
.map((s) => resolveSprite(json, lib, s.letter, dir, s.frameId))
.filter((s): s is Sprite => s !== null)
if (resolved.length > best.length) {
best = resolved
if (dir === DIR_PREFERENCE[0]) break // front pose is good enough
}
}
return best
}
// Prefer the idle-pose layers; if nothing resolves, fall back to every
// layer's static frame 0 (covers pets whose frame ids don't line up).
let sprites = bestDir(idleLayerSpecs(json))
if (sprites.length === 0) {
sprites = bestDir(['a', 'b', 'c', 'd'].map((letter) => ({ letter, frameId: 0 })))
}
if (sprites.length === 0) return null
// Union bounding box in pet-local coords.
let minX = Infinity
let minY = Infinity
let maxX = -Infinity
let maxY = -Infinity
for (const s of sprites) {
minX = Math.min(minX, s.ox)
minY = Math.min(minY, s.oy)
maxX = Math.max(maxX, s.ox + s.rect.w)
maxY = Math.max(maxY, s.oy + s.rect.h)
}
const cw = maxX - minX
const ch = maxY - minY
if (cw <= 0 || ch <= 0 || cw > 256 || ch > 256) return null
const canvas = Buffer.alloc(cw * ch * 4) // transparent
for (const s of sprites) {
const px = cropRgba(sheet.rgba, sheet.width, s.rect.x, s.rect.y, s.rect.w, s.rect.h)
blit(canvas, cw, px, s.rect.w, s.rect.h, s.ox - minX, s.oy - minY)
}
recolorMask(canvas, DEFAULT_PET_COLOR[lib] ?? FALLBACK_PET_COLOR)
const blob = keepLargestBlob(canvas, cw, ch)
return encodePng(blob.w, blob.h, blob.rgba)
}
/**
* Build a composited PNG icon for a pet from its `.nitro` spritesheet, or null
* if the pet has no bundle / no usable frames. Cached in-process.
*/
export function getPetIconPng(lib: string): Buffer | null {
if (!/^[A-Za-z0-9_]+$/.test(lib)) return null
const cached = iconCache.get(lib)
if (cached !== undefined) return cached
const nitroPath = findNitro(lib)
if (!nitroPath) {
iconCache.set(lib, null)
return null
}
try {
const { json, png } = parseNitroBundle(readFileSync(/*turbopackIgnore: true*/ nitroPath))
const sheet = decodePng(png)
const icon = composite(json as NitroJson, sheet)
iconCache.set(lib, icon)
return icon
} catch {
iconCache.set(lib, null)
return null
}
}
+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')
})
})
+103
View File
@@ -0,0 +1,103 @@
import { existsSync, promises as fs } from 'node:fs'
import path from 'node:path'
import { downloadFile } from '@/lib/services/import/core/download'
import { CANONICAL_PETS, resolvePetSwfUrl } from '@/lib/services/pet-source'
import { convertSwfToNitro } from '@/lib/services/swf-to-nitro'
export interface PetListItem {
lib: string
nitroExists: boolean
}
export interface ImportPetResult {
ok: boolean
lib: string
warnings: string[]
error?: string
}
export const PET_NITRO_DIR = path.join(
/*turbopackIgnore: true*/ process.cwd(),
'public/nitro-assets/bundled/pet',
)
export const PET_SWF_DIR = path.join(/*turbopackIgnore: true*/ process.cwd(), 'public/swf/gordon')
export async function ensurePetDirs(): Promise<void> {
await Promise.all(
[PET_NITRO_DIR, PET_SWF_DIR]
.filter((dir) => !existsSync(/*turbopackIgnore: true*/ dir))
.map((dir) => fs.mkdir(/*turbopackIgnore: true*/ dir, { recursive: true })),
)
}
function nitroPathFor(lib: string): string {
return path.join(/*turbopackIgnore: true*/ PET_NITRO_DIR, `${lib}.nitro`)
}
export async function importSinglePet(params: {
lib: string
onProgress?: (status: string) => void
}): Promise<ImportPetResult> {
const { lib, onProgress } = params
const warnings: string[] = []
await ensurePetDirs()
onProgress?.('downloading')
const swfPath = path.join(/*turbopackIgnore: true*/ PET_SWF_DIR, `${lib}.swf`)
let swfUrl: string
try {
swfUrl = await resolvePetSwfUrl(lib)
} catch (err) {
return {
ok: false,
lib,
warnings,
error: `Could not resolve SWF URL: ${(err as Error).message}`,
}
}
const dl = await downloadFile(swfUrl, swfPath, { validate: 'swf' })
if (!dl.ok) return { ok: false, lib, warnings, error: `SWF download failed: ${swfUrl}` }
onProgress?.('converting')
try {
const swfBuffer = await fs.readFile(/*turbopackIgnore: true*/ swfPath)
const result = convertSwfToNitro(swfBuffer, lib)
await fs.writeFile(/*turbopackIgnore: true*/ nitroPathFor(lib), result.bundle)
if (result.warnings?.length) warnings.push(...result.warnings)
} catch (err) {
return {
ok: false,
lib,
warnings,
error: `SWF→Nitro conversion failed: ${(err as Error).message}`,
}
}
return { ok: true, lib, warnings }
}
export async function deleteImportedPet(lib: string): Promise<{ deletedFile: boolean }> {
const p = nitroPathFor(lib)
if (existsSync(/*turbopackIgnore: true*/ p)) {
await fs.unlink(/*turbopackIgnore: true*/ p).catch(() => {})
return { deletedFile: true }
}
return { deletedFile: false }
}
export function getPetList(search: string): PetListItem[] {
const term = search.trim().toLowerCase()
const list = CANONICAL_PETS.map((lib) => ({
lib,
nitroExists: existsSync(/*turbopackIgnore: true*/ nitroPathFor(lib)),
}))
return term ? list.filter((p) => p.lib.toLowerCase().includes(term)) : list
}
export function getPetStats(): { total: number; imported: number; missing: number } {
const imported = CANONICAL_PETS.filter((lib) =>
existsSync(/*turbopackIgnore: true*/ nitroPathFor(lib)),
).length
return { total: CANONICAL_PETS.length, imported, missing: CANONICAL_PETS.length - imported }
}
+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";
}
async update(key: string, value: string): Promise<void> {
await prisma.websiteSetting.upsert({
where: { key },
create: { key, value },
update: { value },
});
this.reload();
}
reload(): void {
this.cache = null;
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
}