Interaction detection: - Trust SWF-derived sit/lay/stand flags only when the logic XML actually contains action data (new hasActions metadata); otherwise fall back to keyword detection so custom furni without <action> nodes are still classified correctly - Add French/Dutch/German/Spanish/Italian keywords (chaise, banquette, stoel, silla, sedia, stuhl, tafel, mesa, ...) to sit/lay/stand detection with token-boundary matching to avoid false positives like bedside_table - Unify interaction_modes_count priority: mechanic fixed modes, then raw animation state count, then sit/lay fallback, then keyword default - Detect mechanic type even when real flags are not used Catalog integrity: - Skip duplicate catalog_items inserts in clone and upload imports - Re-check live catalog rows before applying generated repair SQL - Add rebuildCatalogOfferIds() which rebuilds every catalog_items.offer_id from the local FurnitureData.json (matched by entry id, then classname) and run it after every import path (single, batch, batch-regen, clone)
659 lines
21 KiB
TypeScript
659 lines
21 KiB
TypeScript
import { existsSync, promises as fs } from "node:fs";
|
|
import path from "node:path";
|
|
import { eq } from "drizzle-orm";
|
|
import { CatalogItems, db, ItemsBase } from "@/lib/db";
|
|
import {
|
|
getGamedataRoot,
|
|
getNitroFilesRoot,
|
|
} from "@/lib/services/furni-asset-dirs";
|
|
import type { OfficialHabboFurniEntry } from "@/lib/services/habbo-furnidata-cache";
|
|
import { siteSettings } from "@/lib/services/site-settings";
|
|
import type { FurniMetadata } from "@/lib/services/swf-to-nitro";
|
|
|
|
const LOCK_STALE_MS = 60_000;
|
|
const DEFAULT_FURNI_DATA_URL = "/gamedata/config/FurnitureData.json";
|
|
|
|
/**
|
|
* Resolve the FurnitureData file path from the admin setting
|
|
* `furni_data_url`. The production `/gamedata` mount maps to the configured
|
|
* Gamedata filesystem root; other relative URLs map under public/.
|
|
*/
|
|
export async function getFurnitureDataPath(): Promise<string> {
|
|
const url =
|
|
(await siteSettings.get("furni_data_url", DEFAULT_FURNI_DATA_URL)) ??
|
|
DEFAULT_FURNI_DATA_URL;
|
|
if (path.isAbsolute(url) && !url.startsWith("/")) return url;
|
|
if (url === "/gamedata" || url.startsWith("/gamedata/")) {
|
|
const gamedataRoot = await getGamedataRoot();
|
|
if (gamedataRoot) {
|
|
const relative = url.replace(/^\/gamedata\/?/, "");
|
|
return path.join(/*turbopackIgnore: true*/ gamedataRoot, relative);
|
|
}
|
|
}
|
|
const relative = url.replace(/^\/+/, "");
|
|
// The `relative` segment is admin-configurable so its concrete value is
|
|
// unknown at build time; without the hint Turbopack traces the entire
|
|
// public/ tree (~400k files) into the route's NFT.
|
|
return path.join(/*turbopackIgnore: true*/ process.cwd(), "public", relative);
|
|
}
|
|
|
|
export async function getFurnitureDataWritePaths(): Promise<string[]> {
|
|
const primary = await getFurnitureDataPath();
|
|
const configuredMirror =
|
|
(await siteSettings.get("furni_data_mirror_path", "")) ?? "";
|
|
const [nitroFilesRoot, gamedataRoot] = await Promise.all([
|
|
getNitroFilesRoot(),
|
|
getGamedataRoot(),
|
|
]);
|
|
const mirror = configuredMirror.trim()
|
|
? configuredMirror
|
|
: nitroFilesRoot
|
|
? path.join(
|
|
/*turbopackIgnore: true*/ nitroFilesRoot,
|
|
"nitro-assets/gamedata/FurnitureData.json",
|
|
)
|
|
: "";
|
|
|
|
const gamedataPath = gamedataRoot
|
|
? path.join(
|
|
/*turbopackIgnore: true*/ gamedataRoot,
|
|
"config/FurnitureData.json",
|
|
)
|
|
: "";
|
|
const paths = [primary, mirror, gamedataPath]
|
|
.filter(Boolean)
|
|
.map((p) => path.normalize(p));
|
|
const seen = new Set<string>();
|
|
return paths.filter((p) => {
|
|
const key = process.platform === "win32" ? p.toLowerCase() : p;
|
|
if (seen.has(key)) return false;
|
|
seen.add(key);
|
|
return true;
|
|
});
|
|
}
|
|
|
|
async function getFurnitureDataReadPath(): Promise<string> {
|
|
const paths = await getFurnitureDataWritePaths();
|
|
const existing = paths.find((p) => existsSync(/*turbopackIgnore: true*/ p));
|
|
return existing ?? paths[0];
|
|
}
|
|
|
|
// ── FurnitureData.json lock + helpers ────────────────────────────────
|
|
|
|
let furniDataLock: Promise<void> = Promise.resolve();
|
|
|
|
async function acquireDiskLock(lockPath: string): Promise<void> {
|
|
const deadline = Date.now() + 30_000;
|
|
while (true) {
|
|
try {
|
|
const fh = await fs.open(lockPath, "wx");
|
|
await fh.writeFile(String(process.pid));
|
|
await fh.close();
|
|
return;
|
|
} catch (err) {
|
|
const e = err as NodeJS.ErrnoException;
|
|
if (e.code !== "EEXIST") throw err;
|
|
try {
|
|
const stat = await fs.stat(lockPath);
|
|
if (Date.now() - stat.mtimeMs > LOCK_STALE_MS) {
|
|
await fs.unlink(lockPath).catch(() => {});
|
|
continue;
|
|
}
|
|
} catch {
|
|
/* lock vanished between checks */
|
|
}
|
|
if (Date.now() > deadline) throw new Error("FurnitureData lock timeout");
|
|
await new Promise((r) => setTimeout(r, 100));
|
|
}
|
|
}
|
|
}
|
|
|
|
/** Build a map of sprite id → classname from both sections of the furnidata.
|
|
* If multiple entries share the same id, the first encountered classname wins. */
|
|
function furniDataIdOwners(
|
|
data: Record<string, { furnitype: Array<Record<string, unknown>> }>,
|
|
): Map<number, string> {
|
|
const owners = new Map<number, string>();
|
|
for (const section of ["roomitemtypes", "wallitemtypes"] as const) {
|
|
for (const e of data[section]?.furnitype ?? []) {
|
|
const id = Number(e?.id);
|
|
if (!Number.isFinite(id) || id <= 0) continue;
|
|
const classname = typeof e?.classname === "string" ? e.classname : "";
|
|
if (!classname) continue;
|
|
if (!owners.has(id)) owners.set(id, classname);
|
|
}
|
|
}
|
|
return owners;
|
|
}
|
|
|
|
/** Assert that no entry in `entries` has a spriteId already used by a different classname
|
|
* in the existing furnidata or within this batch. Throws if a conflict is found. */
|
|
function assertNoFurniDataIdConflicts(
|
|
existingOwners: Map<number, string>,
|
|
entries: Array<{ entry: Record<string, unknown>; itemType: string }>,
|
|
): void {
|
|
const conflicts: string[] = [];
|
|
for (const { entry } of entries) {
|
|
const id = Number(entry?.id);
|
|
if (!Number.isFinite(id) || id <= 0) continue;
|
|
const classname =
|
|
typeof entry?.classname === "string" ? entry.classname : "";
|
|
const existing = existingOwners.get(id);
|
|
if (existing !== undefined && existing !== classname) {
|
|
conflicts.push(
|
|
`spriteId ${id} already used by "${existing}" in FurnitureData.json ` +
|
|
`(attempted "${classname}")`,
|
|
);
|
|
continue;
|
|
}
|
|
// reserve the id within this batch so a second entry with the same id + different classname
|
|
// is also caught (even though the file hasn't been written yet).
|
|
existingOwners.set(id, classname);
|
|
}
|
|
if (conflicts.length > 0) {
|
|
throw new Error(
|
|
`FurnitureData spriteId conflicts: ${conflicts.join("; ")}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
/** Check if a spriteId is already used by a different classname in the local
|
|
* FurnitureData.json. Returns the existing classname if there's a conflict,
|
|
* or null if the spriteId is free or the file couldn't be read. */
|
|
export async function findFurniDataIdConflict(
|
|
spriteId: number,
|
|
classname: string,
|
|
): Promise<string | null> {
|
|
try {
|
|
const furniData = await readFurniData();
|
|
const owners = furniDataIdOwners(
|
|
furniData as Record<
|
|
string,
|
|
{ furnitype: Array<Record<string, unknown>> }
|
|
>,
|
|
);
|
|
const existing = owners.get(spriteId);
|
|
return existing !== undefined && existing !== classname ? existing : null;
|
|
} catch {
|
|
return null; // if file missing/unreadable, treat as no conflict
|
|
}
|
|
}
|
|
|
|
export async function withFurniDataLock<T>(fn: () => Promise<T>): Promise<T> {
|
|
let release!: () => void;
|
|
const acquired = new Promise<void>((r) => {
|
|
release = r;
|
|
});
|
|
const prev = furniDataLock;
|
|
furniDataLock = acquired;
|
|
await prev;
|
|
const lockBase = await getFurnitureDataReadPath();
|
|
await fs.mkdir(path.dirname(lockBase), { recursive: true });
|
|
const lockPath = `${lockBase}.lock`;
|
|
await acquireDiskLock(lockPath);
|
|
try {
|
|
return await fn();
|
|
} finally {
|
|
await fs.unlink(lockPath).catch(() => {});
|
|
release?.();
|
|
}
|
|
}
|
|
|
|
export async function readFurniData(): Promise<Record<string, unknown>> {
|
|
const target = await getFurnitureDataReadPath();
|
|
const raw = await fs.readFile(/*turbopackIgnore: true*/ target, "utf-8");
|
|
return JSON.parse(raw);
|
|
}
|
|
|
|
export async function writeFurniData(
|
|
data: Record<string, unknown>,
|
|
): Promise<void> {
|
|
const targets = await getFurnitureDataWritePaths();
|
|
const payload = JSON.stringify(data);
|
|
for (const target of targets) {
|
|
await fs.mkdir(path.dirname(target), { recursive: true });
|
|
const tmp = `${target}.tmp`;
|
|
await fs.writeFile(/*turbopackIgnore: true*/ tmp, payload, "utf-8");
|
|
await fs.rename(/*turbopackIgnore: true*/ tmp, target);
|
|
}
|
|
}
|
|
|
|
export function buildFurniEntry(params: {
|
|
id: number;
|
|
classname: string;
|
|
revision: number;
|
|
category: string;
|
|
name: string;
|
|
description: string;
|
|
spriteId: number;
|
|
dims: { x: number; y: number; z: number };
|
|
metadata: FurniMetadata | null;
|
|
officialHabbo?: OfficialHabboFurniEntry | null;
|
|
}): Record<string, unknown> {
|
|
const {
|
|
id,
|
|
classname,
|
|
revision,
|
|
category,
|
|
name,
|
|
description,
|
|
spriteId,
|
|
dims,
|
|
metadata,
|
|
officialHabbo,
|
|
} = params;
|
|
|
|
// Prefer the configured official hotel's real hex colors, then .nitro metadata.
|
|
const partcolors = officialHabbo?.partcolors?.color?.length
|
|
? officialHabbo.partcolors
|
|
: (metadata?.partcolors ?? { color: [] });
|
|
|
|
return {
|
|
id,
|
|
classname,
|
|
revision: revision || officialHabbo?.revision || 0,
|
|
category: category || officialHabbo?.category || "unknown",
|
|
defaultdir: metadata?.defaultdir ?? officialHabbo?.defaultdir ?? 0,
|
|
xdim: dims.x,
|
|
ydim: dims.y,
|
|
partcolors,
|
|
name,
|
|
description: description || "",
|
|
adurl: "",
|
|
offerid: spriteId,
|
|
buyout: !!officialHabbo?.bc,
|
|
rentofferid: -1,
|
|
rentbuyout: false,
|
|
bc: officialHabbo?.bc ?? false,
|
|
excludeddynamic: false,
|
|
customparams: officialHabbo?.customparams ?? "",
|
|
specialtype: metadata?.specialtype ?? officialHabbo?.specialtype ?? 0,
|
|
canstandon: metadata?.canstandon ?? officialHabbo?.canstandon ?? false,
|
|
cansiton: metadata?.cansiton ?? officialHabbo?.cansiton ?? false,
|
|
canlayon: metadata?.canlayon ?? officialHabbo?.canlayon ?? false,
|
|
furniline: officialHabbo?.furniline ?? "",
|
|
environment: officialHabbo?.environment ?? "",
|
|
rare: officialHabbo?.rare ?? false,
|
|
};
|
|
}
|
|
|
|
function upsertEntryInSection(
|
|
section: { furnitype: Array<Record<string, unknown>> },
|
|
entry: Record<string, unknown>,
|
|
) {
|
|
const classname = entry.classname as string | undefined;
|
|
if (classname) {
|
|
const idx = section.furnitype.findIndex((e) => e.classname === classname);
|
|
if (idx >= 0) {
|
|
section.furnitype[idx] = entry;
|
|
return;
|
|
}
|
|
}
|
|
section.furnitype.push(entry);
|
|
}
|
|
|
|
export async function appendFurniEntry(
|
|
entry: Record<string, unknown>,
|
|
itemType: string,
|
|
): Promise<void> {
|
|
await withFurniDataLock(async () => {
|
|
const furniData = (await readFurniData()) as Record<
|
|
string,
|
|
{ furnitype: Array<Record<string, unknown>> }
|
|
>;
|
|
|
|
// Guard: ensure the spriteId is not already used by a different classname in the file.
|
|
const existingOwners = furniDataIdOwners(furniData);
|
|
assertNoFurniDataIdConflicts(existingOwners, [{ entry, itemType }]);
|
|
|
|
if (itemType === "i") {
|
|
if (!furniData.wallitemtypes) furniData.wallitemtypes = { furnitype: [] };
|
|
upsertEntryInSection(furniData.wallitemtypes, entry);
|
|
} else {
|
|
if (!furniData.roomitemtypes) furniData.roomitemtypes = { furnitype: [] };
|
|
upsertEntryInSection(furniData.roomitemtypes, entry);
|
|
}
|
|
await writeFurniData(furniData as Record<string, unknown>);
|
|
});
|
|
}
|
|
|
|
export async function removeFurniEntry(classname: string): Promise<boolean> {
|
|
return withFurniDataLock(async () => {
|
|
const furniData = (await readFurniData()) as Record<
|
|
string,
|
|
{ furnitype: Array<Record<string, unknown>> }
|
|
>;
|
|
let removed = false;
|
|
|
|
for (const section of ["roomitemtypes", "wallitemtypes"] as const) {
|
|
if (furniData[section]?.furnitype) {
|
|
const before = furniData[section].furnitype.length;
|
|
furniData[section].furnitype = furniData[section].furnitype.filter(
|
|
(e) => e.classname !== classname,
|
|
);
|
|
if (furniData[section].furnitype.length < before) removed = true;
|
|
}
|
|
}
|
|
|
|
if (removed) {
|
|
await writeFurniData(furniData as Record<string, unknown>);
|
|
}
|
|
return removed;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Patch name/description on existing FurnitureData entries by classname.
|
|
* Optionally creates a minimal entry when missing.
|
|
*/
|
|
export async function patchFurniEntryNames(
|
|
updates: Array<{
|
|
classname: string;
|
|
itemType: string;
|
|
name?: string;
|
|
description?: string;
|
|
spriteId?: number;
|
|
createIfMissing?: boolean;
|
|
}>,
|
|
): Promise<{ updated: number; inserted: number }> {
|
|
if (updates.length === 0) return { updated: 0, inserted: 0 };
|
|
|
|
return withFurniDataLock(async () => {
|
|
const furniData = (await readFurniData()) as Record<
|
|
string,
|
|
{ furnitype: Array<Record<string, unknown>> }
|
|
>;
|
|
let updated = 0;
|
|
let inserted = 0;
|
|
|
|
for (const u of updates) {
|
|
const sectionKey = u.itemType === "i" ? "wallitemtypes" : "roomitemtypes";
|
|
if (!furniData[sectionKey]) furniData[sectionKey] = { furnitype: [] };
|
|
const section = furniData[sectionKey];
|
|
const idx = section.furnitype.findIndex(
|
|
(e) => e.classname === u.classname,
|
|
);
|
|
|
|
if (idx >= 0) {
|
|
const entry = { ...section.furnitype[idx] };
|
|
if (u.name !== undefined) entry.name = u.name;
|
|
if (u.description !== undefined) entry.description = u.description;
|
|
section.furnitype[idx] = entry;
|
|
updated++;
|
|
} else if (u.createIfMissing) {
|
|
section.furnitype.push({
|
|
id: u.spriteId ?? 0,
|
|
classname: u.classname,
|
|
revision: 0,
|
|
category: "unknown",
|
|
name: u.name ?? u.classname,
|
|
description: u.description ?? "",
|
|
offerid: u.spriteId ?? -1,
|
|
});
|
|
inserted++;
|
|
}
|
|
}
|
|
|
|
if (updated > 0 || inserted > 0) {
|
|
await writeFurniData(furniData as Record<string, unknown>);
|
|
}
|
|
return { updated, inserted };
|
|
});
|
|
}
|
|
|
|
export async function appendFurniEntriesBatch(
|
|
entries: Array<{ entry: Record<string, unknown>; itemType: string }>,
|
|
): Promise<void> {
|
|
await withFurniDataLock(async () => {
|
|
const furniData = (await readFurniData()) as Record<
|
|
string,
|
|
{ furnitype: unknown[] }
|
|
>;
|
|
|
|
// Guard: ensure no spriteId conflicts with existing file entries
|
|
// or within this batch (same id + different classname).
|
|
const existingOwners = furniDataIdOwners(
|
|
furniData as Record<
|
|
string,
|
|
{ furnitype: Array<Record<string, unknown>> }
|
|
>,
|
|
);
|
|
assertNoFurniDataIdConflicts(existingOwners, entries);
|
|
|
|
for (const { entry, itemType } of entries) {
|
|
if (itemType === "i") {
|
|
if (!furniData.wallitemtypes)
|
|
furniData.wallitemtypes = { furnitype: [] };
|
|
upsertEntryInSection(
|
|
furniData.wallitemtypes as {
|
|
furnitype: Array<Record<string, unknown>>;
|
|
},
|
|
entry,
|
|
);
|
|
} else {
|
|
if (!furniData.roomitemtypes)
|
|
furniData.roomitemtypes = { furnitype: [] };
|
|
upsertEntryInSection(
|
|
furniData.roomitemtypes as {
|
|
furnitype: Array<Record<string, unknown>>;
|
|
},
|
|
entry,
|
|
);
|
|
}
|
|
}
|
|
await writeFurniData(furniData as Record<string, unknown>);
|
|
});
|
|
}
|
|
|
|
export interface FurniDataReconcileResult {
|
|
/** Entries whose stale spriteId was corrected to the DB id. */
|
|
fixedIds: number;
|
|
/** Entries whose stale offerid was corrected to the DB id. */
|
|
fixedOfferIds: number;
|
|
/** Cross-section copies (same classname in both sections) whose stale id was aligned to their offerid. */
|
|
normalizedIds: number;
|
|
/** items_base rows with no matching furnitype entry (need re-import). */
|
|
missing: number;
|
|
/** spriteIds used by a different classname in the file; left untouched. */
|
|
conflicts: number;
|
|
}
|
|
|
|
/**
|
|
* Cross-check every items_base row against the local FurnitureData.json and
|
|
* repair drift:
|
|
* - an entry matching the classname but carrying a stale spriteId is updated
|
|
* to the DB id (items_base is authoritative);
|
|
* - the same is done for a stale `offerid`, which must equal the spriteId so
|
|
* the emulator's catalog offer lookup stays in sync with the local ids;
|
|
* - rows missing entirely from the file are counted (they need a re-import
|
|
* or batch-regen, since building an entry requires the SWF/nitro metadata);
|
|
* - genuine spriteId conflicts (id used by a different classname) are counted
|
|
* but never auto-fixed.
|
|
*/
|
|
export async function reconcileFurniDataWithItemsBase(): Promise<FurniDataReconcileResult> {
|
|
return withFurniDataLock(async () => {
|
|
const rows = await db
|
|
.select({ id: ItemsBase.id, itemName: ItemsBase.itemName })
|
|
.from(ItemsBase);
|
|
|
|
const furniData = (await readFurniData()) as Record<
|
|
string,
|
|
{ furnitype: Array<Record<string, unknown>> }
|
|
>;
|
|
|
|
const byId = new Map<
|
|
number,
|
|
{ section: string; index: number; entry: Record<string, unknown> }
|
|
>();
|
|
const byClass = new Map<
|
|
string,
|
|
{ section: string; index: number; entry: Record<string, unknown> }
|
|
>();
|
|
for (const section of ["roomitemtypes", "wallitemtypes"] as const) {
|
|
const arr = furniData[section]?.furnitype ?? [];
|
|
for (let i = 0; i < arr.length; i++) {
|
|
const e = arr[i];
|
|
const id = Number(e?.id);
|
|
if (Number.isFinite(id) && id > 0 && !byId.has(id)) {
|
|
byId.set(id, { section, index: i, entry: e });
|
|
}
|
|
const cn = typeof e?.classname === "string" ? e.classname : "";
|
|
if (cn && !byClass.has(cn)) {
|
|
byClass.set(cn, { section, index: i, entry: e });
|
|
}
|
|
}
|
|
}
|
|
|
|
let fixedIds = 0;
|
|
let fixedOfferIds = 0;
|
|
let normalizedIds = 0;
|
|
let missing = 0;
|
|
let conflicts = 0;
|
|
|
|
const entryOfferId = (entry: Record<string, unknown>): number => {
|
|
const v = Number(entry.offerid);
|
|
return Number.isFinite(v) ? v : -1;
|
|
};
|
|
|
|
for (const row of rows) {
|
|
const spriteId = row.id;
|
|
const existing = byId.get(spriteId);
|
|
if (existing) {
|
|
if (existing.entry.classname !== row.itemName) {
|
|
conflicts++;
|
|
} else if (entryOfferId(existing.entry) !== spriteId) {
|
|
existing.entry.offerid = spriteId;
|
|
fixedOfferIds++;
|
|
}
|
|
continue;
|
|
}
|
|
const byName = byClass.get(row.itemName);
|
|
if (byName) {
|
|
byName.entry.id = spriteId;
|
|
if (entryOfferId(byName.entry) !== spriteId) {
|
|
byName.entry.offerid = spriteId;
|
|
}
|
|
byId.set(spriteId, byName);
|
|
fixedIds++;
|
|
} else {
|
|
missing++;
|
|
}
|
|
}
|
|
|
|
// Normalize cross-section duplicates (e.g. a wall item listed in both
|
|
// roomitemtypes and wallitemtypes). The canonical copy already has the
|
|
// correct id/offerid; a stray copy may carry a legacy wrong `id` while
|
|
// its offerid still points at the right sprite. Only fix when the
|
|
// offerid maps to an items_base row whose classname matches this entry
|
|
// exactly, so we never guess.
|
|
const dbById = new Map<number, string>();
|
|
for (const row of rows) dbById.set(row.id, row.itemName);
|
|
for (const section of ["roomitemtypes", "wallitemtypes"] as const) {
|
|
for (const e of furniData[section]?.furnitype ?? []) {
|
|
const offer = entryOfferId(e);
|
|
if (offer <= 0 || Number(e.id) === offer) continue;
|
|
if (dbById.get(offer) !== e.classname) continue;
|
|
e.id = offer;
|
|
normalizedIds++;
|
|
}
|
|
}
|
|
|
|
if (fixedIds > 0 || fixedOfferIds > 0) {
|
|
await writeFurniData(furniData as Record<string, unknown>);
|
|
}
|
|
return { fixedIds, fixedOfferIds, normalizedIds, missing, conflicts };
|
|
});
|
|
}
|
|
|
|
export interface CatalogOfferRebuildResult {
|
|
/** catalog_items rows examined. */
|
|
checked: number;
|
|
/** Rows whose offer_id was corrected. */
|
|
fixed: number;
|
|
/** Fixed rows with no furnidata entry (offer_id fell back to the sprite id). */
|
|
fallback: number;
|
|
}
|
|
|
|
/**
|
|
* Rebuild EVERY catalog_items.offer_id from the local FurnitureData.json.
|
|
*
|
|
* The Nitro client resolves offers through the furnidata `offerid`, and the
|
|
* emulator maps that offer id back to a catalog item (CatalogManager
|
|
* offerDefs). The chain only works when catalog_items.offer_id equals the
|
|
* furnidata offerid — which this CMS reconciles to the items_base sprite id.
|
|
*
|
|
* For every catalog row: resolve item_ids → items_base classname → furnidata
|
|
* offerid and write it into offer_id. Rows without a furnidata entry fall
|
|
* back to the sprite id itself (same value in practice). Run after every
|
|
* import so drift from manual edits or legacy rows is repaired automatically.
|
|
*/
|
|
export async function rebuildCatalogOfferIds(): Promise<CatalogOfferRebuildResult> {
|
|
return withFurniDataLock(async () => {
|
|
const furniData = (await readFurniData()) as Record<
|
|
string,
|
|
{ furnitype?: Array<Record<string, unknown>> }
|
|
>;
|
|
const offerById = new Map<number, number>();
|
|
const offerByClass = new Map<string, number>();
|
|
for (const section of ["roomitemtypes", "wallitemtypes"] as const) {
|
|
for (const e of furniData[section]?.furnitype ?? []) {
|
|
const cn = typeof e?.classname === "string" ? e.classname : "";
|
|
const entryId = Number(e?.id);
|
|
const offer = Number(e?.offerid);
|
|
if (
|
|
cn &&
|
|
Number.isFinite(offer) &&
|
|
offer > 0 &&
|
|
!offerByClass.has(cn)
|
|
) {
|
|
offerByClass.set(cn, offer);
|
|
}
|
|
if (
|
|
Number.isFinite(entryId) &&
|
|
entryId > 0 &&
|
|
!offerById.has(entryId)
|
|
) {
|
|
offerById.set(
|
|
entryId,
|
|
Number.isFinite(offer) && offer > 0 ? offer : entryId,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
const itemRows = await db
|
|
.select({ id: ItemsBase.id, itemName: ItemsBase.itemName })
|
|
.from(ItemsBase);
|
|
const nameById = new Map<number, string>();
|
|
for (const r of itemRows) nameById.set(r.id, r.itemName);
|
|
|
|
const catalogRows = await db
|
|
.select({
|
|
id: CatalogItems.id,
|
|
itemIds: CatalogItems.itemIds,
|
|
offerId: CatalogItems.offerId,
|
|
})
|
|
.from(CatalogItems);
|
|
|
|
let fixed = 0;
|
|
let fallback = 0;
|
|
for (const row of catalogRows) {
|
|
const itemId = Number(String(row.itemIds ?? "").split(/[;,]/)[0]);
|
|
if (!Number.isFinite(itemId) || itemId <= 0) continue;
|
|
const classname = nameById.get(itemId);
|
|
const furnidataOffer =
|
|
offerById.get(itemId) ??
|
|
(classname ? offerByClass.get(classname) : undefined);
|
|
const target = furnidataOffer ?? itemId;
|
|
if (row.offerId === target) continue;
|
|
await db
|
|
.update(CatalogItems)
|
|
.set({ offerId: target })
|
|
.where(eq(CatalogItems.id, row.id));
|
|
fixed++;
|
|
if (furnidataOffer === undefined) fallback++;
|
|
}
|
|
return { checked: catalogRows.length, fixed, fallback };
|
|
});
|
|
}
|