fix(catalog): take furnidata values from the clone source for retro items
Gitea Actions Runner Test / test-job (push) Successful in 0s
CI / check (push) Successful in 29s
CI / tests-integration (push) Successful in 1m33s
CI / tests-unit (push) Successful in 1m33s
CI / tests-ui (push) Successful in 2m21s
CI / preflight (push) Skipped
CI / deploy (push) Successful in 1m53s

The resync route rebuilt every entry from items_base plus the *official
Habbo* furnidata. A classname that only exists on a retro hotel (leet.ws
and friends) is absent from the official set, so lookupOfficialHabboFurni
returned null and the entry was written with revision 0, category
"unknown" and an empty description — even though the clone import had
those exact values available at import time from the source's own
furnidata.

- resync/route.ts: when a classname is genuinely missing from official
  Habbo, fall back to the configured clone sources. Their furnidata is
  indexed by normalized classname and each entry is coerced into the
  OfficialHabboFurniEntry shape, which is the same JSON shape, so it drives
  the existing buildFurniEntry fallbacks for revision, category, name,
  description, defaultdir, partcolors, specialtype, furniline, environment,
  rare and bc. items_base stays authoritative for id, spriteId and dims,
  and public_name still wins over the source name, matching the import.

  The index is memoized per request, not at module scope: a module-level
  cache would pin the source list for the life of the process and a source
  added later would never be picked up. fetchSourceFurnidata already caches
  per URL, so this costs one parse rather than a network round-trip.

  Disabled sources and sources that fail to respond are skipped, so an
  unreachable hotel degrades to the previous items_base-only behaviour
  instead of failing the run. Official Habbo still wins whenever it has the
  classname, so existing behaviour is unchanged for everything but the
  retro-only case.

Applies to every resync mode, so the pre-existing ?missing=1 sweep picks
this up too.
This commit is contained in:
openhands committed 2026-09-29 16:02:58 +02:00
1 parent 2f7e557d5e
commit d73baf1458
2 files changed
+255 -3

No files matched your search

