fix: use configured furni source and catalog assets

This commit is contained in:
Simo committed 2026-08-02 14:22:05 +02:00
1 parent aed70db81f
commit c78f8812ad
25 files changed
+368 -129

No files matched your search

+3 -3
View File
@@ -12,7 +12,7 @@ import {
normalizeHabboGamedataHotel, normalizeHabboGamedataHotel,
} from "@/lib/habbo-gamedata-hotel"; } from "@/lib/habbo-gamedata-hotel";
import { PERMS } from "@/lib/permissions"; 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 { clearBadgeCache } from "@/lib/services/habboassets";
import { siteSettings } from "@/lib/services/site-settings"; import { siteSettings } from "@/lib/services/site-settings";
@@ -27,7 +27,7 @@ function normalizeSettingValue(key: string, value: string): string {
function bustGamedataCachesIfNeeded(key: string): void { function bustGamedataCachesIfNeeded(key: string): void {
if (key === HABBO_GAMEDATA_HOTEL_SETTING_KEY) { if (key === HABBO_GAMEDATA_HOTEL_SETTING_KEY) {
clearHabboItCache(); clearOfficialHabboFurnidataCache();
clearBadgeCache(); clearBadgeCache();
} }
} }
@@ -57,7 +57,7 @@ export const saveManagedSettings = adminAction(
); );
await siteSettings.reload(); await siteSettings.reload();
if (entries.some(([key]) => key === HABBO_GAMEDATA_HOTEL_SETTING_KEY)) { if (entries.some(([key]) => key === HABBO_GAMEDATA_HOTEL_SETTING_KEY)) {
clearHabboItCache(); clearOfficialHabboFurnidataCache();
clearBadgeCache(); clearBadgeCache();
} }
revalidatePath("/admin/settings"); revalidatePath("/admin/settings");
@@ -7,8 +7,10 @@ import {
Check, Check,
CheckCheck, CheckCheck,
Download, Download,
ExternalLink,
FileWarning, FileWarning,
FolderSync, FolderSync,
Globe2,
LayoutGrid, LayoutGrid,
List, List,
Loader2, Loader2,
@@ -52,6 +54,7 @@ import {
SelectValue, SelectValue,
} from "@/components/ui/select"; } from "@/components/ui/select";
import { adminFetch } from "@/lib/admin-fetch"; import { adminFetch } from "@/lib/admin-fetch";
import type { FurniImportSource } from "@/lib/habbo-gamedata-hotel";
import { NitroEditorDialog } from "./nitro-editor-dialog"; import { NitroEditorDialog } from "./nitro-editor-dialog";
interface FurniItem { interface FurniItem {
@@ -146,7 +149,7 @@ const STATUS_LABELS: Record<string, string> = {
failed: "Failed", failed: "Failed",
}; };
export function ImportFurniClient() { export function ImportFurniClient({ source }: { source: FurniImportSource }) {
const [searchTerm, setSearchTerm] = useState(""); const [searchTerm, setSearchTerm] = useState("");
const [activeSearch, setActiveSearch] = useState(""); const [activeSearch, setActiveSearch] = useState("");
const [items, setItems] = useState<FurniItem[]>([]); const [items, setItems] = useState<FurniItem[]>([]);
@@ -936,6 +939,31 @@ export function ImportFurniClient() {
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<div className="flex flex-wrap items-center justify-between gap-3 rounded-lg border bg-card px-4 py-3">
<div className="flex items-center gap-3">
<div className="rounded-md bg-primary/10 p-2 text-primary">
<Globe2 className="h-4 w-4" />
</div>
<div>
<p className="text-xs text-muted-foreground">
Official furnidata source
</p>
<p className="text-sm font-medium">{source.label}</p>
<p className="text-[10px] text-muted-foreground">
Furniture assets: {source.assetCdnHost}
</p>
</div>
</div>
<a
href={source.furnidataUrl}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1 text-xs font-medium text-primary hover:underline"
>
{source.host}
<ExternalLink className="h-3 w-3" />
</a>
</div>
{/* Dashboard Statistics */} {/* Dashboard Statistics */}
{!stats && ( {!stats && (
<div className="grid grid-cols-2 md:grid-cols-5 gap-3"> <div className="grid grid-cols-2 md:grid-cols-5 gap-3">
@@ -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"',
);
});
});
+4 -1
View File
@@ -1,5 +1,7 @@
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { buildFurniImportSource } from "@/lib/habbo-gamedata-hotel";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions"; import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { getHabboGamedataHotel } from "@/lib/services/habbo-gamedata-hotel";
import { ImportFurniClient } from "./import-furni-client"; import { ImportFurniClient } from "./import-furni-client";
export default async function ImportFurniPage(_props: { export default async function ImportFurniPage(_props: {
@@ -10,5 +12,6 @@ export default async function ImportFurniPage(_props: {
redirect("/admin"); redirect("/admin");
} }
return <ImportFurniClient />; const hotel = await getHabboGamedataHotel();
return <ImportFurniClient source={buildFurniImportSource(hotel)} />;
} }
+13 -22
View File
@@ -1,16 +1,11 @@
import { promises as fs } from "node:fs";
import path from "node:path";
import { withAdmin } from "@/lib/api-handler"; import { withAdmin } from "@/lib/api-handler";
import { apiOk } from "@/lib/api-response"; import { apiOk } from "@/lib/api-response";
import { PERMS } from "@/lib/permissions"; import { PERMS } from "@/lib/permissions";
import {
const CATALOGUE_DIR = path.join( catalogueAssetDirectories,
/*turbopackIgnore: true*/ process.cwd(), readCatalogueAssetFiles,
"public", } from "@/lib/services/catalog-assets";
"swf", import { getGamedataRoot } from "@/lib/services/furni-asset-dirs";
"c_images",
"catalogue",
);
export const GET = withAdmin( export const GET = withAdmin(
{ permission: PERMS.CATALOG_VIEW }, { permission: PERMS.CATALOG_VIEW },
@@ -23,18 +18,14 @@ export const GET = withAdmin(
const offset = Math.max(Number(sp.get("offset") || 0) || 0, 0); const offset = Math.max(Number(sp.get("offset") || 0) || 0, 0);
const search = (sp.get("search") || "").trim().toLowerCase(); const search = (sp.get("search") || "").trim().toLowerCase();
let icons: number[] = []; const directories = catalogueAssetDirectories(await getGamedataRoot());
try { const files = await readCatalogueAssetFiles(directories);
const files = await fs.readdir(CATALOGUE_DIR); let icons = files
icons = files .map((f) => /^icon_(\d+)\.(?:png|gif)$/i.exec(f))
.map((f) => /^icon_(\d+)\.(?:png|gif)$/i.exec(f)) .filter((m): m is RegExpExecArray => Boolean(m))
.filter((m): m is RegExpExecArray => Boolean(m)) .map((m) => Number(m[1]))
.map((m) => Number(m[1])) .filter((n) => Number.isFinite(n))
.filter((n) => Number.isFinite(n)) .sort((a, b) => a - b);
.sort((a, b) => a - b);
} catch {
icons = [];
}
if (search) { if (search) {
icons = icons.filter((id) => String(id).includes(search)); icons = icons.filter((id) => String(id).includes(search));
+15 -23
View File
@@ -1,16 +1,11 @@
import { promises as fs } from "node:fs";
import path from "node:path";
import { withAdmin } from "@/lib/api-handler"; import { withAdmin } from "@/lib/api-handler";
import { apiOk } from "@/lib/api-response"; import { apiOk } from "@/lib/api-response";
import { PERMS } from "@/lib/permissions"; import { PERMS } from "@/lib/permissions";
import {
const CATALOGUE_DIR = path.join( catalogueAssetDirectories,
/*turbopackIgnore: true*/ process.cwd(), readCatalogueAssetFiles,
"public", } from "@/lib/services/catalog-assets";
"swf", import { getGamedataRoot } from "@/lib/services/furni-asset-dirs";
"c_images",
"catalogue",
);
export const GET = withAdmin( export const GET = withAdmin(
{ permission: PERMS.CATALOG_VIEW }, { permission: PERMS.CATALOG_VIEW },
@@ -25,20 +20,17 @@ export const GET = withAdmin(
const type = sp.get("type") || "all"; const type = sp.get("type") || "all";
let names: string[] = []; let names: string[] = [];
try { const directories = catalogueAssetDirectories(await getGamedataRoot());
const files = await fs.readdir(CATALOGUE_DIR); const files = await readCatalogueAssetFiles(directories);
const seen = new Set<string>(); const seen = new Set<string>();
for (const file of files) { for (const file of files) {
if (/^icon_\d+\./i.test(file)) continue; if (/^icon_\d+\./i.test(file)) continue;
const base = file.replace(/\.(png|gif|jpg|jpeg|webp)$/i, ""); const base = file.replace(/\.(png|gif|jpg|jpeg|webp)$/i, "");
if (!base || seen.has(base)) continue; if (!base || seen.has(base)) continue;
seen.add(base); seen.add(base);
names.push(base); names.push(base);
}
names.sort((a, b) => a.localeCompare(b));
} catch {
names = [];
} }
names.sort((a, b) => a.localeCompare(b));
if (type === "header") { if (type === "header") {
names = names.filter((n) => /header/i.test(n)); names = names.filter((n) => /header/i.test(n));
@@ -12,7 +12,7 @@ import {
readFurniData, readFurniData,
} from "@/lib/services/furni-data"; } from "@/lib/services/furni-data";
import { downloadFile, ensureDirectories } from "@/lib/services/furni-import"; 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 { rcon } from "@/lib/services/rcon";
import { convertSwfToNitro } from "@/lib/services/swf-to-nitro"; import { convertSwfToNitro } from "@/lib/services/swf-to-nitro";
@@ -62,7 +62,7 @@ export const POST = withAdmin(
}> = []; }> = [];
// Load furnidata once for all items // Load furnidata once for all items
const furnidata = await getHabboItFurnidata(); const furnidata = await getOfficialHabboFurnidata();
// Load existing FurnitureData.json classnames for dedup // Load existing FurnitureData.json classnames for dedup
const existingFD = (await readFurniData()) as { const existingFD = (await readFurniData()) as {
@@ -204,7 +204,7 @@ export const POST = withAdmin(
spriteId: dbItem?.spriteId || 0, spriteId: dbItem?.spriteId || 0,
dims: conversion.dimensions || { x: 1, y: 1, z: 1 }, dims: conversion.dimensions || { x: 1, y: 1, z: 1 },
metadata: conversion.metadata || null, metadata: conversion.metadata || null,
habboIt: fd || null, officialHabbo: fd || null,
}); });
furniDataEntries.push({ entry, itemType }); furniDataEntries.push({ entry, itemType });
fdClassnames.add(baseClassname); fdClassnames.add(baseClassname);
@@ -10,7 +10,7 @@ import {
getFurnitureDataPath, getFurnitureDataPath,
readFurniData, readFurniData,
} from "@/lib/services/furni-data"; } 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"; import { rcon } from "@/lib/services/rcon";
/** /**
@@ -86,9 +86,9 @@ export const POST = withAdmin(
for (const row of rows) { for (const row of rows) {
try { try {
const classname = row.itemName; const classname = row.itemName;
let habboIt = null; let officialHabbo = null;
try { try {
habboIt = await lookupHabboIt(classname); officialHabbo = await lookupOfficialHabboFurni(classname);
} catch { } catch {
/* optional */ /* optional */
} }
@@ -96,10 +96,10 @@ export const POST = withAdmin(
const entry = buildFurniEntry({ const entry = buildFurniEntry({
id: row.id, id: row.id,
classname, classname,
revision: habboIt?.revision ?? 0, revision: officialHabbo?.revision ?? 0,
category: habboIt?.category || "unknown", category: officialHabbo?.category || "unknown",
name: row.publicName || habboIt?.name || classname, name: row.publicName || officialHabbo?.name || classname,
description: habboIt?.description || "", description: officialHabbo?.description || "",
spriteId: row.spriteId, spriteId: row.spriteId,
dims: { dims: {
x: row.width, x: row.width,
@@ -107,7 +107,7 @@ export const POST = withAdmin(
z: Math.round(Number(row.stackHeight)), z: Math.round(Number(row.stackHeight)),
}, },
metadata: null, metadata: null,
habboIt, officialHabbo,
}); });
entries.push({ entry, itemType: row.type === "i" ? "i" : "s" }); entries.push({ entry, itemType: row.type === "i" ? "i" : "s" });
+5 -5
View File
@@ -18,7 +18,7 @@ import {
IMPORTED_PAGE_CAPTION_SAVE, IMPORTED_PAGE_CAPTION_SAVE,
importSingleFurni, importSingleFurni,
} from "@/lib/services/furni-import"; } 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 { listFurniture } from "@/lib/services/habbofurni";
import { rcon } from "@/lib/services/rcon"; import { rcon } from "@/lib/services/rcon";
import { convertSwfToNitro } from "@/lib/services/swf-to-nitro"; import { convertSwfToNitro } from "@/lib/services/swf-to-nitro";
@@ -183,7 +183,7 @@ export const GET = withAdmin(
const pageItems = missing.slice(start, start + perPage); const pageItems = missing.slice(start, start + perPage);
// Enrich with revision from official furnidata // Enrich with revision from official furnidata
const furnidata = await getHabboItFurnidata(); const furnidata = await getOfficialHabboFurnidata();
const items = pageItems.map((item) => { const items = pageItems.map((item) => {
const fd = const fd =
furnidata.get(item.item_name) || furnidata.get(item.item_name) ||
@@ -434,7 +434,7 @@ export const PATCH = withAdmin(
// Resolve revision: try furnidata first, then spriteId as fallback // Resolve revision: try furnidata first, then spriteId as fallback
let rev = item.revision || 0; let rev = item.revision || 0;
if (rev === 0) { if (rev === 0) {
const furnidata = await getHabboItFurnidata(); const furnidata = await getOfficialHabboFurnidata();
const fd = const fd =
furnidata.get(item.classname) || furnidata.get(item.classname) ||
furnidata.get(baseClassname) || furnidata.get(baseClassname) ||
@@ -516,7 +516,7 @@ export const PATCH = withAdmin(
.from(ItemsBase) .from(ItemsBase)
.where(eq(ItemsBase.itemName, item.classname)) .where(eq(ItemsBase.itemName, item.classname))
.limit(1); .limit(1);
const furnidata = await getHabboItFurnidata(); const furnidata = await getOfficialHabboFurnidata();
const fd = const fd =
furnidata.get(item.classname) || furnidata.get(baseClassname); furnidata.get(item.classname) || furnidata.get(baseClassname);
const itemType = dbItem?.type === "i" ? "i" : "s"; const itemType = dbItem?.type === "i" ? "i" : "s";
@@ -531,7 +531,7 @@ export const PATCH = withAdmin(
spriteId: dbItem?.spriteId || 0, spriteId: dbItem?.spriteId || 0,
dims: conversion.dimensions || { x: 1, y: 1, z: 1 }, dims: conversion.dimensions || { x: 1, y: 1, z: 1 },
metadata: conversion.metadata || null, metadata: conversion.metadata || null,
habboIt: fd || null, officialHabbo: fd || null,
}); });
await appendFurniEntry(entry, itemType); await appendFurniEntry(entry, itemType);
} }
@@ -2,6 +2,7 @@
import { ImageOff, Loader2 } from "lucide-react"; import { ImageOff, Loader2 } from "lucide-react";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { CATALOGUE_ASSET_BASE_PATH } from "@/lib/catalog-assets";
interface ImagePreviewProps { interface ImagePreviewProps {
value: string; value: string;
@@ -12,7 +13,7 @@ interface ImagePreviewProps {
export function ImagePreview({ export function ImagePreview({
value, value,
basePath = "/swf/c_images/catalogue", basePath = CATALOGUE_ASSET_BASE_PATH,
height = 60, height = 60,
className, className,
}: ImagePreviewProps) { }: ImagePreviewProps) {
+2 -1
View File
@@ -51,6 +51,7 @@ import {
TooltipTrigger, TooltipTrigger,
} from "@/components/ui/tooltip"; } from "@/components/ui/tooltip";
import { adminFetch } from "@/lib/admin-fetch"; import { adminFetch } from "@/lib/admin-fetch";
import { catalogueIconUrl } from "@/lib/catalog-assets";
import { translateCaption } from "@/lib/catalog-translations"; import { translateCaption } from "@/lib/catalog-translations";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@@ -115,7 +116,7 @@ export function CatalogIcon({
return ( return (
<img <img
src={`/swf/c_images/catalogue/icon_${iconImage}.png`} src={catalogueIconUrl(iconImage)}
alt="" alt=""
width={size} width={size}
height={size} height={size}
@@ -11,6 +11,7 @@ import {
DialogTitle, DialogTitle,
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { catalogueAssetUrl } from "@/lib/catalog-assets";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
interface CatalogImagePickerProps { interface CatalogImagePickerProps {
@@ -248,10 +249,7 @@ function CatalogImageThumb({
} }
// Try .png first, fallback to .gif // Try .png first, fallback to .gif
const src = const src = catalogueAssetUrl(name, error === 0 ? "png" : "gif");
error === 0
? `/swf/c_images/catalogue/${name}.png`
: `/swf/c_images/catalogue/${name}.gif`;
return ( return (
<img <img
+2 -1
View File
@@ -11,6 +11,7 @@ import {
DialogTitle, DialogTitle,
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { catalogueIconUrl } from "@/lib/catalog-assets";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
interface IconPickerProps { interface IconPickerProps {
@@ -228,7 +229,7 @@ function IconPreview({
return ( return (
<img <img
src={`/swf/c_images/catalogue/icon_${iconImage}.png`} src={catalogueIconUrl(iconImage)}
alt="" alt=""
width={size} width={size}
height={size} height={size}
+17
View File
@@ -0,0 +1,17 @@
import { describe, expect, it } from "vitest";
import { catalogueAssetUrl, catalogueIconUrl } from "@/lib/catalog-assets";
describe("catalogue asset URLs", () => {
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",
);
});
});
+12
View File
@@ -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}`;
}
+27
View File
@@ -19,6 +19,14 @@ export const HABBO_GAMEDATA_HOTELS = [
export type HabboGamedataHotel = export type HabboGamedataHotel =
(typeof HABBO_GAMEDATA_HOTELS)[number]["value"]; (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 HABBO_GAMEDATA_HOTEL_SETTING_KEY = "habbo_gamedata_hotel";
export const DEFAULT_HABBO_GAMEDATA_HOTEL: HabboGamedataHotel = "it"; 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`; 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 { export function habboExternalTextsUrl(hotel: string): string {
const h = normalizeHabboGamedataHotel(hotel); const h = normalizeHabboGamedataHotel(hotel);
return `https://www.habbo.${h}/gamedata/external_flash_texts/1`; return `https://www.habbo.${h}/gamedata/external_flash_texts/1`;
+23
View File
@@ -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"),
]);
});
});
+26
View File
@@ -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<string[]> {
const files = new Set<string>();
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];
}
+19 -19
View File
@@ -4,7 +4,7 @@ import {
getGamedataRoot, getGamedataRoot,
getNitroFilesRoot, getNitroFilesRoot,
} from "@/lib/services/furni-asset-dirs"; } 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 { siteSettings } from "@/lib/services/site-settings";
import type { FurniMetadata } from "@/lib/services/swf-to-nitro"; import type { FurniMetadata } from "@/lib/services/swf-to-nitro";
@@ -150,7 +150,7 @@ export function buildFurniEntry(params: {
spriteId: number; spriteId: number;
dims: { x: number; y: number; z: number }; dims: { x: number; y: number; z: number };
metadata: FurniMetadata | null; metadata: FurniMetadata | null;
habboIt?: HabboItFurniEntry | null; officialHabbo?: OfficialHabboFurniEntry | null;
}): Record<string, unknown> { }): Record<string, unknown> {
const { const {
id, id,
@@ -162,20 +162,20 @@ export function buildFurniEntry(params: {
spriteId, spriteId,
dims, dims,
metadata, metadata,
habboIt, officialHabbo,
} = params; } = params;
// Merge partcolors: prefer habbo.it (has real hex colors), fallback to .nitro extraction // Prefer the configured official hotel's real hex colors, then .nitro metadata.
const partcolors = habboIt?.partcolors?.color?.length const partcolors = officialHabbo?.partcolors?.color?.length
? habboIt.partcolors ? officialHabbo.partcolors
: (metadata?.partcolors ?? { color: [] }); : (metadata?.partcolors ?? { color: [] });
return { return {
id, id,
classname, classname,
revision: revision || habboIt?.revision || 0, revision: revision || officialHabbo?.revision || 0,
category: category || habboIt?.category || "unknown", category: category || officialHabbo?.category || "unknown",
defaultdir: metadata?.defaultdir ?? habboIt?.defaultdir ?? 0, defaultdir: metadata?.defaultdir ?? officialHabbo?.defaultdir ?? 0,
xdim: dims.x, xdim: dims.x,
ydim: dims.y, ydim: dims.y,
partcolors, partcolors,
@@ -183,19 +183,19 @@ export function buildFurniEntry(params: {
description: description || "", description: description || "",
adurl: "", adurl: "",
offerid: spriteId, offerid: spriteId,
buyout: !!habboIt?.bc, buyout: !!officialHabbo?.bc,
rentofferid: -1, rentofferid: -1,
rentbuyout: false, rentbuyout: false,
bc: habboIt?.bc ?? false, bc: officialHabbo?.bc ?? false,
excludeddynamic: false, excludeddynamic: false,
customparams: habboIt?.customparams ?? "", customparams: officialHabbo?.customparams ?? "",
specialtype: metadata?.specialtype ?? habboIt?.specialtype ?? 0, specialtype: metadata?.specialtype ?? officialHabbo?.specialtype ?? 0,
canstandon: metadata?.canstandon ?? habboIt?.canstandon ?? false, canstandon: metadata?.canstandon ?? officialHabbo?.canstandon ?? false,
cansiton: metadata?.cansiton ?? habboIt?.cansiton ?? false, cansiton: metadata?.cansiton ?? officialHabbo?.cansiton ?? false,
canlayon: metadata?.canlayon ?? habboIt?.canlayon ?? false, canlayon: metadata?.canlayon ?? officialHabbo?.canlayon ?? false,
furniline: habboIt?.furniline ?? "", furniline: officialHabbo?.furniline ?? "",
environment: habboIt?.environment ?? "", environment: officialHabbo?.environment ?? "",
rare: habboIt?.rare ?? false, rare: officialHabbo?.rare ?? false,
}; };
} }
+1 -1
View File
@@ -14,7 +14,7 @@ vi.mock("@/lib/services/furni-data", () => ({
})); }));
vi.mock("@/lib/services/habbo-furnidata-cache", () => ({ vi.mock("@/lib/services/habbo-furnidata-cache", () => ({
lookupHabboIt: vi.fn(), lookupOfficialHabboFurni: vi.fn(),
})); }));
vi.mock("@/lib/services/import/core/download", () => ({ vi.mock("@/lib/services/import/core/download", () => ({
+23 -20
View File
@@ -2,14 +2,16 @@ import { existsSync, promises as fs } from "node:fs";
import path from "node:path"; import path from "node:path";
import { and, eq, type SQL, sql } from "drizzle-orm"; import { and, eq, type SQL, sql } from "drizzle-orm";
import { CatalogPages, db, ItemsBase } from "@/lib/db"; import { CatalogPages, db, ItemsBase } from "@/lib/db";
import { officialHabboEnrichmentWarning } from "@/lib/habbo-gamedata-hotel";
import { logger } from "@/lib/logger"; import { logger } from "@/lib/logger";
import { import {
appendFurniEntry, appendFurniEntry,
buildFurniEntry, buildFurniEntry,
removeFurniEntry, removeFurniEntry,
} from "@/lib/services/furni-data"; } from "@/lib/services/furni-data";
import type { HabboItFurniEntry } from "@/lib/services/habbo-furnidata-cache"; import type { OfficialHabboFurniEntry } from "@/lib/services/habbo-furnidata-cache";
import { lookupHabboIt } 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. // Re-export the download helpers now owned by the shared import core.
// Existing furni callers keep importing them from here. // Existing furni callers keep importing them from here.
@@ -396,22 +398,23 @@ export async function importSingleFurni(params: {
const itemType = type === "wallitem" ? "i" : "s"; const itemType = type === "wallitem" ? "i" : "s";
const spriteId = originalId; const spriteId = originalId;
// ── Cross-reference with habbo.it furnidata for enrichment ──────── // ── Cross-reference with configured official Habbo furnidata ──────
let habboIt: HabboItFurniEntry | null = null; let officialHabbo: OfficialHabboFurniEntry | null = null;
try { try {
habboIt = await lookupHabboIt(classname); const hotel = await getHabboGamedataHotel();
if (habboIt) { officialHabbo = await lookupOfficialHabboFurni(classname);
warnings.push(`Enriched from habbo.it: "${habboIt.name}"`); if (officialHabbo) {
warnings.push(officialHabboEnrichmentWarning(hotel, officialHabbo.name));
} }
} catch { } catch {
// Non-blocking: habbo.it enrichment is optional // Non-blocking: official Habbo enrichment is optional
} }
// Best values: use habbo.it furnidata for enrichment // Best values: use configured official Habbo furnidata for enrichment.
const bestName = name || habboIt?.name || classname; const bestName = name || officialHabbo?.name || classname;
const bestDescription = description || habboIt?.description || ""; const bestDescription = description || officialHabbo?.description || "";
const bestRevision = revision || habboIt?.revision || 0; const bestRevision = revision || officialHabbo?.revision || 0;
const bestCategory = category || habboIt?.category || "unknown"; const bestCategory = category || officialHabbo?.category || "unknown";
// ── Insert into DB with enriched data ────────────────────────────── // ── Insert into DB with enriched data ──────────────────────────────
onProgress?.("writing_db"); onProgress?.("writing_db");
@@ -581,15 +584,15 @@ export async function importSingleFurni(params: {
// ── Build enriched FurnitureData entry ──────────────────────────── // ── Build enriched FurnitureData entry ────────────────────────────
const dims = const dims =
conversionResult?.dimensions || conversionResult?.dimensions ||
(habboIt (officialHabbo
? { x: habboIt.xdim, y: habboIt.ydim, z: 0 } ? { x: officialHabbo.xdim, y: officialHabbo.ydim, z: 0 }
: { x: 1, y: 1, z: 0 }); : { x: 1, y: 1, z: 0 });
const meta = conversionResult?.metadata ?? null; const meta = conversionResult?.metadata ?? null;
// Merge interaction flags: prefer .nitro extraction, fallback to habbo.it // Prefer .nitro flags, then the configured official Habbo furnidata.
const cansiton = meta?.cansiton ?? habboIt?.cansiton ?? false; const cansiton = meta?.cansiton ?? officialHabbo?.cansiton ?? false;
const canlayon = meta?.canlayon ?? habboIt?.canlayon ?? false; const canlayon = meta?.canlayon ?? officialHabbo?.canlayon ?? false;
const canstandon = meta?.canstandon ?? habboIt?.canstandon ?? false; const canstandon = meta?.canstandon ?? officialHabbo?.canstandon ?? false;
// Derive interaction_type. Priority: // Derive interaction_type. Priority:
// 1. Wired LOGIC items (wf_act_/wf_trg_/wf_cnd_/wf_slc_/wf_xtra_/wf_var_) // 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, spriteId,
dims, dims,
metadata: meta, metadata: meta,
habboIt, officialHabbo,
}); });
// ── Add to FurnitureData.json (or defer for batch) ──────────────── // ── Add to FurnitureData.json (or defer for batch) ────────────────
@@ -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<Map<string, { name: string }>>;
};
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" } }),
);
});
});
+11 -11
View File
@@ -3,7 +3,7 @@
* (`habbo_gamedata_hotel`). Downloads once per hotel, keeps in memory. * (`habbo_gamedata_hotel`). Downloads once per hotel, keeps in memory.
*/ */
export type { HabboItFurniEntry } from "@/types/furni"; export type { OfficialHabboFurniEntry } from "@/types/furni";
import { import {
type HabboGamedataHotel, type HabboGamedataHotel,
@@ -11,21 +11,21 @@ import {
} from "@/lib/habbo-gamedata-hotel"; } from "@/lib/habbo-gamedata-hotel";
import { logger } from "@/lib/logger"; import { logger } from "@/lib/logger";
import { getHabboGamedataHotel } from "@/lib/services/habbo-gamedata-hotel"; 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 const CACHE_TTL = 30 * 60 * 1000; // 30 minutes
let cache: Map<string, HabboItFurniEntry> | null = null; let cache: Map<string, OfficialHabboFurniEntry> | null = null;
let cacheHotel: HabboGamedataHotel | null = null; let cacheHotel: HabboGamedataHotel | null = null;
let cacheTimestamp = 0; let cacheTimestamp = 0;
let loading: Promise<Map<string, HabboItFurniEntry>> | null = null; let loading: Promise<Map<string, OfficialHabboFurniEntry>> | null = null;
/** /**
* Fetches and parses the full official Habbo furnidata JSON for the * Fetches and parses the full official Habbo furnidata JSON for the
* configured hotel. Results are cached in memory for 30 minutes. * configured hotel. Results are cached in memory for 30 minutes.
*/ */
export async function getHabboItFurnidata(): Promise< export async function getOfficialHabboFurnidata(): Promise<
Map<string, HabboItFurniEntry> Map<string, OfficialHabboFurniEntry>
> { > {
const hotel = await getHabboGamedataHotel(); const hotel = await getHabboGamedataHotel();
const now = Date.now(); const now = Date.now();
@@ -51,7 +51,7 @@ export async function getHabboItFurnidata(): Promise<
} }
const data = await res.json(); const data = await res.json();
const map = new Map<string, HabboItFurniEntry>(); const map = new Map<string, OfficialHabboFurniEntry>();
const roomTypes = data?.roomitemtypes?.furnitype; const roomTypes = data?.roomitemtypes?.furnitype;
if (Array.isArray(roomTypes)) { if (Array.isArray(roomTypes)) {
@@ -137,10 +137,10 @@ export async function getHabboItFurnidata(): Promise<
* Lookup a single item by classname from official Habbo furnidata. * Lookup a single item by classname from official Habbo furnidata.
* Tries exact match first, then base classname (without color variant). * Tries exact match first, then base classname (without color variant).
*/ */
export async function lookupHabboIt( export async function lookupOfficialHabboFurni(
classname: string, classname: string,
): Promise<HabboItFurniEntry | null> { ): Promise<OfficialHabboFurniEntry | null> {
const map = await getHabboItFurnidata(); const map = await getOfficialHabboFurnidata();
const exact = map.get(classname); const exact = map.get(classname);
if (exact) return exact; if (exact) return exact;
@@ -162,7 +162,7 @@ export async function lookupHabboIt(
} }
/** Clear the cache (e.g. after changing habbo_gamedata_hotel). */ /** Clear the cache (e.g. after changing habbo_gamedata_hotel). */
export function clearHabboItCache(): void { export function clearOfficialHabboFurnidataCache(): void {
cache = null; cache = null;
cacheHotel = null; cacheHotel = null;
cacheTimestamp = 0; cacheTimestamp = 0;
+4 -4
View File
@@ -7,7 +7,7 @@
import { logger } from "@/lib/logger"; import { logger } from "@/lib/logger";
import type { FurniListResult, HabboFurniItem } from "@/types/furni"; import type { FurniListResult, HabboFurniItem } from "@/types/furni";
import { getHabboItFurnidata } from "./habbo-furnidata-cache"; import { getOfficialHabboFurnidata } from "./habbo-furnidata-cache";
export type { FurniListResult, HabboFurniItem }; export type { FurniListResult, HabboFurniItem };
@@ -40,7 +40,7 @@ export async function listFurniture(
const importedSet = options.importedSet; const importedSet = options.importedSet;
try { try {
const map = await getHabboItFurnidata(); const map = await getOfficialHabboFurnidata();
if (map.size === 0) return empty; if (map.size === 0) return empty;
// Convert to array and optionally filter by search // Convert to array and optionally filter by search
@@ -108,12 +108,12 @@ export async function searchFurniture(
/** /**
* Get a single furniture item by classname. * 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( export async function getFurnitureByClassname(
classname: string, classname: string,
): Promise<HabboFurniItem | null> { ): Promise<HabboFurniItem | null> {
const map = await getHabboItFurnidata(); const map = await getOfficialHabboFurnidata();
// Try exact match // Try exact match
let entry = map.get(classname); let entry = map.get(classname);
+1 -1
View File
@@ -55,7 +55,7 @@ export interface BatchConversionResult {
error?: string; error?: string;
} }
export interface HabboItFurniEntry { export interface OfficialHabboFurniEntry {
id: number; id: number;
classname: string; classname: string;
revision: number; revision: number;