@playcraft/build 0.1.50-beta → 0.1.51

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -209,4 +209,4 @@ console.log(adapter.getDefaultFormat()); // "html"
209
209
 
210
210
  - `@playcraft/cli`:CLI 工具
211
211
  - `@playcraft/worker`:队列消费与异步构建
212
- - `@playcraft/backend-business-server`:类型定义用于参数校验
212
+ - `@playcraft/backend`:类型定义用于参数校验
@@ -3,17 +3,26 @@ import path from 'path';
3
3
  /**
4
4
  * 引擎检测映射表 - 从 package.json 的 dependencies 检测引擎类型
5
5
  * 复用后端 ExternalImportService 的逻辑
6
+ *
7
+ * 多引擎项目(如 Three.js + PixiJS:3D 场景 + 2D HUD)必须按优先级定主引擎,
8
+ * **不能所有项同分** —— 同分时结果取决于依赖在 package.json 里的书写顺序,
9
+ * 换个顺序识别结果就变了。分层口径与后端 `engine-detect.ts` 保持一致:
10
+ * - 完整游戏引擎(playcanvas / cocos / layaair / egret)优先级最高
11
+ * - 3D 渲染库(three / babylonjs)次之
12
+ * - 2D 框架 / 渲染库(phaser / pixijs)最低
6
13
  */
7
14
  const ENGINE_DETECTION_MAP = [
8
- // PlayCanvas - 优先检测(通过文件名而非 package.json)
15
+ // 完整游戏引擎
16
+ { match: (n) => n === 'playcanvas', engine: 'playcanvas', priority: 300 },
17
+ { match: (n) => n === 'cocos-creator' || n === 'cc', engine: 'cocos', priority: 300 },
18
+ { match: (n) => n.startsWith('laya-') || n.startsWith('@layabox/'), engine: 'layaair', priority: 300 },
19
+ { match: (n) => n === 'egret', engine: 'egret', priority: 300 },
20
+ // 3D 渲染库
21
+ { match: (n) => n === 'three', engine: 'threejs', priority: 200 },
22
+ { match: (n) => n === 'babylonjs' || n.startsWith('@babylonjs/'), engine: 'babylonjs', priority: 200 },
23
+ // 2D 框架 / 渲染库
9
24
  { match: (n) => n === 'phaser', engine: 'phaser', priority: 100 },
10
25
  { match: (n) => n === 'pixi.js' || n.startsWith('@pixi/'), engine: 'pixijs', priority: 100 },
11
- { match: (n) => n === 'three', engine: 'threejs', priority: 100 },
12
- { match: (n) => n === 'cocos-creator' || n === 'cc', engine: 'cocos', priority: 100 },
13
- { match: (n) => n === 'playcanvas', engine: 'playcanvas', priority: 100 },
14
- { match: (n) => n === 'babylonjs' || n.startsWith('@babylonjs/'), engine: 'babylonjs', priority: 100 },
15
- { match: (n) => n.startsWith('laya-') || n.startsWith('@layabox/'), engine: 'layaair', priority: 100 },
16
- { match: (n) => n === 'egret', engine: 'egret', priority: 100 },
17
26
  ];
