Recognize imported furniture by normalized classname and local IDs
This commit is contained in:
1 parent
9dc6b8a261
commit
a78d8256e0
7 files changed
+254
-46
No files matched your search
File diff suppressed because it is too large.
Load diff
@@ -94,4 +94,50 @@ describe("furniture inspection", () => {
|
|||||||
expect(response.status).toBe(200);
|
expect(response.status).toBe(200);
|
||||||
expect((await response.json()).items[0].furnidataReadable).toBe(false);
|
expect((await response.json()).items[0].furnidataReadable).toBe(false);
|
||||||
});
|
});
|
||||||
|
it("recognizes existing SQL and furnidata across classname casing", async () => {
|
||||||
|
mocks.execute
|
||||||
|
.mockResolvedValueOnce([
|
||||||
|
[
|
||||||
|
{
|
||||||
|
id: 2000037258,
|
||||||
|
spriteId: 2000037258,
|
||||||
|
classname: "recycler_kintsugib",
|
||||||
|
name: "Local",
|
||||||
|
type: "s",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[],
|
||||||
|
])
|
||||||
|
.mockResolvedValueOnce([
|
||||||
|
[
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
pageId: 4,
|
||||||
|
classname: "RECYCLER_KINTSUGIB",
|
||||||
|
credits: 3,
|
||||||
|
points: 0,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[],
|
||||||
|
]);
|
||||||
|
mocks.read.mockResolvedValue({
|
||||||
|
roomitemtypes: {
|
||||||
|
furnitype: [
|
||||||
|
{ id: 2000037258, classname: "recycler_kintsugib", name: "Local" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
wallitemtypes: { furnitype: [] },
|
||||||
|
});
|
||||||
|
mocks.stat.mockImplementation(async (filename) => {
|
||||||
|
if (String(filename).includes("kintsugib"))
|
||||||
|
return { size: 256, isFile: () => true };
|
||||||
|
throw { code: "ENOENT" };
|
||||||
|
});
|
||||||
|
const result = await (await POST(request(["recycler_kintsugiB"]))).json();
|
||||||
|
expect(result.items[0].sql[0].id).toBe(2000037258);
|
||||||
|
expect(result.items[0].catalog).toHaveLength(1);
|
||||||
|
expect(result.items[0].furnidata).toHaveLength(1);
|
||||||
|
expect(result.items[0].nitro.exists).toBe(true);
|
||||||
|
expect(result.items[0].icon.exists).toBe(true);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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)),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import { promises as fs } from "node:fs";
|
|||||||
import { eq, inArray, sql } from "drizzle-orm";
|
import { eq, inArray, sql } from "drizzle-orm";
|
||||||
import { db, ItemsBase } from "@/lib/db";
|
import { db, ItemsBase } from "@/lib/db";
|
||||||
import { autoDetectInteraction } from "@/lib/furni/auto-interaction";
|
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 { HABBO_GAMEDATA_HOTEL_SETTING_KEY } from "@/lib/habbo-gamedata-hotel";
|
||||||
import { logger } from "@/lib/logger";
|
import { logger } from "@/lib/logger";
|
||||||
import { extractFurniIconPng } from "@/lib/services/clone-icon";
|
import { extractFurniIconPng } from "@/lib/services/clone-icon";
|
||||||
@@ -510,21 +511,24 @@ export async function getCloneList(params: {
|
|||||||
.select({ itemName: ItemsBase.itemName })
|
.select({ itemName: ItemsBase.itemName })
|
||||||
.from(ItemsBase)
|
.from(ItemsBase)
|
||||||
.where(inArray(ItemsBase.itemName, allNames));
|
.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 =
|
const filtered =
|
||||||
filter === "missing"
|
filter === "missing"
|
||||||
? searched.filter((e) => !present.has(e.classname))
|
? searched.filter((e) => !present.has(normalizeClassname(e.classname)))
|
||||||
: filter === "present"
|
: filter === "present"
|
||||||
? searched.filter((e) => present.has(e.classname))
|
? searched.filter((e) => present.has(normalizeClassname(e.classname)))
|
||||||
: searched;
|
: searched;
|
||||||
|
|
||||||
const total = filtered.length;
|
const total = filtered.length;
|
||||||
const start = (Math.max(page, 1) - 1) * perPage;
|
const start = (Math.max(page, 1) - 1) * perPage;
|
||||||
const slice = filtered.slice(start, start + perPage);
|
const slice = filtered.slice(start, start + perPage);
|
||||||
return {
|
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 },
|
meta: { page: Math.max(page, 1), perPage, total },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -540,9 +544,11 @@ export async function getCloneStats(
|
|||||||
.select({ itemName: ItemsBase.itemName })
|
.select({ itemName: ItemsBase.itemName })
|
||||||
.from(ItemsBase)
|
.from(ItemsBase)
|
||||||
.where(inArray(ItemsBase.itemName, classnames));
|
.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 };
|
return { total: all.length, present, clonable: all.length - present };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -561,7 +567,9 @@ export async function getClonableClassnames(
|
|||||||
.select({ itemName: ItemsBase.itemName })
|
.select({ itemName: ItemsBase.itemName })
|
||||||
.from(ItemsBase)
|
.from(ItemsBase)
|
||||||
.where(inArray(ItemsBase.itemName, classnames));
|
.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);
|
||||||
}
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { normalizeClassname } from "@/lib/furni/classname";
|
||||||
/**
|
/**
|
||||||
* Furniture search & listing — powered by official Habbo furnidata.
|
* 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 perPage = Math.min(options.perPage || 50, 100);
|
||||||
const page = options.page || 1;
|
const page = options.page || 1;
|
||||||
const search = options.search?.toLowerCase().trim() || "";
|
const search = options.search?.toLowerCase().trim() || "";
|
||||||
const importedSet = options.importedSet;
|
const importedSet = options.importedSet
|
||||||
|
? new Set([...options.importedSet].map(normalizeClassname))
|
||||||
|
: undefined;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const map = await getOfficialHabboFurnidata();
|
const map = await getOfficialHabboFurnidata();
|
||||||
@@ -58,8 +61,8 @@ export async function listFurniture(
|
|||||||
if (importedSet) {
|
if (importedSet) {
|
||||||
// Non-imported first (id desc), then imported (id desc).
|
// Non-imported first (id desc), then imported (id desc).
|
||||||
items.sort((a, b) => {
|
items.sort((a, b) => {
|
||||||
const aImp = importedSet.has(a.classname) ? 1 : 0;
|
const aImp = importedSet.has(normalizeClassname(a.classname)) ? 1 : 0;
|
||||||
const bImp = importedSet.has(b.classname) ? 1 : 0;
|
const bImp = importedSet.has(normalizeClassname(b.classname)) ? 1 : 0;
|
||||||
if (aImp !== bImp) return aImp - bImp;
|
if (aImp !== bImp) return aImp - bImp;
|
||||||
return b.id - a.id;
|
return b.id - a.id;
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in new issue
Block a user