Files
EpicNext-Cms/src/app/api/admin/import/furni/batch-regen/route.ts
T
openhands 984b5bf793
CI / check (push) Successful in 28s
CI / release (push) Skipped
CI / deploy (push) Successful in 54s
feat: auto-repair sprite_id drift after every import
The emulator sends items_base.sprite_id to the client and Nitro resolves
the furniture by looking up that id in FurnitureData.json. Legacy rows
where sprite_id drifted from the id made the client render a completely
different item (e.g. a wired or custom asset instead of the purchased
furniture).

Add verifyAndFixSpriteIds() which repairs any row whose sprite_id no
longer matches its id while the local furnidata carries the item under
that id, and run it after every import path (single, batch, batch-regen,
clone) next to the offer_id rebuild.
2026-08-21 14:02:42 +02:00

312 lines
8.6 KiB
TypeScript

import { existsSync, promises as fs } from "node:fs";
import path from "node:path";
import { eq } from "drizzle-orm";
import { withAdmin } from "@/lib/api-handler";
import { apiError } from "@/lib/api-response";
import { db, ItemsBase } from "@/lib/db";
import { PERMS } from "@/lib/permissions";
import { getFurniAssetDirs } from "@/lib/services/furni-asset-dirs";
import {
appendFurniEntriesBatch,
buildFurniEntry,
readFurniData,
rebuildCatalogOfferIds,
reconcileFurniDataWithItemsBase,
verifyAndFixSpriteIds,
} from "@/lib/services/furni-data";
import {
downloadFile,
ensureDirectories,
ensureFurniOwnership,
syncAssetsToGamedataBundle,
} from "@/lib/services/furni-import";
import { getOfficialHabboFurnidata } from "@/lib/services/habbo-furnidata-cache";
import { rcon } from "@/lib/services/rcon";
import { convertSwfToNitro } from "@/lib/services/swf-to-nitro";
interface RegenItem {
classname: string;
revision: number;
}
/**
* SSE batch regenerate .nitro files.
* Streams progress events for each item.
*/
export const POST = withAdmin(
{ permission: PERMS.ASSETS_IMPORT },
async (request) => {
const body = await request.json();
const items: RegenItem[] = body.items || [];
const concurrency = Math.min(Math.max(body.concurrency || 3, 1), 5);
if (items.length === 0) return apiError("No items to regenerate", 400);
await ensureDirectories();
const { swfDir, nitroDir } = await getFurniAssetDirs();
const encoder = new TextEncoder();
const stream = new ReadableStream({
async start(controller) {
const send = (data: unknown) => {
try {
controller.enqueue(
encoder.encode(`data: ${JSON.stringify(data)}\n\n`),
);
} catch {
/* stream closed */
}
};
const startTime = Date.now();
send({ type: "regen_start", total: items.length });
let succeeded = 0;
let failed = 0;
let skipped = 0;
const furniDataEntries: Array<{
entry: Record<string, unknown>;
itemType: string;
}> = [];
// Load furnidata once for all items
const furnidata = await getOfficialHabboFurnidata();
// Load existing FurnitureData.json classnames for dedup
const existingFD = (await readFurniData()) as {
roomitemtypes?: { furnitype?: Array<{ classname?: string }> };
wallitemtypes?: { furnitype?: Array<{ classname?: string }> };
};
const fdClassnames = new Set([
...(existingFD.roomitemtypes?.furnitype || []).map(
(e) => e.classname,
),
...(existingFD.wallitemtypes?.furnitype || []).map(
(e) => e.classname,
),
]);
for (let i = 0; i < items.length; i += concurrency) {
const chunk = items.slice(i, i + concurrency);
const promises = chunk.map(async (item) => {
const starIdx = item.classname.indexOf("*");
const baseClassname =
starIdx !== -1
? item.classname.substring(0, starIdx)
: item.classname;
const nitroPath = path.join(
/*turbopackIgnore: true*/ nitroDir,
`${baseClassname}.nitro`,
);
if (existsSync(/*turbopackIgnore: true*/ nitroPath)) {
skipped++;
send({
type: "regen_progress",
classname: item.classname,
status: "skipped",
});
return;
}
send({
type: "regen_progress",
classname: item.classname,
status: "downloading",
});
// Resolve revision
let rev = item.revision || 0;
if (rev === 0) {
const fd =
furnidata.get(item.classname) ||
furnidata.get(baseClassname) ||
furnidata.get(`${baseClassname}*0`);
if (fd) rev = fd.revision;
}
// Try spriteId as fallback revision
const revsToTry: number[] = [];
if (rev > 0) revsToTry.push(rev);
const [dbItem] = await db
.select({
id: ItemsBase.id,
spriteId: ItemsBase.spriteId,
publicName: ItemsBase.publicName,
type: ItemsBase.type,
})
.from(ItemsBase)
.where(eq(ItemsBase.itemName, item.classname))
.limit(1);
if (dbItem?.spriteId && !revsToTry.includes(dbItem.spriteId)) {
revsToTry.push(dbItem.spriteId);
}
if (revsToTry.length === 0) {
failed++;
send({
type: "regen_progress",
classname: item.classname,
status: "failed",
message: "No revision found",
});
return;
}
const swfPath = path.join(swfDir, `${baseClassname}.swf`);
// Download SWF if needed
if (!existsSync(swfPath)) {
let downloaded = false;
for (const tryRev of revsToTry) {
const dl = await downloadFile(
`https://images.habbo.com/dcr/hof_furni/${tryRev}/${encodeURIComponent(baseClassname)}.swf`,
swfPath,
{ validate: "swf" },
);
if (dl.ok) {
rev = tryRev;
downloaded = true;
break;
}
}
if (!downloaded) {
failed++;
send({
type: "regen_progress",
classname: item.classname,
status: "failed",
message: `SWF not found (revs: ${revsToTry.join(", ")})`,
});
return;
}
}
// Convert
send({
type: "regen_progress",
classname: item.classname,
status: "converting",
});
try {
const swfBuffer = await fs.readFile(swfPath);
const conversion = convertSwfToNitro(swfBuffer, baseClassname);
await fs.writeFile(nitroPath, conversion.bundle);
// Build FurnitureData entry if missing
if (
!fdClassnames.has(baseClassname) &&
!fdClassnames.has(item.classname)
) {
const fd =
furnidata.get(item.classname) || furnidata.get(baseClassname);
const itemType = dbItem?.type === "i" ? "i" : "s";
const entry = buildFurniEntry({
id: dbItem?.spriteId || 0,
classname: baseClassname,
revision: rev,
category: fd?.category || "",
name: dbItem?.publicName || baseClassname,
description: fd?.description || "",
spriteId: dbItem?.spriteId || 0,
dims: conversion.dimensions || { x: 1, y: 1, z: 1 },
metadata: conversion.metadata || null,
officialHabbo: fd || null,
});
furniDataEntries.push({ entry, itemType });
fdClassnames.add(baseClassname);
}
succeeded++;
send({
type: "regen_progress",
classname: item.classname,
status: "done",
});
} catch (err) {
failed++;
send({
type: "regen_progress",
classname: item.classname,
status: "failed",
message: (err as Error).message,
});
}
});
await Promise.allSettled(promises);
}
// Batch write FurnitureData.json
if (furniDataEntries.length > 0) {
try {
await appendFurniEntriesBatch(furniDataEntries);
send({
type: "regen_info",
message: `Added ${furniDataEntries.length} entries to FurnitureData.json`,
});
} catch (err) {
send({
type: "regen_info",
message: `FurnitureData.json write failed: ${(err as Error).message}`,
});
}
}
// RCON refresh
try {
await rcon.updateItems();
await rcon.updateCatalog();
} catch {
/* non-fatal */
}
// Keep regenerated assets readable by the web server
const ownership = await ensureFurniOwnership();
// Backfill any missing nitros and icons into the emulator's Gamedata bundle
const assetsSynced = await syncAssetsToGamedataBundle();
// Verify the furnidata matches the DB ids
const furniReconcile = await reconcileFurniDataWithItemsBase();
// Rebuild every catalog offer_id from the furnidata
const offerRebuild = await rebuildCatalogOfferIds();
// Repair any sprite_id drift so the client renders the right item
const spriteVerify = await verifyAndFixSpriteIds();
send({
type: "regen_complete",
succeeded,
failed,
skipped,
furniDataAdded: furniDataEntries.length,
ownershipFixed: ownership.fixed,
nitrosSynced: assetsSynced.copiedNitros,
iconsSynced: assetsSynced.copiedIcons,
furniDataFixedIds: furniReconcile.fixedIds,
furniDataMissing: furniReconcile.missing,
furniDataConflicts: furniReconcile.conflicts,
offerRebuildChecked: offerRebuild.checked,
offerRebuildFixed: offerRebuild.fixed,
spriteChecked: spriteVerify.checked,
spriteFixed: spriteVerify.fixed,
duration: Date.now() - startTime,
});
controller.close();
},
});
return new Response(stream, {
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
Connection: "keep-alive",
},
});
},
);