fix(skin): 修复最终评审发现的 6 处缺陷(junction 排除失效/meta 静默丢失等)

跨任务视角发现的问题:Critical1 materialize.mjs 的 junction 排除比较在
Windows 上因长路径前缀恒假、从未生效,且测试夹具的 junction 指向真源自身,
掩盖了此问题;Critical2 composeSkin 例外通道在 uuid 缺失/框架侧无 meta 时
静默失败,违反第二准则;Important3 可覆盖范围误把 theme/ 下的框架 TS 代码
也纳入;Important5 cli-entry 测试漏注册 check-skin/build-game;Important6
composeSkin 与 check-skin 各自拼路径,未共用 paths.mjs 权威推导;Important7
--platform 缺值时静默落到默认平台。逐条修复并补测试,Critical1 用诱饵框架
目录验证过能真正杀掉该 bug(变异推演见 fix report)。

spec §5 step5 的 dist/<name>/ 拷贝按要求标注为本期未实现,不实现。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-27 23:15:28 +08:00
co-authored by Claude Opus 5
parent 88deab3533
commit 5708926711
12 changed files with 209 additions and 24 deletions
@@ -13,7 +13,9 @@
在子游戏间完全相同;框架不带默认,这部分就要在每个子游戏各存一份且改了不会传播。
2. **只放「所有子游戏都需要」的平台界面资源**。判定问句:*下一个子游戏还需要它吗?*
「部分子游戏共用」**不构成**进框架的理由——放进去等于让其余子游戏白背包体。
3. **目录划分 = 图集划分**。Cocos 的 Auto Atlas 只对同目录散图生效,故目录结构是性能设计:
3. **目录划分 = 图集划分**。Cocos 的 Auto Atlas 只对同目录散图生效,故目录结构是性能设计。
以下 **8 个目录**是可覆盖资源范围的全部(`scripts/lib/skin.mjs` 的 `COVERABLE_DIRS` 白名单,
与此表逐字对应):
| 目录 | 用途 |
|---|---|
| `atlas-common/` | 全程常驻:按钮、面板底、图标、通用弹窗 |
@@ -21,6 +23,10 @@
| `standalone/` | 超图集尺寸上限的大图、整屏背景(不进图集) |
| `spine/` | Spine 骨骼动画,**每个资源一个子目录**,含骨骼数据/atlas/贴图 |
| `audio/` / `font/` | 平台音效 / 字体 |
**`theme/` 不在此列**——那是 §4.2 规定的**框架 TypeScript 代码**位置(类型契约 + 默认值 +
合并 + Provider),不是资源,**不可覆盖**、也不计入"框架可覆盖资源共 N 个"的统计。子游戏若在
`override/theme/` 下放文件会被判为 `orphan`(框架里没有对应的可覆盖路径)。
4. **图片资源统一用 PNG**。非 PNG 图片不做尺寸校验(`scripts/lib/skin.mjs`),会削弱保护。
## 子游戏怎么换肤
+11 -1
View File
@@ -80,7 +80,17 @@ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href)
process.exit(1);
}
const pIdx = process.argv.indexOf('--platform');
const targetPlatform = pIdx > -1 ? process.argv[pIdx + 1] : undefined;
let targetPlatform;
if (pIdx > -1) {
const val = process.argv[pIdx + 1];
// 用户明确写了 --platform 却没给值(或紧跟下一个 flag):显式报错,
// 不能落到 buildGame 的 `?? 'android'` 默认值——那会把"用户指定"悄悄变成"猜的"(第二准则)。
if (!val || val.startsWith('--')) {
console.error('用法: npm run build-game <game> [-- --platform <platform>](--platform 后必须跟平台名,如 android)');
process.exit(1);
}
targetPlatform = val;
}
try {
const r = buildGame(name, targetPlatform ? { platform: targetPlatform } : {});
console.log(`皮肤命中 ${r.skin.matched} 处,框架可覆盖资源共 ${r.skin.frameworkTotal} 个`);
+2 -2
View File
@@ -3,7 +3,7 @@ import { join } from 'node:path';
import { existsSync } from 'node:fs';
import { pathToFileURL } from 'node:url';
import { FRAMEWORK_UI_DIR, GAMES_DIR, gameOverrideDir } from './lib/paths.mjs';
import { listAssets, validateOverrides } from './lib/skin.mjs';
import { listCoverableAssets, validateOverrides } from './lib/skin.mjs';
/** 校验某子游戏的皮肤覆盖。opts 可注入路径用于测试。 */
export function checkSkin(gameName, opts = {}) {
@@ -16,7 +16,7 @@ export function checkSkin(gameName, opts = {}) {
}
// override 目录不存在 = 该子游戏不换肤,合法
const { matched, errors } = validateOverrides(frameworkUiDir, gameOverrideDir(projectDir));
return { matched, errors, frameworkTotal: listAssets(frameworkUiDir).length };
return { matched, errors, frameworkTotal: listCoverableAssets(frameworkUiDir).length };
}
// CLI 入口:有错误则退出码 1
@@ -1,6 +1,7 @@
import { cpSync, mkdirSync, existsSync, readFileSync, writeFileSync, copyFileSync } from 'node:fs';
import { join, posix, dirname, relative, sep, resolve } from 'node:path';
import { listAssets, validateOverrides } from './skin.mjs';
import { join, posix, dirname, relative, sep, resolve, basename } from 'node:path';
import { listCoverableAssets, validateOverrides } from './skin.mjs';
import { gameOverrideDir, frameworkUiDirOf } from './paths.mjs';
/**
* 工程缓存目录:实体化时跳过。
@@ -27,17 +28,19 @@ export function materialize(gameDir, frameworkSrc, destDir) {
mkdirSync(destDir, { recursive: true });
// 1) 拷工程本体,跳过缓存目录与 assets/framework(后者单独从真源拷)
const frameworkLink = join(gameDir, 'assets', 'framework');
const frameworkRel = join('assets', 'framework');
const gameDirNorm = resolve(gameDir);
cpSync(gameDir, destDir, {
recursive: true,
dereference: true,
filter: (src) => {
if (src === frameworkLink) return false;
// 处理 Windows UNC 长路径前缀 (\\?\) 导致 relative() 失败的问题
// 处理 Windows UNC 长路径前缀 (\\?\):cpSync 传入的 src 在 Windows 上
// 每一层都带该前缀,若不归一化,下面任何基于 rel 的比较都会恒假
// (包括曾经直接 `src === frameworkLink` 的写法——那是 Critical 1 的成因)。
const srcNorm = resolve(src.replace(/^\\\\\?\\/, ''));
const rel = relative(gameDirNorm, srcNorm);
if (!rel) return true; // gameDir 自身
if (rel === frameworkRel) return false; // junction:单独从真源拷贝,不经此路径解引用
return !CACHE_DIRS.has(rel.split(sep)[0]); // 只看第一段:锚定到工程根
},
});
@@ -51,8 +54,10 @@ export function materialize(gameDir, frameworkSrc, destDir) {
* 先整体校验,不通过则抛错且**不写任何文件**(避免半成品)。
*/
export function composeSkin(destDir) {
const frameworkUiDir = join(destDir, 'assets', 'framework', 'ui');
const overrideDir = join(destDir, 'assets', 'game', 'override');
// 路径推导唯一权威在 paths.mjs(check-skin 用同一套),composeSkin 不再自己拼一份地图
const frameworkUiDir = frameworkUiDirOf(destDir);
const overrideDir = gameOverrideDir(destDir);
const gameName = basename(destDir);
const { matched, errors } = validateOverrides(frameworkUiDir, overrideDir);
if (errors.length) throw new SkinValidationError(errors);
@@ -69,11 +74,28 @@ export function composeSkin(destDir) {
const fwMeta = `${to}.meta`;
if (existsSync(ovMeta) && existsSync(fwMeta)) {
const fwUuid = JSON.parse(readFileSync(fwMeta, 'utf8')).uuid;
if (!fwUuid) {
// 静默通过会写出没有 uuid 的 .meta——Cocos 重新生成 UUID,框架 Prefab 对该
// 资源的引用在发布包里断裂,这正是"保留 .meta"整个设计要防的唯一失效模式。
throw new SkinValidationError([{
path: rel,
code: 'meta-missing-uuid',
message: `${gameName}: 框架侧 .meta 没有 uuid(${fwMeta}),无法为例外通道保留引用,请先修复框架该文件的 .meta`,
}]);
}
const merged = JSON.parse(readFileSync(ovMeta, 'utf8'));
merged.uuid = fwUuid;
writeFileSync(fwMeta, `${JSON.stringify(merged, null, 2)}\n`);
} else if (existsSync(ovMeta) && !existsSync(fwMeta)) {
// 框架侧没有 .meta 时整段合并被跳过,override 自带的 meta 会被无声丢弃——
// 同样违反"下游不兜底":必须显式报错,而不是悄悄忽略 override 的意图。
throw new SkinValidationError([{
path: rel,
code: 'meta-orphan',
message: `${gameName}: override 提供了 ${rel}.meta,但框架侧没有对应的 .meta(${fwMeta}),无法合并 uuid(例外通道要求框架侧资源本身已有 .meta)`,
}]);
}
}
return { matched, frameworkTotal: listAssets(frameworkUiDir).length };
return { matched, frameworkTotal: listCoverableAssets(frameworkUiDir).length };
}
+8 -1
View File
@@ -8,7 +8,6 @@ export const HOST_PROJECT = join(ROOT, 'YouleNexus');
export const FRAMEWORK_SRC = join(HOST_PROJECT, 'assets', 'framework');
export const GAMES_DIR = join(ROOT, 'games');
export const SEED_DIR = join(ROOT, 'templates', 'game-seed');
export const FRAMEWORK_UI_DIR = join(FRAMEWORK_SRC, 'ui');
export const BUILD_WORKSPACE = join(ROOT, 'build-workspace');
// 子游戏的皮肤覆盖目录
@@ -16,6 +15,14 @@ export function gameOverrideDir(projectDir) {
return join(projectDir, 'assets', 'game', 'override');
}
// 某工程(宿主真源,或实体化后的临时工作区)的 framework/ui 目录。
// check-skin 与 build-game(composeSkin) 共用同一份推导,不各自拼路径(唯一权威)。
export function frameworkUiDirOf(projectDir) {
return join(projectDir, 'assets', 'framework', 'ui');
}
export const FRAMEWORK_UI_DIR = frameworkUiDirOf(HOST_PROJECT);
export function readCreatorVersion(projectDir) {
try {
const pkg = JSON.parse(readFileSync(join(projectDir, 'package.json'), 'utf8'));
+19 -1
View File
@@ -5,6 +5,15 @@ const IMAGE_EXTS = new Set(['.png', '.jpg', '.jpeg', '.webp']);
const SPINE_ROOT = 'spine'; // framework/ui/spine/<资源名>/ 为一套 Spine
const SKIPPED = new Set(['.gitkeep']);
/**
* 可被子游戏覆盖的框架资源目录(spec §2 规则 3 枚举的 8 个目录)。
* `theme/` 不在其中——那是 §4.2 规定的框架 TS 代码位置,不是资源,不可覆盖。
*/
const COVERABLE_DIRS = new Set([
'atlas-common', 'atlas-login', 'atlas-hall', 'atlas-room',
'standalone', 'spine', 'audio', 'font',
]);
/** 列出目录下所有资源的相对路径(正斜杠),排除 .meta 与 .gitkeep。 */
export function listAssets(dir) {
if (!existsSync(dir)) return [];
@@ -20,6 +29,15 @@ export function listAssets(dir) {
return out;
}
/**
* 列出框架 UI 目录下**可被覆盖**的资源(白名单目录,见 COVERABLE_DIRS)。
* `frameworkUiDir` 下还有 `theme/`、`README.md` 等非资源内容,
* 不属于可覆盖范围,也不计入"框架可覆盖资源共 N 个"的统计。
*/
export function listCoverableAssets(frameworkUiDir) {
return listAssets(frameworkUiDir).filter((rel) => COVERABLE_DIRS.has(rel.split(posix.sep)[0]));
}
/** 读 PNG 宽高;非 PNG 或读取失败返回 null。 */
export function readPngSize(file) {
try {
@@ -39,7 +57,7 @@ const spineGroup = (rel) => rel.split(posix.sep).slice(0, 2).join(posix.sep);
* 返回 { matched: 相对路径[], errors: {path, code, message}[] }。
*/
export function validateOverrides(frameworkUiDir, overrideDir) {
const fwList = listAssets(frameworkUiDir);
const fwList = listCoverableAssets(frameworkUiDir);
const fwSet = new Set(fwList);
const ovList = listAssets(overrideDir);
const ovSet = new Set(ovList);
@@ -54,13 +54,20 @@ function scaffold(gameVersion = '3.8.8') {
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(frameworkSrc, join(gameDir, 'assets', 'framework'), LINK_TYPE);
symlinkSync(frameworkDecoy, join(gameDir, 'assets', 'framework'), LINK_TYPE);
return { root, hostProject, frameworkSrc, gamesDir, gameDir, workspace: join(root, 'build-workspace') };
return { root, hostProject, frameworkSrc, frameworkDecoy, gamesDir, gameDir, workspace: join(root, 'build-workspace') };
}
function cleanup(root) { rmSync(root, { recursive: true, force: true }); }
@@ -83,6 +90,8 @@ test('buildGame 走通全流程:实体化 → 合成 → 调构建 → 写 bui
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');
@@ -5,7 +5,10 @@ import { fileURLToPath, pathToFileURL } from 'node:url';
import { dirname, join } from 'node:path';
const scriptsDir = join(dirname(fileURLToPath(import.meta.url)), '..');
const CLI = ['setup-links.mjs', 'new-game.mjs', 'check-cocos-version.mjs', 'bump-cocos.mjs'];
const CLI = [
'setup-links.mjs', 'new-game.mjs', 'check-cocos-version.mjs', 'bump-cocos.mjs',
'check-skin.mjs', 'build-game.mjs',
];
for (const name of CLI) {
test(`${name} 被动态 import 时不崩溃(argv[1] undefined)`, () => {
@@ -49,7 +49,15 @@ function put(dir, rel, content) {
function read(dir, rel) { return readFileSync(join(dir, ...rel.split('/')), 'utf8'); }
function readBinary(dir, rel) { return readFileSync(join(dir, ...rel.split('/'))); }
/** 造一个「框架真源 + 挂 junction 的子游戏工程」的临时环境 */
/**
* 造一个「框架真源 + 挂 junction 的子游戏工程」的临时环境。
*
* junction **故意不指向 frameworkSrc 本身**,而是指向一个内容相似、但多一个
* 标记文件的「诱饵」目录(frameworkDecoy)。这样"实体化后的 framework 到底是
* 从 junction 解引用来的,还是从真源 frameworkSrc 单独拷的"才是可判定的——
* 若两者是同一目录,字节完全相同,Critical 1(junction 排除分支在 Windows 上
* 从未生效)这个 bug 永远不会被测出来。
*/
function scaffold() {
const root = mkdtempSync(join(tmpdir(), 'youle-mat-'));
const frameworkSrc = join(root, 'nexus', 'assets', 'framework');
@@ -59,15 +67,24 @@ function scaffold() {
put(frameworkSrc, 'ui/atlas-hall/bg.png', DEFAULT_BG);
put(frameworkSrc, 'core/net.ts', 'export const x=1;');
// 诱饵:junction 实际指向这里。内容与 frameworkSrc 一致,但多一个标记文件。
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/btn.png.meta', '{"uuid":"fw-btn-uuid"}');
put(frameworkDecoy, 'ui/atlas-hall/bg.png', DEFAULT_BG);
put(frameworkDecoy, 'core/net.ts', 'export const x=1;');
put(frameworkDecoy, 'ui/atlas-hall/__decoy_marker__.png', DEFAULT_BG); // 只存在于诱饵里
const gameDir = join(root, 'games', 'demo');
mkdirSync(join(gameDir, 'assets'), { recursive: true });
writeFileSync(join(gameDir, 'package.json'), JSON.stringify({ name: 'demo', creator: { version: '3.8.8' } }));
symlinkSync(frameworkSrc, join(gameDir, 'assets', 'framework'), LINK_TYPE);
symlinkSync(frameworkDecoy, join(gameDir, 'assets', 'framework'), LINK_TYPE);
put(gameDir, 'assets/game/theme.ts', 'export const theme={};');
mkdirSync(join(gameDir, 'library'), { recursive: true }); // 缓存目录,应被跳过
writeFileSync(join(gameDir, 'library', 'junk'), 'x');
return { root, frameworkSrc, gameDir, dest: join(root, 'workspace', 'demo') };
return { root, frameworkSrc, frameworkDecoy, gameDir, dest: join(root, 'workspace', 'demo') };
}
function cleanup(root) { rmSync(root, { recursive: true, force: true }); }
@@ -100,6 +117,17 @@ test('materialize 后写临时工程不影响框架真源', () => {
} finally { cleanup(s.root); }
});
test('materialize 的 framework 来自真源 frameworkSrc,而非 junction 解引用(诱饵标记文件必须不存在)', () => {
const s = scaffold();
try {
materialize(s.gameDir, s.frameworkSrc, s.dest);
assert.ok(
!existsSync(join(s.dest, 'assets', 'framework', 'ui', 'atlas-hall', '__decoy_marker__.png')),
'framework 必须单独从 frameworkSrc 真源拷贝;诱饵(junction 目标)独有的标记文件不应出现在工作区里',
);
} finally { cleanup(s.root); }
});
test('materialize 拒绝覆盖已存在的目标目录', () => {
const s = scaffold();
try {
@@ -135,6 +163,47 @@ test('composeSkin 例外通道:override 自带 meta 时,uuid 被改回框架
} finally { cleanup(s.root); }
});
test('composeSkin 例外通道:框架侧 .meta 没有 uuid 时显式抛错(不得写出没有 uuid 的 .meta)', () => {
const s = scaffold();
try {
// 框架 bg.png 本身没有 .meta;给它补一份没有 uuid 字段的 .meta,
// 模拟 fwUuid === undefined 的场景(JSON.stringify 会静默丢掉 undefined 的键)
put(s.gameDir, 'assets/game/override/atlas-hall/bg.png', SKINNED_BTN);
put(s.gameDir, 'assets/game/override/atlas-hall/bg.png.meta', '{"ver":"1.0.0"}');
materialize(s.gameDir, s.frameworkSrc, s.dest);
// 框架侧补一份没有 uuid 的 .meta(模拟框架 meta 本身缺 uuid 的坏数据)
writeFileSync(join(s.dest, 'assets', 'framework', 'ui', 'atlas-hall', 'bg.png.meta'), '{"ver":"0.9.0"}');
let err;
assert.throws(() => {
try { composeSkin(s.dest); } catch (e) { err = e; throw e; }
}, SkinValidationError);
assert.equal(err.errors[0].code, 'meta-missing-uuid');
assert.match(err.errors[0].path, /bg\.png/, '错误须指向具体文件相对路径');
assert.match(err.errors[0].message, /demo/, '错误须指向子游戏名');
assert.match(err.errors[0].message, /uuid/, '错误须说明具体原因');
} finally { cleanup(s.root); }
});
test('composeSkin 例外通道:框架侧没有 .meta 时显式抛错(不得静默丢弃 override 的 meta)', () => {
const s = scaffold();
try {
// bg.png 在框架侧没有 .meta,但 override 给它带了一份 —— 之前的实现会静默跳过整段合并
put(s.gameDir, 'assets/game/override/atlas-hall/bg.png', SKINNED_BTN);
put(s.gameDir, 'assets/game/override/atlas-hall/bg.png.meta', '{"uuid":"game-own-uuid"}');
materialize(s.gameDir, s.frameworkSrc, s.dest);
let err;
assert.throws(() => {
try { composeSkin(s.dest); } catch (e) { err = e; throw e; }
}, SkinValidationError);
assert.equal(err.errors[0].code, 'meta-orphan');
assert.match(err.errors[0].path, /bg\.png/, '错误须指向具体文件相对路径');
assert.match(err.errors[0].message, /demo/, '错误须指向子游戏名');
assert.match(err.errors[0].message, /\.meta/, '错误须说明具体原因');
} finally { cleanup(s.root); }
});
test('composeSkin 校验不过时抛 SkinValidationError 且不写任何文件', () => {
const s = scaffold();
try {
@@ -1,9 +1,9 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { join } from 'node:path';
import { join, sep } from 'node:path';
import { writeFileSync, mkdirSync } from 'node:fs';
import { makeTempMonorepo, makeGameProject, cleanup } from './helpers.mjs';
import { readCreatorVersion, listGameProjects } from '../lib/paths.mjs';
import { readCreatorVersion, listGameProjects, BUILD_WORKSPACE, GAMES_DIR } from '../lib/paths.mjs';
test('readCreatorVersion 读取 package.json 的 creator.version', () => {
const mono = makeTempMonorepo();
@@ -42,3 +42,10 @@ test('listGameProjects 只列出含 package.json 的子目录', () => {
test('listGameProjects 对不存在的目录返回空数组', () => {
assert.deepEqual(listGameProjects('/no/such/dir'), []);
});
test('BUILD_WORKSPACE 不在 GAMES_DIR 之下(红线③:临时工程不得置于 games/ 下)', () => {
assert.ok(
BUILD_WORKSPACE !== GAMES_DIR && !BUILD_WORKSPACE.startsWith(GAMES_DIR + sep),
`BUILD_WORKSPACE(${BUILD_WORKSPACE}) 不应落在 GAMES_DIR(${GAMES_DIR}) 之下`,
);
});
@@ -4,7 +4,7 @@ import { join } from 'node:path';
import { mkdirSync, writeFileSync, rmSync, mkdtempSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { deflateSync } from 'node:zlib';
import { listAssets, readPngSize, validateOverrides } from '../lib/skin.mjs';
import { listAssets, listCoverableAssets, readPngSize, validateOverrides } from '../lib/skin.mjs';
// ---- 测试辅助:零依赖生成指定尺寸的 PNG ----
function crc32(buf) {
@@ -59,6 +59,36 @@ test('listAssets 对不存在的目录返回空数组', () => {
assert.deepEqual(listAssets('/no/such/dir'), []);
});
// ---- listCoverableAssets:Important 3——可覆盖范围须限定为 spec §2 规则 3 的 8 个目录 ----
test('listCoverableAssets 排除 theme/ 与 README.md,只保留 8 个白名单目录下的资源', () => {
const d = makeDirs();
try {
png(d.fw, 'atlas-hall/btn.png');
writeFile(d.fw, 'README.md', '# framework ui');
writeFile(d.fw, 'theme/default-theme.ts', 'export const DEFAULT_THEME = {};');
writeFile(d.fw, 'theme/types.ts', 'export interface ThemeConfig {}');
const covered = listCoverableAssets(d.fw).sort();
assert.deepEqual(covered, ['atlas-hall/btn.png']);
assert.ok(!covered.some((f) => f.startsWith('theme/')), 'theme/ 下的框架代码不属于可覆盖资源');
assert.ok(!covered.includes('README.md'), 'README.md 不是可覆盖资源');
} finally { cleanup(d.root); }
});
test('validateOverrides:override 放一个 .ts 到 theme/ 会被判为 orphan(不在可覆盖范围内)', () => {
const d = makeDirs();
try {
png(d.fw, 'atlas-hall/btn.png');
writeFile(d.fw, 'theme/resolve-theme.ts', 'export function resolveTheme() {}');
// 子游戏企图覆盖框架代码
writeFile(d.ov, 'theme/resolve-theme.ts', 'export function resolveTheme() { /* hijacked */ }');
const r = validateOverrides(d.fw, d.ov);
assert.equal(r.errors.length, 1);
assert.equal(r.errors[0].code, 'orphan');
assert.equal(r.errors[0].path, 'theme/resolve-theme.ts');
assert.equal(r.matched.length, 0, 'theme 覆盖不应被当作合法命中');
} finally { cleanup(d.root); }
});
// ---- readPngSize ----
test('readPngSize 读出宽高;非 PNG 返回 null', () => {
const d = makeDirs();
@@ -269,9 +269,13 @@ npm run build-game <name> [--platform android]
3. 皮肤合成 assets/game/override/** 覆盖到 assets/framework/ui/**
按 §3.2 校验;任一不合法即中止;打印命中统计
4. 构建 调 Cocos CLI 在临时工程上构建
5. 产出 拷至 dist/<name>/,写 build-info.json
5. 产出 写 build-info.json(拷至 dist/<name>/ 本期未实现,见下)
```
> **`dist/<name>/` 拷贝:本期未实现。** `build-game.mjs` 目前止步于 `build-workspace/<name>/`
> 内调用 Cocos CLI 构建 + 写 `build-info.json`;把构建产物拷到独立的 `dist/<name>/` 尚未落地
> (构建产物的实际目录结构未经真实构建验证,贸然实现拷贝逻辑等于凭空猜测路径)。
### 5.1 四条硬规则
**① 绝不在框架真源上做合成。**