# Monorepo 骨架与工具链 Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** 搭建 Cocos Creator 子游戏 monorepo 的目录骨架与工具链脚本,做到「一键 `new-game` 生成挂好 framework junction、共享框架真源的子游戏工程」,消灭旧架构「拷贝整份模板」的痛点。 **Architecture:** 框架真源唯一存放于 `cocoscreator_projects/YouleNexus/assets/framework`;各子游戏工程在 `cocoscreator_projects/games//` 下,通过 Windows junction 把 `assets/framework` 链接到真源实现即时共享。工具链是一组纯 Node ESM 脚本(`setup-links`/`new-game`/`check-cocos-version`/`bump-cocos`),核心逻辑抽到可单测的 `lib/` 纯函数,CLI 入口仅做参数解析与输出。 **Tech Stack:** Node.js 20(ESM `.mjs`)、`node:test` + `node:assert` 内置测试、`node:fs`/`node:crypto`/`node:url` 标准库;无第三方依赖。 > 本计划遵循 spec `docs/superpowers/specs/2026-06-28-cocos-framework-design.md` 的 §2、§7、§8 与 §0.1(协议 SSOT:脚手架产物只引用 `docs/protocol`,不内联协议细节)。 --- ## 目录与文件结构(本计划建立) ``` cocoscreator_projects/ ├─ package.json # monorepo 工具层(type:module + npm scripts) ├─ YouleNexus/ # 已存在(框架宿主工程) │ └─ assets/framework/ # 框架真源占位结构(本计划建 6 层空目录 + README) ├─ games/ # 子游戏工程根(本计划建,含 .gitkeep) ├─ templates/game-seed/ # 种子工程(Task 7 从 YouleNexus 派生) └─ scripts/ ├─ lib/ │ ├─ paths.mjs # 路径常量 + 列工程 + 读 creator.version │ ├─ junction.mjs # junction 创建/检测(跨平台) │ └─ scaffold.mjs # 复制种子 / 改身份 / 铺 game 骨架 ├─ setup-links.mjs # 遍历 games/* 重建 framework junction ├─ new-game.mjs # 一键新建子游戏工程 ├─ check-cocos-version.mjs # 校验各工程 creator.version 一致 ├─ bump-cocos.mjs # 批量改 creator.version ├─ README.md # 工具链使用说明 └─ test/ ├─ helpers.mjs # 临时 monorepo fixture 构造 ├─ paths.test.mjs ├─ junction.test.mjs ├─ setup-links.test.mjs ├─ scaffold.test.mjs ├─ new-game.test.mjs ├─ check-cocos-version.test.mjs └─ bump-cocos.test.mjs ``` **所有命令的工作目录均为 `G:/Works/YouleGamesCocosCreator/cocoscreator_projects`**(下称 monorepo 根),除非另行说明。 --- ### Task 1: monorepo 工具层脚手架 **Files:** - Create: `cocoscreator_projects/package.json` - Create: `cocoscreator_projects/games/.gitkeep` - Create: `cocoscreator_projects/YouleNexus/assets/framework/README.md` - Create: `cocoscreator_projects/YouleNexus/assets/framework/{core,net,protocol,platform,ui,sdk}/.gitkeep` - Modify: `.gitignore`(仓库根,修正 games 路径前缀) - [ ] **Step 1: 建立工具层 package.json** 创建 `cocoscreator_projects/package.json`: ```json { "name": "youle-monorepo-tools", "private": true, "type": "module", "version": "0.1.0", "scripts": { "test": "node --test scripts/test/", "setup-links": "node scripts/setup-links.mjs", "new-game": "node scripts/new-game.mjs", "check-cocos": "node scripts/check-cocos-version.mjs", "bump-cocos": "node scripts/bump-cocos.mjs" } } ``` - [ ] **Step 2: 建立框架真源占位结构** 创建 `cocoscreator_projects/YouleNexus/assets/framework/README.md`: ```markdown # framework — 框架真源(唯一权威副本) 各子游戏工程的 `assets/framework` 是指向**本目录**的 junction(见 `scripts/setup-links.mjs`)。 六层结构(依赖严格单向,详见 spec §3):core / net / protocol / platform / ui / sdk。 协议相关实现一律以 `docs/protocol/` 为唯一信息源(spec §0.1),不在代码或注释中内联复制协议字段。 ``` 为六层各建一个 `.gitkeep` 空文件占位: `core/.gitkeep`、`net/.gitkeep`、`protocol/.gitkeep`、`platform/.gitkeep`、`ui/.gitkeep`、`sdk/.gitkeep`。 创建 `cocoscreator_projects/games/.gitkeep`(空文件,使空目录可入库)。 - [ ] **Step 3: 修正仓库根 .gitignore 的 games 路径** `games/` 实际位于 `cocoscreator_projects/games/`,修正 `.gitignore` 中的链接忽略规则。 将 `.gitignore` 中这两行: ``` games/*/assets/framework games/*/assets/framework.meta ``` 替换为: ``` cocoscreator_projects/games/*/assets/framework cocoscreator_projects/games/*/assets/framework.meta ``` - [ ] **Step 4: 验证 .gitignore 行为** Run: `cd G:/Works/YouleGamesCocosCreator && git check-ignore cocoscreator_projects/games/demo/assets/framework` Expected: 输出 `cocoscreator_projects/games/demo/assets/framework`(命中规则即正确) - [ ] **Step 5: Commit** ```bash cd G:/Works/YouleGamesCocosCreator git add cocoscreator_projects/package.json cocoscreator_projects/games/.gitkeep cocoscreator_projects/YouleNexus/assets/framework .gitignore git commit -m "chore(monorepo): 工具层脚手架 + 框架真源占位结构 Co-Authored-By: Claude Opus 4.8 (1M context) " ``` --- ### Task 2: `lib/paths.mjs` — 路径解析与工程发现 **Files:** - Create: `cocoscreator_projects/scripts/test/helpers.mjs` - Create: `cocoscreator_projects/scripts/test/paths.test.mjs` - Create: `cocoscreator_projects/scripts/lib/paths.mjs` - [ ] **Step 1: 写测试 fixture 辅助 + 失败测试** 创建 `scripts/test/helpers.mjs`: ```js import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; // 构造一个临时 monorepo:YouleNexus(host) + framework 真源 + 空 games/ export function makeTempMonorepo() { const root = mkdtempSync(join(tmpdir(), 'youle-mono-')); const host = join(root, 'YouleNexus'); const framework = join(host, 'assets', 'framework'); mkdirSync(framework, { recursive: true }); writeFileSync( join(host, 'package.json'), JSON.stringify({ name: 'YouleNexus', uuid: 'host-uuid', creator: { version: '3.8.8' } }, null, 2) ); writeFileSync(join(framework, 'README.md'), '# framework src\n'); const games = join(root, 'games'); mkdirSync(games, { recursive: true }); return { root, host, framework, games }; } // 在 gamesDir 下造一个子游戏工程(含 package.json) export function makeGameProject(gamesDir, name, version = '3.8.8') { const dir = join(gamesDir, name); mkdirSync(join(dir, 'assets'), { recursive: true }); writeFileSync( join(dir, 'package.json'), JSON.stringify({ name, uuid: `${name}-uuid`, creator: { version } }, null, 2) ); return dir; } // 造一个最小种子工程 export function makeSeed(root) { const seed = join(root, 'templates', 'game-seed'); mkdirSync(join(seed, 'assets'), { recursive: true }); mkdirSync(join(seed, 'settings'), { recursive: true }); writeFileSync( join(seed, 'package.json'), JSON.stringify({ name: 'game-seed', uuid: 'seed-uuid', creator: { version: '3.8.8' } }, null, 2) ); writeFileSync(join(seed, 'settings', 'v2.json'), '{}\n'); return seed; } export function cleanup(root) { rmSync(root, { recursive: true, force: true }); } ``` 创建 `scripts/test/paths.test.mjs`: ```js import { test } from 'node:test'; import assert from 'node:assert/strict'; import { join } from 'node:path'; import { writeFileSync, mkdirSync } from 'node:fs'; import { makeTempMonorepo, makeGameProject, cleanup } from './helpers.mjs'; import { readCreatorVersion, listGameProjects } from '../lib/paths.mjs'; test('readCreatorVersion 读取 package.json 的 creator.version', () => { const mono = makeTempMonorepo(); try { assert.equal(readCreatorVersion(mono.host), '3.8.8'); } finally { cleanup(mono.root); } }); test('readCreatorVersion 缺字段返回 null', () => { const mono = makeTempMonorepo(); try { const dir = makeGameProject(mono.games, 'nover'); // 覆写成无 creator 字段 writeFileSync(join(dir, 'package.json'), JSON.stringify({ name: 'nover' })); assert.equal(readCreatorVersion(dir), null); } finally { cleanup(mono.root); } }); test('listGameProjects 只列出含 package.json 的子目录', () => { const mono = makeTempMonorepo(); try { makeGameProject(mono.games, 'doudizhu'); makeGameProject(mono.games, 'niuniu'); mkdirSync(join(mono.games, 'not-a-project')); // 无 package.json,应被忽略 const found = listGameProjects(mono.games).map((p) => p.split(/[\\/]/).pop()).sort(); assert.deepEqual(found, ['doudizhu', 'niuniu']); } finally { cleanup(mono.root); } }); test('listGameProjects 对不存在的目录返回空数组', () => { assert.deepEqual(listGameProjects('/no/such/dir'), []); }); ``` - [ ] **Step 2: 运行测试确认失败** Run: `node --test scripts/test/paths.test.mjs` Expected: FAIL — 报 `Cannot find module '../lib/paths.mjs'` - [ ] **Step 3: 实现 `lib/paths.mjs`** 创建 `scripts/lib/paths.mjs`: ```js import { fileURLToPath } from 'node:url'; import { dirname, join, resolve } from 'node:path'; import { readFileSync, readdirSync, existsSync } from 'node:fs'; // 本文件位于 /scripts/lib/paths.mjs → 上溯两级为 monorepo 根 export const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..'); 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 function readCreatorVersion(projectDir) { try { const pkg = JSON.parse(readFileSync(join(projectDir, 'package.json'), 'utf8')); return pkg?.creator?.version ?? null; } catch { return null; } } export function listGameProjects(gamesDir = GAMES_DIR) { if (!existsSync(gamesDir)) return []; return readdirSync(gamesDir, { withFileTypes: true }) .filter((d) => d.isDirectory()) .map((d) => join(gamesDir, d.name)) .filter((dir) => existsSync(join(dir, 'package.json'))); } ``` - [ ] **Step 4: 运行测试确认通过** Run: `node --test scripts/test/paths.test.mjs` Expected: PASS — 4 tests passed - [ ] **Step 5: Commit** ```bash cd G:/Works/YouleGamesCocosCreator git add cocoscreator_projects/scripts/lib/paths.mjs cocoscreator_projects/scripts/test/helpers.mjs cocoscreator_projects/scripts/test/paths.test.mjs git commit -m "feat(tools): paths.mjs 路径解析与工程发现 Co-Authored-By: Claude Opus 4.8 (1M context) " ``` --- ### Task 3: `lib/junction.mjs` — junction 创建与检测 **Files:** - Create: `cocoscreator_projects/scripts/test/junction.test.mjs` - Create: `cocoscreator_projects/scripts/lib/junction.mjs` - [ ] **Step 1: 写失败测试** 创建 `scripts/test/junction.test.mjs`: ```js import { test } from 'node:test'; import assert from 'node:assert/strict'; import { join } from 'node:path'; import { existsSync, readFileSync, writeFileSync } from 'node:fs'; import { makeTempMonorepo, cleanup } from './helpers.mjs'; import { ensureJunction, isLink } from '../lib/junction.mjs'; test('ensureJunction 创建链接后可经链接读到真源文件', () => { const mono = makeTempMonorepo(); try { const link = join(mono.games, 'demo-framework'); const status = ensureJunction(link, mono.framework); assert.equal(status, 'created'); assert.ok(isLink(link)); assert.equal(readFileSync(join(link, 'README.md'), 'utf8'), '# framework src\n'); } finally { cleanup(mono.root); } }); test('ensureJunction 对已存在链接返回 exists 且不抛错', () => { const mono = makeTempMonorepo(); try { const link = join(mono.games, 'demo-framework'); ensureJunction(link, mono.framework); assert.equal(ensureJunction(link, mono.framework), 'exists'); } finally { cleanup(mono.root); } }); test('ensureJunction 目标不存在时抛错', () => { const mono = makeTempMonorepo(); try { assert.throws(() => ensureJunction(join(mono.games, 'x'), join(mono.root, 'no-such')), /target 不存在/); } finally { cleanup(mono.root); } }); test('ensureJunction 拒绝覆盖已存在的真实目录/文件', () => { const mono = makeTempMonorepo(); try { const link = join(mono.games, 'realfile'); writeFileSync(link, 'x'); assert.throws(() => ensureJunction(link, mono.framework), /非链接/); } finally { cleanup(mono.root); } }); ``` - [ ] **Step 2: 运行测试确认失败** Run: `node --test scripts/test/junction.test.mjs` Expected: FAIL — 报 `Cannot find module '../lib/junction.mjs'` - [ ] **Step 3: 实现 `lib/junction.mjs`** 创建 `scripts/lib/junction.mjs`: ```js import { symlinkSync, lstatSync, existsSync } from 'node:fs'; import { resolve } from 'node:path'; import { platform } from 'node:process'; // Windows 用 junction(免管理员、仅限目录);其它平台用 dir 符号链接 const LINK_TYPE = platform === 'win32' ? 'junction' : 'dir'; export function isLink(p) { try { return lstatSync(p).isSymbolicLink(); } catch { return false; } } // 在 linkPath 建一个指向 targetPath 的目录链接。 // 返回 'created' | 'exists';目标不存在或 linkPath 已被真实文件占用则抛错。 export function ensureJunction(linkPath, targetPath) { const target = resolve(targetPath); // junction 要求绝对目标 if (!existsSync(target)) { throw new Error(`junction target 不存在: ${target}`); } if (isLink(linkPath)) return 'exists'; if (existsSync(linkPath)) { throw new Error(`路径已存在且非链接,拒绝覆盖: ${linkPath}`); } symlinkSync(target, linkPath, LINK_TYPE); return 'created'; } ``` - [ ] **Step 4: 运行测试确认通过** Run: `node --test scripts/test/junction.test.mjs` Expected: PASS — 4 tests passed > 注:若在非 Windows CI 上 `dir` symlink 需权限,本仓库目标平台为 Windows(junction 免管理员),开发机直接通过。 - [ ] **Step 5: Commit** ```bash cd G:/Works/YouleGamesCocosCreator git add cocoscreator_projects/scripts/lib/junction.mjs cocoscreator_projects/scripts/test/junction.test.mjs git commit -m "feat(tools): junction.mjs 跨平台目录链接创建/检测 Co-Authored-By: Claude Opus 4.8 (1M context) " ``` --- ### Task 4: `setup-links.mjs` — 批量重建 framework junction **Files:** - Create: `cocoscreator_projects/scripts/test/setup-links.test.mjs` - Create: `cocoscreator_projects/scripts/setup-links.mjs` - [ ] **Step 1: 写失败测试** 创建 `scripts/test/setup-links.test.mjs`: ```js import { test } from 'node:test'; import assert from 'node:assert/strict'; import { join } from 'node:path'; import { readFileSync } from 'node:fs'; import { makeTempMonorepo, makeGameProject, cleanup } from './helpers.mjs'; import { setupLinks } from '../setup-links.mjs'; import { isLink } from '../lib/junction.mjs'; test('setupLinks 为每个子游戏工程建立 assets/framework junction', () => { const mono = makeTempMonorepo(); try { makeGameProject(mono.games, 'doudizhu'); makeGameProject(mono.games, 'niuniu'); const results = setupLinks(mono.games, mono.framework); assert.equal(results.length, 2); for (const r of results) { assert.equal(r.status, 'created'); assert.ok(isLink(r.link)); assert.equal(readFileSync(join(r.link, 'README.md'), 'utf8'), '# framework src\n'); } } finally { cleanup(mono.root); } }); test('setupLinks 二次运行幂等(已存在则 exists)', () => { const mono = makeTempMonorepo(); try { makeGameProject(mono.games, 'doudizhu'); setupLinks(mono.games, mono.framework); const second = setupLinks(mono.games, mono.framework); assert.equal(second[0].status, 'exists'); } finally { cleanup(mono.root); } }); ``` - [ ] **Step 2: 运行测试确认失败** Run: `node --test scripts/test/setup-links.test.mjs` Expected: FAIL — 报 `Cannot find module '../setup-links.mjs'` - [ ] **Step 3: 实现 `setup-links.mjs`** 创建 `scripts/setup-links.mjs`: ```js #!/usr/bin/env node import { join } from 'node:path'; import { mkdirSync, existsSync } from 'node:fs'; import { pathToFileURL } from 'node:url'; import { GAMES_DIR, FRAMEWORK_SRC, listGameProjects } from './lib/paths.mjs'; import { ensureJunction } from './lib/junction.mjs'; // 为 gamesDir 下每个工程在 assets/framework 建指向 frameworkSrc 的 junction export function setupLinks(gamesDir, frameworkSrc) { const results = []; for (const proj of listGameProjects(gamesDir)) { const assetsDir = join(proj, 'assets'); if (!existsSync(assetsDir)) mkdirSync(assetsDir, { recursive: true }); const link = join(assetsDir, 'framework'); const status = ensureJunction(link, frameworkSrc); results.push({ project: proj, link, status }); } return results; } // CLI 入口 if (import.meta.url === pathToFileURL(process.argv[1]).href) { const results = setupLinks(GAMES_DIR, FRAMEWORK_SRC); if (results.length === 0) { console.log('games/ 下暂无子游戏工程。'); } for (const r of results) { console.log(`[${r.status}] ${r.link}`); } } ``` - [ ] **Step 4: 运行测试确认通过** Run: `node --test scripts/test/setup-links.test.mjs` Expected: PASS — 2 tests passed - [ ] **Step 5: Commit** ```bash cd G:/Works/YouleGamesCocosCreator git add cocoscreator_projects/scripts/setup-links.mjs cocoscreator_projects/scripts/test/setup-links.test.mjs git commit -m "feat(tools): setup-links 批量重建 framework junction Co-Authored-By: Claude Opus 4.8 (1M context) " ``` --- ### Task 5: `lib/scaffold.mjs` — 复制种子 / 改身份 / 铺 game 骨架 **Files:** - Create: `cocoscreator_projects/scripts/test/scaffold.test.mjs` - Create: `cocoscreator_projects/scripts/lib/scaffold.mjs` - [ ] **Step 1: 写失败测试** 创建 `scripts/test/scaffold.test.mjs`: ```js import { test } from 'node:test'; import assert from 'node:assert/strict'; import { join } from 'node:path'; import { existsSync, readFileSync, mkdirSync, writeFileSync } from 'node:fs'; import { makeTempMonorepo, makeSeed, cleanup } from './helpers.mjs'; import { copySeed, setProjectIdentity, scaffoldGameAssets } from '../lib/scaffold.mjs'; test('copySeed 复制种子并清理缓存目录', () => { const mono = makeTempMonorepo(); try { const seed = makeSeed(mono.root); mkdirSync(join(seed, 'library'), { recursive: true }); // 模拟缓存 writeFileSync(join(seed, 'library', 'x'), '1'); const dest = join(mono.games, 'demo'); copySeed(seed, dest); assert.ok(existsSync(join(dest, 'package.json'))); assert.ok(existsSync(join(dest, 'settings', 'v2.json'))); assert.ok(!existsSync(join(dest, 'library')), 'library 应被清理'); } finally { cleanup(mono.root); } }); test('copySeed 拒绝覆盖已存在目标', () => { const mono = makeTempMonorepo(); try { const seed = makeSeed(mono.root); const dest = join(mono.games, 'demo'); copySeed(seed, dest); assert.throws(() => copySeed(seed, dest), /已存在/); } finally { cleanup(mono.root); } }); test('setProjectIdentity 改 name 并生成新 uuid', () => { const mono = makeTempMonorepo(); try { const seed = makeSeed(mono.root); const dest = join(mono.games, 'demo'); copySeed(seed, dest); const pkg = setProjectIdentity(dest, 'doudizhu'); assert.equal(pkg.name, 'doudizhu'); assert.notEqual(pkg.uuid, 'seed-uuid'); assert.match(pkg.uuid, /^[0-9a-f-]{36}$/); const onDisk = JSON.parse(readFileSync(join(dest, 'package.json'), 'utf8')); assert.equal(onDisk.name, 'doudizhu'); assert.equal(onDisk.creator.version, '3.8.8'); // 引擎版本保留 } finally { cleanup(mono.root); } }); test('scaffoldGameAssets 铺出 game 骨架(GameModule/theme/override)', () => { const mono = makeTempMonorepo(); try { const seed = makeSeed(mono.root); const dest = join(mono.games, 'demo'); copySeed(seed, dest); scaffoldGameAssets(dest, 'doudizhu'); const gm = readFileSync(join(dest, 'assets', 'game', 'GameModule.ts'), 'utf8'); assert.match(gm, /doudizhu/); assert.match(gm, /docs\/protocol/); // 提醒协议 SSOT assert.ok(existsSync(join(dest, 'assets', 'game', 'theme.ts'))); assert.ok(existsSync(join(dest, 'assets', 'game', 'override', '.gitkeep'))); } finally { cleanup(mono.root); } }); ``` - [ ] **Step 2: 运行测试确认失败** Run: `node --test scripts/test/scaffold.test.mjs` Expected: FAIL — 报 `Cannot find module '../lib/scaffold.mjs'` - [ ] **Step 3: 实现 `lib/scaffold.mjs`** 创建 `scripts/lib/scaffold.mjs`: ```js import { cpSync, rmSync, mkdirSync, writeFileSync, readFileSync, existsSync } from 'node:fs'; import { join } from 'node:path'; import { randomUUID } from 'node:crypto'; const CACHE_DIRS = ['library', 'temp', 'local', 'build']; // 复制种子工程到 destDir,并清理可能残留的缓存目录 export function copySeed(seedDir, destDir) { if (existsSync(destDir)) throw new Error(`目标已存在: ${destDir}`); cpSync(seedDir, destDir, { recursive: true }); for (const c of CACHE_DIRS) { rmSync(join(destDir, c), { recursive: true, force: true }); } } // 修改工程 package.json 的 name 并分配全新 uuid(保留 creator.version 等其它字段) export function setProjectIdentity(projectDir, name) { const pkgPath = join(projectDir, 'package.json'); const pkg = JSON.parse(readFileSync(pkgPath, 'utf8')); pkg.name = name; pkg.uuid = randomUUID(); writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n'); return pkg; } // 铺出子游戏私有 game/ 骨架:IGameModule 占位 + theme 占位 + override 目录 export function scaffoldGameAssets(projectDir, name) { const gameDir = join(projectDir, 'assets', 'game'); mkdirSync(join(gameDir, 'override'), { recursive: true }); writeFileSync(join(gameDir, 'override', '.gitkeep'), ''); writeFileSync(join(gameDir, 'GameModule.ts'), gameModuleStub(name)); writeFileSync(join(gameDir, 'theme.ts'), themeStub(name)); } function gameModuleStub(name) { return `/** * ${name} 子游戏模块骨架。 * TODO(Plan 5): 待 framework/sdk 的 IGameModule / GameContext 接口定稿后实现 * (route / onEnter / onReceive / onReconnect 等)。 * 协议字段一律参见 docs/protocol/(spec §0.1:只引用、不内联复制)。 */ export const GAME_ROUTE = '${name}'; `; } function themeStub(name) { return `/** * ${name} 主题覆盖骨架。 * TODO(Plan 4): 待 framework/ui 的皮肤变量清单定稿后填写主色/字体/图集映射等。 */ export const theme = {}; `; } ``` - [ ] **Step 4: 运行测试确认通过** Run: `node --test scripts/test/scaffold.test.mjs` Expected: PASS — 4 tests passed - [ ] **Step 5: Commit** ```bash cd G:/Works/YouleGamesCocosCreator git add cocoscreator_projects/scripts/lib/scaffold.mjs cocoscreator_projects/scripts/test/scaffold.test.mjs git commit -m "feat(tools): scaffold.mjs 种子复制/改身份/铺 game 骨架 Co-Authored-By: Claude Opus 4.8 (1M context) " ``` --- ### Task 6: `new-game.mjs` — 一键新建子游戏工程 **Files:** - Create: `cocoscreator_projects/scripts/test/new-game.test.mjs` - Create: `cocoscreator_projects/scripts/new-game.mjs` - [ ] **Step 1: 写失败测试** 创建 `scripts/test/new-game.test.mjs`: ```js import { test } from 'node:test'; import assert from 'node:assert/strict'; import { join } from 'node:path'; import { existsSync, readFileSync } from 'node:fs'; import { makeTempMonorepo, makeSeed, cleanup } from './helpers.mjs'; import { newGame } from '../new-game.mjs'; import { isLink } from '../lib/junction.mjs'; test('newGame 生成工程:改身份 + 挂 framework junction + 铺 game 骨架', () => { const mono = makeTempMonorepo(); try { const seed = makeSeed(mono.root); const result = newGame('doudizhu', { seedDir: seed, gamesDir: mono.games, frameworkSrc: mono.framework }); const dest = join(mono.games, 'doudizhu'); assert.equal(result.projectDir, dest); // 身份 const pkg = JSON.parse(readFileSync(join(dest, 'package.json'), 'utf8')); assert.equal(pkg.name, 'doudizhu'); assert.notEqual(pkg.uuid, 'seed-uuid'); // junction 指向真源 const link = join(dest, 'assets', 'framework'); assert.ok(isLink(link)); assert.equal(readFileSync(join(link, 'README.md'), 'utf8'), '# framework src\n'); // game 骨架 assert.ok(existsSync(join(dest, 'assets', 'game', 'GameModule.ts'))); } finally { cleanup(mono.root); } }); test('newGame 拒绝重名工程', () => { const mono = makeTempMonorepo(); try { const seed = makeSeed(mono.root); const opts = { seedDir: seed, gamesDir: mono.games, frameworkSrc: mono.framework }; newGame('doudizhu', opts); assert.throws(() => newGame('doudizhu', opts), /已存在/); } finally { cleanup(mono.root); } }); test('newGame 校验名称非空且合法', () => { const mono = makeTempMonorepo(); try { const seed = makeSeed(mono.root); const opts = { seedDir: seed, gamesDir: mono.games, frameworkSrc: mono.framework }; assert.throws(() => newGame('', opts), /名称/); assert.throws(() => newGame('Bad Name!', opts), /名称/); } finally { cleanup(mono.root); } }); ``` - [ ] **Step 2: 运行测试确认失败** Run: `node --test scripts/test/new-game.test.mjs` Expected: FAIL — 报 `Cannot find module '../new-game.mjs'` - [ ] **Step 3: 实现 `new-game.mjs`** 创建 `scripts/new-game.mjs`: ```js #!/usr/bin/env node import { join } from 'node:path'; import { pathToFileURL } from 'node:url'; import { SEED_DIR, GAMES_DIR, FRAMEWORK_SRC } from './lib/paths.mjs'; import { copySeed, setProjectIdentity, scaffoldGameAssets } from './lib/scaffold.mjs'; import { ensureJunction } from './lib/junction.mjs'; const NAME_RE = /^[a-z0-9][a-z0-9_-]*$/; // 新建一款子游戏工程。opts 可注入路径用于测试。 export function newGame(name, opts = {}) { const seedDir = opts.seedDir ?? SEED_DIR; const gamesDir = opts.gamesDir ?? GAMES_DIR; const frameworkSrc = opts.frameworkSrc ?? FRAMEWORK_SRC; if (!name || !NAME_RE.test(name)) { throw new Error(`非法子游戏名称: "${name}"(仅限小写字母/数字/-/_,且以字母数字开头)`); } const projectDir = join(gamesDir, name); copySeed(seedDir, projectDir); // 目标已存在会抛错 setProjectIdentity(projectDir, name); scaffoldGameAssets(projectDir, name); const link = join(projectDir, 'assets', 'framework'); ensureJunction(link, frameworkSrc); return { projectDir, link }; } // CLI 入口 if (import.meta.url === pathToFileURL(process.argv[1]).href) { const name = process.argv[2]; if (!name) { console.error('用法: npm run new-game '); process.exit(1); } const { projectDir } = newGame(name); console.log(`已创建子游戏工程: ${projectDir}`); console.log(`下一步: 用 Cocos Creator 打开该目录`); } ``` - [ ] **Step 4: 运行测试确认通过** Run: `node --test scripts/test/new-game.test.mjs` Expected: PASS — 3 tests passed - [ ] **Step 5: Commit** ```bash cd G:/Works/YouleGamesCocosCreator git add cocoscreator_projects/scripts/new-game.mjs cocoscreator_projects/scripts/test/new-game.test.mjs git commit -m "feat(tools): new-game 一键新建子游戏工程 Co-Authored-By: Claude Opus 4.8 (1M context) " ``` --- ### Task 7: 建立真实种子工程 `templates/game-seed` > 数据准备任务(非 TDD):从 YouleNexus 派生一个最小可用的空 Cocos 工程作为种子。new-game 的逻辑已在 Task 6 用 fixture 种子验证;本任务产出运行时使用的真实种子。 **Files:** - Create: `cocoscreator_projects/templates/game-seed/`(从 YouleNexus 派生) - [ ] **Step 1: 从 YouleNexus 复制工程级文件(排除业务与缓存)** Run(在 monorepo 根执行;只复制工程配置,排除 assets 业务内容、缓存、扩展、framework): ```bash cd G:/Works/YouleGamesCocosCreator/cocoscreator_projects mkdir -p templates/game-seed cp -r YouleNexus/.creator templates/game-seed/ 2>/dev/null || true cp -r YouleNexus/settings templates/game-seed/ 2>/dev/null || true cp YouleNexus/package.json templates/game-seed/package.json mkdir -p templates/game-seed/assets ``` - [ ] **Step 2: 把种子 package.json 的 name 改为 game-seed** 编辑 `templates/game-seed/package.json`,将 `"name": "YouleNexus"` 改为 `"name": "game-seed"`,保留 `creator.version`(`3.8.8`)。`uuid` 字段保持原值即可(new-game 会重新分配)。 - [ ] **Step 3: 校验种子是空且不含 framework/extensions/缓存** Run: ```bash cd G:/Works/YouleGamesCocosCreator/cocoscreator_projects ls templates/game-seed && echo "--- 不应出现 framework/extensions/library/temp ---" && \ ( [ ! -e templates/game-seed/assets/framework ] && [ ! -e templates/game-seed/extensions ] && \ [ ! -e templates/game-seed/library ] && echo "OK: 种子干净" || echo "ERROR: 种子含不应有的内容" ) ``` Expected: 打印 `OK: 种子干净` - [ ] **Step 4: 用真实种子做一次 new-game 冒烟(随后删除产物)** Run: ```bash cd G:/Works/YouleGamesCocosCreator/cocoscreator_projects node scripts/new-game.mjs smoke-demo && \ test -f games/smoke-demo/assets/game/GameModule.ts && \ node -e "const fs=require('fs');const t=fs.lstatSync('games/smoke-demo/assets/framework');console.log('framework isSymlink:', t.isSymbolicLink())" && \ rm -rf games/smoke-demo ``` Expected: 打印 `framework isSymlink: true`,无错误;产物已删除 - [ ] **Step 5: Commit** ```bash cd G:/Works/YouleGamesCocosCreator git add cocoscreator_projects/templates/game-seed git commit -m "chore(tools): 建立真实种子工程 templates/game-seed Co-Authored-By: Claude Opus 4.8 (1M context) " ``` --- ### Task 8: `check-cocos-version.mjs` — 引擎版本一致性校验 **Files:** - Create: `cocoscreator_projects/scripts/test/check-cocos-version.test.mjs` - Create: `cocoscreator_projects/scripts/check-cocos-version.mjs` - [ ] **Step 1: 写失败测试** 创建 `scripts/test/check-cocos-version.test.mjs`: ```js import { test } from 'node:test'; import assert from 'node:assert/strict'; import { makeTempMonorepo, makeGameProject, cleanup } from './helpers.mjs'; import { checkVersions } from '../check-cocos-version.mjs'; test('checkVersions 全部一致时 mismatches 为空', () => { const mono = makeTempMonorepo(); try { makeGameProject(mono.games, 'a', '3.8.8'); makeGameProject(mono.games, 'b', '3.8.8'); const r = checkVersions(mono.host, mono.games); assert.equal(r.expected, '3.8.8'); assert.deepEqual(r.mismatches, []); } finally { cleanup(mono.root); } }); test('checkVersions 报告与宿主不一致的工程', () => { const mono = makeTempMonorepo(); try { makeGameProject(mono.games, 'a', '3.8.8'); makeGameProject(mono.games, 'b', '3.8.6'); const r = checkVersions(mono.host, mono.games); assert.equal(r.mismatches.length, 1); assert.match(r.mismatches[0].project, /[\\/]b$/); assert.equal(r.mismatches[0].version, '3.8.6'); assert.equal(r.mismatches[0].expected, '3.8.8'); } finally { cleanup(mono.root); } }); ``` - [ ] **Step 2: 运行测试确认失败** Run: `node --test scripts/test/check-cocos-version.test.mjs` Expected: FAIL — 报 `Cannot find module '../check-cocos-version.mjs'` - [ ] **Step 3: 实现 `check-cocos-version.mjs`** 创建 `scripts/check-cocos-version.mjs`: ```js #!/usr/bin/env node import { pathToFileURL } from 'node:url'; import { HOST_PROJECT, GAMES_DIR, readCreatorVersion, listGameProjects } from './lib/paths.mjs'; // 比对各子游戏工程与宿主工程的 creator.version export function checkVersions(hostProject, gamesDir) { const expected = readCreatorVersion(hostProject); const mismatches = []; for (const proj of listGameProjects(gamesDir)) { const version = readCreatorVersion(proj); if (version !== expected) { mismatches.push({ project: proj, version, expected }); } } return { expected, mismatches }; } // CLI 入口:有不一致则退出码 1 if (import.meta.url === pathToFileURL(process.argv[1]).href) { const { expected, mismatches } = checkVersions(HOST_PROJECT, GAMES_DIR); console.log(`宿主 Cocos 版本: ${expected}`); if (mismatches.length === 0) { console.log('OK: 所有子游戏工程版本一致。'); process.exit(0); } console.error('版本不一致:'); for (const m of mismatches) { console.error(` ${m.project}: ${m.version}(应为 ${m.expected})`); } process.exit(1); } ``` - [ ] **Step 4: 运行测试确认通过** Run: `node --test scripts/test/check-cocos-version.test.mjs` Expected: PASS — 2 tests passed - [ ] **Step 5: Commit** ```bash cd G:/Works/YouleGamesCocosCreator git add cocoscreator_projects/scripts/check-cocos-version.mjs cocoscreator_projects/scripts/test/check-cocos-version.test.mjs git commit -m "feat(tools): check-cocos-version 引擎版本一致性校验 Co-Authored-By: Claude Opus 4.8 (1M context) " ``` --- ### Task 9: `bump-cocos.mjs` — 批量升级引擎版本字段 **Files:** - Create: `cocoscreator_projects/scripts/test/bump-cocos.test.mjs` - Create: `cocoscreator_projects/scripts/bump-cocos.mjs` - [ ] **Step 1: 写失败测试** 创建 `scripts/test/bump-cocos.test.mjs`: ```js import { test } from 'node:test'; import assert from 'node:assert/strict'; import { join } from 'node:path'; import { readFileSync, writeFileSync } from 'node:fs'; import { makeTempMonorepo, makeGameProject, cleanup } from './helpers.mjs'; import { bumpVersion } from '../bump-cocos.mjs'; test('bumpVersion 改写各工程 creator.version 并报告 from/to', () => { const mono = makeTempMonorepo(); try { const a = makeGameProject(mono.games, 'a', '3.8.8'); const changed = bumpVersion([mono.host, a], '3.8.9'); assert.equal(changed.length, 2); assert.equal(changed[1].from, '3.8.8'); assert.equal(changed[1].to, '3.8.9'); const pkgA = JSON.parse(readFileSync(join(a, 'package.json'), 'utf8')); assert.equal(pkgA.creator.version, '3.8.9'); const pkgHost = JSON.parse(readFileSync(join(mono.host, 'package.json'), 'utf8')); assert.equal(pkgHost.creator.version, '3.8.9'); } finally { cleanup(mono.root); } }); test('bumpVersion 对缺 creator 字段的工程补建后写入', () => { const mono = makeTempMonorepo(); try { const a = makeGameProject(mono.games, 'a', '3.8.8'); writeFileSync(join(a, 'package.json'), JSON.stringify({ name: 'a' })); bumpVersion([a], '3.8.9'); const pkgA = JSON.parse(readFileSync(join(a, 'package.json'), 'utf8')); assert.equal(pkgA.creator.version, '3.8.9'); } finally { cleanup(mono.root); } }); ``` - [ ] **Step 2: 运行测试确认失败** Run: `node --test scripts/test/bump-cocos.test.mjs` Expected: FAIL — 报 `Cannot find module '../bump-cocos.mjs'` - [ ] **Step 3: 实现 `bump-cocos.mjs`** 创建 `scripts/bump-cocos.mjs`: ```js #!/usr/bin/env node import { join } from 'node:path'; import { readFileSync, writeFileSync } from 'node:fs'; import { pathToFileURL } from 'node:url'; import { HOST_PROJECT, GAMES_DIR, listGameProjects } from './lib/paths.mjs'; // 批量把若干工程的 creator.version 改为 version,返回每个工程的 from/to export function bumpVersion(projectDirs, version) { const changed = []; for (const dir of projectDirs) { const pkgPath = join(dir, 'package.json'); const pkg = JSON.parse(readFileSync(pkgPath, 'utf8')); if (!pkg.creator) pkg.creator = {}; const from = pkg.creator.version ?? null; pkg.creator.version = version; writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n'); changed.push({ dir, from, to: version }); } return changed; } // CLI 入口:bump 宿主 + 所有子游戏到指定版本 if (import.meta.url === pathToFileURL(process.argv[1]).href) { const version = process.argv[2]; if (!version) { console.error('用法: npm run bump-cocos 例: npm run bump-cocos 3.8.9'); process.exit(1); } const targets = [HOST_PROJECT, ...listGameProjects(GAMES_DIR)]; const changed = bumpVersion(targets, version); for (const c of changed) { console.log(`${c.dir}: ${c.from} → ${c.to}`); } console.log('\n⚠️ 仍需用新版 Cocos Creator 逐个打开各工程执行迁移(library 重建)。'); } ``` - [ ] **Step 4: 运行测试确认通过** Run: `node --test scripts/test/bump-cocos.test.mjs` Expected: PASS — 2 tests passed - [ ] **Step 5: Commit** ```bash cd G:/Works/YouleGamesCocosCreator git add cocoscreator_projects/scripts/bump-cocos.mjs cocoscreator_projects/scripts/test/bump-cocos.test.mjs git commit -m "feat(tools): bump-cocos 批量升级引擎版本字段 Co-Authored-By: Claude Opus 4.8 (1M context) " ``` --- ### Task 10: 全量测试、端到端冒烟与工具链 README **Files:** - Create: `cocoscreator_projects/scripts/README.md` - [ ] **Step 1: 运行全部单测** Run: `cd G:/Works/YouleGamesCocosCreator/cocoscreator_projects && node --test scripts/test/` Expected: PASS — 全部测试通过(7 个测试文件,21 个用例) - [ ] **Step 2: 端到端冒烟(真实种子 → new-game → check-version → 清理)** Run: ```bash cd G:/Works/YouleGamesCocosCreator/cocoscreator_projects node scripts/new-game.mjs e2e-demo && \ node scripts/check-cocos-version.mjs && \ node scripts/setup-links.mjs && \ rm -rf games/e2e-demo && \ echo "E2E OK" ``` Expected: 依次打印新建工程路径、`OK: 所有子游戏工程版本一致。`、junction 状态、`E2E OK` - [ ] **Step 3: 编写工具链 README** 创建 `scripts/README.md`: ```markdown # Monorepo 工具链 所有命令在 `cocoscreator_projects/` 下用 `npm run