diff --git a/apps/ai-game-creator-shell/scripts/skill-pack-manifest.mjs b/apps/ai-game-creator-shell/scripts/skill-pack-manifest.mjs index c03be2ede..a560f09e9 100644 --- a/apps/ai-game-creator-shell/scripts/skill-pack-manifest.mjs +++ b/apps/ai-game-creator-shell/scripts/skill-pack-manifest.mjs @@ -15,6 +15,7 @@ export const EXPECTED_SKILL_NAMES = Object.freeze([ 'agc-web-game-development', 'platform-abstract', 'taonier-art-assets', + 'vite-export-taonier', 'vite-export-xhs-minitool', ]); diff --git a/apps/ai-game-creator-shell/src-tauri/prompts/runtime/texts/execution.json b/apps/ai-game-creator-shell/src-tauri/prompts/runtime/texts/execution.json index bef5c46e7..b9fd08ea2 100644 --- a/apps/ai-game-creator-shell/src-tauri/prompts/runtime/texts/execution.json +++ b/apps/ai-game-creator-shell/src-tauri/prompts/runtime/texts/execution.json @@ -6,7 +6,6 @@ "playtest.tetris": "完成合同要求 tetris-v1 交互试玩。game/index.html 必须持续更新 '; + +/** 造一个最小的 H5 构建产物目录;extra 用来放额外文件。 */ +function writeBuiltDir(name, extra = {}) { + const root = join(BASE, name); + mkdirSync(join(root, 'assets'), { recursive: true }); + writeFileSync(join(root, 'index.html'), VALID_HTML, 'utf8'); + writeFileSync(join(root, 'assets', 'app.js'), "console.log('ok');\n", 'utf8'); + for (const [relativePath, content] of Object.entries(extra)) { + const path = join(root, relativePath); + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, content, 'utf8'); + } + return root; +} + +/** 直接解析中央目录读取 zip 条目名:验证真实写出的字节,而不是只看返回值。 */ +function zipEntryNames(zipPath) { + const buf = readFileSync(zipPath); + const eocd = buf.length - 22; + assert.equal(buf.readUInt32LE(eocd), 0x06054b50, '未找到 EOCD'); + const count = buf.readUInt16LE(eocd + 10); + let offset = buf.readUInt32LE(eocd + 16); + const names = []; + for (let i = 0; i < count; i += 1) { + assert.equal(buf.readUInt32LE(offset), 0x02014b50, '中央目录签名错误'); + const nameLength = buf.readUInt16LE(offset + 28); + const extraLength = buf.readUInt16LE(offset + 30); + const commentLength = buf.readUInt16LE(offset + 32); + names.push(buf.subarray(offset + 46, offset + 46 + nameLength).toString('utf8')); + offset += 46 + nameLength + extraLength + commentLength; + } + return names; +} + +test('干净产物默认不套外层:index.html 直接位于 zip 存档根', () => { + writeBuiltDir('clean'); + const zipOut = join(BASE, 'clean-out', 'taonier.zip'); + assert.equal(packTaonier({ cwd: BASE, viteBuiltDir: 'clean', zipOut }), 0); + + const names = zipEntryNames(zipOut); + assert.ok(names.includes('index.html'), `缺少存档根 index.html: ${names}`); + assert.ok(names.includes('assets/app.js'), `缺少 assets/app.js: ${names}`); + // 不得有外层文件夹:条目不许多出 game/ 之类的前缀。 + assert.deepEqual( + names.filter((name) => !name.startsWith('assets/') && name !== 'index.html'), + [], + `存档根出现了计划外条目: ${names}`, + ); + assert.ok(!names.includes('game/index.html'), `默认不应套外层文件夹: ${names}`); +}); + +test('显式传 rootDir: null 也不套外层文件夹', () => { + writeBuiltDir('explicit-null-root'); + const zipOut = join(BASE, 'explicit-null-root-out', 'taonier.zip'); + assert.equal( + packTaonier({ cwd: BASE, viteBuiltDir: 'explicit-null-root', zipOut, rootDir: null }), + 0, + ); + const names = zipEntryNames(zipOut); + assert.ok(names.includes('index.html'), `缺少存档根 index.html: ${names}`); + assert.ok(!names.some((name) => name.startsWith('game/')), `不应套外层: ${names}`); +}); + +test('--root-dir 可选:给了才套指定顶层文件夹', () => { + writeBuiltDir('custom-root'); + const zipOut = join(BASE, 'custom-root-out', 'taonier.zip'); + assert.equal( + packTaonier({ cwd: BASE, viteBuiltDir: 'custom-root', zipOut, rootDir: 'my-h5' }), + 0, + ); + const names = zipEntryNames(zipOut); + assert.ok(names.includes('my-h5/index.html'), `缺少 my-h5/index.html: ${names}`); + assert.deepEqual(names.filter((name) => !name.startsWith('my-h5/')), []); +}); + +test('产物根缺少 index.html 时整体失败,且不写出 zip', () => { + const root = join(BASE, 'no-index'); + mkdirSync(join(root, 'assets'), { recursive: true }); + writeFileSync(join(root, 'assets', 'app.js'), 'console.log(1);\n', 'utf8'); + const zipOut = join(BASE, 'no-index-out', 'taonier.zip'); + assert.throws( + () => packTaonier({ cwd: BASE, viteBuiltDir: 'no-index', zipOut }), + /缺少 index\.html/, + ); + assert.equal(existsSync(zipOut), false, '失败时不应写出 zip'); +}); + +test('只接受 .zip 落点', () => { + writeBuiltDir('only-zip'); + assert.throws( + () => packTaonier({ cwd: BASE, viteBuiltDir: 'only-zip', zipOut: 'only-zip-out/game.tar' }), + /只支持 \.zip/, + ); +}); + +test('非法 --root-dir 被拒绝', () => { + writeBuiltDir('bad-root'); + for (const rootDir of ['', '..', 'a/b', 'a\\b']) { + assert.throws( + () => packTaonier({ cwd: BASE, viteBuiltDir: 'bad-root', zipOut: join(BASE, 'x.zip'), rootDir }), + /--root-dir/, + `rootDir=${JSON.stringify(rootDir)} 未被拒绝`, + ); + } +}); + +test('zip 内含 sourcemap 时失败并删除已写出的文件', () => { + writeBuiltDir('with-map', { 'assets/app.js.map': '{}\n' }); + const zipOut = join(BASE, 'with-map-out', 'taonier.zip'); + assert.throws( + () => packTaonier({ cwd: BASE, viteBuiltDir: 'with-map', zipOut }), + /禁止文件/, + ); + assert.equal(existsSync(zipOut), false, '不合规的 zip 不应留在落点'); +}); + +test('普通 .map 游戏资源不算 sourcemap', () => { + writeBuiltDir('with-level-map', { 'assets/level.map': '{"layers":[]}\n' }); + const zipOut = join(BASE, 'with-level-map-out', 'taonier.zip'); + assert.equal(packTaonier({ cwd: BASE, viteBuiltDir: 'with-level-map', zipOut }), 0); + assert.ok(zipEntryNames(zipOut).includes('assets/level.map')); +}); + +test('--zip-out 落在产物目录内时被拒绝', () => { + const root = writeBuiltDir('zip-inside'); + assert.throws( + () => packTaonier({ + cwd: BASE, + viteBuiltDir: 'zip-inside', + zipOut: join(root, 'taonier.zip'), + }), + /不能落在 --vite-built-dir 内/, + ); +}); + +test('产物含符号链接时失败', () => { + const root = writeBuiltDir('with-symlink'); + symlinkSync(join(root, 'assets', 'app.js'), join(root, 'assets', 'link.js')); + assert.throws( + () => packTaonier({ cwd: BASE, viteBuiltDir: 'with-symlink', zipOut: join(BASE, 'symlink.zip') }), + /符号链接/, + ); +}); + +test('默认参数:dist-taonier + 存档根 index.html + ../.export/taonier.zip(假设 cwd 是 game/)', () => { + const game = join(BASE, 'default-layout', 'game'); + writeBuiltDir(join('default-layout', 'game', 'dist-taonier')); + assert.equal(packTaonier({ cwd: game }), 0); + const zipOut = join(BASE, 'default-layout', '.export', 'taonier.zip'); + assert.ok(existsSync(zipOut), '默认落点不在项目根的 .export/taonier.zip'); + const names = zipEntryNames(zipOut); + assert.ok(names.includes('index.html'), `默认应当在存档根放 index.html: ${names}`); + assert.ok(!names.includes('game/index.html'), `默认不应套外层文件夹: ${names}`); +}); + +test('CLI 拒绝不认识的参数,不写出 zip', () => { + const dir = writeBuiltDir('cli'); + const zipOut = join(BASE, 'cli-out.zip'); + const result = spawnSync( + process.execPath, + [PACK, '--vite-built-dir', dir, '--out-dir', dir, '--zip-out', zipOut], + { cwd: BASE, encoding: 'utf8' }, + ); + assert.equal(result.status, 2); + assert.match(result.stderr, /不认识的参数/); + assert.equal(existsSync(zipOut), false, '参数被拒时不应写出 zip'); +}); diff --git a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/vite-export-taonier/scripts/.vite.config.taonier.test.mjs b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/vite-export-taonier/scripts/.vite.config.taonier.test.mjs new file mode 100644 index 000000000..89ed5f100 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/vite-export-taonier/scripts/.vite.config.taonier.test.mjs @@ -0,0 +1,156 @@ +#!/usr/bin/env node +/** + * vite.config.taonier.mjs 的收尾插件端到端测试:真跑一遍 `vite build`,验证 dist 在 build + * 结束时已经是可托管的 H5 根目录(根 index.html、资源相对路径、空目录被清掉),而 pack 只 + * 负责套单层文件夹并打 zip。 + * + * 这是本地开发辅助(`.` 前缀),不随 Skill Pack 发布。 + */ +import assert from 'node:assert/strict'; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import test, { after } from 'node:test'; + +import { packTaonier } from './pack.mjs'; +import { + TAONIER_OUT_DIR, + assertH5Root, + defineTaonierConfig, + pruneEmptyDirs, + taonierArtifactPlugin, +} from './vite.config.taonier.mjs'; + +const BASE = mkdtempSync(join(tmpdir(), 'taonier-vite-config-tests-')); + +after(() => { + rmSync(BASE, { recursive: true, force: true }); +}); + +const SOURCE_HTML = '\n' + + '\n' + + ' \n' + + ' \n' + + ' \n' + + ' fixture\n' + + ' \n' + + ' \n' + + '
\n' + + ' \n' + + ' \n' + + '\n'; + +/** 造一个最小 vite 项目;返回项目根。 */ +function writeProject(name) { + const root = join(BASE, name); + mkdirSync(join(root, 'src'), { recursive: true }); + mkdirSync(join(root, 'public', 'empty-dir'), { recursive: true }); + writeFileSync(join(root, 'index.html'), SOURCE_HTML, 'utf8'); + writeFileSync( + join(root, 'src', 'main.js'), + "document.getElementById('app').textContent = 'ok';\n", + 'utf8', + ); + return root; +} + +test('config 的 outDir 固定为 dist-taonier', () => { + assert.equal(TAONIER_OUT_DIR, 'dist-taonier'); + assert.equal(defineTaonierConfig({}).build.outDir, 'dist-taonier'); +}); + +test('vite 构建收尾:dist 可直接被打成存档根带 index.html 的 zip', async () => { + const root = writeProject('finalized'); + const { build } = await import('vite'); + const config = defineTaonierConfig({ root, logLevel: 'silent' }); + + await build({ ...config, configFile: false }); + const outDir = join(root, 'dist-taonier'); + + assert.ok(existsSync(join(outDir, 'index.html')), 'index.html 未生成'); + const html = readFileSync(join(outDir, 'index.html'), 'utf8'); + assert.doesNotMatch(html, /\b(?:src|href)="\//, '仍有根路径资源引用'); + assert.match(html, /src="\.\/assets\/[^"]+\.js"/, '入口脚本不是相对路径'); + // public/empty-dir 是空目录,收尾后不应留在 dist。 + assert.equal(existsSync(join(outDir, 'empty-dir')), false, '空目录没有被清理'); + + const zipOut = join(BASE, 'out', 'taonier.zip'); + assert.equal(packTaonier({ cwd: root, zipOut }), 0); + assert.ok(existsSync(zipOut), 'zip 未生成'); +}); + +test('base 被改成根路径时构建失败', async () => { + const root = writeProject('absolute-base'); + const { build } = await import('vite'); + const config = defineTaonierConfig({ root, logLevel: 'silent', base: '/' }); + + await assert.rejects( + build({ ...config, configFile: false }), + /根路径资源/, + '根路径引用没有被收尾插件拦下', + ); +}); + +test('构建失败时不收尾:不覆盖真实报错', async () => { + const root = join(BASE, 'failed-build'); + mkdirSync(join(root, 'src'), { recursive: true }); + writeFileSync(join(root, 'index.html'), SOURCE_HTML, 'utf8'); + writeFileSync(join(root, 'src', 'main.js'), 'this is not valid js {{\n', 'utf8'); + + const { build } = await import('vite'); + const config = defineTaonierConfig({ root, logLevel: 'silent' }); + await assert.rejects( + build({ ...config, configFile: false }), + (error) => { + assert.doesNotMatch(error.message, /taonier-artifact/, + '插件把自己的报错盖在了真实构建错误上'); + return true; + }, + ); + + assert.equal(existsSync(join(root, 'dist-taonier', 'index.html')), false, + '失败的构建不该有 index.html'); +}); + +test('defineTaonierConfig 始终追加收尾插件,且只跑一次', () => { + const withExtra = defineTaonierConfig({ plugins: [{ name: 'extra' }] }); + const names = withExtra.plugins.map((plugin) => plugin.name); + assert.ok(names.includes('taonier-artifact'), `缺少收尾插件: ${names}`); + assert.equal( + names.filter((name) => name === 'taonier-artifact').length, + 1, + `收尾插件重复: ${names}`, + ); + assert.ok(names.includes('extra'), 'overrides 的插件丢了'); + assert.equal(names.at(-1), 'taonier-artifact', `收尾插件不在最后: ${names}`); +}); + +test('assertH5Root 拦截根路径引用与缺失的 index.html', () => { + const root = join(BASE, 'unit-assert'); + mkdirSync(root, { recursive: true }); + assert.throws(() => assertH5Root(root), /index\.html 不存在/); + + writeFileSync(join(root, 'index.html'), '\n', 'utf8'); + assert.throws(() => assertH5Root(root), /根路径资源/); + + writeFileSync(join(root, 'index.html'), '\n', 'utf8'); + assert.doesNotThrow(() => assertH5Root(root)); +}); + +test('pruneEmptyDirs 只删空目录,保留有内容的目录', () => { + const root = join(BASE, 'prune'); + mkdirSync(join(root, 'empty', 'nested'), { recursive: true }); + mkdirSync(join(root, 'kept'), { recursive: true }); + writeFileSync(join(root, 'kept', 'file.js'), 'console.log(1);\n', 'utf8'); + + pruneEmptyDirs(root); + + assert.equal(existsSync(join(root, 'empty')), false, '空目录未删除'); + assert.ok(existsSync(join(root, 'kept', 'file.js')), '有内容的目录被误删'); +}); + +test('taonierArtifactPlugin 声明为 build-only 的后置插件', () => { + const plugin = taonierArtifactPlugin(); + assert.equal(plugin.apply, 'build'); + assert.equal(plugin.enforce, 'post'); +}); diff --git a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/vite-export-taonier/scripts/pack.mjs b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/vite-export-taonier/scripts/pack.mjs new file mode 100644 index 000000000..b496c362c --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/vite-export-taonier/scripts/pack.mjs @@ -0,0 +1,456 @@ +#!/usr/bin/env node +/** + * 把 vite 构建产物打成陶泥儿 H5 托管 zip(纯 Node,不依赖 zip / zipinfo 系统命令)。 + * + * 陶泥儿的上传要求很短: + * - 仅支持单个 .zip 压缩包; + * - zip 存档根目录必须直接包含 index.html(**不得有外层文件夹**)。 + * + * 所以本脚本只做三件确定性的事: + * 1. 把 `--vite-built-dir`(默认 dist-taonier)的内容原样放到 zip 存档根; + * 2. 用 node:zlib 写出 zip,index.html 位于存档根 `index.html`; + * 3. 校验结构与构建目录根:不合格时整体失败,不留下可误传的 zip。 + * + * 陶泥儿服务端只认存档根 `index.html`(`game/index.html` 会被判 MissingEntry),所以默认**不套** + * 外层文件夹;`--root-dir` 只在调用方确有需要时显式给出,才会额外套一层顶层文件夹。 + * + * dist 的收尾(相对路径、根 index.html、清空目录)已由 `vite.config.taonier.mjs` 的 + * `taonier-artifact` 插件在构建时完成;本脚本只读 dist,不改写它。 + */ +import { + closeSync, + existsSync, + lstatSync, + mkdirSync, + openSync, + readdirSync, + readFileSync, + realpathSync, + rmSync, + statSync, + writeSync, +} from 'node:fs'; +import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { deflateRawSync } from 'node:zlib'; + +const MIB = 1024 * 1024; + +/** + * 默认落点:假设 npm 脚本挂在 `game/` 子工程(cwd = `game/`),宿主在**项目根** + * `.export/taonier.zip` 找产物,所以默认写 `../.export/taonier.zip`。 + * 脚本挂在项目根 `package.json`(cwd = 项目根)时用 `--zip-out .export/taonier.zip` 覆盖。 + */ +const DEFAULT_ZIP_OUT = join('..', '.export', 'taonier.zip'); +const DEFAULT_VITE_BUILT_DIR = 'dist-taonier'; + +/** + * 可选外层文件夹名。陶泥儿要求存档根直接有 index.html,所以默认 `null`=不套外层; + * 调用方显式给 `--root-dir` 时才套一层(名字不能含路径分隔符)。 + */ +const DEFAULT_ROOT_DIR = null; + +/** + * 最终包不应携带的开发/构建残留;命中即失败。 + * + * 只拦 `*.js.map` / `*.css.map`(构建生成的 sourcemap),不拦所有 `.map`——有些引擎把关卡 + * 数据存成 `level.map`,那是正常游戏资源。 + */ +const FORBIDDEN_ENTRY = + /(^|\/)(?:node_modules|\.git)\/|(^|\/)\.DS_Store$|\.(?:js|css)\.map$|(^|\/)(?:vite|webpack|rollup)\.config\.[^/]+$/i; + +const USAGE = `用法:node pack.mjs [--vite-built-dir ] [--zip-out ] [--root-dir ] + +把 vite 构建产物打成陶泥儿 H5 zip(仅 .zip,存档根直接带 index.html)。 + +--vite-built-dir vite 的构建输出目录,必须与 vite config 的 build.outDir 一致。默认 ${DEFAULT_VITE_BUILT_DIR}。 + 该目录根下必须有 index.html;本脚本只读它,不改写。 +--zip-out 落点,必须以 .zip 结尾。默认 ${DEFAULT_ZIP_OUT}(假设 cwd 是 game/ 子工程, + 产物落在项目根 .export/);相对路径按当前工作目录解析,父目录不存在会自动建。 +--root-dir 可选。默认不套外层文件夹(构建产物内容直接位于存档根);给了才把内容套进 + 单层文件夹 /。不能包含路径分隔符。`; + +/** 顶层文件夹名必须是单个安全路径段:非空、不含分隔符、不是 . / ..;`null` 表示不套外层。 */ +export function normalizeRootDir(value) { + if (value === null || value === undefined) return null; + const name = String(value).trim(); + if (!name || name === '.' || name === '..') { + throw new Error('--root-dir 必须是一个非空的文件夹名'); + } + if (name.includes('/') || name.includes('\\')) { + throw new Error(`--root-dir 不能包含路径分隔符:${name}`); + } + return name; +} + +/* ------------------------------------------------------------------ */ +/* 纯 Node zip 写入(store / deflate,UTF-8 文件名,ZIP64 直接报错) */ +/* ------------------------------------------------------------------ */ + +const CRC_TABLE = (() => { + const table = new Uint32Array(256); + for (let n = 0; n < 256; n += 1) { + let c = n; + for (let k = 0; k < 8; k += 1) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; + table[n] = c >>> 0; + } + return table; +})(); + +function crc32(buffer) { + let c = 0xffffffff; + for (let i = 0; i < buffer.length; i += 1) { + c = CRC_TABLE[(c ^ buffer[i]) & 0xff] ^ (c >>> 8); + } + return (c ^ 0xffffffff) >>> 0; +} + +/** + * 收集 dist 下所有条目。`rootName` 为 `null` 时不加前缀:构建目录内容直接落在存档根 + * (`index.html`、`assets/...`);给了 `rootName` 才整体挂到 `/` 前缀下。 + * + * 目录条目在前、文件名用 `/` 分隔、保持排序,产物稳定。套外层时顶层文件夹自身也写一个目录 + * 条目,这样即使 dist 为空(实际不会),解压仍能得到唯一文件夹。 + */ +function collectZipEntries(rootDir, rootName) { + const prefix = rootName ? `${rootName}/` : ''; + const entries = rootName ? [{ name: prefix, dir: true, full: rootDir }] : []; + const visit = (dir) => { + for (const name of readdirSync(dir).sort()) { + const full = join(dir, name); + const st = lstatSync(full); + const nameInZip = `${prefix}${relative(rootDir, full).split(sep).join('/')}`; + if (st.isDirectory()) { + entries.push({ name: `${nameInZip}/`, dir: true, full }); + visit(full); + } else if (st.isFile()) { + entries.push({ name: nameInZip, dir: false, full }); + } + // 符号链接已在 assertNoSymlinks 拒绝,这里不会遇到。 + } + }; + visit(rootDir); + return entries; +} + +/** + * 写出 zip。数据按条目顺序**流式**写盘,只在内存里保留中央目录,避免大包在内存里再拼一份完整副本。 + * + * @returns {string[]} zip 内条目名(含目录条目),用于结构复核 + */ +export function writeZip(rootDir, zipPath, rootName) { + return writeZipEntries(collectZipEntries(rootDir, rootName), zipPath); +} + +/** + * 把已收集的条目流式写入 zip。 + * + * 单独抽一层是为了让调用方能先把条目列表拿去做结构校验、再落盘:结构不合规时落点上不能出现 + * 半成品,也不能把落点原有的文件覆盖掉。数据本身仍然边读边写,不在内存里拼完整副本。 + */ +function writeZipEntries(entries, zipPath) { + if (entries.length > 0xffff) { + throw new Error(`zip 条目数 ${entries.length} 超过 65535,当前 Node zip 实现不支持 ZIP64`); + } + + const now = new Date(); + const dosTime = + ((now.getHours() << 11) | (now.getMinutes() << 5) | (now.getSeconds() >> 1)) & 0xffff; + const dosDate = + (((now.getFullYear() - 1980) << 9) | ((now.getMonth() + 1) << 5) | now.getDate()) & 0xffff; + + const fd = openSync(zipPath, 'w'); + const centralParts = []; + let offset = 0; + + try { + for (const entry of entries) { + const nameBuf = Buffer.from(entry.name, 'utf8'); + let data = Buffer.alloc(0); + let method = 0; + let crc = 0; + let uncompressed = 0; + + if (!entry.dir) { + const raw = readFileSync(entry.full); + uncompressed = raw.length; + const deflated = deflateRawSync(raw, { level: 9 }); + if (deflated.length < raw.length) { + data = deflated; + method = 8; + } else { + data = raw; + } + crc = crc32(raw); + } + + if (data.length > 0xffffffff || uncompressed > 0xffffffff || offset > 0xffffffff) { + throw new Error(`条目 ${entry.name} 超过 4 GiB,当前 Node zip 实现不支持 ZIP64`); + } + + const local = Buffer.alloc(30); + local.writeUInt32LE(0x04034b50, 0); + local.writeUInt16LE(20, 4); + local.writeUInt16LE(0x0800, 6); + local.writeUInt16LE(method, 8); + local.writeUInt16LE(dosTime, 10); + local.writeUInt16LE(dosDate, 12); + local.writeUInt32LE(crc, 14); + local.writeUInt32LE(data.length, 18); + local.writeUInt32LE(uncompressed, 22); + local.writeUInt16LE(nameBuf.length, 26); + local.writeUInt16LE(0, 28); + + writeSync(fd, local); + writeSync(fd, nameBuf); + if (data.length > 0) writeSync(fd, data); + + const central = Buffer.alloc(46); + central.writeUInt32LE(0x02014b50, 0); + central.writeUInt16LE(20, 4); + central.writeUInt16LE(20, 6); + central.writeUInt16LE(0x0800, 8); + central.writeUInt16LE(method, 10); + central.writeUInt16LE(dosTime, 12); + central.writeUInt16LE(dosDate, 14); + central.writeUInt32LE(crc, 16); + central.writeUInt32LE(data.length, 20); + central.writeUInt32LE(uncompressed, 24); + central.writeUInt16LE(nameBuf.length, 28); + central.writeUInt16LE(0, 30); + central.writeUInt16LE(0, 32); + central.writeUInt16LE(0, 34); + central.writeUInt16LE(0, 36); + central.writeUInt32LE(entry.dir ? 0x10 : 0, 38); + central.writeUInt32LE(offset, 42); + centralParts.push(central, nameBuf); + + offset += local.length + nameBuf.length + data.length; + } + + const centralBuf = Buffer.concat(centralParts); + if (centralBuf.length > 0xffffffff) { + throw new Error('中央目录超过 4 GiB,当前 Node zip 实现不支持 ZIP64'); + } + const centralOffset = offset; + writeSync(fd, centralBuf); + + const eocd = Buffer.alloc(22); + eocd.writeUInt32LE(0x06054b50, 0); + eocd.writeUInt16LE(0, 4); + eocd.writeUInt16LE(0, 6); + eocd.writeUInt16LE(entries.length, 8); + eocd.writeUInt16LE(entries.length, 10); + eocd.writeUInt32LE(centralBuf.length, 12); + eocd.writeUInt32LE(centralOffset, 16); + eocd.writeUInt16LE(0, 20); + writeSync(fd, eocd); + } finally { + closeSync(fd); + } + + return entries.map((entry) => entry.name); +} + +/** + * 复核 zip 结构:存档根直接包含 `index.html`。 + * + * `rootName` 为 `null`(默认)时要求条目名恰好是 `index.html`;给了 `rootName` 时要求 + * `/index.html`,且所有条目都在该文件夹内。 + * + * @param {string[]} entries writeZip 返回的条目名 + * @param {string | null} rootName + */ +function assertArchiveStructure(entries, rootName) { + const prefix = rootName ? `${rootName}/` : ''; + const indexName = `${prefix}index.html`; + if (!entries.includes(indexName)) { + throw new Error( + `zip 存档根缺少 ${indexName};陶泥儿要求存档根目录直接包含 index.html(不得有外层文件夹)`, + ); + } + if (rootName) { + const outside = entries.find((name) => !name.startsWith(prefix)); + if (outside) { + throw new Error(`zip 里出现 ${prefix} 之外的条目:${outside};套了 --root-dir 时所有条目都必须在其中`); + } + } + const bad = entries + .map((name) => name.slice(prefix.length)) + .find((name) => FORBIDDEN_ENTRY.test(name)); + if (bad) { + throw new Error(`zip 内含禁止文件:${prefix}${bad}`); + } +} + +/** 产物目录必须存在、根下有 index.html,且不含符号链接(链接不会被打进包)。 */ +function assertPackedSource(viteBuiltDir) { + if (!existsSync(viteBuiltDir)) { + throw new Error(`缺少 vite 构建产物目录 ${viteBuiltDir}`); + } + if (!statSync(viteBuiltDir).isDirectory()) { + throw new Error(`vite 构建产物路径不是目录 ${viteBuiltDir}`); + } + const indexPath = join(viteBuiltDir, 'index.html'); + if (!existsSync(indexPath)) { + throw new Error( + `产物根目录缺少 index.html:${indexPath};` + + '陶泥儿要求 zip 存档根目录直接包含 index.html', + ); + } + assertNoSymlinks(viteBuiltDir); +} + +function assertNoSymlinks(dir) { + for (const name of readdirSync(dir)) { + const full = join(dir, name); + const st = lstatSync(full); + if (st.isSymbolicLink()) { + throw new Error(`产物目录含符号链接:${full};请复制真实文件后再打包`); + } + if (st.isDirectory()) assertNoSymlinks(full); + } +} + +/** + * 打包并复核。结构(存档根 index.html、可选外层文件夹、禁止条目)在落盘前就校验;任何一步失败 + * 都会删掉已写出的 zip,避免把不合规文件留在落点被误上传。 + */ +function makeZip(viteBuiltDir, zipPath, rootName) { + const entries = collectZipEntries(viteBuiltDir, rootName); + // 先校验再落盘:落点原有文件不会被这次失败覆盖,也不存在「不合规 zip 短暂存在」的窗口。 + assertArchiveStructure( + entries.map((entry) => entry.name), + rootName, + ); + + try { + writeZipEntries(entries, zipPath); + + const size = statSync(zipPath).size; + const location = rootName ? `顶层文件夹 ${rootName}/` : '存档根 index.html'; + console.log( + `zip 已生成:${zipPath}(${entries.length} 个条目,${(size / MIB).toFixed(2)} MiB,${location})`, + ); + } catch (error) { + rmSync(zipPath, { force: true }); + const message = error instanceof Error ? error.message : String(error); + throw new Error(`${message};已删除不合规的 zip`); + } +} + +/** + * cwd 歧义预警:宿主在**项目根** `.export/taonier.zip` 找产物,而 `--zip-out` 相对 cwd 解析。 + * npm 脚本挂在 `game/` 子工程时 cwd 就是 `game/`,写 `.export/...` 会落到 `game/.export/...`, + * 导出判失败却没有任何提示。这里只在落点位于「当前 cwd 的 `.export/`」时提醒(`../.export/...` + * 或绝对路径不触发),把 cwd 与绝对落点打出来,让 workdir 问题不再静默。 + */ +function warnIfWorkdirRelative(cwd, zipPath, zipOut) { + if (typeof zipOut !== 'string' || isAbsolute(zipOut)) return; + const relativeOut = relative(cwd, zipPath); + if (relativeOut.startsWith('..') || !relativeOut.startsWith(`.export${sep}`)) return; + console.warn( + `[warn] --zip-out 相对 cwd 解析:cwd=${cwd},落点=${zipPath}。` + + '宿主在项目根 `.export/taonier.zip` 找产物;若 cwd 是 game/ 子工程,' + + '请写 `../.export/taonier.zip`,否则会落在 game/.export/ 导致导出判失败。', + ); +} + +/** + * @param {{ + * cwd?: string, + * viteBuiltDir?: string, + * zipOut?: string, + * rootDir?: string | null, + * }} options + */ +export function packTaonier(options = {}) { + const cwd = resolve(options.cwd || process.cwd()); + const viteBuiltDir = resolve(cwd, options.viteBuiltDir || DEFAULT_VITE_BUILT_DIR); + const zipPath = resolve(cwd, options.zipOut || DEFAULT_ZIP_OUT); + const rootName = normalizeRootDir(options.rootDir ?? DEFAULT_ROOT_DIR); + + if (!zipPath.toLowerCase().endsWith('.zip')) { + throw new Error(`陶泥儿只支持 .zip 压缩包,--zip-out 必须以 .zip 结尾:${zipPath}`); + } + + // 落点在产物目录内时,上一次的 zip 会被当成资源打进新包(写盘前已 collect),也会在写盘 + // 中途被覆盖;直接拒绝这种自包含布局。 + const insideBuiltDir = relative(viteBuiltDir, zipPath); + if (insideBuiltDir === '' || (!insideBuiltDir.startsWith('..') && !isAbsolute(insideBuiltDir))) { + throw new Error(`--zip-out 不能落在 --vite-built-dir 内:${zipPath}`); + } + + warnIfWorkdirRelative(cwd, zipPath, options.zipOut); + assertPackedSource(viteBuiltDir); + + // 落点的父目录可能还不存在(`.export/` 是宿主按需创建的),先建再写。 + mkdirSync(dirname(zipPath), { recursive: true }); + + makeZip(viteBuiltDir, zipPath, rootName); + return 0; +} + +export default packTaonier; + +function isMainModule() { + const entry = process.argv[1]; + if (!entry) return false; + try { + if (realpathSync(entry) === realpathSync(fileURLToPath(import.meta.url))) return true; + } catch { + /* fall through to URL comparison */ + } + try { + return import.meta.url === pathToFileURL(entry).href; + } catch { + return false; + } +} + +if (isMainModule()) { + const raw = process.argv.slice(2); + + if (raw.includes('--help') || raw.includes('-h')) { + console.log(USAGE); + process.exit(0); + } + + // 取走一个参数和它的值;剩下的非空参数说明调用方传了本脚本不认的东西。 + const takeFlag = (name) => { + const idx = raw.indexOf(name); + if (idx === -1) return undefined; + const value = raw[idx + 1]; + if (!value || value.startsWith('-')) { + throw new Error(`${name} 后面要跟一个值`); + } + raw.splice(idx, 2); + return value; + }; + + let viteBuiltDir; + let zipOut; + let rootDir; + try { + viteBuiltDir = takeFlag('--vite-built-dir'); + zipOut = takeFlag('--zip-out') || DEFAULT_ZIP_OUT; + // 不给就是 null=不套外层文件夹;给了才套。 + rootDir = takeFlag('--root-dir'); + // 不认识的参数当场拒绝:否则调用方以为它生效了,产物落到别处还查不出来。 + if (raw.length > 0) { + throw new Error(`不认识的参数:${raw.join(' ')}`); + } + } catch (err) { + console.error(err instanceof Error ? err.message : err); + console.error(USAGE); + process.exit(2); + } + + try { + packTaonier({ viteBuiltDir, zipOut, rootDir }); + process.exit(0); + } catch (err) { + console.error(err instanceof Error ? err.message : err); + process.exit(1); + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/vite-export-taonier/scripts/vite.config.taonier.mjs b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/vite-export-taonier/scripts/vite.config.taonier.mjs new file mode 100644 index 000000000..6fc739216 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/vite-export-taonier/scripts/vite.config.taonier.mjs @@ -0,0 +1,139 @@ +/** + * 陶泥儿 H5 托管 Vite 配置(可直接作为项目 vite.config 使用): + * + * vite build --config <此文件路径> + * + * 或在项目自己的 vite.config 里合并: + * + * import { defineTaonierConfig } from '<此文件路径>'; + * export default defineTaonierConfig({ plugins: [...] }); + * + * 构建边界只有一条:**本配置负责把 dist 变成一个可托管的 H5 根目录,pack.mjs 只负责按契约打 zip。** + * + * 陶泥儿就是普通 H5 托管(不同于小红书小工具 vite-export-xhs-minitool):平台没有 Chrome 61 + * 基线、IIFE 单入口、禁内联脚本等运行约束,所以本配置不降级语法,也不重写 index.html。只固定 + * 三件与上传契约有关的事: + * + * - base: './' 相对路径。zip 存档根就是 ``,会被托管在任意子路径,根路径引用 + * (/assets/...)在子路径下会 404。 + * - build.outDir 与 pack.mjs 的 --vite-built-dir 是同一个目录(默认 dist-taonier),两处 + * 必须一致。 + * - sourcemap 置 false:不把 .map 打进上传包(pack 也会拒绝构建 sourcemap),也减小包体。 + * + * 收尾插件只做契约校验:dist 根必须有 index.html,且 index.html 不得引用根路径资源。这样 + * 「指错目录」或「误改 base」会在构建结束时就失败,而不是上传后才暴露问题。 + * + * 平台约束:仅支持单个 .zip 压缩包;zip 解压后根目录必须直接包含 index.html(不得有外层文件夹), + * 与 pack.mjs 的默认布局一致。 + */ +import { existsSync, readdirSync, readFileSync, rmSync, statSync } from 'node:fs'; +import { join, resolve } from 'node:path'; +import { defineConfig, mergeConfig } from 'vite'; + +/** 产物目录;必须与 pack.mjs 的 --vite-built-dir 默认值一致。 */ +export const TAONIER_OUT_DIR = 'dist-taonier'; + +/** 收尾插件名;defineTaonierConfig 按它去重,保证插件只跑一次。 */ +const ARTIFACT_PLUGIN_NAME = 'taonier-artifact'; + +/** + * 校验 dist 是否是一个合格的 H5 根目录:根 index.html 存在,且不引用根路径资源。 + * + * 缺失 index.html 时直接抛错——那说明构建配置指错了目录,继续打 zip 只会交付一个平台拒收的包 + * (解压后唯一文件夹下没有 index.html)。出现 `src="/…"` / `href="/…"` 同样抛错:交付物会被 + * 托管在子路径,根路径引用一定 404。 + * + * @param {string} outDir 已解析为绝对路径的构建输出目录 + */ +export function assertH5Root(outDir) { + const htmlPath = join(outDir, 'index.html'); + if (!existsSync(htmlPath)) { + throw new Error(`${htmlPath} 不存在,请先执行 vite build,并确认 build.outDir 指向构建输出目录`); + } + + const html = readFileSync(htmlPath, 'utf8'); + const absoluteRefs = [...html.matchAll(/\s(?:src|href)="(\/[^"]*)"/gi)].map((match) => match[1]); + if (absoluteRefs.length > 0) { + throw new Error( + `index.html 仍引用根路径资源(${absoluteRefs.join(', ')});` + + "请把 vite base 设为 './',否则 zip 解压到子路径后资源会 404", + ); + } +} + +/** 递归删除空目录;zip 里不留无意义目录条目。 */ +export function pruneEmptyDirs(dir) { + for (const name of readdirSync(dir)) { + const path = join(dir, name); + if (statSync(path).isDirectory()) { + pruneEmptyDirs(path); + if (readdirSync(path).length === 0) rmSync(path, { recursive: true, force: true }); + } + } +} + +/** + * dist 收尾插件:在 bundle 写盘后原地校验产物,让 pack 只需套文件夹、打 zip。 + * + * 用 `closeBundle`(最后一个构建钩子)而不是 `writeBundle`:publicDir 已复制完,且排在其它 + * 插件之后。`apply: 'build'` 保证 dev server 不触发。 + * + * 注意:rollup 在构建失败时**也会**调 `closeBundle`。所以用 `buildEnd(error)` 记下失败, + * 失败就直接返回——否则会用「index.html 不存在」盖掉真正的语法报错。 + * + * @returns {import('vite').Plugin} + */ +export function taonierArtifactPlugin() { + /** @type {string} */ + let outDir = ''; + let buildFailed = false; + + return { + name: ARTIFACT_PLUGIN_NAME, + apply: 'build', + enforce: 'post', + configResolved(config) { + // config.build.outDir 是相对 config.root 的路径,必须自己解析成绝对路径。 + outDir = resolve(config.root, config.build.outDir); + }, + buildEnd(error) { + buildFailed = Boolean(error); + }, + closeBundle() { + if (buildFailed) return; + assertH5Root(outDir); + pruneEmptyDirs(outDir); + }, + }; +} + +export const taonierBaseConfig = { + base: './', + build: { + outDir: TAONIER_OUT_DIR, + emptyOutDir: true, + sourcemap: false, + }, + plugins: [taonierArtifactPlugin()], +}; + +/** + * 合并项目配置,并保证收尾插件一定在 `plugins` 里。 + * + * `mergeConfig` 对数组是拼接,正常传 `{ plugins: [...] }` 不会丢掉 base 的插件;但调用方 + * 若先展开 `taonierBaseConfig` 再覆盖 `plugins`,插件就被挤掉了。这里按 name 去重后追加一个 + * 新实例:无论 overrides 怎么给,插件都在最后且只跑一次。 + * + * @param {import('vite').UserConfig} [overrides] + * @returns {import('vite').UserConfig} + */ +export function defineTaonierConfig(overrides = {}) { + const merged = mergeConfig(defineConfig(taonierBaseConfig), overrides); + merged.plugins = [ + ...(merged.plugins ?? []).filter((plugin) => plugin?.name !== ARTIFACT_PLUGIN_NAME), + taonierArtifactPlugin(), + ]; + return merged; +} + +export default defineTaonierConfig(); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs index 3a48046b5..a667ff1e0 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs @@ -7358,7 +7358,7 @@ case "$extra_roots" in *'"method":"skills/extraRoots/set"'*) ;; *) exit 87 ;; es printf '%s\n' '{"id":2,"result":{}}' IFS= read -r skills_list case "$skills_list" in *'"method":"skills/list"'*) ;; *) exit 88 ;; esac -printf '%s\n' '{"id":3,"result":{"data":[{"skills":[{"name":"agc-browser-playtest"},{"name":"agc-client-projection"},{"name":"agc-game-production-workflow"},{"name":"agc-godot-editor"},{"name":"agc-unity-editor"},{"name":"agc-project-structure"},{"name":"agc-web-game-development"},{"name":"taonier-art-assets"},{"name":"platform-abstract"},{"name":"vite-export-xhs-minitool"}],"errors":[]}]}}' +printf '%s\n' '{"id":3,"result":{"data":[{"skills":[{"name":"agc-browser-playtest"},{"name":"agc-client-projection"},{"name":"agc-game-production-workflow"},{"name":"agc-godot-editor"},{"name":"agc-unity-editor"},{"name":"agc-project-structure"},{"name":"agc-web-game-development"},{"name":"taonier-art-assets"},{"name":"platform-abstract"},{"name":"vite-export-xhs-minitool"},{"name":"vite-export-taonier"}],"errors":[]}]}}' while IFS= read -r line; do :; done "#, ) @@ -8103,7 +8103,7 @@ while IFS= read -r line; do case "$line" in *'"method":"initialize"'*) printf '{{"id":%s,"result":{{"codexHome":"/tmp","platformFamily":"unix","platformOs":"linux","userAgent":"fixture"}}}}\n' "$id" ;; *'"method":"skills/extraRoots/set"'*) printf '{{"id":%s,"result":{{}}}}\n' "$id" ;; - *'"method":"skills/list"'*) printf '{{"id":%s,"result":{{"data":[{{"skills":[{{"name":"agc-browser-playtest"}},{{"name":"agc-client-projection"}},{{"name":"agc-game-production-workflow"}},{{"name":"agc-godot-editor"}},{{"name":"agc-project-structure"}},{{"name":"agc-unity-editor"}},{{"name":"agc-web-game-development"}},{{"name":"taonier-art-assets"}},{{"name":"platform-abstract"}},{{"name":"vite-export-xhs-minitool"}}],"errors":[]}}]}}}}\n' "$id" ;; + *'"method":"skills/list"'*) printf '{{"id":%s,"result":{{"data":[{{"skills":[{{"name":"agc-browser-playtest"}},{{"name":"agc-client-projection"}},{{"name":"agc-game-production-workflow"}},{{"name":"agc-godot-editor"}},{{"name":"agc-project-structure"}},{{"name":"agc-unity-editor"}},{{"name":"agc-web-game-development"}},{{"name":"taonier-art-assets"}},{{"name":"platform-abstract"}},{{"name":"vite-export-xhs-minitool"}},{{"name":"vite-export-taonier"}}],"errors":[]}}]}}}}\n' "$id" ;; *'"method":"thread/start"'*) printf '{{"id":%s,"result":{{"thread":{{"id":"thread-1"}}}}}}\n' "$id" ;; *'"method":"thread/inject_items"'*) printf '{{"id":%s,"result":{{}}}}\n' "$id" ;; *'"method":"turn/start"'*) @@ -8273,7 +8273,7 @@ while IFS= read -r line; do case "$line" in *'"method":"initialize"'*) printf '{"id":%s,"result":{"codexHome":"/tmp","platformFamily":"unix","platformOs":"linux","userAgent":"fixture"}}\n' "$id" ;; *'"method":"skills/extraRoots/set"'*) printf '{"id":%s,"result":{}}\n' "$id" ;; - *'"method":"skills/list"'*) printf '{"id":%s,"result":{"data":[{"skills":[{"name":"agc-browser-playtest"},{"name":"agc-client-projection"},{"name":"agc-game-production-workflow"},{"name":"agc-godot-editor"},{"name":"agc-unity-editor"},{"name":"agc-project-structure"},{"name":"agc-web-game-development"},{"name":"taonier-art-assets"},{"name":"platform-abstract"},{"name":"vite-export-xhs-minitool"}],"errors":[]}]}}\n' "$id" ;; + *'"method":"skills/list"'*) printf '{"id":%s,"result":{"data":[{"skills":[{"name":"agc-browser-playtest"},{"name":"agc-client-projection"},{"name":"agc-game-production-workflow"},{"name":"agc-godot-editor"},{"name":"agc-unity-editor"},{"name":"agc-project-structure"},{"name":"agc-web-game-development"},{"name":"taonier-art-assets"},{"name":"platform-abstract"},{"name":"vite-export-xhs-minitool"},{"name":"vite-export-taonier"}],"errors":[]}]}}\n' "$id" ;; *'"method":"thread/start"'*) printf '{"id":%s,"result":{"thread":{"id":"thread-echo"}}}\n' "$id" ;; *'"method":"thread/inject_items"'*) printf '{"id":%s,"result":{}}\n' "$id" ;; *'"method":"turn/start"'*) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs index a5a6d0d79..538854a3a 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs @@ -3603,7 +3603,6 @@ fn direct_npm_source_paths(root: &Path) -> Vec { | "dist" | "target" | "memory" - | "exports" | "auth.json" | "credentials.json" | "game-creator.config.json" diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_validation.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_validation.rs index b03817add..b451e611c 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_validation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_validation.rs @@ -233,7 +233,6 @@ fn fingerprint(root: &Path, include_outputs: bool) -> Result { | ".cache" | "node_modules" | "target" - | "exports" | "logs" | "coverage" ) || name.starts_with(".edge-profile") diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/draft_writer.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/draft_writer.rs index ac67a164a..ba5224c66 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/draft_writer.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/draft_writer.rs @@ -19,7 +19,6 @@ pub(crate) fn write_local_game_draft_at( let balance_path = root.join("game/balance.json"); let art_manifest_path = root.join("assets/manifest.art.json"); let audio_manifest_path = root.join("assets/manifest.audio.json"); - let publish_readme_path = root.join("exports/README.md"); let agent_log_path = root.join(".agent/logs/agent.log"); let short_memory_path = root.join("memory/session.md"); let long_memory_path = root.join("memory/project.md"); @@ -68,20 +67,6 @@ pub(crate) fn write_local_game_draft_at( audio_manifest_path.display() ) })?; - fs::write( - &publish_readme_path, - format!( - "# 发布包装草案\n\n## 标题\n\n{title}\n\n## 简介\n\n{prompt}\n\n## Agent 协作交接\n\n{handoff_summary}\n\n{}\n", - draft.publish_readme.trim() - ), - ) - .map_err(|error| { - format!( - "写入发布包装草案失败:{}: {error}", - publish_readme_path.display() - ) - })?; - fs::write(&game_index_path, draft.game_html.trim()) .map_err(|error| format!("写入游戏入口失败:{}: {error}", game_index_path.display()))?; fs::OpenOptions::new() @@ -107,7 +92,6 @@ pub(crate) fn write_local_game_draft_at( "game/balance.json", "assets/manifest.art.json", "assets/manifest.audio.json", - "exports/README.md", "game/index.html", ], }), diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/pass_artifacts.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/pass_artifacts.rs index 6710b1bba..1a8d9b552 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/pass_artifacts.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/pass_artifacts.rs @@ -354,7 +354,7 @@ pub(crate) fn append_agent_success_memories( let timestamp = unix_timestamp(); let title = draft.title.trim(); let mut blackboard_entry = format!( - "\n## pass {pass} - {title}\n\n- 时间:{timestamp}\n- 稳定原型:game/index.html\n- 设计:game/game_design.md\n- 数值:game/balance.json\n- 美术:assets/manifest.art.json\n- 音乐音效:assets/manifest.audio.json\n- 发布包装:exports/README.md\n\n## 角色共享摘要\n\n" + "\n## pass {pass} - {title}\n\n- 时间:{timestamp}\n- 稳定原型:game/index.html\n- 设计:game/game_design.md\n- 数值:game/balance.json\n- 美术:assets/manifest.art.json\n- 音乐音效:assets/manifest.audio.json\n\n## 角色共享摘要\n\n" ); for brief in briefs { diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/trace.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/trace.rs index 46c41064a..8637c474b 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/trace.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/trace.rs @@ -234,7 +234,6 @@ pub(crate) fn append_local_artifact_write_step( "game/balance.json".to_string(), "assets/manifest.art.json".to_string(), "assets/manifest.audio.json".to_string(), - "exports/README.md".to_string(), "game/index.html".to_string(), ".agent/manifest.json".to_string(), ], diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/skill_pack.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/skill_pack.rs index e6615f2bc..940d7acee 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/skill_pack.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/skill_pack.rs @@ -6,7 +6,7 @@ use std::path::{Component, Path}; const AGC_SKILL_PACK_MANIFEST: &[u8] = include_bytes!("../../resources/agc-skills/manifest.json"); const AGC_SKILL_PACK_SCHEMA_VERSION: &str = "agc-skill-pack.v1"; -pub(crate) const AGC_SKILL_PACK_EXPECTED_NAMES: [&str; 10] = [ +pub(crate) const AGC_SKILL_PACK_EXPECTED_NAMES: [&str; 11] = [ "agc-browser-playtest", "agc-client-projection", "agc-game-production-workflow", @@ -16,10 +16,11 @@ pub(crate) const AGC_SKILL_PACK_EXPECTED_NAMES: [&str; 10] = [ "agc-web-game-development", "platform-abstract", "taonier-art-assets", + "vite-export-taonier", "vite-export-xhs-minitool", ]; -const AGC_SKILL_PACK_FILES: [(&str, &[u8]); 36] = [ +const AGC_SKILL_PACK_FILES: [(&str, &[u8]); 39] = [ ( "agc-unity-editor/SKILL.md", include_bytes!("../../resources/agc-skills/agc-unity-editor/SKILL.md"), @@ -128,6 +129,20 @@ const AGC_SKILL_PACK_FILES: [(&str, &[u8]); 36] = [ "platform-abstract/SKILL.md", include_bytes!("../../resources/agc-skills/platform-abstract/SKILL.md"), ), + ( + "vite-export-taonier/SKILL.md", + include_bytes!("../../resources/agc-skills/vite-export-taonier/SKILL.md"), + ), + ( + "vite-export-taonier/scripts/pack.mjs", + include_bytes!("../../resources/agc-skills/vite-export-taonier/scripts/pack.mjs"), + ), + ( + "vite-export-taonier/scripts/vite.config.taonier.mjs", + include_bytes!( + "../../resources/agc-skills/vite-export-taonier/scripts/vite.config.taonier.mjs" + ), + ), ( "vite-export-xhs-minitool/SKILL.md", include_bytes!("../../resources/agc-skills/vite-export-xhs-minitool/SKILL.md"), diff --git a/apps/ai-game-creator-shell/src-tauri/src/commands/desktop.rs b/apps/ai-game-creator-shell/src-tauri/src/commands/desktop.rs index 560b839d4..5767c9391 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/commands/desktop.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/commands/desktop.rs @@ -1156,128 +1156,6 @@ pub(crate) fn create_local_project_checkpoint( ) } -/// 为发布导出试玩包:项目还没有可玩入口时先跑项目自己的 `npm run build`。 -/// -/// 作者只点一次「发布」:已有 `game/index.html` 或 `dist/index.html` 直接打包;只有源码时 -/// 走 `project.verify` 的受控 npm 运行器构建后再打包,失败信息带构建日志尾部。 -#[tauri::command] -pub(crate) async fn export_local_project_package( - project_path: String, -) -> Result { - let root = Path::new(project_path.trim()); - enforce_project_permission_policy(root, "project.export_package")?; - let _lock = acquire_project_write_lock(root, "project.export_package")?; - advance_agent_runtime_project_revision_locked(root)?; - export_local_project_package_for_publish_at(root).await -} - -/// 把归一化后的发行包落到内容寻址的暂存文件,返回分片续传所需的元数据。 -/// -/// 发布链路从此只把「暂存路径 + 摘要 + 体积」交给渲染进程:整包字节不再经过 -/// WebView IPC,续传时也复用同一个暂存文件(同名同内容)。 -#[tauri::command] -pub(crate) fn prepare_local_project_game_package( - app: tauri::AppHandle, - project_path: String, - package_relative_path: String, -) -> Result { - let root = Path::new(project_path.trim()); - enforce_project_permission_policy(root, "project.export_package")?; - let payload = read_local_project_export_package_at(root, package_relative_path.trim())?; - let staging_dir = game_package_upload_staging_dir(&app)?; - let mut staged = crate::game_package_upload::stage_game_package_bytes( - &staging_dir, - &payload.package_sha256, - &payload.package_bytes, - )?; - staged.package_file_count = u32::try_from(payload.files.len()).unwrap_or(u32::MAX); - Ok(staged) -} - -/// 分片续传上传暂存的发行包;进度通过 `game-package-upload-progress` 事件回传。 -#[tauri::command] -pub(crate) async fn upload_local_project_game_package( - app: tauri::AppHandle, - staging_path: String, - version_id: String, - api_base_url: String, - access_token: String, - idempotency_key: String, -) -> Result { - let staging_dir = game_package_upload_staging_dir(&app)?; - let resolved_path = - crate::game_package_upload::ensure_staging_path_in_dir(&staging_dir, &staging_path)?; - let client = reqwest::Client::builder() - .build() - .map_err(|error| format!("创建上传客户端失败:{error}"))?; - let version_id = version_id.trim().to_string(); - if version_id.is_empty() { - return Err("缺少发行版本标识".to_string()); - } - let emit_handle = app.clone(); - let progress_version_id = version_id.clone(); - let progress_publish_id = idempotency_key.trim().to_string(); - let verify_handle = app.clone(); - let verify_version_id = version_id.clone(); - let verify_publish_id = progress_publish_id.clone(); - crate::game_package_upload::upload_staged_game_package( - &client, - crate::game_package_upload::GamePackageUploadRequest { - staging_path: &resolved_path, - version_id: &version_id, - api_base_url: api_base_url.trim(), - access_token: access_token.trim(), - idempotency_key: idempotency_key.trim(), - session_identity: None, - }, - move |received_bytes, total_bytes| { - let _ = emit_handle.emit( - crate::game_package_upload::GAME_PACKAGE_UPLOAD_PROGRESS_EVENT, - crate::game_package_upload::progress_event_payload( - &progress_version_id, - &progress_publish_id, - "upload", - "正在上传发行包…", - received_bytes, - total_bytes, - ), - ); - }, - move || { - let _ = verify_handle.emit( - crate::game_package_upload::GAME_PACKAGE_UPLOAD_PROGRESS_EVENT, - crate::game_package_upload::progress_event_payload( - &verify_version_id, - &verify_publish_id, - "verify", - "正在校验发行包…", - 0, - 0, - ), - ); - }, - ) - .await -} - -fn game_package_upload_staging_dir(app: &tauri::AppHandle) -> Result { - app.path() - .app_data_dir() - .map(|app_data_root| { - crate::game_package_upload::game_package_upload_staging_dir(&app_data_root) - }) - .map_err(|error| format!("无法读取 AGC 应用数据目录:{error}")) -} - -#[tauri::command] -pub(crate) fn list_local_project_export_packages( - project_path: String, -) -> Result { - let root = Path::new(project_path.trim()); - enforce_project_permission_policy(root, "project.export_list")?; - list_local_project_export_packages_at(root) -} - #[tauri::command] pub(crate) fn diff_local_project_checkpoint( project_path: String, diff --git a/apps/ai-game-creator-shell/src-tauri/src/desktop.rs b/apps/ai-game-creator-shell/src-tauri/src/desktop.rs index 7f4e1aadf..5190cd8a3 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/desktop.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/desktop.rs @@ -7,6 +7,10 @@ use asset_generation_tasks::*; use client_extensions::*; use environment_check::preflight_web_game_creation; use error_report::*; +use export::draft::taonier::commands::{ + read_taonier_export, read_taonier_export_hash, run_taonier_export_build, + save_taonier_export_form, +}; use export::draft::xhs_minitool::commands::{ read_xhs_minitool_export, read_xhs_minitool_export_hash, run_xhs_minitool_export_build, save_xhs_minitool_export_form, @@ -613,9 +617,6 @@ pub(super) fn run() { login_client_with_password, login_client_with_phone_code, logout_client_session, - suggest_game_distribution_publish_metadata, - read_game_cover_generation_price, - generate_game_distribution_cover, read_game_distribution_publication, publish_local_project_game, read_game_creator_app_config, @@ -693,14 +694,14 @@ pub(super) fn run() { append_direct_project_conversation_message, build_local_project_index, create_local_project_checkpoint, - export_local_project_package, read_xhs_minitool_export, read_xhs_minitool_export_hash, save_xhs_minitool_export_form, run_xhs_minitool_export_build, - prepare_local_project_game_package, - upload_local_project_game_package, - list_local_project_export_packages, + read_taonier_export, + read_taonier_export_hash, + save_taonier_export_form, + run_taonier_export_build, diff_local_project_checkpoint, restore_local_project_checkpoint, read_project_permission_policy, diff --git a/apps/ai-game-creator-shell/src-tauri/src/export/draft/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/export/draft/mod.rs index 733fe7291..44d8e8742 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/export/draft/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/export/draft/mod.rs @@ -1 +1,2 @@ +pub mod taonier; pub mod xhs_minitool; diff --git a/apps/ai-game-creator-shell/src-tauri/src/export/draft/taonier/build.rs b/apps/ai-game-creator-shell/src-tauri/src/export/draft/taonier/build.rs new file mode 100644 index 000000000..6bd4e0fc7 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/export/draft/taonier/build.rs @@ -0,0 +1,574 @@ +//! 跑用户项目自己的导出脚本,并读取固定产物。 +//! +//! 宿主不碰 vite、不碰 `pack.mjs`:它只是以既有 `command.exec` 的边界(脚本白名单、反漂移 +//! `expectedCommand`、沙箱、超时、输出清洗)运行项目声明的那条脚本,然后确认产物真的在,且是一份 +//! **根目录带 `index.html` 的 zip**(与陶泥儿 zip 规范一致)。 +//! +//! 与表单无关:表单不是构建输入,所以这里不校验表单,也不看 cover。 +//! +//! 产物读取入口 [`read_artifact_package`] 只读 [`artifact_path`] 这一个固定路径,供发布链路 +//! 取包;不做任何路径拼接,天然越界安全。 + +use std::io::{Cursor, Read}; +use std::path::Path; + +use sha2::{Digest, Sha256}; + +use crate::project::run_project_verification_with_commit_at; + +use super::commands::offload; +use super::dto::TaonierExportRunResult; +use super::error::{ + TaonierArtifactMissing, TaonierCommandDenied, TaonierCommandFailed, TaonierExportBuildError, + TaonierExportUnavailable, +}; +use super::layout::{artifact_path, artifact_relative_path, SCRIPT_NAME}; +use super::script::{self, NpmScript}; + +/// 权限点位复用既有命令口径:这就是在跑项目命令,不新增 `GAME_CREATION_APP_COMMANDS` 条目。 +const PERMISSION_COMMAND_ID: &str = "command.exec"; +/// 与前端一致:构建可能真的要跑一遍 vite,给到 `project.verify` 的同一档上限(300 秒)。 +const TIMEOUT_SECONDS: u64 = 300; +/// 回给前端的输出尾部上限(字符)。失败时用户要看的是最后几行报错,不是整份构建日志。 +const OUTPUT_TAIL_MAX_CHARS: usize = 4_000; +/// 陶泥儿 zip 规范的入口:根目录必须有这个文件,否则平台打不开。 +const ARTIFACT_ENTRY_FILE_NAME: &str = "index.html"; +/// 读取产物时单次读入的字节数。sha256 与字节缓冲共用这一遍流式读取。 +const PACKAGE_READ_CHUNK_BYTES: usize = 64 * 1024; +/// 产物 zip 的合理上限:与试玩包同一口径(512 MiB),超过它直接拒绝读入内存。 +const ARTIFACT_PACKAGE_MAX_BYTES: u64 = 512 * 1024 * 1024; + +/// 固定产物读出来的包:发布链路只需要这几项事实,不需要逐条文件摘要。 +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct TaonierArtifactPackage { + pub(crate) package_bytes: Vec, + pub(crate) package_sha256: String, + pub(crate) package_size_bytes: u64, + /// zip 内的文件条目数(不含目录条目)。 + pub(crate) package_file_count: u32, +} + +pub(crate) async fn run(root: &Path) -> Result { + // 找脚本、读权限策略、清上一次产物都是同步文件 IO:与读/存命令同一口径挪到阻塞线程池, + // 不占 async worker。 + let root = root.to_path_buf(); + let script = offload({ + let root = root.clone(); + move || prepare(&root) + }) + .await?; + + let result = run_project_verification_with_commit_at( + &root, + SCRIPT_NAME, + &script.command, + TIMEOUT_SECONDS, + script.cwd_relative, + // 导出不推进项目 revision,也不改任何正式状态:产物只是 `.export/` 下的一个文件。 + || Ok(()), + ) + .await + .map_err(TaonierExportUnavailable::new)?; + + // `target_exec != "established"` 就是「脚本根本没跑起来」:沙箱建不起来(`not-attempted`)或目标 + // 可执行文件执行失败(`failed`)。这跟「脚本跑了但被信号杀掉」(`exit_code` 同样是 None,但 + // `target_exec` 是 `established`)不是一回事,所以判据用它,而不是 `exit_code.is_none()`。 + let script_launched = result.target_exec == "established"; + let exit_code = result.exit_code; + let timed_out = result.timed_out; + let output = result.output; + + // 收尾判产物同样是同步 IO(symlink_metadata + 读 zip),一样搬去阻塞线程池。 + offload(move || conclude(&root, exit_code, timed_out, script_launched, &output)).await +} + +/// 构建前置(同步):找到脚本、确认权限点位没被拒、清掉上一次的产物。 +/// +/// 产物判据只看「非空且是合规 zip」,所以先清掉上一次的 zip:脚本这次什么都没写时,残留的旧 zip 会 +/// 让 no-op 脚本被判成功,用户上传的其实是上一轮的产物。删不掉(权限等)就 fail closed—— +/// 留着旧产物继续判成功,比这次构建失败更糟。 +fn prepare(root: &Path) -> Result { + let Some(script) = script::find(root) else { + return Err(script::missing_error()); + }; + // 用 typed 拒绝而不是 `enforce_project_permission_policy` 的字符串版本:那个版本把 + // 「策略拒绝」与「策略读不出来」拼成同一句人话,调用方就分不出「预期拒绝」与「宿主故障」了。 + crate::project::enforce_project_permission_policy_rejection(root, PERMISSION_COMMAND_ID) + .map_err(|rejection| match rejection { + crate::agent::tool::error::ProjectPermissionRejection::Denied { command_id } => { + TaonierExportBuildError::CommandDenied(TaonierCommandDenied { command_id }) + } + crate::agent::tool::error::ProjectPermissionRejection::PolicyUnavailable { cause } => { + TaonierExportBuildError::ExportUnavailable(TaonierExportUnavailable::new(cause)) + } + })?; + + let artifact = artifact_path(root); + match std::fs::remove_file(&artifact) { + Ok(()) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => { + return Err(TaonierExportBuildError::ExportUnavailable( + TaonierExportUnavailable::new(format!( + "清理上一次导出产物失败:{}:{error}", + artifact.display() + )), + )); + } + } + + Ok(script) +} + +/// 把宿主的构建结果收成导出结果:先判脚本有没有真的跑起来,再判成功,最后确认产物真的在、且是 +/// 根目录带 `index.html` 的 zip。 +/// +/// 只吃四个字段而不是整份 `ProjectVerificationResult`:这条判据是导出链路里唯一"脚本跑完之后 +/// 算不算数"的规则,抽成纯函数才能脱离"真的跑一遍 npm"逐条测(未启动、非零退出、超时、产物缺失、 +/// zip 不合规、成功)。 +fn conclude( + root: &Path, + exit_code: Option, + timed_out: bool, + script_launched: bool, + output: &str, +) -> Result { + if !script_launched { + // 脚本根本没跑起来(沙箱建不起来 / 目标可执行文件执行失败):这是宿主侧事实故障, + // 不是项目代码的毛病。报 `exportUnavailable` 让前端按宿主故障处理(并抛进错误池上报), + // 而不是 `commandFailed` 去引导 code agent 改一份它改不动的宿主环境。 + let (output_tail, _) = tail(output); + return Err(TaonierExportBuildError::ExportUnavailable( + TaonierExportUnavailable::new(output_tail), + )); + } + + if timed_out || exit_code != Some(0) { + let (output_tail, omitted_characters) = tail(output); + return Err(TaonierExportBuildError::CommandFailed( + TaonierCommandFailed { + exit_code, + output_tail, + omitted_characters, + }, + )); + } + + let path = artifact_path(root); + let produced = matches!( + std::fs::symlink_metadata(&path), + // `metadata` 会跟随符号链接:指向项目外某个非空文件的链接会被当成「产出成功」, + // 而下游下载路径又按 fail closed 拒收链接。这里用 symlink_metadata 与之一致。 + Ok(metadata) + if metadata.is_file() + && !metadata.file_type().is_symlink() + && metadata.len() > 0 + ); + // zip 规范:根目录必须有 `index.html`。脚本"成功"但产物不是合规 zip(或写到了别处), + // 与没有产物同类,都报 artifactMissing。 + let conformant = produced + && std::fs::read(&path) + .map(|bytes| inspect_artifact(&bytes).is_ok()) + .unwrap_or(false); + if !conformant { + return Err(TaonierExportBuildError::ArtifactMissing( + TaonierArtifactMissing { + path: artifact_relative_path(), + }, + )); + } + + // 成功也回输出尾部:`pack.mjs` 会把 zip 的条目数与体积打在这里,正是用户上传前要看的数字。 + let (output_tail, omitted_characters) = tail(output); + Ok(TaonierExportRunResult { + output_tail, + omitted_characters, + }) +} + +/// 读取固定产物,交给发布链路。 +/// +/// 只读 [`artifact_path`]:不接用户传入路径、不做拼接,所以没有越界面。校验顺序与 +/// `conclude` 一致:普通文件、非空、在体积上限内、能作为 zip 打开、根目录有 `index.html`。 +/// 失败统一返回给发布链路的 `String` 现场。 +pub(crate) fn read_artifact_package(root: &Path) -> Result { + let path = artifact_path(root); + let metadata = std::fs::symlink_metadata(&path) + .map_err(|error| format!("读取陶泥儿产物失败:{}:{error}", path.display()))?; + if metadata.file_type().is_symlink() || !metadata.is_file() { + return Err("陶泥儿产物必须是普通文件".to_string()); + } + if metadata.len() == 0 { + return Err("陶泥儿产物为空".to_string()); + } + if metadata.len() > ARTIFACT_PACKAGE_MAX_BYTES { + return Err(format!( + "陶泥儿产物大小超出上限:{}B > {}B", + metadata.len(), + ARTIFACT_PACKAGE_MAX_BYTES + )); + } + + let (package_bytes, package_sha256) = read_package_bytes(&path)?; + if package_bytes.len() as u64 != metadata.len() { + // 读取期间文件被替换:宁可让作者重新导出,也不上传一份哈希与内容对不上的包。 + return Err("陶泥儿产物在读取期间发生变化,请重新导出".to_string()); + } + let package_size_bytes = package_bytes.len() as u64; + let package_file_count = inspect_artifact(&package_bytes)?; + Ok(TaonierArtifactPackage { + package_bytes, + package_sha256, + package_size_bytes, + package_file_count, + }) +} + +/// 一遍读完产物并同时算 sha256:不为了哈希再读一次盘。上限之外立即失败,避免把超大文件读进内存。 +fn read_package_bytes(path: &Path) -> Result<(Vec, String), String> { + let mut file = std::fs::File::open(path) + .map_err(|error| format!("打开陶泥儿产物失败:{}:{error}", path.display()))?; + let mut hasher = Sha256::new(); + let mut bytes = Vec::new(); + let mut buffer = vec![0u8; PACKAGE_READ_CHUNK_BYTES]; + loop { + let read = file + .read(&mut buffer) + .map_err(|error| format!("读取陶泥儿产物失败:{error}"))?; + if read == 0 { + break; + } + hasher.update(&buffer[..read]); + bytes.extend_from_slice(&buffer[..read]); + if bytes.len() as u64 > ARTIFACT_PACKAGE_MAX_BYTES { + return Err(format!( + "陶泥儿产物大小超出上限:{}B", + ARTIFACT_PACKAGE_MAX_BYTES + )); + } + } + Ok((bytes, format!("{:x}", hasher.finalize()))) +} + +/// 校验 zip 能打开、根目录有 `index.html`,并返回文件条目数(不含目录条目)。 +/// +/// `conclude` 与 [`read_artifact_package`] 共用它,保证「构建算成功」与「发布读得出来」是同一 +/// 条规范,不会一处放行一处拒绝。 +fn inspect_artifact(bytes: &[u8]) -> Result { + let mut archive = zip::ZipArchive::new(Cursor::new(bytes)) + .map_err(|error| format!("陶泥儿产物不是合法 ZIP:{error}"))?; + let mut file_count: u32 = 0; + let mut has_root_index = false; + for index in 0..archive.len() { + let entry = archive + .by_index(index) + .map_err(|error| format!("读取陶泥儿产物条目失败:{error}"))?; + if entry.is_dir() { + continue; + } + file_count = file_count.saturating_add(1); + if entry.name() == ARTIFACT_ENTRY_FILE_NAME { + has_root_index = true; + } + } + if !has_root_index { + return Err(format!("陶泥儿产物根目录缺少 {ARTIFACT_ENTRY_FILE_NAME}")); + } + Ok(file_count) +} + +/// 只留尾部若干字符:整份构建日志塞进 IPC 没有意义,还会把界面拖住。 +/// +/// 返回(尾部原文, 省略了多少个字符)。省略量是**事实**,「省略前 N 个字符」那句话由前端拼—— +/// 宿主不预拼用户可见文案,否则前端没法按自己的现场排版,也没法判断这段尾巴是否完整。 +fn tail(output: &str) -> (String, usize) { + let trimmed = output.trim(); + let characters: Vec = trimmed.chars().collect(); + if characters.len() <= OUTPUT_TAIL_MAX_CHARS { + return (trimmed.to_string(), 0); + } + let skipped = characters.len() - OUTPUT_TAIL_MAX_CHARS; + let tail: String = characters[skipped..].iter().collect(); + (tail, skipped) +} + +#[cfg(test)] +mod tests { + use std::io::Write; + + use super::*; + + /// 每次都新建一个临时项目根:产物判据是文件系统事实,测试之间不能共享目录。 + fn fixture_root(label: &str) -> std::path::PathBuf { + let root = std::env::temp_dir().join(format!( + "genarrative-taonier-build-{label}-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + let _ = std::fs::remove_dir_all(&root); + std::fs::create_dir_all(&root).unwrap(); + root + } + + fn write_artifact(root: &Path, bytes: &[u8]) { + let path = artifact_path(root); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(&path, bytes).unwrap(); + } + + /// 用 zip 写入器造一份真实产物,避免测试依赖脆弱的 zip 字节字面量。 + fn zip_with_entries(entries: &[(&str, &[u8])]) -> Vec { + let mut writer = zip::ZipWriter::new(Cursor::new(Vec::new())); + let options = zip::write::SimpleFileOptions::default() + .compression_method(zip::CompressionMethod::Deflated); + for (name, content) in entries { + writer.start_file(*name, options).unwrap(); + writer.write_all(content).unwrap(); + } + writer.finish().unwrap().into_inner() + } + + fn valid_artifact() -> Vec { + zip_with_entries(&[("index.html", b"")]) + } + + #[test] + fn keeps_short_output_as_is_and_omits_nothing() { + assert_eq!(tail(" 构建失败\n"), ("构建失败".to_string(), 0)); + } + + #[test] + fn keeps_only_the_tail_of_long_output_and_reports_the_fact() { + let output: String = "前".repeat(OUTPUT_TAIL_MAX_CHARS) + "后"; + let (kept, omitted) = tail(&output); + // 载荷里只有原文与省略量两个事实:说明那句话由前端拼,宿主不写。 + assert!(kept.ends_with('后')); + assert!(!kept.contains("省略")); + assert_eq!(omitted, 1); + } + + #[test] + fn reports_a_missing_script_before_touching_the_project() { + let root = fixture_root("missing-script"); + + let error = tauri::async_runtime::block_on(run(&root)) + .expect_err("no package.json means no script"); + assert!(matches!( + error, + TaonierExportBuildError::BuildScriptMissing(_) + )); + } + + #[test] + fn a_non_zero_exit_keeps_the_code_and_the_output_tail() { + let root = fixture_root("non-zero"); + + let error = conclude(&root, Some(1), false, true, "vite 构建失败\n入口不存在") + .expect_err("non-zero exit must fail"); + let TaonierExportBuildError::CommandFailed(failure) = error else { + panic!("非零退出必须映射成 commandFailed"); + }; + assert_eq!(failure.exit_code, Some(1)); + assert_eq!(failure.output_tail, "vite 构建失败\n入口不存在"); + assert_eq!(failure.omitted_characters, 0); + } + + #[test] + fn a_timeout_still_reports_command_failed_without_an_exit_code() { + let root = fixture_root("timeout"); + + let error = conclude(&root, None, true, true, "已取消") + .expect_err("timeout must fail even with exit code 0"); + let TaonierExportBuildError::CommandFailed(failure) = error else { + panic!("超时必须映射成 commandFailed"); + }; + assert_eq!(failure.exit_code, None); + assert_eq!(failure.output_tail, "已取消"); + assert_eq!(failure.omitted_characters, 0); + } + + #[test] + fn a_successful_script_without_the_artifact_is_reported_as_missing() { + let root = fixture_root("no-artifact"); + + let error = + conclude(&root, Some(0), false, true, "构建完成").expect_err("产物缺失必须失败"); + let TaonierExportBuildError::ArtifactMissing(missing) = error else { + panic!("没有 zip 必须映射成 artifactMissing"); + }; + assert_eq!(missing.path, ".export/taonier.zip"); + } + + #[test] + fn an_empty_artifact_file_does_not_count() { + let root = fixture_root("empty-artifact"); + write_artifact(&root, b""); + + assert!(matches!( + conclude(&root, Some(0), false, true, "构建完成"), + Err(TaonierExportBuildError::ArtifactMissing(_)) + )); + } + + #[cfg(unix)] + #[test] + fn a_symlinked_artifact_does_not_count() { + let root = fixture_root("symlink-artifact"); + let path = artifact_path(&root); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + let target = root.join("outside.zip"); + std::fs::write(&target, valid_artifact()).unwrap(); + std::os::unix::fs::symlink(&target, &path).unwrap(); + + assert!( + matches!( + conclude(&root, Some(0), false, true, "构建完成"), + Err(TaonierExportBuildError::ArtifactMissing(_)) + ), + "指向项目外非空文件的链接不能算产出成功" + ); + } + + #[test] + fn a_zip_without_a_root_index_does_not_count() { + let root = fixture_root("no-root-index"); + // 合法 zip,但入口在 game/ 下:陶泥儿规范要求根 index.html。 + write_artifact( + &root, + &zip_with_entries(&[("game/index.html", b"")]), + ); + + assert!(matches!( + conclude(&root, Some(0), false, true, "构建完成"), + Err(TaonierExportBuildError::ArtifactMissing(_)) + )); + } + + #[test] + fn a_non_zip_artifact_does_not_count() { + let root = fixture_root("not-a-zip"); + write_artifact(&root, b"PK\x03\x04 not really a zip"); + + assert!(matches!( + conclude(&root, Some(0), false, true, "构建完成"), + Err(TaonierExportBuildError::ArtifactMissing(_)) + )); + } + + #[test] + fn a_successful_script_with_a_conformant_artifact_returns_the_output_tail() { + let root = fixture_root("ok"); + write_artifact(&root, &valid_artifact()); + + let result = conclude( + &root, + Some(0), + false, + true, + "zip 已生成:.export/taonier.zip(3 个条目)", + ) + .expect("产物在就应当成功"); + assert!(result.output_tail.contains("3 个条目")); + assert_eq!(result.omitted_characters, 0); + } + + #[test] + fn a_script_that_never_launched_is_host_unavailable_not_command_failed() { + let root = fixture_root("not-launched"); + + // 沙箱建不起来或目标可执行文件执行失败:`exit_code` 同样是 None,但脚本根本没跑, + // 必须报宿主侧故障,不能让「让陶泥儿修」的按钮出现在一个它改不动的环境问题上。 + let error = conclude(&root, None, false, false, "命令沙箱不可用") + .expect_err("脚本没跑起来必须失败"); + let TaonierExportBuildError::ExportUnavailable(unavailable) = error else { + panic!("脚本未启动必须映射成 exportUnavailable"); + }; + assert_eq!(unavailable.cause, "命令沙箱不可用"); + } + + #[test] + fn reads_the_fixed_artifact_with_hash_size_and_file_count() { + let root = fixture_root("read-package"); + let bytes = zip_with_entries(&[ + ("index.html", b""), + ("assets/app.js", b"console.log(1);"), + ]); + write_artifact(&root, &bytes); + + let package = read_artifact_package(&root).expect("合规产物应当读得出来"); + assert_eq!(package.package_bytes, bytes); + assert_eq!(package.package_size_bytes, bytes.len() as u64); + assert_eq!(package.package_file_count, 2); + assert_eq!( + package.package_sha256, + format!("{:x}", Sha256::digest(&bytes)) + ); + } + + #[test] + fn reading_a_missing_or_illegal_artifact_fails() { + let root = fixture_root("read-package-missing"); + assert!(read_artifact_package(&root).is_err(), "缺文件必须失败"); + + write_artifact(&root, b"not a zip"); + assert!(read_artifact_package(&root).is_err(), "非法 zip 必须失败"); + + write_artifact(&root, &zip_with_entries(&[("game/index.html", b"x")])); + assert!( + read_artifact_package(&root).is_err(), + "根目录没有 index.html 必须失败" + ); + } + + /// 真起进程跑一遍宿主链路:解析 npm 包、走 `command.exec` 边界执行项目脚本、再按产物判据收尾。 + /// + /// 默认 `#[ignore]`:它要本机 `node` / `npm` 与可用的命令沙箱,慢且依赖宿主环境,不进常规单测。 + /// 手动跑:`cargo test --bin genarrative-ai-game-creator-shell -- --ignored executes_the_project_script`。 + #[test] + #[ignore = "需要本机 node/npm 与可用命令沙箱,手动用 --ignored 运行"] + fn executes_the_project_script_and_requires_a_conformant_artifact_each_time() { + const MARKER: &str = "打包完成:3 个条目"; + // store-only 的最小 zip:根目录一个 `index.html`。用 base64 内联,避免脚本依赖外部打包器。 + const FIXTURE_ZIP_BASE64: &str = "UEsDBBQAAAAAACkER12BbcM1JQAAACUAAAAKAAAAaW5kZXguaHRtbDwhZG9jdHlwZSBodG1sPjx0aXRsZT50YW9uaWVyPC90aXRsZT5QSwMEFAAAAAAAKQRHXT3uIyAPAAAADwAAAA0AAABhc3NldHMvYXBwLmpzY29uc29sZS5sb2coMSk7UEsBAhQDFAAAAAAAKQRHXYFtwzUlAAAAJQAAAAoAAAAAAAAAAAAAAIABAAAAAGluZGV4Lmh0bWxQSwECFAMUAAAAAAApBEddPe4jIA8AAAAPAAAADQAAAAAAAAAAAAAAgAFNAAAAYXNzZXRzL2FwcC5qc1BLBQYAAAAAAgACAHMAAACHAAAAAAA="; + let root = fixture_root("real-run"); + std::fs::write( + root.join("package.json"), + format!( + "{{\"name\":\"taonier-real-run-fixture\",\"private\":true,\"scripts\":{{\"{SCRIPT_NAME}\":\"node make-artifact.mjs\"}}}}" + ), + ) + .unwrap(); + std::fs::write( + root.join("make-artifact.mjs"), + format!( + "import {{ mkdirSync, writeFileSync }} from 'node:fs';\n\ + mkdirSync('.export', {{ recursive: true }});\n\ + writeFileSync('.export/taonier.zip', Buffer.from('{FIXTURE_ZIP_BASE64}', 'base64'));\n\ + console.log('{MARKER}');\n" + ), + ) + .unwrap(); + + let first = tauri::async_runtime::block_on(run(&root)).expect("真实脚本应当跑通"); + assert!( + first.output_tail.contains(MARKER), + "输出尾部应当带回脚本的 stdout:{}", + first.output_tail + ); + + // 每一次导出都重新构建:删掉产物再点一次导出,必须是脚本重新产出的那一份。 + std::fs::remove_file(artifact_path(&root)).expect("删掉第一次的产物"); + tauri::async_runtime::block_on(run(&root)).expect("第二次导出也应当重新产出"); + assert!(artifact_path(&root).is_file(), "产物必须被重新写出来"); + + // 脚本"成功"却没产出:改成什么都不写,必须报 artifactMissing 而不是假成功。 + std::fs::write(root.join("make-artifact.mjs"), "console.log('nothing');\n").unwrap(); + std::fs::remove_file(artifact_path(&root)).unwrap(); + let error = + tauri::async_runtime::block_on(run(&root)).expect_err("脚本没产出 zip 时必须失败"); + assert!(matches!(error, TaonierExportBuildError::ArtifactMissing(_))); + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/export/draft/taonier/commands.rs b/apps/ai-game-creator-shell/src-tauri/src/export/draft/taonier/commands.rs new file mode 100644 index 000000000..03d7b3b36 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/export/draft/taonier/commands.rs @@ -0,0 +1,248 @@ +//! 导出面板用到的四个 `#[tauri::command]`。 +//! +//! 每个命令只做一件事,失败表也各有一条([`super::error`])。没有项目写锁、不推进 revision:宿主 +//! 在这里读写的,就是 `.export/` 下那一个注册表文件。 +//! +//! 「内容」与「注册表指纹」是两条命令:[`read_taonier_export`] 是面板自动刷新反复调的 +//! 那条,拿不到指纹;指纹(写回时的 `baseHash`)只在进编辑会话、或内容真的被采纳时单独取一次。 +//! 分开是为了让刷新动作在类型上就动不了写回基线——否则外部改动会被静默吞掉。 +//! +//! 入参在 JS 侧是 camelCase(Tauri 的既有口径):`projectPath`、`baseHash`。 +//! +//! 命令边界各记一条 `app_log!`(走在 `project/*` 模块同样的口径上):面板卡在「正在读取」时, +//! AppData 里的 `export.<命令>.failed` 那一行就是答案。读命令每 2 秒重试一次,所以**同一个失败只 +//! 记一次**,变了或中间成功过一次才再记——否则日志会被同一句刷满。 + +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::sync::{LazyLock, Mutex}; + +use super::dto::{TaonierExportForm, TaonierExportRunResult, TaonierExportState}; +use super::error::{ + TaonierExportBuildError, TaonierExportReadError, TaonierExportSaveError, + TaonierExportUnavailable, +}; +use super::registry::{self, RegistrySnapshot}; +use super::{build, script}; + +/// 读内容:注册表(缺则补一份空表单)+ 脚本是否存在。自动刷新反复调的就是这一条。 +#[tauri::command] +pub(crate) async fn read_taonier_export( + project_path: String, +) -> Result { + let result = offload({ + let project_path = project_path.clone(); + move || read_content(&project_path) + }) + .await; + report("read", &project_path, &result); + result +} + +/// 读注册表指纹:写回时原样带回做冲突判据。 +/// +/// 单独一条命令,且**只在内容被采纳时**取:让「刷新」这条高频路径在类型上碰不到写回基线。 +#[tauri::command] +pub(crate) async fn read_taonier_export_hash( + project_path: String, +) -> Result { + let result = offload({ + let project_path = project_path.clone(); + move || read_content_hash(&project_path) + }) + .await; + report("readHash", &project_path, &result); + result +} + +/// 保存表单:只按 `base_hash` 判冲突,成功后回新的指纹。 +/// +/// **不校验表单内容**——必填与字数上限由界面在提交前挡住(用户最终面对的是界面,这份表单也只是 +/// 一份草稿),宿主只负责把它原样写进注册表。`coverPath` / `screenshotPaths` 这类路径事实在真正读 +/// 它的那条路(预览、下载)由统一的路径门禁复核,不在这里预支。 +#[tauri::command] +pub(crate) async fn save_taonier_export_form( + project_path: String, + form: TaonierExportForm, + base_hash: String, +) -> Result { + let result = offload({ + let project_path = project_path.clone(); + let form = form.clone(); + let base_hash = base_hash.clone(); + move || save_form(&project_path, &form, &base_hash) + }) + .await; + report("save", &project_path, &result); + if let Ok(hash) = &result { + app_log!( + "export.save.ok project={project_path} titleChars={} summaryChars={} categoryChars={} coverPath={} screenshotCount={} baseHash={} contentHash={}", + form.title.trim().chars().count(), + form.summary.trim().chars().count(), + form.category.trim().chars().count(), + form.cover_path.trim(), + form.screenshot_paths.len(), + short(&base_hash), + short(hash), + ); + } + result +} + +/// 跑一次导出:每次都重新执行项目脚本、重新产出 zip。**不校验表单**——表单不是构建输入。 +#[tauri::command] +pub(crate) async fn run_taonier_export_build( + project_path: String, +) -> Result { + app_log!("export.build.start project={project_path}"); + let result = run_build(&project_path).await; + match &result { + Ok(run) => { + clear_failure("build", &project_path); + app_log!( + "export.build.ok project={project_path} outputChars={} omittedCharacters={}", + run.output_tail.chars().count(), + run.omitted_characters, + ); + } + Err(error) => log_failure("build", &project_path, error), + } + result +} + +/// `hasScript` 永远现算:注册表里不存、也不缓存这个事实。 +fn content(root: &Path, snapshot: RegistrySnapshot) -> TaonierExportState { + TaonierExportState { + form: snapshot.form, + has_script: script::find(root).is_some(), + } +} + +/// 项目根校验对三条命令都是同一种宿主故障:各命令用 `From` 自己收口。 +fn project_root(project_path: &str) -> Result { + let root = PathBuf::from(project_path); + crate::project::validate_project_root(&root).map_err(TaonierExportUnavailable::new)?; + Ok(root) +} + +/// 把小块同步 IO 挪到 Tokio 的阻塞线程池。 +/// +/// 读命令每 2 秒被轮询一次,注册表 + `package.json` 探测虽然小,但直接在 `async fn` 里做同步 +/// 文件 IO 会占着 worker 线程,拖慢同一 runtime 上的其它异步任务(构建子进程、SpacetimeDB +/// 客户端)。任务 panic 时 `JoinError` 收口成 `ExportUnavailable`,走各命令既有的失败表。 +/// +/// 构建命令(`build::run`)的前后置同步 IO 也复用这里,所以对同级的 `build` 模块可见。 +pub(super) async fn offload(job: F) -> Result +where + T: Send + 'static, + E: From + Send + 'static, + F: FnOnce() -> Result + Send + 'static, +{ + match tokio::task::spawn_blocking(job).await { + Ok(result) => result, + Err(error) => Err(E::from(TaonierExportUnavailable::new(format!( + "后台执行导出读写任务失败:{error}" + )))), + } +} + +fn read_content(project_path: &str) -> Result { + let root = project_root(project_path)?; + Ok(content( + &root, + registry::load::(&root)?, + )) +} + +fn read_content_hash(project_path: &str) -> Result { + let root = project_root(project_path)?; + Ok(registry::load::(&root)?.content_hash) +} + +fn save_form( + project_path: &str, + form: &TaonierExportForm, + base_hash: &str, +) -> Result { + let root = project_root(project_path)?; + Ok(registry::save(&root, form, base_hash)?.content_hash) +} + +async fn run_build(project_path: &str) -> Result { + let root = project_root(project_path)?; + build::run(&root).await +} + +/// 上一次记过的失败(按命令名 + 项目路径)。面板每 2 秒重读,同一个失败会反复落到同一条命令上。 +/// +/// 去重键必须带上项目:`registryMalformed` 之类的现场文案不含文件路径,两个项目可能给出同一句 +/// `Debug`,只按命令名去重会把项目 B 的失败当成项目 A 的重复现场静默吞掉。 +static LAST_FAILURE: LazyLock>> = + LazyLock::new(|| Mutex::new(HashMap::new())); + +/// 命令边界的统一收口:成功清掉去重记录,失败记一条。 +fn report(command: &'static str, project_path: &str, result: &Result) { + match result { + Ok(_) => clear_failure(command, project_path), + Err(error) => log_failure(command, project_path, error), + } +} + +/// 一条失败日志最多记多少个字符。 +/// +/// `CommandFailed` 的 `Debug` 里带着最多 4,000 字符的脚本输出尾部,原样落进 AppData 日志会把一次 +/// 失败撑成好几 KB,去重表也跟着留一份长字符串。日志只需要认得出「哪个变体、哪条命令失败」, +/// 完整现场已经结构化回给前端了。 +const MAX_LOGGED_FAILURE_CHARS: usize = 512; + +fn log_failure(command: &'static str, project_path: &str, error: &impl std::fmt::Debug) { + let message = bounded_for_log(&format!("{error:?}"), MAX_LOGGED_FAILURE_CHARS); + let key = (command, project_path.trim().to_string()); + if let Ok(mut last) = LAST_FAILURE.lock() { + if last.get(&key).is_some_and(|seen| *seen == message) { + return; + } + last.insert(key, message.clone()); + } + app_log!("export.{command}.failed project={project_path}: {message}"); +} + +/// 按**字符**(不是字节)截断日志文本,避免切坏 UTF-8;发生截断时补一个省略号说明。 +fn bounded_for_log(message: &str, max_chars: usize) -> String { + if message.chars().count() <= max_chars { + return message.to_string(); + } + let head: String = message.chars().take(max_chars).collect(); + format!("{head}…") +} + +fn clear_failure(command: &'static str, project_path: &str) { + if let Ok(mut last) = LAST_FAILURE.lock() { + last.remove(&(command, project_path.trim().to_string())); + } +} + +/// 日志里的指纹只留前 8 位:长度一样能看出「换了没有」,又不用把整串抄进日志。 +fn short(hash: &str) -> &str { + let end = hash.char_indices().nth(8).map_or(hash.len(), |(at, _)| at); + &hash[..end] +} + +#[cfg(test)] +mod tests { + use super::{bounded_for_log, short}; + + #[test] + fn short_keeps_first_eight_characters() { + assert_eq!(short("0123456789abcdef"), "01234567"); + assert_eq!(short("0123"), "0123"); + assert_eq!(short(""), ""); + } + + #[test] + fn bounded_for_log_keeps_short_text_and_truncates_on_char_boundaries() { + assert_eq!(bounded_for_log("构建失败", 8), "构建失败"); + // 按字符截断:4 个中文字符不会被切坏,末尾补省略号。 + assert_eq!(bounded_for_log("一二三四五六", 4), "一二三四…"); + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/export/draft/taonier/dto.rs b/apps/ai-game-creator-shell/src-tauri/src/export/draft/taonier/dto.rs new file mode 100644 index 000000000..ca04a7a2f --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/export/draft/taonier/dto.rs @@ -0,0 +1,88 @@ +//! 陶泥儿导出的线上形状(Rust → TS 由 ts-rs 生成,生成物不手改)。 + +use serde::{Deserialize, Serialize}; +use ts_rs::TS; + +/// 平台上传表单。**不是构建输入**:zip 规范里没有任何 app 元数据,这些值的唯一用途是让用户 +/// 复制粘贴到陶泥儿平台的上传页。 +/// +/// `deny_unknown_fields` 是刻意的:这个文件也由 code agent 手写,字段漂移必须当场可见,不能 +/// 被静默忽略后带着半份表单继续走。 +/// +/// `cover_path` 为空字符串表示「沿用线上封面」;`screenshot_paths` 为空数组表示「整组沿用线上」。 +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/export/generated/"))] +pub(crate) struct TaonierExportForm { + pub(crate) title: String, + pub(crate) summary: String, + pub(crate) category: String, + /// 项目内相对路径。空字符串表示沿用线上封面。 + pub(crate) cover_path: String, + /// 项目内相对路径列表。空数组表示整组沿用线上截图。 + pub(crate) screenshot_paths: Vec, +} + +impl TaonierExportForm { + /// 落盘形态:文本字段去掉首尾空白,路径列表逐项去空白并**丢弃空串**。 + /// + /// 界面按去空白后的文本校验并计数(`" "` 算空、尾随空格不占字数),所以落盘也必须去空白, + /// 否则「校验通过的值」与「写进文件的值」不是同一个,冲突比较也会被空白差异搅乱。截图列表里的 + /// 空串是界面清空某项后的残留,不能作为一条「有路径」的记录落盘。 + pub(crate) fn normalized(&self) -> Self { + Self { + title: self.title.trim().to_string(), + summary: self.summary.trim().to_string(), + category: self.category.trim().to_string(), + cover_path: self.cover_path.trim().to_string(), + screenshot_paths: self + .screenshot_paths + .iter() + .map(|path| path.trim().to_string()) + .filter(|path| !path.is_empty()) + .collect(), + } + } +} + +/// 注册表文件形状:`.export/taonier.json`。 +/// +/// 只有 `form` 一个键——没有 status、没有已适配标记、没有产物存在标记、没有 revision,那些要么是 +/// `package.json` 的现算结果,要么由内容指纹承担。外层留一层对象是为了以后加目标级键而不动表单形状。 +/// +/// 这个形状也进 agent 的指令正文(前端拿 ts-rs 生成的类型拼一份「值的位置写要求」的示意),所以它 +/// 必须导出绑定:Rust 侧加键、改名,前端那份示意会在 `typecheck` 里当场对不上。 +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/export/generated/"))] +pub(crate) struct TaonierExportRegistry { + pub(crate) form: TaonierExportForm, +} + +/// 面板读到的现场内容。 +/// +/// 没有「产物是否已存在」:每次导出都重新构建。也没有 status / 已适配标记——「有没有脚本」 +/// 永远从 npm 包的 `package.json` 现算,不落盘。也没有注册表指纹:那是写回时的同步细节, +/// 由 `read_taonier_export_hash` 单独给,自动刷新读的这条命令碰不到它。 +#[derive(Clone, Debug, PartialEq, Eq, Serialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/export/generated/"))] +pub(crate) struct TaonierExportState { + pub(crate) form: TaonierExportForm, + /// `scripts["build:taonier"]` 是否存在。 + pub(crate) has_script: bool, +} + +/// 一次成功导出的结果。产物路径是常量(`.export/taonier.zip`),不进 DTO。 +/// +/// 不带 `exit_code`:走到这里的出口码必然是 0,带回来只是一个恒为 0 的死字段;失败侧 +/// (`CommandFailed`)自己带退出码。 +#[derive(Clone, Debug, PartialEq, Eq, Serialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/export/generated/"))] +pub(crate) struct TaonierExportRunResult { + /// 构建输出尾部原文(stdout 与 stderr 合并)。打包脚本会把 zip 的条目数与体积打在这里。 + pub(crate) output_tail: String, + /// 尾部之前省略了多少个字符;0 表示没截。说明文字由前端拼。 + pub(crate) omitted_characters: usize, +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/export/draft/taonier/error.rs b/apps/ai-game-creator-shell/src-tauri/src/export/draft/taonier/error.rs new file mode 100644 index 000000000..b9ed8b562 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/export/draft/taonier/error.rs @@ -0,0 +1,196 @@ +//! 命令边界的 typed 失败。 +//! +//! **一条命令一张失败表**,不合并成一张大枚举:调用方拿到的 `Result` 本身就限定了「这条命令 +//! 可能怎么失败」,JS 侧也因此不用按命令再挑一遍变体。 +//! +//! - [`TaonierExportReadError`]:读注册表(`read_taonier_export` / +//! `read_taonier_export_hash`); +//! - [`TaonierExportSaveError`]:写表单(`save_taonier_export_form`,自动保存与「打包」前的 +//! flush 走同一条)。`saveConflict` 只出现在这里——它是「这份输入还没落盘」的结果,不是构建 +//! 失败。表单内容的必填与字数由界面在提交前校验,宿主这一层不做业务校验,所以这张表里没有 +//! 「字段不合法」这一类; +//! - [`TaonierExportBuildError`]:跑构建(`run_taonier_export_build`)。构建不是表单的消费者, +//! 所以这张表里没有表单类变体。 +//! +//! 变体名就是线上的分流键(`type`),前端只按它选通道与话术,**不解析任何文案**。每条命令都是 +//! 首个错误即返回,不留问题数组——表单一次只报一个字段,用户改完再报下一个。 +//! +//! 形状沿用仓库既有约定:带载荷的变体是 newtype 变体持有具名结构体,ts-rs 才能生成可判别的 +//! TS 联合类型。 +//! +//! 载荷结构体统一带 `Taonier` 前缀(变体名不变,所以线上 `type` 标签与小红书目标完全一致)。 +//! 原因是 ts-rs 的 `export_to` 是一个**按类型名落文件的平铺目录**:`RegistryMalformed` / +//! `BuildScriptMissing` / `SaveConflict` 这些通用名会被每个导出目标各写一遍,同名互相覆盖, +//! 生成结果取决于测试执行顺序。加平台前缀后每个目标的绑定各落各的文件,重生成可逐字节复现。 + +use serde::Serialize; +use ts_rs::TS; + +use super::dto::TaonierExportForm; + +/// 读注册表可能怎么失败。 +#[derive(Clone, Debug, PartialEq, Eq, Serialize, TS)] +#[serde( + tag = "type", + rename_all = "camelCase", + rename_all_fields = "camelCase" +)] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/export/generated/"))] +pub(crate) enum TaonierExportReadError { + /// 注册表存在但不是本目标期望的形状。fail closed:不猜测、不修补,引导重新适配。 + RegistryMalformed(TaonierRegistryMalformed), + /// 项目根不可用或其它宿主侧事实故障。 + ExportUnavailable(TaonierExportUnavailable), +} + +/// 保存表单可能怎么失败。自动保存与「打包」前的 flush 共用这一张表。 +#[derive(Clone, Debug, PartialEq, Eq, Serialize, TS)] +#[serde( + tag = "type", + rename_all = "camelCase", + rename_all_fields = "camelCase" +)] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/export/generated/"))] +pub(crate) enum TaonierExportSaveError { + /// 注册表存在但不是本目标期望的形状。 + RegistryMalformed(TaonierRegistryMalformed), + /// 保存时发现注册表被别人改过,且字段真的不同。绝不覆盖,交给用户逐字段选。 + SaveConflict(TaonierSaveConflict), + /// 项目根不可用或其它宿主侧事实故障。 + ExportUnavailable(TaonierExportUnavailable), +} + +/// 跑构建可能怎么失败。构建不是表单的消费者,所以这里没有表单类变体。 +#[derive(Clone, Debug, PartialEq, Eq, Serialize, TS)] +#[serde( + tag = "type", + rename_all = "camelCase", + rename_all_fields = "camelCase" +)] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/export/generated/"))] +pub(crate) enum TaonierExportBuildError { + /// npm 包里没有 `build:taonier`;这就是「还没适配」的唯一现算判据。 + BuildScriptMissing(TaonierBuildScriptMissing), + /// 项目权限策略拒绝执行 `command.exec`:用户/项目策略说了不让跑,不是缺陷。 + CommandDenied(TaonierCommandDenied), + /// 脚本跑完没有正常收场(非零退出或超时)。 + CommandFailed(TaonierCommandFailed), + /// 脚本正常收场但合格产物不在(没有 zip,或 zip 根目录没有 `index.html`)。 + ArtifactMissing(TaonierArtifactMissing), + /// 脚本无法启动或其它宿主侧事实故障。 + ExportUnavailable(TaonierExportUnavailable), +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/export/generated/"))] +pub(crate) struct TaonierRegistryMalformed { + /// 解析器原文。它是引导语,不是给前端做判断的依据。 + pub(crate) cause: String, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/export/generated/"))] +pub(crate) struct TaonierBuildScriptMissing { + /// 期望的脚本名(`build:taonier`)。 + pub(crate) script: String, + /// 宿主找过哪些 `package.json`,让用户知道去哪看。 + pub(crate) searched: Vec, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/export/generated/"))] +pub(crate) struct TaonierSaveConflict { + /// 文件里现在是什么(字段级二选一的「文件里的」那一侧)。 + pub(crate) current: TaonierExportForm, + /// 冲突时文件的指纹;用户选完之后带着它重试保存。 + pub(crate) content_hash: String, +} + +/// 策略拒绝与宿主故障分开:前者是预期拒绝(面板给固定话术,不入错误池),后者是宿主侧事实故障 +/// (面板给现场,并原样抛出进错误池)。ADR 要求顶层只放调用方要分流的类别,这里就是那个分流点。 +#[derive(Clone, Debug, PartialEq, Eq, Serialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/export/generated/"))] +pub(crate) struct TaonierCommandDenied { + /// 被拒的命令点位(`command.exec`)。句子由前端拼,宿主只给事实。 + pub(crate) command_id: String, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/export/generated/"))] +pub(crate) struct TaonierCommandFailed { + pub(crate) exit_code: Option, + /// 脚本输出尾部原文。**不含**任何宿主拼的说明:省略了多少单独作为事实回给前端。 + pub(crate) output_tail: String, + /// 为了让载荷留在 IPC 尺寸内,尾部之前省略了多少个字符;0 表示没截。 + pub(crate) omitted_characters: usize, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/export/generated/"))] +pub(crate) struct TaonierArtifactMissing { + /// 产物相对项目根的路径。 + pub(crate) path: String, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/export/generated/"))] +pub(crate) struct TaonierExportUnavailable { + pub(crate) cause: String, +} + +impl TaonierExportUnavailable { + pub(crate) fn new(cause: impl Into) -> Self { + Self { + cause: cause.into(), + } + } +} + +/// 三条命令都要能把「项目根不可用 / 脚本起不来」装进自己的表里。 +impl From for TaonierExportReadError { + fn from(payload: TaonierExportUnavailable) -> Self { + Self::ExportUnavailable(payload) + } +} + +impl From for TaonierExportSaveError { + fn from(payload: TaonierExportUnavailable) -> Self { + Self::ExportUnavailable(payload) + } +} + +impl From for TaonierExportBuildError { + fn from(payload: TaonierExportUnavailable) -> Self { + Self::ExportUnavailable(payload) + } +} + +/// 注册表层的失败折叠成各命令自己的表:读不出来的形状是 `registryMalformed`,其余是宿主故障。 +macro_rules! impl_from_registry_error { + ($error:ty) => { + impl From for $error { + fn from(error: crate::export::RegistryError) -> Self { + match error { + crate::export::RegistryError::Malformed { cause, .. } => { + Self::RegistryMalformed(TaonierRegistryMalformed { + cause: cause.to_string(), + }) + } + other => { + Self::ExportUnavailable(TaonierExportUnavailable::new(other.to_string())) + } + } + } + } + }; +} + +impl_from_registry_error!(TaonierExportReadError); +impl_from_registry_error!(TaonierExportSaveError); diff --git a/apps/ai-game-creator-shell/src-tauri/src/export/draft/taonier/layout.rs b/apps/ai-game-creator-shell/src-tauri/src/export/draft/taonier/layout.rs new file mode 100644 index 000000000..e9f5be532 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/export/draft/taonier/layout.rs @@ -0,0 +1,92 @@ +//! 陶泥儿在 `.export/` 下的固定名字与路径。 +//! +//! 这些名字同时出现在三处:宿主、agent 写的 npm 脚本、用户手动上传的文件名。所以它们是 +//! **契约常量**,只在这里定义一次,改名字要三处一起改。 + +use std::path::{Path, PathBuf}; + +use crate::export::EXPORT_WORK_RELATIVE_DIR; + +/// npm 脚本名。冒号形式不是审美选择:既有 `command.exec` 白名单只放行 `build` 与 `build:<后缀>`。 +pub(crate) const SCRIPT_NAME: &str = "build:taonier"; + +/// 注册表文件名(`.export/` 下)。 +pub(crate) const REGISTRY_FILE_NAME: &str = "taonier.json"; + +/// 交付产物文件名(`.export/` 下),人工上传用。 +pub(crate) const ARTIFACT_FILE_NAME: &str = "taonier.zip"; + +pub(crate) fn export_dir(root: &Path) -> PathBuf { + root.join(EXPORT_WORK_RELATIVE_DIR) +} + +pub(crate) fn registry_path(root: &Path) -> PathBuf { + export_dir(root).join(REGISTRY_FILE_NAME) +} + +pub(crate) fn artifact_path(root: &Path) -> PathBuf { + export_dir(root).join(ARTIFACT_FILE_NAME) +} + +/// 产物相对项目根的路径。前端下载与 prompt 用的都是它。 +pub(crate) fn artifact_relative_path() -> String { + format!("{EXPORT_WORK_RELATIVE_DIR}/{ARTIFACT_FILE_NAME}") +} + +#[cfg(test)] +mod tests { + use std::path::Component; + + use super::*; + + /// 前端拿的是**字符串**相对路径(下载命令与 prompt 都用它),宿主写的是 `PathBuf`。两条路各自 + /// 拼一遍,必须落在同一个文件上——谁只改了一边,这条先红。 + #[test] + fn the_relative_path_names_the_same_file_the_host_writes() { + let root = Path::new("/tmp/project"); + assert_eq!(root.join(artifact_relative_path()), artifact_path(root)); + assert_eq!( + artifact_path(root) + .file_name() + .and_then(|name| name.to_str()), + Some(ARTIFACT_FILE_NAME) + ); + } + + /// 这个字符串会交给宿主的路径门禁(下载命令复核项目内相对路径)并写进 prompt:它必须是 + /// **项目内、单层、不带 `..`** 的相对路径。前端与 agent 都按它找文件,跑出 `.export/` 就是越界。 + #[test] + fn the_relative_path_cannot_walk_out_of_the_export_directory() { + let relative_path = artifact_relative_path(); + let relative = Path::new(&relative_path); + assert!(relative.is_relative()); + assert!(relative + .components() + .all(|component| matches!(component, Component::Normal(_)))); + // flat 布局:导出目录**直接**下的一个文件名,不为目标建子目录。 + assert_eq!(relative.parent(), Some(Path::new(EXPORT_WORK_RELATIVE_DIR))); + // 跨平台:它是给前端与 prompt 的字符串,Windows 上不许漏出 `\`。 + assert!(!relative_path.contains('\\')); + } + + /// flat 是刻意的决定:注册表与产物共用一个**项目根锚定**的目录,多目标也只是同目录不同文件名。 + /// 谁加了一层 per-target 子目录,这条就红。 + #[test] + fn every_file_of_this_target_shares_one_flat_directory() { + let root = Path::new("/tmp/project"); + assert_eq!(registry_path(root).parent(), artifact_path(root).parent()); + assert_eq!(export_dir(root).parent(), Some(root)); + assert_eq!( + export_dir(root).file_name().and_then(|name| name.to_str()), + Some(EXPORT_WORK_RELATIVE_DIR) + ); + } + + /// 脚本名不是自由文本:`command.exec` 的白名单只放行 `build` 与 `build:<安全后缀>`。 + /// 尤其不能退化成裸 `build`——那会跑掉项目自己的构建,产物永远不会出现。 + #[test] + fn the_script_name_keeps_the_form_the_command_allowlist_requires() { + assert_ne!(SCRIPT_NAME, "build"); + assert!(SCRIPT_NAME.starts_with("build:")); + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/export/draft/taonier/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/export/draft/taonier/mod.rs new file mode 100644 index 000000000..63e20e4f3 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/export/draft/taonier/mod.rs @@ -0,0 +1,25 @@ +//! 陶泥儿导出目标。 +//! +//! 文件都堆在项目根的 `.export/`(见 [`crate::export::EXPORT_WORK_RELATIVE_DIR`]): +//! `taonier.json` 是注册表,`taonier.zip` 是交付给人工上传的产物,`taonier.*.mjs` +//! 是 agent 首次适配时从 `vite-export-taonier` skill 显式拷进来的脚本(允许它改)。共享 +//! icon 放 `.export/` 根,注册表只存项目内相对路径,多个目标可以指同一个文件。 +//! +//! 分层:`dto` 是线上形状,`error` 是命令边界失败表,`layout` 是契约常量,`registry` 管注册表, +//! `script` 管 npm 脚本发现,`build` 跑脚本、确认产物并对外提供固定产物读取入口,`commands` 是 +//! 唯一对外的命令面。 +//! +//! 表单**不在宿主侧做业务校验**:命令只把用户那份草稿原样读出来、写回去,必填与字数由界面在 +//! 提交前挡住。宿主要挡的只有「这份注册表文件写得对不对」这类 IO 事实(见 `registry`)。 + +pub mod commands; + +mod build; +mod dto; +mod error; +mod layout; +mod registry; +mod script; + +/// 发布链路读固定产物的入口。只读 `.export/taonier.zip`,不接用户路径。 +pub(crate) use build::{read_artifact_package, TaonierArtifactPackage}; diff --git a/apps/ai-game-creator-shell/src-tauri/src/export/draft/taonier/registry.rs b/apps/ai-game-creator-shell/src-tauri/src/export/draft/taonier/registry.rs new file mode 100644 index 000000000..d862541e4 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/export/draft/taonier/registry.rs @@ -0,0 +1,203 @@ +//! 陶泥儿注册表的读取与保存。 +//! +//! 文件级细节(自动创建、严格解析、原子写入、内容指纹)都在 [`crate::export::registry`];这里 +//! 只管本目标的事:注册表 ↔ 表单的映射,以及冲突判定。 +//! +//! 已知取舍(评审提出):这里用共享的 `load_or_create` / `save`,**不**额外检查注册表文件与 +//! `.export` 目录是不是符号链接。也就是说,如果 `.export/taonier.json` 被换成链接,读写会 +//! 落到项目外。 +//! +//! **决定不改,理由**:要挡的是「把项目内容交给打包链」的入口——产物(`build::conclude`)、脚本 +//! (`script::find`)和图标在预览 / 下载时走的路径门禁都拒链,因为它们把项目内容送出项目边界。 +//! 注册表读写的是用户自己项目里的一份草稿,用户既然能在磁盘上把它换成链接,就按「项目文件由用户 +//! 掌控」接受这个重定向;为它单独加守卫会改掉共享读写的契约,收益与成本不成比例。若以后注册表要 +//! 承载跨项目共享的内容,再按与那三处同口径补 `symlink_metadata` 检查。 + +use std::path::Path; + +use crate::export::{load_or_create, save as write_registry}; + +use super::dto::{TaonierExportForm, TaonierExportRegistry}; +use super::error::{TaonierExportSaveError, TaonierSaveConflict}; +use super::layout::registry_path; + +/// 注册表现场:表单,加上**这份内容**的指纹。 +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct RegistrySnapshot { + pub(crate) form: TaonierExportForm, + pub(crate) content_hash: String, +} + +/// 读注册表;文件不存在时补一份全空表单。解析失败原样上抛成 `registryMalformed`。 +/// +/// 返回的错误类型由调用方定:读命令与保存命令各有一张失败表,两边都实现了 +/// `From`,所以这里泛型化,不替调用方选表。 +pub(crate) fn load(root: &Path) -> Result +where + E: From, +{ + let loaded = load_or_create(®istry_path(root), &TaonierExportRegistry::default())?; + Ok(RegistrySnapshot { + form: loaded.value.form, + content_hash: loaded.content_hash, + }) +} + +/// 保存表单。 +/// +/// `base_hash` 是调用方读到的指纹。文件没被换过就直接写;被换过但字段完全一致(只剩格式差异) +/// 就静默吸收、不写盘;字段真的不同则回 `saveConflict`,把文件里的值交给用户逐字段挑——**绝不** +/// 覆盖用户或 agent 的输入。 +pub(crate) fn save( + root: &Path, + form: &TaonierExportForm, + base_hash: &str, +) -> Result { + let path = registry_path(root); + // 读 `load_or_create` 与下面的 `write_registry` 之间没有锁,两步不是原子的:并发写(用户保存 + // 撞上 code agent 改同一个 `.export/taonier.json`)存在「后写覆盖先写」的窗口。 + // + // 这里明确接受这个窗口,不加 compare-and-swap:保存一律由用户手动触发(点保存 / 停手后的 + // 自动落盘 / 点打包前的 flush),用户本来就知道 code agent 在同一项目里改文件;真撞上时重新 + // 读一次注册表就能看到最新内容,不需要写入门禁级别的并发保证。把 `base_hash` 比较下沉进共享的 + // `crate::export::save`(原子重读 + 比较 + rename)会改掉所有导出目标的写契约,成本高于收益。 + let current = load_or_create(&path, &TaonierExportRegistry::default())?; + let form = form.normalized(); + if !current.created && current.content_hash != base_hash { + let current_form = current.value.form.normalized(); + if current_form != form { + return Err(TaonierExportSaveError::SaveConflict(TaonierSaveConflict { + current: current_form, + content_hash: current.content_hash, + })); + } + return Ok(RegistrySnapshot { + form: current_form, + content_hash: current.content_hash, + }); + } + let content_hash = write_registry(&path, &TaonierExportRegistry { form: form.clone() })?; + Ok(RegistrySnapshot { form, content_hash }) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use std::path::PathBuf; + + static NEXT_TEST_ID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); + + fn fixture_project(prefix: &str) -> PathBuf { + let root = std::env::temp_dir().join(format!( + "genarrative-taonier-registry-{prefix}-{}-{}", + std::process::id(), + NEXT_TEST_ID.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + )); + fs::create_dir_all(&root).expect("create fixture project"); + root + } + + fn form(title: &str) -> TaonierExportForm { + TaonierExportForm { + title: title.to_string(), + summary: "简介".to_string(), + category: "休闲".to_string(), + cover_path: "assets/cover.png".to_string(), + screenshot_paths: vec!["assets/shot-1.png".to_string()], + } + } + + #[test] + fn creates_an_empty_registry_on_first_read() { + let root = fixture_project("create"); + + let snapshot = load::(&root).expect("load"); + assert_eq!(snapshot.form, TaonierExportForm::default()); + assert!( + root.join(".export/taonier.json").is_file(), + "读取必须顺手补出空注册表" + ); + } + + #[test] + fn saves_and_normalizes_whitespace() { + let root = fixture_project("save"); + let base = load::(&root).expect("load"); + + let mut value = form(" 陶泥儿作品 "); + value.summary = " 一句话简介 ".to_string(); + let saved = save(&root, &value, &base.content_hash).expect("save"); + assert_eq!(saved.form.title, "陶泥儿作品"); + assert_eq!(saved.form.summary, "一句话简介"); + + let reread = load::(&root).expect("reload"); + assert_eq!(reread.form, saved.form); + assert_eq!(reread.content_hash, saved.content_hash); + } + + #[test] + fn normalizes_each_screenshot_path_and_drops_empty_entries() { + let root = fixture_project("screenshots"); + let base = load::(&root).expect("load"); + + let mut value = form("陶泥儿作品"); + value.screenshot_paths = vec![ + " assets/a.png ".to_string(), + " ".to_string(), + String::new(), + "assets/b.png".to_string(), + ]; + let saved = save(&root, &value, &base.content_hash).expect("save"); + assert_eq!( + saved.form.screenshot_paths, + vec!["assets/a.png".to_string(), "assets/b.png".to_string()] + ); + + let reread = load::(&root).expect("reload"); + assert_eq!(reread.form, saved.form); + } + + #[test] + fn absorbs_formatting_only_changes_without_rewriting() { + let root = fixture_project("format"); + let base = load::(&root).expect("load"); + // agent 重排了文件:字段没变,指纹变了。 + let compact = "{\"form\":{\"title\":\"\",\"summary\":\"\",\"category\":\"\",\"coverPath\":\"\",\"screenshotPaths\":[]}}"; + fs::write(root.join(".export/taonier.json"), compact).unwrap(); + + let saved = save(&root, &TaonierExportForm::default(), &base.content_hash) + .expect("formatting-only change is absorbed"); + // 吸收的是「字段相同」这个现实:不覆写别人的排版,但指纹要跟着文件走。 + assert_eq!( + fs::read_to_string(root.join(".export/taonier.json")).unwrap(), + compact + ); + assert_eq!(saved.content_hash, crate::export::content_hash(compact)); + assert_ne!(saved.content_hash, base.content_hash); + } + + #[test] + fn refuses_to_overwrite_a_real_change() { + let root = fixture_project("conflict"); + let base = load::(&root).expect("load"); + let theirs = form("agent 写的"); + fs::write( + root.join(".export/taonier.json"), + serde_json::to_vec_pretty(&TaonierExportRegistry { + form: theirs.clone(), + }) + .unwrap(), + ) + .unwrap(); + + let error = save(&root, &form("用户写的"), &base.content_hash) + .expect_err("real change must not be overwritten"); + match error { + TaonierExportSaveError::SaveConflict(payload) => { + assert_eq!(payload.current, theirs); + } + other => panic!("expected saveConflict, got {other:?}"), + } + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/export/draft/taonier/script.rs b/apps/ai-game-creator-shell/src-tauri/src/export/draft/taonier/script.rs new file mode 100644 index 000000000..819803f66 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/export/draft/taonier/script.rs @@ -0,0 +1,157 @@ +//! 找声明 `build:taonier` 的 npm 包。 +//! +//! 判据只有一个:`package.json` 的 `scripts` 里有没有这个名字。**不落盘、不缓存**——「已适配」 +//! 就是此刻这个问题的答案,用户手删脚本或换机器之后必须立刻反映出来。候选目录与发布流程 +//! (`resolve_publish_build_cwd`)同口径:项目根,或模板工程常用的 `game/` 子工程。 + +use std::path::Path; + +use super::error::{TaonierBuildScriptMissing, TaonierExportBuildError}; +use super::layout::SCRIPT_NAME; + +/// npm 工作目录候选,按顺序取第一个声明了脚本的。 +pub(crate) const NPM_WORK_CANDIDATES: [&str; 2] = [".", "game"]; + +/// 命中的 npm 脚本。 +pub(crate) struct NpmScript { + /// 运行脚本时的工作目录(相对项目根)。 + pub(crate) cwd_relative: &'static str, + /// `package.json` 里声明的脚本文本。交给运行器做反漂移的 `expectedCommand`。 + pub(crate) command: String, + /// 相对项目根的 `package.json` 路径,报错与提示都用它。 + pub(crate) package_json_relative: String, +} + +/// 找到脚本就返回它,否则 `None`。读不了或不是合法 JSON 的 `package.json` 直接跳过,与发布 +/// 流程保持一致:这里不是校验工程结构的地方,找不到就是「还没适配」。 +pub(crate) fn find(root: &Path) -> Option { + for cwd in NPM_WORK_CANDIDATES { + let package_root = if cwd == "." { + root.to_path_buf() + } else { + match crate::project::resolve_local_project_path(root, cwd) { + Ok(path) => path, + Err(_) => continue, + } + }; + let package_path = package_root.join("package.json"); + let Ok(metadata) = std::fs::symlink_metadata(&package_path) else { + continue; + }; + if metadata.file_type().is_symlink() || !metadata.is_file() { + continue; + } + let Ok(content) = std::fs::read_to_string(&package_path) else { + continue; + }; + let Ok(package) = serde_json::from_str::(&content) else { + continue; + }; + let Some(command) = package + .get("scripts") + .and_then(|scripts| scripts.get(SCRIPT_NAME)) + .and_then(serde_json::Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + else { + continue; + }; + return Some(NpmScript { + cwd_relative: cwd, + command: command.to_string(), + package_json_relative: package_json_relative(cwd), + }); + } + None +} + +/// 找不到脚本时的 typed 失败:把找过的位置一起给出去,用户才知道去哪看。 +pub(crate) fn missing_error() -> TaonierExportBuildError { + TaonierExportBuildError::BuildScriptMissing(TaonierBuildScriptMissing { + script: SCRIPT_NAME.to_string(), + searched: NPM_WORK_CANDIDATES + .iter() + .map(|cwd| package_json_relative(cwd)) + .collect(), + }) +} + +fn package_json_relative(cwd: &str) -> String { + if cwd == "." { + "package.json".to_string() + } else { + format!("{cwd}/package.json") + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use std::path::PathBuf; + + static NEXT_TEST_ID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); + + fn fixture_project(prefix: &str) -> PathBuf { + let root = std::env::temp_dir().join(format!( + "genarrative-taonier-script-{prefix}-{}-{}", + std::process::id(), + NEXT_TEST_ID.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + )); + fs::create_dir_all(&root).expect("create fixture project"); + root + } + + #[test] + fn finds_the_script_in_the_root_package() { + let root = fixture_project("root"); + fs::write( + root.join("package.json"), + format!("{{\"scripts\":{{\"{SCRIPT_NAME}\":\"node pack.mjs\"}}}}"), + ) + .unwrap(); + + let script = find(&root).expect("script found"); + assert_eq!(script.cwd_relative, "."); + assert_eq!(script.command, "node pack.mjs"); + assert_eq!(script.package_json_relative, "package.json"); + } + + #[test] + fn finds_the_script_in_the_game_subproject() { + let root = fixture_project("game"); + fs::create_dir_all(root.join("game")).unwrap(); + fs::write( + root.join("game/package.json"), + format!("{{\"scripts\":{{\"{SCRIPT_NAME}\":\"vite build\"}}}}"), + ) + .unwrap(); + + let script = find(&root).expect("script found"); + assert_eq!(script.cwd_relative, "game"); + assert_eq!(script.package_json_relative, "game/package.json"); + } + + #[test] + fn ignores_other_scripts_and_broken_packages() { + let root = fixture_project("missing"); + fs::write( + root.join("package.json"), + "{\"scripts\":{\"build\":\"vite build\"}}", + ) + .unwrap(); + assert!(find(&root).is_none()); + + fs::write(root.join("package.json"), "{ not json").unwrap(); + assert!(find(&root).is_none()); + + let error = missing_error(); + match error { + TaonierExportBuildError::BuildScriptMissing(payload) => { + assert_eq!(payload.script, SCRIPT_NAME); + assert_eq!(payload.searched, vec!["package.json", "game/package.json"]); + } + other => panic!("expected buildScriptMissing, got {other:?}"), + } + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/game_distribution_publish.rs b/apps/ai-game-creator-shell/src-tauri/src/game_distribution_publish.rs index 9039f401e..721ef61c5 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/game_distribution_publish.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/game_distribution_publish.rs @@ -1259,22 +1259,20 @@ pub(crate) async fn generate_game_distribution_cover( fn stage_publish_package( app_data_dir: &Path, project_path: &str, - package_relative_path: &str, + _package_relative_path: &str, ) -> Result { let root = Path::new(project_path.trim()); crate::project::enforce_project_permission_policy(root, "project.export_package")?; - // 发布前先按平台合同校验工程栈:非 Phaser 4 / 非 Vite 的 npm 工程在这里就给出 - // 可操作错误,不进入后续任何服务端请求。 - crate::project::ensure_publish_project_stack(root)?; - let payload = - crate::project::read_local_project_export_package_at(root, package_relative_path.trim())?; + // 打包产物固定来自 `.export/taonier.zip`:路径由 `vite-export-taonier` skill 与宿主契约常量 + // 共同锚定,不再接受前端传入的任意 `exports/playtest-package-*.zip`。 + let payload = crate::export::draft::taonier::read_artifact_package(root)?; let staging_dir = game_package_upload_staging_dir(app_data_dir); let mut staged = stage_game_package_bytes( &staging_dir, &payload.package_sha256, &payload.package_bytes, )?; - staged.package_file_count = u32::try_from(payload.files.len()).unwrap_or(u32::MAX); + staged.package_file_count = payload.package_file_count; Ok(staged) } diff --git a/apps/ai-game-creator-shell/src-tauri/src/main.rs b/apps/ai-game-creator-shell/src-tauri/src/main.rs index 2e968ac5f..9f2673cdb 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -1194,31 +1194,6 @@ struct LocalProjectCheckpointResult { total_bytes: u64, } -#[derive(Debug, Eq, PartialEq, Serialize)] -#[serde(rename_all = "camelCase")] -struct LocalProjectExportPackageResult { - package_path: String, - package_relative_path: String, - file_count: usize, - total_bytes: u64, -} - -#[derive(Debug, Eq, PartialEq, Serialize)] -#[serde(rename_all = "camelCase")] -struct LocalProjectExportPackageSummary { - package_path: String, - package_relative_path: String, - total_bytes: u64, - modified_at: u64, -} - -#[derive(Debug, Eq, PartialEq, Serialize)] -#[serde(rename_all = "camelCase")] -struct LocalProjectExportPackagesResult { - project_path: String, - packages: Vec, -} - #[derive(Clone, Debug, Eq, PartialEq, Serialize)] #[serde(rename_all = "camelCase")] struct LocalProjectDiffEntry { @@ -1327,9 +1302,7 @@ fn default_agent_runtime_run_profile() -> String { } const MAX_CANVAS_EXPORT_FILES: usize = 500; const MAX_CANVAS_EXPORT_BYTES: u64 = 512 * 1024 * 1024; -const MAX_PROJECT_EXPORT_PACKAGE_FILES: usize = 1200; -const MAX_PROJECT_EXPORT_PACKAGE_BYTES: u64 = 512 * 1024 * 1024; -const GAME_CREATOR_AGENT_ARTIFACT_PATHS: [&str; 15] = [ +const GAME_CREATOR_AGENT_ARTIFACT_PATHS: [&str; 14] = [ ".agent/agent.db", ".agent/spec.md", ".agent/findings.md", @@ -1343,7 +1316,6 @@ const GAME_CREATOR_AGENT_ARTIFACT_PATHS: [&str; 15] = [ "game/balance.json", "assets/manifest.art.json", "assets/manifest.audio.json", - "exports/README.md", "game/index.html", ]; static GAME_CREATOR_RUNTIME_CONFIG_DIR: OnceLock>> = OnceLock::new(); diff --git a/apps/ai-game-creator-shell/src-tauri/src/preview.rs b/apps/ai-game-creator-shell/src-tauri/src/preview.rs index 2f617ce1a..43ca8de2a 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/preview.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/preview.rs @@ -1109,7 +1109,6 @@ fn canonical_preview_path(root: &Path, file_path: &Path) -> Result Result, - pub(crate) package_sha256: String, - pub(crate) package_size_bytes: u64, - pub(crate) files: Vec, -} - -pub(crate) fn export_local_project_package_at( - root: &Path, -) -> Result { - validate_project_root(root)?; - super::verification::validate_project_game_entry(root)?; - ensure_project_export_readme(root)?; - - let files = collect_project_export_package_files(root)?; - let total_bytes = files.iter().map(|(_, _, size)| *size).sum::(); - if files.len() > MAX_PROJECT_EXPORT_PACKAGE_FILES { - return Err(format!( - "试玩包文件数量超过上限:{} > {}", - files.len(), - MAX_PROJECT_EXPORT_PACKAGE_FILES - )); - } - if total_bytes > MAX_PROJECT_EXPORT_PACKAGE_BYTES { - return Err(format!( - "试玩包文件总大小超过上限:{}B > {}B", - total_bytes, MAX_PROJECT_EXPORT_PACKAGE_BYTES - )); - } - let package_relative_path = next_project_export_package_relative_path(root)?; - let package_path = resolve_local_project_path(root, &package_relative_path)?; - if let Some(parent) = package_path.parent() { - ensure_game_creator_private_directory_tree(parent, "导出目录")?; - prepare_game_creator_private_path_for_read(parent, true, "导出目录")?; - } - prepare_game_creator_private_path_for_read(&package_path, false, "试玩包")?; - - let mut options = fs::OpenOptions::new(); - options.write(true).create_new(true); - #[cfg(windows)] - { - use std::os::windows::fs::OpenOptionsExt; - options.custom_flags(PROJECT_FILE_FLAG_OPEN_REPARSE_POINT); - } - let file = options - .open(&package_path) - .map_err(|error| format!("创建试玩包失败:{}: {error}", package_path.display()))?; - if let Err(error) = harden_new_game_creator_private_path(&package_path, false, "试玩包") { - drop(file); - let _ = fs::remove_file(&package_path); - return Err(error); - } - let mut writer = zip::ZipWriter::new(file); - let options = zip::write::SimpleFileOptions::default() - .compression_method(zip::CompressionMethod::Deflated); - for (relative_path, absolute_path, _) in &files { - writer - .start_file(relative_path, options) - .map_err(|error| format!("写入试玩包条目失败:{relative_path}: {error}"))?; - prepare_game_creator_private_path_for_read(absolute_path, false, "导出文件")?; - let bytes = fs::read(absolute_path) - .map_err(|error| format!("读取导出文件失败:{}: {error}", absolute_path.display()))?; - writer - .write_all(&bytes) - .map_err(|error| format!("写入试玩包文件失败:{relative_path}: {error}"))?; - } - writer - .finish() - .map_err(|error| format!("完成试玩包失败:{}: {error}", package_path.display()))?; - prepare_game_creator_private_path_for_read(&package_path, false, "试玩包")?; - - let updated_at = unix_timestamp(); - let log_path = root.join(".agent/logs/command.log"); - let output = format!( - "导出试玩包:{},{} 个文件,{}B", - package_relative_path, - files.len(), - total_bytes - ); - let line = format!("{updated_at} project.export_package: {output}\n"); - append_game_creator_private_file(&log_path, line.as_bytes(), "命令日志")?; - record_command_run( - root, - GameCreationAppCommandRunState { - command_id: "project.export_package".to_string(), - status: GameCreationAppCommandRunStatus::Completed, - output, - log_path: log_path.to_string_lossy().into_owned(), - updated_at, - }, - )?; - append_agent_db_record( - root, - serde_json::json!({ - "recordType": "project.export_package", - "packagePath": package_relative_path, - "fileCount": files.len(), - "totalBytes": total_bytes, - }), - )?; - - Ok(LocalProjectExportPackageResult { - package_path: package_path.to_string_lossy().into_owned(), - package_relative_path, - file_count: files.len(), - total_bytes, - }) -} - -/// Read a previously exported package for the explicit AGC publish flow. -/// -/// The caller receives the package bytes and a deterministic file manifest, but -/// never receives a filesystem path that it could accidentally send to the API. -/// 发布前构建的超时上限:与 `project.verify` 的上限保持一致(构建属于常规步骤, -/// 给足时间但必须有界),避免发布路径越过校验器允许的区间。 -pub(crate) const PUBLISH_BUILD_TIMEOUT_SECONDS: u64 = 300; - -fn project_has_runnable_prototype(manifest: &GameCreationAppManifest) -> bool { - let has_completed_prototype_task = manifest.tasks.iter().any(|task| { - task.id == "code-prototype" && task.status == GameCreationAppTaskStatus::Completed - }); - let has_running_preview = manifest.preview.as_ref().is_some_and(|preview| { - preview.status == GameCreationAppPreviewStatus::Running - && preview - .url - .as_deref() - .is_some_and(|url| !url.trim().is_empty()) - }); - has_completed_prototype_task || has_running_preview -} - -/// 找到声明了 `scripts.build` 的 npm 工作目录(项目根或 `game/` 子工程)。 -/// -/// 只读 `package.json`,不执行任何东西;真正的执行交给 `project.verify` 的受控 -/// npm 运行器(脚本白名单含 `build`、禁止项目级 `.npmrc` 改写语义、沙箱与超时都在那里)。 -pub(crate) fn resolve_publish_build_cwd(root: &Path) -> Result, String> { - for cwd in [".", "game"] { - let package_root = if cwd == "." { - root.to_path_buf() - } else { - resolve_local_project_path(root, cwd)? - }; - let package_path = package_root.join("package.json"); - let metadata = match fs::symlink_metadata(&package_path) { - Ok(metadata) => metadata, - Err(_) => continue, - }; - if metadata.file_type().is_symlink() || !metadata.is_file() { - continue; - } - let Ok(content) = fs::read_to_string(&package_path) else { - continue; - }; - let Ok(package) = serde_json::from_str::(&content) else { - continue; - }; - let declared = package - .get("scripts") - .and_then(|scripts| scripts.get("build")) - .and_then(serde_json::Value::as_str) - .map(str::trim) - .filter(|value| !value.is_empty()); - if declared.is_some() { - return Ok(Some(cwd)); - } - } - Ok(None) -} - -/// 读取声明的 build 脚本原文:`project.verify` 用它做 expectedCommand 反漂移校验。 -pub(crate) fn read_publish_build_command( - root: &Path, - cwd_relative: &str, -) -> Result { - let package_root = if cwd_relative == "." { - root.to_path_buf() - } else { - resolve_local_project_path(root, cwd_relative)? - }; - let package_path = package_root.join("package.json"); - let content = fs::read_to_string(&package_path).map_err(|error| { - format!( - "读取 package.json 失败:{}: {error}", - package_path.display() - ) - })?; - let package: serde_json::Value = serde_json::from_str(&content) - .map_err(|error| format!("解析 package.json 失败:{error}"))?; - package - .get("scripts") - .and_then(|scripts| scripts.get("build")) - .and_then(serde_json::Value::as_str) - .map(|value| value.trim().to_string()) - .filter(|value| !value.is_empty()) - .ok_or_else(|| "package.json 未定义 build 脚本".to_string()) -} - -/// 构建失败的日志尾部:命令输出有界,直接回传最后一段给作者判断。 -fn publish_build_failure_tail(output: &str) -> String { - const MAX_CHARS: usize = 2_000; - let trimmed = output.trim(); - let chars = trimmed.chars().count(); - if chars <= MAX_CHARS { - return trimmed.to_string(); - } - let tail = trimmed.chars().skip(chars - MAX_CHARS).collect::(); - format!("…{tail}") -} - -/// 发布前构建计划:在哪个目录构建、构建脚本原文、以及是否需要先装依赖。 -#[derive(Clone, Debug, Eq, PartialEq)] -pub(crate) struct PublishBuildPlan { - pub(crate) cwd_relative: &'static str, - pub(crate) command: String, - /// `game/` 子工程缺 `node_modules` 时为 true:构建前必须先跑 `project.bootstrap`。 - pub(crate) needs_dependency_install: bool, -} - -/// 解析发布前构建计划;项目没有任何可构建的 npm 工程时返回可操作错误。 -pub(crate) fn resolve_publish_build_plan(root: &Path) -> Result { - let Some(cwd_relative) = resolve_publish_build_cwd(root)? else { - return Err( - "项目还没有可玩入口,且项目根 / game 目录的 package.json 都没有 build 脚本:请让 Agent 生成可玩产物,或补上 build 脚本后重试" - .to_string(), - ); - }; - let command = read_publish_build_command(root, cwd_relative)?; - let needs_dependency_install = - cwd_relative == "game" && !root.join("game").join("node_modules").is_dir(); - Ok(PublishBuildPlan { - cwd_relative, - command, - needs_dependency_install, - }) -} - -/// 为发布导出试玩包:项目还没有可玩入口时,先跑项目自己的 `npm run build`。 -/// -/// 作者只需要点一次「发布」:已有可玩产物(`game/index.html` 或 `dist/index.html`)直接打包; -/// 只有源码时用 `project.verify` 的受控 npm 运行器执行 build,再校验入口并打包。构建失败 -/// 返回带日志尾部的可操作错误,不回传本地路径。 -pub(crate) async fn export_local_project_package_for_publish_at( - root: &Path, -) -> Result { - if validate_project_game_entry(root).is_ok() { - ensure_publish_project_stack(root)?; - return export_local_project_package_at(root); - } - let manifest = read_manifest_for_project(root)?; - if !project_has_runnable_prototype(&manifest) { - return Err( - "首个可运行原型尚未完成,暂不能发布;请先完成可运行原型并通过运行验证。".to_string(), - ); - } - let plan = resolve_publish_build_plan(root)?; - // 首版发行包合同只覆盖 Phaser 4 + Vite 的 npm 工程:在跑安装/构建之前先说清楚, - // 避免作者等完一次 npm install 才拿到一句泛化错误。 - ensure_publish_project_stack_at(root, plan.cwd_relative)?; - // `game/` 子工程构建前必须先有依赖:缺 node_modules 时由发布流程自己补一次安装, - // 否则作者要点两次(先 bootstrap 再发布)。 - if plan.needs_dependency_install { - let bootstrap = - crate::project::run_project_bootstrap_at(root, PUBLISH_BUILD_TIMEOUT_SECONDS).await?; - if bootstrap.status != "completed" { - return Err(format!( - "安装 game 依赖失败(npm install 未通过):\n{}", - publish_build_failure_tail(&bootstrap.output) - )); - } - } - let built = crate::project::verification::run_project_verification_with_commit_at( - root, - "build", - &plan.command, - PUBLISH_BUILD_TIMEOUT_SECONDS, - plan.cwd_relative, - || Ok(()), - ) - .await?; - if built.status != "completed" { - return Err(format!( - "构建可玩版本失败(npm run build 未通过):\n{}", - publish_build_failure_tail(&built.output) - )); - } - validate_project_game_entry(root) - .map_err(|error| format!("构建完成但项目仍没有可玩入口:{error}"))?; - export_local_project_package_at(root) -} - -/// 解析依赖声明里的版本号主版本:跳过 `^`、`~`、`>=` 等前缀,取第一段连续数字。 -/// -/// `*`、`latest`、`workspace:*` 这类没有数字的声明返回 `None`,由调用方决定是否回退到 -/// `package-lock.json` 的解析结果。 -fn declared_version_major(spec: &str) -> Option { - let digits = spec - .trim() - .chars() - .skip_while(|character| !character.is_ascii_digit()) - .take_while(char::is_ascii_digit) - .collect::(); - digits.parse::().ok() -} - -/// 读取依赖声明原文;兼容 `"phaser": "4.2.1"` 与 `"phaser": { "version": "4.2.1" }` 两种写法。 -fn declared_dependency_spec(package: &serde_json::Value, name: &str) -> Option { - const SECTIONS: [&str; 3] = ["dependencies", "devDependencies", "peerDependencies"]; - for section in SECTIONS { - let Some(entry) = package.get(section).and_then(|deps| deps.get(name)) else { - continue; - }; - let spec = match entry { - serde_json::Value::String(spec) => spec.trim().to_string(), - serde_json::Value::Object(object) => object - .get("version") - .and_then(serde_json::Value::as_str) - .map(str::trim) - .unwrap_or_default() - .to_string(), - _ => String::new(), - }; - if !spec.is_empty() { - return Some(spec); - } - } - None -} - -/// `package-lock.json` 的已解析版本:v2/v3 看 `packages["node_modules/"]`,v1 看 `dependencies`。 -fn locked_dependency_version(package_root: &Path, name: &str) -> Option { - let lock_path = package_root.join("package-lock.json"); - let content = fs::read_to_string(&lock_path).ok()?; - let lock: serde_json::Value = serde_json::from_str(&content).ok()?; - let from_packages = lock - .get("packages") - .and_then(|packages| packages.get(format!("node_modules/{name}"))) - .and_then(|entry| entry.get("version")) - .and_then(serde_json::Value::as_str); - from_packages - .or_else(|| { - lock.get("dependencies") - .and_then(|dependencies| dependencies.get(name)) - .and_then(|entry| entry.get("version")) - .and_then(serde_json::Value::as_str) - }) - .map(|version| version.trim().to_string()) - .filter(|version| !version.is_empty()) -} - -/// 读取某个 npm 工作区的 `package.json`;不存在或不是普通文件时返回 `None`。 -fn read_publish_workspace_package( - root: &Path, - cwd_relative: &str, -) -> Result, String> { - let package_root = if cwd_relative == "." { - root.to_path_buf() - } else { - resolve_local_project_path(root, cwd_relative)? - }; - let package_path = package_root.join("package.json"); - let metadata = match fs::symlink_metadata(&package_path) { - Ok(metadata) => metadata, - Err(_) => return Ok(None), - }; - if metadata.file_type().is_symlink() || !metadata.is_file() { - return Err(format!("{cwd_relative}/package.json 必须是普通文件")); - } - let content = fs::read_to_string(&package_path) - .map_err(|error| format!("读取 {cwd_relative}/package.json 失败:{error}"))?; - let package: serde_json::Value = serde_json::from_str(&content) - .map_err(|error| format!("解析 {cwd_relative}/package.json 失败:{error}"))?; - Ok(Some((package_root, package))) -} - -fn workspace_has_vite_config(package_root: &Path) -> bool { - [ - "vite.config.js", - "vite.config.mjs", - "vite.config.cjs", - "vite.config.ts", - "vite.config.mts", - "vite.config.cts", - ] - .iter() - .any(|name| package_root.join(name).is_file()) -} - -/// 发布到游戏广场首版只接受 Phaser 4 + Vite 的 npm 工程。 -/// -/// 客户端发布必须命中 npm 构建工作区;无 `package.json` 的单文件 HTML 项目不属于首版 Phaser 4 发布合同。 -/// 判定顺序以**将被构建的那个工作区**为准,它没声明 phaser 时再看项目里的另一个工作区 -/// (根工程 / `game` 子工程),避免多工作区项目被误判;校验失败必须给出可操作错误—— -/// 缺什么依赖、当前声明是什么版本、要怎么做。错误文案只带项目内相对路径。 - -fn ensure_publish_project_stack_at( - root: &Path, - build_cwd_relative: &'static str, -) -> Result<(), String> { - let candidates: [&'static str; 2] = if build_cwd_relative == "." { - [".", "game"] - } else { - [build_cwd_relative, "."] - }; - let mut phaser: Option<(String, String, PathBuf)> = None; - let mut vite_ready = false; - for cwd_relative in candidates { - let Some((package_root, package)) = read_publish_workspace_package(root, cwd_relative)? - else { - continue; - }; - if phaser.is_none() { - if let Some(spec) = declared_dependency_spec(&package, "phaser") { - phaser = Some((spec, cwd_relative.to_string(), package_root.clone())); - } - } - if declared_dependency_spec(&package, "vite").is_some() - || workspace_has_vite_config(&package_root) - { - vite_ready = true; - } - } - let Some((spec, cwd_relative, package_root)) = phaser else { - return Err(format!( - "发布到游戏广场目前只支持 Phaser 4(npm + Vite)工程:{build_cwd_relative}/package.json 未声明 phaser 依赖。请先安装 Phaser 4(例如 npm install phaser@4.2.1)后重新发布。" - )); - }; - let major = declared_version_major(&spec).or_else(|| { - locked_dependency_version(&package_root, "phaser") - .as_deref() - .and_then(declared_version_major) - }); - if major.is_some_and(|major| major != 4) { - return Err(format!( - "发布到游戏广场目前只支持 Phaser 4(npm + Vite)工程:{cwd_relative}/package.json 声明的 phaser 版本是 {spec}。请升级到 Phaser 4(例如 npm install phaser@4.2.1)后重新发布。" - )); - } - if !vite_ready { - return Err(format!( - "发布到游戏广场目前只支持 Phaser 4(npm + Vite)工程:{cwd_relative}/package.json 未声明 vite 依赖,也没有 vite.config.*。请改用 Vite 构建(例如 npm create vite)后重新发布。" - )); - } - Ok(()) -} - -/// 发布前校验工程栈:客户端发布必须使用 Phaser 4 + Vite npm 工程。 -pub(crate) fn ensure_publish_project_stack(root: &Path) -> Result<(), String> { - let Some(cwd_relative) = resolve_publish_build_cwd(root)? else { - return Err( - "发布到游戏广场目前只支持 Phaser 4(npm + Vite)工程:项目没有可识别的 npm build 工作区。请先使用 Phaser 4 + Vite 工程并提供 npm run build 后重新发布。".to_string(), - ); - }; - ensure_publish_project_stack_at(root, cwd_relative) -} - -/// 发布前的客户端预检:包体不得超过平台上限。 -/// -/// 服务端仍会独立校验;这里先判一次是为了给作者可操作错误(含实际体积与上限), -/// 而不是让他把包传上去再吃一个 413。上限单一来源在 `shared-contracts`。 -fn ensure_within_platform_package_limit(package_bytes: u64) -> Result<(), String> { - let limit = shared_contracts::game_distribution::GAME_DISTRIBUTION_MAX_PACKAGE_BYTES; - if package_bytes > limit { - return Err(format!( - "发行包 {:.1} MiB 超过平台上限 {} MiB;请精简资源后重新导出再发布。", - package_bytes as f64 / (1024.0 * 1024.0), - limit / (1024 * 1024) - )); - } - Ok(()) -} - -pub(crate) fn read_local_project_export_package_at( - root: &Path, - package_relative_path: &str, -) -> Result { - validate_project_root(root)?; - let normalized = normalize_export_package_entry_path(package_relative_path)?; - if !normalized.starts_with("exports/playtest-package-") - || !normalized.ends_with(".zip") - || normalized.contains('/') && normalized.split('/').count() != 2 - { - return Err("发行包路径必须是 exports/playtest-package-*.zip".to_string()); - } - let package_path = resolve_local_project_path(root, &normalized)?; - prepare_game_creator_private_path_for_read(&package_path, false, "发行包")?; - let metadata = checked_export_package_metadata(&package_path, &normalized)?; - if !metadata.is_file() { - return Err("发行包必须是普通文件".to_string()); - } - if metadata.len() == 0 || metadata.len() > MAX_PROJECT_EXPORT_PACKAGE_BYTES { - return Err("发行包大小超出本地发布上限".to_string()); - } - ensure_within_platform_package_limit(metadata.len())?; - let package_bytes = - fs::read(&package_path).map_err(|error| format!("读取发行包失败:{error}"))?; - if package_bytes.len() as u64 != metadata.len() { - return Err("发行包在读取期间发生变化,请重新导出".to_string()); - } - - let mut archive = zip::ZipArchive::new(Cursor::new(&package_bytes)) - .map_err(|error| format!("读取发行包 ZIP 失败:{error}"))?; - let mut entries = Vec::with_capacity(archive.len()); - let mut seen = BTreeSet::new(); - for index in 0..archive.len() { - let mut entry = archive - .by_index(index) - .map_err(|error| format!("读取发行包条目失败:{error}"))?; - if entry.is_dir() { - continue; - } - let source_path = normalize_export_package_entry_path(entry.name())?; - // 本地试玩包以 game/index.html 为入口,而平台发行合同要求根 - // index.html。把 game/ 前缀剥离到内存 ZIP,避免上传本地路径或修改 - // 工作区里的原始导出文件;根目录的 README/assets 等公共条目原样保留。 - let path = source_path - .strip_prefix("game/") - .unwrap_or(source_path.as_str()) - .to_string(); - let path = normalize_export_package_entry_path(&path)?; - if !seen.insert(path.clone()) { - return Err(format!("发行包包含重复条目:{path}")); - } - let expected_size = entry.size(); - let mut content = Vec::with_capacity(expected_size.min(16 * 1024 * 1024) as usize); - entry - .read_to_end(&mut content) - .map_err(|error| format!("读取发行包文件失败:{path}: {error}"))?; - if content.len() as u64 != expected_size { - return Err(format!("发行包条目长度不一致:{path}")); - } - let content = normalize_release_asset_references(&path, content); - entries.push((path, content)); - } - entries.sort_by(|left, right| left.0.cmp(&right.0)); - if entries.is_empty() { - return Err("发行包没有可上传文件".to_string()); - } - - let mut normalized_writer = zip::ZipWriter::new(Cursor::new(Vec::new())); - let options = zip::write::SimpleFileOptions::default() - .compression_method(zip::CompressionMethod::Deflated); - for (path, content) in &entries { - normalized_writer - .start_file(path, options) - .map_err(|error| format!("写入发行包条目失败:{path}: {error}"))?; - normalized_writer - .write_all(content) - .map_err(|error| format!("写入发行包文件失败:{path}: {error}"))?; - } - let normalized_cursor = normalized_writer - .finish() - .map_err(|error| format!("完成发行包失败:{error}"))?; - let package_bytes = normalized_cursor.into_inner(); - if package_bytes.is_empty() || package_bytes.len() as u64 > MAX_PROJECT_EXPORT_PACKAGE_BYTES { - return Err("归一化发行包大小超出本地发布上限".to_string()); - } - let package_sha256 = format!("{:x}", Sha256::digest(&package_bytes)); - let files = entries - .into_iter() - .map(|(path, content)| LocalProjectExportPackageFileDigest { - size_bytes: content.len() as u64, - sha256: format!("{:x}", Sha256::digest(&content)), - path, - }) - .collect::>(); - if !files.iter().any(|file| file.path == "index.html") { - return Err("归一化发行包缺少根 index.html".to_string()); - } - Ok(LocalProjectExportPackagePayload { - package_relative_path: normalized, - package_size_bytes: package_bytes.len() as u64, - package_bytes, - package_sha256, - files, - }) -} - -fn normalize_release_asset_references(path: &str, content: Vec) -> Vec { - let is_text = matches!( - path.rsplit('.') - .next() - .unwrap_or_default() - .to_ascii_lowercase() - .as_str(), - "css" | "html" | "htm" | "js" | "json" | "mjs" - ); - if !is_text { - return content; - } - let mut source = match String::from_utf8(content) { - Ok(source) => source, - Err(error) => return error.into_bytes(), - }; - for root in ["assets", "game", "ui"] { - source = source.replace(&format!("\"/{root}/"), &format!("\"{root}/")); - source = source.replace(&format!("'/{root}/"), &format!("'{root}/")); - source = source.replace(&format!("`/{root}/"), &format!("`{root}/")); - source = source.replace(&format!("url(/{root}/"), &format!("url({root}/")); - } - source.into_bytes() -} - -#[cfg(test)] -mod tests { - use super::normalize_release_asset_references; - - #[test] - fn normalize_release_asset_references_removes_root_prefix_only_in_text() { - let source = br#""#; - let normalized = normalize_release_asset_references("index.html", source.to_vec()); - let normalized = String::from_utf8(normalized).expect("normalized text"); - assert!(normalized.contains("src=\"assets/hero.png\"")); - assert!(normalized.contains("fetch('ui/config.json')")); - assert!(normalized.contains("url(game/icon.svg)")); - assert_eq!( - normalize_release_asset_references("image.png", b"/assets/image.png".to_vec()), - b"/assets/image.png" - ); - } -} - -pub(crate) fn next_project_export_package_relative_path(root: &Path) -> Result { - let seed = unix_millis(); - for suffix in 0..1000 { - let file_name = if suffix == 0 { - format!("playtest-package-{seed}.zip") - } else { - format!("playtest-package-{seed}-{suffix}.zip") - }; - let relative_path = format!("exports/{file_name}"); - let package_path = resolve_local_project_path(root, &relative_path)?; - if !package_path.exists() { - return Ok(relative_path); - } - } - Err("无法生成唯一试玩包文件名".to_string()) -} - -pub(crate) fn list_local_project_export_packages_at( - root: &Path, -) -> Result { - validate_project_root(root)?; - let export_dir = resolve_local_project_path(root, "exports")?; - if !export_dir.exists() { - return Ok(LocalProjectExportPackagesResult { - project_path: root.to_string_lossy().into_owned(), - packages: Vec::new(), - }); - } - let export_dir_metadata = checked_export_package_metadata(&export_dir, "exports")?; - if !export_dir_metadata.is_dir() { - return Err("exports 必须是目录".to_string()); - } - - let mut packages = Vec::new(); - for entry in fs::read_dir(&export_dir) - .map_err(|error| format!("读取试玩包目录失败:{}: {error}", export_dir.display()))? - { - let entry = entry - .map_err(|error| format!("读取试玩包文件失败:{}: {error}", export_dir.display()))?; - let file_type = entry.file_type().map_err(|error| { - format!( - "读取试玩包文件类型失败:{}: {error}", - entry.path().display() - ) - })?; - if file_type.is_symlink() || !file_type.is_file() { - continue; - } - let file_name = match entry.file_name().to_str() { - Some(value) => value.to_string(), - None => continue, - }; - if !file_name.starts_with("playtest-package-") || !file_name.ends_with(".zip") { - continue; - } - let package_relative_path = - normalize_export_package_entry_path(&format!("exports/{file_name}"))?; - let metadata = entry.metadata().map_err(|error| { - format!("读取试玩包元数据失败:{}: {error}", entry.path().display()) - })?; - let modified_at = metadata - .modified() - .ok() - .and_then(|time| time.duration_since(UNIX_EPOCH).ok()) - .map(|duration| duration.as_millis().min(u128::from(u64::MAX)) as u64) - .unwrap_or(0); - packages.push(LocalProjectExportPackageSummary { - package_path: entry.path().to_string_lossy().into_owned(), - package_relative_path, - total_bytes: metadata.len(), - modified_at, - }); - } - packages.sort_by(|left, right| { - (right.modified_at, &right.package_relative_path) - .cmp(&(left.modified_at, &left.package_relative_path)) - }); - - Ok(LocalProjectExportPackagesResult { - project_path: root.to_string_lossy().into_owned(), - packages, - }) -} - -/// 发布导出前确保 `exports/README.md` 存在。 -/// -/// 缺失时按项目 manifest 生成最小发布说明;已有文件原样保留,避免覆盖作者或 Agent 的正式文案。 -/// README 本身仍沿用试玩包的普通文件 / 符号链接安全口径。 -fn ensure_project_export_readme(root: &Path) -> Result { - let readme_path = resolve_local_project_path(root, "exports/README.md")?; - match fs::symlink_metadata(&readme_path) { - Ok(metadata) => { - if metadata.file_type().is_symlink() { - return Err("试玩包不能包含符号链接:exports/README.md".to_string()); - } - if !metadata.is_file() { - return Err("exports/README.md 必须是普通文件".to_string()); - } - } - Err(error) if error.kind() == std::io::ErrorKind::NotFound => { - let manifest = read_manifest_for_project(root)?; - let content = render_project_export_readme(&manifest); - write_game_creator_private_file(&readme_path, content.as_bytes(), "试玩包发布说明")?; - } - Err(error) => { - return Err(format!( - "读取 exports/README.md 元数据失败:{}: {error}", - readme_path.display() - )); - } - } - Ok(readme_path) -} - -fn render_project_export_readme(manifest: &GameCreationAppManifest) -> String { - let title = manifest.name.trim(); - let title = if title.is_empty() { - "试玩游戏" - } else { - title - }; - let summary = manifest - .goal - .as_deref() - .map(str::trim) - .filter(|value| !value.is_empty()) - .unwrap_or("这是一个可直接在浏览器中试玩的游戏原型。"); - format!( - "# {title} 试玩说明\n\n## 简介\n\n{summary}\n\n## 试玩方式\n\n- 解压试玩包后打开根目录的 `index.html`。\n- 使用游戏内提示开始试玩。\n\n## 反馈\n\n请记录问题发生步骤、预期结果和实际结果,便于快速定位。\n" - ) -} - -pub(crate) fn collect_project_export_package_files( - root: &Path, -) -> Result, String> { - let mut files = Vec::new(); - let game_root = crate::preview::project_game_root(root); - let built = game_root == root.join("dist") || game_root == root.join("game/dist"); - if built { - let relative = relative_project_path(root, &game_root)?; - collect_project_export_package_dir_files(root, &relative, &mut files)?; - for (name, _, _) in &mut files { - *name = format!( - "game/{}", - name.strip_prefix(&format!("{relative}/")) - .ok_or("构建产物路径非法")? - ); - } - // AGC 上传的素材登记在项目根 `assets/`,部分 Phaser 工程直接以 - // `assets/uploads/...` 作为运行 URL,没有通过 Vite public 目录复制到 dist。 - // 构建产物优先;只有 dist 没有同路径时才补入根目录素材,避免重复 ZIP 条目。 - let assets_root = resolve_local_project_path(root, "assets")?; - if assets_root.exists() { - let mut root_assets = Vec::new(); - collect_project_export_package_dir_files(root, "assets", &mut root_assets)?; - for entry in root_assets { - let dist_path = format!("game/{}", entry.0); - if !files.iter().any(|(path, _, _)| path == &dist_path) { - files.push(entry); - } - } - } - } else { - collect_project_export_package_dir_files(root, "game", &mut files)?; - } - if !built && resolve_local_project_path(root, "assets")?.exists() { - collect_project_export_package_dir_files(root, "assets", &mut files)?; - } - let readme_path = resolve_local_project_path(root, "exports/README.md")?; - let readme_metadata = checked_export_package_metadata(&readme_path, "exports/README.md")?; - if !readme_metadata.is_file() { - return Err("导出试玩包前需要先生成 exports/README.md".to_string()); - } - let readme_size = readme_metadata.len(); - files.push(("exports/README.md".to_string(), readme_path, readme_size)); - files.sort_by(|left, right| left.0.cmp(&right.0)); - Ok(files) -} - -pub(crate) fn collect_project_export_package_dir_files( - root: &Path, - relative_dir: &str, - files: &mut Vec<(String, PathBuf, u64)>, -) -> Result<(), String> { - let dir = resolve_local_project_path(root, relative_dir)?; - if !dir.exists() { - return Ok(()); - } - let dir_metadata = checked_export_package_metadata(&dir, relative_dir)?; - if !dir_metadata.is_dir() { - return Err(format!("{relative_dir} 必须是目录")); - } - let mut dirs = vec![dir]; - while let Some(current_dir) = dirs.pop() { - for entry in fs::read_dir(¤t_dir) - .map_err(|error| format!("读取导出目录失败:{}: {error}", current_dir.display()))? - { - let entry = entry - .map_err(|error| format!("读取导出文件失败:{}: {error}", current_dir.display()))?; - let file_type = entry.file_type().map_err(|error| { - format!("读取导出文件类型失败:{}: {error}", entry.path().display()) - })?; - let relative_path = - normalize_export_package_entry_path(&relative_project_path(root, &entry.path())?)?; - if file_type.is_symlink() { - return Err(format!("试玩包不能包含符号链接:{relative_path}")); - } - if file_type.is_dir() { - dirs.push(entry.path()); - } else if file_type.is_file() { - let size = entry - .metadata() - .map_err(|error| { - format!( - "读取导出文件元数据失败:{}: {error}", - entry.path().display() - ) - })? - .len(); - files.push((relative_path, entry.path(), size)); - } - } - } - Ok(()) -} - -pub(crate) fn checked_export_package_metadata( - path: &Path, - relative_path: &str, -) -> Result { - let metadata = fs::symlink_metadata(path) - .map_err(|error| format!("读取导出文件类型失败:{}: {error}", path.display()))?; - if metadata.file_type().is_symlink() { - return Err(format!( - "试玩包不能包含符号链接:{}", - normalize_export_package_entry_path(relative_path)? - )); - } - Ok(metadata) -} - -pub(crate) fn normalize_export_package_entry_path(relative_path: &str) -> Result { - if relative_path.chars().any(char::is_control) { - return Err("试玩包条目路径不能包含控制字符".to_string()); - } - normalize_relative_path(relative_path) -} - -#[cfg(test)] -mod npm_export_tests { - use super::*; - - #[test] - fn declared_version_major_reads_semver_prefixes_and_ranges() { - assert_eq!(declared_version_major("4.2.1"), Some(4)); - assert_eq!(declared_version_major("^4.2.1"), Some(4)); - assert_eq!(declared_version_major("~4.2.1"), Some(4)); - assert_eq!(declared_version_major(">=4 <5"), Some(4)); - assert_eq!(declared_version_major("3.90.0"), Some(3)); - assert_eq!(declared_version_major("latest"), None); - assert_eq!(declared_version_major("*"), None); - assert_eq!(declared_version_major("workspace:*"), None); - } - - #[test] - fn declared_dependency_spec_accepts_string_and_object_forms() { - let package = serde_json::json!({ - "dependencies": { "phaser": "^4.2.1" }, - "devDependencies": { "vite": { "version": "^6.2.0" } } - }); - assert_eq!( - declared_dependency_spec(&package, "phaser").as_deref(), - Some("^4.2.1") - ); - assert_eq!( - declared_dependency_spec(&package, "vite").as_deref(), - Some("^6.2.0") - ); - assert_eq!(declared_dependency_spec(&package, "three"), None); - } - - #[test] - fn publish_precheck_rejects_packages_over_the_platform_limit() { - let limit = shared_contracts::game_distribution::GAME_DISTRIBUTION_MAX_PACKAGE_BYTES; - // 边界:正好等于上限放行,差一个字节也放行(服务端同样按 `>` 判定)。 - ensure_within_platform_package_limit(limit).unwrap(); - ensure_within_platform_package_limit(limit - 1).unwrap(); - // 超限:错误文案必须给出实际体积与上限,作者才能据此精简。 - let error = ensure_within_platform_package_limit(limit + 1).expect_err("over limit"); - assert!(error.contains("超过平台上限"), "{error}"); - assert!(error.contains("200 MiB"), "{error}"); - assert!(error.contains("MiB"), "{error}"); - // 本地导出上限更宽松(512 MiB):超平台的包必须在读包阶段就被拦下, - // 不能等到上传后由服务端 413。 - assert!(limit < MAX_PROJECT_EXPORT_PACKAGE_BYTES); - } - - #[test] - fn npm_package_contains_dist_project_root_assets_and_publish_readme() { - // 仓库约定的跨平台临时根(基于 std::env::temp_dir()):不依赖 HOME, - // Windows 上只有 USERPROFILE 时同样可跑。 - let root = crate::tests::canonical_test_tempdir("agc-npm-export-"); - for directory in [ - "dist/assets/uploads", - "assets/uploads", - "exports", - "node_modules", - "game", - ] { - fs::create_dir_all(root.path().join(directory)).unwrap(); - } - for file in [ - "package.json", - "dist/index.html", - "dist/assets/main.js", - "dist/assets/uploads/shared.png", - "assets/hero.png", - "assets/uploads/shared.png", - "exports/README.md", - "node_modules/private.js", - "game/source.js", - ] { - fs::write(root.path().join(file), "test").unwrap(); - } - let files = collect_project_export_package_files(root.path()).unwrap(); - let names = files - .iter() - .map(|(name, _, _)| name.as_str()) - .collect::>(); - // 构建工程(dist 存在)的试玩包 = dist 构建产物(带 `game/` 前缀,归一化成 - // 发行包时剥离)+ 项目根 `assets/**` 中 dist 未包含的素材(AGC 上传素材以 - // `assets/uploads/...` 作为运行 URL,未必经 Vite public 复制进 dist) - // + 发布说明。`node_modules` 与 `game/` 源码不进包。 - assert_eq!( - names, - vec![ - "assets/hero.png", - "exports/README.md", - "game/assets/main.js", - "game/assets/uploads/shared.png", - "game/index.html", - ] - ); - // 同名素材以 dist 构建产物为准:根 `assets/uploads/shared.png` 会被丢弃, - // 否则归一化剥离 `game/` 前缀后会与 dist 条目重名。 - let shared = files - .iter() - .find(|(name, _, _)| name == "game/assets/uploads/shared.png") - .expect("dist asset entry"); - assert_eq!( - shared.1, - root.path().join("dist/assets/uploads/shared.png"), - "同路径素材必须保留 dist 构建产物" - ); - } - - #[test] - fn built_project_publish_payload_includes_uploaded_assets_and_prefers_dist_assets() { - // 与同批另 5 条 publish_export_tests 同口径:用仓库的跨平台临时根,不依赖 HOME。 - let root = crate::tests::canonical_test_tempdir("agc-publish-payload-"); - for directory in [ - "dist/assets/uploads", - "assets/uploads", - "exports", - "node_modules", - "game/src", - ] { - fs::create_dir_all(root.path().join(directory)).unwrap(); - } - // 构建入口用根绝对引用 `/assets/main.js`:发行包必须把它改写成发行目录内可用的 - // 相对引用,否则包根 index.html 取不到同一包内的资源。 - let built_entry = ""; - for (file, content) in [ - ("package.json", "{\"scripts\":{\"build\":\"vite build\"}}"), - ("dist/index.html", built_entry), - ("dist/assets/main.js", "dist-main"), - ("dist/assets/uploads/shared.png", "dist-shared"), - ("assets/uploads/food.png", "root-food"), - ("assets/uploads/shared.png", "root-shared"), - ("exports/README.md", "publish notes"), - ("node_modules/private.js", "private"), - ("game/src/app.js", "game source"), - ] { - fs::write(root.path().join(file), content).unwrap(); - } - - let exported = export_local_project_package_at(root.path()).expect("export built project"); - let payload = - read_local_project_export_package_at(root.path(), &exported.package_relative_path) - .expect("normalize publish package"); - - // 归一化后的发行包条目:`game/` 前缀已剥掉、dist 产物落在包根、项目根 `assets/**` - // 与 dist 独有路径并存、发布说明在包内;`node_modules` 与 `game/` 源码不入包。 - let names = payload - .files - .iter() - .map(|file| file.path.clone()) - .collect::>(); - assert_eq!( - names, - vec![ - "assets/main.js", - "assets/uploads/food.png", - "assets/uploads/shared.png", - "exports/README.md", - "index.html", - ] - ); - assert!( - names.iter().all(|name| !name.starts_with("game/")), - "发行包不得保留试玩包的 game/ 前缀:{names:?}" - ); - - let mut archive = zip::ZipArchive::new(Cursor::new(payload.package_bytes)) - .expect("read normalized package"); - let read_entry = |archive: &mut zip::ZipArchive>>, name: &str| { - let mut bytes = Vec::new(); - archive - .by_name(name) - .unwrap_or_else(|error| panic!("{name}: {error}")) - .read_to_end(&mut bytes) - .expect("read entry"); - bytes - }; - // dist 未包含的 AGC 上传素材(运行 URL `assets/uploads/...`)必须补入发行包。 - assert_eq!( - read_entry(&mut archive, "assets/uploads/food.png"), - b"root-food" - ); - // 同路径素材以 dist 构建产物为准,根版本被丢弃(否则归一化后会重名)。 - assert_eq!( - read_entry(&mut archive, "assets/uploads/shared.png"), - b"dist-shared" - ); - assert_eq!(read_entry(&mut archive, "assets/main.js"), b"dist-main"); - assert_eq!( - read_entry(&mut archive, "exports/README.md"), - b"publish notes" - ); - let entry_html = - String::from_utf8(read_entry(&mut archive, "index.html")).expect("utf8 entry"); - assert!( - entry_html.contains("src=\"assets/main.js\""), - "{entry_html}" - ); - assert!(!entry_html.contains("\"/assets/"), "{entry_html}"); - // `node_modules` 与 `game/` 源码不得随包发布。 - for forbidden in ["node_modules/private.js", "src/app.js", "game/src/app.js"] { - assert!( - archive.by_name(forbidden).is_err(), - "{forbidden} 不应进入发行包" - ); - } - } -} - -#[cfg(test)] -mod publish_export_tests { - use super::*; - - /// 发布前工程栈校验与「已有可玩入口时跳过构建」的回归覆盖。 - /// - /// 这些用例原在 `src/tests/project.rs`,随 master `839051f53` 删除该文件、merge - /// `783ca014e` 采纳 master 侧而丢失,这里按原断言意图重建在现役测试布局内。 - fn publish_project_tempdir(prefix: &str) -> tempfile::TempDir { - crate::tests::canonical_test_tempdir(prefix) - } - - fn write_game_package_json(root: &Path, package: serde_json::Value) { - fs::write( - root.join("game/package.json"), - serde_json::to_string_pretty(&package).expect("serialize package json"), - ) - .expect("write package json"); - } - - #[test] - fn publish_stack_validation_accepts_scaffolded_phaser4_vite_project() { - let temporary = publish_project_tempdir("agc-publish-stack-ok-"); - let root = temporary.path(); - init_local_game_project_at(root, "project-stack-ok", "栈校验通过项目") - .expect("project init"); - - ensure_publish_project_stack(root).expect("Phaser 4 + Vite 脚手架应通过发布前工程栈校验"); - } - - #[test] - fn publish_stack_validation_reports_actionable_error_for_phaser3() { - let temporary = publish_project_tempdir("agc-publish-stack-phaser3-"); - let root = temporary.path(); - init_local_game_project_at(root, "project-stack-phaser3", "Phaser 3 项目") - .expect("project init"); - write_game_package_json( - root, - serde_json::json!({ - "name": "stack-phaser3", - "private": true, - "scripts": { "build": "vite build" }, - "dependencies": { "phaser": "^3.90.0" }, - "devDependencies": { "vite": "^6.2.0" } - }), - ); - - let error = ensure_publish_project_stack(root).expect_err("Phaser 3 必须被拦下"); - - assert!(error.contains("只支持 Phaser 4"), "{error}"); - assert!(error.contains("3.90.0"), "错误要带上当前声明版本:{error}"); - assert!( - error.contains("npm install phaser@4.2.1"), - "错误要给出可操作修复:{error}" - ); - // 错误文案只带项目内相对路径,不带本地绝对路径。 - assert!( - !error.contains(&root.to_string_lossy().to_string()), - "{error}" - ); - } - - #[test] - fn publish_stack_validation_requires_phaser_dependency_and_vite() { - let temporary = publish_project_tempdir("agc-publish-stack-missing-"); - let root = temporary.path(); - init_local_game_project_at(root, "project-stack-missing", "缺依赖项目") - .expect("project init"); - write_game_package_json( - root, - serde_json::json!({ - "name": "stack-no-phaser", - "private": true, - "scripts": { "build": "vite build" }, - "devDependencies": { "vite": "^6.2.0" } - }), - ); - let error = ensure_publish_project_stack(root).expect_err("缺 phaser 必须被拦下"); - assert!(error.contains("未声明 phaser 依赖"), "{error}"); - - // Phaser 4 但没有 Vite(依赖与配置都不存在):错误必须指向 Vite。 - fs::remove_file(root.join("game/vite.config.js")).expect("remove scaffold vite config"); - write_game_package_json( - root, - serde_json::json!({ - "name": "stack-webpack", - "private": true, - "scripts": { "build": "webpack" }, - "dependencies": { "phaser": "4.2.1" } - }), - ); - let error = ensure_publish_project_stack(root).expect_err("非 Vite 工程必须被拦下"); - assert!(error.contains("vite"), "{error}"); - assert!(error.contains("Vite 构建"), "{error}"); - } - - #[test] - fn publish_stack_validation_reads_semver_ranges_and_lockfile_versions() { - let temporary = publish_project_tempdir("agc-publish-stack-semver-"); - let root = temporary.path(); - init_local_game_project_at(root, "project-stack-semver", "版本区间项目") - .expect("project init"); - write_game_package_json( - root, - serde_json::json!({ - "name": "stack-semver", - "private": true, - "scripts": { "build": "vite build" }, - "dependencies": { "phaser": ">=4 <5" }, - "devDependencies": { "vite": "^6.2.0" } - }), - ); - ensure_publish_project_stack(root).expect(">=4 <5 属于 Phaser 4"); - - // 声明成 `*` 时以 package-lock.json 的解析版本为准。 - write_game_package_json( - root, - serde_json::json!({ - "name": "stack-star", - "private": true, - "scripts": { "build": "vite build" }, - "dependencies": { "phaser": "*" }, - "devDependencies": { "vite": "^6.2.0" } - }), - ); - fs::write( - root.join("game/package-lock.json"), - serde_json::to_string_pretty(&serde_json::json!({ - "name": "stack-star", - "lockfileVersion": 3, - "packages": { "": {}, "node_modules/phaser": { "version": "3.90.0" } } - })) - .expect("serialize lockfile"), - ) - .expect("write lockfile"); - let error = ensure_publish_project_stack(root).expect_err("锁文件里的 Phaser 3 也要拦下"); - assert!(error.contains("只支持 Phaser 4"), "{error}"); - } - - #[test] - fn publish_stack_validation_rejects_projects_without_npm_build_workspace() { - let temporary = publish_project_tempdir("agc-publish-stack-legacy-"); - let root = temporary.path(); - init_local_game_project_at(root, "project-stack-legacy", "无 npm 构建项目") - .expect("project init"); - // 去掉 npm 工作区(没有 package.json 也就没有 build 脚本),单文件项目不属于客户端首版合同。 - fs::remove_file(root.join("game/package.json")).expect("remove game package json"); - - let error = ensure_publish_project_stack(root).expect_err("单文件项目必须失败关闭"); - assert!(error.contains("只支持 Phaser 4"), "{error}"); - } - - #[tokio::test] - async fn publish_export_skips_build_when_phaser_vite_entry_exists() { - let temporary = publish_project_tempdir("agc-publish-skip-build-"); - let root = temporary.path(); - init_local_game_project_at(root, "project-publish-skip", "已构建发布项目") - .expect("project init"); - // 已有可玩构建产物:发布会直接打包,不再触发 npm run build。 - fs::create_dir_all(root.join("game/dist")).expect("create dist directory"); - write_game_package_json( - root, - serde_json::json!({ - "name": "publish-skip-fixture", - "private": true, - "scripts": { "build": "vite build" }, - "dependencies": { "phaser": "4.2.1" }, - "devDependencies": { "vite": "^6.2.0" } - }), - ); - fs::write(root.join("game/vite.config.js"), "export default {};\n") - .expect("write vite config"); - fs::write( - root.join("game/dist/index.html"), - "", - ) - .expect("write playable html"); - - let result = export_local_project_package_for_publish_at(root) - .await - .expect("已有可玩入口且缺少 README 时应自动生成并直接导出"); - - assert!(result.package_relative_path.ends_with(".zip")); - let readme = - fs::read_to_string(root.join("exports/README.md")).expect("read generated readme"); - assert!(readme.contains("# 已构建发布项目 试玩说明"), "{readme}"); - let payload = read_local_project_export_package_at(root, &result.package_relative_path) - .expect("read publishable package"); - assert!(payload.files.iter().any(|file| file.path == "index.html")); - assert!(payload - .files - .iter() - .any(|file| file.path == "exports/README.md")); - let log = fs::read_to_string(root.join(".agent/logs/command.log")).unwrap_or_default(); - assert!( - !log.contains("project.verify build"), - "已有可玩入口时不应触发构建:{log}" - ); - } - - /// 单文件可玩 HTML:原 `tests/mod.rs::fake_llm_game_draft().game_html` 的最小内联副本, - /// 满足 `validate_game_html_smoke`(画布、主循环、输入、目标/胜负/重开文案、自包含)。 - const PLAYABLE_GAME_HTML: &str = r#" - - 月光弹幕厨房 - - - - -"#; - - /// 单文件 HTML 项目(非构建布局):脚手架提供 manifest 与目录,再去掉 npm 工作区脚手架, - /// 换成可玩入口。等价于已删除的 `tests/mod.rs::init_existing_html_project_at`。 - fn init_existing_html_project(root: &Path, project_id: &str, name: &str) { - init_local_game_project_at(root, project_id, name).expect("project init"); - for relative in [ - "game/package.json", - "game/package-lock.json", - "game/vite.config.js", - "game/style.css", - "game/game.js", - ] { - let _ = fs::remove_file(root.join(relative)); - } - fs::write(root.join("game/index.html"), PLAYABLE_GAME_HTML).expect("write playable html"); - } - - #[test] - fn publish_build_plan_prefers_game_subproject_and_requires_dependencies() { - let temporary = publish_project_tempdir("agc-publish-plan-game-"); - let root = temporary.path(); - init_local_game_project_at(root, "project-plan-game", "Phaser 工程").expect("project init"); - - // 脚手架是 game/ + vite build(Phaser 4 工程):缺依赖时必须先 install。 - let plan = resolve_publish_build_plan(root).expect("解析构建计划"); - assert_eq!(plan.cwd_relative, "game"); - assert_eq!(plan.command, "vite build"); - assert!( - plan.needs_dependency_install, - "缺少 game/node_modules 时应先装依赖" - ); - - fs::create_dir_all(root.join("game/node_modules")).expect("create node_modules"); - let installed = resolve_publish_build_plan(root).expect("解析构建计划"); - assert!( - !installed.needs_dependency_install, - "已有依赖时不应重复 install" - ); - } - - #[test] - fn publish_build_plan_falls_back_to_root_npm_build() { - let temporary = publish_project_tempdir("agc-publish-plan-root-"); - let root = temporary.path(); - init_local_game_project_at(root, "project-plan-root", "根工程构建").expect("project init"); - // 去掉 game 子工程的 build,改用项目根 npm 工程构建。 - write_game_package_json( - root, - serde_json::json!({ - "name": "plan-root-game", - "private": true, - "scripts": { "check": "node -e \"process.exit(0)\"" } - }), - ); - fs::write( - root.join("package.json"), - serde_json::to_string_pretty(&serde_json::json!({ - "name": "plan-root-fixture", - "private": true, - "scripts": { "build": "node build-root.mjs" } - })) - .expect("serialize root package json"), - ) - .expect("write root package json"); - - let plan = resolve_publish_build_plan(root).expect("解析构建计划"); - assert_eq!(plan.cwd_relative, "."); - assert_eq!(plan.command, "node build-root.mjs"); - assert!(!plan.needs_dependency_install); - } - - #[tokio::test] - async fn publish_export_runs_project_build_before_packaging() { - let temporary = publish_project_tempdir("agc-publish-auto-build-"); - let root = temporary.path(); - init_local_game_project_at(root, "project-auto-build", "自动构建发布项目") - .expect("project init"); - // 只有源码:package.json 声明 build,构建脚本产出 dist/ 可玩产物。 - fs::write( - root.join("package.json"), - serde_json::to_string_pretty(&serde_json::json!({ - "name": "publish-auto-build-fixture", - "private": true, - "scripts": { "build": "node build-publish.mjs" } - })) - .expect("serialize package json"), - ) - .expect("write package json"); - fs::write( - root.join("build-publish.mjs"), - r#"import { mkdirSync, writeFileSync } from 'node:fs'; -mkdirSync('dist/assets', { recursive: true }); -writeFileSync('dist/index.html', 'Auto Build

AUTO-BUILD

'); -writeFileSync('dist/assets/app.js', 'document.documentElement.dataset.autoBuild = "1";'); -"#, - ) - .expect("write build script"); - update_manifest_task_status_at( - root, - "code-prototype", - GameCreationAppTaskStatus::Completed, - ) - .expect("mark prototype completed"); - write_local_project_file_at(root, "exports/README.md", "publish notes") - .expect("write readme"); - - let result = export_local_project_package_for_publish_at(root) - .await - .expect("发布前构建并导出"); - - assert!(root.join("dist/index.html").is_file()); - assert!(root.join("dist/assets/app.js").is_file()); - assert!(result - .package_relative_path - .starts_with("exports/playtest-package-")); - let log = fs::read_to_string(root.join(".agent/logs/command.log")).unwrap_or_default(); - assert!( - log.contains("project.verify build"), - "发布前应记录一次 project.verify build:{log}" - ); - } - - #[tokio::test] - async fn publish_export_blocks_build_until_runnable_prototype_is_completed() { - let temporary = publish_project_tempdir("agc-publish-prototype-pending-"); - let root = temporary.path(); - init_local_game_project_at(root, "project-prototype-pending", "原型未完成项目") - .expect("project init"); - fs::write( - root.join("package.json"), - serde_json::to_string_pretty(&serde_json::json!({ - "name": "publish-prototype-pending-fixture", - "private": true, - "scripts": { "build": "node build-should-not-run.mjs" } - })) - .expect("serialize package json"), - ) - .expect("write package json"); - fs::write( - root.join("build-should-not-run.mjs"), - "import { writeFileSync } from 'node:fs'; writeFileSync('build-ran.txt', 'yes');", - ) - .expect("write build marker script"); - - let error = export_local_project_package_for_publish_at(root) - .await - .expect_err("原型未完成时禁止触发用户项目构建"); - - assert!( - error.contains("首个可运行原型尚未完成"), - "错误应说明原型未完成:{error}" - ); - assert!(!root.join("build-ran.txt").exists()); - let log = fs::read_to_string(root.join(".agent/logs/command.log")).unwrap_or_default(); - assert!( - !log.contains("project.verify build"), - "原型未完成时不应执行项目构建:{log}" - ); - } - - #[tokio::test] - async fn publish_export_reports_actionable_error_without_entry_or_build_script() { - let temporary = publish_project_tempdir("agc-publish-no-entry-"); - let root = temporary.path(); - init_local_game_project_at(root, "project-no-entry", "缺少可玩入口项目") - .expect("project init"); - update_manifest_task_status_at( - root, - "code-prototype", - GameCreationAppTaskStatus::Completed, - ) - .expect("mark prototype completed"); - // 脚手架默认带 build 脚本;这里改成只有 check 脚本,模拟“没有可玩产物且没有构建脚本”。 - write_game_package_json( - root, - serde_json::json!({ - "name": "publish-no-entry-fixture", - "private": true, - "scripts": { "check": "node -e \"process.exit(0)\"" } - }), - ); - - let error = export_local_project_package_for_publish_at(root) - .await - .expect_err("缺少入口且没有 build 脚本时必须失败关闭"); - - assert!( - error.contains("还没有可玩入口"), - "错误应说明缺少可玩入口:{error}" - ); - assert!( - error.contains("build 脚本"), - "错误应指向 build 脚本:{error}" - ); - } - - #[test] - fn local_project_export_package_publish_payload_contains_bytes_and_file_digests() { - let temporary = publish_project_tempdir("agc-publish-payload-"); - let root = temporary.path(); - init_existing_html_project(root, "project-publish", "在线试玩项目"); - write_local_project_file_at(root, "exports/README.md", "publish notes") - .expect("write readme"); - - let exported = export_local_project_package_at(root).expect("export package"); - assert_eq!( - fs::read_to_string(root.join("exports/README.md")).unwrap(), - "publish notes" - ); - let payload = read_local_project_export_package_at(root, &exported.package_relative_path) - .expect("read publish payload"); - - assert_eq!( - payload.package_relative_path, - exported.package_relative_path - ); - assert_eq!( - payload.package_size_bytes, - payload.package_bytes.len() as u64 - ); - assert_eq!(payload.files.len(), 2); - assert!(payload.files.iter().any(|file| file.path == "index.html")); - assert!(payload - .files - .iter() - .any(|file| file.path == "exports/README.md")); - let mut archive = zip::ZipArchive::new(std::io::Cursor::new(&payload.package_bytes)) - .expect("read normalized package"); - let names = (0..archive.len()) - .map(|index| { - archive - .by_index(index) - .expect("normalized entry") - .name() - .to_string() - }) - .collect::>(); - assert!(names.iter().any(|name| name == "index.html")); - assert!(!names.iter().any(|name| name.starts_with("game/"))); - assert_eq!(payload.package_sha256.len(), 64); - assert!(payload - .package_sha256 - .chars() - .all(|value| value.is_ascii_hexdigit())); - } - - #[test] - fn local_project_export_package_list_only_returns_recent_playtest_zips() { - let temporary = publish_project_tempdir("agc-export-list-"); - let root = temporary.path(); - init_local_game_project_at(root, "project-1", "像素动作原型").expect("project init"); - fs::write(root.join("exports/playtest-package-001.zip"), "old").expect("write old zip"); - fs::write(root.join("exports/not-playtest.zip"), "ignore").expect("write ignored zip"); - fs::write(root.join("exports/playtest-package-002.zip"), "newer").expect("write newer zip"); - fs::write(root.join("game/playtest-package-003.zip"), "wrong dir") - .expect("write wrong dir zip"); - - let result = list_local_project_export_packages_at(root).expect("list packages"); - - assert_eq!(result.project_path, root.to_string_lossy().into_owned()); - let paths = result - .packages - .iter() - .map(|package| package.package_relative_path.as_str()) - .collect::>(); - assert_eq!( - paths, - vec![ - "exports/playtest-package-002.zip", - "exports/playtest-package-001.zip" - ] - ); - assert_eq!(result.packages[0].total_bytes, 5); - assert!(result.packages[0].modified_at > 0); - } - - #[cfg(unix)] - #[test] - fn local_project_export_package_list_skips_symlink_packages() { - let temporary = publish_project_tempdir("agc-export-list-link-"); - let root = temporary.path(); - init_local_game_project_at(root, "project-1", "像素动作原型").expect("project init"); - fs::write(root.join("exports/playtest-package-real.zip"), "real").expect("write zip"); - std::os::unix::fs::symlink( - root.join("memory/project.md"), - root.join("exports/playtest-package-link.zip"), - ) - .expect("create package symlink"); - - let result = list_local_project_export_packages_at(root).expect("list packages"); - - assert_eq!(result.packages.len(), 1); - assert_eq!( - result.packages[0].package_relative_path, - "exports/playtest-package-real.zip" - ); - } - - #[cfg(unix)] - #[test] - fn local_project_export_package_rejects_symlink_assets() { - let temporary = publish_project_tempdir("agc-export-link-assets-"); - let root = temporary.path(); - init_existing_html_project(root, "project-1", "像素动作原型"); - write_local_project_file_at(root, "exports/README.md", "playtest notes") - .expect("write readme"); - std::os::unix::fs::symlink( - root.join("memory/project.md"), - root.join("assets/private.md"), - ) - .expect("create symlink asset"); - - let error = export_local_project_package_at(root).expect_err("symlink should be rejected"); - - assert!(error.contains("试玩包不能包含符号链接")); - assert!(!root.join("exports").join("playtest-package-").exists()); - } - - #[cfg(unix)] - #[test] - fn local_project_export_package_rejects_symlink_runtime_dirs_and_readme() { - let temporary = publish_project_tempdir("agc-export-link-runtime-"); - let root = temporary.path(); - init_existing_html_project(root, "project-1", "像素动作原型"); - write_local_project_file_at(root, "memory/project.md", "private memory") - .expect("write memory"); - std::os::unix::fs::symlink( - root.join("memory/project.md"), - root.join("exports/README.md"), - ) - .expect("create readme symlink"); - - let error = export_local_project_package_at(root).expect_err("readme symlink rejected"); - - assert!(error.contains("符号链接"), "{error}"); - fs::remove_file(root.join("exports/README.md")).expect("remove readme symlink"); - write_local_project_file_at(root, "exports/README.md", "playtest notes") - .expect("write readme"); - fs::remove_dir_all(root.join("assets")).expect("remove assets dir"); - fs::create_dir_all(root.join("memory/assets")).expect("create memory assets"); - fs::write(root.join("memory/assets/hero.txt"), "private asset").expect("write asset"); - std::os::unix::fs::symlink(root.join("memory/assets"), root.join("assets")) - .expect("create assets symlink"); - - let error = export_local_project_package_at(root).expect_err("assets symlink rejected"); - - assert!(error.contains("符号链接"), "{error}"); - } -} diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/manifest.rs b/apps/ai-game-creator-shell/src-tauri/src/project/manifest.rs index 44e55db43..2a4c3e5b0 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/manifest.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/manifest.rs @@ -611,7 +611,7 @@ pub(crate) fn init_local_game_project_at( && !root.join("game/index.html").exists() && !root.join("package.json").exists() && !root.join("game/package.json").exists(); - for relative in ["game", "assets", "memory", "memory/agents", "exports"] { + for relative in ["game", "assets", "memory", "memory/agents"] { let path = root.join(relative); ensure_game_creator_private_directory_tree(&path, "本地项目目录")?; prepare_game_creator_private_path_for_read(&path, true, "本地项目目录")?; diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/manifest/import_tests.rs b/apps/ai-game-creator-shell/src-tauri/src/project/manifest/import_tests.rs index ab0f99690..05a8417d8 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/manifest/import_tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/manifest/import_tests.rs @@ -64,7 +64,7 @@ fn imports_godot_project_without_creating_parallel_game_directories() { fs::read_to_string(root.join("scenes/main.tscn")).expect("read preserved scene"), "[gd_scene format=3]\n" ); - for unexpected in ["game", "assets", "memory", "exports"] { + for unexpected in ["game", "assets", "memory"] { assert!( !root.join(unexpected).exists(), "Godot import must not create {unexpected}" diff --git a/apps/ai-game-creator-shell/src-tauri/src/repository_context.rs b/apps/ai-game-creator-shell/src-tauri/src/repository_context.rs index b01f26b76..62969d5b7 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/repository_context.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/repository_context.rs @@ -716,9 +716,10 @@ fn should_ignore_directory(path: &Path) -> bool { { return true; } - if components.first().is_some_and(|component| { - matches!(component.as_str(), "memory" | "logs" | ".app" | "exports") - }) { + if components + .first() + .is_some_and(|component| matches!(component.as_str(), "memory" | "logs" | ".app")) + { return true; } components.windows(2).any(|pair| { diff --git a/apps/ai-game-creator-shell/src-tauri/src/template_library.rs b/apps/ai-game-creator-shell/src-tauri/src/template_library.rs index 65265250e..8f83cddb6 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/template_library.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/template_library.rs @@ -1496,7 +1496,7 @@ mod tests { assert!(created.manifest.godot_project_root.is_none()); assert!(root.join(".agent/manifest.json").is_file()); assert!(root.join(".agent/agent.db").is_file()); - for unexpected in ["game", "memory", "exports", TEMPLATE_INSTALLED_MARKER_FILE] { + for unexpected in ["game", "memory", TEMPLATE_INSTALLED_MARKER_FILE] { assert!(!root.join(unexpected).exists(), "unexpected {unexpected}"); } let package: serde_json::Value = serde_json::from_slice( diff --git a/apps/ai-game-creator-shell/src/view/project-development/export/generated/TaonierArtifactMissing.ts b/apps/ai-game-creator-shell/src/view/project-development/export/generated/TaonierArtifactMissing.ts new file mode 100644 index 000000000..b5b995007 --- /dev/null +++ b/apps/ai-game-creator-shell/src/view/project-development/export/generated/TaonierArtifactMissing.ts @@ -0,0 +1,7 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type TaonierArtifactMissing = { +/** + * 产物相对项目根的路径。 + */ +path: string, }; diff --git a/apps/ai-game-creator-shell/src/view/project-development/export/generated/TaonierBuildScriptMissing.ts b/apps/ai-game-creator-shell/src/view/project-development/export/generated/TaonierBuildScriptMissing.ts new file mode 100644 index 000000000..94f719a41 --- /dev/null +++ b/apps/ai-game-creator-shell/src/view/project-development/export/generated/TaonierBuildScriptMissing.ts @@ -0,0 +1,11 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type TaonierBuildScriptMissing = { +/** + * 期望的脚本名(`build:taonier`)。 + */ +script: string, +/** + * 宿主找过哪些 `package.json`,让用户知道去哪看。 + */ +searched: Array, }; diff --git a/apps/ai-game-creator-shell/src/view/project-development/export/generated/TaonierCommandDenied.ts b/apps/ai-game-creator-shell/src/view/project-development/export/generated/TaonierCommandDenied.ts new file mode 100644 index 000000000..265ddd0a8 --- /dev/null +++ b/apps/ai-game-creator-shell/src/view/project-development/export/generated/TaonierCommandDenied.ts @@ -0,0 +1,11 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * 策略拒绝与宿主故障分开:前者是预期拒绝(面板给固定话术,不入错误池),后者是宿主侧事实故障 + * (面板给现场,并原样抛出进错误池)。ADR 要求顶层只放调用方要分流的类别,这里就是那个分流点。 + */ +export type TaonierCommandDenied = { +/** + * 被拒的命令点位(`command.exec`)。句子由前端拼,宿主只给事实。 + */ +commandId: string, }; diff --git a/apps/ai-game-creator-shell/src/view/project-development/export/generated/TaonierCommandFailed.ts b/apps/ai-game-creator-shell/src/view/project-development/export/generated/TaonierCommandFailed.ts new file mode 100644 index 000000000..10c1458fc --- /dev/null +++ b/apps/ai-game-creator-shell/src/view/project-development/export/generated/TaonierCommandFailed.ts @@ -0,0 +1,11 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type TaonierCommandFailed = { exitCode: number | null, +/** + * 脚本输出尾部原文。**不含**任何宿主拼的说明:省略了多少单独作为事实回给前端。 + */ +outputTail: string, +/** + * 为了让载荷留在 IPC 尺寸内,尾部之前省略了多少个字符;0 表示没截。 + */ +omittedCharacters: number, }; diff --git a/apps/ai-game-creator-shell/src/view/project-development/export/generated/TaonierExportBuildError.ts b/apps/ai-game-creator-shell/src/view/project-development/export/generated/TaonierExportBuildError.ts new file mode 100644 index 000000000..9ebd3a462 --- /dev/null +++ b/apps/ai-game-creator-shell/src/view/project-development/export/generated/TaonierExportBuildError.ts @@ -0,0 +1,11 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { TaonierArtifactMissing } from "./TaonierArtifactMissing"; +import type { TaonierBuildScriptMissing } from "./TaonierBuildScriptMissing"; +import type { TaonierCommandDenied } from "./TaonierCommandDenied"; +import type { TaonierCommandFailed } from "./TaonierCommandFailed"; +import type { TaonierExportUnavailable } from "./TaonierExportUnavailable"; + +/** + * 跑构建可能怎么失败。构建不是表单的消费者,所以这里没有表单类变体。 + */ +export type TaonierExportBuildError = { "type": "buildScriptMissing" } & TaonierBuildScriptMissing | { "type": "commandDenied" } & TaonierCommandDenied | { "type": "commandFailed" } & TaonierCommandFailed | { "type": "artifactMissing" } & TaonierArtifactMissing | { "type": "exportUnavailable" } & TaonierExportUnavailable; diff --git a/apps/ai-game-creator-shell/src/view/project-development/export/generated/TaonierExportForm.ts b/apps/ai-game-creator-shell/src/view/project-development/export/generated/TaonierExportForm.ts new file mode 100644 index 000000000..e650c6948 --- /dev/null +++ b/apps/ai-game-creator-shell/src/view/project-development/export/generated/TaonierExportForm.ts @@ -0,0 +1,20 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * 平台上传表单。**不是构建输入**:zip 规范里没有任何 app 元数据,这些值的唯一用途是让用户 + * 复制粘贴到陶泥儿平台的上传页。 + * + * `deny_unknown_fields` 是刻意的:这个文件也由 code agent 手写,字段漂移必须当场可见,不能 + * 被静默忽略后带着半份表单继续走。 + * + * `cover_path` 为空字符串表示「沿用线上封面」;`screenshot_paths` 为空数组表示「整组沿用线上」。 + */ +export type TaonierExportForm = { title: string, summary: string, category: string, +/** + * 项目内相对路径。空字符串表示沿用线上封面。 + */ +coverPath: string, +/** + * 项目内相对路径列表。空数组表示整组沿用线上截图。 + */ +screenshotPaths: Array, }; diff --git a/apps/ai-game-creator-shell/src/view/project-development/export/generated/TaonierExportReadError.ts b/apps/ai-game-creator-shell/src/view/project-development/export/generated/TaonierExportReadError.ts new file mode 100644 index 000000000..3baa97912 --- /dev/null +++ b/apps/ai-game-creator-shell/src/view/project-development/export/generated/TaonierExportReadError.ts @@ -0,0 +1,8 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { TaonierExportUnavailable } from "./TaonierExportUnavailable"; +import type { TaonierRegistryMalformed } from "./TaonierRegistryMalformed"; + +/** + * 读注册表可能怎么失败。 + */ +export type TaonierExportReadError = { "type": "registryMalformed" } & TaonierRegistryMalformed | { "type": "exportUnavailable" } & TaonierExportUnavailable; diff --git a/apps/ai-game-creator-shell/src/view/project-development/export/generated/TaonierExportRegistry.ts b/apps/ai-game-creator-shell/src/view/project-development/export/generated/TaonierExportRegistry.ts new file mode 100644 index 000000000..379edbb80 --- /dev/null +++ b/apps/ai-game-creator-shell/src/view/project-development/export/generated/TaonierExportRegistry.ts @@ -0,0 +1,13 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { TaonierExportForm } from "./TaonierExportForm"; + +/** + * 注册表文件形状:`.export/taonier.json`。 + * + * 只有 `form` 一个键——没有 status、没有已适配标记、没有产物存在标记、没有 revision,那些要么是 + * `package.json` 的现算结果,要么由内容指纹承担。外层留一层对象是为了以后加目标级键而不动表单形状。 + * + * 这个形状也进 agent 的指令正文(前端拿 ts-rs 生成的类型拼一份「值的位置写要求」的示意),所以它 + * 必须导出绑定:Rust 侧加键、改名,前端那份示意会在 `typecheck` 里当场对不上。 + */ +export type TaonierExportRegistry = { form: TaonierExportForm, }; diff --git a/apps/ai-game-creator-shell/src/view/project-development/export/generated/TaonierExportRunResult.ts b/apps/ai-game-creator-shell/src/view/project-development/export/generated/TaonierExportRunResult.ts new file mode 100644 index 000000000..5ce0b69d8 --- /dev/null +++ b/apps/ai-game-creator-shell/src/view/project-development/export/generated/TaonierExportRunResult.ts @@ -0,0 +1,17 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * 一次成功导出的结果。产物路径是常量(`.export/taonier.zip`),不进 DTO。 + * + * 不带 `exit_code`:走到这里的出口码必然是 0,带回来只是一个恒为 0 的死字段;失败侧 + * (`CommandFailed`)自己带退出码。 + */ +export type TaonierExportRunResult = { +/** + * 构建输出尾部原文(stdout 与 stderr 合并)。打包脚本会把 zip 的条目数与体积打在这里。 + */ +outputTail: string, +/** + * 尾部之前省略了多少个字符;0 表示没截。说明文字由前端拼。 + */ +omittedCharacters: number, }; diff --git a/apps/ai-game-creator-shell/src/view/project-development/export/generated/TaonierExportSaveError.ts b/apps/ai-game-creator-shell/src/view/project-development/export/generated/TaonierExportSaveError.ts new file mode 100644 index 000000000..0b07412fa --- /dev/null +++ b/apps/ai-game-creator-shell/src/view/project-development/export/generated/TaonierExportSaveError.ts @@ -0,0 +1,9 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { TaonierExportUnavailable } from "./TaonierExportUnavailable"; +import type { TaonierRegistryMalformed } from "./TaonierRegistryMalformed"; +import type { TaonierSaveConflict } from "./TaonierSaveConflict"; + +/** + * 保存表单可能怎么失败。自动保存与「打包」前的 flush 共用这一张表。 + */ +export type TaonierExportSaveError = { "type": "registryMalformed" } & TaonierRegistryMalformed | { "type": "saveConflict" } & TaonierSaveConflict | { "type": "exportUnavailable" } & TaonierExportUnavailable; diff --git a/apps/ai-game-creator-shell/src/view/project-development/export/generated/TaonierExportState.ts b/apps/ai-game-creator-shell/src/view/project-development/export/generated/TaonierExportState.ts new file mode 100644 index 000000000..c908ef31a --- /dev/null +++ b/apps/ai-game-creator-shell/src/view/project-development/export/generated/TaonierExportState.ts @@ -0,0 +1,15 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { TaonierExportForm } from "./TaonierExportForm"; + +/** + * 面板读到的现场内容。 + * + * 没有「产物是否已存在」:每次导出都重新构建。也没有 status / 已适配标记——「有没有脚本」 + * 永远从 npm 包的 `package.json` 现算,不落盘。也没有注册表指纹:那是写回时的同步细节, + * 由 `read_taonier_export_hash` 单独给,自动刷新读的这条命令碰不到它。 + */ +export type TaonierExportState = { form: TaonierExportForm, +/** + * `scripts["build:taonier"]` 是否存在。 + */ +hasScript: boolean, }; diff --git a/apps/ai-game-creator-shell/src/view/project-development/export/generated/TaonierExportUnavailable.ts b/apps/ai-game-creator-shell/src/view/project-development/export/generated/TaonierExportUnavailable.ts new file mode 100644 index 000000000..dcb42fc36 --- /dev/null +++ b/apps/ai-game-creator-shell/src/view/project-development/export/generated/TaonierExportUnavailable.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type TaonierExportUnavailable = { cause: string, }; diff --git a/apps/ai-game-creator-shell/src/view/project-development/export/generated/TaonierRegistryMalformed.ts b/apps/ai-game-creator-shell/src/view/project-development/export/generated/TaonierRegistryMalformed.ts new file mode 100644 index 000000000..3da05d609 --- /dev/null +++ b/apps/ai-game-creator-shell/src/view/project-development/export/generated/TaonierRegistryMalformed.ts @@ -0,0 +1,7 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type TaonierRegistryMalformed = { +/** + * 解析器原文。它是引导语,不是给前端做判断的依据。 + */ +cause: string, }; diff --git a/apps/ai-game-creator-shell/src/view/project-development/export/generated/TaonierSaveConflict.ts b/apps/ai-game-creator-shell/src/view/project-development/export/generated/TaonierSaveConflict.ts new file mode 100644 index 000000000..e4b95c273 --- /dev/null +++ b/apps/ai-game-creator-shell/src/view/project-development/export/generated/TaonierSaveConflict.ts @@ -0,0 +1,12 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { TaonierExportForm } from "./TaonierExportForm"; + +export type TaonierSaveConflict = { +/** + * 文件里现在是什么(字段级二选一的「文件里的」那一侧)。 + */ +current: TaonierExportForm, +/** + * 冲突时文件的指纹;用户选完之后带着它重试保存。 + */ +contentHash: string, }; diff --git a/packages/shared/src/contracts/gameCreationApp.ts b/packages/shared/src/contracts/gameCreationApp.ts index fa6ce1072..fbfc74aac 100644 --- a/packages/shared/src/contracts/gameCreationApp.ts +++ b/packages/shared/src/contracts/gameCreationApp.ts @@ -435,7 +435,7 @@ export const GAME_CREATION_APP_SEED_TASKS = [ role: 'Publish', status: 'pending', dependencies: ['publish-strategy'], - artifacts: ['exports/README.md'], + artifacts: [], acceptanceCriteria: ['标题、简介、标签、封面需求和导出检查已完成'], }, ] satisfies readonly GameCreationAppTaskState[]; diff --git a/server-rs/crates/platform-agent/src/game_creation.rs b/server-rs/crates/platform-agent/src/game_creation.rs index 3adb769f0..b6b64a700 100644 --- a/server-rs/crates/platform-agent/src/game_creation.rs +++ b/server-rs/crates/platform-agent/src/game_creation.rs @@ -984,7 +984,7 @@ pub fn build_game_creation_seed_task_graph( GameCreationAgentGroup::Publishing, "Publish", ["publish-strategy"], - ["exports/README.md"], + [], ["标题、简介、标签、封面需求和导出检查已完成"], ), ], diff --git a/server-rs/crates/shared-contracts/src/game_creation_app.rs b/server-rs/crates/shared-contracts/src/game_creation_app.rs index a04fd71e0..93847ae71 100644 --- a/server-rs/crates/shared-contracts/src/game_creation_app.rs +++ b/server-rs/crates/shared-contracts/src/game_creation_app.rs @@ -424,7 +424,7 @@ pub fn new_game_creation_app_seed_tasks() -> Vec { GameCreationAppAgentGroup::Publishing, "Publish", ["publish-strategy"], - ["exports/README.md"], + [], ["标题、简介、标签、封面需求和导出检查已完成"], ), ]