Files
youle_cocos/docs/superpowers/plans/2026-06-28-monorepo-scaffold-toolchain.md
T
joywayerandClaude Opus 4.8 f5673c33c8 docs(plan): Plan 1 Monorepo 骨架与工具链实施计划
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) <noreply@anthropic.com>
2026-06-28 11:46:19 +08:00

1179 lines
40 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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/<name>/` 下,通过 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) <noreply@anthropic.com>"
```
---
### 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';
// 本文件位于 <monorepo>/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) <noreply@anthropic.com>"
```
---
### 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) <noreply@anthropic.com>"
```
---
### 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) <noreply@anthropic.com>"
```
---
### 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) <noreply@anthropic.com>"
```
---
### 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 <name>');
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) <noreply@anthropic.com>"
```
---
### 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) <noreply@anthropic.com>"
```
---
### 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) <noreply@anthropic.com>"
```
---
### 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 <version> 例: 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) <noreply@anthropic.com>"
```
---
### 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 <script>` 执行。
| 命令 | 作用 |
|------|------|
| `npm run new-game <name>` | 从 `templates/game-seed` 克隆一个子游戏工程到 `games/<name>`,重置身份、铺 `game/` 骨架、挂 framework junction |
| `npm run setup-links` | 为 `games/*` 重建 `assets/framework` junction(clone 仓库后必跑一次) |
| `npm run check-cocos` | 校验所有子游戏工程的 `creator.version` 与宿主 `YouleNexus` 一致(不一致退出码 1) |
| `npm run bump-cocos <version>` | 批量改宿主+所有子游戏的 `creator.version`(之后仍需逐个用编辑器打开迁移) |
| `npm test` | 运行工具链全部单元测试 |
## 关键约束
- **framework 真源**唯一存放于 `YouleNexus/assets/framework`;各子游戏的 `assets/framework` 是指向它的 junction(不入库,见根 `.gitignore`)。clone 后跑 `setup-links` 重建。
- **Cocos 版本必须全 monorepo 统一**(junction 共享同一份资源,跨版本不兼容)。升级走 `bump-cocos` + 逐工程编辑器迁移,详见 spec §8。
- 子游戏私有内容只放 `games/<name>/assets/game/`;协议一律引用 `docs/protocol/`(spec §0.1)。
```
- [ ] **Step 4: Commit**
```bash
cd G:/Works/YouleGamesCocosCreator
git add cocoscreator_projects/scripts/README.md
git commit -m "docs(tools): 工具链 README 与端到端冒烟通过
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
```
---
## 验收标准(Plan 1 完成定义)
1. `node --test scripts/test/` 全绿(7 文件、21 用例)。
2. `npm run new-game <name>` 能生成一个:身份独立(新 uuid)、挂好 framework junction、含 `assets/game/` 骨架的子游戏工程。
3. `npm run setup-links` 幂等重建 junction;`npm run check-cocos` 在版本一致时退出码 0、不一致时为 1。
4. 根 `.gitignore` 正确忽略 `cocoscreator_projects/games/*/assets/framework`。
5. 框架真源 `YouleNexus/assets/framework` 六层占位结构入库;junction 与缓存不入库。
## 后续计划衔接
- **Plan 2(框架内核 net+protocol+core)** 开工前需先:依 `docs/protocol/01、04` 落 `protocol/` 的 TS 类型、确定响应式 Store 选型(spec §9)。
- 框架真源开始有实质代码后,本计划建立的 junction 共享与版本校验即为其提供工程底座。