import { test } from 'node:test'; import assert from 'node:assert/strict'; import { join } from 'node:path'; import { mkdirSync, writeFileSync, readFileSync, existsSync, rmSync, mkdtempSync, symlinkSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { platform } from 'node:process'; import { deflateSync } from 'node:zlib'; import { buildGame } from '../build-game.mjs'; const LINK_TYPE = platform === 'win32' ? 'junction' : 'dir'; function crc32(buf) { let c = ~0; for (const x of buf) { c ^= x; for (let i = 0; i < 8; i++) c = (c >>> 1) ^ (0xEDB88320 & -(c & 1)); } return ~c >>> 0; } function chunk(type, data) { const len = Buffer.alloc(4); len.writeUInt32BE(data.length); const td = Buffer.concat([Buffer.from(type, 'ascii'), data]); const c = Buffer.alloc(4); c.writeUInt32BE(crc32(td)); return Buffer.concat([len, td, c]); } function makePng(w, h, rgb = [0, 0, 0]) { const ihdr = Buffer.alloc(13); ihdr.writeUInt32BE(w, 0); ihdr.writeUInt32BE(h, 4); ihdr[8] = 8; ihdr[9] = 2; const raw = Buffer.alloc(h * (1 + w * 3)); for (let y = 0; y < h; y++) { const off = y * (1 + w * 3); raw[off] = 0; for (let x = 0; x < w; x++) { raw[off + 1 + x * 3] = rgb[0]; raw[off + 2 + x * 3] = rgb[1]; raw[off + 3 + x * 3] = rgb[2]; } } return Buffer.concat([ Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]), chunk('IHDR', ihdr), chunk('IDAT', deflateSync(raw)), chunk('IEND', Buffer.alloc(0)), ]); } const DEFAULT_BTN = makePng(64, 64, [1, 2, 3]); const SKINNED = makePng(64, 64, [9, 9, 9]); function put(dir, rel, content) { const p = join(dir, ...rel.split('/')); mkdirSync(join(p, '..'), { recursive: true }); writeFileSync(p, content); } function scaffold(gameVersion = '3.8.8') { const root = mkdtempSync(join(tmpdir(), 'youle-build-')); const hostProject = join(root, 'nexus'); const frameworkSrc = join(hostProject, 'assets', 'framework'); mkdirSync(frameworkSrc, { recursive: true }); writeFileSync(join(hostProject, 'package.json'), JSON.stringify({ name: 'YouleNexus', creator: { version: '3.8.8' } })); put(frameworkSrc, 'ui/atlas-hall/btn.png', DEFAULT_BTN); // junction 故意指向一个诱饵目录而非 frameworkSrc 自身,与 materialize.test.mjs 同理: // 否则"经 junction 拷来的字节"与"从真源拷来的字节"完全相同,测不出 Critical 1 那类 bug。 const frameworkDecoy = join(root, 'decoy', 'assets', 'framework'); mkdirSync(frameworkDecoy, { recursive: true }); put(frameworkDecoy, 'ui/atlas-hall/btn.png', DEFAULT_BTN); put(frameworkDecoy, 'ui/atlas-hall/__decoy_marker__.png', DEFAULT_BTN); const gamesDir = join(root, 'games'); const gameDir = join(gamesDir, 'demo'); mkdirSync(join(gameDir, 'assets'), { recursive: true }); writeFileSync(join(gameDir, 'package.json'), JSON.stringify({ name: 'demo', creator: { version: gameVersion } })); symlinkSync(frameworkDecoy, join(gameDir, 'assets', 'framework'), LINK_TYPE); return { root, hostProject, frameworkSrc, frameworkDecoy, gamesDir, gameDir, workspace: join(root, 'build-workspace') }; } function cleanup(root) { rmSync(root, { recursive: true, force: true }); } function baseOpts(s, extra = {}) { return { gamesDir: s.gamesDir, frameworkSrc: s.frameworkSrc, hostProject: s.hostProject, workspace: s.workspace, platform: 'web-mobile', frameworkCommit: 'abc1234', now: '2026-08-27T00:00:00.000Z', runBuild: () => {}, ...extra, }; } test('buildGame 走通全流程:实体化 → 合成 → 调构建 → 写 build-info', () => { const s = scaffold(); try { put(s.gameDir, 'assets/game/override/atlas-hall/btn.png', SKINNED); const calls = []; const r = buildGame('demo', baseOpts(s, { runBuild: (dir, p) => calls.push([dir, p]) })); assert.ok(readFileSync(join(r.workspaceDir, 'assets', 'framework', 'ui', 'atlas-hall', 'btn.png')).equals(SKINNED)); assert.ok(readFileSync(join(s.frameworkSrc, 'ui', 'atlas-hall', 'btn.png')).equals(DEFAULT_BTN), '框架真源必须只读:合成只能发生在临时工作区'); assert.ok(!existsSync(join(r.workspaceDir, 'assets', 'framework', 'ui', 'atlas-hall', '__decoy_marker__.png')), 'framework 必须来自 frameworkSrc 真源,不能残留 junction 诱饵目标独有的文件(Critical 1)'); assert.equal(calls.length, 1); assert.equal(calls[0][0], r.workspaceDir); assert.equal(calls[0][1], 'web-mobile'); const info = JSON.parse(readFileSync(join(r.workspaceDir, 'build-info.json'), 'utf8')); assert.equal(info.game, 'demo'); assert.equal(info.frameworkCommit, 'abc1234'); assert.equal(info.platform, 'web-mobile'); assert.deepEqual(info.skin, { matched: 1, frameworkTotal: 1 }); } finally { cleanup(s.root); } }); test('buildGame 在引擎版本不一致时中止,且不调用构建', () => { const s = scaffold('3.8.6'); try { let called = false; assert.throws( () => buildGame('demo', baseOpts(s, { runBuild: () => { called = true; } })), /引擎版本不一致/, ); assert.equal(called, false); } finally { cleanup(s.root); } }); test('buildGame 在皮肤校验失败时中止,且不调用构建', () => { const s = scaffold(); try { put(s.gameDir, 'assets/game/override/atlas-hall/typo.png', 'X'); let called = false; assert.throws( () => buildGame('demo', baseOpts(s, { runBuild: () => { called = true; } })), /皮肤覆盖校验未通过/, ); assert.equal(called, false); } finally { cleanup(s.root); } }); test('buildGame 重复构建:清掉旧工作区后重建(幂等)', () => { const s = scaffold(); try { const opts = baseOpts(s); buildGame('demo', opts); const r2 = buildGame('demo', opts); // 不应因目标已存在而抛错 assert.ok(existsSync(join(r2.workspaceDir, 'build-info.json'))); } finally { cleanup(s.root); } }); test('buildGame 对不存在的子游戏抛错', () => { const s = scaffold(); try { assert.throws(() => buildGame('nosuch', baseOpts(s)), /找不到子游戏工程/); } finally { cleanup(s.root); } });