18
27
  /**
19
28
  * 构建工具检测映射表 - 优先级高于引擎检测
@@ -40,6 +49,78 @@ const BUILD_TOOL_DETECTION_MAP = [
40
49
  priority: 200,
41
50
  },
42
51
  ];
52
+ /**
53
+ * 文件级引擎特征只做静态读取,不执行用户工程里的 JavaScript。
54
+ *
55
+ * 这条兜底覆盖引擎运行时由编辑器/平台内置、因此不会出现在 package.json
56
+ * 的项目(典型是 Cocos Creator)。它也让 `packages/build` 与外部导入层的
57
+ * 识别结果保持一致,避免导入时识别为 Cocos、真正构建时又退回 generic。
58
+ */
59
+ const FILE_ENGINE_LABELS = {
60
+ phaser: 'phaser',
61
+ pixi: 'pixijs',
62
+ pixijs: 'pixijs',
63
+ three: 'threejs',
64
+ threejs: 'threejs',
65
+ playcanvas: 'playcanvas',
66
+ cocos: 'cocos',
67
+ cocoscreator: 'cocos',
68
+ cc: 'cocos',
69
+ babylon: 'babylonjs',
70
+ babylonjs: 'babylonjs',
71
+ laya: 'layaair',
72
+ layaair: 'layaair',
73
+ egret: 'egret',
74
+ };
75
+ function normalizeFileEngineKey(value) {
76
+ return value.trim().toLowerCase().replace(/[\s.]/g, '');
77
+ }
78
+ /** 从 builds.config.js 静态读取 engine 字符串字段,不执行文件内容。 */
79
+ function readEngineFromBuildConfig(source) {
80
+ // 仅移除注释,再做字段匹配;不会触发 require/import 或执行任意代码。
81
+ const withoutComments = source
82
+ .replace(/\/\*[\s\S]*?\*\//g, '')
83
+ .replace(/(^|\s)\/\/.*$/gm, '$1');
84
+ const match = /\bengine\s*:\s*['"`]([^'"`]+)['"`]/.exec(withoutComments);
85
+ if (!match)
86
+ return null;
87
+ return FILE_ENGINE_LABELS[normalizeFileEngineKey(match[1])] ?? null;
88
+ }
89
+ function isRecord(value) {
90
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
91
+ }
92
+ /** 校验 Cocos Creator 配置内容,避免仅凭同名文件误判。 */
93
+ function isCocosConfig(source) {
94
+ try {
95
+ const parsed = JSON.parse(source);
96
+ return isRecord(parsed) && (isRecord(parsed.builder) || isRecord(parsed.engine));
97
+ }
98
+ catch {
99
+ return false;
100
+ }
101
+ }
102
+ async function detectEngineFromProjectFiles(projectDir) {
103
+ try {
104
+ const source = await fs.readFile(path.join(projectDir, 'builds.config.js'), 'utf8');
105
+ const engine = readEngineFromBuildConfig(source);
106
+ if (engine)
107
+ return engine;
108
+ }
109
+ catch {
110
+ // 文件不存在或不可读,继续检查 Cocos 工程标记。
111
+ }
112
+ for (const relativePath of ['settings/cocos.config.json', 'profiles/cocos.config.json']) {
113
+ try {
114
+ const source = await fs.readFile(path.join(projectDir, relativePath), 'utf8');
115
+ if (isCocosConfig(source))
116
+ return 'cocos';
117
+ }
118
+ catch {
119
+ // 单个标记缺失不影响另一个位置的检查。
120
+ }
121
+ }
122
+ return null;
123
+ }
43
124
  /**
44
125
  * 引擎检测器
45
126
  * 根据项目文件结构自动检测引擎类型和构建工具
@@ -58,24 +139,48 @@ export class EngineDetector {
58
139
  }
59
140
  // 2. 检测 package.json
60
141
  const pkg = await this.readPackageJson(projectDir);
142
+ const fileEngine = await detectEngineFromProjectFiles(projectDir);
61
143
  if (!pkg) {
144
+ if (fileEngine) {
145
+ console.log(`[EngineDetector] 检测到引擎: ${fileEngine}(通过文件特征)`);
146
+ return { engine: fileEngine, buildTool: 'generic' };
147
+ }
62
148
  throw new Error(`[EngineDetector] 无法识别项目类型:找不到 package.json 或 PlayCanvas 文件结构\n` +
63
149
  `项目目录: ${projectDir}\n` +
64
150
  `请确保这是一个有效的项目目录。`);
65
151
  }
66
- // 3. 优先检测构建工具(BUILD_TOOL_DETECTION_MAP,优先级 200)
152
+ // 3. 先得到 package.json 引擎结果,用于处理「构建工具隐含引擎」与文件特征冲突。
153
+ const packageEngine = this.detectFromPackageJson(pkg);
154
+ // 4. 优先检测构建工具(BUILD_TOOL_DETECTION_MAP,优先级 200)。
155
+ //
156
+ // **engine 与 buildTool 必须解耦**:`@playcraft/adsdk` / `@playcraft/devkit`
157
+ // 决定的是「用 devkit 打包」这件事,与项目是哪个引擎无关 —— Cocos 同样靠
158
+ // devkit 出产物,走的是同一条链路。而 adsdk 表里的 `impliedEngine: 'phaser'`
159
+ // 只是「大多数 adsdk 项目是 Phaser」的经验值,一旦项目里已经能明确识别出
160
+ // 引擎(依赖或文件特征),就该以识别结果为准,且**不能因此把 buildTool
161
+ // 一起退回 generic**,否则 Cocos / Three.js 会被踢出 devkit 链路。
67
162
  const buildToolResult = this.detectBuildToolFromPackageJson(pkg);
68
163
  if (buildToolResult) {
164
+ const knownEngine = packageEngine ?? fileEngine;
165
+ if (knownEngine && knownEngine !== buildToolResult.engine) {
166
+ console.log(`[EngineDetector] 检测到构建工具: ${buildToolResult.buildTool};` +
167
+ `引擎以识别结果 ${knownEngine} 为准(构建工具隐含 ${buildToolResult.engine} 不予采用)`);
168
+ return { engine: knownEngine, buildTool: buildToolResult.buildTool };
169
+ }
69
170
  console.log(`[EngineDetector] 检测到构建工具: ${buildToolResult.buildTool}(隐含引擎: ${buildToolResult.engine})`);
70
171
  return buildToolResult;
71
172
  }
72
- // 4. 检测引擎类型(ENGINE_DETECTION_MAP,优先级 100)
73
- const engine = this.detectFromPackageJson(pkg);
74
- if (engine) {
75
- console.log(`[EngineDetector] 检测到引擎: ${engine}(通过 package.json)`);
76
- return { engine, buildTool: 'generic' };
173
+ // 5. 检测引擎类型(ENGINE_DETECTION_MAP,优先级 100)
174
+ if (packageEngine) {
175
+ console.log(`[EngineDetector] 检测到引擎: ${packageEngine}(通过 package.json)`);
176
+ return { engine: packageEngine, buildTool: 'generic' };
177
+ }
178
+ // 6. 依赖未命中时使用文件级特征兜底。
179
+ if (fileEngine) {
180
+ console.log(`[EngineDetector] 检测到引擎: ${fileEngine}(通过文件特征)`);
181
+ return { engine: fileEngine, buildTool: 'generic' };
77
182
  }
78
- // 5. 兜底
183
+ // 7. 兜底
79
184
  console.log('[EngineDetector] 无法识别具体引擎,使用 generic 类型');
80
185
  return { engine: 'generic', buildTool: 'generic' };
81
186
  }
@@ -47,7 +47,12 @@ export declare class GenericAdapter {
47
47
  private runNpmBuild;
48
48
  /**
49
49
  * 查找构建输出目录
50
- * 按优先级搜索:dist/、build/、out/、public/
50
+ * 按优先级搜索:dist/、build/、out/,并兼容编辑器型引擎常见的
51
+ * 多一层输出目录(例如 Cocos 的 build/web-mobile/)。
52
+ *
53
+ * 刻意**不包含 `public/`**:Vite / webpack 都约定它是「直接拷贝到产物根的静态
54
+ * 资源目录」,放的是 favicon、robots.txt 这类原样文件,本身不是构建产物。
55
+ * 一旦里面有 index.html,就会被误当成构建结果整目录复制出去。
51
56
  */
52
57
  private findBuildOutputDir;
53
58
  /**
@@ -40,11 +40,19 @@ export class GenericAdapter {
40
40
  console.log('[GenericAdapter] 执行依赖安装...');
41
41
  await this.runNpmInstall(packageManager);
42
42
  }
43
- // 4. 检测构建命令
43
+ // 4. 检测构建命令。**必须先构建、再取产物**,不允许因为「找不到脚本」就
44
+ // 复用工程里现成的目录:那会把残留的 `dist/` 或 Vite 的 `public/` 当成
45
+ // 构建结果,用户拿到的是过期或完全不对的内容。
46
+ //
47
+ // 平台导入的工程都带 `builds.config.js`,由 playable-scripts 构建链负责
48
+ // 产出产物,正常路径下这里必然能拿到 build 脚本;拿不到就是工程本身有问题,
49
+ // 应当明确报错而不是静默降级。
44
50
  const buildScript = await this.detectBuildScript();
45
51
  if (!buildScript) {
46
- throw new Error(`[GenericAdapter] 未找到 build script\n` +
47
- `请确保 package.json 中包含 scripts.build 字段。`);
52
+ throw new Error(`[GenericAdapter] 未找到可用的构建命令,拒绝复用工程内现成的目录。\n` +
53
+ `项目目录: ${this.projectDir}\n` +
54
+ `请在 package.json 的 scripts 中声明 build(支持 build / build:prod / ` +
55
+ `build:production / dist / compile 之一)。`);
48
56
  }
49
57
  console.log(`[GenericAdapter] 检测到构建命令: ${buildScript}`);
50
58
  // 5. 执行构建
@@ -76,9 +84,6 @@ export class GenericAdapter {
76
84
  { file: 'pnpm-lock.yaml', manager: 'pnpm' },
77
85
  { file: 'yarn.lock', manager: 'yarn' },
78
86
  { file: 'package-lock.json', manager: 'npm' },
79
- { file: 'npm-shrinkwrap.json', manager: 'npm' },
80
- { file: 'bun.lock', manager: 'bun' },
81
- { file: 'bun.lockb', manager: 'bun' },
82
87
  ];
83
88
  for (const { file, manager } of lockFiles) {
84
89
  try {
@@ -140,25 +145,28 @@ export class GenericAdapter {
140
145
  * 检测 build script
141
146
  */
142
147
  async detectBuildScript() {
148
+ const pkgPath = path.join(this.projectDir, 'package.json');
149
+ // package.json 读不到 / 解析失败属于「工程本身有问题」,直接抛出并说明原因。
150
+ // 不能静默返回 null —— 那会让调用方以为「只是没配脚本」,掩盖真实故障。
151
+ let pkg;
143
152
  try {
144
- const pkgPath = path.join(this.projectDir, 'package.json');
145
- const content = await fs.readFile(pkgPath, 'utf-8');
146
- const pkg = JSON.parse(content);
147
- if (!pkg.scripts) {
148
- return null;
149
- }
150
- // 优先查找常见的 build 命令
151
- const buildScripts = ['build', 'build:prod', 'build:production', 'dist', 'compile'];
152
- for (const script of buildScripts) {
153
- if (pkg.scripts[script]) {
154
- return script;
155
- }
156
- }
157
- return null;
153
+ pkg = JSON.parse(await fs.readFile(pkgPath, 'utf-8'));
158
154
  }
159
- catch {
155
+ catch (error) {
156
+ throw new Error(`[GenericAdapter] 无法读取或解析 package.json,构建中止。\n` +
157
+ `路径: ${pkgPath}\n` +
158
+ `原因: ${error instanceof Error ? error.message : String(error)}`);
159
+ }
160
+ if (!pkg.scripts)
160
161
  return null;
162
+ // 优先查找常见的 build 命令
163
+ const buildScripts = ['build', 'build:prod', 'build:production', 'dist', 'compile'];
164
+ for (const script of buildScripts) {
165
+ if (pkg.scripts[script]) {
166
+ return script;
167
+ }
161
168
  }
169
+ return null;
162
170
  }
163
171
  /**
164
172
  * 执行 npm run build
@@ -166,7 +174,7 @@ export class GenericAdapter {
166
174
  */
167
175
  async runNpmBuild(packageManager, script) {
168
176
  const cmd = packageManager === 'npm' ? 'npm' : packageManager;
169
- const args = packageManager === 'npm' || packageManager === 'bun' ? ['run', script] : [script];
177
+ const args = packageManager === 'npm' ? ['run', script] : [script];
170
178
  console.log(`[GenericAdapter] 运行: ${cmd} ${args.join(' ')}`);
171
179
  await this.runCommandWithTimeout(cmd, args, {
172
180
  cwd: this.projectDir,
@@ -183,10 +191,27 @@ export class GenericAdapter {
183
191
  }
184
192
  /**
185
193
  * 查找构建输出目录
186
- * 按优先级搜索:dist/、build/、out/、public/
194
+ * 按优先级搜索:dist/、build/、out/,并兼容编辑器型引擎常见的
195
+ * 多一层输出目录(例如 Cocos 的 build/web-mobile/)。
196
+ *
197
+ * 刻意**不包含 `public/`**:Vite / webpack 都约定它是「直接拷贝到产物根的静态
198
+ * 资源目录」,放的是 favicon、robots.txt 这类原样文件,本身不是构建产物。
199
+ * 一旦里面有 index.html,就会被误当成构建结果整目录复制出去。
187
200
  */
188
201
  async findBuildOutputDir() {
189
- const possibleDirs = ['dist', 'build', 'out', 'public'];
202
+ const possibleDirs = [
203
+ 'dist',
204
+ 'build',
205
+ 'out',
206
+ 'build/web-mobile',
207
+ 'build/web-desktop',
208
+ 'build/html5',
209
+ 'dist/web-mobile',
210
+ 'dist/html5',
211
+ 'web-mobile',
212
+ 'web-desktop',
213
+ 'html5',
214
+ ];
190
215
  for (const dir of possibleDirs) {
191
216
  const fullPath = path.join(this.projectDir, dir);
192
217
  try {
@@ -204,6 +229,38 @@ export class GenericAdapter {
204
229
  // 目录不存在,继续检查下一个
205
230
  }
206
231
  }
232
+ // 最多向下搜索三层,覆盖未知引擎的嵌套产物目录;跳过依赖和 VCS 目录,
233
+ // 避免在大型源码树里把 node_modules 中的 index.html 当成构建结果。
234
+ const roots = ['dist', 'build', 'out'];
235
+ const ignored = new Set(['node_modules', '.git', 'library', 'temp', 'local']);
236
+ const search = async (root, depth) => {
237
+ if (depth > 3)
238
+ return null;
239
+ const fullRoot = path.join(this.projectDir, root);
240
+ let entries;
241
+ try {
242
+ entries = await fs.readdir(fullRoot, { withFileTypes: true });
243
+ }
244
+ catch {
245
+ return null;
246
+ }
247
+ if (entries.some((entry) => entry.isFile() && entry.name.toLowerCase() === 'index.html')) {
248
+ return fullRoot;
249
+ }
250
+ for (const entry of entries) {
251
+ if (!entry.isDirectory() || ignored.has(entry.name))
252
+ continue;
253
+ const found = await search(path.join(root, entry.name), depth + 1);
254
+ if (found)
255
+ return found;
256
+ }
257
+ return null;
258
+ };
259
+ for (const root of roots) {
260
+ const nested = await search(root, 1);
261
+ if (nested)
262
+ return nested;
263
+ }
207
264
  // 如果没找到,尝试从 package.json 读取
208
265
  try {
209
266
  const pkgPath = path.join(this.projectDir, 'package.json');
@@ -285,12 +342,6 @@ export class GenericAdapter {
285
342
  const entries = await fs.readdir(src, { withFileTypes: true });
286
343
  for (const entry of entries) {
287
344
  const srcPath = path.join(src, entry.name);
288
- if (entry.name === '.playcraft.json')
289
- continue;
290
- if (path.basename(src) === '.playcraft' && ['skills', 'skill-artifacts', 'video-replication', 'workspace-context.json'].includes(entry.name))
291
- continue;
292
- if (entry.name === 'user_uploads' && path.basename(src) === '.tmp')
293
- continue;
294
345
  const destPath = path.join(dest, entry.name);
295
346
  // 安全检查:确保目标路径在输出目录内
296
347
  const resolvedDest = path.resolve(destPath);
@@ -5,7 +5,7 @@ import type { BaseBuildOptions, BaseBuildOutput, PlayableScriptsConfig } from '.
5
5
  * 职责:作为 PlayCraft 和 @playcraft/devkit 之间的中间层
6
6
  * - @playcraft/devkit 是 PlayCraft 平台内置的构建工具(已集成到 @playcraft/cli)
7
7
  * - 用户项目只需依赖 @playcraft/adsdk(运行时 SDK),不需要安装构建工具
8
- * - 通过平台内置的 devkit 调用 playable-scripts build/builds 命令
8
+ * - 通过平台内置的 devkit 调用 playable-scripts builds 命令(单渠道与批量统一)
9
9
  * - 将 playcraft.config.json 的参数转换为 playable-scripts CLI 参数
10
10
  * - 收集构建产物并标记 skipChannelBuild
11
11
  *
@@ -27,7 +27,7 @@ export declare class PlayableScriptsAdapter {
27
27
  * 流程:
28
28
  * 1. 合并配置(playcraft.config.json → builds.config.js)
29
29
  * 2. 构建 CLI 参数
30
- * 3. 调用平台内置的 playable-scripts build/builds
30
+ * 3. 调用平台内置的 playable-scripts builds
31
31
  * 4. 收集构建产物
32
32
  * 5. 复制到输出目录
33
33
  * 6. 写入元数据(skipChannelBuild: true)
@@ -45,7 +45,7 @@ export declare class PlayableScriptsAdapter {
45
45
  * 将 playcraft.config.json 的参数注入到项目的 builds.config.js
46
46
  * - storeUrls → googlePlayUrl / appStoreUrl
47
47
  * - naming → naming
48
- * - themes.enabled → 批量构建时自动禁用主题
48
+ * - themes.enabled → 未指定主题时显式禁用(避免 builds 意外产出全部主题变体)
49
49
  * - build.outputDir → 强制与 PlayCraft 输出目录一致
50
50
  */
51
51
  private mergeConfig;
@@ -69,12 +69,6 @@ export declare class PlayableScriptsAdapter {
69
69
  * 递归查找源码文件
70
70
  */
71
71
  private findSourceFiles;
72
- /**
73
- * 准备构建环境
74
- * - 执行包名迁移
75
- * - 确保项目本地 devkit 和 loader 可用
76
- */
77
- private prepareBuildEnvironment;
78
72
  /**
79
73
  * 调用 playable-scripts 命令
80
74
  *
@@ -82,14 +76,11 @@ export declare class PlayableScriptsAdapter {
82
76
  * 1. 准备构建环境(包名迁移、安装本地 devkit 和 loader)
83
77
  * 2. 执行构建(使用项目本地 devkit)
84
78
  *
85
- * 单渠道模式: <local-devkit> build <channel> [options]
86
- * 批量模式: <local-devkit> builds [options]
79
+ * 单渠道与批量统一使用 `builds`:
80
+ * - devkit 仅在 `builds` 上持续迭代并新增特性,`build` 已停止更新
81
+ * - 单渠道通过 `--channels <channel>` 表达,无需再走 `build <channel>`
87
82
  */
88
83
  private executePlayableScripts;
89
- /**
90
- * 从参数列表中移除指定参数及其值
91
- */
92
- private removeArg;
93
84
  /**
94
85
  * 统一构造 pnpm CLI 参数:
95
86
  * - 透传通用 flag(dangerouslyAllowAllBuilds / strict-peer-dependencies / reporter)
@@ -120,8 +111,18 @@ export declare class PlayableScriptsAdapter {
120
111
  */
121
112
  private ensureBuildLoaders;
122
113
  /**
123
- * 确保 package.json 中有 build/builds 脚本
124
- * devkit 必须通过 npm script 调用,不能直接执行 .js 文件
114
+ * 确保 package.json 的 `builds` 脚本存在,且**内容确实是 `playable-scripts builds`**。
115
+ *
116
+ * 为什么不能只判断"存在":我们最终执行的是 `pnpm run builds`,如果项目里的 `builds`
117
+ * 还写着老命令 `playable-scripts build`(老模板 / 手工改过),即使平台代码已经统一,
118
+ * 实际跑的仍是单渠道 legacy 命令 —— 输出布局、默认 network/protocol、是否追加 preview
119
+ * 都会跟 `builds` 不一样,等于历史问题复活。
120
+ *
121
+ * 处理策略(保留前置步骤,如 `node scripts/generate-levels.js &&`):
122
+ * 1. 已是 `playable-scripts builds` → 不动
123
+ * 2. 存在但含 `playable-scripts build` → 就地改写为 `builds`
124
+ * 3. 不存在 → 从 `build` 脚本推导(保留其前置步骤),否则注入纯 `playable-scripts builds`
125
+ * 4. 存在但完全自定义(不含 playable-scripts)→ 直接报错,避免静默跑错命令
125
126
  */
126
127
  private ensureBuildScripts;
127
128
  /**
@@ -141,15 +142,26 @@ export declare class PlayableScriptsAdapter {
141
142
  * 注意:只收集目标渠道的产物,过滤掉其他渠道
142
143
  */
143
144
  private scanOutputDir;
144
- /**
145
- * 清理输出目录,删除所有旧产物
146
- */
147
- private cleanOutputDir;
148
145
  /**
149
146
  * 递归删除目录
150
147
  */
151
148
  private removeDirRecursive;
152
149
  private copyToOutputDir;
150
+ /**
151
+ * 从同一渠道的多个产物中选出交付用的那一个。
152
+ *
153
+ * 旧实现是「文件名含 `-` 就 break,其次 HTML 优先」,在 devkit 扁平布局下会把
154
+ * `xxx-preview.html` 选成目标渠道产物(snapchat 构建最终交付了预览 html)。
155
+ *
156
+ * 现在的优先级(打分):
157
+ * 1. 文件名命中目标渠道(devkit 命名 `<主题>-<渠道>[_L|_P].<ext>`) +4
158
+ * 2. 与请求格式一致(请求 zip 优先 .zip,请求 html 优先 .html) +2
159
+ * 3. zip 兜底(同为候选时优先交付压缩包) +1
160
+ * 4. 无方向后缀的基准产物优先 +0.5
161
+ *
162
+ * 出现多份等价产物时打印完整候选清单,避免再出现「静默挑了一个错的」。
163
+ */
164
+ private pickOutputForChannel;
153
165
  /**
154
166
  * 是否对 playable-scripts 使用 --zip。
155
167
  * 双格式渠道默认 HTML;仅在请求或配置显式选择 ZIP 时传递 --zip。
@@ -32,6 +32,24 @@ const UNSUPPORTED_CHANNELS = new Set([]);
32
32
  function mapChannel(playcraftPlatform) {
33
33
  return CHANNEL_MAP[playcraftPlatform] ?? playcraftPlatform;
34
34
  }
35
+ /**
36
+ * 归一化 devkit 产物文件名(去掉扩展名与方向后缀),用于渠道识别。
37
+ *
38
+ * devkit 命名约定(core/batch-build.js copyTempFiles):
39
+ * `<主题>-<渠道>.zip|html`、多方向时 `<主题>-<渠道>_L.zip|html`
40
+ * 兼容仅渠道名的命名(`--filename` 命中渠道时,如 `preview.html`)。
41
+ */
42
+ function artifactStem(fileName) {
43
+ return fileName
44
+ .replace(/\.(html|zip)$/i, '')
45
+ .replace(/_[LP]$/i, '')
46
+ .toLowerCase();
47
+ }
48
+ /** 判断产物文件名是否属于指定渠道(devkit 命名约定) */
49
+ function artifactMatchesChannel(fileName, channel) {
50
+ const stem = artifactStem(fileName);
51
+ return stem === channel || stem.endsWith(`-${channel}`) || stem.endsWith(`_${channel}`);
52
+ }
35
53
  /**
36
54
  * 需要「HTML → ZIP」兜底的渠道:交付与后端校验(PlatformValidatorService)都要求 ZIP,
37
55
  * 但 devkit(第三方)可能只产出单文件 HTML。
@@ -50,7 +68,7 @@ const ZIP_FALLBACK_CHANNELS = new Set(['remerge']);
50
68
  * 职责:作为 PlayCraft 和 @playcraft/devkit 之间的中间层
51
69
  * - @playcraft/devkit 是 PlayCraft 平台内置的构建工具(已集成到 @playcraft/cli)
52
70
  * - 用户项目只需依赖 @playcraft/adsdk(运行时 SDK),不需要安装构建工具
53
- * - 通过平台内置的 devkit 调用 playable-scripts build/builds 命令
71
+ * - 通过平台内置的 devkit 调用 playable-scripts builds 命令(单渠道与批量统一)
54
72
  * - 将 playcraft.config.json 的参数转换为 playable-scripts CLI 参数
55
73
  * - 收集构建产物并标记 skipChannelBuild
56
74
  *
@@ -108,7 +126,7 @@ export class PlayableScriptsAdapter {
108
126
  * 流程:
109
127
  * 1. 合并配置(playcraft.config.json → builds.config.js)
110
128
  * 2. 构建 CLI 参数
111
- * 3. 调用平台内置的 playable-scripts build/builds
129
+ * 3. 调用平台内置的 playable-scripts builds
112
130
  * 4. 收集构建产物
113
131
  * 5. 复制到输出目录
114
132
  * 6. 写入元数据(skipChannelBuild: true)
@@ -124,20 +142,9 @@ export class PlayableScriptsAdapter {
124
142
  const cliArgs = this.buildCLIArgs();
125
143
  // 3. 打印构建参数信息
126
144
  this.logBuildParams();
127
- // 4. 清理输出目录(在 devkit 构建前清理,避免旧产物干扰)
128
- // 判断是否为批量构建模式(多个渠道或有主题)
129
- const isBatchMode = (this.config?.channels && this.config.channels.length > 1) ||
130
- (this.config?.themes?.enabled);
131
- if (isBatchMode) {
132
- // 批量构建:不预清理,让 devkit 来清理,避免 EBUSY 错误
133
- console.log('[PlayableScriptsAdapter] 批量构建模式:跳过预清理(由 devkit 负责)...');
134
- }
135
- else {
136
- // 单渠道构建:清理项目目录下的 dist 目录(playable-scripts 实际输出目录)
137
- const devkitOutputDir = path.join(this.projectDir, this.config?.outputDir ?? 'dist');
138
- console.log('[PlayableScriptsAdapter] 单渠道构建模式:清理 devkit 输出目录...');
139
- await this.cleanOutputDir(devkitOutputDir);
140
- }
145
+ // 4. 输出目录清理统一交给 devkit(builds 内部负责):
146
+ // 预清理会与 devkit 自身的清理阶段竞争,在 Windows 上触发 EBUSY,因此这里不再处理。
147
+ console.log('[PlayableScriptsAdapter] 输出目录清理交由 devkit 负责...');
141
148
  // 5. 执行 playable-scripts 构建(使用平台内置的 devkit)
142
149
  console.log('[PlayableScriptsAdapter] 执行 playable-scripts 构建...');
143
150
  await this.executePlayableScripts(cliArgs);
@@ -194,13 +201,10 @@ export class PlayableScriptsAdapter {
194
201
  * 将 playcraft.config.json 的参数注入到项目的 builds.config.js
195
202
  * - storeUrls → googlePlayUrl / appStoreUrl
196
203
  * - naming → naming
197
- * - themes.enabled → 批量构建时自动禁用主题
204
+ * - themes.enabled → 未指定主题时显式禁用(避免 builds 意外产出全部主题变体)
198
205
  * - build.outputDir → 强制与 PlayCraft 输出目录一致
199
206
  */
200
207
  async mergeConfig() {
201
- // 判断是否为批量构建模式
202
- const isBatchMode = (this.config?.channels && this.config.channels.length > 1) ||
203
- (this.config?.themes?.enabled);
204
208
  // 读取或创建 builds.config.js
205
209
  const configPath = path.join(this.projectDir, 'builds.config.js');
206
210
  let configContent;
@@ -228,29 +232,27 @@ export class PlayableScriptsAdapter {
228
232
  if (this.config?.naming) {
229
233
  injections.push(` naming: ${JSON.stringify(this.config.naming)}`);
230
234
  }
231
- // 批量构建时处理主题配置
232
- if (isBatchMode) {
233
- // 扫描可用主题
234
- const availableThemes = await this.scanThemes();
235
- if (this.config?.themes?.whitelist && this.config.themes.whitelist.length > 0) {
236
- // 用户指定了主题白名单:启用主题切换
237
- const validThemes = this.config.themes.whitelist.filter(t => availableThemes.includes(t));
238
- if (validThemes.length > 0) {
239
- injections.push(` themes: { enabled: true, sourceDir: 'src/theme', entryFile: 'src/theme/index.ts' }`);
240
- console.log(`[PlayableScriptsAdapter] 批量构建模式:启用主题切换 (${validThemes.length} 个主题)`);
241
- }
242
- else {
243
- // 指定的主题不存在,禁用主题切换
244
- injections.push(` themes: { enabled: false }`);
245
- console.log('[PlayableScriptsAdapter] 批量构建模式:指定主题未找到,使用默认主题');
246
- }
235
+ // 主题配置一律显式注入:统一走 builds 后,devkit 会读取项目 builds.config.js
236
+ // 的 themes.enabled;若在未指定主题时不显式关闭,单渠道构建会意外产出全部主题变体。
237
+ const availableThemes = await this.scanThemes();
238
+ if (this.config?.themes?.whitelist && this.config.themes.whitelist.length > 0) {
239
+ // 用户指定了主题白名单:启用主题切换
240
+ const validThemes = this.config.themes.whitelist.filter(t => availableThemes.includes(t));
241
+ if (validThemes.length > 0) {
242
+ injections.push(` themes: { enabled: true, sourceDir: 'src/theme', entryFile: 'src/theme/index.ts' }`);
243
+ console.log(`[PlayableScriptsAdapter] 启用主题切换 (${validThemes.length} 个主题)`);
247
244
  }
248
245
  else {
249
- // 用户未指定主题:禁用主题切换,使用当前默认主题
246
+ // 指定的主题不存在,禁用主题切换
250
247
  injections.push(` themes: { enabled: false }`);
251
- console.log('[PlayableScriptsAdapter] 批量构建模式:使用当前默认主题');
248
+ console.log('[PlayableScriptsAdapter] 指定主题未找到,使用默认主题');
252
249
  }
253
250
  }
251
+ else {
252
+ // 用户未指定主题:禁用主题切换,使用当前默认主题
253
+ injections.push(` themes: { enabled: false }`);
254
+ console.log('[PlayableScriptsAdapter] 使用当前默认主题');
255
+ }
254
256
  // 始终注入配置,确保 build.outputDir 被覆盖
255
257
  // 在 module.exports 的对象末尾注入配置
256
258
  // 策略:创建一个 wrapper 文件来合并配置
@@ -290,14 +292,23 @@ export class PlayableScriptsAdapter {
290
292
  // fflate 压缩:config 指定 > 项目 builds.config.js > PlayCraft 默认 true
291
293
  const fflate = this.resolveFflateCompression();
292
294
  args.push('--fflate-compression', `${fflate}`);
293
- // 渠道列表
294
- if (config.channels && config.channels.length > 0) {
295
- const mappedChannels = config.channels
296
- .filter(ch => !UNSUPPORTED_CHANNELS.has(ch))
297
- .map(ch => mapChannel(ch));
298
- if (mappedChannels.length > 0) {
299
- args.push('--channels', mappedChannels.join(','));
300
- }
295
+ // 渠道列表:单渠道与多渠道统一走 `builds --channels <...>`
296
+ // 缺失时退回 validation.channel(沙箱 runner / worker / CLI 都会传本次渠道);
297
+ // 仍为空说明调用方没给渠道 —— devkit 会按项目 builds.config.js 构建**全部**渠道,
298
+ // 平台侧如果只要单渠道就会多出大量产物,这里显式告警。
299
+ const requestedChannels = config.channels && config.channels.length > 0
300
+ ? config.channels
301
+ : this.options.validation?.channel
302
+ ? [this.options.validation.channel]
303
+ : [];
304
+ const mappedChannels = requestedChannels
305
+ .filter(ch => !UNSUPPORTED_CHANNELS.has(ch))
306
+ .map(ch => mapChannel(ch));
307
+ if (mappedChannels.length > 0) {
308
+ args.push('--channels', mappedChannels.join(','));
309
+ }
310
+ else {
311
+ console.warn('[PlayableScriptsAdapter] ⚠️ 未指定目标渠道:devkit 将按项目配置构建全部渠道');
301
312
  }
302
313
  // 商店跳转地址
303
314
  if (config.storeUrls) {
@@ -432,24 +443,6 @@ export class PlayableScriptsAdapter {
432
443
  }
433
444
  return files;
434
445
  }
435
- /**
436
- * 准备构建环境
437
- * - 执行包名迁移
438
- * - 确保项目本地 devkit 和 loader 可用
439
- */
440
- async prepareBuildEnvironment() {
441
- // 1. 执行包名迁移
442
- await this.migratePackageNames();
443
- // 2. 确保项目本地存在 devkit 和构建依赖
444
- const localDevkit = await this.ensureLocalDevkit();
445
- if (!localDevkit) {
446
- throw new Error('[PlayableScriptsAdapter] 无法安装项目本地的 @playcraft/devkit。\n' +
447
- '请检查网络连接或手动运行: npm install --save-dev @playcraft/devkit');
448
- }
449
- console.log('[PlayableScriptsAdapter] ✅ 使用项目本地 devkit');
450
- // 3. 确保构建 loader 存在
451
- await this.ensureBuildLoaders();
452
- }
453
446
  /**
454
447
  * 调用 playable-scripts 命令
455
448
  *
@@ -457,8 +450,9 @@ export class PlayableScriptsAdapter {
457
450
  * 1. 准备构建环境(包名迁移、安装本地 devkit 和 loader)
458
451
  * 2. 执行构建(使用项目本地 devkit)
459
452
  *
460
- * 单渠道模式: <local-devkit> build <channel> [options]
461
- * 批量模式: <local-devkit> builds [options]
453
+ * 单渠道与批量统一使用 `builds`:
454
+ * - devkit 仅在 `builds` 上持续迭代并新增特性,`build` 已停止更新
455
+ * - 单渠道通过 `--channels <channel>` 表达,无需再走 `build <channel>`
462
456
  */
463
457
  async executePlayableScripts(cliArgs) {
464
458
  // 1. 执行包名迁移
@@ -473,61 +467,35 @@ export class PlayableScriptsAdapter {
473
467
  // 3. 确保构建 loader 存在
474
468
  await this.ensureBuildLoaders();
475
469
  const config = this.config ?? {};
476
- const isBatchMode = (config.channels && config.channels.length > 1) ||
477
- (config.themes?.enabled);
478
- // 构建命令参数
479
- let args;
480
- let modeLabel;
481
- if (isBatchMode) {
482
- // 批量模式: <devkit> builds [options]
483
- args = ['builds', ...cliArgs];
484
- // 扫描可用主题
485
- const availableThemes = await this.scanThemes();
486
- // 确定要构建的主题列表
487
- let selectedThemes = [];
488
- if (config.themes?.whitelist && config.themes.whitelist.length > 0) {
489
- // 使用配置中指定的主题白名单,清理可能的引号
490
- const cleanWhitelist = config.themes.whitelist.map(t => t.replace(/^["']|["']$/g, ''));
491
- selectedThemes = cleanWhitelist.filter(t => availableThemes.includes(t));
492
- if (selectedThemes.length === 0) {
493
- console.log(`[PlayableScriptsAdapter] ⚠️ 配置的主题未找到,使用当前默认主题`);
494
- selectedThemes = [];
495
- }
496
- }
497
- // 如果有指定主题,传入 -t 参数
498
- if (selectedThemes.length > 0) {
499
- args.push('-t', selectedThemes.join(','));
500
- console.log(`[PlayableScriptsAdapter] 构建主题: ${selectedThemes.join(', ')}`);
501
- }
502
- else {
503
- // 未指定主题:使用 devkit 默认行为(当前 index.ts 指向的主题)
504
- console.log(`[PlayableScriptsAdapter] 使用当前默认主题 (src/theme/index.ts)`);
505
- }
506
- modeLabel = '批量构建模式';
470
+ // 单渠道与批量统一使用 builds 命令:
471
+ // devkit 仅在 builds 上持续迭代并新增特性,build 已停止更新。
472
+ const args = ['builds', ...cliArgs];
473
+ // 扫描可用主题,命中白名单时追加 -t(devkit 的主题过滤参数)
474
+ const availableThemes = await this.scanThemes();
475
+ let selectedThemes = [];
476
+ if (config.themes?.whitelist && config.themes.whitelist.length > 0) {
477
+ // 使用配置中指定的主题白名单,清理可能的引号
478
+ const cleanWhitelist = config.themes.whitelist.map(t => t.replace(/^["']|["']$/g, ''));
479
+ selectedThemes = cleanWhitelist.filter(t => availableThemes.includes(t));
480
+ if (selectedThemes.length === 0) {
481
+ console.log(`[PlayableScriptsAdapter] ⚠️ 配置的主题未找到,使用当前默认主题`);
482
+ }
483
+ }
484
+ if (selectedThemes.length > 0) {
485
+ args.push('-t', selectedThemes.join(','));
486
+ console.log(`[PlayableScriptsAdapter] 构建主题: ${selectedThemes.join(', ')}`);
507
487
  }
508
488
  else {
509
- // 单渠道模式: <devkit> build <channel> [options]
510
- const channel = config.channels?.[0]
511
- ? mapChannel(config.channels[0])
512
- : 'google';
513
- const filteredArgs = this.removeArg(cliArgs, '--channels');
514
- args = ['build', channel, ...filteredArgs];
515
- modeLabel = `单渠道构建模式: ${channel}`;
516
- }
517
- console.log(`[PlayableScriptsAdapter] ${modeLabel}`);
489
+ // 未指定主题:使用 devkit 默认行为(当前 index.ts 指向的主题)
490
+ console.log(`[PlayableScriptsAdapter] 使用当前默认主题 (src/theme/index.ts)`);
491
+ }
518
492
  // devkit 必须通过 npm script 调用,不能直接执行 .js 文件
519
- // 1. 确保 package.json 中有 build/builds 脚本
520
493
  await this.ensureBuildScripts();
521
- // 2. 通过 pnpm run 执行
522
- const scriptName = isBatchMode ? 'builds' : 'build';
523
- // 单渠道: args = ['build', channel, ...options],需要传递 [channel, ...options]
524
- // 批量: args = ['builds', ...options],需要传递 [...options]
525
- const runArgs = isBatchMode
526
- ? args.slice(1) // builds 命令,去掉第一个 'builds'
527
- : args.slice(1); // build 命令,去掉第一个 'build',保留 channel 和所有参数
494
+ const scriptName = 'builds';
495
+ const runArgs = args.slice(1);
528
496
  console.log(`[PlayableScriptsAdapter] 运行: pnpm run ${scriptName} ${runArgs.length > 0 ? runArgs.join(' ') : ''}`);
529
497
  try {
530
- // 注意: package.json 的 scripts 已经是 "playable-scripts build/builds"
498
+ // 注意: package.json 的 scripts 已经是 "playable-scripts builds"
531
499
  // 所以不需要 '--' 分隔符,直接传参数即可
532
500
  const pnpmArgs = runArgs.length > 0
533
501
  ? ['run', scriptName, ...runArgs]
@@ -566,21 +534,6 @@ export class PlayableScriptsAdapter {
566
534
  console.error(`[PlayableScriptsAdapter] 请检查 devkit 构建日志,确认构建是否成功`);
567
535
  }
568
536
  }
569
- /**
570
- * 从参数列表中移除指定参数及其值
571
- */
572
- removeArg(args, argName) {
573
- const result = [];
574
- for (let i = 0; i < args.length; i++) {
575
- if (args[i] === argName) {
576
- i++; // 跳过参数值
577
- }
578
- else {
579
- result.push(args[i]);
580
- }
581
- }
582
- return result;
583
- }
584
537
  /**
585
538
  * 统一构造 pnpm CLI 参数:
586
539
  * - 透传通用 flag(dangerouslyAllowAllBuilds / strict-peer-dependencies / reporter)
@@ -971,8 +924,18 @@ export class PlayableScriptsAdapter {
971
924
  }
972
925
  }
973
926
  /**
974
- * 确保 package.json 中有 build/builds 脚本
975
- * devkit 必须通过 npm script 调用,不能直接执行 .js 文件
927
+ * 确保 package.json 的 `builds` 脚本存在,且**内容确实是 `playable-scripts builds`**。
928
+ *
929
+ * 为什么不能只判断"存在":我们最终执行的是 `pnpm run builds`,如果项目里的 `builds`
930
+ * 还写着老命令 `playable-scripts build`(老模板 / 手工改过),即使平台代码已经统一,
931
+ * 实际跑的仍是单渠道 legacy 命令 —— 输出布局、默认 network/protocol、是否追加 preview
932
+ * 都会跟 `builds` 不一样,等于历史问题复活。
933
+ *
934
+ * 处理策略(保留前置步骤,如 `node scripts/generate-levels.js &&`):
935
+ * 1. 已是 `playable-scripts builds` → 不动
936
+ * 2. 存在但含 `playable-scripts build` → 就地改写为 `builds`
937
+ * 3. 不存在 → 从 `build` 脚本推导(保留其前置步骤),否则注入纯 `playable-scripts builds`
938
+ * 4. 存在但完全自定义(不含 playable-scripts)→ 直接报错,避免静默跑错命令
976
939
  */
977
940
  async ensureBuildScripts() {
978
941
  const pkgPath = path.join(this.projectDir, 'package.json');
@@ -982,25 +945,37 @@ export class PlayableScriptsAdapter {
982
945
  if (!pkg.scripts) {
983
946
  pkg.scripts = {};
984
947
  }
985
- let modified = false;
986
- // 确保有 build 脚本
987
- if (!pkg.scripts.build) {
988
- pkg.scripts.build = 'playable-scripts build';
989
- modified = true;
990
- console.log('[PlayableScriptsAdapter] 注入 package.json script: build');
991
- }
992
- // 确保有 builds 脚本
993
- if (!pkg.scripts.builds) {
994
- pkg.scripts.builds = 'playable-scripts builds';
995
- modified = true;
996
- console.log('[PlayableScriptsAdapter] 注入 package.json script: builds');
948
+ // `\b` 在 build|s 之间不成立,所以 build\b 不会误匹配 builds
949
+ const BUILDS_CMD = /\bplayable-scripts\s+builds\b/;
950
+ const LEGACY_BUILD_CMD = /\bplayable-scripts\s+build\b/;
951
+ const currentBuilds = typeof pkg.scripts.builds === 'string' ? pkg.scripts.builds : '';
952
+ const currentBuild = typeof pkg.scripts.build === 'string' ? pkg.scripts.build : '';
953
+ if (BUILDS_CMD.test(currentBuilds)) {
954
+ return;
955
+ }
956
+ if (currentBuilds.trim()) {
957
+ if (!LEGACY_BUILD_CMD.test(currentBuilds)) {
958
+ throw new Error(`[PlayableScriptsAdapter] package.json 的 scripts.builds 不是 playable-scripts builds:` +
959
+ `"${currentBuilds}"。单渠道与多渠道统一要求通过 builds 构建,请修正该脚本后重试。`);
960
+ }
961
+ pkg.scripts.builds = currentBuilds.replace(LEGACY_BUILD_CMD, 'playable-scripts builds');
962
+ console.log(`[PlayableScriptsAdapter] 修复 legacy builds 脚本: "${currentBuilds}" -> "${pkg.scripts.builds}"`);
997
963
  }
998
- if (modified) {
999
- await fs.writeFile(pkgPath, JSON.stringify(pkg, null, 2), 'utf-8');
1000
- console.log('[PlayableScriptsAdapter] ✓ package.json 已更新');
964
+ else {
965
+ // 从 build 脚本推导,保留其前置步骤(如 node scripts/generate-levels.js &&)
966
+ pkg.scripts.builds = LEGACY_BUILD_CMD.test(currentBuild)
967
+ ? currentBuild.replace(LEGACY_BUILD_CMD, 'playable-scripts builds')
968
+ : 'playable-scripts builds';
969
+ console.log(`[PlayableScriptsAdapter] 注入 package.json script: builds -> "${pkg.scripts.builds}"`);
1001
970
  }
971
+ await fs.writeFile(pkgPath, JSON.stringify(pkg, null, 2), 'utf-8');
972
+ console.log('[PlayableScriptsAdapter] ✓ package.json 已更新');
1002
973
  }
1003
974
  catch (error) {
975
+ // 主动抛出的错误(自定义 builds 脚本)需要冒泡,不能吞掉
976
+ if (error instanceof Error && error.message.includes('不是 playable-scripts builds')) {
977
+ throw error;
978
+ }
1004
979
  console.warn('[PlayableScriptsAdapter] ⚠️ 无法更新 package.json:', error.message);
1005
980
  }
1006
981
  }
@@ -1104,13 +1079,28 @@ export class PlayableScriptsAdapter {
1104
1079
  channel = firstPart;
1105
1080
  }
1106
1081
  else {
1107
- // 可能是主题目录(没有渠道子目录)
1108
- // 这种情况不太可能,但保留兼容性
1082
+ // devkit 正式布局是 dist/<theme>/<file>(见 core/batch-build.js copyTempFiles:
1083
+ // 「最终结构:outputDir/themeName/*.zip|*.html」),这里记下主题,渠道从文件名补判
1109
1084
  theme = firstPart;
1110
1085
  }
1111
1086
  }
1112
1087
  // parts.length === 1: 直接在 dist 下的文件(单渠道模式)
1088
+ // 路径判断不出渠道时,按 devkit 命名 `<主题>-<渠道>[_L|_P].<ext>` 从文件名补判。
1089
+ // 不做这一步会导致所有产物都是 unknown,目标渠道过滤(尤其 preview)全部失效。
1090
+ if (channel === 'unknown') {
1091
+ const matched = targetChannels.find((ch) => artifactMatchesChannel(entry.name, ch));
1092
+ if (matched) {
1093
+ channel = matched;
1094
+ }
1095
+ else if (artifactMatchesChannel(entry.name, 'preview')) {
1096
+ // devkit 固定追加 preview 渠道产出预览文件(core/batch-build.js 无条件 push),
1097
+ // 文件名同样带 -preview 后缀
1098
+ channel = 'preview';
1099
+ }
1100
+ }
1113
1101
  // 过滤:只收集目标渠道的产物
1102
+ // 注:devkit 无论 `--channels` 传什么都会额外构建 preview 渠道(用于上传预览),
1103
+ // 非 preview 构建必须把它排除,否则 preview.html 会顶掉目标渠道的 zip。
1114
1104
  if (targetChannels.length > 0 && channel !== 'unknown' && !targetChannels.includes(channel)) {
1115
1105
  console.log(`[PlayableScriptsAdapter] 跳过非目标渠道: ${channel} (文件: ${relativePath})`);
1116
1106
  continue;
@@ -1125,29 +1115,6 @@ export class PlayableScriptsAdapter {
1125
1115
  }
1126
1116
  }
1127
1117
  // ==================== 6. 复制到输出目录 ====================
1128
- /**
1129
- * 清理输出目录,删除所有旧产物
1130
- */
1131
- async cleanOutputDir(dir) {
1132
- try {
1133
- await fs.access(dir);
1134
- // 目录存在,递归删除所有内容
1135
- const entries = await fs.readdir(dir, { withFileTypes: true });
1136
- for (const entry of entries) {
1137
- const fullPath = path.join(dir, entry.name);
1138
- if (entry.isDirectory()) {
1139
- await this.removeDirRecursive(fullPath);
1140
- }
1141
- else {
1142
- await fs.unlink(fullPath);
1143
- }
1144
- }
1145
- console.log(`[PlayableScriptsAdapter] 已清理输出目录: ${dir}`);
1146
- }
1147
- catch {
1148
- // 目录不存在,无需清理
1149
- }
1150
- }
1151
1118
  /**
1152
1119
  * 递归删除目录
1153
1120
  */
@@ -1178,7 +1145,7 @@ export class PlayableScriptsAdapter {
1178
1145
  // 判断构建模式
1179
1146
  const isBatchMode = (this.config?.channels && this.config.channels.length > 1) ||
1180
1147
  (this.config?.themes?.enabled);
1181
- // 按渠道分组产物(每个渠道只保留一个产物,优先 HTML,其次 ZIP)
1148
+ // 按渠道分组产物(每个渠道只保留一个交付产物,选择规则见 pickOutputForChannel)
1182
1149
  const channelOutputs = new Map();
1183
1150
  for (const output of outputs) {
1184
1151
  const channelList = channelOutputs.get(output.channel) || [];
@@ -1195,19 +1162,7 @@ export class PlayableScriptsAdapter {
1195
1162
  }
1196
1163
  }
1197
1164
  for (const [channel, channelFiles] of channelOutputs) {
1198
- // 选择最佳产物:优先带渠道名的文件,其次 HTML,最后 ZIP
1199
- let bestOutput = channelFiles[0];
1200
- for (const output of channelFiles) {
1201
- // 优先选择带有完整命名的文件(如 xxx-facebook.html)
1202
- if (output.fileName.includes(channel) || output.fileName.includes('-')) {
1203
- bestOutput = output;
1204
- break;
1205
- }
1206
- // 其次优先 HTML
1207
- if (output.fileName.endsWith('.html') && bestOutput.fileName.endsWith('.zip')) {
1208
- bestOutput = output;
1209
- }
1210
- }
1165
+ const bestOutput = this.pickOutputForChannel(channel, channelFiles);
1211
1166
  // 确定目标文件名
1212
1167
  let destFileName;
1213
1168
  // HTML 文件统一重命名为 index.html(与 PlayCanvas 项目保持一致)
@@ -1260,6 +1215,54 @@ export class PlayableScriptsAdapter {
1260
1215
  console.log(`[PlayableScriptsAdapter] 产物已复制到: ${this.options.outputDir}`);
1261
1216
  return files;
1262
1217
  }
1218
+ /**
1219
+ * 从同一渠道的多个产物中选出交付用的那一个。
1220
+ *
1221
+ * 旧实现是「文件名含 `-` 就 break,其次 HTML 优先」,在 devkit 扁平布局下会把
1222
+ * `xxx-preview.html` 选成目标渠道产物(snapchat 构建最终交付了预览 html)。
1223
+ *
1224
+ * 现在的优先级(打分):
1225
+ * 1. 文件名命中目标渠道(devkit 命名 `<主题>-<渠道>[_L|_P].<ext>`) +4
1226
+ * 2. 与请求格式一致(请求 zip 优先 .zip,请求 html 优先 .html) +2
1227
+ * 3. zip 兜底(同为候选时优先交付压缩包) +1
1228
+ * 4. 无方向后缀的基准产物优先 +0.5
1229
+ *
1230
+ * 出现多份等价产物时打印完整候选清单,避免再出现「静默挑了一个错的」。
1231
+ */
1232
+ pickOutputForChannel(channel, channelFiles) {
1233
+ if (channelFiles.length <= 1)
1234
+ return channelFiles[0];
1235
+ const wantedExt = this.isZipOutput() ? '.zip' : '.html';
1236
+ const scoreOf = (file) => {
1237
+ const name = file.fileName.toLowerCase();
1238
+ let score = 0;
1239
+ if (channel !== 'unknown' && artifactMatchesChannel(file.fileName, channel.toLowerCase())) {
1240
+ score += 4;
1241
+ }
1242
+ if (name.endsWith(wantedExt))
1243
+ score += 2;
1244
+ if (name.endsWith('.zip'))
1245
+ score += 1;
1246
+ if (!/_[lp]\.(html|zip)$/i.test(name))
1247
+ score += 0.5;
1248
+ return score;
1249
+ };
1250
+ const ranked = [...channelFiles].sort((a, b) => {
1251
+ const diff = scoreOf(b) - scoreOf(a);
1252
+ return diff !== 0 ? diff : a.fileName.localeCompare(b.fileName);
1253
+ });
1254
+ if (ranked.length > 1 && scoreOf(ranked[0]) === scoreOf(ranked[1])) {
1255
+ console.warn(`[PlayableScriptsAdapter] 渠道 ${channel} 存在多份等价产物,已按文件名选择 ${ranked[0].fileName}` +
1256
+ `(候选: ${ranked.map((file) => file.fileName).join(', ')})`);
1257
+ }
1258
+ const chosen = ranked[0];
1259
+ const expectedExt = this.isZipOutput() ? '.zip' : '.html';
1260
+ if (!chosen.fileName.toLowerCase().endsWith(expectedExt)) {
1261
+ console.warn(`[PlayableScriptsAdapter] 渠道 ${channel} 请求格式为 ${expectedExt.slice(1)},` +
1262
+ `但可用产物中没有该格式,将使用 ${chosen.fileName}(下游可能报格式不符)`);
1263
+ }
1264
+ return chosen;
1265
+ }
1263
1266
  /**
1264
1267
  * 是否对 playable-scripts 使用 --zip。
1265
1268
  * 双格式渠道默认 HTML;仅在请求或配置显式选择 ZIP 时传递 --zip。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@playcraft/build",
3
- "version": "0.1.50-beta",
3
+ "version": "0.1.51",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",