Recognize imported furniture by normalized classname and local IDs
CI / check (push) Successful in 1m30s
CI / deploy (push) Successful in 1m21s
CI / e2e (push) Successful in 22s

This commit is contained in:
Simo committed 2026-09-05 14:04:52 +02:00
1 parent 9dc6b8a261
commit a78d8256e0
7 files changed
+254 -46

No files matched your search

+55
View File
@@ -0,0 +1,55 @@
import { describe, expect, it } from "vitest";
import { indexLocalFurniture, localFurnitureStatus } from "./local-presence";
const index = indexLocalFurniture([
{ id: 2000037258, itemName: "recycler_kintsugib" },
]);
describe("local furniture recognition", () => {
it("recognizes casing differences and uses the allocated catalog ID", () => {
expect(
localFurnitureStatus(
"recycler_kintsugiB",
index,
new Set(["recycler_kintsugib"]),
new Set([2000037258]),
),
).toEqual({
alreadyImported: true,
localId: 2000037258,
hasFurnidata: true,
hasCatalogEntry: true,
});
});
it("does not confuse the base furniture with its named variant", () => {
expect(
localFurnitureStatus(
"recycler_kintsugi",
index,
new Set(),
new Set([16940]),
).alreadyImported,
).toBe(false);
});
it("does not identify a source-ID collision as a matching catalog offer", () => {
expect(
localFurnitureStatus(
"recycler_kintsugiB",
index,
new Set(),
new Set([16940]),
).hasCatalogEntry,
).toBe(false);
});
it("retains separate color variants and duplicate SQL records", () => {
const rows = indexLocalFurniture([
{ id: 1, itemName: "chair*0" },
{ id: 2, itemName: "CHAIR*0" },
{ id: 3, itemName: "chair*1" },
]);
expect(rows.get("chair*0")).toHaveLength(2);
expect(rows.get("chair*1")).toHaveLength(1);
expect(
localFurnitureStatus("chair*0", rows, new Set(), new Set()).localId,
).toBeUndefined();
});
});
+29
View File
@@ -0,0 +1,29 @@
import { normalizeClassname } from "./classname";
export function indexLocalFurniture<T extends { id: number; itemName: string }>(
rows: T[],
) {
const index = new Map<string, T[]>();
for (const row of rows) {
const key = normalizeClassname(row.itemName);
const matches = index.get(key) ?? [];
matches.push(row);
index.set(key, matches);
}
return index;
}
export function localFurnitureStatus(
classname: string,
local: Map<string, Array<{ id: number; itemName: string }>>,
furnidata: Set<string>,
catalogIds: Set<number>,
) {
const key = normalizeClassname(classname);
const rows = local.get(key) ?? [];
return {
alreadyImported: rows.length > 0,
localId: rows.length === 1 ? rows[0].id : undefined,
hasFurnidata: furnidata.has(key),
hasCatalogEntry: rows.some((row) => catalogIds.has(row.id)),
};
}
+16 -8
View File
@@ -2,6 +2,7 @@ import { promises as fs } from "node:fs";
import { eq, inArray, sql } from "drizzle-orm";
import { db, ItemsBase } from "@/lib/db";
import { autoDetectInteraction } from "@/lib/furni/auto-interaction";
import { normalizeClassname } from "@/lib/furni/classname";
import { HABBO_GAMEDATA_HOTEL_SETTING_KEY } from "@/lib/habbo-gamedata-hotel";
import { logger } from "@/lib/logger";
import { extractFurniIconPng } from "@/lib/services/clone-icon";
@@ -510,21 +511,24 @@ export async function getCloneList(params: {
.select({ itemName: ItemsBase.itemName })
.from(ItemsBase)
.where(inArray(ItemsBase.itemName, allNames));
for (const r of rows) present.add(r.itemName);
for (const r of rows) present.add(normalizeClassname(r.itemName));
}
const filtered =
filter === "missing"
? searched.filter((e) => !present.has(e.classname))
? searched.filter((e) => !present.has(normalizeClassname(e.classname)))
: filter === "present"
? searched.filter((e) => present.has(e.classname))
? searched.filter((e) => present.has(normalizeClassname(e.classname)))
: searched;
const total = filtered.length;
const start = (Math.max(page, 1) - 1) * perPage;
const slice = filtered.slice(start, start + perPage);
return {
items: slice.map((e) => ({ ...e, present: present.has(e.classname) })),
items: slice.map((e) => ({
...e,
present: present.has(normalizeClassname(e.classname)),
})),
meta: { page: Math.max(page, 1), perPage, total },
};
}
@@ -540,9 +544,11 @@ export async function getCloneStats(
.select({ itemName: ItemsBase.itemName })
.from(ItemsBase)
.where(inArray(ItemsBase.itemName, classnames));
for (const r of rows) have.add(r.itemName);
for (const r of rows) have.add(normalizeClassname(r.itemName));
}
const present = all.filter((e) => have.has(e.classname)).length;
const present = all.filter((e) =>
have.has(normalizeClassname(e.classname)),
).length;
return { total: all.length, present, clonable: all.length - present };
}
@@ -561,7 +567,9 @@ export async function getClonableClassnames(
.select({ itemName: ItemsBase.itemName })
.from(ItemsBase)
.where(inArray(ItemsBase.itemName, classnames));
for (const r of rows) have.add(r.itemName);
for (const r of rows) have.add(normalizeClassname(r.itemName));
}
return all.filter((e) => !have.has(e.classname)).map((e) => e.classname);
return all
.filter((e) => !have.has(normalizeClassname(e.classname)))
.map((e) => e.classname);
}
+6 -3
View File
@@ -1,3 +1,4 @@
import { normalizeClassname } from "@/lib/furni/classname";
/**
* Furniture search & listing — powered by official Habbo furnidata.
*
@@ -37,7 +38,9 @@ export async function listFurniture(
const perPage = Math.min(options.perPage || 50, 100);
const page = options.page || 1;
const search = options.search?.toLowerCase().trim() || "";
const importedSet = options.importedSet;
const importedSet = options.importedSet
? new Set([...options.importedSet].map(normalizeClassname))
: undefined;
try {
const map = await getOfficialHabboFurnidata();
@@ -58,8 +61,8 @@ export async function listFurniture(
if (importedSet) {
// Non-imported first (id desc), then imported (id desc).
items.sort((a, b) => {
const aImp = importedSet.has(a.classname) ? 1 : 0;
const bImp = importedSet.has(b.classname) ? 1 : 0;
const aImp = importedSet.has(normalizeClassname(a.classname)) ? 1 : 0;
const bImp = importedSet.has(normalizeClassname(b.classname)) ? 1 : 0;
if (aImp !== bImp) return aImp - bImp;
return b.id - a.id;
});