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; }