import { cpSync, mkdirSync, existsSync, readFileSync, writeFileSync, copyFileSync } from 'node:fs'; import { join, posix, dirname, relative, sep, resolve, basename } from 'node:path'; import { listCoverableAssets, validateOverrides } from './skin.mjs'; import { gameOverrideDir, frameworkUiDirOf } from './paths.mjs'; /** * 工程缓存目录:实体化时跳过。 * **只匹配工程根的直接子目录**——绝不可按目录名在任意层级匹配,否则会连 * `assets/game/native/` 这类业务目录一起吞掉(与 .gitignore 无锚点模式同一类错误,见 Task 1)。 */ const CACHE_DIRS = new Set(['library', 'temp', 'local', 'build', 'native', 'profiles', 'node_modules']); export class SkinValidationError extends Error { constructor(errors) { super(`皮肤覆盖校验未通过(${errors.length} 处问题)`); this.name = 'SkinValidationError'; this.errors = errors; } } /** * 把子游戏工程实体化到 destDir: * `assets/framework` 由 junction 变成对 frameworkSrc 的真实目录拷贝;跳过缓存目录。 * **frameworkSrc 全程只读**(spec §5.1 ①)。 */ export function materialize(gameDir, frameworkSrc, destDir) { if (existsSync(destDir)) throw new Error(`目标已存在:${destDir}`); mkdirSync(destDir, { recursive: true }); // 1) 拷工程本体,跳过缓存目录与 assets/framework(后者单独从真源拷) const frameworkRel = join('assets', 'framework'); const gameDirNorm = resolve(gameDir); cpSync(gameDir, destDir, { recursive: true, dereference: true, filter: (src) => { // 处理 Windows UNC 长路径前缀 (\\?\):cpSync 传入的 src 在 Windows 上 // 每一层都带该前缀,若不归一化,下面任何基于 rel 的比较都会恒假 // (包括曾经直接 `src === frameworkLink` 的写法——那是 Critical 1 的成因)。 const srcNorm = resolve(src.replace(/^\\\\\?\\/, '')); const rel = relative(gameDirNorm, srcNorm); if (!rel) return true; // gameDir 自身 if (rel === frameworkRel) return false; // junction:单独从真源拷贝,不经此路径解引用 return !CACHE_DIRS.has(rel.split(sep)[0]); // 只看第一段:锚定到工程根 }, }); // 2) 从真源实体化 framework(不依赖 junction 存在,spec §5.1 ②) cpSync(frameworkSrc, join(destDir, 'assets', 'framework'), { recursive: true, dereference: true }); } /** * 把临时工程内的 `assets/game/override/**` 合成到 `assets/framework/ui/**`。 * 先整体校验,不通过则抛错且**不写任何文件**(避免半成品)。 */ export function composeSkin(destDir) { // 路径推导唯一权威在 paths.mjs(check-skin 用同一套),composeSkin 不再自己拼一份地图 const frameworkUiDir = frameworkUiDirOf(destDir); const overrideDir = gameOverrideDir(destDir); const gameName = basename(destDir); const { matched, errors } = validateOverrides(frameworkUiDir, overrideDir); if (errors.length) throw new SkinValidationError(errors); for (const rel of matched) { const parts = rel.split(posix.sep); const from = join(overrideDir, ...parts); const to = join(frameworkUiDir, ...parts); mkdirSync(dirname(to), { recursive: true }); copyFileSync(from, to); // 例外通道:override 自带 meta 时,把 uuid 改回框架原值以保持引用 const ovMeta = `${from}.meta`; const fwMeta = `${to}.meta`; if (existsSync(ovMeta) && existsSync(fwMeta)) { const fwUuid = JSON.parse(readFileSync(fwMeta, 'utf8')).uuid; if (!fwUuid) { // 静默通过会写出没有 uuid 的 .meta——Cocos 重新生成 UUID,框架 Prefab 对该 // 资源的引用在发布包里断裂,这正是"保留 .meta"整个设计要防的唯一失效模式。 throw new SkinValidationError([{ path: rel, code: 'meta-missing-uuid', message: `${gameName}: 框架侧 .meta 没有 uuid(${fwMeta}),无法为例外通道保留引用,请先修复框架该文件的 .meta`, }]); } const merged = JSON.parse(readFileSync(ovMeta, 'utf8')); merged.uuid = fwUuid; writeFileSync(fwMeta, `${JSON.stringify(merged, null, 2)}\n`); } else if (existsSync(ovMeta) && !existsSync(fwMeta)) { // 框架侧没有 .meta 时整段合并被跳过,override 自带的 meta 会被无声丢弃—— // 同样违反"下游不兜底":必须显式报错,而不是悄悄忽略 override 的意图。 throw new SkinValidationError([{ path: rel, code: 'meta-orphan', message: `${gameName}: override 提供了 ${rel}.meta,但框架侧没有对应的 .meta(${fwMeta}),无法合并 uuid(例外通道要求框架侧资源本身已有 .meta)`, }]); } } return { matched, frameworkTotal: listCoverableAssets(frameworkUiDir).length }; }