This commit is contained in:
1 parent
8efd032cc6
commit
df38dccbf1
735 files changed
+128321
-120870
No files matched your search
@@ -5,225 +5,272 @@ import { apiError } from "@/lib/api-response";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { getFurniAssetDirs } from "@/lib/services/furni-asset-dirs";
|
||||
import { appendFurniEntriesBatch, buildFurniEntry, readFurniData } from "@/lib/services/furni-data";
|
||||
import {
|
||||
appendFurniEntriesBatch,
|
||||
buildFurniEntry,
|
||||
readFurniData,
|
||||
} from "@/lib/services/furni-data";
|
||||
import { downloadFile, ensureDirectories } from "@/lib/services/furni-import";
|
||||
import { getHabboItFurnidata } 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;
|
||||
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);
|
||||
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);
|
||||
if (items.length === 0) return apiError("No items to regenerate", 400);
|
||||
|
||||
await ensureDirectories();
|
||||
const { swfDir, nitroDir } = await getFurniAssetDirs();
|
||||
const encoder = new TextEncoder();
|
||||
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 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 });
|
||||
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 }> = [];
|
||||
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 getHabboItFurnidata();
|
||||
// Load furnidata once for all items
|
||||
const furnidata = await getHabboItFurnidata();
|
||||
|
||||
// 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),
|
||||
]);
|
||||
// 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);
|
||||
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`);
|
||||
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;
|
||||
}
|
||||
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" });
|
||||
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;
|
||||
}
|
||||
// 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 prisma.itemsBase.findFirst({
|
||||
where: { itemName: item.classname },
|
||||
select: { id: true, spriteId: true, publicName: true, type: true },
|
||||
});
|
||||
if (dbItem?.spriteId && !revsToTry.includes(dbItem.spriteId)) {
|
||||
revsToTry.push(dbItem.spriteId);
|
||||
}
|
||||
// Try spriteId as fallback revision
|
||||
const revsToTry: number[] = [];
|
||||
if (rev > 0) revsToTry.push(rev);
|
||||
const dbItem = await prisma.itemsBase.findFirst({
|
||||
where: { itemName: item.classname },
|
||||
select: {
|
||||
id: true,
|
||||
spriteId: true,
|
||||
publicName: true,
|
||||
type: true,
|
||||
},
|
||||
});
|
||||
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;
|
||||
}
|
||||
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`);
|
||||
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;
|
||||
}
|
||||
}
|
||||
// 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);
|
||||
// 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,
|
||||
habboIt: fd || null,
|
||||
});
|
||||
furniDataEntries.push({ entry, itemType });
|
||||
fdClassnames.add(baseClassname);
|
||||
}
|
||||
// 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,
|
||||
habboIt: 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,
|
||||
});
|
||||
}
|
||||
});
|
||||
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);
|
||||
}
|
||||
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}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
// 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 */
|
||||
}
|
||||
// RCON refresh
|
||||
try {
|
||||
await rcon.updateItems();
|
||||
await rcon.updateCatalog();
|
||||
} catch {
|
||||
/* non-fatal */
|
||||
}
|
||||
|
||||
send({
|
||||
type: "regen_complete",
|
||||
succeeded,
|
||||
failed,
|
||||
skipped,
|
||||
furniDataAdded: furniDataEntries.length,
|
||||
duration: Date.now() - startTime,
|
||||
});
|
||||
send({
|
||||
type: "regen_complete",
|
||||
succeeded,
|
||||
failed,
|
||||
skipped,
|
||||
furniDataAdded: furniDataEntries.length,
|
||||
duration: Date.now() - startTime,
|
||||
});
|
||||
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
"Content-Type": "text/event-stream",
|
||||
"Cache-Control": "no-cache",
|
||||
Connection: "keep-alive",
|
||||
},
|
||||
});
|
||||
});
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
"Content-Type": "text/event-stream",
|
||||
"Cache-Control": "no-cache",
|
||||
Connection: "keep-alive",
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -3,18 +3,21 @@ import { apiError } from "@/lib/api-response";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
import { logAudit } from "@/lib/services/audit";
|
||||
import { appendFurniEntriesBatch } from "@/lib/services/furni-data";
|
||||
import { ensureDirectories, importSingleFurni } from "@/lib/services/furni-import";
|
||||
import {
|
||||
ensureDirectories,
|
||||
importSingleFurni,
|
||||
} from "@/lib/services/furni-import";
|
||||
import { rcon } from "@/lib/services/rcon";
|
||||
import type { ImportSingleResult } from "@/types/furni";
|
||||
|
||||
interface BatchItem {
|
||||
id: number;
|
||||
classname: string;
|
||||
name: string;
|
||||
description: string;
|
||||
type: string;
|
||||
revision: number;
|
||||
category: string;
|
||||
id: number;
|
||||
classname: string;
|
||||
name: string;
|
||||
description: string;
|
||||
type: string;
|
||||
revision: number;
|
||||
category: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -22,176 +25,199 @@ interface BatchItem {
|
||||
* Processes multiple furniture items with configurable concurrency,
|
||||
* streaming real-time progress events to the client.
|
||||
*/
|
||||
export const POST = withAdmin({ permission: PERMS.ASSETS_IMPORT }, async (request, ctx) => {
|
||||
const body = await request.json();
|
||||
const rawItems: BatchItem[] = body.items || [];
|
||||
const concurrency = Math.min(Math.max(body.concurrency || 3, 1), 5);
|
||||
export const POST = withAdmin(
|
||||
{ permission: PERMS.ASSETS_IMPORT },
|
||||
async (request, ctx) => {
|
||||
const body = await request.json();
|
||||
const rawItems: BatchItem[] = body.items || [];
|
||||
const concurrency = Math.min(Math.max(body.concurrency || 3, 1), 5);
|
||||
|
||||
if (rawItems.length === 0) return apiError("No items to import", 400);
|
||||
if (rawItems.length === 0) return apiError("No items to import", 400);
|
||||
|
||||
// Deduplicate by (spriteId, classname) to avoid concurrent INSERT races on
|
||||
// items_base.id when the same item is listed twice in the request body.
|
||||
const seen = new Set<string>();
|
||||
const items: BatchItem[] = [];
|
||||
for (const it of rawItems) {
|
||||
const key = `${it.id}:${it.classname}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
items.push(it);
|
||||
}
|
||||
// Deduplicate by (spriteId, classname) to avoid concurrent INSERT races on
|
||||
// items_base.id when the same item is listed twice in the request body.
|
||||
const seen = new Set<string>();
|
||||
const items: BatchItem[] = [];
|
||||
for (const it of rawItems) {
|
||||
const key = `${it.id}:${it.classname}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
items.push(it);
|
||||
}
|
||||
|
||||
await ensureDirectories();
|
||||
const encoder = new TextEncoder();
|
||||
await ensureDirectories();
|
||||
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 by client */
|
||||
}
|
||||
};
|
||||
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 by client */
|
||||
}
|
||||
};
|
||||
|
||||
const startTime = Date.now();
|
||||
send({ type: "batch_start", total: items.length, concurrency });
|
||||
const startTime = Date.now();
|
||||
send({ type: "batch_start", total: items.length, concurrency });
|
||||
|
||||
let succeeded = 0;
|
||||
let failed = 0;
|
||||
let withWarnings = 0;
|
||||
const furniDataEntries: Array<{ entry: Record<string, unknown>; itemType: string }> = [];
|
||||
let succeeded = 0;
|
||||
let failed = 0;
|
||||
let withWarnings = 0;
|
||||
const furniDataEntries: Array<{
|
||||
entry: Record<string, unknown>;
|
||||
itemType: string;
|
||||
}> = [];
|
||||
|
||||
// Process in chunks of `concurrency`
|
||||
for (let i = 0; i < items.length; i += concurrency) {
|
||||
const chunk = items.slice(i, i + concurrency);
|
||||
// Process in chunks of `concurrency`
|
||||
for (let i = 0; i < items.length; i += concurrency) {
|
||||
const chunk = items.slice(i, i + concurrency);
|
||||
|
||||
const promises = chunk.map(async (item, chunkIdx) => {
|
||||
const index = i + chunkIdx;
|
||||
send({ type: "item_progress", classname: item.classname, status: "started", index });
|
||||
const promises = chunk.map(async (item, chunkIdx) => {
|
||||
const index = i + chunkIdx;
|
||||
send({
|
||||
type: "item_progress",
|
||||
classname: item.classname,
|
||||
status: "started",
|
||||
index,
|
||||
});
|
||||
|
||||
try {
|
||||
const result: ImportSingleResult = await importSingleFurni({
|
||||
id: item.id ?? 0,
|
||||
classname: item.classname,
|
||||
name: item.name,
|
||||
description: item.description ?? "",
|
||||
type: item.type ?? "flooritem",
|
||||
revision: item.revision ?? 0,
|
||||
category: item.category ?? "unknown",
|
||||
skipFurniDataWrite: true,
|
||||
onProgress: (status: string) => {
|
||||
send({ type: "item_progress", classname: item.classname, status, index });
|
||||
},
|
||||
});
|
||||
try {
|
||||
const result: ImportSingleResult = await importSingleFurni({
|
||||
id: item.id ?? 0,
|
||||
classname: item.classname,
|
||||
name: item.name,
|
||||
description: item.description ?? "",
|
||||
type: item.type ?? "flooritem",
|
||||
revision: item.revision ?? 0,
|
||||
category: item.category ?? "unknown",
|
||||
skipFurniDataWrite: true,
|
||||
onProgress: (status: string) => {
|
||||
send({
|
||||
type: "item_progress",
|
||||
classname: item.classname,
|
||||
status,
|
||||
index,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
if (result.ok) {
|
||||
succeeded++;
|
||||
if (result.warnings.length > 0) withWarnings++;
|
||||
if (result.furniDataEntry) furniDataEntries.push(result.furniDataEntry);
|
||||
if (result.ok) {
|
||||
succeeded++;
|
||||
if (result.warnings.length > 0) withWarnings++;
|
||||
if (result.furniDataEntry)
|
||||
furniDataEntries.push(result.furniDataEntry);
|
||||
|
||||
send({
|
||||
type: "item_progress",
|
||||
classname: item.classname,
|
||||
status: "done",
|
||||
index,
|
||||
itemId: result.itemId,
|
||||
warnings: result.warnings.length > 0 ? result.warnings : undefined,
|
||||
});
|
||||
send({
|
||||
type: "item_progress",
|
||||
classname: item.classname,
|
||||
status: "done",
|
||||
index,
|
||||
itemId: result.itemId,
|
||||
warnings:
|
||||
result.warnings.length > 0 ? result.warnings : undefined,
|
||||
});
|
||||
|
||||
logAudit({
|
||||
userId: ctx.session.user.id,
|
||||
action: "furni_import",
|
||||
target: "ItemsBase",
|
||||
targetId: result.itemId!,
|
||||
after: {
|
||||
classname: item.classname,
|
||||
name: item.name,
|
||||
type: item.type === "wallitem" ? "i" : "s",
|
||||
catalogItemId: result.catalogItemId,
|
||||
dimensions: result.dimensions,
|
||||
batch: true,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
failed++;
|
||||
send({
|
||||
type: "item_progress",
|
||||
classname: item.classname,
|
||||
status: "failed",
|
||||
index,
|
||||
message: result.error,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
failed++;
|
||||
send({
|
||||
type: "item_progress",
|
||||
classname: item.classname,
|
||||
status: "failed",
|
||||
index,
|
||||
message: (err as Error).message,
|
||||
});
|
||||
}
|
||||
});
|
||||
logAudit({
|
||||
userId: ctx.session.user.id,
|
||||
action: "furni_import",
|
||||
target: "ItemsBase",
|
||||
targetId: result.itemId!,
|
||||
after: {
|
||||
classname: item.classname,
|
||||
name: item.name,
|
||||
type: item.type === "wallitem" ? "i" : "s",
|
||||
catalogItemId: result.catalogItemId,
|
||||
dimensions: result.dimensions,
|
||||
batch: true,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
failed++;
|
||||
send({
|
||||
type: "item_progress",
|
||||
classname: item.classname,
|
||||
status: "failed",
|
||||
index,
|
||||
message: result.error,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
failed++;
|
||||
send({
|
||||
type: "item_progress",
|
||||
classname: item.classname,
|
||||
status: "failed",
|
||||
index,
|
||||
message: (err as Error).message,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
await Promise.allSettled(promises);
|
||||
}
|
||||
await Promise.allSettled(promises);
|
||||
}
|
||||
|
||||
// Batch write FurnitureData.json once at the end — BEFORE RCON so the
|
||||
// emulator never sees items that the Nitro client can't render.
|
||||
let furniDataWriteOk = true;
|
||||
if (furniDataEntries.length > 0) {
|
||||
try {
|
||||
await appendFurniEntriesBatch(furniDataEntries);
|
||||
} catch (err) {
|
||||
furniDataWriteOk = false;
|
||||
send({
|
||||
type: "item_progress",
|
||||
classname: "_batch_",
|
||||
status: "failed",
|
||||
index: -1,
|
||||
message: `FurnitureData.json batch write failed: ${(err as Error).message}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
// Batch write FurnitureData.json once at the end — BEFORE RCON so the
|
||||
// emulator never sees items that the Nitro client can't render.
|
||||
let furniDataWriteOk = true;
|
||||
if (furniDataEntries.length > 0) {
|
||||
try {
|
||||
await appendFurniEntriesBatch(furniDataEntries);
|
||||
} catch (err) {
|
||||
furniDataWriteOk = false;
|
||||
send({
|
||||
type: "item_progress",
|
||||
classname: "_batch_",
|
||||
status: "failed",
|
||||
index: -1,
|
||||
message: `FurnitureData.json batch write failed: ${(err as Error).message}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Refresh game server caches once — skip if FurnitureData write failed
|
||||
// to avoid an inconsistent state (emulator sees items, client doesn't).
|
||||
if (furniDataWriteOk) {
|
||||
try {
|
||||
const okCat = await rcon.updateCatalog();
|
||||
const okItems = await rcon.updateItems();
|
||||
if (!okCat || !okItems) {
|
||||
send({
|
||||
type: "item_progress",
|
||||
classname: "_rcon_",
|
||||
status: "failed",
|
||||
index: -1,
|
||||
message: "RCON refresh failed — emulator cache may be stale",
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn("[import-furni] RCON update failed after batch:", (err as Error).message);
|
||||
}
|
||||
}
|
||||
// Refresh game server caches once — skip if FurnitureData write failed
|
||||
// to avoid an inconsistent state (emulator sees items, client doesn't).
|
||||
if (furniDataWriteOk) {
|
||||
try {
|
||||
const okCat = await rcon.updateCatalog();
|
||||
const okItems = await rcon.updateItems();
|
||||
if (!okCat || !okItems) {
|
||||
send({
|
||||
type: "item_progress",
|
||||
classname: "_rcon_",
|
||||
status: "failed",
|
||||
index: -1,
|
||||
message: "RCON refresh failed — emulator cache may be stale",
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
"[import-furni] RCON update failed after batch:",
|
||||
(err as Error).message,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
send({
|
||||
type: "batch_complete",
|
||||
succeeded,
|
||||
failed,
|
||||
warnings: withWarnings,
|
||||
duration: Date.now() - startTime,
|
||||
});
|
||||
send({
|
||||
type: "batch_complete",
|
||||
succeeded,
|
||||
failed,
|
||||
warnings: withWarnings,
|
||||
duration: Date.now() - startTime,
|
||||
});
|
||||
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
"Content-Type": "text/event-stream",
|
||||
"Cache-Control": "no-cache",
|
||||
Connection: "keep-alive",
|
||||
},
|
||||
});
|
||||
});
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
"Content-Type": "text/event-stream",
|
||||
"Cache-Control": "no-cache",
|
||||
Connection: "keep-alive",
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -7,121 +7,153 @@ import { PERMS } from "@/lib/permissions";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { logAudit } from "@/lib/services/audit";
|
||||
import { getFurniAssetDirs } from "@/lib/services/furni-asset-dirs";
|
||||
import { readFurniData, withFurniDataLock, writeFurniData } from "@/lib/services/furni-data";
|
||||
import {
|
||||
readFurniData,
|
||||
withFurniDataLock,
|
||||
writeFurniData,
|
||||
} from "@/lib/services/furni-data";
|
||||
import { rcon } from "@/lib/services/rcon";
|
||||
import {
|
||||
computeDefaultDir,
|
||||
createNitroBundle,
|
||||
extractPartColors,
|
||||
parseNitroBundle,
|
||||
computeDefaultDir,
|
||||
createNitroBundle,
|
||||
extractPartColors,
|
||||
parseNitroBundle,
|
||||
} from "@/lib/services/swf-to-nitro";
|
||||
|
||||
function baseClassname(classname: string): string {
|
||||
return classname.includes("*") ? classname.split("*")[0] : classname;
|
||||
return classname.includes("*") ? classname.split("*")[0] : classname;
|
||||
}
|
||||
|
||||
// ── GET: Read .nitro metadata ──────────────────────────────────────
|
||||
|
||||
export const GET = withAdmin({ permission: PERMS.ASSETS_IMPORT }, async (request: NextRequest) => {
|
||||
const rawClassname = request.nextUrl.searchParams.get("classname");
|
||||
if (!rawClassname) return apiError("Missing classname", 400);
|
||||
export const GET = withAdmin(
|
||||
{ permission: PERMS.ASSETS_IMPORT },
|
||||
async (request: NextRequest) => {
|
||||
const rawClassname = request.nextUrl.searchParams.get("classname");
|
||||
if (!rawClassname) return apiError("Missing classname", 400);
|
||||
|
||||
const base = baseClassname(rawClassname);
|
||||
const { nitroDir } = await getFurniAssetDirs();
|
||||
const nitroPath = path.join(/*turbopackIgnore: true*/ nitroDir, `${base}.nitro`);
|
||||
const base = baseClassname(rawClassname);
|
||||
const { nitroDir } = await getFurniAssetDirs();
|
||||
const nitroPath = path.join(
|
||||
/*turbopackIgnore: true*/ nitroDir,
|
||||
`${base}.nitro`,
|
||||
);
|
||||
|
||||
if (!existsSync(/*turbopackIgnore: true*/ nitroPath)) return apiError("Nitro file not found", 404);
|
||||
if (!existsSync(/*turbopackIgnore: true*/ nitroPath))
|
||||
return apiError("Nitro file not found", 404);
|
||||
|
||||
try {
|
||||
const buffer = await fs.readFile(/*turbopackIgnore: true*/ nitroPath);
|
||||
const { json } = parseNitroBundle(buffer);
|
||||
try {
|
||||
const buffer = await fs.readFile(/*turbopackIgnore: true*/ nitroPath);
|
||||
const { json } = parseNitroBundle(buffer);
|
||||
|
||||
// Fetch interaction flags from DB
|
||||
const items = await prisma.$queryRaw<
|
||||
Array<{
|
||||
allow_sit: string;
|
||||
allow_lay: string;
|
||||
allow_walk: string;
|
||||
}>
|
||||
>`
|
||||
// Fetch interaction flags from DB
|
||||
const items = await prisma.$queryRaw<
|
||||
Array<{
|
||||
allow_sit: string;
|
||||
allow_lay: string;
|
||||
allow_walk: string;
|
||||
}>
|
||||
>`
|
||||
SELECT allow_sit, allow_lay, allow_walk
|
||||
FROM items_base WHERE item_name = ${rawClassname} LIMIT 1
|
||||
`;
|
||||
|
||||
const dbRow = items[0];
|
||||
const flags = {
|
||||
canstandon: dbRow?.allow_walk === "1",
|
||||
cansiton: dbRow?.allow_sit === "1",
|
||||
canlayon: dbRow?.allow_lay === "1",
|
||||
};
|
||||
const dbRow = items[0];
|
||||
const flags = {
|
||||
canstandon: dbRow?.allow_walk === "1",
|
||||
cansiton: dbRow?.allow_sit === "1",
|
||||
canlayon: dbRow?.allow_lay === "1",
|
||||
};
|
||||
|
||||
return apiOk({ metadata: json, flags });
|
||||
} catch (err) {
|
||||
console.error("[nitro-editor] Failed to parse .nitro:", (err as Error).message);
|
||||
return apiError("Failed to parse .nitro file", 500);
|
||||
}
|
||||
});
|
||||
return apiOk({ metadata: json, flags });
|
||||
} catch (err) {
|
||||
console.error(
|
||||
"[nitro-editor] Failed to parse .nitro:",
|
||||
(err as Error).message,
|
||||
);
|
||||
return apiError("Failed to parse .nitro file", 500);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// ── PUT: Save .nitro metadata ──────────────────────────────────────
|
||||
|
||||
export const PUT = withAdmin({ permission: PERMS.ASSETS_IMPORT }, async (request: NextRequest, ctx) => {
|
||||
const body = await request.json();
|
||||
const { classname: rawClassname, metadata, canstandon, cansiton, canlayon } = body;
|
||||
export const PUT = withAdmin(
|
||||
{ permission: PERMS.ASSETS_IMPORT },
|
||||
async (request: NextRequest, ctx) => {
|
||||
const body = await request.json();
|
||||
const {
|
||||
classname: rawClassname,
|
||||
metadata,
|
||||
canstandon,
|
||||
cansiton,
|
||||
canlayon,
|
||||
} = body;
|
||||
|
||||
if (!rawClassname || !metadata) return apiError("Missing classname or metadata", 400);
|
||||
if (!rawClassname || !metadata)
|
||||
return apiError("Missing classname or metadata", 400);
|
||||
|
||||
const base = baseClassname(rawClassname);
|
||||
const { nitroDir } = await getFurniAssetDirs();
|
||||
const nitroPath = path.join(/*turbopackIgnore: true*/ nitroDir, `${base}.nitro`);
|
||||
const base = baseClassname(rawClassname);
|
||||
const { nitroDir } = await getFurniAssetDirs();
|
||||
const nitroPath = path.join(
|
||||
/*turbopackIgnore: true*/ nitroDir,
|
||||
`${base}.nitro`,
|
||||
);
|
||||
|
||||
if (!existsSync(/*turbopackIgnore: true*/ nitroPath)) return apiError("Nitro file not found", 404);
|
||||
if (!existsSync(/*turbopackIgnore: true*/ nitroPath))
|
||||
return apiError("Nitro file not found", 404);
|
||||
|
||||
// 1. Read existing .nitro to extract PNG
|
||||
const existingBuffer = await fs.readFile(/*turbopackIgnore: true*/ nitroPath);
|
||||
const { png } = parseNitroBundle(existingBuffer);
|
||||
// 1. Read existing .nitro to extract PNG
|
||||
const existingBuffer = await fs.readFile(
|
||||
/*turbopackIgnore: true*/ nitroPath,
|
||||
);
|
||||
const { png } = parseNitroBundle(existingBuffer);
|
||||
|
||||
// 2. Rebuild .nitro with updated JSON + same PNG
|
||||
const newBundle = createNitroBundle(metadata, png, base);
|
||||
await fs.writeFile(nitroPath, newBundle);
|
||||
// 2. Rebuild .nitro with updated JSON + same PNG
|
||||
const newBundle = createNitroBundle(metadata, png, base);
|
||||
await fs.writeFile(nitroPath, newBundle);
|
||||
|
||||
// 3. Extract dimensions from metadata
|
||||
const dims = metadata.logic?.model?.dimensions || { x: 1, y: 1, z: 0 };
|
||||
const directions: number[] = metadata.logic?.model?.directions || [0];
|
||||
const visualizations = metadata.visualizations || [];
|
||||
// 3. Extract dimensions from metadata
|
||||
const dims = metadata.logic?.model?.dimensions || { x: 1, y: 1, z: 0 };
|
||||
const directions: number[] = metadata.logic?.model?.directions || [0];
|
||||
const visualizations = metadata.visualizations || [];
|
||||
|
||||
// 4. Update FurnitureData.json (best-effort)
|
||||
try {
|
||||
await withFurniDataLock(async () => {
|
||||
const furniData = (await readFurniData()) as Record<
|
||||
string,
|
||||
{ furnitype: Array<Record<string, unknown>> }
|
||||
>;
|
||||
// 4. Update FurnitureData.json (best-effort)
|
||||
try {
|
||||
await withFurniDataLock(async () => {
|
||||
const furniData = (await readFurniData()) as Record<
|
||||
string,
|
||||
{ furnitype: Array<Record<string, unknown>> }
|
||||
>;
|
||||
|
||||
for (const section of ["roomitemtypes", "wallitemtypes"] as const) {
|
||||
const types = furniData[section]?.furnitype;
|
||||
if (!Array.isArray(types)) continue;
|
||||
const idx = types.findIndex((e) => e.classname === rawClassname);
|
||||
if (idx !== -1) {
|
||||
types[idx].xdim = dims.x;
|
||||
types[idx].ydim = dims.y;
|
||||
types[idx].defaultdir = computeDefaultDir(directions);
|
||||
types[idx].partcolors = extractPartColors(visualizations);
|
||||
types[idx].canstandon = canstandon ?? false;
|
||||
types[idx].cansiton = cansiton ?? false;
|
||||
types[idx].canlayon = canlayon ?? false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
for (const section of ["roomitemtypes", "wallitemtypes"] as const) {
|
||||
const types = furniData[section]?.furnitype;
|
||||
if (!Array.isArray(types)) continue;
|
||||
const idx = types.findIndex((e) => e.classname === rawClassname);
|
||||
if (idx !== -1) {
|
||||
types[idx].xdim = dims.x;
|
||||
types[idx].ydim = dims.y;
|
||||
types[idx].defaultdir = computeDefaultDir(directions);
|
||||
types[idx].partcolors = extractPartColors(visualizations);
|
||||
types[idx].canstandon = canstandon ?? false;
|
||||
types[idx].cansiton = cansiton ?? false;
|
||||
types[idx].canlayon = canlayon ?? false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
await writeFurniData(furniData as Record<string, unknown>);
|
||||
});
|
||||
} catch (err) {
|
||||
console.warn("[nitro-editor] FurnitureData.json update failed:", (err as Error).message);
|
||||
}
|
||||
await writeFurniData(furniData as Record<string, unknown>);
|
||||
});
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
"[nitro-editor] FurnitureData.json update failed:",
|
||||
(err as Error).message,
|
||||
);
|
||||
}
|
||||
|
||||
// 5. Update items_base (best-effort)
|
||||
try {
|
||||
await prisma.$executeRaw`
|
||||
// 5. Update items_base (best-effort)
|
||||
try {
|
||||
await prisma.$executeRaw`
|
||||
UPDATE items_base
|
||||
SET width = ${dims.x}, length = ${dims.y}, stack_height = ${dims.z},
|
||||
allow_sit = ${cansiton ? "1" : "0"},
|
||||
@@ -129,33 +161,46 @@ export const PUT = withAdmin({ permission: PERMS.ASSETS_IMPORT }, async (request
|
||||
allow_walk = ${canstandon ? "1" : "0"}
|
||||
WHERE item_name = ${rawClassname}
|
||||
`;
|
||||
} catch (err) {
|
||||
console.warn("[nitro-editor] items_base update failed:", (err as Error).message);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
"[nitro-editor] items_base update failed:",
|
||||
(err as Error).message,
|
||||
);
|
||||
}
|
||||
|
||||
// 6. RCON refresh (best-effort)
|
||||
try {
|
||||
await rcon.updateItems();
|
||||
} catch (err) {
|
||||
console.warn("[nitro-editor] RCON updateItems failed:", (err as Error).message);
|
||||
}
|
||||
// 6. RCON refresh (best-effort)
|
||||
try {
|
||||
await rcon.updateItems();
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
"[nitro-editor] RCON updateItems failed:",
|
||||
(err as Error).message,
|
||||
);
|
||||
}
|
||||
|
||||
// 7. Audit log
|
||||
try {
|
||||
const dbItem = await prisma.itemsBase.findFirst({
|
||||
where: { itemName: rawClassname },
|
||||
select: { id: true },
|
||||
});
|
||||
await logAudit({
|
||||
userId: ctx.session.user.id,
|
||||
action: "furni_edit_nitro",
|
||||
target: "ItemsBase",
|
||||
targetId: dbItem?.id ?? 0,
|
||||
after: { classname: rawClassname, dimensions: dims, canstandon, cansiton, canlayon },
|
||||
});
|
||||
} catch (err) {
|
||||
console.warn("[nitro-editor] Audit log failed:", (err as Error).message);
|
||||
}
|
||||
// 7. Audit log
|
||||
try {
|
||||
const dbItem = await prisma.itemsBase.findFirst({
|
||||
where: { itemName: rawClassname },
|
||||
select: { id: true },
|
||||
});
|
||||
await logAudit({
|
||||
userId: ctx.session.user.id,
|
||||
action: "furni_edit_nitro",
|
||||
target: "ItemsBase",
|
||||
targetId: dbItem?.id ?? 0,
|
||||
after: {
|
||||
classname: rawClassname,
|
||||
dimensions: dims,
|
||||
canstandon,
|
||||
cansiton,
|
||||
canlayon,
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
console.warn("[nitro-editor] Audit log failed:", (err as Error).message);
|
||||
}
|
||||
|
||||
return apiOk({ dimensions: dims });
|
||||
});
|
||||
return apiOk({ dimensions: dims });
|
||||
},
|
||||
);
|
||||
@@ -4,10 +4,10 @@ import { apiOk } from "@/lib/api-response";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import {
|
||||
appendFurniEntriesBatch,
|
||||
buildFurniEntry,
|
||||
getFurnitureDataPath,
|
||||
readFurniData,
|
||||
appendFurniEntriesBatch,
|
||||
buildFurniEntry,
|
||||
getFurnitureDataPath,
|
||||
readFurniData,
|
||||
} from "@/lib/services/furni-data";
|
||||
import { lookupHabboIt } from "@/lib/services/habbo-furnidata-cache";
|
||||
import { rcon } from "@/lib/services/rcon";
|
||||
@@ -26,171 +26,201 @@ import { rcon } from "@/lib/services/rcon";
|
||||
* Entries are upserted by classname so running this repeatedly is safe.
|
||||
* Triggers RCON updateCatalog + updateItems at the end.
|
||||
*/
|
||||
export const POST = withAdmin({ permission: PERMS.ASSETS_IMPORT }, async (request) => {
|
||||
const qp = request.nextUrl.searchParams;
|
||||
const mode: "days" | "missing" | "broken" | "all" =
|
||||
qp.get("all") === "1"
|
||||
? "all"
|
||||
: qp.get("broken") === "1"
|
||||
? "broken"
|
||||
: qp.get("missing") === "1"
|
||||
? "missing"
|
||||
: "days";
|
||||
export const POST = withAdmin(
|
||||
{ permission: PERMS.ASSETS_IMPORT },
|
||||
async (request) => {
|
||||
const qp = request.nextUrl.searchParams;
|
||||
const mode: "days" | "missing" | "broken" | "all" =
|
||||
qp.get("all") === "1"
|
||||
? "all"
|
||||
: qp.get("broken") === "1"
|
||||
? "broken"
|
||||
: qp.get("missing") === "1"
|
||||
? "missing"
|
||||
: "days";
|
||||
|
||||
const days = Math.max(1, Math.min(parseInt(qp.get("days") || "7", 10) || 7, 365));
|
||||
const days = Math.max(
|
||||
1,
|
||||
Math.min(parseInt(qp.get("days") || "7", 10) || 7, 365),
|
||||
);
|
||||
|
||||
const targetIds = await resolveTargetIds(mode, days);
|
||||
const targetIds = await resolveTargetIds(mode, days);
|
||||
|
||||
if (targetIds.length === 0) {
|
||||
return apiOk({
|
||||
mode,
|
||||
days: mode === "days" ? days : undefined,
|
||||
examined: 0,
|
||||
resynced: 0,
|
||||
failed: 0,
|
||||
errors: [],
|
||||
});
|
||||
}
|
||||
if (targetIds.length === 0) {
|
||||
return apiOk({
|
||||
mode,
|
||||
days: mode === "days" ? days : undefined,
|
||||
examined: 0,
|
||||
resynced: 0,
|
||||
failed: 0,
|
||||
errors: [],
|
||||
});
|
||||
}
|
||||
|
||||
// Process in chunks to keep memory flat and make progress visible.
|
||||
const CHUNK = 500;
|
||||
let resynced = 0;
|
||||
const errors: Array<{ classname: string; message: string }> = [];
|
||||
// Process in chunks to keep memory flat and make progress visible.
|
||||
const CHUNK = 500;
|
||||
let resynced = 0;
|
||||
const errors: Array<{ classname: string; message: string }> = [];
|
||||
|
||||
for (let i = 0; i < targetIds.length; i += CHUNK) {
|
||||
const slice = targetIds.slice(i, i + CHUNK);
|
||||
const rows = await prisma.itemsBase.findMany({
|
||||
where: { id: { in: slice } },
|
||||
select: {
|
||||
id: true,
|
||||
spriteId: true,
|
||||
itemName: true,
|
||||
publicName: true,
|
||||
type: true,
|
||||
width: true,
|
||||
length: true,
|
||||
stackHeight: true,
|
||||
},
|
||||
});
|
||||
for (let i = 0; i < targetIds.length; i += CHUNK) {
|
||||
const slice = targetIds.slice(i, i + CHUNK);
|
||||
const rows = await prisma.itemsBase.findMany({
|
||||
where: { id: { in: slice } },
|
||||
select: {
|
||||
id: true,
|
||||
spriteId: true,
|
||||
itemName: true,
|
||||
publicName: true,
|
||||
type: true,
|
||||
width: true,
|
||||
length: true,
|
||||
stackHeight: true,
|
||||
},
|
||||
});
|
||||
|
||||
const entries: Array<{ entry: Record<string, unknown>; itemType: string }> = [];
|
||||
for (const row of rows) {
|
||||
try {
|
||||
const classname = row.itemName;
|
||||
let habboIt = null;
|
||||
try {
|
||||
habboIt = await lookupHabboIt(classname);
|
||||
} catch {
|
||||
/* optional */
|
||||
}
|
||||
const entries: Array<{
|
||||
entry: Record<string, unknown>;
|
||||
itemType: string;
|
||||
}> = [];
|
||||
for (const row of rows) {
|
||||
try {
|
||||
const classname = row.itemName;
|
||||
let habboIt = null;
|
||||
try {
|
||||
habboIt = await lookupHabboIt(classname);
|
||||
} catch {
|
||||
/* optional */
|
||||
}
|
||||
|
||||
const entry = buildFurniEntry({
|
||||
id: row.id,
|
||||
classname,
|
||||
revision: habboIt?.revision ?? 0,
|
||||
category: habboIt?.category || "unknown",
|
||||
name: row.publicName || habboIt?.name || classname,
|
||||
description: habboIt?.description || "",
|
||||
spriteId: row.spriteId,
|
||||
dims: { x: row.width, y: row.length, z: Math.round(Number(row.stackHeight)) },
|
||||
metadata: null,
|
||||
habboIt,
|
||||
});
|
||||
const entry = buildFurniEntry({
|
||||
id: row.id,
|
||||
classname,
|
||||
revision: habboIt?.revision ?? 0,
|
||||
category: habboIt?.category || "unknown",
|
||||
name: row.publicName || habboIt?.name || classname,
|
||||
description: habboIt?.description || "",
|
||||
spriteId: row.spriteId,
|
||||
dims: {
|
||||
x: row.width,
|
||||
y: row.length,
|
||||
z: Math.round(Number(row.stackHeight)),
|
||||
},
|
||||
metadata: null,
|
||||
habboIt,
|
||||
});
|
||||
|
||||
entries.push({ entry, itemType: row.type === "i" ? "i" : "s" });
|
||||
} catch (err) {
|
||||
errors.push({ classname: row.itemName, message: (err as Error).message });
|
||||
}
|
||||
}
|
||||
entries.push({ entry, itemType: row.type === "i" ? "i" : "s" });
|
||||
} catch (err) {
|
||||
errors.push({
|
||||
classname: row.itemName,
|
||||
message: (err as Error).message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (entries.length > 0) {
|
||||
try {
|
||||
await appendFurniEntriesBatch(entries);
|
||||
resynced += entries.length;
|
||||
} catch (err) {
|
||||
errors.push({
|
||||
classname: `_chunk_${i}_${i + slice.length}_`,
|
||||
message: `FurnitureData.json write failed: ${(err as Error).message}`,
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (entries.length > 0) {
|
||||
try {
|
||||
await appendFurniEntriesBatch(entries);
|
||||
resynced += entries.length;
|
||||
} catch (err) {
|
||||
errors.push({
|
||||
classname: `_chunk_${i}_${i + slice.length}_`,
|
||||
message: `FurnitureData.json write failed: ${(err as Error).message}`,
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let rconOk = false;
|
||||
try {
|
||||
const a = await rcon.updateCatalog();
|
||||
const b = await rcon.updateItems();
|
||||
rconOk = a && b;
|
||||
} catch {
|
||||
/* logged in rcon service */
|
||||
}
|
||||
let rconOk = false;
|
||||
try {
|
||||
const a = await rcon.updateCatalog();
|
||||
const b = await rcon.updateItems();
|
||||
rconOk = a && b;
|
||||
} catch {
|
||||
/* logged in rcon service */
|
||||
}
|
||||
|
||||
return apiOk({
|
||||
mode,
|
||||
days: mode === "days" ? days : undefined,
|
||||
examined: targetIds.length,
|
||||
resynced,
|
||||
failed: errors.length,
|
||||
rconOk,
|
||||
errors: errors.slice(0, 50),
|
||||
});
|
||||
});
|
||||
return apiOk({
|
||||
mode,
|
||||
days: mode === "days" ? days : undefined,
|
||||
examined: targetIds.length,
|
||||
resynced,
|
||||
failed: errors.length,
|
||||
rconOk,
|
||||
errors: errors.slice(0, 50),
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
async function resolveTargetIds(
|
||||
mode: "days" | "missing" | "broken" | "all",
|
||||
days: number,
|
||||
mode: "days" | "missing" | "broken" | "all",
|
||||
days: number,
|
||||
): Promise<number[]> {
|
||||
if (mode === "all") {
|
||||
const rows = await prisma.itemsBase.findMany({ select: { id: true }, orderBy: { id: "asc" } });
|
||||
return rows.map((r) => r.id);
|
||||
}
|
||||
if (mode === "all") {
|
||||
const rows = await prisma.itemsBase.findMany({
|
||||
select: { id: true },
|
||||
orderBy: { id: "asc" },
|
||||
});
|
||||
return rows.map((r) => r.id);
|
||||
}
|
||||
|
||||
if (mode === "missing" || mode === "broken") {
|
||||
const furniDataPath = await getFurnitureDataPath();
|
||||
if (!existsSync(furniDataPath)) {
|
||||
const rows = await prisma.itemsBase.findMany({ select: { id: true }, orderBy: { id: "asc" } });
|
||||
return rows.map((r) => r.id);
|
||||
}
|
||||
const furniData = (await readFurniData()) as {
|
||||
roomitemtypes?: { furnitype: Array<Record<string, unknown>> };
|
||||
wallitemtypes?: { furnitype: Array<Record<string, unknown>> };
|
||||
};
|
||||
const byClassname = new Map<string, Record<string, unknown>>();
|
||||
for (const section of [furniData.roomitemtypes, furniData.wallitemtypes]) {
|
||||
for (const e of section?.furnitype ?? []) {
|
||||
if (typeof e?.classname === "string") byClassname.set(e.classname, e);
|
||||
}
|
||||
}
|
||||
const rows = await prisma.itemsBase.findMany({
|
||||
select: { id: true, itemName: true, spriteId: true, width: true, length: true },
|
||||
orderBy: { id: "asc" },
|
||||
});
|
||||
if (mode === "missing" || mode === "broken") {
|
||||
const furniDataPath = await getFurnitureDataPath();
|
||||
if (!existsSync(furniDataPath)) {
|
||||
const rows = await prisma.itemsBase.findMany({
|
||||
select: { id: true },
|
||||
orderBy: { id: "asc" },
|
||||
});
|
||||
return rows.map((r) => r.id);
|
||||
}
|
||||
const furniData = (await readFurniData()) as {
|
||||
roomitemtypes?: { furnitype: Array<Record<string, unknown>> };
|
||||
wallitemtypes?: { furnitype: Array<Record<string, unknown>> };
|
||||
};
|
||||
const byClassname = new Map<string, Record<string, unknown>>();
|
||||
for (const section of [furniData.roomitemtypes, furniData.wallitemtypes]) {
|
||||
for (const e of section?.furnitype ?? []) {
|
||||
if (typeof e?.classname === "string") byClassname.set(e.classname, e);
|
||||
}
|
||||
}
|
||||
const rows = await prisma.itemsBase.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
itemName: true,
|
||||
spriteId: true,
|
||||
width: true,
|
||||
length: true,
|
||||
},
|
||||
orderBy: { id: "asc" },
|
||||
});
|
||||
|
||||
if (mode === "missing") {
|
||||
return rows.filter((r) => !byClassname.has(r.itemName)).map((r) => r.id);
|
||||
}
|
||||
if (mode === "missing") {
|
||||
return rows.filter((r) => !byClassname.has(r.itemName)).map((r) => r.id);
|
||||
}
|
||||
|
||||
// broken: entry present but inconsistent with items_base
|
||||
return rows
|
||||
.filter((r) => {
|
||||
const e = byClassname.get(r.itemName);
|
||||
if (!e) return true;
|
||||
const entryId = Number(e.id ?? -1);
|
||||
if (entryId !== r.id && entryId !== r.spriteId) return true;
|
||||
const xdim = Number(e.xdim ?? 0);
|
||||
const ydim = Number(e.ydim ?? 0);
|
||||
if (xdim <= 0 || ydim <= 0) return true;
|
||||
if (xdim !== r.width || ydim !== r.length) return true;
|
||||
return false;
|
||||
})
|
||||
.map((r) => r.id);
|
||||
}
|
||||
// broken: entry present but inconsistent with items_base
|
||||
return rows
|
||||
.filter((r) => {
|
||||
const e = byClassname.get(r.itemName);
|
||||
if (!e) return true;
|
||||
const entryId = Number(e.id ?? -1);
|
||||
if (entryId !== r.id && entryId !== r.spriteId) return true;
|
||||
const xdim = Number(e.xdim ?? 0);
|
||||
const ydim = Number(e.ydim ?? 0);
|
||||
if (xdim <= 0 || ydim <= 0) return true;
|
||||
if (xdim !== r.width || ydim !== r.length) return true;
|
||||
return false;
|
||||
})
|
||||
.map((r) => r.id);
|
||||
}
|
||||
|
||||
// mode === 'days'
|
||||
const sinceIso = new Date(Date.now() - days * 24 * 60 * 60 * 1000).toISOString();
|
||||
const sinceMysql = sinceIso.slice(0, 19).replace("T", " ");
|
||||
const auditRows = await prisma.$queryRaw<Array<{ target_id: number }>>`
|
||||
// mode === 'days'
|
||||
const sinceIso = new Date(
|
||||
Date.now() - days * 24 * 60 * 60 * 1000,
|
||||
).toISOString();
|
||||
const sinceMysql = sinceIso.slice(0, 19).replace("T", " ");
|
||||
const auditRows = await prisma.$queryRaw<Array<{ target_id: number }>>`
|
||||
SELECT DISTINCT target_id
|
||||
FROM admin_audit_log
|
||||
WHERE action = 'furni_import'
|
||||
@@ -199,5 +229,5 @@ async function resolveTargetIds(
|
||||
AND created_at >= ${sinceMysql}
|
||||
ORDER BY target_id ASC
|
||||
`;
|
||||
return auditRows.map((r) => r.target_id).filter(Boolean);
|
||||
return auditRows.map((r) => r.target_id).filter(Boolean);
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user