// @vitest-environment node import { execFileSync } from "node:child_process"; import { mkdtempSync, readdirSync, readFileSync, rmSync, statSync } from "node:fs"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import sharp from "sharp"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { artManifest, pieceIds } from "../src/map/art/manifest"; // @ts-expect-error: a plain .mjs build script, tested directly. import { MIN_ALPHA, sliceSheet } from "../scripts/slice.mjs"; const webRoot = join(dirname(fileURLToPath(import.meta.url)), ".."); const script = join(webRoot, "scripts/map-art.mjs"); sharp.cache(false); /** Reads through a buffer so sharp never holds the temp files open (Windows). */ const image = (path: string) => sharp(readFileSync(path)); const SIZES = { body: [256, 512], badge: [256, 512], ship: [128, 256], rocks: [64, 128], } as const; const run = (outDir: string) => execFileSync(process.execPath, [script, outDir], { cwd: webRoot, stdio: "pipe" }); describe("map art optimization script (CR-003)", () => { let first: string; let second: string; beforeAll(() => { first = mkdtempSync(join(tmpdir(), "map-art-a-")); second = mkdtempSync(join(tmpdir(), "map-art-b-")); run(first); run(second); }, 120_000); afterAll(() => { rmSync(first, { recursive: true, force: true }); rmSync(second, { recursive: true, force: true }); }); const present = Object.entries(artManifest.assets).filter(([, a]) => a.file !== null); it("writes every expected file at the right size", async () => { for (const [id, asset] of present) { if (asset.kind === "background") { const meta = await image(join(first, `${id}.webp`)).metadata(); expect(meta.format).toBe("webp"); expect(Math.max(meta.width!, meta.height!)).toBeLessThanOrEqual(2048); continue; } if (asset.kind === "strip") { const meta = await image(join(first, `${id}.png`)).metadata(); expect(meta.width!).toBeLessThanOrEqual(4096); expect(meta.height!).toBeLessThanOrEqual(256); continue; } // A sliced sheet writes one sprite per piece (CR-016). const bases = asset.kind === "rocks" ? pieceIds(id, asset) : [id]; const [one, two] = SIZES[asset.kind]; for (const base of bases) { for (const [suffix, longSide] of [ ["1x", one], ["2x", two], ] as const) { const meta = await image(join(first, `${base}@${suffix}.png`)).metadata(); expect(Math.max(meta.width!, meta.height!), `${base}@${suffix}`).toBe(longSide); } } } }); it("produces identical files when run twice", () => { const files = readdirSync(first).sort(); expect(readdirSync(second).sort()).toEqual(files); for (const file of files) { expect(readFileSync(join(second, file)).equals(readFileSync(join(first, file))), file).toBe( true, ); } }); it("matches the committed output pixel for pixel", async () => { // Pixels, not bytes: PNG compression may differ between platforms (CI runs Linux). const committed = join(webRoot, "public/assets/map-gen"); for (const file of readdirSync(first).filter((f) => f.endsWith(".png"))) { const [fresh, stored] = await Promise.all([ image(join(first, file)).raw().toBuffer(), image(join(committed, file)).raw().toBuffer(), ]); expect(stored.equals(fresh), file).toBe(true); } }); it("keeps the background under 1 MB", () => { expect(statSync(join(first, "bg.webp")).size).toBeLessThan(1024 * 1024); }); it("has source sprites with transparent backgrounds", async () => { for (const [id, asset] of present) { if (asset.kind === "background") continue; const { data, info } = await image(join(webRoot, artManifest.sourceDir, asset.file!)) .ensureAlpha() .raw() .toBuffer({ resolveWithObject: true }); const alpha = (x: number, y: number) => data[(y * info.width + x) * info.channels + 3]; const corners = [ alpha(0, 0), alpha(info.width - 1, 0), alpha(0, info.height - 1), alpha(info.width - 1, info.height - 1), ]; expect( // Alpha up to 2/255 is invisible (ship-lighter.png has a stray alpha-1 corner pixel). corners.every((a) => a! <= 2), `${asset.file} (${id}): background isn't transparent`, ).toBe(true); } }); }); /** A transparent sheet with an opaque square at each `[x, y, size]`, plus faint glow and specks. */ async function sheet(squares: [number, number, number][]): Promise { const width = 200; const height = 120; const data = Buffer.alloc(width * height * 4); const paint = (x: number, y: number, a: number) => { const i = (y * width + x) * 4; data[i] = 180; data[i + 1] = 160; data[i + 2] = 140; data[i + 3] = Math.max(data[i + 3]!, a); }; for (const [left, top, size] of squares) { // A faint halo bridging toward the neighbours, below the slicing threshold. for (let y = top - 6; y < top + size + 6; y++) { for (let x = left - 6; x < left + size + 6; x++) { if (x >= 0 && y >= 0 && x < width && y < height) paint(x, y, MIN_ALPHA - 6); } } for (let y = top; y < top + size; y++) for (let x = left; x < left + size; x++) paint(x, y, 255); } // A stray speck: far too small to be a sprite. paint(195, 115, 255); return sharp(data, { raw: { width, height, channels: 4 } }) .png() .toBuffer(); } describe("slicing a sprite sheet (Step 16.5)", () => { it("cuts three separate rocks into exactly three trimmed sprites", async () => { const pieces: Buffer[] = await sliceSheet( sharp, await sheet([ [10, 10, 30], [80, 20, 20], [140, 60, 40], ]), ); expect(pieces).toHaveLength(3); const sizes = await Promise.all(pieces.map((piece) => sharp(piece).metadata())); // Reading order (top row left to right), each trimmed to its own square. expect(sizes.map((m) => [m.width, m.height])).toEqual([ [30, 30], [20, 20], [40, 40], ]); }); it("keeps a single rock as one sprite", async () => { expect(await sliceSheet(sharp, await sheet([[60, 30, 50]]))).toHaveLength(1); }); it("drops a neighbour's glow from each sprite", async () => { const [piece] = await sliceSheet( sharp, await sheet([ [10, 10, 30], [44, 10, 30], ]), ); const { data } = await sharp(piece).raw().toBuffer({ resolveWithObject: true }); // Every pixel is either the rock (opaque) or cleared, never the faint halo. for (let i = 3; i < data.length; i += 4) expect([0, 255]).toContain(data[i]); }); });