@@ -9,6 +9,11 @@ const state = vi.hoisted(() => ({
clear: vi.fn(),
rconCatalog: vi.fn(async () => true),
rconItems: vi.fn(async () => true),
official: vi.fn(
async (_classname: string) => null as Record<string, unknown> | null,
),
sources: [] as unknown[],
fetchSource: vi.fn(async (_url: string) => [] as unknown[]),
}));
vi.mock("@/lib/api-handler", () => ({
@@ -57,16 +62,37 @@ vi.mock("@/lib/services/furni-import-cache", () => ({
vi.mock("@/lib/services/furni-data", () => ({
appendFurniEntriesBatch: state.append,
buildFurniEntry: (params: { classname: string; id: number }) => ({
classname: params.classname,
// Mirrors the real coercion closely enough to assert the fields a
// retro-hotel classname would otherwise lose.
buildFurniEntry: (params: {
id: number;
classname: string;
revision: number;
category: string;
name: string;
description: string;
}) => ({
id: params.id,
classname: params.classname,
revision: params.revision,
category: params.category,
name: params.name,
description: params.description,
}),
getFurnitureDataPath: async () => "/nonexistent/furnidata.json",
readFurniData: async () => ({}),
}));
vi.mock("@/lib/services/habbo-furnidata-cache", () => ({
lookupOfficialHabboFurni: async () => null,
lookupOfficialHabboFurni: state.official,
}));
vi.mock("@/lib/services/clone-sources", () => ({
listSources: async () => state.sources,
}));
vi.mock("@/lib/services/clone-import", () => ({
fetchSourceFurnidata: state.fetchSource,
}));
vi.mock("@/lib/services/rcon", () => ({
@@ -84,6 +110,15 @@ const post = (body?: unknown) =>
body: body === undefined ? undefined : JSON.stringify(body),
});
function firstEntry(): Record<string, unknown> {
const calls = state.append.mock.calls;
expect(calls.length).toBeGreaterThan(0);
const entries = calls[0]?.[0] as Array<{ entry: Record<string, unknown> }>;
const first = entries?.[0];
if (!first) throw new Error("no furnidata entry was written");
return first.entry;
}
beforeEach(() => {
state.localIndex = new Map([
["chair", [{ id: 1, itemName: "chair" }]],
@@ -93,6 +128,9 @@ beforeEach(() => {
{ id: 1, itemName: "chair", spriteId: 1, type: "s" },
{ id: 2, itemName: "table", spriteId: 2, type: "s" },
];
state.sources = [];
state.official.mockClear().mockResolvedValue(null);
state.fetchSource.mockClear().mockResolvedValue([]);
state.append.mockClear();
state.clear.mockClear();
state.rconCatalog.mockClear();
@@ -149,6 +187,133 @@ describe("resync with an explicit classname selection", () => {
});
});
describe("resync entry data for retro-hotel classnames", () => {
/** A leet.ws-only classname: present in items_base, absent from official Habbo. */
const retroRow = {
id: 3,
itemName: "sh Uni*1",
spriteId: 3,
publicName: "Shabby Uni",
type: "i",
width: 1,
length: 1,
stackHeight: 1,
};
const retroSourceEntry = {
id: 3,
classname: "sh Uni*1",
revision: 42,
category: "sh Uni",
name: "Uni Shabby",
description: "Een leuk stoeltje",
defaultdir: 0,
xdim: 1,
ydim: 1,
partcolors: { color: ["3d3d3d"] },
specialtype: 0,
canstandon: false,
cansiton: false,
canlayon: false,
furniline: "uni",
environment: "",
rare: false,
bc: false,
customparams: "",
itemType: "i",
};
it("uses the clone source's revision, category and description", async () => {
state.localIndex = new Map([
["sh uni*1", [{ id: 3, itemName: "sh Uni*1" }]],
]);
state.rows = [retroRow];
state.sources = [
{ id: "leet", furnidataUrl: "https://leet.ws/furnidata", enabled: true },
];
state.fetchSource.mockResolvedValue([retroSourceEntry]);
const response = await POST(
post({ classnames: ["sh Uni*1"] }),
emptyRouteContext(),
);
expect((await response.json()).resynced).toBe(1);
expect(firstEntry()).toMatchObject({
classname: "sh Uni*1",
id: 3,
revision: 42,
category: "sh Uni",
description: "Een leuk stoeltje",
});
});
it("prefers items_base public_name over the source name", async () => {
state.localIndex = new Map([
["sh uni*1", [{ id: 3, itemName: "sh Uni*1" }]],
]);
state.rows = [retroRow];
state.sources = [
{ id: "leet", furnidataUrl: "https://leet.ws/furnidata", enabled: true },
];
state.fetchSource.mockResolvedValue([retroSourceEntry]);
await POST(post({ classnames: ["sh Uni*1"] }), emptyRouteContext());
expect(firstEntry()).toMatchObject({ name: "Shabby Uni" });
});
it("skips a disabled source", async () => {
state.localIndex = new Map([
["sh uni*1", [{ id: 3, itemName: "sh Uni*1" }]],
]);
state.rows = [retroRow];
state.sources = [
{ id: "leet", furnidataUrl: "https://leet.ws/furnidata", enabled: false },
];
await POST(post({ classnames: ["sh Uni*1"] }), emptyRouteContext());
expect(state.fetchSource).not.toHaveBeenCalled();
expect(firstEntry()).toMatchObject({ revision: 0, category: "unknown" });
});
it("still resyncs when a source is unreachable", async () => {
state.localIndex = new Map([
["sh uni*1", [{ id: 3, itemName: "sh Uni*1" }]],
]);
state.rows = [retroRow];
state.sources = [
{ id: "leet", furnidataUrl: "https://leet.ws/furnidata", enabled: true },
];
state.fetchSource.mockRejectedValue(new Error("502 from leet.ws"));
const response = await POST(
post({ classnames: ["sh Uni*1"] }),
emptyRouteContext(),
);
const body = await response.json();
expect(body).toMatchObject({ resynced: 1, failed: 0 });
expect(firstEntry()).toMatchObject({ revision: 0, category: "unknown" });
});
it("never contacts a clone source when official Habbo has the classname", async () => {
state.sources = [
{ id: "leet", furnidataUrl: "https://leet.ws/furnidata", enabled: true },
];
state.official.mockResolvedValue({
id: 1,
classname: "chair",
revision: 7,
category: "chair",
name: "Chair",
description: "",
});
await POST(post({ classnames: ["chair"] }), emptyRouteContext());
expect(state.fetchSource).not.toHaveBeenCalled();
expect(firstEntry()).toMatchObject({ revision: 7, category: "chair" });
});
});
describe("resync query-param modes", () => {
it("falls back to the default days mode when no body is sent", async () => {
const response = await POST(post(), emptyRouteContext());
@@ -5,6 +5,11 @@ import { withAdmin } from "@/lib/api-handler";
import { db, ItemsBase, queryRows } from "@/lib/db";
import { normalizeClassname } from "@/lib/furni/classname";
import { PERMS } from "@/lib/permission-slugs";
import {
fetchSourceFurnidata,
type SourceFurni,
} from "@/lib/services/clone-import";
import { type CloneSource, listSources } from "@/lib/services/clone-sources";
import {
appendFurniEntriesBatch,
buildFurniEntry,
@@ -17,6 +22,7 @@ import {
} from "@/lib/services/furni-import-cache";
import { lookupOfficialHabboFurni } from "@/lib/services/habbo-furnidata-cache";
import { rcon } from "@/lib/services/rcon";
import type { OfficialHabboFurniEntry } from "@/types/furni";
/**
* POST /api/admin/import/furni/resync
@@ -89,6 +95,15 @@ export const POST = withAdmin(
let resynced = 0;
const errors: Array<{ classname: string; message: string }> = [];
// Configured clone sources' furnidata, indexed by normalized classname.
// Memoized for this request only: a module-level cache would pin the
// source list for the life of the process, so a source added later would
// never be picked up. `fetchSourceFurnidata` already caches per URL, so
// building this per request costs one parse, not one network round-trip.
let cloneSourceIndex: Promise<Map<string, OfficialHabboFurniEntry>> | null =
null;
const cloneIndex = () => (cloneSourceIndex ??= buildCloneSourceIndex());
for (let i = 0; i < targetIds.length; i += CHUNK) {
const slice = targetIds.slice(i, i + CHUNK);
const rows = await db
@@ -118,6 +133,14 @@ export const POST = withAdmin(
} catch {
/* optional */
}
// A retro-hotel classname (leet.ws et al.) is absent from the
// official Habbo furnidata, so fall back to the configured clone
// sources. Without this the entry was written with revision 0,
// category "unknown" and an empty description.
if (!officialHabbo) {
officialHabbo =
(await cloneIndex()).get(normalizeClassname(classname)) ?? null;
}
const entry = buildFurniEntry({
id: row.id,
@@ -184,6 +207,70 @@ export const POST = withAdmin(
},
);
async function buildCloneSourceIndex(): Promise<
Map<string, OfficialHabboFurniEntry>
> {
const index = new Map<string, OfficialHabboFurniEntry>();
let sources: CloneSource[];
try {
sources = await listSources();
} catch {
// Without the source list we simply fall back to items_base values.
return index;
}
for (const source of sources) {
if (source.enabled === false || !source.furnidataUrl) continue;
try {
for (const entry of await fetchSourceFurnidata(source.furnidataUrl)) {
const key = normalizeClassname(entry.classname);
if (key && !index.has(key)) index.set(key, toFurniEnrichment(entry));
}
} catch {
// An unreachable source is skipped; its classnames keep falling back
// to items_base rather than failing the whole resync.
}
}
return index;
}
/**
* A clone source entry is the same JSON shape as an official Habbo one, so it
* can drive the same enrichment fallbacks in `buildFurniEntry`. Mirrors the
* coercion the official furnidata cache applies.
*/
function toFurniEnrichment(entry: SourceFurni): OfficialHabboFurniEntry {
return {
id: entry.id,
classname: entry.classname,
revision: Number(entry.revision ?? 0),
category: String(entry.category ?? ""),
defaultdir: Number(entry.defaultdir ?? 0),
xdim: entry.xdim,
ydim: entry.ydim,
partcolors: toPartcolors(entry.partcolors),
name: entry.name,
description: entry.description,
specialtype: Number(entry.specialtype ?? 0),
canstandon: entry.canstandon,
cansiton: entry.cansiton,
canlayon: entry.canlayon,
furniline: String(entry.furniline ?? ""),
environment: String(entry.environment ?? ""),
rare: entry.rare === true,
tradeable: entry.tradeable === true,
bc: entry.bc === true,
customparams: String(entry.customparams ?? ""),
itemType: entry.itemType,
};
}
function toPartcolors(raw: unknown): { color: string[] } {
// Only the `{ color: [...] }` shape is understood, same as the official
// furnidata cache. A flat colour array is dropped rather than guessed at.
const color = (raw as { color?: unknown } | null | undefined)?.color;
return Array.isArray(color) ? (raw as { color: string[] }) : { color: [] };
}
async function resolveClassnames(classnames: string[]): Promise<number[]> {
if (classnames.length === 0) return [];
const { localIndex } = await getLocalCatalogSnapshot();