diff --git a/cocoscreator_projects/YouleNexus/assets/framework/ui/README.md b/cocoscreator_projects/YouleNexus/assets/framework/ui/README.md index 9021a2e..f2fd41b 100644 --- a/cocoscreator_projects/YouleNexus/assets/framework/ui/README.md +++ b/cocoscreator_projects/YouleNexus/assets/framework/ui/README.md @@ -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`),会削弱保护。 ## 子游戏怎么换肤 diff --git a/cocoscreator_projects/scripts/build-game.mjs b/cocoscreator_projects/scripts/build-game.mjs index 6fd147b..b05e5a2 100644 --- a/cocoscreator_projects/scripts/build-game.mjs +++ b/cocoscreator_projects/scripts/build-game.mjs @@ -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 [-- --platform ](--platform 后必须跟平台名,如 android)'); + process.exit(1); + } + targetPlatform = val; + } try { const r = buildGame(name, targetPlatform ? { platform: targetPlatform } : {}); console.log(`皮肤命中 ${r.skin.matched} 处,框架可覆盖资源共 ${r.skin.frameworkTotal} 个`); diff --git a/cocoscreator_projects/scripts/check-skin.mjs b/cocoscreator_projects/scripts/check-skin.mjs index dab2786..cd91e1a 100644 --- a/cocoscreator_projects/scripts/check-skin.mjs +++ b/cocoscreator_projects/scripts/check-skin.mjs @@ -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 diff --git a/cocoscreator_projects/scripts/lib/materialize.mjs b/cocoscreator_projects/scripts/lib/materialize.mjs index e874b9f..085b28e 100644 --- a/cocoscreator_projects/scripts/lib/materialize.mjs +++ b/cocoscreator_projects/scripts/lib/materialize.mjs @@ -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 }; } diff --git a/cocoscreator_projects/scripts/lib/paths.mjs b/cocoscreator_projects/scripts/lib/paths.mjs index 2f8ab21..a24a51d 100644 --- a/cocoscreator_projects/scripts/lib/paths.mjs +++ b/cocoscreator_projects/scripts/lib/paths.mjs @@ -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')); diff --git a/cocoscreator_projects/scripts/lib/skin.mjs b/cocoscreator_projects/scripts/lib/skin.mjs index 7cb56a6..3e8f6c4 100644 --- a/cocoscreator_projects/scripts/lib/skin.mjs +++ b/cocoscreator_projects/scripts/lib/skin.mjs @@ -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); diff --git a/cocoscreator_projects/scripts/test/build-game.test.mjs b/cocoscreator_projects/scripts/test/build-game.test.mjs index 25afcce..4710aa2 100644 --- a/cocoscreator_projects/scripts/test/build-game.test.mjs +++ b/cocoscreator_projects/scripts/test/build-game.test.mjs @@ -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'); diff --git a/cocoscreator_projects/scripts/test/cli-entry.test.mjs b/cocoscreator_projects/scripts/test/cli-entry.test.mjs index 8ef6c25..1db2995 100644 --- a/cocoscreator_projects/scripts/test/cli-entry.test.mjs +++ b/cocoscreator_projects/scripts/test/cli-entry.test.mjs @@ -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)`, () => { diff --git a/cocoscreator_projects/scripts/test/materialize.test.mjs b/cocoscreator_projects/scripts/test/materialize.test.mjs index 6d2a5bd..fe9b434 100644 --- a/cocoscreator_projects/scripts/test/materialize.test.mjs +++ b/cocoscreator_projects/scripts/test/materialize.test.mjs @@ -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 { diff --git a/cocoscreator_projects/scripts/test/paths.test.mjs b/cocoscreator_projects/scripts/test/paths.test.mjs index aba17da..8ad0451 100644 --- a/cocoscreator_projects/scripts/test/paths.test.mjs +++ b/cocoscreator_projects/scripts/test/paths.test.mjs @@ -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}) 之下`, + ); +}); diff --git a/cocoscreator_projects/scripts/test/skin.test.mjs b/cocoscreator_projects/scripts/test/skin.test.mjs index 4022e3a..f6a30a3 100644 --- a/cocoscreator_projects/scripts/test/skin.test.mjs +++ b/cocoscreator_projects/scripts/test/skin.test.mjs @@ -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(); diff --git a/docs/superpowers/specs/2026-08-27-ui-asset-and-skin-design.md b/docs/superpowers/specs/2026-08-27-ui-asset-and-skin-design.md index 123b1b3..5244cd6 100644 --- a/docs/superpowers/specs/2026-08-27-ui-asset-and-skin-design.md +++ b/docs/superpowers/specs/2026-08-27-ui-asset-and-skin-design.md @@ -269,9 +269,13 @@ npm run build-game [--platform android] 3. 皮肤合成 assets/game/override/** 覆盖到 assets/framework/ui/** 按 §3.2 校验;任一不合法即中止;打印命中统计 4. 构建 调 Cocos CLI 在临时工程上构建 -5. 产出 拷至 dist//,写 build-info.json +5. 产出 写 build-info.json(拷至 dist// 本期未实现,见下) ``` +> **`dist//` 拷贝:本期未实现。** `build-game.mjs` 目前止步于 `build-workspace//` +> 内调用 Cocos CLI 构建 + 写 `build-info.json`;把构建产物拷到独立的 `dist//` 尚未落地 +> (构建产物的实际目录结构未经真实构建验证,贸然实现拷贝逻辑等于凭空猜测路径)。 + ### 5.1 四条硬规则 **① 绝不在框架真源上做合成。**