From b4021f6b422cbc20568ab96acb9b4004df195b9e Mon Sep 17 00:00:00 2001 From: openhands Date: Tue, 18 Aug 2026 20:35:48 +0200 Subject: [PATCH] Backfill icons too in the Gamedata bundle sync Extend the post-import backfill to also copy missing _icon.png files into /var/www/Gamedata/icons (in addition to nitros into bundled/furniture) so all imported furniture shows an icon in-game. Report copied nitros and icons separately in the API/SSE responses. --- .../admin/import/furni/batch-regen/route.ts | 9 +- src/app/api/admin/import/furni/batch/route.ts | 8 +- src/app/api/admin/import/furni/route.ts | 14 +- src/lib/services/furni-import.ts | 122 ++++++++++++------ 4 files changed, 99 insertions(+), 54 deletions(-) diff --git a/src/app/api/admin/import/furni/batch-regen/route.ts b/src/app/api/admin/import/furni/batch-regen/route.ts index c5789d96..9b1252c3 100644 --- a/src/app/api/admin/import/furni/batch-regen/route.ts +++ b/src/app/api/admin/import/furni/batch-regen/route.ts @@ -15,7 +15,7 @@ import { downloadFile, ensureDirectories, ensureFurniOwnership, - syncNitrosToGamedataBundle, + syncAssetsToGamedataBundle, } from "@/lib/services/furni-import"; import { getOfficialHabboFurnidata } from "@/lib/services/habbo-furnidata-cache"; import { rcon } from "@/lib/services/rcon"; @@ -262,8 +262,8 @@ export const POST = withAdmin( // Keep regenerated assets readable by the web server const ownership = await ensureFurniOwnership(); - // Backfill any missing nitros into the emulator's Gamedata bundle - const nitrosSynced = await syncNitrosToGamedataBundle(); + // Backfill any missing nitros and icons into the emulator's Gamedata bundle + const assetsSynced = await syncAssetsToGamedataBundle(); send({ type: "regen_complete", @@ -272,7 +272,8 @@ export const POST = withAdmin( skipped, furniDataAdded: furniDataEntries.length, ownershipFixed: ownership.fixed, - nitrosSynced: nitrosSynced.copied, + nitrosSynced: assetsSynced.copiedNitros, + iconsSynced: assetsSynced.copiedIcons, duration: Date.now() - startTime, }); diff --git a/src/app/api/admin/import/furni/batch/route.ts b/src/app/api/admin/import/furni/batch/route.ts index 1e88e357..cdcf835d 100644 --- a/src/app/api/admin/import/furni/batch/route.ts +++ b/src/app/api/admin/import/furni/batch/route.ts @@ -9,7 +9,7 @@ import { ensureFurniOwnership, importSingleFurni, reconcileImportedOfferIds, - syncNitrosToGamedataBundle, + syncAssetsToGamedataBundle, } from "@/lib/services/furni-import"; import { rcon } from "@/lib/services/rcon"; import type { ImportSingleResult } from "@/types/furni"; @@ -218,10 +218,13 @@ export const POST = withAdmin( let offerIdsFixed = 0; let ownershipFixed: string[] = []; let nitrosSynced: string[] = []; + let iconsSynced: string[] = []; try { offerIdsFixed = (await reconcileImportedOfferIds()).fixed; ownershipFixed = (await ensureFurniOwnership()).fixed; - nitrosSynced = (await syncNitrosToGamedataBundle()).copied; + const assetsSynced = await syncAssetsToGamedataBundle(); + nitrosSynced = assetsSynced.copiedNitros; + iconsSynced = assetsSynced.copiedIcons; } catch (err) { console.warn( "[import-furni] Post-import reconcile/ownership failed:", @@ -237,6 +240,7 @@ export const POST = withAdmin( offerIdsFixed, ownershipFixed, nitrosSynced, + iconsSynced, duration: Date.now() - startTime, }); diff --git a/src/app/api/admin/import/furni/route.ts b/src/app/api/admin/import/furni/route.ts index 998e6ba5..e8363fa7 100644 --- a/src/app/api/admin/import/furni/route.ts +++ b/src/app/api/admin/import/furni/route.ts @@ -22,7 +22,7 @@ import { importSingleFurni, reconcileImportedOfferIds, resetCatalogPageCache, - syncNitrosToGamedataBundle, + syncAssetsToGamedataBundle, } from "@/lib/services/furni-import"; import { getOfficialHabboFurnidata } from "@/lib/services/habbo-furnidata-cache"; import { listFurniture } from "@/lib/services/habbofurni"; @@ -413,7 +413,7 @@ export const POST = withAdmin( const ownership = await ensureFurniOwnership(); // Backfill any missing nitros into the emulator's Gamedata bundle - const nitrosSynced = await syncNitrosToGamedataBundle(); + const assetsSynced = await syncAssetsToGamedataBundle(); const succeeded = results.filter((r) => r.ok).length; const failed = results.filter((r) => !r.ok).length; @@ -425,7 +425,8 @@ export const POST = withAdmin( results, offerIdsFixed: reconciled.fixed, ownershipFixed: ownership.fixed, - nitrosSynced: nitrosSynced.copied, + nitrosSynced: assetsSynced.copiedNitros, + iconsSynced: assetsSynced.copiedIcons, }); } @@ -481,8 +482,8 @@ export const POST = withAdmin( // Keep asset + furnidata folders readable by the web server const ownership = await ensureFurniOwnership(); - // Backfill any missing nitros into the emulator's Gamedata bundle - const nitrosSynced = await syncNitrosToGamedataBundle(); + // Backfill any missing nitros and icons into the emulator's Gamedata bundle + const assetsSynced = await syncAssetsToGamedataBundle(); logAudit({ userId: ctx.session.user.id, @@ -506,7 +507,8 @@ export const POST = withAdmin( catalogItemId: result.catalogItemId, offerIdsFixed: reconciled.fixed, ownershipFixed: ownership.fixed, - nitrosSynced: nitrosSynced.copied, + nitrosSynced: assetsSynced.copiedNitros, + iconsSynced: assetsSynced.copiedIcons, spriteCount: result.spriteCount, warnings: result.warnings, }); diff --git a/src/lib/services/furni-import.ts b/src/lib/services/furni-import.ts index 7bf24ae2..1192f8ea 100644 --- a/src/lib/services/furni-import.ts +++ b/src/lib/services/furni-import.ts @@ -347,53 +347,91 @@ export async function ensureFurniOwnership(): Promise<{ fixed: string[] }> { } } -/** - * Copy every .nitro file that is missing from the emulator's Gamedata - * bundle directory (/var/www/Gamedata/bundled/furniture) so all imported - * furniture stays renderable in-game even when a mirror write was skipped. - */ -export async function syncNitrosToGamedataBundle(): Promise<{ - copied: string[]; -}> { - if (process.platform === "win32") return { copied: [] }; - - const gamedataRoot = await getGamedataRoot(); - if (!gamedataRoot) return { copied: [] }; - - const targetDir = path.join(gamedataRoot, "bundled/furniture"); - const { nitroDir } = await getFurniAssetDirs(); - if (nitroDir === targetDir) return { copied: [] }; - if (!existsSync(nitroDir)) return { copied: [] }; - +/** Copy a single missing file from source dir into target dir; logs on failure. */ +async function copyIfMissing( + sourceDir: string, + targetDir: string, + entry: string, + copied: string[], +): Promise { + const dest = path.join(targetDir, entry); + if (existsSync(dest)) return; try { - await fs.mkdir(targetDir, { recursive: true }); - } catch { - return { copied: [] }; - } - - const copied: string[] = []; - try { - const entries = await fs.readdir(nitroDir); - for (const entry of entries) { - if (!entry.endsWith(".nitro")) continue; - const dest = path.join(targetDir, entry); - if (existsSync(dest)) continue; - try { - await fs.copyFile(path.join(nitroDir, entry), dest); - copied.push(entry); - } catch (err) { - logger.warn("[import-furni] nitro sync copy failed", { - entry, - error: (err as Error).message, - }); - } - } + await fs.copyFile(path.join(sourceDir, entry), dest); + copied.push(entry); } catch (err) { - logger.warn("[import-furni] nitro sync scan failed", { + logger.warn("[import-furni] gamedata bundle sync copy failed", { + entry, error: (err as Error).message, }); } - return { copied }; +} + +/** Scan a source dir and copy every file that is missing from the target dir. */ +async function syncDirMissingFiles( + sourceDir: string, + targetDir: string, + predicate: (entry: string) => boolean, +): Promise { + if (!existsSync(sourceDir)) return []; + try { + await fs.mkdir(targetDir, { recursive: true }); + } catch { + return []; + } + const copied: string[] = []; + try { + const entries = await fs.readdir(sourceDir); + for (const entry of entries) { + if (!predicate(entry)) continue; + await copyIfMissing(sourceDir, targetDir, entry, copied); + } + } catch (err) { + logger.warn("[import-furni] gamedata bundle sync scan failed", { + error: (err as Error).message, + }); + } + return copied; +} + +/** + * Backfill any nitro and icon files missing from the emulator's Gamedata + * bundle (/var/www/Gamedata/bundled/furniture and /var/www/Gamedata/icons) + * so all imported furniture stays renderable in-game even when a mirror + * write was skipped. + */ +export async function syncAssetsToGamedataBundle(): Promise<{ + copiedNitros: string[]; + copiedIcons: string[]; +}> { + if (process.platform === "win32") + return { copiedNitros: [], copiedIcons: [] }; + + const gamedataRoot = await getGamedataRoot(); + if (!gamedataRoot) return { copiedNitros: [], copiedIcons: [] }; + + const { nitroDir, iconDir } = await getFurniAssetDirs(); + + const copiedNitros: string[] = []; + const copiedIcons: string[] = []; + + const nitroTarget = path.join(gamedataRoot, "bundled/furniture"); + if (nitroDir !== nitroTarget) { + const copied = await syncDirMissingFiles(nitroDir, nitroTarget, (entry) => + entry.endsWith(".nitro"), + ); + copiedNitros.push(...copied); + } + + const iconTarget = path.join(gamedataRoot, "icons"); + if (iconDir !== iconTarget) { + const copied = await syncDirMissingFiles(iconDir, iconTarget, (entry) => + entry.endsWith("_icon.png"), + ); + copiedIcons.push(...copied); + } + + return { copiedNitros, copiedIcons }; } /**