fix(skin): 修复最终评审发现的 6 处缺陷(junction 排除失效/meta 静默丢失等)
跨任务视角发现的问题:Critical1 materialize.mjs 的 junction 排除比较在 Windows 上因长路径前缀恒假、从未生效,且测试夹具的 junction 指向真源自身, 掩盖了此问题;Critical2 composeSkin 例外通道在 uuid 缺失/框架侧无 meta 时 静默失败,违反第二准则;Important3 可覆盖范围误把 theme/ 下的框架 TS 代码 也纳入;Important5 cli-entry 测试漏注册 check-skin/build-game;Important6 composeSkin 与 check-skin 各自拼路径,未共用 paths.mjs 权威推导;Important7 --platform 缺值时静默落到默认平台。逐条修复并补测试,Critical1 用诱饵框架 目录验证过能真正杀掉该 bug(变异推演见 fix report)。 spec §5 step5 的 dist/<name>/ 拷贝按要求标注为本期未实现,不实现。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import { cpSync, mkdirSync, existsSync, readFileSync, writeFileSync, copyFileSync } from 'node:fs';
|
||||
import { join, posix, dirname, relative, sep, resolve } from 'node:path';
|
||||
import { listAssets, validateOverrides } from './skin.mjs';
|
||||
import { join, posix, dirname, relative, sep, resolve, basename } from 'node:path';
|
||||
import { listCoverableAssets, validateOverrides } from './skin.mjs';
|
||||
import { gameOverrideDir, frameworkUiDirOf } from './paths.mjs';
|
||||
|
||||
/**
|
||||
* 工程缓存目录:实体化时跳过。
|
||||
@@ -27,17 +28,19 @@ export function materialize(gameDir, frameworkSrc, destDir) {
|
||||
mkdirSync(destDir, { recursive: true });
|
||||
|
||||
// 1) 拷工程本体,跳过缓存目录与 assets/framework(后者单独从真源拷)
|
||||
const frameworkLink = join(gameDir, 'assets', 'framework');
|
||||
const frameworkRel = join('assets', 'framework');
|
||||
const gameDirNorm = resolve(gameDir);
|
||||
cpSync(gameDir, destDir, {
|
||||
recursive: true,
|
||||
dereference: true,
|
||||
filter: (src) => {
|
||||
if (src === frameworkLink) return false;
|
||||
// 处理 Windows UNC 长路径前缀 (\\?\) 导致 relative() 失败的问题
|
||||
// 处理 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]); // 只看第一段:锚定到工程根
|
||||
},
|
||||
});
|
||||
@@ -51,8 +54,10 @@ export function materialize(gameDir, frameworkSrc, destDir) {
|
||||
* 先整体校验,不通过则抛错且**不写任何文件**(避免半成品)。
|
||||
*/
|
||||
export function composeSkin(destDir) {
|
||||
const frameworkUiDir = join(destDir, 'assets', 'framework', 'ui');
|
||||
const overrideDir = join(destDir, 'assets', 'game', 'override');
|
||||
// 路径推导唯一权威在 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);
|
||||
@@ -69,11 +74,28 @@ export function composeSkin(destDir) {
|
||||
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: listAssets(frameworkUiDir).length };
|
||||
return { matched, frameworkTotal: listCoverableAssets(frameworkUiDir).length };
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@ 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 const FRAMEWORK_UI_DIR = join(FRAMEWORK_SRC, 'ui');
|
||||
export const BUILD_WORKSPACE = join(ROOT, 'build-workspace');
|
||||
|
||||
// 子游戏的皮肤覆盖目录
|
||||
@@ -16,6 +15,14 @@ export function gameOverrideDir(projectDir) {
|
||||
return join(projectDir, 'assets', 'game', 'override');
|
||||
}
|
||||
|
||||
// 某工程(宿主真源,或实体化后的临时工作区)的 framework/ui 目录。
|
||||
// check-skin 与 build-game(composeSkin) 共用同一份推导,不各自拼路径(唯一权威)。
|
||||
export function frameworkUiDirOf(projectDir) {
|
||||
return join(projectDir, 'assets', 'framework', 'ui');
|
||||
}
|
||||
|
||||
export const FRAMEWORK_UI_DIR = frameworkUiDirOf(HOST_PROJECT);
|
||||
|
||||
export function readCreatorVersion(projectDir) {
|
||||
try {
|
||||
const pkg = JSON.parse(readFileSync(join(projectDir, 'package.json'), 'utf8'));
|
||||
|
||||
@@ -5,6 +5,15 @@ const IMAGE_EXTS = new Set(['.png', '.jpg', '.jpeg', '.webp']);
|
||||
const SPINE_ROOT = 'spine'; // framework/ui/spine/<资源名>/ 为一套 Spine
|
||||
const SKIPPED = new Set(['.gitkeep']);
|
||||
|
||||
/**
|
||||
* 可被子游戏覆盖的框架资源目录(spec §2 规则 3 枚举的 8 个目录)。
|
||||
* `theme/` 不在其中——那是 §4.2 规定的框架 TS 代码位置,不是资源,不可覆盖。
|
||||
*/
|
||||
const COVERABLE_DIRS = new Set([
|
||||
'atlas-common', 'atlas-login', 'atlas-hall', 'atlas-room',
|
||||
'standalone', 'spine', 'audio', 'font',
|
||||
]);
|
||||
|
||||
/** 列出目录下所有资源的相对路径(正斜杠),排除 .meta 与 .gitkeep。 */
|
||||
export function listAssets(dir) {
|
||||
if (!existsSync(dir)) return [];
|
||||
@@ -20,6 +29,15 @@ export function listAssets(dir) {
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 列出框架 UI 目录下**可被覆盖**的资源(白名单目录,见 COVERABLE_DIRS)。
|
||||
* `frameworkUiDir` 下还有 `theme/`、`README.md` 等非资源内容,
|
||||
* 不属于可覆盖范围,也不计入"框架可覆盖资源共 N 个"的统计。
|
||||
*/
|
||||
export function listCoverableAssets(frameworkUiDir) {
|
||||
return listAssets(frameworkUiDir).filter((rel) => COVERABLE_DIRS.has(rel.split(posix.sep)[0]));
|
||||
}
|
||||
|
||||
/** 读 PNG 宽高;非 PNG 或读取失败返回 null。 */
|
||||
export function readPngSize(file) {
|
||||
try {
|
||||
@@ -39,7 +57,7 @@ const spineGroup = (rel) => rel.split(posix.sep).slice(0, 2).join(posix.sep);
|
||||
* 返回 { matched: 相对路径[], errors: {path, code, message}[] }。
|
||||
*/
|
||||
export function validateOverrides(frameworkUiDir, overrideDir) {
|
||||
const fwList = listAssets(frameworkUiDir);
|
||||
const fwList = listCoverableAssets(frameworkUiDir);
|
||||
const fwSet = new Set(fwList);
|
||||
const ovList = listAssets(overrideDir);
|
||||
const ovSet = new Set(ovList);
|
||||
|
||||
Reference in New Issue
Block a user