feat: add asset import services
This commit is contained in:
1 parent
4b596226e0
commit
3497df9dfd
46 files changed
+5383
No files matched your search
@@ -0,0 +1,38 @@
|
|||||||
|
import type { NextRequest } from 'next/server'
|
||||||
|
import { NextResponse } from 'next/server'
|
||||||
|
import { logAuthorizationEvent } from '@/lib/admin/authorization-events'
|
||||||
|
import { canAccess, getApiAdminContext } from '@/lib/permissions'
|
||||||
|
import { logServerError } from '@/lib/server-log'
|
||||||
|
|
||||||
|
type AdminContext = NonNullable<Awaited<ReturnType<typeof getApiAdminContext>>>
|
||||||
|
type RouteContext = { params?: Promise<Record<string, string | string[]>> }
|
||||||
|
type AdminHandler = (
|
||||||
|
request: NextRequest,
|
||||||
|
context: AdminContext,
|
||||||
|
routeContext: RouteContext,
|
||||||
|
) => Promise<Response> | Response
|
||||||
|
|
||||||
|
export function withAdmin(options: { permission?: string }, handler: AdminHandler) {
|
||||||
|
return async (request: NextRequest, routeContext: RouteContext = {}) => {
|
||||||
|
const context = await getApiAdminContext()
|
||||||
|
if (!context) return NextResponse.json({ ok: false, error: 'Unauthorized' }, { status: 401 })
|
||||||
|
if (options.permission && !canAccess(context.permissions, options.permission, context.session.user.rank)) {
|
||||||
|
await logAuthorizationEvent({
|
||||||
|
kind: 'permission.denied',
|
||||||
|
userId: context.session.user.id,
|
||||||
|
username: context.session.user.username,
|
||||||
|
rank: context.session.user.rank,
|
||||||
|
permission: options.permission,
|
||||||
|
source: request.nextUrl.pathname,
|
||||||
|
reason: 'API permission check denied',
|
||||||
|
})
|
||||||
|
return NextResponse.json({ ok: false, error: 'Forbidden' }, { status: 403 })
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return await handler(request, context, routeContext)
|
||||||
|
} catch (error) {
|
||||||
|
logServerError('admin.api_failed', error, { path: request.nextUrl.pathname, userId: context.session.user.id })
|
||||||
|
return NextResponse.json({ ok: false, error: 'Internal server error' }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { NextResponse } from 'next/server'
|
||||||
|
import { ZodError, type z } from 'zod'
|
||||||
|
|
||||||
|
/** Throwable API error with HTTP status code */
|
||||||
|
export class ApiError extends Error {
|
||||||
|
status: number
|
||||||
|
constructor(message: string, status: number = 400) {
|
||||||
|
super(message)
|
||||||
|
this.name = 'ApiError'
|
||||||
|
this.status = status
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Standard success response: { ok: true, ...data } */
|
||||||
|
export function apiOk(data?: Record<string, unknown>) {
|
||||||
|
return NextResponse.json({ ok: true, ...data })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Standard error response: { error: message } with given status */
|
||||||
|
export function apiError(message: string, status: number = 400) {
|
||||||
|
return NextResponse.json({ error: message }, { status })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Validation error from Zod: { error: fieldErrors } with 400 */
|
||||||
|
export function apiValidationError(zodError: z.ZodError) {
|
||||||
|
return NextResponse.json({ error: zodError.flatten().fieldErrors }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Centralized error handler for API routes */
|
||||||
|
export function handleApiError(error: unknown): Response {
|
||||||
|
if (error instanceof ApiError) {
|
||||||
|
return apiError(error.message, error.status)
|
||||||
|
}
|
||||||
|
if (error instanceof ZodError) {
|
||||||
|
return apiValidationError(error)
|
||||||
|
}
|
||||||
|
// Prisma P2025 "Record not found"
|
||||||
|
if (
|
||||||
|
error instanceof Error &&
|
||||||
|
(error.constructor.name === 'PrismaClientKnownRequestError' ||
|
||||||
|
error.name === 'PrismaClientKnownRequestError') &&
|
||||||
|
(error as Error & { code?: string }).code === 'P2025'
|
||||||
|
) {
|
||||||
|
return apiError('Not found', 404)
|
||||||
|
}
|
||||||
|
console.error(
|
||||||
|
'[API error]',
|
||||||
|
error instanceof Error ? { message: error.message, name: error.name } : error,
|
||||||
|
)
|
||||||
|
return apiError('Internal server error', 500)
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export * from './catalog-categories/index'
|
||||||
|
|
||||||
@@ -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',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
/**
|
||||||
|
* Release eras for organizing Habbo furniture by revision number.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ── Release Eras ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface ReleaseEra {
|
||||||
|
key: string
|
||||||
|
label: string
|
||||||
|
labelEn: string
|
||||||
|
revisionMin: number
|
||||||
|
revisionMax: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export const RELEASE_ERAS: ReleaseEra[] = [
|
||||||
|
{
|
||||||
|
key: 'classic',
|
||||||
|
label: 'Classici 2000-2003',
|
||||||
|
labelEn: 'Classic 2000-2003',
|
||||||
|
revisionMin: 0,
|
||||||
|
revisionMax: 19999,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'mid',
|
||||||
|
label: 'Era HC 2004-2006',
|
||||||
|
labelEn: 'HC Era 2004-2006',
|
||||||
|
revisionMin: 20000,
|
||||||
|
revisionMax: 39999,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'modern',
|
||||||
|
label: 'Moderni 2007-2009',
|
||||||
|
labelEn: 'Modern 2007-2009',
|
||||||
|
revisionMin: 40000,
|
||||||
|
revisionMax: 59999,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'recent',
|
||||||
|
label: 'Recenti 2010-2012',
|
||||||
|
labelEn: 'Recent 2010-2012',
|
||||||
|
revisionMin: 60000,
|
||||||
|
revisionMax: 79999,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'latest',
|
||||||
|
label: 'Ultimi 2013+',
|
||||||
|
labelEn: 'Latest 2013+',
|
||||||
|
revisionMin: 80000,
|
||||||
|
revisionMax: Infinity,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Match a revision number to a release era.
|
||||||
|
*/
|
||||||
|
export function matchEra(revision: number): ReleaseEra {
|
||||||
|
for (const era of RELEASE_ERAS) {
|
||||||
|
if (revision >= era.revisionMin && revision <= era.revisionMax) {
|
||||||
|
return era
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return RELEASE_ERAS[RELEASE_ERAS.length - 1]
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
/**
|
||||||
|
* Supreme catalog categories + release eras for organizing Habbo furniture.
|
||||||
|
*
|
||||||
|
* Matching priority (first match wins within each level):
|
||||||
|
* 1. Interaction type (most specific)
|
||||||
|
* 2. Keywords anywhere in item name (normalized, *N stripped)
|
||||||
|
* 3. Regex prefix patterns (on normalized name)
|
||||||
|
* 4. Auto-prefix grouping (handled in route.ts)
|
||||||
|
* 5. Uncategorized fallback
|
||||||
|
*
|
||||||
|
* Each category belongs to a root: furni, costruire, animali, vestiti, or staff.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export * from './categories'
|
||||||
|
export * from './eras'
|
||||||
|
|
||||||
|
import { SUPREME_CATEGORIES, type SupremeCategory } from './categories'
|
||||||
|
|
||||||
|
// ── Name Normalization ──────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize a Habbo item classname for matching.
|
||||||
|
* Strips the *N color suffix (e.g. "chair_plasto*5" -> "chair_plasto")
|
||||||
|
* and lowercases the result.
|
||||||
|
*/
|
||||||
|
export function normalizeName(itemName: string): string {
|
||||||
|
return itemName.replace(/\*\d+$/, '').toLowerCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Category Detection ──────────────────────────────────────────
|
||||||
|
|
||||||
|
export const UNCATEGORIZED_KEY = 'uncategorized'
|
||||||
|
export const AUTO_PREFIX_MIN_ITEMS = 5
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find the supreme category for an item by classname and interaction type.
|
||||||
|
*
|
||||||
|
* Priority: interaction type -> keywords (anywhere in normalized name) -> regex patterns.
|
||||||
|
* First match wins within each priority level.
|
||||||
|
* Item names are normalized (strip *N suffix, lowercase) before matching.
|
||||||
|
*/
|
||||||
|
export function matchCategory(itemName: string, interactionType?: string): SupremeCategory {
|
||||||
|
const lower = normalizeName(itemName)
|
||||||
|
|
||||||
|
// Pass 1: Interaction type (most specific)
|
||||||
|
for (const cat of SUPREME_CATEGORIES) {
|
||||||
|
if (interactionType && cat.interactionTypes?.includes(interactionType)) {
|
||||||
|
return cat
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pass 2: Keywords anywhere in name (user priority)
|
||||||
|
for (const cat of SUPREME_CATEGORIES) {
|
||||||
|
if (cat.key === UNCATEGORIZED_KEY) continue
|
||||||
|
for (const kw of cat.keywords) {
|
||||||
|
if (lower.includes(kw)) return cat
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pass 3: Regex prefix patterns (fallback)
|
||||||
|
for (const cat of SUPREME_CATEGORIES) {
|
||||||
|
if (cat.key === UNCATEGORIZED_KEY) continue
|
||||||
|
for (const pattern of cat.patterns) {
|
||||||
|
if (pattern.test(lower)) return cat
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return SUPREME_CATEGORIES[SUPREME_CATEGORIES.length - 1]
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Auto-Prefix Grouping ────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface AutoDetectedCategory {
|
||||||
|
key: string
|
||||||
|
prefix: string
|
||||||
|
label: string
|
||||||
|
labelEn: string
|
||||||
|
icon: number
|
||||||
|
layout: string
|
||||||
|
autoDetected: true
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract prefix from an item name for auto-grouping.
|
||||||
|
* - Strips *N color suffix first ("chair_plasto*5" -> "chair_plasto")
|
||||||
|
* - If name contains '_', take everything before the first '_'
|
||||||
|
* - If no underscore, take the alphabetic prefix (letters before first digit)
|
||||||
|
* - Lowercase the result; return null if prefix is empty or 1 character
|
||||||
|
*/
|
||||||
|
export function extractPrefix(itemName: string): string | null {
|
||||||
|
const normalized = normalizeName(itemName)
|
||||||
|
const idx = normalized.indexOf('_')
|
||||||
|
if (idx > 1) return normalized.substring(0, idx)
|
||||||
|
const match = normalized.match(/^([a-z]{2,})/)
|
||||||
|
return match ? match[1] : null
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { cropRgba, decodePng } from '@/lib/services/imager/png-decode'
|
||||||
|
import { encodePng, parseNitroBundle } from '@/lib/services/swf/nitro-builder'
|
||||||
|
|
||||||
|
interface FurniAsset {
|
||||||
|
x?: number
|
||||||
|
y?: number
|
||||||
|
source?: string
|
||||||
|
}
|
||||||
|
interface FurniFrame {
|
||||||
|
frame: { x: number; y: number; w: number; h: number }
|
||||||
|
rotated?: boolean
|
||||||
|
}
|
||||||
|
interface FurniNitroJson {
|
||||||
|
name?: string
|
||||||
|
assets?: Record<string, FurniAsset>
|
||||||
|
spritesheet?: { frames?: Record<string, FurniFrame> }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract the furni catalog icon — the `{name}_icon_a` sprite — from a Nitro
|
||||||
|
* bundle and return it as a standalone PNG, or null if the bundle has no icon
|
||||||
|
* asset / usable frame. Used for sources that embed furni icons inside the
|
||||||
|
* `.nitro` instead of serving a separate `{classname}_icon.png`.
|
||||||
|
*/
|
||||||
|
export function extractFurniIconPng(nitro: Buffer): Buffer | null {
|
||||||
|
let json: FurniNitroJson
|
||||||
|
let png: Buffer
|
||||||
|
try {
|
||||||
|
const parsed = parseNitroBundle(nitro)
|
||||||
|
json = parsed.json as FurniNitroJson
|
||||||
|
png = parsed.png
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
const name = json.name
|
||||||
|
if (!name || !json.assets) return null
|
||||||
|
|
||||||
|
// The icon asset is conventionally `${name}_icon_a`; fall back to any
|
||||||
|
// `${name}_icon*` the bundle exposes.
|
||||||
|
const iconKey = json.assets[`${name}_icon_a`]
|
||||||
|
? `${name}_icon_a`
|
||||||
|
: Object.keys(json.assets).find((k) => k.startsWith(`${name}_icon`))
|
||||||
|
if (!iconKey) return null
|
||||||
|
|
||||||
|
const asset = json.assets[iconKey]
|
||||||
|
// An asset may alias another's pixels via `source`; the frame key is the
|
||||||
|
// bundle name prefixed onto the asset name. Different converters pack the
|
||||||
|
// key with or without a trailing `.png`, so try both.
|
||||||
|
const pixelAsset = asset.source ?? iconKey
|
||||||
|
const frames = json.spritesheet?.frames ?? {}
|
||||||
|
const frame = frames[`${name}_${pixelAsset}`] ?? frames[`${name}_${pixelAsset}.png`]
|
||||||
|
if (!frame || frame.rotated) return null
|
||||||
|
const { x, y, w, h } = frame.frame
|
||||||
|
if (w <= 0 || h <= 0) return null
|
||||||
|
|
||||||
|
try {
|
||||||
|
const sheet = decodePng(png)
|
||||||
|
const px = cropRgba(sheet.rgba, sheet.width, x, y, w, h)
|
||||||
|
return encodePng(w, h, px)
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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/)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
@@ -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 }
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
@@ -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 }
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { resolveGordonBuildUrl } from '@/lib/services/import/core/gordon'
|
||||||
|
import { siteSettings } from '@/lib/services/site-settings'
|
||||||
|
import type { EffectMapEntry } from '@/types/effects'
|
||||||
|
|
||||||
|
// Defaults to VALIDATE against a live fetch (spec Open Item #2). Override the
|
||||||
|
// SWF source with the `effect_swf_base_url` site setting if these drift.
|
||||||
|
const OFFICIAL_EFFECTMAP_URL = 'https://www.habbo.com/gamedata/effectmap/1'
|
||||||
|
const CACHE_TTL = 30 * 60 * 1000
|
||||||
|
|
||||||
|
let effectCache: { list: EffectMapEntry[]; ts: number } | null = null
|
||||||
|
|
||||||
|
export function __clearEffectSourceCache(): void {
|
||||||
|
effectCache = null
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseEffectMapXml(xml: string): EffectMapEntry[] {
|
||||||
|
const out: EffectMapEntry[] = []
|
||||||
|
const re = /<effect\b([^>]*)\/?>/g
|
||||||
|
for (const m of xml.matchAll(re)) {
|
||||||
|
const attrs = m[1]
|
||||||
|
const id = /\bid="([^"]*)"/.exec(attrs)?.[1]
|
||||||
|
const lib = /\blib="([^"]*)"/.exec(attrs)?.[1]
|
||||||
|
const type = /\btype="([^"]*)"/.exec(attrs)?.[1] ?? 'fx'
|
||||||
|
const revision = Number(/\brevision="([^"]*)"/.exec(attrs)?.[1] ?? '0')
|
||||||
|
if (id && lib) out.push({ id, lib, type, revision: Number.isFinite(revision) ? revision : 0 })
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listOfficialEffects(): Promise<EffectMapEntry[]> {
|
||||||
|
const now = Date.now()
|
||||||
|
if (effectCache && now - effectCache.ts < CACHE_TTL) return effectCache.list
|
||||||
|
const res = await fetch(OFFICIAL_EFFECTMAP_URL, { signal: AbortSignal.timeout(20000) })
|
||||||
|
if (!res.ok) {
|
||||||
|
console.warn(`[effect-source] effectmap fetch failed: ${res.status}`)
|
||||||
|
return effectCache?.list ?? []
|
||||||
|
}
|
||||||
|
const list = parseEffectMapXml(await res.text())
|
||||||
|
effectCache = { list, ts: now }
|
||||||
|
return list
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resolveEffectSwfUrl(lib: string): Promise<string> {
|
||||||
|
const override = ((await siteSettings.get('effect_swf_base_url', '')) ?? '').trim()
|
||||||
|
if (override) return `${override.replace(/\/+$/, '')}/${lib}.swf`
|
||||||
|
const gordon = await resolveGordonBuildUrl()
|
||||||
|
return `${gordon}${lib}.swf`
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { resolveGamedataFile } from '@/lib/services/import/core/asset-paths'
|
||||||
|
import {
|
||||||
|
readGamedataJson,
|
||||||
|
withGamedataLock,
|
||||||
|
writeGamedataJsonAtomic,
|
||||||
|
} from '@/lib/services/import/core/gamedata-json'
|
||||||
|
import { siteSettings } from '@/lib/services/site-settings'
|
||||||
|
import type { EffectMapEntry } from '@/types/effects'
|
||||||
|
|
||||||
|
interface EffectMapFile {
|
||||||
|
effects: EffectMapEntry[]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test seam: allows the suite to point the resolver at a temp file.
|
||||||
|
let pathOverride: string | null = null
|
||||||
|
export function __setEffectMapPathForTests(p: string | null): void {
|
||||||
|
pathOverride = p
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getEffectMapPath(): Promise<string> {
|
||||||
|
if (pathOverride) return pathOverride
|
||||||
|
const url = await siteSettings.get('effectmap_url', '/nitro-assets/gamedata/EffectMap.json')
|
||||||
|
return resolveGamedataFile(url ?? '/nitro-assets/gamedata/EffectMap.json', 'EffectMap.json')
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function readEffectMap(): Promise<EffectMapFile> {
|
||||||
|
const file = await getEffectMapPath()
|
||||||
|
const data = await readGamedataJson<Partial<EffectMapFile>>(file)
|
||||||
|
return { effects: Array.isArray(data.effects) ? data.effects : [] }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listLocalEffects(): Promise<EffectMapEntry[]> {
|
||||||
|
return (await readEffectMap()).effects
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function mergeEffect(entry: EffectMapEntry): Promise<void> {
|
||||||
|
const file = await getEffectMapPath()
|
||||||
|
await withGamedataLock(file, async () => {
|
||||||
|
const data = await readGamedataJson<Partial<EffectMapFile>>(file)
|
||||||
|
const effects = Array.isArray(data.effects) ? data.effects : []
|
||||||
|
const idx = effects.findIndex((e) => e.id === entry.id)
|
||||||
|
if (idx >= 0) effects[idx] = entry
|
||||||
|
else effects.push(entry)
|
||||||
|
await writeGamedataJsonAtomic(file, { ...data, effects })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function removeEffect(idOrLib: string): Promise<boolean> {
|
||||||
|
const file = await getEffectMapPath()
|
||||||
|
return withGamedataLock(file, async () => {
|
||||||
|
const data = await readGamedataJson<Partial<EffectMapFile>>(file)
|
||||||
|
const effects = Array.isArray(data.effects) ? data.effects : []
|
||||||
|
const next = effects.filter((e) => e.id !== idOrLib && e.lib !== idOrLib)
|
||||||
|
const removed = next.length < effects.length
|
||||||
|
if (removed) await writeGamedataJsonAtomic(file, { ...data, effects: next })
|
||||||
|
return removed
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
// @vitest-environment node
|
||||||
|
import { describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
vi.mock('@/lib/services/import/core/download', () => ({
|
||||||
|
downloadFile: vi.fn(async () => ({ ok: true, size: 100 })),
|
||||||
|
}))
|
||||||
|
vi.mock('@/lib/services/figure-source', () => ({
|
||||||
|
resolveFigureSwfUrl: vi.fn(async (lib: string) => `https://x/${lib}.swf`),
|
||||||
|
}))
|
||||||
|
vi.mock('@/lib/services/figuremap', () => ({
|
||||||
|
listFigureLibraries: vi.fn(async () => [
|
||||||
|
{ id: 'Hair_F_Bob', revision: 1 },
|
||||||
|
{ id: 'shirt_M_x', revision: 2 },
|
||||||
|
{ id: 'Hat_U_y', revision: 3 },
|
||||||
|
]),
|
||||||
|
}))
|
||||||
|
const convertSwfToNitro = vi.fn(() => ({ bundle: Buffer.from('nitro'), warnings: [] }))
|
||||||
|
vi.mock('@/lib/services/swf-to-nitro', () => ({
|
||||||
|
convertSwfToNitro: (...a: unknown[]) =>
|
||||||
|
convertSwfToNitro(...(a as Parameters<typeof convertSwfToNitro>)),
|
||||||
|
}))
|
||||||
|
vi.mock('node:fs', async (orig) => {
|
||||||
|
const real = (await orig()) as typeof import('node:fs')
|
||||||
|
return {
|
||||||
|
...real,
|
||||||
|
existsSync: vi.fn(() => false),
|
||||||
|
promises: {
|
||||||
|
...real.promises,
|
||||||
|
readFile: vi.fn(async () => Buffer.from('CWS')),
|
||||||
|
writeFile: vi.fn(async () => {}),
|
||||||
|
mkdir: vi.fn(async () => {}),
|
||||||
|
unlink: vi.fn(async () => {}),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
import { getFigureList, importSingleFigure } from './figure-import'
|
||||||
|
|
||||||
|
describe('figure-import', () => {
|
||||||
|
it('paginates the library list with present flags', async () => {
|
||||||
|
const { items, meta } = await getFigureList({ search: '', page: 1, perPage: 2 })
|
||||||
|
expect(items).toHaveLength(2)
|
||||||
|
expect(meta).toMatchObject({ page: 1, perPage: 2, total: 3 })
|
||||||
|
expect(items[0]).toMatchObject({ lib: 'Hair_F_Bob', nitroExists: false })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('filters by search', async () => {
|
||||||
|
const { items, meta } = await getFigureList({ search: 'shirt', page: 1, perPage: 50 })
|
||||||
|
expect(meta.total).toBe(1)
|
||||||
|
expect(items[0].lib).toBe('shirt_M_x')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('importSingleFigure downloads + converts + writes', async () => {
|
||||||
|
const r = await importSingleFigure({ lib: 'Hair_F_Bob' })
|
||||||
|
expect(r.ok).toBe(true)
|
||||||
|
expect(convertSwfToNitro).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('fails cleanly when conversion throws', async () => {
|
||||||
|
convertSwfToNitro.mockImplementationOnce(() => {
|
||||||
|
throw new Error('bad swf')
|
||||||
|
})
|
||||||
|
const r = await importSingleFigure({ lib: 'shirt_M_x' })
|
||||||
|
expect(r.ok).toBe(false)
|
||||||
|
expect(r.error).toContain('conversion')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
@@ -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 }
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { resolveGordonBuildUrl } from '@/lib/services/import/core/gordon'
|
||||||
|
import { siteSettings } from '@/lib/services/site-settings'
|
||||||
|
|
||||||
|
export async function resolveFigureSwfUrl(lib: string): Promise<string> {
|
||||||
|
const override = ((await siteSettings.get('figure_swf_base_url', '')) ?? '').trim()
|
||||||
|
if (override) return `${override.replace(/\/+$/, '')}/${lib}.swf`
|
||||||
|
const gordon = await resolveGordonBuildUrl()
|
||||||
|
return `${gordon}${lib}.swf`
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import { resolveGamedataFile } from '@/lib/services/import/core/asset-paths'
|
||||||
|
import {
|
||||||
|
readGamedataJson,
|
||||||
|
withGamedataLock,
|
||||||
|
writeGamedataJsonAtomic,
|
||||||
|
} from '@/lib/services/import/core/gamedata-json'
|
||||||
|
import { siteSettings } from '@/lib/services/site-settings'
|
||||||
|
|
||||||
|
interface FdSet {
|
||||||
|
id: number
|
||||||
|
[k: string]: unknown
|
||||||
|
}
|
||||||
|
interface FdSetType {
|
||||||
|
type: string
|
||||||
|
paletteId: number
|
||||||
|
sets: FdSet[]
|
||||||
|
[k: string]: unknown
|
||||||
|
}
|
||||||
|
interface FdPalette {
|
||||||
|
id: number
|
||||||
|
colors: unknown[]
|
||||||
|
}
|
||||||
|
interface FigureDataFile {
|
||||||
|
palettes: FdPalette[]
|
||||||
|
setTypes: FdSetType[]
|
||||||
|
}
|
||||||
|
|
||||||
|
let pathOverride: string | null = null
|
||||||
|
export function __setFigureDataPathForTests(p: string | null): void {
|
||||||
|
pathOverride = p
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getFigureDataPath(): Promise<string> {
|
||||||
|
if (pathOverride) return pathOverride
|
||||||
|
const url = await siteSettings.get('figuredata_url', '/nitro-assets/gamedata/FigureData.json')
|
||||||
|
return resolveGamedataFile(url ?? '/nitro-assets/gamedata/FigureData.json', 'FigureData.json')
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function readFigureData(): Promise<FigureDataFile> {
|
||||||
|
const data = await readGamedataJson<Partial<FigureDataFile>>(await getFigureDataPath())
|
||||||
|
return { palettes: data.palettes ?? [], setTypes: data.setTypes ?? [] }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listLocalSetIds(setType: string): Promise<Set<number>> {
|
||||||
|
const fd = await readFigureData()
|
||||||
|
const st = fd.setTypes.find((s) => s.type === setType)
|
||||||
|
return new Set((st?.sets ?? []).map((s) => s.id))
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function mergeSet(setType: string, paletteId: number, set: FdSet): Promise<void> {
|
||||||
|
const file = await getFigureDataPath()
|
||||||
|
await withGamedataLock(file, async () => {
|
||||||
|
const fd = await readGamedataJson<FigureDataFile>(file)
|
||||||
|
fd.setTypes ??= []
|
||||||
|
let st = fd.setTypes.find((s) => s.type === setType)
|
||||||
|
if (!st) {
|
||||||
|
st = { type: setType, paletteId, sets: [] }
|
||||||
|
fd.setTypes.push(st)
|
||||||
|
}
|
||||||
|
st.sets ??= []
|
||||||
|
const idx = st.sets.findIndex((s) => s.id === set.id)
|
||||||
|
if (idx >= 0) {
|
||||||
|
st.sets[idx] = set
|
||||||
|
} else {
|
||||||
|
st.sets.push(set)
|
||||||
|
}
|
||||||
|
await writeGamedataJsonAtomic(file, fd)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function mergePalette(palette: FdPalette): Promise<void> {
|
||||||
|
const file = await getFigureDataPath()
|
||||||
|
await withGamedataLock(file, async () => {
|
||||||
|
const fd = await readGamedataJson<FigureDataFile>(file)
|
||||||
|
fd.palettes ??= []
|
||||||
|
if (!fd.palettes.some((p) => p.id === palette.id)) {
|
||||||
|
fd.palettes.push(palette)
|
||||||
|
await writeGamedataJsonAtomic(file, fd)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { resolveGamedataFile } from '@/lib/services/import/core/asset-paths'
|
||||||
|
import {
|
||||||
|
readGamedataJson,
|
||||||
|
withGamedataLock,
|
||||||
|
writeGamedataJsonAtomic,
|
||||||
|
} from '@/lib/services/import/core/gamedata-json'
|
||||||
|
import { siteSettings } from '@/lib/services/site-settings'
|
||||||
|
|
||||||
|
export interface FigureLibrary {
|
||||||
|
id: string
|
||||||
|
revision: number
|
||||||
|
parts?: Array<{ id: number; type: string }>
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FigureMapFile {
|
||||||
|
libraries: FigureLibrary[]
|
||||||
|
}
|
||||||
|
|
||||||
|
let pathOverride: string | null = null
|
||||||
|
export function __setFigureMapPathForTests(p: string | null): void {
|
||||||
|
pathOverride = p
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getFigureMapPath(): Promise<string> {
|
||||||
|
if (pathOverride) return pathOverride
|
||||||
|
const url = await siteSettings.get('figuremap_url', '/nitro-assets/gamedata/FigureMap.json')
|
||||||
|
return resolveGamedataFile(url ?? '/nitro-assets/gamedata/FigureMap.json', 'FigureMap.json')
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listFigureLibraries(): Promise<FigureLibrary[]> {
|
||||||
|
const file = await getFigureMapPath()
|
||||||
|
const data = await readGamedataJson<Partial<FigureMapFile>>(file)
|
||||||
|
return Array.isArray(data.libraries) ? data.libraries : []
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function mergeLibrary(lib: FigureLibrary): Promise<void> {
|
||||||
|
const file = await getFigureMapPath()
|
||||||
|
await withGamedataLock(file, async () => {
|
||||||
|
const data = await readGamedataJson<{ libraries?: FigureLibrary[] }>(file)
|
||||||
|
const libraries = Array.isArray(data.libraries) ? data.libraries : []
|
||||||
|
const idx = libraries.findIndex((l) => l.id === lib.id)
|
||||||
|
if (idx >= 0) {
|
||||||
|
libraries[idx] = lib
|
||||||
|
} else {
|
||||||
|
libraries.push(lib)
|
||||||
|
}
|
||||||
|
await writeGamedataJsonAtomic(file, { ...data, libraries })
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
// @vitest-environment node
|
||||||
|
import { describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
vi.mock('@/lib/prisma', () => ({
|
||||||
|
prisma: {},
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/lib/services/furni-data', () => ({
|
||||||
|
appendFurniEntry: vi.fn(),
|
||||||
|
buildFurniEntry: vi.fn(),
|
||||||
|
removeFurniEntry: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/lib/services/habbo-furnidata-cache', () => ({
|
||||||
|
lookupHabboIt: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/lib/services/import/core/download', () => ({
|
||||||
|
downloadFile: vi.fn(),
|
||||||
|
validatePngBytes: vi.fn(),
|
||||||
|
validateSwfBytes: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/lib/services/swf-to-nitro', () => ({
|
||||||
|
convertSwfToNitro: vi.fn(),
|
||||||
|
extractIconFromSwf: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
import { buildItemsBaseEnrichmentUpdate } from './furni-import'
|
||||||
|
|
||||||
|
describe('buildItemsBaseEnrichmentUpdate', () => {
|
||||||
|
it('only updates columns present in the live Arcturus items_base schema', () => {
|
||||||
|
const update = buildItemsBaseEnrichmentUpdate({
|
||||||
|
id: 123,
|
||||||
|
dims: { x: 2, y: 3, z: 1.5 },
|
||||||
|
cansiton: true,
|
||||||
|
canlayon: false,
|
||||||
|
canstandon: true,
|
||||||
|
allowStack: '1',
|
||||||
|
interactionType: 'default',
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(update.sql).not.toContain('specialtype')
|
||||||
|
expect(update.values).toEqual([2, 3, 1.5, '1', '0', '1', '1', 'default', 123])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import { promises as fs } from 'node:fs'
|
||||||
|
|
||||||
|
export function validateSwfBytes(buffer: Buffer): boolean {
|
||||||
|
if (buffer.length < 8) return false
|
||||||
|
const sig = buffer.toString('ascii', 0, 3)
|
||||||
|
return sig === 'FWS' || sig === 'CWS' || sig === 'ZWS'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validatePngBytes(buffer: Buffer): boolean {
|
||||||
|
if (buffer.length < 8) return false
|
||||||
|
return buffer[0] === 137 && buffer[1] === 80 && buffer[2] === 78 && buffer[3] === 71
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function downloadFile(
|
||||||
|
url: string,
|
||||||
|
destPath: string,
|
||||||
|
options?: { maxRetries?: number; validate?: 'swf' | 'png' },
|
||||||
|
): Promise<{ ok: boolean; size: number }> {
|
||||||
|
const maxRetries = options?.maxRetries ?? 3
|
||||||
|
const baseDelay = 1000
|
||||||
|
|
||||||
|
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
||||||
|
try {
|
||||||
|
if (attempt > 0) {
|
||||||
|
await new Promise((r) => setTimeout(r, baseDelay * 2 ** (attempt - 1)))
|
||||||
|
}
|
||||||
|
const res = await fetch(url, { signal: AbortSignal.timeout(15000) })
|
||||||
|
if (!res.ok) {
|
||||||
|
const deterministic = res.status === 404 || res.status === 410 || res.status === 403
|
||||||
|
if (deterministic || attempt === maxRetries) {
|
||||||
|
console.warn(
|
||||||
|
`[import-download] Download failed ${url}: ${res.status}${deterministic ? ' (deterministic, not retrying)' : ` after ${maxRetries + 1} attempts`}`,
|
||||||
|
)
|
||||||
|
return { ok: false, size: 0 }
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const buffer = Buffer.from(await res.arrayBuffer())
|
||||||
|
if (buffer.length < 8) {
|
||||||
|
if (attempt === maxRetries) return { ok: false, size: 0 }
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (options?.validate === 'swf' && !validateSwfBytes(buffer)) {
|
||||||
|
if (attempt === maxRetries) {
|
||||||
|
console.warn(`[import-download] Invalid SWF magic bytes from ${url}`)
|
||||||
|
return { ok: false, size: 0 }
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (options?.validate === 'png' && !validatePngBytes(buffer)) {
|
||||||
|
if (attempt === maxRetries) {
|
||||||
|
console.warn(`[import-download] Invalid PNG magic bytes from ${url}`)
|
||||||
|
return { ok: false, size: 0 }
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
await fs.writeFile(/*turbopackIgnore: true*/ destPath, buffer)
|
||||||
|
return { ok: true, size: buffer.length }
|
||||||
|
} catch (err) {
|
||||||
|
if (attempt === maxRetries) {
|
||||||
|
console.warn(`[import-download] Download error ${url}:`, (err as Error).message)
|
||||||
|
return { ok: false, size: 0 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { ok: false, size: 0 }
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { promises as fs } from 'node:fs'
|
||||||
|
import os from 'node:os'
|
||||||
|
import path from 'node:path'
|
||||||
|
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||||
|
import { readGamedataJson, withGamedataLock, writeGamedataJsonAtomic } from './gamedata-json'
|
||||||
|
|
||||||
|
let dir: string
|
||||||
|
let file: string
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
dir = await fs.mkdtemp(path.join(os.tmpdir(), 'gamedata-'))
|
||||||
|
file = path.join(dir, 'EffectMap.json')
|
||||||
|
await fs.writeFile(file, JSON.stringify({ effects: [{ id: '1', lib: 'A' }] }))
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await fs.rm(dir, { recursive: true, force: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('import/core/gamedata-json', () => {
|
||||||
|
it('reads parsed JSON', async () => {
|
||||||
|
const data = await readGamedataJson<{ effects: unknown[] }>(file)
|
||||||
|
expect(data.effects).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('writes atomically (round-trips)', async () => {
|
||||||
|
await writeGamedataJsonAtomic(file, { effects: [{ id: '9', lib: 'Z' }] })
|
||||||
|
const data = await readGamedataJson<{ effects: Array<{ id: string }> }>(file)
|
||||||
|
expect(data.effects[0].id).toBe('9')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('serializes concurrent writers (no lost update)', async () => {
|
||||||
|
const bump = (n: number) =>
|
||||||
|
withGamedataLock(file, async () => {
|
||||||
|
const d = await readGamedataJson<{ effects: unknown[] }>(file)
|
||||||
|
d.effects.push({ id: String(n), lib: `L${n}` })
|
||||||
|
await writeGamedataJsonAtomic(file, d)
|
||||||
|
})
|
||||||
|
await Promise.all([bump(2), bump(3), bump(4)])
|
||||||
|
const d = await readGamedataJson<{ effects: unknown[] }>(file)
|
||||||
|
expect(d.effects).toHaveLength(4) // 1 seed + 3 appended, none lost
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { promises as fs } from 'node:fs'
|
||||||
|
|
||||||
|
const LOCK_STALE_MS = 60_000
|
||||||
|
const LOCK_ACQUIRE_TIMEOUT_MS = 30_000
|
||||||
|
|
||||||
|
// In-process serialization, keyed by absolute file path, layered on top of the
|
||||||
|
// on-disk lock so multiple awaiters in the same Node process queue cleanly.
|
||||||
|
const chains = new Map<string, Promise<void>>()
|
||||||
|
|
||||||
|
async function acquireDiskLock(lockPath: string): Promise<void> {
|
||||||
|
const deadline = Date.now() + LOCK_ACQUIRE_TIMEOUT_MS
|
||||||
|
while (true) {
|
||||||
|
try {
|
||||||
|
const fh = await fs.open(/*turbopackIgnore: true*/ lockPath, 'wx')
|
||||||
|
await fh.writeFile(String(process.pid))
|
||||||
|
await fh.close()
|
||||||
|
return
|
||||||
|
} catch (err) {
|
||||||
|
const e = err as NodeJS.ErrnoException
|
||||||
|
if (e.code !== 'EEXIST') throw err
|
||||||
|
try {
|
||||||
|
const stat = await fs.stat(/*turbopackIgnore: true*/ lockPath)
|
||||||
|
if (Date.now() - stat.mtimeMs > LOCK_STALE_MS) {
|
||||||
|
await fs.unlink(/*turbopackIgnore: true*/ lockPath).catch(() => {})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* lock vanished between checks */
|
||||||
|
}
|
||||||
|
if (Date.now() > deadline) throw new Error(`gamedata lock timeout: ${lockPath}`)
|
||||||
|
await new Promise((r) => setTimeout(r, 100))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function withGamedataLock<T>(filePath: string, fn: () => Promise<T>): Promise<T> {
|
||||||
|
let release!: () => void
|
||||||
|
const acquired = new Promise<void>((r) => {
|
||||||
|
release = r
|
||||||
|
})
|
||||||
|
const prev = chains.get(filePath) ?? Promise.resolve()
|
||||||
|
chains.set(filePath, acquired)
|
||||||
|
await prev
|
||||||
|
const lockPath = `${filePath}.lock`
|
||||||
|
await acquireDiskLock(lockPath)
|
||||||
|
try {
|
||||||
|
return await fn()
|
||||||
|
} finally {
|
||||||
|
await fs.unlink(/*turbopackIgnore: true*/ lockPath).catch(() => {})
|
||||||
|
release()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function readGamedataJson<T>(filePath: string): Promise<T> {
|
||||||
|
const raw = await fs.readFile(/*turbopackIgnore: true*/ filePath, 'utf-8')
|
||||||
|
return JSON.parse(raw) as T
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function writeGamedataJsonAtomic(filePath: string, data: unknown): Promise<void> {
|
||||||
|
const tmp = `${filePath}.tmp`
|
||||||
|
await fs.writeFile(/*turbopackIgnore: true*/ tmp, JSON.stringify(data, null, 2), 'utf-8')
|
||||||
|
await fs.rename(/*turbopackIgnore: true*/ tmp, filePath)
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { __clearGordonCache, resolveGordonBuildUrl } from './gordon'
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
__clearGordonCache()
|
||||||
|
vi.restoreAllMocks()
|
||||||
|
})
|
||||||
|
afterEach(() => vi.restoreAllMocks())
|
||||||
|
|
||||||
|
describe('import/core/gordon', () => {
|
||||||
|
it('parses flash.client.url from external_variables and normalizes', async () => {
|
||||||
|
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
|
||||||
|
new Response('flash.client.url=//images.habbo.com/gordon/PRODUCTION-XYZ/\n', { status: 200 }),
|
||||||
|
)
|
||||||
|
expect(await resolveGordonBuildUrl()).toBe('https://images.habbo.com/gordon/PRODUCTION-XYZ/')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('caches the resolved build (second call does not refetch)', async () => {
|
||||||
|
const spy = vi
|
||||||
|
.spyOn(globalThis, 'fetch')
|
||||||
|
.mockResolvedValue(new Response('flash.client.url=//x/gordon/B/\n', { status: 200 }))
|
||||||
|
await resolveGordonBuildUrl()
|
||||||
|
await resolveGordonBuildUrl()
|
||||||
|
expect(spy).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('throws when flash.client.url is absent', async () => {
|
||||||
|
vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response('other=1\n', { status: 200 }))
|
||||||
|
await expect(resolveGordonBuildUrl()).rejects.toThrow()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
const EXTERNAL_VARIABLES_URL = 'https://www.habbo.com/gamedata/external_variables/1'
|
||||||
|
const CACHE_TTL = 30 * 60 * 1000
|
||||||
|
|
||||||
|
let gordonCache: { url: string; ts: number } | null = null
|
||||||
|
|
||||||
|
export function __clearGordonCache(): void {
|
||||||
|
gordonCache = null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resolve the current Habbo "gordon" asset build URL (trailing slash) from external_variables. */
|
||||||
|
export async function resolveGordonBuildUrl(): Promise<string> {
|
||||||
|
const now = Date.now()
|
||||||
|
if (gordonCache && now - gordonCache.ts < CACHE_TTL) return gordonCache.url
|
||||||
|
const res = await fetch(EXTERNAL_VARIABLES_URL, { signal: AbortSignal.timeout(20000) })
|
||||||
|
if (!res.ok) throw new Error(`external_variables fetch failed: ${res.status}`)
|
||||||
|
const text = await res.text()
|
||||||
|
const line = /^flash\.client\.url=(.+)$/m.exec(text)?.[1]?.trim()
|
||||||
|
if (!line) throw new Error('flash.client.url not found in external_variables')
|
||||||
|
const normalized = (line.startsWith('//') ? `https:${line}` : line).replace(/\/?$/, '/')
|
||||||
|
gordonCache = { url: normalized, ts: now }
|
||||||
|
return normalized
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { runSseBatch } from './sse-batch'
|
||||||
|
|
||||||
|
async function collect(res: Response): Promise<Array<Record<string, unknown>>> {
|
||||||
|
const text = await res.text()
|
||||||
|
return text
|
||||||
|
.split('\n\n')
|
||||||
|
.filter((l) => l.startsWith('data: '))
|
||||||
|
.map((l) => JSON.parse(l.slice(6)))
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('import/core/sse-batch', () => {
|
||||||
|
it('emits batch_start, per-item, and batch_complete with correct counts', async () => {
|
||||||
|
const res = runSseBatch({
|
||||||
|
items: ['a', 'b', 'c'],
|
||||||
|
concurrency: 2,
|
||||||
|
worker: async (item) => ({ ok: item !== 'b', error: item === 'b' ? 'boom' : undefined }),
|
||||||
|
labelOf: (item) => item,
|
||||||
|
})
|
||||||
|
const events = await collect(res)
|
||||||
|
expect(events[0]).toMatchObject({ type: 'batch_start', total: 3, concurrency: 2 })
|
||||||
|
const done = events.filter((e) => e.type === 'item_progress' && e.status === 'done')
|
||||||
|
const failed = events.filter((e) => e.type === 'item_progress' && e.status === 'failed')
|
||||||
|
expect(done).toHaveLength(2)
|
||||||
|
expect(failed).toHaveLength(1)
|
||||||
|
expect(events.at(-1)).toMatchObject({ type: 'batch_complete', succeeded: 2, failed: 1 })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
@@ -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',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
@@ -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 }
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { resolveGordonBuildUrl } from '@/lib/services/import/core/gordon'
|
||||||
|
import { siteSettings } from '@/lib/services/site-settings'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The canonical Habbo pet set (matches renderer-config.json `pet.types`).
|
||||||
|
* The lib/filename for each is the name itself → `{name}.nitro`.
|
||||||
|
*/
|
||||||
|
export const CANONICAL_PETS: string[] = [
|
||||||
|
'dog',
|
||||||
|
'cat',
|
||||||
|
'croco',
|
||||||
|
'terrier',
|
||||||
|
'bear',
|
||||||
|
'pig',
|
||||||
|
'lion',
|
||||||
|
'rhino',
|
||||||
|
'spider',
|
||||||
|
'turtle',
|
||||||
|
'chicken',
|
||||||
|
'frog',
|
||||||
|
'dragon',
|
||||||
|
'monster',
|
||||||
|
'monkey',
|
||||||
|
'horse',
|
||||||
|
'monsterplant',
|
||||||
|
'bunnyeaster',
|
||||||
|
'bunnyevil',
|
||||||
|
'bunnydepressed',
|
||||||
|
'bunnylove',
|
||||||
|
'pigeongood',
|
||||||
|
'pigeonevil',
|
||||||
|
'demonmonkey',
|
||||||
|
'bearbaby',
|
||||||
|
'terrierbaby',
|
||||||
|
'gnome',
|
||||||
|
'kittenbaby',
|
||||||
|
'puppybaby',
|
||||||
|
'pigletbaby',
|
||||||
|
'haloompa',
|
||||||
|
'fools',
|
||||||
|
'pterosaur',
|
||||||
|
'velociraptor',
|
||||||
|
'cow',
|
||||||
|
'elephants',
|
||||||
|
]
|
||||||
|
|
||||||
|
export async function resolvePetSwfUrl(lib: string): Promise<string> {
|
||||||
|
const override = ((await siteSettings.get('pet_swf_base_url', '')) ?? '').trim()
|
||||||
|
if (override) return `${override.replace(/\/+$/, '')}/${lib}.swf`
|
||||||
|
const gordon = await resolveGordonBuildUrl()
|
||||||
|
return `${gordon}${lib}.swf`
|
||||||
|
}
|
||||||
@@ -70,6 +70,15 @@ class SiteSettings {
|
|||||||
return s === "1" || s === "true";
|
return s === "1" || s === "true";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async update(key: string, value: string): Promise<void> {
|
||||||
|
await prisma.websiteSetting.upsert({
|
||||||
|
where: { key },
|
||||||
|
create: { key, value },
|
||||||
|
update: { value },
|
||||||
|
});
|
||||||
|
this.reload();
|
||||||
|
}
|
||||||
|
|
||||||
reload(): void {
|
reload(): void {
|
||||||
this.cache = null;
|
this.cache = null;
|
||||||
if (redis) {
|
if (redis) {
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
export interface EffectMapEntry {
|
||||||
|
id: string
|
||||||
|
lib: string
|
||||||
|
type: string // 'fx' | 'dance'
|
||||||
|
revision: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EffectListItem extends EffectMapEntry {
|
||||||
|
present: boolean
|
||||||
|
nitroExists: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ImportEffectResult {
|
||||||
|
ok: boolean
|
||||||
|
lib: string
|
||||||
|
warnings: string[]
|
||||||
|
error?: string
|
||||||
|
}
|
||||||
|
|
||||||
Reference in new issue
Block a user