diff --git a/src/lib/api-handler.ts b/src/lib/api-handler.ts new file mode 100644 index 00000000..57e6a48f --- /dev/null +++ b/src/lib/api-handler.ts @@ -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>> +type RouteContext = { params?: Promise> } +type AdminHandler = ( + request: NextRequest, + context: AdminContext, + routeContext: RouteContext, +) => Promise | 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 }) + } + } +} diff --git a/src/lib/api-response.ts b/src/lib/api-response.ts new file mode 100644 index 00000000..622902f0 --- /dev/null +++ b/src/lib/api-response.ts @@ -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) { + 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) +} + diff --git a/src/lib/catalog-categories.ts b/src/lib/catalog-categories.ts new file mode 100644 index 00000000..5a4b92cf --- /dev/null +++ b/src/lib/catalog-categories.ts @@ -0,0 +1,2 @@ +export * from './catalog-categories/index' + diff --git a/src/lib/catalog-categories/categories.ts b/src/lib/catalog-categories/categories.ts new file mode 100644 index 00000000..cfd83468 --- /dev/null +++ b/src/lib/catalog-categories/categories.ts @@ -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 = { + 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', + }, +] + diff --git a/src/lib/catalog-categories/eras.ts b/src/lib/catalog-categories/eras.ts new file mode 100644 index 00000000..44054b24 --- /dev/null +++ b/src/lib/catalog-categories/eras.ts @@ -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] +} + diff --git a/src/lib/catalog-categories/index.ts b/src/lib/catalog-categories/index.ts new file mode 100644 index 00000000..160972d7 --- /dev/null +++ b/src/lib/catalog-categories/index.ts @@ -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 +} + diff --git a/src/lib/services/clone-icon.ts b/src/lib/services/clone-icon.ts new file mode 100644 index 00000000..0b6cd752 --- /dev/null +++ b/src/lib/services/clone-icon.ts @@ -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 + spritesheet?: { frames?: Record } +} + +/** + * 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 + } +} + diff --git a/src/lib/services/clone-import.test.ts b/src/lib/services/clone-import.test.ts new file mode 100644 index 00000000..70507d1a --- /dev/null +++ b/src/lib/services/clone-import.test.ts @@ -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(async () => ({ ok: true, size: 200 })), + appendFurniEntry: vi.fn(async () => {}), + parseNitroBundle: vi.fn(() => ({ json: {}, png: Buffer.alloc(0) })), + execRaw: vi.fn(async () => 1), + queryRaw: vi.fn(async () => [] as unknown[]), + fsUnlink: vi.fn(async () => {}), + fsReadFile: vi.fn(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(async () => {}), + getOrCreateCategoryPage: vi.fn(async () => 99), + autoPriceFurni: vi.fn(() => ({ credits: 5, points: 0, pointsType: 0 })), + allocateCatalogItemId: vi.fn(async (fn: (n: number) => Promise) => fn(1000)), +})) +vi.mock('@/lib/services/furni-asset-dirs', () => ({ + getFurniAssetDirs: vi.fn(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/) + }) +}) + diff --git a/src/lib/services/clone-import.ts b/src/lib/services/clone-import.ts new file mode 100644 index 00000000..435d2fee --- /dev/null +++ b/src/lib/services/clone-import.ts @@ -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> +} +interface RemoteFurnidata { + roomitemtypes?: FurniType + wallitemtypes?: FurniType + furnitype?: Array> +} + +// Spread ...raw first so the coerced typed fields always win over raw values. +function toSourceFurni(raw: Record, 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() +const TTL = 5 * 60 * 1000 + +export async function fetchSourceFurnidata(url: string, now = Date.now()): Promise { + 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 = Promise.resolve() + +async function allocateItemsBaseId(insertFn: (nextId: number) => Promise): Promise { + const prev = itemsBaseIdAllocChain + let settle!: () => void + itemsBaseIdAllocChain = new Promise((r) => { + settle = r + }) + await prev.catch(() => {}) + try { + const idRow = await prisma.$queryRaw>` + 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 { + 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>` + 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, 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 + 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() + if (allNames.length) { + const placeholders = allNames.map(() => '?').join(',') + const rows = await prisma.$queryRawUnsafe>( + `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() + if (classnames.length) { + const placeholders = classnames.map(() => '?').join(',') + const rows = await prisma.$queryRawUnsafe>( + `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 { + const all = await fetchSourceFurnidata(source.furnidataUrl) + const classnames = all.map((e) => e.classname) + const have = new Set() + if (classnames.length) { + const placeholders = classnames.map(() => '?').join(',') + const rows = await prisma.$queryRawUnsafe>( + `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) +} + diff --git a/src/lib/services/clone-sources.ts b/src/lib/services/clone-sources.ts new file mode 100644 index 00000000..23468d27 --- /dev/null +++ b/src/lib/services/clone-sources.ts @@ -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 { + 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 & { id?: string }, +): Promise { + 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 { + const sources = (await listSources()).filter((s) => s.id !== id) + await siteSettings.update(KEY, JSON.stringify(sources)) +} + +export async function getSource(id: string): Promise { + return (await listSources()).find((s) => s.id === id) ?? null +} diff --git a/src/lib/services/clothing-set-import.test.ts b/src/lib/services/clothing-set-import.test.ts new file mode 100644 index 00000000..218ec8d6 --- /dev/null +++ b/src/lib/services/clothing-set-import.test.ts @@ -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)), + mergePalette: (...a: unknown[]) => mergePalette(...(a as Parameters)), + listLocalSetIds: vi.fn(async () => new Set()), +})) +vi.mock('@/lib/services/figuremap', () => ({ + mergeLibrary: (...a: unknown[]) => mergeLibrary(...(a as Parameters)), + listFigureLibraries: vi.fn(async () => []), +})) +vi.mock('@/lib/services/figure-import', () => ({ + importSingleFigure: (...a: unknown[]) => + importSingleFigure(...(a as Parameters)), + 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) + }) +}) + diff --git a/src/lib/services/clothing-set-import.ts b/src/lib/services/clothing-set-import.ts new file mode 100644 index 00000000..463c16c0 --- /dev/null +++ b/src/lib/services/clothing-set-import.ts @@ -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 { + const index = new Map() + 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 { + const result: Record = { + 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 { + 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() + 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 { + 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 { + 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 } +} + diff --git a/src/lib/services/effect-import.test.ts b/src/lib/services/effect-import.test.ts new file mode 100644 index 00000000..87588670 --- /dev/null +++ b/src/lib/services/effect-import.test.ts @@ -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)), + 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)), +})) +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() + }) +}) + diff --git a/src/lib/services/effect-import.ts b/src/lib/services/effect-import.ts new file mode 100644 index 00000000..fed06ff7 --- /dev/null +++ b/src/lib/services/effect-import.ts @@ -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 { + 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 { + 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 { + 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 } +} + diff --git a/src/lib/services/effect-source.ts b/src/lib/services/effect-source.ts new file mode 100644 index 00000000..0ab7d751 --- /dev/null +++ b/src/lib/services/effect-source.ts @@ -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 = /]*)\/?>/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 { + 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 { + 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` +} diff --git a/src/lib/services/effectmap.ts b/src/lib/services/effectmap.ts new file mode 100644 index 00000000..a8c6fa5d --- /dev/null +++ b/src/lib/services/effectmap.ts @@ -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 { + 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 { + const file = await getEffectMapPath() + const data = await readGamedataJson>(file) + return { effects: Array.isArray(data.effects) ? data.effects : [] } +} + +export async function listLocalEffects(): Promise { + return (await readEffectMap()).effects +} + +export async function mergeEffect(entry: EffectMapEntry): Promise { + const file = await getEffectMapPath() + await withGamedataLock(file, async () => { + const data = await readGamedataJson>(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 { + const file = await getEffectMapPath() + return withGamedataLock(file, async () => { + const data = await readGamedataJson>(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 + }) +} diff --git a/src/lib/services/figure-import.test.ts b/src/lib/services/figure-import.test.ts new file mode 100644 index 00000000..46001d23 --- /dev/null +++ b/src/lib/services/figure-import.test.ts @@ -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)), +})) +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') + }) +}) + diff --git a/src/lib/services/figure-import.ts b/src/lib/services/figure-import.ts new file mode 100644 index 00000000..6bfd307e --- /dev/null +++ b/src/lib/services/figure-import.ts @@ -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 { + 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 { + 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 } +} + diff --git a/src/lib/services/figure-official.ts b/src/lib/services/figure-official.ts new file mode 100644 index 00000000..2cb3ada7 --- /dev/null +++ b/src/lib/services/figure-official.ts @@ -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 { + 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(/]*)>([\s\S]*?)<\/palette>/g)) { + const id = num(attr(pm[1], 'id')) + const colors: OfficialColor[] = [] + for (const cm of pm[2].matchAll(/]*)>([^<]*)<\/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(/]*)>([\s\S]*?)<\/settype>/g)) { + const a = sm[1] + const sets: OfficialSet[] = [] + for (const setM of sm[2].matchAll(/]*)>([\s\S]*?)<\/set>/g)) { + const sa = setM[1] + const parts: OfficialPart[] = [] + for (const pm of setM[2].matchAll(/]*)\/?>/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(/]*)\/?>/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 { + 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(/]*)>([\s\S]*?)<\/lib>/g)) { + const parts: { id: number; type: string }[] = [] + for (const pm of lm[2].matchAll(/]*)\/?>/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 +} diff --git a/src/lib/services/figure-source.ts b/src/lib/services/figure-source.ts new file mode 100644 index 00000000..e2a40ab3 --- /dev/null +++ b/src/lib/services/figure-source.ts @@ -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 { + 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` +} diff --git a/src/lib/services/figuredata.ts b/src/lib/services/figuredata.ts new file mode 100644 index 00000000..127b942b --- /dev/null +++ b/src/lib/services/figuredata.ts @@ -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 { + 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 { + const data = await readGamedataJson>(await getFigureDataPath()) + return { palettes: data.palettes ?? [], setTypes: data.setTypes ?? [] } +} + +export async function listLocalSetIds(setType: string): Promise> { + 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 { + const file = await getFigureDataPath() + await withGamedataLock(file, async () => { + const fd = await readGamedataJson(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 { + const file = await getFigureDataPath() + await withGamedataLock(file, async () => { + const fd = await readGamedataJson(file) + fd.palettes ??= [] + if (!fd.palettes.some((p) => p.id === palette.id)) { + fd.palettes.push(palette) + await writeGamedataJsonAtomic(file, fd) + } + }) +} diff --git a/src/lib/services/figuremap.ts b/src/lib/services/figuremap.ts new file mode 100644 index 00000000..e919c1ce --- /dev/null +++ b/src/lib/services/figuremap.ts @@ -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 { + 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 { + const file = await getFigureMapPath() + const data = await readGamedataJson>(file) + return Array.isArray(data.libraries) ? data.libraries : [] +} + +export async function mergeLibrary(lib: FigureLibrary): Promise { + 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 }) + }) +} diff --git a/src/lib/services/furni-import.test.ts b/src/lib/services/furni-import.test.ts new file mode 100644 index 00000000..fded3660 --- /dev/null +++ b/src/lib/services/furni-import.test.ts @@ -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]) + }) +}) + diff --git a/src/lib/services/furni-import.ts b/src/lib/services/furni-import.ts new file mode 100644 index 00000000..d1b64834 --- /dev/null +++ b/src/lib/services/furni-import.ts @@ -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_`) +export const CATEGORY_PAGE: Record = { + 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 { + 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 { + 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 } { + 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 { + 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__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_ → 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 = Promise.resolve() + +export async function allocateCatalogItemId( + insertFn: (nextId: number) => Promise, +): Promise { + const prev = catalogIdAllocChain + let settle!: () => void + catalogIdAllocChain = new Promise((r) => { + settle = r + }) + await prev.catch(() => {}) + try { + const maxIdResult = await prisma.$queryRaw>` + 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 { + 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 { + 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>` + 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, + } +} diff --git a/src/lib/services/furni-repair.ts b/src/lib/services/furni-repair.ts new file mode 100644 index 00000000..c7280cd1 --- /dev/null +++ b/src/lib/services/furni-repair.ts @@ -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 { + 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 = { 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 { + 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 } +} diff --git a/src/lib/services/habboassets.ts b/src/lib/services/habboassets.ts new file mode 100644 index 00000000..8d8a2099 --- /dev/null +++ b/src/lib/services/habboassets.ts @@ -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 | null = null + +/** + * Fetch and parse all badges from official Habbo external texts. + * Cached in memory for 30 minutes. + */ +async function loadBadges(): Promise { + 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() + const descMap = new Map() + + 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 { + 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 +} diff --git a/src/lib/services/habbofurni.ts b/src/lib/services/habbofurni.ts new file mode 100644 index 00000000..15b63abe --- /dev/null +++ b/src/lib/services/habbofurni.ts @@ -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 + } = {}, +): Promise { + 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 { + 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 { + 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> { + const result = new Map() + 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 +} + diff --git a/src/lib/services/imager/png-decode.test.ts b/src/lib/services/imager/png-decode.test.ts new file mode 100644 index 00000000..fa44a6f9 --- /dev/null +++ b/src/lib/services/imager/png-decode.test.ts @@ -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') + }) +}) + diff --git a/src/lib/services/imager/png-decode.ts b/src/lib/services/imager/png-decode.ts new file mode 100644 index 00000000..f6a9da13 --- /dev/null +++ b/src/lib/services/imager/png-decode.ts @@ -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 +} + diff --git a/src/lib/services/import/core/asset-paths.test.ts b/src/lib/services/import/core/asset-paths.test.ts new file mode 100644 index 00000000..20f62fa4 --- /dev/null +++ b/src/lib/services/import/core/asset-paths.test.ts @@ -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) + }) +}) + diff --git a/src/lib/services/import/core/asset-paths.ts b/src/lib/services/import/core/asset-paths.ts new file mode 100644 index 00000000..3bc06396 --- /dev/null +++ b/src/lib/services/import/core/asset-paths.ts @@ -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/` 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 +} + diff --git a/src/lib/services/import/core/download.test.ts b/src/lib/services/import/core/download.test.ts new file mode 100644 index 00000000..46065ec9 --- /dev/null +++ b/src/lib/services/import/core/download.test.ts @@ -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) + }) +}) + diff --git a/src/lib/services/import/core/download.ts b/src/lib/services/import/core/download.ts new file mode 100644 index 00000000..ce690417 --- /dev/null +++ b/src/lib/services/import/core/download.ts @@ -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 } +} + diff --git a/src/lib/services/import/core/gamedata-json.test.ts b/src/lib/services/import/core/gamedata-json.test.ts new file mode 100644 index 00000000..f16b1c10 --- /dev/null +++ b/src/lib/services/import/core/gamedata-json.test.ts @@ -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 + }) +}) + diff --git a/src/lib/services/import/core/gamedata-json.ts b/src/lib/services/import/core/gamedata-json.ts new file mode 100644 index 00000000..4242d109 --- /dev/null +++ b/src/lib/services/import/core/gamedata-json.ts @@ -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>() + +async function acquireDiskLock(lockPath: string): Promise { + 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(filePath: string, fn: () => Promise): Promise { + let release!: () => void + const acquired = new Promise((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(filePath: string): Promise { + 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 { + const tmp = `${filePath}.tmp` + await fs.writeFile(/*turbopackIgnore: true*/ tmp, JSON.stringify(data, null, 2), 'utf-8') + await fs.rename(/*turbopackIgnore: true*/ tmp, filePath) +} + diff --git a/src/lib/services/import/core/gordon.test.ts b/src/lib/services/import/core/gordon.test.ts new file mode 100644 index 00000000..a40aa2bf --- /dev/null +++ b/src/lib/services/import/core/gordon.test.ts @@ -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() + }) +}) + diff --git a/src/lib/services/import/core/gordon.ts b/src/lib/services/import/core/gordon.ts new file mode 100644 index 00000000..a5019163 --- /dev/null +++ b/src/lib/services/import/core/gordon.ts @@ -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 { + 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 +} + diff --git a/src/lib/services/import/core/sse-batch.test.ts b/src/lib/services/import/core/sse-batch.test.ts new file mode 100644 index 00000000..4d854f6c --- /dev/null +++ b/src/lib/services/import/core/sse-batch.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from 'vitest' +import { runSseBatch } from './sse-batch' + +async function collect(res: Response): Promise>> { + 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 }) + }) +}) + diff --git a/src/lib/services/import/core/sse-batch.ts b/src/lib/services/import/core/sse-batch.ts new file mode 100644 index 00000000..d1e26d3d --- /dev/null +++ b/src/lib/services/import/core/sse-batch.ts @@ -0,0 +1,106 @@ +export interface SseWorkerResult { + ok: boolean + warnings?: string[] + error?: string +} + +export interface RunSseBatchOptions { + 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 +} + +/** + * Generic SSE batch runner. Emits the same event shape the furni client + * parser consumes: batch_start / item_progress / batch_complete. + */ +export function runSseBatch(opts: RunSseBatchOptions): 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', + }, + }) +} + diff --git a/src/lib/services/nitro-assets.ts b/src/lib/services/nitro-assets.ts new file mode 100644 index 00000000..85a3aab9 --- /dev/null +++ b/src/lib/services/nitro-assets.ts @@ -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 { + 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 { + 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> { + try { + const { nitroDir } = await getFurniAssetDirs() + const entries = await fs.readdir(nitroDir, { recursive: true }) + const classnames = new Set() + 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() + } +} + diff --git a/src/lib/services/pet-icon.ts b/src/lib/services/pet-icon.ts new file mode 100644 index 00000000..b3d32195 --- /dev/null +++ b/src/lib/services/pet-icon.ts @@ -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 }> +} +interface Visualization { + size?: number + layerCount?: number + animations?: Record }> +} +interface NitroJson { + name?: string + assets?: Record + spritesheet?: { frames?: Record } + 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() + +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 = { + 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 + } +} + diff --git a/src/lib/services/pet-import.test.ts b/src/lib/services/pet-import.test.ts new file mode 100644 index 00000000..eb0fbfd5 --- /dev/null +++ b/src/lib/services/pet-import.test.ts @@ -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)), +})) +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') + }) +}) + diff --git a/src/lib/services/pet-import.ts b/src/lib/services/pet-import.ts new file mode 100644 index 00000000..e66fbe18 --- /dev/null +++ b/src/lib/services/pet-import.ts @@ -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 { + 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 { + 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 } +} + diff --git a/src/lib/services/pet-source.ts b/src/lib/services/pet-source.ts new file mode 100644 index 00000000..1de03803 --- /dev/null +++ b/src/lib/services/pet-source.ts @@ -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 { + 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` +} diff --git a/src/lib/services/site-settings.ts b/src/lib/services/site-settings.ts index 81733c30..fd9bde60 100644 --- a/src/lib/services/site-settings.ts +++ b/src/lib/services/site-settings.ts @@ -70,6 +70,15 @@ class SiteSettings { return s === "1" || s === "true"; } + async update(key: string, value: string): Promise { + await prisma.websiteSetting.upsert({ + where: { key }, + create: { key, value }, + update: { value }, + }); + this.reload(); + } + reload(): void { this.cache = null; if (redis) { diff --git a/src/types/effects.ts b/src/types/effects.ts new file mode 100644 index 00000000..234817d8 --- /dev/null +++ b/src/types/effects.ts @@ -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 +} +