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.
312 lines
8.6 KiB
TypeScript
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",
|
|
},
|
|
});
|
|
},
|
|
);
|