From f5673c33c8cfdc4555f7392aee95c1ac699a4802 Mon Sep 17 00:00:00 2001 From: Joywayer Date: Sun, 28 Jun 2026 11:46:19 +0800 Subject: [PATCH] =?UTF-8?q?docs(plan):=20Plan=201=20Monorepo=20=E9=AA=A8?= =?UTF-8?q?=E6=9E=B6=E4=B8=8E=E5=B7=A5=E5=85=B7=E9=93=BE=E5=AE=9E=E6=96=BD?= =?UTF-8?q?=E8=AE=A1=E5=88=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 10 个 TDD 任务:工具层脚手架、paths/junction/scaffold 库、 setup-links/new-game/check-cocos-version/bump-cocos 脚本、种子工程、端到端冒烟。 纯 Node ESM + node:test,无第三方依赖;遵循 spec §2/§7/§8/§0.1。 Co-Authored-By: Claude Opus 4.8 (1M context) --- .../2026-06-28-monorepo-scaffold-toolchain.md | 1178 +++++++++++++++++ 1 file changed, 1178 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-28-monorepo-scaffold-toolchain.md diff --git a/docs/superpowers/plans/2026-06-28-monorepo-scaffold-toolchain.md b/docs/superpowers/plans/2026-06-28-monorepo-scaffold-toolchain.md new file mode 100644 index 0000000..af00c67 --- /dev/null +++ b/docs/superpowers/plans/2026-06-28-monorepo-scaffold-toolchain.md @@ -0,0 +1,1178 @@ +# 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