diff --git a/ecosystem.config.cjs b/ecosystem.config.cjs deleted file mode 100644 index ad8b4ac5..00000000 --- a/ecosystem.config.cjs +++ /dev/null @@ -1,19 +0,0 @@ -module.exports = { - apps: [ - { - name: 'epic-next-app', - script: 'node_modules/next/dist/bin/next', - args: 'start', - cwd: '/var/www/atom-nexst', - instances: 1, - autorestart: true, - watch: false, - max_memory_restart: '1G', - node_args: '--max-old-space-size=1024', - env: { - NODE_ENV: 'production', - PORT: 3002 - } - } - ] -}; \ No newline at end of file diff --git a/scripts/compress-gamedata.mjs b/scripts/compress-gamedata.mjs deleted file mode 100644 index 4782c754..00000000 --- a/scripts/compress-gamedata.mjs +++ /dev/null @@ -1,130 +0,0 @@ -/** - * Pre-compress large gamedata JSON files to .gz so nginx `gzip_static` - * serves them via sendfile instead of re-compressing up to 48 MB on every - * request (see /etc/nginx/nginx.conf: `gzip_static on`). - * - * Idempotent: a file is only re-compressed when its source mtime is newer - * than the existing .gz (e.g. after a Studio catalog export rewrites it). - * - * Usage: node scripts/compress-gamedata.mjs - * Env: GAMEDATA_ROOT (default /var/www/Gamedata) - * GAMEDATA_GZIP_MIN_BYTES (default 1048576) - */ - -import { createReadStream, createWriteStream, promises as fs } from "node:fs"; -import { cpus } from "node:os"; -import { join, relative } from "node:path"; -import { pipeline } from "node:stream/promises"; -import { createGzip } from "node:zlib"; - -const GAMEDATA_ROOT = process.env.GAMEDATA_ROOT ?? "/var/www/Gamedata"; -const MIN_BYTES = Number(process.env.GAMEDATA_GZIP_MIN_BYTES ?? 1024 * 1024); -const GZIP_LEVEL = 9; -const TARGET_DIRS = ["config", "bundled/config"]; -const CONCURRENCY = Math.max(1, Math.min(4, cpus().length)); - -function isCompressible(name) { - return ( - name.endsWith(".json") && - !name.endsWith(".gz") && - !name.endsWith(".min.json") - ); -} - -async function collectCandidates() { - const files = []; - for (const dir of TARGET_DIRS) { - const root = join(GAMEDATA_ROOT, dir); - let entries; - try { - entries = await fs.readdir(root, { withFileTypes: true }); - } catch { - continue; - } - for (const entry of entries) { - if (!entry.isFile() || !isCompressible(entry.name)) continue; - const full = join(root, entry.name); - const stat = await fs.stat(full); - if (stat.size < MIN_BYTES) continue; - files.push({ src: full, size: stat.size, mtimeMs: stat.mtimeMs }); - } - } - return files; -} - -async function needsCompression({ src, mtimeMs }) { - const gz = `${src}.gz`; - try { - const stat = await fs.stat(gz); - return stat.mtimeMs < mtimeMs; - } catch { - return true; - } -} - -async function compressOne({ src }) { - const gz = `${src}.gz`; - const tmp = `${gz}.tmp-${process.pid}`; - await pipeline( - createReadStream(src), - createGzip({ level: GZIP_LEVEL }), - createWriteStream(tmp), - ); - await fs.rename(tmp, gz); - try { - await fs.chmod(gz, 0o644); - } catch { - // Best-effort; ownership is up to the caller. - } - return gz; -} - -async function main() { - const candidates = await collectCandidates(); - const work = []; - const skipped = []; - for (const candidate of candidates) { - if (await needsCompression(candidate)) { - work.push(candidate); - } else { - skipped.push(candidate); - } - } - - const results = []; - let idx = 0; - async function worker() { - while (idx < work.length) { - const item = work[idx++]; - try { - const gz = await compressOne(item); - const stat = await fs.stat(gz); - results.push( - `compressed ${relative(GAMEDATA_ROOT, gz)} (${item.size} -> ${stat.size} bytes, ${Math.round((1 - stat.size / item.size) * 1000) / 10}% smaller)`, - ); - } catch (err) { - results.push( - `FAILED ${relative(GAMEDATA_ROOT, item.src)}: ${err instanceof Error ? err.message : String(err)}`, - ); - } - } - } - - const workers = Array.from( - { length: Math.min(CONCURRENCY, work.length) }, - () => worker(), - ); - await Promise.all(workers); - - console.log( - `gamedata: ${work.length} to compress, ${skipped.length} up to date`, - ); - for (const line of results) console.log(`gamedata: ${line}`); -} - -main().catch((err) => { - console.error( - `gamedata: FATAL ${err instanceof Error ? err.message : String(err)}`, - ); - process.exit(1); -}); diff --git a/src/lib/services/upload-import.test.ts b/src/lib/services/upload-import.test.ts index ee369483..bff8bbe9 100644 --- a/src/lib/services/upload-import.test.ts +++ b/src/lib/services/upload-import.test.ts @@ -84,8 +84,19 @@ vi.mock("@/lib/services/furni-import", () => ({ IMPORTED_PAGE_CAPTION_SAVE: "imported_furni", })); +const nitroBuilderMock = vi.hoisted(() => ({ + createNitroBundle: vi.fn((json: object) => + Buffer.from(`rebuilt:${JSON.stringify(json)}`, "utf-8"), + ), +})); + vi.mock("@/lib/services/swf/nitro-builder", () => ({ - parseNitroBundle: vi.fn(() => ({ json: { xdim: 1, ydim: 1 } })), + parseNitroBundle: vi.fn(() => ({ + json: { xdim: 1, ydim: 1 }, + texture: Buffer.from("texture-bytes"), + textureFileName: "chair.png", + })), + createNitroBundle: nitroBuilderMock.createNitroBundle, })); import { @@ -135,11 +146,19 @@ describe("uploadSingleFurni live asset mirrors", () => { }); expect(result.ok).toBe(true); + // The bundle is re-written through the builder so `meta.image` can never + // disagree with the texture member, while the member keeps the name it + // arrived with (colour variants share a library name). + expect(nitroBuilderMock.createNitroBundle).toHaveBeenCalledWith( + { xdim: 1, ydim: 1 }, + Buffer.from("texture-bytes"), + "chair", + ); await expect( fs.readFile( getRuntimePath(assetTargets.mirrorDirs[0].nitroDir, "chair.nitro"), ), - ).resolves.toEqual(nitroBuffer); + ).resolves.toEqual(Buffer.from('rebuilt:{"xdim":1,"ydim":1}', "utf-8")); await expect( fs.readFile( getRuntimePath(assetTargets.mirrorDirs[0].iconDir, "chair_icon.png"), diff --git a/src/lib/services/upload-import.ts b/src/lib/services/upload-import.ts index d3763f3d..047f2af7 100644 --- a/src/lib/services/upload-import.ts +++ b/src/lib/services/upload-import.ts @@ -17,7 +17,10 @@ import { IMPORTED_PAGE_CAPTION_SAVE, } from "@/lib/services/furni-import"; import { nitroAnimationStatesCount } from "@/lib/services/furni-real-interaction"; -import { parseNitroBundle } from "@/lib/services/swf/nitro-builder"; +import { + createNitroBundle, + parseNitroBundle, +} from "@/lib/services/swf/nitro-builder"; import { getRuntimePath, getRuntimeResolve } from "@/lib/utils/runtime-path"; export interface UploadResult { @@ -275,9 +278,19 @@ export async function uploadSingleFurni(params: { } let nitroMeta: Record | null = null; + // The client resolves the spritesheet through `spritesheet.meta.image`, so + // that pointer must name the member we actually ship. A bundle from a + // third-party tool can arrive with a stale pointer (say `.png` next to a + // WebP member), which stores fine and then renders nothing. Re-writing + // through the builder repairs it without touching a single texture byte. + // The member keeps its own base name, so `chair*2` colour variants that + // share the `chair` library are not renamed. + let storedNitro = nitroBuffer; try { const parsed = parseNitroBundle(nitroBuffer); nitroMeta = parsed.json; + const memberBase = parsed.textureFileName.replace(/\.(?:png|webp)$/i, ""); + storedNitro = createNitroBundle(parsed.json, parsed.texture, memberBase); } catch { return { ok: false, classname, warnings, error: "invalid .nitro bundle" }; } @@ -299,7 +312,7 @@ export async function uploadSingleFurni(params: { error: ".nitro file already exists on disk", }; } - await fs.writeFile(nitroPath, nitroBuffer); + await fs.writeFile(nitroPath, storedNitro); let iconFileName: string | null = null; let iconPath: string | null = null;