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

40 KiB
Raw Blame History

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 上 dir symlink 需权限,本仓库目标平台为 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 完成定义)

  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 共享与版本校验即为其提供工程底座。