From c78f8812ad81341fc871cd6c2469353d6e06e0a4 Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Sun, 2 Aug 2026 14:22:05 +0200 Subject: [PATCH] fix: use configured furni source and catalog assets --- src/actions/admin-settings.ts | 6 +- .../import/furni/import-furni-client.tsx | 30 ++++++- .../admin/import/furni/import-source.test.ts | 38 +++++++++ src/app/admin/import/furni/page.tsx | 5 +- src/app/api/admin/catalog/icons/route.ts | 35 ++++----- src/app/api/admin/catalog/images/route.ts | 38 ++++----- .../admin/import/furni/batch-regen/route.ts | 6 +- .../api/admin/import/furni/resync/route.ts | 16 ++-- src/app/api/admin/import/furni/route.ts | 10 +-- .../admin/catalog-manager/image-preview.tsx | 3 +- src/components/admin/catalog-tree.tsx | 3 +- .../admin/catalog/catalog-image-picker.tsx | 6 +- src/components/admin/catalog/icon-picker.tsx | 3 +- src/lib/catalog-assets.test.ts | 17 ++++ src/lib/catalog-assets.ts | 12 +++ src/lib/habbo-gamedata-hotel.ts | 27 +++++++ src/lib/services/catalog-assets.test.ts | 23 ++++++ src/lib/services/catalog-assets.ts | 26 +++++++ src/lib/services/furni-data.ts | 38 ++++----- src/lib/services/furni-import.test.ts | 2 +- src/lib/services/furni-import.ts | 43 +++++----- .../services/habbo-furnidata-cache.test.ts | 78 +++++++++++++++++++ src/lib/services/habbo-furnidata-cache.ts | 22 +++--- src/lib/services/habbofurni.ts | 8 +- src/types/furni.ts | 2 +- 25 files changed, 368 insertions(+), 129 deletions(-) create mode 100644 src/app/admin/import/furni/import-source.test.ts create mode 100644 src/lib/catalog-assets.test.ts create mode 100644 src/lib/catalog-assets.ts create mode 100644 src/lib/services/catalog-assets.test.ts create mode 100644 src/lib/services/catalog-assets.ts create mode 100644 src/lib/services/habbo-furnidata-cache.test.ts diff --git a/src/actions/admin-settings.ts b/src/actions/admin-settings.ts index cdc187c0..391c370c 100644 --- a/src/actions/admin-settings.ts +++ b/src/actions/admin-settings.ts @@ -12,7 +12,7 @@ import { normalizeHabboGamedataHotel, } from "@/lib/habbo-gamedata-hotel"; import { PERMS } from "@/lib/permissions"; -import { clearHabboItCache } from "@/lib/services/habbo-furnidata-cache"; +import { clearOfficialHabboFurnidataCache } from "@/lib/services/habbo-furnidata-cache"; import { clearBadgeCache } from "@/lib/services/habboassets"; import { siteSettings } from "@/lib/services/site-settings"; @@ -27,7 +27,7 @@ function normalizeSettingValue(key: string, value: string): string { function bustGamedataCachesIfNeeded(key: string): void { if (key === HABBO_GAMEDATA_HOTEL_SETTING_KEY) { - clearHabboItCache(); + clearOfficialHabboFurnidataCache(); clearBadgeCache(); } } @@ -57,7 +57,7 @@ export const saveManagedSettings = adminAction( ); await siteSettings.reload(); if (entries.some(([key]) => key === HABBO_GAMEDATA_HOTEL_SETTING_KEY)) { - clearHabboItCache(); + clearOfficialHabboFurnidataCache(); clearBadgeCache(); } revalidatePath("/admin/settings"); diff --git a/src/app/admin/import/furni/import-furni-client.tsx b/src/app/admin/import/furni/import-furni-client.tsx index 399c83c1..dcde3aae 100644 --- a/src/app/admin/import/furni/import-furni-client.tsx +++ b/src/app/admin/import/furni/import-furni-client.tsx @@ -7,8 +7,10 @@ import { Check, CheckCheck, Download, + ExternalLink, FileWarning, FolderSync, + Globe2, LayoutGrid, List, Loader2, @@ -52,6 +54,7 @@ import { SelectValue, } from "@/components/ui/select"; import { adminFetch } from "@/lib/admin-fetch"; +import type { FurniImportSource } from "@/lib/habbo-gamedata-hotel"; import { NitroEditorDialog } from "./nitro-editor-dialog"; interface FurniItem { @@ -146,7 +149,7 @@ const STATUS_LABELS: Record = { failed: "Failed", }; -export function ImportFurniClient() { +export function ImportFurniClient({ source }: { source: FurniImportSource }) { const [searchTerm, setSearchTerm] = useState(""); const [activeSearch, setActiveSearch] = useState(""); const [items, setItems] = useState([]); @@ -936,6 +939,31 @@ export function ImportFurniClient() { return (
+
+
+
+ +
+
+

+ Official furnidata source +

+

{source.label}

+

+ Furniture assets: {source.assetCdnHost} +

+
+
+ + {source.host} + + +
{/* Dashboard Statistics */} {!stats && (
diff --git a/src/app/admin/import/furni/import-source.test.ts b/src/app/admin/import/furni/import-source.test.ts new file mode 100644 index 00000000..725b9648 --- /dev/null +++ b/src/app/admin/import/furni/import-source.test.ts @@ -0,0 +1,38 @@ +// @vitest-environment node +import { describe, expect, it } from "vitest"; +import * as hotelHelpers from "@/lib/habbo-gamedata-hotel"; + +describe("furni import source metadata", () => { + it("describes the configured hotel without changing the global asset CDN", () => { + const helpers = hotelHelpers as unknown as { + buildFurniImportSource?: (hotel: string) => { + hotel: string; + label: string; + host: string; + furnidataUrl: string; + assetCdnHost: string; + }; + officialHabboEnrichmentWarning?: (hotel: string, name: string) => string; + }; + + expect(helpers.buildFurniImportSource).toBeTypeOf("function"); + expect(helpers.officialHabboEnrichmentWarning).toBeTypeOf("function"); + if ( + !helpers.buildFurniImportSource || + !helpers.officialHabboEnrichmentWarning + ) { + return; + } + + expect(helpers.buildFurniImportSource("nl")).toEqual({ + hotel: "nl", + label: "Netherlands (habbo.nl)", + host: "habbo.nl", + furnidataUrl: "https://www.habbo.nl/gamedata/furnidata_json/1", + assetCdnHost: "images.habbo.com", + }); + expect(helpers.officialHabboEnrichmentWarning("nl", "Stoel")).toBe( + 'Enriched from habbo.nl: "Stoel"', + ); + }); +}); diff --git a/src/app/admin/import/furni/page.tsx b/src/app/admin/import/furni/page.tsx index 7dac0e5d..59ebb61e 100644 --- a/src/app/admin/import/furni/page.tsx +++ b/src/app/admin/import/furni/page.tsx @@ -1,5 +1,7 @@ import { redirect } from "next/navigation"; +import { buildFurniImportSource } from "@/lib/habbo-gamedata-hotel"; import { canAccess, getAdminContext, PERMS } from "@/lib/permissions"; +import { getHabboGamedataHotel } from "@/lib/services/habbo-gamedata-hotel"; import { ImportFurniClient } from "./import-furni-client"; export default async function ImportFurniPage(_props: { @@ -10,5 +12,6 @@ export default async function ImportFurniPage(_props: { redirect("/admin"); } - return ; + const hotel = await getHabboGamedataHotel(); + return ; } diff --git a/src/app/api/admin/catalog/icons/route.ts b/src/app/api/admin/catalog/icons/route.ts index 715ac5a5..e22c08e8 100644 --- a/src/app/api/admin/catalog/icons/route.ts +++ b/src/app/api/admin/catalog/icons/route.ts @@ -1,16 +1,11 @@ -import { promises as fs } from "node:fs"; -import path from "node:path"; import { withAdmin } from "@/lib/api-handler"; import { apiOk } from "@/lib/api-response"; import { PERMS } from "@/lib/permissions"; - -const CATALOGUE_DIR = path.join( - /*turbopackIgnore: true*/ process.cwd(), - "public", - "swf", - "c_images", - "catalogue", -); +import { + catalogueAssetDirectories, + readCatalogueAssetFiles, +} from "@/lib/services/catalog-assets"; +import { getGamedataRoot } from "@/lib/services/furni-asset-dirs"; export const GET = withAdmin( { permission: PERMS.CATALOG_VIEW }, @@ -23,18 +18,14 @@ export const GET = withAdmin( const offset = Math.max(Number(sp.get("offset") || 0) || 0, 0); const search = (sp.get("search") || "").trim().toLowerCase(); - let icons: number[] = []; - try { - const files = await fs.readdir(CATALOGUE_DIR); - icons = files - .map((f) => /^icon_(\d+)\.(?:png|gif)$/i.exec(f)) - .filter((m): m is RegExpExecArray => Boolean(m)) - .map((m) => Number(m[1])) - .filter((n) => Number.isFinite(n)) - .sort((a, b) => a - b); - } catch { - icons = []; - } + const directories = catalogueAssetDirectories(await getGamedataRoot()); + const files = await readCatalogueAssetFiles(directories); + let icons = files + .map((f) => /^icon_(\d+)\.(?:png|gif)$/i.exec(f)) + .filter((m): m is RegExpExecArray => Boolean(m)) + .map((m) => Number(m[1])) + .filter((n) => Number.isFinite(n)) + .sort((a, b) => a - b); if (search) { icons = icons.filter((id) => String(id).includes(search)); diff --git a/src/app/api/admin/catalog/images/route.ts b/src/app/api/admin/catalog/images/route.ts index 543509d1..a371b66d 100644 --- a/src/app/api/admin/catalog/images/route.ts +++ b/src/app/api/admin/catalog/images/route.ts @@ -1,16 +1,11 @@ -import { promises as fs } from "node:fs"; -import path from "node:path"; import { withAdmin } from "@/lib/api-handler"; import { apiOk } from "@/lib/api-response"; import { PERMS } from "@/lib/permissions"; - -const CATALOGUE_DIR = path.join( - /*turbopackIgnore: true*/ process.cwd(), - "public", - "swf", - "c_images", - "catalogue", -); +import { + catalogueAssetDirectories, + readCatalogueAssetFiles, +} from "@/lib/services/catalog-assets"; +import { getGamedataRoot } from "@/lib/services/furni-asset-dirs"; export const GET = withAdmin( { permission: PERMS.CATALOG_VIEW }, @@ -25,20 +20,17 @@ export const GET = withAdmin( const type = sp.get("type") || "all"; let names: string[] = []; - try { - const files = await fs.readdir(CATALOGUE_DIR); - const seen = new Set(); - for (const file of files) { - if (/^icon_\d+\./i.test(file)) continue; - const base = file.replace(/\.(png|gif|jpg|jpeg|webp)$/i, ""); - if (!base || seen.has(base)) continue; - seen.add(base); - names.push(base); - } - names.sort((a, b) => a.localeCompare(b)); - } catch { - names = []; + const directories = catalogueAssetDirectories(await getGamedataRoot()); + const files = await readCatalogueAssetFiles(directories); + const seen = new Set(); + for (const file of files) { + if (/^icon_\d+\./i.test(file)) continue; + const base = file.replace(/\.(png|gif|jpg|jpeg|webp)$/i, ""); + if (!base || seen.has(base)) continue; + seen.add(base); + names.push(base); } + names.sort((a, b) => a.localeCompare(b)); if (type === "header") { names = names.filter((n) => /header/i.test(n)); diff --git a/src/app/api/admin/import/furni/batch-regen/route.ts b/src/app/api/admin/import/furni/batch-regen/route.ts index 68e78cbd..3655fa46 100644 --- a/src/app/api/admin/import/furni/batch-regen/route.ts +++ b/src/app/api/admin/import/furni/batch-regen/route.ts @@ -12,7 +12,7 @@ import { readFurniData, } from "@/lib/services/furni-data"; import { downloadFile, ensureDirectories } from "@/lib/services/furni-import"; -import { getHabboItFurnidata } from "@/lib/services/habbo-furnidata-cache"; +import { getOfficialHabboFurnidata } from "@/lib/services/habbo-furnidata-cache"; import { rcon } from "@/lib/services/rcon"; import { convertSwfToNitro } from "@/lib/services/swf-to-nitro"; @@ -62,7 +62,7 @@ export const POST = withAdmin( }> = []; // Load furnidata once for all items - const furnidata = await getHabboItFurnidata(); + const furnidata = await getOfficialHabboFurnidata(); // Load existing FurnitureData.json classnames for dedup const existingFD = (await readFurniData()) as { @@ -204,7 +204,7 @@ export const POST = withAdmin( spriteId: dbItem?.spriteId || 0, dims: conversion.dimensions || { x: 1, y: 1, z: 1 }, metadata: conversion.metadata || null, - habboIt: fd || null, + officialHabbo: fd || null, }); furniDataEntries.push({ entry, itemType }); fdClassnames.add(baseClassname); diff --git a/src/app/api/admin/import/furni/resync/route.ts b/src/app/api/admin/import/furni/resync/route.ts index 79998c4e..e29b2b77 100644 --- a/src/app/api/admin/import/furni/resync/route.ts +++ b/src/app/api/admin/import/furni/resync/route.ts @@ -10,7 +10,7 @@ import { getFurnitureDataPath, readFurniData, } from "@/lib/services/furni-data"; -import { lookupHabboIt } from "@/lib/services/habbo-furnidata-cache"; +import { lookupOfficialHabboFurni } from "@/lib/services/habbo-furnidata-cache"; import { rcon } from "@/lib/services/rcon"; /** @@ -86,9 +86,9 @@ export const POST = withAdmin( for (const row of rows) { try { const classname = row.itemName; - let habboIt = null; + let officialHabbo = null; try { - habboIt = await lookupHabboIt(classname); + officialHabbo = await lookupOfficialHabboFurni(classname); } catch { /* optional */ } @@ -96,10 +96,10 @@ export const POST = withAdmin( const entry = buildFurniEntry({ id: row.id, classname, - revision: habboIt?.revision ?? 0, - category: habboIt?.category || "unknown", - name: row.publicName || habboIt?.name || classname, - description: habboIt?.description || "", + revision: officialHabbo?.revision ?? 0, + category: officialHabbo?.category || "unknown", + name: row.publicName || officialHabbo?.name || classname, + description: officialHabbo?.description || "", spriteId: row.spriteId, dims: { x: row.width, @@ -107,7 +107,7 @@ export const POST = withAdmin( z: Math.round(Number(row.stackHeight)), }, metadata: null, - habboIt, + officialHabbo, }); entries.push({ entry, itemType: row.type === "i" ? "i" : "s" }); diff --git a/src/app/api/admin/import/furni/route.ts b/src/app/api/admin/import/furni/route.ts index 094a1f2f..8b7ac1ff 100644 --- a/src/app/api/admin/import/furni/route.ts +++ b/src/app/api/admin/import/furni/route.ts @@ -18,7 +18,7 @@ import { IMPORTED_PAGE_CAPTION_SAVE, importSingleFurni, } from "@/lib/services/furni-import"; -import { getHabboItFurnidata } from "@/lib/services/habbo-furnidata-cache"; +import { getOfficialHabboFurnidata } from "@/lib/services/habbo-furnidata-cache"; import { listFurniture } from "@/lib/services/habbofurni"; import { rcon } from "@/lib/services/rcon"; import { convertSwfToNitro } from "@/lib/services/swf-to-nitro"; @@ -183,7 +183,7 @@ export const GET = withAdmin( const pageItems = missing.slice(start, start + perPage); // Enrich with revision from official furnidata - const furnidata = await getHabboItFurnidata(); + const furnidata = await getOfficialHabboFurnidata(); const items = pageItems.map((item) => { const fd = furnidata.get(item.item_name) || @@ -434,7 +434,7 @@ export const PATCH = withAdmin( // Resolve revision: try furnidata first, then spriteId as fallback let rev = item.revision || 0; if (rev === 0) { - const furnidata = await getHabboItFurnidata(); + const furnidata = await getOfficialHabboFurnidata(); const fd = furnidata.get(item.classname) || furnidata.get(baseClassname) || @@ -516,7 +516,7 @@ export const PATCH = withAdmin( .from(ItemsBase) .where(eq(ItemsBase.itemName, item.classname)) .limit(1); - const furnidata = await getHabboItFurnidata(); + const furnidata = await getOfficialHabboFurnidata(); const fd = furnidata.get(item.classname) || furnidata.get(baseClassname); const itemType = dbItem?.type === "i" ? "i" : "s"; @@ -531,7 +531,7 @@ export const PATCH = withAdmin( spriteId: dbItem?.spriteId || 0, dims: conversion.dimensions || { x: 1, y: 1, z: 1 }, metadata: conversion.metadata || null, - habboIt: fd || null, + officialHabbo: fd || null, }); await appendFurniEntry(entry, itemType); } diff --git a/src/components/admin/catalog-manager/image-preview.tsx b/src/components/admin/catalog-manager/image-preview.tsx index a615c403..e4127d3e 100644 --- a/src/components/admin/catalog-manager/image-preview.tsx +++ b/src/components/admin/catalog-manager/image-preview.tsx @@ -2,6 +2,7 @@ import { ImageOff, Loader2 } from "lucide-react"; import { useEffect, useRef, useState } from "react"; +import { CATALOGUE_ASSET_BASE_PATH } from "@/lib/catalog-assets"; interface ImagePreviewProps { value: string; @@ -12,7 +13,7 @@ interface ImagePreviewProps { export function ImagePreview({ value, - basePath = "/swf/c_images/catalogue", + basePath = CATALOGUE_ASSET_BASE_PATH, height = 60, className, }: ImagePreviewProps) { diff --git a/src/components/admin/catalog-tree.tsx b/src/components/admin/catalog-tree.tsx index 15f7adc3..5e371ad4 100644 --- a/src/components/admin/catalog-tree.tsx +++ b/src/components/admin/catalog-tree.tsx @@ -51,6 +51,7 @@ import { TooltipTrigger, } from "@/components/ui/tooltip"; import { adminFetch } from "@/lib/admin-fetch"; +import { catalogueIconUrl } from "@/lib/catalog-assets"; import { translateCaption } from "@/lib/catalog-translations"; import { cn } from "@/lib/utils"; @@ -115,7 +116,7 @@ export function CatalogIcon({ return ( { + it("uses the production gamedata mount for category icons", () => { + expect(catalogueIconUrl(7)).toBe("/gamedata/c_images/catalogue/icon_7.png"); + }); + + it("encodes catalogue image names and supports both image formats", () => { + expect(catalogueAssetUrl("front page", "png")).toBe( + "/gamedata/c_images/catalogue/front%20page.png", + ); + expect(catalogueAssetUrl("front page", "gif")).toBe( + "/gamedata/c_images/catalogue/front%20page.gif", + ); + }); +}); diff --git a/src/lib/catalog-assets.ts b/src/lib/catalog-assets.ts new file mode 100644 index 00000000..34bee162 --- /dev/null +++ b/src/lib/catalog-assets.ts @@ -0,0 +1,12 @@ +export const CATALOGUE_ASSET_BASE_PATH = "/gamedata/c_images/catalogue"; + +export function catalogueIconUrl(iconImage: number): string { + return `${CATALOGUE_ASSET_BASE_PATH}/icon_${iconImage}.png`; +} + +export function catalogueAssetUrl( + name: string, + extension: "png" | "gif", +): string { + return `${CATALOGUE_ASSET_BASE_PATH}/${encodeURIComponent(name)}.${extension}`; +} diff --git a/src/lib/habbo-gamedata-hotel.ts b/src/lib/habbo-gamedata-hotel.ts index 604e7448..f56dd0de 100644 --- a/src/lib/habbo-gamedata-hotel.ts +++ b/src/lib/habbo-gamedata-hotel.ts @@ -19,6 +19,14 @@ export const HABBO_GAMEDATA_HOTELS = [ export type HabboGamedataHotel = (typeof HABBO_GAMEDATA_HOTELS)[number]["value"]; +export interface FurniImportSource { + hotel: HabboGamedataHotel; + label: string; + host: string; + furnidataUrl: string; + assetCdnHost: "images.habbo.com"; +} + export const HABBO_GAMEDATA_HOTEL_SETTING_KEY = "habbo_gamedata_hotel"; export const DEFAULT_HABBO_GAMEDATA_HOTEL: HabboGamedataHotel = "it"; @@ -47,6 +55,25 @@ export function habboFurnidataUrl(hotel: string): string { return `https://www.habbo.${h}/gamedata/furnidata_json/1`; } +export function buildFurniImportSource(hotel: string): FurniImportSource { + const normalized = normalizeHabboGamedataHotel(hotel); + return { + hotel: normalized, + label: habboGamedataHotelLabel(normalized), + host: `habbo.${normalized}`, + furnidataUrl: habboFurnidataUrl(normalized), + assetCdnHost: "images.habbo.com", + }; +} + +export function officialHabboEnrichmentWarning( + hotel: string, + name: string, +): string { + const normalized = normalizeHabboGamedataHotel(hotel); + return `Enriched from habbo.${normalized}: "${name}"`; +} + export function habboExternalTextsUrl(hotel: string): string { const h = normalizeHabboGamedataHotel(hotel); return `https://www.habbo.${h}/gamedata/external_flash_texts/1`; diff --git a/src/lib/services/catalog-assets.test.ts b/src/lib/services/catalog-assets.test.ts new file mode 100644 index 00000000..628af4fb --- /dev/null +++ b/src/lib/services/catalog-assets.test.ts @@ -0,0 +1,23 @@ +import path from "node:path"; +import { describe, expect, it } from "vitest"; +import { catalogueAssetDirectories } from "@/lib/services/catalog-assets"; + +describe("catalogue asset directories", () => { + it("prefers the configured Gamedata catalogue and retains the bundled fallback", () => { + const cwd = path.join("test", "cms"); + const gamedataRoot = path.join("test", "Gamedata"); + + expect(catalogueAssetDirectories(gamedataRoot, cwd)).toEqual([ + path.join(gamedataRoot, "c_images", "catalogue"), + path.join(cwd, "public", "swf", "c_images", "catalogue"), + ]); + }); + + it("uses only the bundled catalogue when no Gamedata root is configured", () => { + const cwd = path.join("test", "cms"); + + expect(catalogueAssetDirectories("", cwd)).toEqual([ + path.join(cwd, "public", "swf", "c_images", "catalogue"), + ]); + }); +}); diff --git a/src/lib/services/catalog-assets.ts b/src/lib/services/catalog-assets.ts new file mode 100644 index 00000000..8f527bdb --- /dev/null +++ b/src/lib/services/catalog-assets.ts @@ -0,0 +1,26 @@ +import { promises as fs } from "node:fs"; +import path from "node:path"; + +export function catalogueAssetDirectories( + gamedataRoot: string, + cwd = process.cwd(), +): string[] { + const bundled = path.join(cwd, "public", "swf", "c_images", "catalogue"); + if (!gamedataRoot.trim()) return [bundled]; + + return [path.join(gamedataRoot, "c_images", "catalogue"), bundled]; +} + +export async function readCatalogueAssetFiles( + directories: string[], +): Promise { + const files = new Set(); + for (const directory of directories) { + try { + for (const file of await fs.readdir(directory)) files.add(file); + } catch { + // A missing optional source must not hide assets from the other source. + } + } + return [...files]; +} diff --git a/src/lib/services/furni-data.ts b/src/lib/services/furni-data.ts index 5d1d6025..f5e114e2 100644 --- a/src/lib/services/furni-data.ts +++ b/src/lib/services/furni-data.ts @@ -4,7 +4,7 @@ import { getGamedataRoot, getNitroFilesRoot, } from "@/lib/services/furni-asset-dirs"; -import type { HabboItFurniEntry } from "@/lib/services/habbo-furnidata-cache"; +import type { OfficialHabboFurniEntry } from "@/lib/services/habbo-furnidata-cache"; import { siteSettings } from "@/lib/services/site-settings"; import type { FurniMetadata } from "@/lib/services/swf-to-nitro"; @@ -150,7 +150,7 @@ export function buildFurniEntry(params: { spriteId: number; dims: { x: number; y: number; z: number }; metadata: FurniMetadata | null; - habboIt?: HabboItFurniEntry | null; + officialHabbo?: OfficialHabboFurniEntry | null; }): Record { const { id, @@ -162,20 +162,20 @@ export function buildFurniEntry(params: { spriteId, dims, metadata, - habboIt, + officialHabbo, } = params; - // Merge partcolors: prefer habbo.it (has real hex colors), fallback to .nitro extraction - const partcolors = habboIt?.partcolors?.color?.length - ? habboIt.partcolors + // Prefer the configured official hotel's real hex colors, then .nitro metadata. + const partcolors = officialHabbo?.partcolors?.color?.length + ? officialHabbo.partcolors : (metadata?.partcolors ?? { color: [] }); return { id, classname, - revision: revision || habboIt?.revision || 0, - category: category || habboIt?.category || "unknown", - defaultdir: metadata?.defaultdir ?? habboIt?.defaultdir ?? 0, + revision: revision || officialHabbo?.revision || 0, + category: category || officialHabbo?.category || "unknown", + defaultdir: metadata?.defaultdir ?? officialHabbo?.defaultdir ?? 0, xdim: dims.x, ydim: dims.y, partcolors, @@ -183,19 +183,19 @@ export function buildFurniEntry(params: { description: description || "", adurl: "", offerid: spriteId, - buyout: !!habboIt?.bc, + buyout: !!officialHabbo?.bc, rentofferid: -1, rentbuyout: false, - bc: habboIt?.bc ?? false, + bc: officialHabbo?.bc ?? false, excludeddynamic: false, - customparams: habboIt?.customparams ?? "", - specialtype: metadata?.specialtype ?? habboIt?.specialtype ?? 0, - canstandon: metadata?.canstandon ?? habboIt?.canstandon ?? false, - cansiton: metadata?.cansiton ?? habboIt?.cansiton ?? false, - canlayon: metadata?.canlayon ?? habboIt?.canlayon ?? false, - furniline: habboIt?.furniline ?? "", - environment: habboIt?.environment ?? "", - rare: habboIt?.rare ?? false, + customparams: officialHabbo?.customparams ?? "", + specialtype: metadata?.specialtype ?? officialHabbo?.specialtype ?? 0, + canstandon: metadata?.canstandon ?? officialHabbo?.canstandon ?? false, + cansiton: metadata?.cansiton ?? officialHabbo?.cansiton ?? false, + canlayon: metadata?.canlayon ?? officialHabbo?.canlayon ?? false, + furniline: officialHabbo?.furniline ?? "", + environment: officialHabbo?.environment ?? "", + rare: officialHabbo?.rare ?? false, }; } diff --git a/src/lib/services/furni-import.test.ts b/src/lib/services/furni-import.test.ts index 5958b12b..2d87eec3 100644 --- a/src/lib/services/furni-import.test.ts +++ b/src/lib/services/furni-import.test.ts @@ -14,7 +14,7 @@ vi.mock("@/lib/services/furni-data", () => ({ })); vi.mock("@/lib/services/habbo-furnidata-cache", () => ({ - lookupHabboIt: vi.fn(), + lookupOfficialHabboFurni: vi.fn(), })); vi.mock("@/lib/services/import/core/download", () => ({ diff --git a/src/lib/services/furni-import.ts b/src/lib/services/furni-import.ts index 3216bdc3..66dc0d04 100644 --- a/src/lib/services/furni-import.ts +++ b/src/lib/services/furni-import.ts @@ -2,14 +2,16 @@ import { existsSync, promises as fs } from "node:fs"; import path from "node:path"; import { and, eq, type SQL, sql } from "drizzle-orm"; import { CatalogPages, db, ItemsBase } from "@/lib/db"; +import { officialHabboEnrichmentWarning } from "@/lib/habbo-gamedata-hotel"; import { logger } from "@/lib/logger"; 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"; +import type { OfficialHabboFurniEntry } from "@/lib/services/habbo-furnidata-cache"; +import { lookupOfficialHabboFurni } from "@/lib/services/habbo-furnidata-cache"; +import { getHabboGamedataHotel } from "@/lib/services/habbo-gamedata-hotel"; // Re-export the download helpers now owned by the shared import core. // Existing furni callers keep importing them from here. @@ -396,22 +398,23 @@ export async function importSingleFurni(params: { const itemType = type === "wallitem" ? "i" : "s"; const spriteId = originalId; - // ── Cross-reference with habbo.it furnidata for enrichment ──────── - let habboIt: HabboItFurniEntry | null = null; + // ── Cross-reference with configured official Habbo furnidata ────── + let officialHabbo: OfficialHabboFurniEntry | null = null; try { - habboIt = await lookupHabboIt(classname); - if (habboIt) { - warnings.push(`Enriched from habbo.it: "${habboIt.name}"`); + const hotel = await getHabboGamedataHotel(); + officialHabbo = await lookupOfficialHabboFurni(classname); + if (officialHabbo) { + warnings.push(officialHabboEnrichmentWarning(hotel, officialHabbo.name)); } } catch { - // Non-blocking: habbo.it enrichment is optional + // Non-blocking: official Habbo 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"; + // Best values: use configured official Habbo furnidata for enrichment. + const bestName = name || officialHabbo?.name || classname; + const bestDescription = description || officialHabbo?.description || ""; + const bestRevision = revision || officialHabbo?.revision || 0; + const bestCategory = category || officialHabbo?.category || "unknown"; // ── Insert into DB with enriched data ────────────────────────────── onProgress?.("writing_db"); @@ -581,15 +584,15 @@ export async function importSingleFurni(params: { // ── Build enriched FurnitureData entry ──────────────────────────── const dims = conversionResult?.dimensions || - (habboIt - ? { x: habboIt.xdim, y: habboIt.ydim, z: 0 } + (officialHabbo + ? { x: officialHabbo.xdim, y: officialHabbo.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; + // Prefer .nitro flags, then the configured official Habbo furnidata. + const cansiton = meta?.cansiton ?? officialHabbo?.cansiton ?? false; + const canlayon = meta?.canlayon ?? officialHabbo?.canlayon ?? false; + const canstandon = meta?.canstandon ?? officialHabbo?.canstandon ?? false; // Derive interaction_type. Priority: // 1. Wired LOGIC items (wf_act_/wf_trg_/wf_cnd_/wf_slc_/wf_xtra_/wf_var_) @@ -657,7 +660,7 @@ export async function importSingleFurni(params: { spriteId, dims, metadata: meta, - habboIt, + officialHabbo, }); // ── Add to FurnitureData.json (or defer for batch) ──────────────── diff --git a/src/lib/services/habbo-furnidata-cache.test.ts b/src/lib/services/habbo-furnidata-cache.test.ts new file mode 100644 index 00000000..fe6e254d --- /dev/null +++ b/src/lib/services/habbo-furnidata-cache.test.ts @@ -0,0 +1,78 @@ +// @vitest-environment node +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { getHabboGamedataHotelMock } = vi.hoisted(() => ({ + getHabboGamedataHotelMock: vi.fn(), +})); + +vi.mock("@/lib/services/habbo-gamedata-hotel", () => ({ + getHabboGamedataHotel: getHabboGamedataHotelMock, +})); + +vi.mock("@/lib/logger", () => ({ + logger: { warn: vi.fn() }, +})); + +import * as cacheModule from "./habbo-furnidata-cache"; + +function fixture(classname: string, name: string) { + return { + roomitemtypes: { + furnitype: [ + { + id: 1, + classname, + name, + description: "", + revision: 100, + }, + ], + }, + wallitemtypes: { furnitype: [] }, + }; +} + +describe("official Habbo furnidata cache", () => { + beforeEach(() => { + vi.restoreAllMocks(); + const cacheApi = cacheModule as unknown as { + clearOfficialHabboFurnidataCache?: () => void; + clearHabboItCache?: () => void; + }; + ( + cacheApi.clearOfficialHabboFurnidataCache ?? cacheApi.clearHabboItCache + )?.(); + }); + + it("exports a generic API and isolates cached data by configured hotel", async () => { + const generic = cacheModule as unknown as { + getOfficialHabboFurnidata?: () => Promise>; + }; + expect(generic.getOfficialHabboFurnidata).toBeTypeOf("function"); + if (!generic.getOfficialHabboFurnidata) return; + + getHabboGamedataHotelMock + .mockResolvedValueOnce("it") + .mockResolvedValueOnce("nl"); + const fetchMock = vi + .fn() + .mockResolvedValueOnce( + new Response(JSON.stringify(fixture("italian_chair", "Sedia"))), + ) + .mockResolvedValueOnce( + new Response(JSON.stringify(fixture("dutch_chair", "Stoel"))), + ); + vi.stubGlobal("fetch", fetchMock); + + const italian = await generic.getOfficialHabboFurnidata(); + const dutch = await generic.getOfficialHabboFurnidata(); + + expect(italian.get("italian_chair")?.name).toBe("Sedia"); + expect(dutch.get("dutch_chair")?.name).toBe("Stoel"); + expect(fetchMock).toHaveBeenNthCalledWith( + 2, + "https://www.habbo.nl/gamedata/furnidata_json/1", + expect.objectContaining({ headers: { Accept: "application/json" } }), + ); + }); +}); diff --git a/src/lib/services/habbo-furnidata-cache.ts b/src/lib/services/habbo-furnidata-cache.ts index f8b87db8..065b0ed5 100644 --- a/src/lib/services/habbo-furnidata-cache.ts +++ b/src/lib/services/habbo-furnidata-cache.ts @@ -3,7 +3,7 @@ * (`habbo_gamedata_hotel`). Downloads once per hotel, keeps in memory. */ -export type { HabboItFurniEntry } from "@/types/furni"; +export type { OfficialHabboFurniEntry } from "@/types/furni"; import { type HabboGamedataHotel, @@ -11,21 +11,21 @@ import { } from "@/lib/habbo-gamedata-hotel"; import { logger } from "@/lib/logger"; import { getHabboGamedataHotel } from "@/lib/services/habbo-gamedata-hotel"; -import type { HabboItFurniEntry } from "@/types/furni"; +import type { OfficialHabboFurniEntry } from "@/types/furni"; const CACHE_TTL = 30 * 60 * 1000; // 30 minutes -let cache: Map | null = null; +let cache: Map | null = null; let cacheHotel: HabboGamedataHotel | null = null; let cacheTimestamp = 0; -let loading: Promise> | null = null; +let loading: Promise> | null = null; /** * Fetches and parses the full official Habbo furnidata JSON for the * configured hotel. Results are cached in memory for 30 minutes. */ -export async function getHabboItFurnidata(): Promise< - Map +export async function getOfficialHabboFurnidata(): Promise< + Map > { const hotel = await getHabboGamedataHotel(); const now = Date.now(); @@ -51,7 +51,7 @@ export async function getHabboItFurnidata(): Promise< } const data = await res.json(); - const map = new Map(); + const map = new Map(); const roomTypes = data?.roomitemtypes?.furnitype; if (Array.isArray(roomTypes)) { @@ -137,10 +137,10 @@ export async function getHabboItFurnidata(): Promise< * Lookup a single item by classname from official Habbo furnidata. * Tries exact match first, then base classname (without color variant). */ -export async function lookupHabboIt( +export async function lookupOfficialHabboFurni( classname: string, -): Promise { - const map = await getHabboItFurnidata(); +): Promise { + const map = await getOfficialHabboFurnidata(); const exact = map.get(classname); if (exact) return exact; @@ -162,7 +162,7 @@ export async function lookupHabboIt( } /** Clear the cache (e.g. after changing habbo_gamedata_hotel). */ -export function clearHabboItCache(): void { +export function clearOfficialHabboFurnidataCache(): void { cache = null; cacheHotel = null; cacheTimestamp = 0; diff --git a/src/lib/services/habbofurni.ts b/src/lib/services/habbofurni.ts index 44804a95..441c10a2 100644 --- a/src/lib/services/habbofurni.ts +++ b/src/lib/services/habbofurni.ts @@ -7,7 +7,7 @@ import { logger } from "@/lib/logger"; import type { FurniListResult, HabboFurniItem } from "@/types/furni"; -import { getHabboItFurnidata } from "./habbo-furnidata-cache"; +import { getOfficialHabboFurnidata } from "./habbo-furnidata-cache"; export type { FurniListResult, HabboFurniItem }; @@ -40,7 +40,7 @@ export async function listFurniture( const importedSet = options.importedSet; try { - const map = await getHabboItFurnidata(); + const map = await getOfficialHabboFurnidata(); if (map.size === 0) return empty; // Convert to array and optionally filter by search @@ -108,12 +108,12 @@ export async function searchFurniture( /** * Get a single furniture item by classname. - * Uses the cached habbo.it furnidata. + * Uses cached furnidata from the configured official Habbo hotel. */ export async function getFurnitureByClassname( classname: string, ): Promise { - const map = await getHabboItFurnidata(); + const map = await getOfficialHabboFurnidata(); // Try exact match let entry = map.get(classname); diff --git a/src/types/furni.ts b/src/types/furni.ts index 14275882..7e576313 100644 --- a/src/types/furni.ts +++ b/src/types/furni.ts @@ -55,7 +55,7 @@ export interface BatchConversionResult { error?: string; } -export interface HabboItFurniEntry { +export interface OfficialHabboFurniEntry { id: number; classname: string; revision: number;