diff --git a/src/app/api/admin/import/furni/resync/route.test.ts b/src/app/api/admin/import/furni/resync/route.test.ts index 0f5cdc58..f8669310 100644 --- a/src/app/api/admin/import/furni/resync/route.test.ts +++ b/src/app/api/admin/import/furni/resync/route.test.ts @@ -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 | 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 { + const calls = state.append.mock.calls; + expect(calls.length).toBeGreaterThan(0); + const entries = calls[0]?.[0] as Array<{ entry: Record }>; + 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()); diff --git a/src/app/api/admin/import/furni/resync/route.ts b/src/app/api/admin/import/furni/resync/route.ts index 16eac70e..7ceb95f4 100644 --- a/src/app/api/admin/import/furni/resync/route.ts +++ b/src/app/api/admin/import/furni/resync/route.ts @@ -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> | 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 +> { + const index = new Map(); + 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 { if (classnames.length === 0) return []; const { localIndex } = await getLocalCatalogSnapshot();