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>
40 KiB
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:
{
"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:
# 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
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:
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:
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:
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
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:
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:
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 上
dirsymlink 需权限,本仓库目标平台为 Windows(junction 免管理员),开发机直接通过。
- Step 5: Commit
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:
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:
#!/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
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:
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:
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
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:
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:
#!/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
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):
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:
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:
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
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:
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:
#!/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
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:
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:
#!/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
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:
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:
# 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
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 完成定义)
node --test scripts/test/全绿(7 文件、21 用例)。npm run new-game <name>能生成一个:身份独立(新 uuid)、挂好 framework junction、含assets/game/骨架的子游戏工程。npm run setup-links幂等重建 junction;npm run check-cocos在版本一致时退出码 0、不一致时为 1。- 根
.gitignore正确忽略cocoscreator_projects/games/*/assets/framework。 - 框架真源
YouleNexus/assets/framework六层占位结构入库;junction 与缓存不入库。
后续计划衔接
- Plan 2(框架内核 net+protocol+core) 开工前需先:依
docs/protocol/01、04落protocol/的 TS 类型、确定响应式 Store 选型(spec §9)。 - 框架真源开始有实质代码后,本计划建立的 junction 共享与版本校验即为其提供工程底座。