import { existsSync, promises as fs } from "node:fs"; import path from "node:path"; import { prisma } from "@/lib/prisma"; import { getFurniAssetDirs } from "@/lib/services/furni-asset-dirs"; import { appendFurniEntry, buildFurniEntry } from "@/lib/services/furni-data"; import { allocateCatalogItemId, autoPriceFurni, ensureDirectories, getOrCreateCategoryPage, } from "@/lib/services/furni-import"; import { parseNitroBundle } from "@/lib/services/swf/nitro-builder"; export interface UploadResult { ok: boolean; itemId?: number; catalogItemId?: number | null; classname: string; warnings: string[]; error?: string; sqlFile?: string; } export interface UploadParams { classname: string; name: string; description?: string; itemType: "s" | "i"; xdim?: number; ydim?: number; canstandon?: boolean; cansiton?: boolean; canlayon?: boolean; interactionType?: string; customparams?: string; } const MIGRATIONS_DIR = path.resolve(/*turbopackIgnore: true*/ process.cwd(), "prisma/migrations"); let itemsBaseIdAllocChain: Promise = Promise.resolve(); async function allocateItemsBaseId( insertFn: (nextId: number) => Promise, ): Promise { const prev = itemsBaseIdAllocChain; let settle!: () => void; itemsBaseIdAllocChain = new Promise((r) => { settle = r; }); await prev.catch(() => {}); try { const idRow = await prisma.$queryRaw>` SELECT COALESCE(MAX(id), 0) + 1 AS next FROM items_base`; const nextId = Number(idRow[0]?.next ?? 1); return await insertFn(nextId); } finally { settle(); } } function escapeSql(val: string | number): string { if (typeof val === "number") return String(val); return `'${val.replace(/\\/g, "\\\\").replace(/'/g, "\\'")}'`; } async function findNextMigrationNumber(): Promise { try { const entries = await fs.readdir(MIGRATIONS_DIR); let max = 0; for (const entry of entries) { const match = entry.match(/^(\d+)_/); if (match) { const num = Number.parseInt(match[1], 10); if (num > max) max = num; } } return max + 1; } catch { return 1; } } async function writeSqlMigration(params: { classname: string; name: string; itemType: string; xdim: number; ydim: number; stackHeight: number; allowStack: string; cansiton: boolean; canlayon: boolean; canstandon: boolean; interactionType: string; customparams: string; price: { credits: number; points: number; pointsType: number }; }): Promise { const num = await findNextMigrationNumber(); const padded = String(num).padStart(4, "0"); const className = params.classname.replace(/[^a-zA-Z0-9_-]/g, "_"); const fileName = `${padded}_import_furni_${className}.sql`; const filePath = path.join(/*turbopackIgnore: true*/ MIGRATIONS_DIR, fileName); const lines: string[] = [ `-- Migration: ${fileName}`, `-- Description: Import furniture "${params.classname}"`, `-- Generated: ${new Date().toISOString()}`, "", `INSERT INTO items_base`, ` (id, sprite_id, public_name, item_name, type, width, length, stack_height,`, ` allow_stack, allow_sit, allow_lay, allow_walk, interaction_type, customparams)`, `VALUES`, ` (@next_id, @next_id, ${escapeSql(params.name)}, ${escapeSql(params.classname)},`, ` ${escapeSql(params.itemType)}, ${params.xdim}, ${params.ydim}, ${params.stackHeight},`, ` ${params.allowStack}, ${params.cansiton ? "1" : "0"}, ${params.canlayon ? "1" : "0"}, ${params.canstandon ? "1" : "0"},`, ` ${escapeSql(params.interactionType)}, ${escapeSql(params.customparams)});`, "", `SET @next_id = LAST_INSERT_ID();`, "", `INSERT INTO catalog_items`, ` (id, page_id, item_ids, catalog_name, cost_credits, cost_points, points_type, amount, order_number, offer_id, extradata)`, `VALUES`, ` (NULL, ${escapeSql("imp_other")}, @next_id, ${escapeSql(params.classname)},`, ` ${params.price.credits}, ${params.price.points}, ${params.price.pointsType}, 1, 1, '-1', '');`, "", ]; const sql = lines.join("\n"); await fs.mkdir(MIGRATIONS_DIR, { recursive: true }); await fs.writeFile(filePath, sql, "utf-8"); return fileName; } export async function uploadSingleFurni(params: { classname: string; name: string; description?: string; itemType: "s" | "i"; xdim?: number; ydim?: number; canstandon?: boolean; cansiton?: boolean; canlayon?: boolean; interactionType?: string; customparams?: string; nitroBuffer: Buffer; iconBuffer?: Buffer | null; generateSql?: boolean; }): Promise { const { classname, name, description = "", itemType, xdim: userXdim, ydim: userYdim, canstandon = false, cansiton = false, canlayon = false, interactionType: userInteractionType = "default", customparams = "", nitroBuffer, iconBuffer, generateSql = false, } = params; const warnings: string[] = []; if (!/^[\w\-.*]+$/.test(classname)) { return { ok: false, classname, warnings, error: "invalid classname" }; } const existing = await prisma.$queryRaw>` SELECT id FROM items_base WHERE item_name = ${classname} LIMIT 1`; if (existing.length > 0) { return { ok: false, classname, warnings, error: "Item already exists in database", }; } let nitroMeta: Record | null = null; try { const parsed = parseNitroBundle(nitroBuffer); nitroMeta = parsed.json; } catch { return { ok: false, classname, warnings, error: "invalid .nitro bundle" }; } await ensureDirectories(); const { iconDir, nitroDir } = await getFurniAssetDirs(); const nitroFileName = `${classname}.nitro`; const nitroPath = path.join(/*turbopackIgnore: true*/ nitroDir, nitroFileName); if (existsSync(nitroPath)) { return { ok: false, classname, warnings, error: ".nitro file already exists on disk", }; } await fs.writeFile(nitroPath, nitroBuffer); let iconFileName: string | null = null; let iconPath: string | null = null; if (iconBuffer && iconBuffer.length > 0) { iconFileName = `${classname}_icon.png`; iconPath = path.join(/*turbopackIgnore: true*/ iconDir, iconFileName); await fs.writeFile(iconPath, iconBuffer); } const xdim = userXdim ?? Number(nitroMeta?.xdim ?? 1); const ydim = userYdim ?? Number(nitroMeta?.ydim ?? 1); const stackHeight = canlayon || cansiton ? 1.0 : canstandon ? 1.0 : 0.0; const allowStack = stackHeight > 0 ? "1" : "0"; const price = autoPriceFurni(classname); let newId: number; try { newId = await allocateItemsBaseId(async (nextId) => { await prisma.$executeRaw` INSERT INTO items_base (id, sprite_id, public_name, item_name, type, width, length, stack_height, allow_stack, allow_sit, allow_lay, allow_walk, interaction_type, customparams) VALUES (${nextId}, ${nextId}, ${name}, ${classname}, ${itemType}, ${xdim}, ${ydim}, ${stackHeight}, ${allowStack}, ${cansiton ? "1" : "0"}, ${canlayon ? "1" : "0"}, ${canstandon ? "1" : "0"}, ${userInteractionType}, ${customparams})`; return nextId; }); } catch (err) { await fs.unlink(nitroPath).catch(() => {}); if (iconPath) await fs.unlink(iconPath).catch(() => {}); return { ok: false, classname, warnings, error: `items_base insert failed: ${(err as Error).message}`, }; } try { const furniEntry = buildFurniEntry({ id: newId, classname, revision: 0, category: "unknown", name, description, spriteId: newId, dims: { x: xdim, y: ydim, z: stackHeight }, metadata: null, }); await appendFurniEntry(furniEntry, itemType); } catch (err) { warnings.push(`FurnitureData append failed: ${(err as Error).message}`); } let catalogItemId: number | null = null; try { const pageId = await getOrCreateCategoryPage(classname, itemType); catalogItemId = await allocateCatalogItemId(async (nextCatalogId) => { await prisma.$executeRaw` INSERT INTO catalog_items (id, page_id, item_ids, catalog_name, cost_credits, cost_points, points_type, amount, order_number, offer_id, extradata) VALUES (${nextCatalogId}, ${String(pageId)}, ${String(newId)}, ${classname}, ${price.credits}, ${price.points}, ${price.pointsType}, 1, 1, '-1', '')`; return nextCatalogId; }); } catch (err) { warnings.push(`catalog entry failed: ${(err as Error).message}`); } let sqlFile: string | undefined; if (generateSql) { try { sqlFile = await writeSqlMigration({ classname, name, itemType, xdim, ydim, stackHeight, allowStack, cansiton, canlayon, canstandon, interactionType: userInteractionType, customparams, price, }); warnings.push(`SQL migration written: ${sqlFile}`); } catch (err) { warnings.push(`Failed to write SQL migration: ${(err as Error).message}`); } } return { ok: true, itemId: newId, catalogItemId, classname, warnings, sqlFile, }; }