diff --git a/docs/superpowers/plans/2026-08-02-production-furni-assets.md b/docs/superpowers/plans/2026-08-02-production-furni-assets.md index bbe4be4b..01248342 100644 --- a/docs/superpowers/plans/2026-08-02-production-furni-assets.md +++ b/docs/superpowers/plans/2026-08-02-production-furni-assets.md @@ -47,7 +47,7 @@ Also assert that an absent unconfigured root adds no Gamedata mirror and that a - [ ] **Step 2: Run the resolver test and verify RED** -Run: `pnpm test src/lib/services/furni-asset-dirs.test.ts` +Run: `pnpm exec vitest run --coverage=false src/lib/services/furni-asset-dirs.test.ts` Expected: FAIL because `gamedata_root` is not read and the Gamedata mirror is absent. @@ -61,7 +61,7 @@ Make `getFurnitureDataWritePaths()` include `/config/FurnitureDat - [ ] **Step 5: Run resolver tests and verify GREEN** -Run: `pnpm test src/lib/services/furni-asset-dirs.test.ts` +Run: `pnpm exec vitest run --coverage=false src/lib/services/furni-asset-dirs.test.ts` Expected: PASS. @@ -92,7 +92,7 @@ await expect(fs.readFile(path.join(liveIconDir, "chair_icon.png"))).resolves.toE - [ ] **Step 2: Run the upload test and verify RED** -Run: `pnpm test src/lib/services/upload-import.test.ts` +Run: `pnpm exec vitest run --coverage=false src/lib/services/upload-import.test.ts` Expected: FAIL because manual uploads currently write only to the primary CMS directories. @@ -102,7 +102,7 @@ Resolve all write targets, create their directories through the existing `ensure - [ ] **Step 4: Run the upload test and verify GREEN** -Run: `pnpm test src/lib/services/upload-import.test.ts` +Run: `pnpm exec vitest run --coverage=false src/lib/services/upload-import.test.ts` Expected: PASS. @@ -111,7 +111,7 @@ Expected: PASS. Run: ```text -pnpm test src/lib/services/furni-asset-dirs.test.ts src/lib/services/upload-import.test.ts src/lib/services/furni-import.test.ts +pnpm exec vitest run --coverage=false src/lib/services/furni-asset-dirs.test.ts src/lib/services/upload-import.test.ts src/lib/services/furni-import.test.ts pnpm typecheck pnpm test pnpm build diff --git a/src/app/admin/settings/cms-settings-config.ts b/src/app/admin/settings/cms-settings-config.ts index 2338c5e9..38b8f8b7 100644 --- a/src/app/admin/settings/cms-settings-config.ts +++ b/src/app/admin/settings/cms-settings-config.ts @@ -148,6 +148,14 @@ export const SETTINGS_GROUPS: SettingsGroup[] = [ type: "text", placeholder: "E:\\path\\to\\Nitro-Files", }, + { + key: "gamedata_root", + label: "Production Gamedata root (server path)", + description: + "Filesystem root served at /gamedata. Auto-detected as /var/www/Gamedata when present.", + type: "text", + placeholder: "/var/www/Gamedata", + }, ], }, { diff --git a/src/lib/services/furni-asset-dirs.test.ts b/src/lib/services/furni-asset-dirs.test.ts new file mode 100644 index 00000000..b71d9543 --- /dev/null +++ b/src/lib/services/furni-asset-dirs.test.ts @@ -0,0 +1,87 @@ +// @vitest-environment node +import path from "node:path"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { existsSyncMock, settingGetMock } = vi.hoisted(() => ({ + existsSyncMock: vi.fn(), + settingGetMock: vi.fn(), +})); + +vi.mock("node:fs", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, existsSync: existsSyncMock }; +}); + +vi.mock("@/lib/services/site-settings", () => ({ + siteSettings: { get: settingGetMock }, +})); + +import { getFurniAssetWriteTargets } from "./furni-asset-dirs"; + +const primary = { + swfDir: path.join("test-assets", "primary", "swf"), + iconDir: path.join("test-assets", "primary", "icons"), + nitroDir: path.join("test-assets", "primary", "furniture"), +}; + +function configureSettings(values: Record): void { + settingGetMock.mockImplementation( + async (key: string, fallback: string | null = null) => + values[key] ?? fallback, + ); +} + +describe("getFurniAssetWriteTargets", () => { + beforeEach(() => { + vi.clearAllMocks(); + existsSyncMock.mockReturnValue(false); + }); + + it("maps a configured production Gamedata root to the directories served by Nitro", async () => { + const gamedataRoot = path.join("test-assets", "Gamedata"); + configureSettings({ + furni_swf_dir: primary.swfDir, + furni_icon_dir: primary.iconDir, + furni_nitro_dir: primary.nitroDir, + nitro_files_root: "", + gamedata_root: gamedataRoot, + }); + + const targets = await getFurniAssetWriteTargets(); + + expect(targets.mirrorDirs).toContainEqual({ + swfDir: primary.swfDir, + iconDir: path.join(gamedataRoot, "icons"), + nitroDir: path.join(gamedataRoot, "bundled", "furniture"), + }); + }); + + it("does not add a production mirror when no root is configured or present", async () => { + configureSettings({ + furni_swf_dir: primary.swfDir, + furni_icon_dir: primary.iconDir, + furni_nitro_dir: primary.nitroDir, + nitro_files_root: "", + gamedata_root: "", + }); + + const targets = await getFurniAssetWriteTargets(); + + expect(targets.mirrorDirs).toEqual([]); + }); + + it("does not duplicate a Gamedata layout already used as the primary target", async () => { + const gamedataRoot = path.join("test-assets", "Gamedata"); + configureSettings({ + furni_swf_dir: primary.swfDir, + furni_icon_dir: path.join(gamedataRoot, "icons"), + furni_nitro_dir: path.join(gamedataRoot, "bundled", "furniture"), + nitro_files_root: "", + gamedata_root: gamedataRoot, + }); + + const targets = await getFurniAssetWriteTargets(); + + expect(targets.mirrorDirs).toEqual([]); + }); +}); diff --git a/src/lib/services/furni-asset-dirs.ts b/src/lib/services/furni-asset-dirs.ts index 620eb25d..76c9515d 100644 --- a/src/lib/services/furni-asset-dirs.ts +++ b/src/lib/services/furni-asset-dirs.ts @@ -7,6 +7,8 @@ export const DEFAULT_NITRO_FILES_ROOT = path.join( "DEV", "Nitro-Files", ); +export const DEFAULT_GAMEDATA_ROOT = + process.platform === "win32" ? "" : "/var/www/Gamedata"; export const DEFAULT_FURNI_ICON_DIR = path.join( /*turbopackIgnore: true*/ process.cwd(), "public/swf/dcr/hof_furni/icons", @@ -63,6 +65,16 @@ export async function getNitroFilesRoot(): Promise { : ""; } +export async function getGamedataRoot(): Promise { + const configured = (await siteSettings.get("gamedata_root", "")) ?? ""; + if (configured.trim()) return normalizeDir(configured, configured); + + return DEFAULT_GAMEDATA_ROOT && + existsSync(/*turbopackIgnore: true*/ DEFAULT_GAMEDATA_ROOT) + ? normalizeDir(DEFAULT_GAMEDATA_ROOT, DEFAULT_GAMEDATA_ROOT) + : ""; +} + function dirsFromNitroFilesRoot(root: string): FurniAssetDirs { return { swfDir: path.join(/*turbopackIgnore: true*/ root, "swf/dcr/hof_furni"), @@ -77,6 +89,17 @@ function dirsFromNitroFilesRoot(root: string): FurniAssetDirs { }; } +function dirsFromGamedataRoot( + root: string, + primarySwfDir: string, +): FurniAssetDirs { + return { + swfDir: primarySwfDir, + iconDir: path.join(/*turbopackIgnore: true*/ root, "icons"), + nitroDir: path.join(/*turbopackIgnore: true*/ root, "bundled/furniture"), + }; +} + export async function getFurniAssetDirs(): Promise { const [swfDir, iconDir, nitroDir] = await Promise.all([ siteSettings @@ -99,10 +122,16 @@ export async function getFurniAssetDirs(): Promise { export async function getFurniAssetWriteTargets(): Promise { const primary = await getFurniAssetDirs(); - const nitroFilesRoot = await getNitroFilesRoot(); - const mirrorCandidates = nitroFilesRoot - ? [dirsFromNitroFilesRoot(nitroFilesRoot)] - : []; + const [nitroFilesRoot, gamedataRoot] = await Promise.all([ + getNitroFilesRoot(), + getGamedataRoot(), + ]); + const mirrorCandidates = [ + ...(nitroFilesRoot ? [dirsFromNitroFilesRoot(nitroFilesRoot)] : []), + ...(gamedataRoot + ? [dirsFromGamedataRoot(gamedataRoot, primary.swfDir)] + : []), + ]; const primaryKey = { swfDir: @@ -119,18 +148,26 @@ export async function getFurniAssetWriteTargets(): Promise(); const mirrorDirs = mirrorCandidates.filter((dirs) => { const swfDir = normalizeDir(dirs.swfDir, dirs.swfDir); const iconDir = normalizeDir(dirs.iconDir, dirs.iconDir); const nitroDir = normalizeDir(dirs.nitroDir, dirs.nitroDir); - return ( + const isDifferent = (process.platform === "win32" ? swfDir.toLowerCase() : swfDir) !== primaryKey.swfDir || (process.platform === "win32" ? iconDir.toLowerCase() : iconDir) !== primaryKey.iconDir || (process.platform === "win32" ? nitroDir.toLowerCase() : nitroDir) !== - primaryKey.nitroDir - ); + primaryKey.nitroDir; + if (!isDifferent) return false; + + const candidateKey = [swfDir, iconDir, nitroDir] + .map((dir) => (process.platform === "win32" ? dir.toLowerCase() : dir)) + .join("\0"); + if (seenCandidates.has(candidateKey)) return false; + seenCandidates.add(candidateKey); + return true; }); return { diff --git a/src/lib/services/furni-data-paths.test.ts b/src/lib/services/furni-data-paths.test.ts new file mode 100644 index 00000000..622d9fe6 --- /dev/null +++ b/src/lib/services/furni-data-paths.test.ts @@ -0,0 +1,63 @@ +// @vitest-environment node +import { promises as fs } from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const { getGamedataRootMock, getNitroFilesRootMock, settingGetMock } = + vi.hoisted(() => ({ + getGamedataRootMock: vi.fn(), + getNitroFilesRootMock: vi.fn(), + settingGetMock: vi.fn(), + })); + +vi.mock("@/lib/services/furni-asset-dirs", () => ({ + getGamedataRoot: getGamedataRootMock, + getNitroFilesRoot: getNitroFilesRootMock, +})); + +vi.mock("@/lib/services/site-settings", () => ({ + siteSettings: { get: settingGetMock }, +})); + +import { writeFurniData } from "./furni-data"; + +describe("writeFurniData production mirror", () => { + let tempDir: string; + + beforeEach(async () => { + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "furni-data-paths-")); + getNitroFilesRootMock.mockResolvedValue(""); + getGamedataRootMock.mockResolvedValue(path.join(tempDir, "Gamedata")); + settingGetMock.mockImplementation( + async (key: string, fallback: string | null = null) => { + if (key === "furni_data_url") { + return path.join(tempDir, "cms", "FurnitureData.json"); + } + if (key === "furni_data_mirror_path") return ""; + return fallback; + }, + ); + }); + + afterEach(async () => { + await fs.rm(tempDir, { recursive: true, force: true }); + vi.clearAllMocks(); + }); + + it("writes metadata into the config directory served by production", async () => { + const data = { roomitemtypes: { furnitype: [{ id: 123 }] } }; + + await writeFurniData(data); + + const livePath = path.join( + tempDir, + "Gamedata", + "config", + "FurnitureData.json", + ); + await expect(fs.readFile(livePath, "utf-8")).resolves.toBe( + JSON.stringify(data), + ); + }); +}); diff --git a/src/lib/services/furni-data.ts b/src/lib/services/furni-data.ts index 497343ff..5d1d6025 100644 --- a/src/lib/services/furni-data.ts +++ b/src/lib/services/furni-data.ts @@ -1,6 +1,9 @@ import { existsSync, promises as fs } from "node:fs"; import path from "node:path"; -import { getNitroFilesRoot } from "@/lib/services/furni-asset-dirs"; +import { + getGamedataRoot, + getNitroFilesRoot, +} from "@/lib/services/furni-asset-dirs"; import type { HabboItFurniEntry } from "@/lib/services/habbo-furnidata-cache"; import { siteSettings } from "@/lib/services/site-settings"; import type { FurniMetadata } from "@/lib/services/swf-to-nitro"; @@ -31,7 +34,10 @@ async function getFurnitureDataWritePaths(): Promise { const primary = await getFurnitureDataPath(); const configuredMirror = (await siteSettings.get("furni_data_mirror_path", "")) ?? ""; - const nitroFilesRoot = await getNitroFilesRoot(); + const [nitroFilesRoot, gamedataRoot] = await Promise.all([ + getNitroFilesRoot(), + getGamedataRoot(), + ]); const mirror = configuredMirror.trim() ? configuredMirror : nitroFilesRoot @@ -41,7 +47,15 @@ async function getFurnitureDataWritePaths(): Promise { ) : ""; - const paths = [primary, mirror].filter(Boolean).map((p) => path.normalize(p)); + const gamedataPath = gamedataRoot + ? path.join( + /*turbopackIgnore: true*/ gamedataRoot, + "config/FurnitureData.json", + ) + : ""; + const paths = [primary, mirror, gamedataPath] + .filter(Boolean) + .map((p) => path.normalize(p)); const seen = new Set(); return paths.filter((p) => { const key = process.platform === "win32" ? p.toLowerCase() : p;