Add multi-hotel Habbo gamedata locale in CMS settings.
Local Build and Deploy / deploy (push) Failing after 33s

Furni name suggestions, import enrichment, and badge texts now follow habbo_gamedata_hotel instead of hardcoded habbo.it.

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
SimoandCursor committed 2026-07-17 22:28:18 +02:00
1 parent 7bc0845932
commit d1baaf798a
15 files changed
+275 -65

No files matched your search

+30
View File
@@ -0,0 +1,30 @@
import { describe, expect, it } from "vitest";
import {
habboExternalTextsUrl,
habboFurnidataUrl,
habboGamedataHotelShort,
normalizeHabboGamedataHotel,
} from "./habbo-gamedata-hotel";
describe("habbo-gamedata-hotel", () => {
it("normalizes known hotels and falls back to it", () => {
expect(normalizeHabboGamedataHotel("ES")).toBe("es");
expect(normalizeHabboGamedataHotel("com.br")).toBe("com.br");
expect(normalizeHabboGamedataHotel("nope")).toBe("it");
expect(normalizeHabboGamedataHotel(null)).toBe("it");
});
it("builds official gamedata URLs", () => {
expect(habboFurnidataUrl("de")).toBe(
"https://www.habbo.de/gamedata/furnidata_json/1",
);
expect(habboExternalTextsUrl("com.br")).toBe(
"https://www.habbo.com.br/gamedata/external_flash_texts/1",
);
});
it("formats short hotel labels", () => {
expect(habboGamedataHotelShort("it")).toBe("IT");
expect(habboGamedataHotelShort("com.br")).toBe("COM.BR");
});
});
+57
View File
@@ -0,0 +1,57 @@
/** Official Habbo hotel TLDs used for gamedata (furnidata / external texts). */
export const HABBO_GAMEDATA_HOTELS = [
{ value: "it", label: "Italy (habbo.it)" },
{ value: "com", label: "International (habbo.com)" },
{ value: "es", label: "Spain (habbo.es)" },
{ value: "de", label: "Germany (habbo.de)" },
{ value: "fr", label: "France (habbo.fr)" },
{ value: "fi", label: "Finland (habbo.fi)" },
{ value: "nl", label: "Netherlands (habbo.nl)" },
{ value: "com.br", label: "Brazil (habbo.com.br)" },
{ value: "com.tr", label: "Turkey (habbo.com.tr)" },
] as const;
export type HabboGamedataHotel =
(typeof HABBO_GAMEDATA_HOTELS)[number]["value"];
export const HABBO_GAMEDATA_HOTEL_SETTING_KEY = "habbo_gamedata_hotel";
export const DEFAULT_HABBO_GAMEDATA_HOTEL: HabboGamedataHotel = "it";
const ALLOWED = new Set<string>(HABBO_GAMEDATA_HOTELS.map((h) => h.value));
export function normalizeHabboGamedataHotel(
raw: string | null | undefined,
): HabboGamedataHotel {
const v = (raw ?? "").trim().toLowerCase();
if (ALLOWED.has(v)) return v as HabboGamedataHotel;
return DEFAULT_HABBO_GAMEDATA_HOTEL;
}
export function habboGamedataHotelLabel(hotel: string): string {
const found = HABBO_GAMEDATA_HOTELS.find((h) => h.value === hotel);
return found?.label ?? `habbo.${hotel}`;
}
/** Short badge for buttons, e.g. IT / COM / COM.BR */
export function habboGamedataHotelShort(hotel: string): string {
return normalizeHabboGamedataHotel(hotel).toUpperCase();
}
export function habboFurnidataUrl(hotel: string): string {
const h = normalizeHabboGamedataHotel(hotel);
return `https://www.habbo.${h}/gamedata/furnidata_json/1`;
}
export function habboExternalTextsUrl(hotel: string): string {
const h = normalizeHabboGamedataHotel(hotel);
return `https://www.habbo.${h}/gamedata/external_flash_texts/1`;
}
export async function getHabboGamedataHotel(): Promise<HabboGamedataHotel> {
const { siteSettings } = await import("@/lib/services/site-settings");
const raw = await siteSettings.get(
HABBO_GAMEDATA_HOTEL_SETTING_KEY,
DEFAULT_HABBO_GAMEDATA_HOTEL,
);
return normalizeHabboGamedataHotel(raw);
}
+6 -1
View File
@@ -1,5 +1,6 @@
import { promises as fs } from "node:fs";
import { Prisma } from "@/generated/prisma/client";
import { getHabboGamedataHotel } from "@/lib/habbo-gamedata-hotel";
import { prisma } from "@/lib/prisma";
import { getFurnitureDataPath } from "@/lib/services/furni-data";
@@ -128,6 +129,8 @@ export interface CatalogItemsData {
furniRevisionMap: Record<number, { classname: string; revision: number }>;
interactionTypes: string[];
allPages: { id: number; caption: string }[];
/** CMS `habbo_gamedata_hotel` — locale used for Suggest names */
gamedataHotel: string;
}
export async function loadCatalogItemsData(
@@ -160,7 +163,7 @@ export async function loadCatalogItemsData(
clubOnly: String(r.club_only ?? "0"),
}));
const [allPages, interactionTypesRaw] = await Promise.all([
const [allPages, interactionTypesRaw, gamedataHotel] = await Promise.all([
prisma.catalogPages.findMany({
orderBy: { caption: "asc" },
select: { id: true, caption: true },
@@ -168,6 +171,7 @@ export async function loadCatalogItemsData(
prisma.$queryRaw<Array<{ interaction_type: string }>>`
SELECT DISTINCT interaction_type FROM items_base ORDER BY interaction_type ASC
`,
getHabboGamedataHotel(),
]);
const interactionTypes = interactionTypesRaw.map((r) =>
@@ -319,5 +323,6 @@ export async function loadCatalogItemsData(
furniRevisionMap,
interactionTypes,
allPages,
gamedataHotel,
};
}
+30 -23
View File
@@ -1,53 +1,61 @@
/**
* habbo.it furnidata cache — downloads once, keeps in memory.
* Provides instant lookup by classname for enriching imports.
* Official Habbo furnidata cache — hotel chosen in CMS settings
* (`habbo_gamedata_hotel`). Downloads once per hotel, keeps in memory.
*/
export type { HabboItFurniEntry } from "@/types/furni";
import type { HabboItFurniEntry } from "@/types/furni";
import {
getHabboGamedataHotel,
habboFurnidataUrl,
type HabboGamedataHotel,
} from "@/lib/habbo-gamedata-hotel";
const FURNIDATA_URL = "https://www.habbo.it/gamedata/furnidata_json/1";
const CACHE_TTL = 30 * 60 * 1000; // 30 minutes
let cache: Map<string, HabboItFurniEntry> | null = null;
let cacheHotel: HabboGamedataHotel | null = null;
let cacheTimestamp = 0;
let loading: Promise<Map<string, HabboItFurniEntry>> | null = null;
/**
* Fetches and parses the full habbo.it furnidata JSON.
* Results are cached in memory for 30 minutes.
* 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<string, HabboItFurniEntry>
> {
const hotel = await getHabboGamedataHotel();
const now = Date.now();
// Return cache if still valid
if (cache && now - cacheTimestamp < CACHE_TTL) {
if (
cache &&
cacheHotel === hotel &&
now - cacheTimestamp < CACHE_TTL
) {
return cache;
}
// Deduplicate concurrent requests
if (loading) return loading;
loading = (async () => {
const url = habboFurnidataUrl(hotel);
try {
console.debug("[habbo-it] Fetching furnidata from habbo.it...");
const res = await fetch(FURNIDATA_URL, {
console.debug(`[habbo-furnidata] Fetching furnidata from ${url}...`);
const res = await fetch(url, {
signal: AbortSignal.timeout(20000),
headers: { Accept: "application/json" },
});
if (!res.ok) {
console.warn(`[habbo-it] Fetch failed: ${res.status}`);
console.warn(`[habbo-furnidata] Fetch failed: ${res.status}`);
return cache || new Map();
}
const data = await res.json();
const map = new Map<string, HabboItFurniEntry>();
// Process room items (floor furniture)
const roomTypes = data?.roomitemtypes?.furnitype;
if (Array.isArray(roomTypes)) {
for (const item of roomTypes) {
@@ -78,7 +86,6 @@ export async function getHabboItFurnidata(): Promise<
}
}
// Process wall items
const wallTypes = data?.wallitemtypes?.furnitype;
if (Array.isArray(wallTypes)) {
for (const item of wallTypes) {
@@ -110,13 +117,17 @@ export async function getHabboItFurnidata(): Promise<
}
console.debug(
`[habbo-it] Cached ${map.size} items (${roomTypes?.length ?? 0} room + ${wallTypes?.length ?? 0} wall)`,
`[habbo-furnidata] Cached ${map.size} items from habbo.${hotel} (${roomTypes?.length ?? 0} room + ${wallTypes?.length ?? 0} wall)`,
);
cache = map;
cacheTimestamp = now;
cacheHotel = hotel;
cacheTimestamp = Date.now();
return map;
} catch (err) {
console.warn("[habbo-it] Fetch error:", (err as Error).message);
console.warn(
"[habbo-furnidata] Fetch error:",
(err as Error).message,
);
return cache || new Map();
} finally {
loading = null;
@@ -127,7 +138,7 @@ export async function getHabboItFurnidata(): Promise<
}
/**
* Lookup a single item by classname from habbo.it furnidata.
* Lookup a single item by classname from official Habbo furnidata.
* Tries exact match first, then base classname (without color variant).
*/
export async function lookupHabboIt(
@@ -135,22 +146,17 @@ export async function lookupHabboIt(
): Promise<HabboItFurniEntry | null> {
const map = await getHabboItFurnidata();
// 1. Exact match
const exact = map.get(classname);
if (exact) return exact;
// 2. If classname has color variant (e.g. "chair*4"), try base
if (classname.includes("*")) {
const base = classname.split("*")[0];
// Try base with *0 first (most common default)
const variant0 = map.get(`${base}*0`);
if (variant0) return variant0;
// Try just the base
const baseMatch = map.get(base);
if (baseMatch) return baseMatch;
}
// 3. If no variant in classname, try *0
if (!classname.includes("*")) {
const withVariant = map.get(`${classname}*0`);
if (withVariant) return withVariant;
@@ -159,8 +165,9 @@ export async function lookupHabboIt(
return null;
}
/** Clear the cache (useful after manual edits) */
/** Clear the cache (e.g. after changing habbo_gamedata_hotel). */
export function clearHabboItCache(): void {
cache = null;
cacheHotel = null;
cacheTimestamp = 0;
}
+36 -26
View File
@@ -1,17 +1,19 @@
/**
* Badge search — powered by official Habbo external texts.
*
* Uses habbo.it/gamedata/external_flash_texts as the single source.
* Hotel locale comes from CMS setting `habbo_gamedata_hotel`.
* 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";
import {
getHabboGamedataHotel,
habboExternalTextsUrl,
type HabboGamedataHotel,
} from "@/lib/habbo-gamedata-hotel";
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
@@ -22,26 +24,36 @@ interface BadgeEntry {
}
let badgeCache: BadgeEntry[] | null = null;
let cacheHotel: HabboGamedataHotel | null = null;
let cacheTimestamp = 0;
let loadingPromise: Promise<BadgeEntry[]> | null = null;
/**
* Fetch and parse all badges from official Habbo external texts.
* Cached in memory for 30 minutes.
* Cached in memory for 30 minutes (per configured hotel).
*/
async function loadBadges(): Promise<BadgeEntry[]> {
async function loadBadges(): Promise<{
badges: BadgeEntry[];
hotel: HabboGamedataHotel;
}> {
const hotel = await getHabboGamedataHotel();
const now = Date.now();
if (badgeCache && now - cacheTimestamp < CACHE_TTL) return badgeCache;
if (loadingPromise) return loadingPromise;
if (
badgeCache &&
cacheHotel === hotel &&
now - cacheTimestamp < CACHE_TTL
) {
return { badges: badgeCache, hotel };
}
if (loadingPromise) {
const badges = await loadingPromise;
return { badges, hotel: cacheHotel ?? hotel };
}
loadingPromise = (async () => {
const url = habboExternalTextsUrl(hotel);
try {
if (!EXTERNAL_TEXTS_URL) {
console.warn("[badges] HABBO_EXTERNAL_TEXTS_URL not configured");
return badgeCache || [];
}
const res = await fetch(EXTERNAL_TEXTS_URL, {
const res = await fetch(url, {
signal: AbortSignal.timeout(20000),
});
@@ -53,7 +65,6 @@ async function loadBadges(): Promise<BadgeEntry[]> {
const text = await res.text();
const lines = text.split("\n");
// Parse key=value format, extract badge_name_CODE and badge_desc_CODE
const nameMap = new Map<string, string>();
const descMap = new Map<string, string>();
@@ -63,7 +74,6 @@ async function loadBadges(): Promise<BadgeEntry[]> {
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);
@@ -86,7 +96,6 @@ async function loadBadges(): Promise<BadgeEntry[]> {
}
}
// Merge into badge entries
const allCodes = new Set([...nameMap.keys(), ...descMap.keys()]);
const badges: BadgeEntry[] = [];
@@ -98,14 +107,14 @@ async function loadBadges(): Promise<BadgeEntry[]> {
});
}
// Sort alphabetically by code
badges.sort((a, b) => a.code.localeCompare(b.code));
console.debug(
`[badges] Loaded ${badges.length} badges from external texts`,
`[badges] Loaded ${badges.length} badges from habbo.${hotel}`,
);
badgeCache = badges;
cacheTimestamp = now;
cacheHotel = hotel;
cacheTimestamp = Date.now();
return badges;
} catch (err) {
console.warn("[badges] Load error:", (err as Error).message);
@@ -115,7 +124,8 @@ async function loadBadges(): Promise<BadgeEntry[]> {
}
})();
return loadingPromise;
const badges = await loadingPromise;
return { badges, hotel: cacheHotel ?? hotel };
}
/**
@@ -126,9 +136,9 @@ export async function searchBadges(
term: string,
limit = 100,
offset = 0,
_hotel: string | null = null, // kept for API compat, ignored (single source)
_hotel: string | null = null, // API compat; locale comes from CMS settings
): Promise<HabboAssetBadge[]> {
const badges = await loadBadges();
const { badges, hotel } = await loadBadges();
const search = term.toLowerCase().trim();
let filtered = badges;
@@ -140,7 +150,6 @@ export async function searchBadges(
b.description.toLowerCase().includes(search),
);
} else {
// No search term: return newest (reverse sorted, last entries are newest)
filtered = [...badges].reverse();
}
@@ -151,14 +160,15 @@ export async function searchBadges(
code: b.code,
name: b.name,
description: b.description,
hotel: "it",
hotel,
url_habbo: `${BADGE_IMAGE_BASE}/${b.code}.gif`,
url_habboassets: `${BADGE_IMAGE_BASE}/${b.code}.gif`, // same source, no fallback needed
url_habboassets: `${BADGE_IMAGE_BASE}/${b.code}.gif`,
}));
}
/** Clear the badge cache */
export function clearBadgeCache(): void {
badgeCache = null;
cacheHotel = null;
cacheTimestamp = 0;
}
+2 -2
View File
@@ -1,7 +1,7 @@
/**
* Furniture search & listing — powered by official Habbo furnidata.
*
* Uses habbo.it/gamedata/furnidata_json as the single source of truth.
* Hotel locale comes from CMS setting `habbo_gamedata_hotel`.
* No third-party APIs (habbofurni.com) needed.
*/
@@ -14,7 +14,7 @@ export type { FurniListResult, HabboFurniItem };
/**
* List furniture from official Habbo furnidata with optional search and pagination.
* Data comes from habbo.it/gamedata/furnidata_json/1 (cached 30 min).
* Data comes from the configured hotel furnidata_json (cached 30 min).
*/
export async function listFurniture(
options: {
+1
View File
@@ -6,6 +6,7 @@ const DEFAULTS: Record<string, string> = {
habbo_imaging_url: "/imaging",
logo_url: "",
nitro_client_url: "",
habbo_gamedata_hotel: "it",
};
const CACHE_TTL_MS = 300_000;