From c52e0814d61bf09073b21c9f8b458b2260996858 Mon Sep 17 00:00:00 2001 From: kdletters <61648117+kdletters@users.noreply.github.com> Date: Mon, 21 Sep 2026 17:05:24 +0800 Subject: [PATCH 1/2] =?UTF-8?q?AGC=E6=B8=A0=E9=81=93=E5=AE=89=E8=A3=85?= =?UTF-8?q?=E8=BA=AB=E4=BB=BD=E9=9A=94=E7=A6=BB=EF=BC=9A=E4=B8=8D=E5=90=8C?= =?UTF-8?q?=E6=B8=A0=E9=81=93=E5=8C=85=E4=BD=93=E5=8F=AF=E5=9C=A8=E5=90=8C?= =?UTF-8?q?=E4=B8=80=E5=8F=B0=E8=AE=BE=E5=A4=87=E5=B9=B6=E5=AD=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 channel-identity.mjs 单点定义渠道安装身份,默认渠道保持基线 productName 与 identifier - build-release.mjs 渠道 --config 同批注入 productName、identifier 与 updater 端点 - build-macos-ci.mjs 按发布渠道解析产品名,派生 .app、updater 归档与 DMG 名称 - main.rs 与 windows.rs 窗口标题取构建期产品名,同机并存的渠道客户端可区分 - config.rs 提权 ACL 的 managed 范围按目录名识别基线目录与渠道派生目录 - check-config.mjs 断言基线等于默认渠道身份且非默认渠道身份必须隔离 - build-release.test.mjs 与 prepare-macos-codex.test.mjs 补齐渠道身份与渠道首装包用例 - 更新 AGC 更新主规范、开发运维文档、共享记忆与渠道安装身份隔离的里程碑与实施计划 --- .../scripts/agent-swarm-test-chat.mjs | 5 +- .../scripts/build-macos-ci.mjs | 24 ++--- .../scripts/build-release.mjs | 37 +++----- .../scripts/build-release.test.mjs | 69 ++++++++++++++ .../scripts/channel-identity.mjs | 73 +++++++++++++++ .../scripts/check-config.mjs | 36 +++++++- .../scripts/prepare-macos-codex.test.mjs | 12 ++- .../src-tauri/src/config.rs | 92 +++++++++++++++---- .../src-tauri/src/main.rs | 17 ++++ .../src-tauri/src/windows.rs | 4 +- ...实施计划】AGC渠道安装身份隔离-2026-09-21.md | 49 ++++++++++ ...里程碑】AGC渠道安装身份隔离-2026-09-21.md | 44 +++++++++ .../shared-memory/decision-log.md | 9 ++ docs/project-memory/shared-memory/pitfalls.md | 9 ++ .../shared-memory/project-overview.md | 2 +- .../shared-memory/team-conventions.md | 2 +- ...方案】AGC客户端更新检查与下载-2026-08-31.md | 43 ++++++++- ...案】AI游戏创作智能体App实施计划-2026-06-24.md | 2 +- ...发运维】本地开发验证与生产运维-2026-05-15.md | 4 +- 19 files changed, 465 insertions(+), 68 deletions(-) create mode 100644 apps/ai-game-creator-shell/scripts/channel-identity.mjs create mode 100644 docs/project-memory/plans/【实施计划】AGC渠道安装身份隔离-2026-09-21.md create mode 100644 docs/project-memory/plans/【里程碑】AGC渠道安装身份隔离-2026-09-21.md diff --git a/apps/ai-game-creator-shell/scripts/agent-swarm-test-chat.mjs b/apps/ai-game-creator-shell/scripts/agent-swarm-test-chat.mjs index b1f317674..45656fc67 100644 --- a/apps/ai-game-creator-shell/scripts/agent-swarm-test-chat.mjs +++ b/apps/ai-game-creator-shell/scripts/agent-swarm-test-chat.mjs @@ -20,7 +20,10 @@ import { createInterface } from 'node:readline/promises'; import { fileURLToPath, pathToFileURL } from 'node:url'; import { inflateSync } from 'node:zlib'; -export const appIdentifier = 'world.genarrative.ai-game-creator'; +import { AGC_APP_IDENTIFIER } from './channel-identity.mjs'; + +// 联调工具驱动的始终是默认渠道客户端:安装身份取渠道基线,不跟随发布渠道。 +export const appIdentifier = AGC_APP_IDENTIFIER; export const configFileName = 'game-creator.config.json'; export const localConfigFileName = 'game-creator.config.local.json'; export const runnerEndpointFileName = 'agent-runner.endpoint.json'; diff --git a/apps/ai-game-creator-shell/scripts/build-macos-ci.mjs b/apps/ai-game-creator-shell/scripts/build-macos-ci.mjs index 49bf93abf..67c4ad0b8 100644 --- a/apps/ai-game-creator-shell/scripts/build-macos-ci.mjs +++ b/apps/ai-game-creator-shell/scripts/build-macos-ci.mjs @@ -14,6 +14,7 @@ import { resolveReleasePartition, runTauriBuild, } from './build-release.mjs'; +import { resolveChannelInstallIdentity } from './channel-identity.mjs'; import { readReleaseDryRun, uploadReleaseArtifacts } from './release-oss.mjs'; import { readUpdaterPubkey, @@ -39,27 +40,19 @@ const appRoot = fileURLToPath(new URL('..', import.meta.url)); const repoRoot = path.resolve(appRoot, '../..'); /** - * 产品名只从 Tauri 配置读取:它同时决定 `*.app` 目录名、updater 归档名与 DMG 卷名。 - * 写死会在改名后让入口静默找错对象(清理、打包、归档三处一起失效)。 + * 产品名只从渠道安装身份派生(渠道身份由构建期 `--config` 注入 Tauri 配置): + * 它同时决定 `*.app` 目录名、updater 归档名与 DMG 卷名。写死会在改名或换渠道后 + * 让入口静默找错对象(清理、打包、归档三处一起失效)。 */ -function readProductName() { - const read = (file) => - JSON.parse(fs.readFileSync(path.join(appRoot, 'src-tauri', file), 'utf8')); - const base = read('tauri.conf.json'); - const macosPath = path.join(appRoot, 'src-tauri', 'tauri.macos.conf.json'); - const productName = fs.existsSync(macosPath) - ? (read('tauri.macos.conf.json').productName ?? base.productName) - : base.productName; +function resolveProductName(channel) { + const { productName } = resolveChannelInstallIdentity(channel); assert.ok( typeof productName === 'string' && productName.trim().length > 0, - 'Tauri 配置缺少 productName', + '渠道安装身份缺少 productName', ); return productName; } -const productName = readProductName(); -const appBundleName = `${productName}.app`; -const updaterArtifactName = `${productName}.app.tar.gz`; assert.equal(process.platform, 'darwin', '只能在 macOS Agent 执行'); assert.equal( process.env.JENKINS_URL?.length > 0, @@ -102,6 +95,9 @@ process.env.CARGO_TARGET_DIR = path.join(appRoot, 'src-tauri/target'); const macTarget = 'aarch64-apple-darwin'; const context = resolveReleaseContext([`--target=${macTarget}`]); const partition = resolveReleasePartition(context.channel, context.target); +const productName = resolveProductName(context.channel); +const appBundleName = `${productName}.app`; +const updaterArtifactName = `${productName}.app.tar.gz`; const version = await prepareReleaseVersion(context); // 首装包名必须让清单侧的单架构分支唯一匹配:`<产品名>_<版本>_<架构>.dmg`, // 架构段用 Tauri 的 aarch64 口径(不是 updater 平台键的 arm64 / x86_64)。 diff --git a/apps/ai-game-creator-shell/scripts/build-release.mjs b/apps/ai-game-creator-shell/scripts/build-release.mjs index 70cfbdd87..bd1d5907b 100644 --- a/apps/ai-game-creator-shell/scripts/build-release.mjs +++ b/apps/ai-game-creator-shell/scripts/build-release.mjs @@ -13,6 +13,10 @@ import { defaultEditorFeatures, withDefaultCargoFeatures, } from './cargo-features.mjs'; +import { + resolveChannelInstallIdentity, + resolveReleaseChannel, +} from './channel-identity.mjs'; import { prepareNsisToolsetForRelease } from './nsis-toolset.mjs'; import { stageNodeRuntime } from './stage-node-runtime.mjs'; @@ -89,14 +93,7 @@ const cargoLockPath = path.join(appRoot, 'src-tauri', 'Cargo.lock'); const defaultOssBaseUrl = 'https://agc-dev.oss-rg-china-mainland.aliyuncs.com/agc'; -const reservedChannelNames = new Set([ - 'win', - 'mac', - 'windows', - 'macos', - 'darwin', - 'linux', -]); +export { resolveReleaseChannel } from './channel-identity.mjs'; /** * 影响 Windows 客户端产物的路径。调度管线的发布范围判定与这里的提交摘要必须 @@ -165,21 +162,6 @@ export function resolveReleasePlatform(target = defaultTarget()) { throw new Error(`不支持的发布目标:${target}`); } -export function resolveReleaseChannel(env = process.env) { - const channel = env.AGC_UPDATE_CHANNEL?.trim() ?? 'dev'; - if ( - !/^[a-z][a-z0-9-]{0,31}$/u.test(channel) || - channel.endsWith('-') || - reservedChannelNames.has(channel) || - /-(win|mac)$/u.test(channel) - ) { - throw new Error( - '发布渠道无效:请使用 dev、release 或最多 32 位的小写字母、数字和连字符名称,系统名称不属于渠道', - ); - } - return channel; -} - /** 系统分区延续已发布客户端端点,渠道本身不包含系统。 */ export function resolveReleasePartition( channel = resolveReleaseChannel(), @@ -428,12 +410,19 @@ export function buildTauriBuildArguments( ]; } -/** 渠道端点必须由构建期注入:官方更新插件的端点配置不支持运行期改渠道。 */ +/** + * 渠道端点与安装身份必须由构建期注入:官方更新插件的端点配置不支持运行期改渠道, + * 而 `productName` / `identifier` 决定安装目录、卸载项与客户端数据目录, + * 不同渠道必须在同一台设备上并存而不是互相顶掉。 + */ export function createChannelConfig( channel = resolveReleaseChannel(), target = defaultTarget(), ) { + const { productName, identifier } = resolveChannelInstallIdentity(channel); return { + productName, + identifier, plugins: { updater: { endpoints: [updateManifestUrl(channel, target)], diff --git a/apps/ai-game-creator-shell/scripts/build-release.test.mjs b/apps/ai-game-creator-shell/scripts/build-release.test.mjs index 15bcc11aa..848b74692 100644 --- a/apps/ai-game-creator-shell/scripts/build-release.test.mjs +++ b/apps/ai-game-creator-shell/scripts/build-release.test.mjs @@ -37,6 +37,11 @@ import { selectReleaseArtifact, updateManifestUrl, } from './build-release.mjs'; +import { + AGC_APP_IDENTIFIER, + AGC_PRODUCT_NAME, + resolveChannelInstallIdentity, +} from './channel-identity.mjs'; const windowsTarget = 'x86_64-pc-windows-msvc'; const universalTarget = 'universal-apple-darwin'; @@ -184,6 +189,8 @@ test('channel manifest URL and build-time endpoint follow the channel', () => { 'https://agc-dev.oss-rg-china-mainland.aliyuncs.com/agc/dev-win/latest.json', ); assert.deepEqual(createChannelConfig('dev', 'aarch64-apple-darwin'), { + productName: AGC_PRODUCT_NAME, + identifier: AGC_APP_IDENTIFIER, plugins: { updater: { endpoints: [ @@ -204,6 +211,68 @@ test('channel manifest URL and build-time endpoint follow the channel', () => { }); }); +test('channel install identity isolates co-installed builds and keeps the default channel stable', () => { + // 默认渠道必须保持已发布客户端身份:改身份等于换一个 App,升级链会断。 + assert.deepEqual(resolveChannelInstallIdentity('dev'), { + productName: AGC_PRODUCT_NAME, + identifier: AGC_APP_IDENTIFIER, + }); + assert.deepEqual(resolveChannelInstallIdentity('release'), { + productName: '陶泥儿 Release', + identifier: `${AGC_APP_IDENTIFIER}.release`, + }); + assert.deepEqual(resolveChannelInstallIdentity('beta-2'), { + productName: '陶泥儿 Beta-2', + identifier: `${AGC_APP_IDENTIFIER}.beta-2`, + }); + + // 同一台设备上不同渠道的安装目录、卸载项与数据目录必须互不相同。 + for (const channel of ['release', 'beta-2', 'a'.repeat(32)]) { + const identity = resolveChannelInstallIdentity(channel); + assert.notEqual(identity.productName, AGC_PRODUCT_NAME); + assert.notEqual(identity.identifier, AGC_APP_IDENTIFIER); + assert.ok(identity.identifier.startsWith(`${AGC_APP_IDENTIFIER}.`)); + } + + for (const channel of ['dev-win', 'Release', 'win', 'beta-']) { + assert.throws( + () => resolveChannelInstallIdentity(channel), + /发布渠道无效/u, + ); + } +}); + +test('channel install identity is baked into the same build-time config as the endpoint', () => { + withEnv({ AGC_UPDATE_OSS_BASE_URL: undefined }, () => { + const config = createChannelConfig('release', windowsTarget); + assert.equal(config.productName, '陶泥儿 Release'); + assert.equal(config.identifier, `${AGC_APP_IDENTIFIER}.release`); + assert.match( + config.plugins.updater.endpoints[0], + /\/release-win\/latest\.json$/u, + ); + }); +}); + +test('channel products keep first-install selection working under the channel product name', () => { + const root = mkdtempSync(path.join(os.tmpdir(), 'agc-channel-dmg-')); + try { + const { productName } = resolveChannelInstallIdentity('release'); + const dmg = path.join(root, `${productName}_${packageVersion}_aarch64.dmg`); + writeFileSync(dmg, 'channel first installation disk image'); + writeFileSync(path.join(root, 'windows.exe'), 'wrong platform'); + assert.equal( + selectFirstInstallArtifact([dmg, path.join(root, 'windows.exe')], { + target: 'aarch64-apple-darwin', + version: packageVersion, + }), + dmg, + ); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + test('packaged renderer receives the same channel as the updater manifest', () => { const context = resolveReleaseContext([], { AGC_BUILD_TARGET: windowsTarget, diff --git a/apps/ai-game-creator-shell/scripts/channel-identity.mjs b/apps/ai-game-creator-shell/scripts/channel-identity.mjs new file mode 100644 index 000000000..5de68db22 --- /dev/null +++ b/apps/ai-game-creator-shell/scripts/channel-identity.mjs @@ -0,0 +1,73 @@ +/** + * AGC 渠道 → 安装身份。 + * + * 渠道同时决定两件事: + * - 更新端点:OSS 分区 `-win` / `-mac` 的清单地址; + * - 安装身份:`productName` 与 `identifier`。 + * + * 安装身份决定 Windows 安装目录与卸载项、macOS `.app` 名字与 bundle id、 + * Windows WebView2 数据目录以及 `%APPDATA%\` 客户端数据目录。 + * 因此不同渠道的包体在同一台设备上并存时互不顶掉,也不会共享登录态、 + * 本地项目与运行锁。 + * + * 默认渠道 `dev` 保持已发布客户端身份不变:升级链路与既有安装不能断。 + */ + +export const AGC_DEFAULT_CHANNEL = 'dev'; +export const AGC_PRODUCT_NAME = '陶泥儿'; +export const AGC_APP_IDENTIFIER = 'world.genarrative.ai-game-creator'; + +const reservedChannelNames = new Set([ + 'win', + 'mac', + 'windows', + 'macos', + 'darwin', + 'linux', +]); + +/** 校验渠道名:小写字母开头,允许数字与连字符,系统名不属于渠道。 */ +export function validateReleaseChannel(channel) { + if ( + typeof channel !== 'string' || + !/^[a-z][a-z0-9-]{0,31}$/u.test(channel) || + channel.endsWith('-') || + reservedChannelNames.has(channel) || + /-(win|mac)$/u.test(channel) + ) { + throw new Error( + '发布渠道无效:请使用 dev、release 或最多 32 位的小写字母、数字和连字符名称,系统名称不属于渠道', + ); + } + return channel; +} + +export function resolveReleaseChannel(env = process.env) { + return validateReleaseChannel(env.AGC_UPDATE_CHANNEL?.trim() ?? 'dev'); +} + +/** 安装身份里的展示后缀:`release` → `Release`,`beta-2` → `Beta-2`。 */ +export function channelDisplaySuffix(channel) { + return validateReleaseChannel(channel) + .split('-') + .map((segment) => segment.charAt(0).toUpperCase() + segment.slice(1)) + .join('-'); +} + +/** + * 渠道对应的安装身份。默认渠道返回基线身份,其它渠道派生渠道后缀, + * 保证同一台设备上不同渠道互不覆盖。 + */ +export function resolveChannelInstallIdentity(channel = AGC_DEFAULT_CHANNEL) { + validateReleaseChannel(channel); + if (channel === AGC_DEFAULT_CHANNEL) { + return Object.freeze({ + productName: AGC_PRODUCT_NAME, + identifier: AGC_APP_IDENTIFIER, + }); + } + return Object.freeze({ + productName: `${AGC_PRODUCT_NAME} ${channelDisplaySuffix(channel)}`, + identifier: `${AGC_APP_IDENTIFIER}.${channel}`, + }); +} diff --git a/apps/ai-game-creator-shell/scripts/check-config.mjs b/apps/ai-game-creator-shell/scripts/check-config.mjs index df9518a8d..57c325aef 100644 --- a/apps/ai-game-creator-shell/scripts/check-config.mjs +++ b/apps/ai-game-creator-shell/scripts/check-config.mjs @@ -27,6 +27,11 @@ import { appIdentifier, defaultRealSwarmTestTask, } from './agent-swarm-test-chat.mjs'; +import { + AGC_APP_IDENTIFIER, + AGC_PRODUCT_NAME, + resolveChannelInstallIdentity, +} from './channel-identity.mjs'; import { askHidden, assertSafeGameCreatorConfigDestination, @@ -1308,7 +1313,8 @@ if ( } for (const requiredSource of [ - "export const appIdentifier = 'world.genarrative.ai-game-creator'", + "import { AGC_APP_IDENTIFIER } from './channel-identity.mjs'", + 'export const appIdentifier = AGC_APP_IDENTIFIER', "'--swarm-chat'", "'--autonomous-game-build'", "'--preview-serve'", @@ -1319,14 +1325,38 @@ for (const requiredSource of [ } } -if (tauriConfig.productName !== '陶泥儿') { +// 基线配置必须等于默认渠道的安装身份:默认渠道不能改身份,否则已发布客户端 +// 的升级链路与既有安装目录都会断开。 +const defaultChannelIdentity = resolveChannelInstallIdentity('dev'); +if (tauriConfig.productName !== AGC_PRODUCT_NAME) { throw new Error('AI game creator shell productName drifted'); } -if (tauriConfig.identifier !== 'world.genarrative.ai-game-creator') { +if (tauriConfig.identifier !== AGC_APP_IDENTIFIER) { throw new Error('AI game creator shell identifier drifted'); } +if ( + tauriConfig.productName !== defaultChannelIdentity.productName || + tauriConfig.identifier !== defaultChannelIdentity.identifier +) { + throw new Error( + 'AI game creator shell baseline config must match the default channel identity', + ); +} + +// 非默认渠道必须派生出独立安装身份,否则同机安装会互相顶掉。 +for (const channel of ['release', 'beta-2']) { + const identity = resolveChannelInstallIdentity(channel); + if ( + identity.productName === defaultChannelIdentity.productName || + identity.identifier === defaultChannelIdentity.identifier || + !identity.identifier.startsWith(`${AGC_APP_IDENTIFIER}.`) + ) { + throw new Error(`channel install identity not isolated: ${channel}`); + } +} + const expectedBundledDesignAgentResources = { 'design-agent': 'design-agent', ...Object.fromEntries( diff --git a/apps/ai-game-creator-shell/scripts/prepare-macos-codex.test.mjs b/apps/ai-game-creator-shell/scripts/prepare-macos-codex.test.mjs index b62fe76a1..fc3546be7 100644 --- a/apps/ai-game-creator-shell/scripts/prepare-macos-codex.test.mjs +++ b/apps/ai-game-creator-shell/scripts/prepare-macos-codex.test.mjs @@ -174,8 +174,16 @@ test('macOS release entry and smoke script derive product names from config and new URL('./build-macos-ci.mjs', import.meta.url), 'utf8', ); - // 产品名决定 *.app、updater 归档与 DMG 卷名:写死会在改名后静默找错对象。 - assert.ok(entry.includes('readProductName'), '入口必须从 Tauri 配置读产品名'); + // 产品名决定 *.app、updater 归档与 DMG 卷名:它必须从渠道安装身份派生, + // 写死会在换渠道或改名后静默找错对象。 + assert.ok( + entry.includes('resolveChannelInstallIdentity'), + '入口必须从渠道安装身份派生产品名', + ); + assert.ok( + entry.includes('resolveProductName(context.channel)'), + '产品名必须按当前发布渠道解析', + ); assert.ok(!entry.includes('陶泥儿'), 'macOS 发布入口不得写死产品名'); assert.ok( entry.includes("const macTarget = 'aarch64-apple-darwin'"), diff --git a/apps/ai-game-creator-shell/src-tauri/src/config.rs b/apps/ai-game-creator-shell/src-tauri/src/config.rs index d3f31417c..5587e7a06 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/config.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/config.rs @@ -862,6 +862,48 @@ pub(crate) fn validate_game_creator_private_path_ancestors( Ok(()) } +/// 客户端安装身份基线:`productName` / `identifier` 由构建期按渠道注入。 +/// +/// 默认渠道保持基线身份,其它渠道派生 `<基线>.<渠道>`,因此同一台设备上 +/// 不同渠道各自拥有独立的安装目录与 AppData 数据目录。 +#[cfg(windows)] +const GAME_CREATOR_APP_IDENTIFIER: &str = "world.genarrative.ai-game-creator"; + +#[cfg(windows)] +fn is_game_creator_packaged_app_data_leaf(name: &std::ffi::OsStr) -> bool { + let Some(name) = name.to_str() else { + return false; + }; + let Some(remainder) = name.strip_prefix(GAME_CREATOR_APP_IDENTIFIER) else { + return false; + }; + if remainder.is_empty() { + return true; + } + // 渠道名是小写字母开头的 32 位以内小写字母、数字与连字符。 + remainder + .strip_prefix('.') + .is_some_and(|channel| !channel.is_empty() && channel.len() <= 32) +} + +/// 路径是否位于 `<平台配置根>/<安装身份目录>` 之内。提权助手是独立进程, +/// 看不到父进程的配置目录覆盖,因此这里必须按目录名识别全部渠道身份。 +#[cfg(windows)] +fn path_is_inside_game_creator_packaged_app_data(root: &Path, path: &Path) -> bool { + let root = normalize_windows_policy_path(root); + let path = normalize_windows_policy_path(path); + let Ok(relative) = path.strip_prefix(&root) else { + return false; + }; + relative + .components() + .next() + .is_some_and(|component| match component { + std::path::Component::Normal(name) => is_game_creator_packaged_app_data_leaf(name), + _ => false, + }) +} + /// Automatic ACL repair for managed paths is limited to objects AGC owns. A /// separate, explicit user-selected scope below covers native picker/project /// root results, including projects stored outside the current profile. @@ -900,11 +942,8 @@ fn game_creator_private_path_allows_auto_elevation(path: &Path) -> bool { // elevated helper runs in a fresh process, so the in-memory runtime // config-dir override is unavailable there; recognize the packaged // path from the user's profile as well. - let packaged_app_data = home - .join("AppData") - .join("Local") - .join("world.genarrative.ai-game-creator"); - if starts_with_path(&packaged_app_data) { + if path_is_inside_game_creator_packaged_app_data(&home.join("AppData").join("Local"), &path) + { return true; } } @@ -914,7 +953,7 @@ fn game_creator_private_path_allows_auto_elevation(path: &Path) -> bool { .map(PathBuf::from) .filter(|candidate| candidate.is_absolute()) { - if starts_with_path(&root.join("world.genarrative.ai-game-creator")) { + if path_is_inside_game_creator_packaged_app_data(&root, &path) { return true; } } @@ -1096,10 +1135,9 @@ fn game_creator_runtime_config_repair_scope(path: &Path) -> WindowsAclRepairScop .filter(|candidate| candidate.is_absolute()) { if is_builtin_root(home.join(".config").join("genarrative")) - || is_builtin_root( - home.join("AppData") - .join("Local") - .join("world.genarrative.ai-game-creator"), + || path_is_inside_game_creator_packaged_app_data( + &home.join("AppData").join("Local"), + &path, ) { return WindowsAclRepairScope::Managed; @@ -1110,7 +1148,7 @@ fn game_creator_runtime_config_repair_scope(path: &Path) -> WindowsAclRepairScop .map(PathBuf::from) .filter(|candidate| candidate.is_absolute()) { - if is_builtin_root(root.join("world.genarrative.ai-game-creator")) { + if path_is_inside_game_creator_packaged_app_data(&root, &path) { return WindowsAclRepairScope::Managed; } } @@ -5044,18 +5082,38 @@ mod private_path_elevation_policy_tests { #[cfg(windows)] #[test] - fn verbatim_packaged_appdata_path_keeps_managed_repair_scope() { + fn packaged_appdata_paths_keep_managed_repair_scope_for_every_channel() { let root = std::env::var_os("LOCALAPPDATA") .or_else(|| std::env::var_os("APPDATA")) .map(PathBuf::from) .expect("local appdata"); - let packaged = root.join("world.genarrative.ai-game-creator"); - let verbatim = PathBuf::from(format!(r"\\?\{}", packaged.display())); - assert!(game_creator_private_path_allows_auto_elevation(&verbatim)); + // 默认渠道是基线目录,其它渠道派生 `<基线>.<渠道>`;提权助手按目录名识别, + // 两种身份都必须落在 managed 赋权范围内。 + for leaf in [ + "world.genarrative.ai-game-creator", + "world.genarrative.ai-game-creator.release", + "world.genarrative.ai-game-creator.beta-2", + ] { + let packaged = root.join(leaf).join("diagnostics"); + let verbatim = PathBuf::from(format!(r"\\?\{}", packaged.display())); + assert!( + game_creator_private_path_allows_auto_elevation(&verbatim), + "{leaf}" + ); + assert_eq!( + game_creator_runtime_config_repair_scope(&verbatim), + WindowsAclRepairScope::Managed, + "{leaf}" + ); + } + + // 相似前缀不是安装身份目录,不能落进 managed 赋权范围。 + let foreign = root.join("world.genarrative.ai-game-creator-backup"); + assert!(!game_creator_private_path_allows_auto_elevation(&foreign)); assert_eq!( - game_creator_runtime_config_repair_scope(&verbatim), - WindowsAclRepairScope::Managed + game_creator_runtime_config_repair_scope(&foreign), + WindowsAclRepairScope::UserSelected ); } 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 cb3f75eb7..d123ec186 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -2005,6 +2005,13 @@ fn show_startup_error_dialog(log_path: Option<&Path>) { } } +/// 客户端产品名跟随构建期渠道身份:默认渠道是「陶泥儿」,其它渠道带渠道后缀 +/// (例如「陶泥儿 Release」)。同机并存的渠道客户端因此在窗口标题、任务栏与 +/// Alt-Tab 里可区分;默认渠道结果不变。 +pub(crate) fn game_creator_product_name(app: &tauri::AppHandle) -> String { + app.package_info().name.clone() +} + /// 配置目录就绪前的启动日志路径:优先用已经生效的配置目录(例如 `--config-dir` /// 已经设置好的目录),否则退到平台配置根。两者都不可用时返回 `None`,此时 /// `StartupLogSlot::fail` 仍然必须给出用户可见提示。 @@ -2468,6 +2475,16 @@ fn main() { setup_log.fail("startup.appdata.resolve.failed details=config-dir-uninitialized"); error })?; + // 主窗口标题与产品名保持一致:配置里的标题来自基线配置,渠道后缀只 + // 由构建期身份决定,因此必须在这里按产品名覆盖。 + match app.get_webview_window("client") { + Some(window) => { + if let Err(error) = window.set_title(&game_creator_product_name(app.handle())) { + app_log!("startup.window-title.failed: {error}"); + } + } + None => app_log!("startup.window-title.failed: 缺少 client 主窗口"), + } spawn_project_snapshot_scheduler(app.handle().clone()); if let Err(error) = builtin_plugins::initialize(&config_dir) { app_log!("startup.builtin-plugins.initialize.failed: {error}"); diff --git a/apps/ai-game-creator-shell/src-tauri/src/windows.rs b/apps/ai-game-creator-shell/src-tauri/src/windows.rs index fc146f387..854970f6f 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/windows.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/windows.rs @@ -164,7 +164,7 @@ pub(crate) fn open_game_creator_workspace_window( existing.close().map_err(|error| error.to_string())?; } tauri::WebviewWindowBuilder::new(&app, "main", workspace_window_url(project_path)) - .title("陶泥儿") + .title(crate::game_creator_product_name(&app)) .decorations(false) .inner_size(1180.0, 820.0) .min_inner_size(760.0, 560.0) @@ -183,7 +183,7 @@ pub(crate) fn open_game_creator_launcher_window( existing.set_focus().map_err(|error| error.to_string())?; } else { tauri::WebviewWindowBuilder::new(&app, "launcher", launcher_window_url()) - .title("陶泥儿") + .title(crate::game_creator_product_name(&app)) .decorations(false) .inner_size(820.0, 640.0) .min_inner_size(720.0, 520.0) diff --git a/docs/project-memory/plans/【实施计划】AGC渠道安装身份隔离-2026-09-21.md b/docs/project-memory/plans/【实施计划】AGC渠道安装身份隔离-2026-09-21.md new file mode 100644 index 000000000..0b57d90e7 --- /dev/null +++ b/docs/project-memory/plans/【实施计划】AGC渠道安装身份隔离-2026-09-21.md @@ -0,0 +1,49 @@ +# 【实施计划】AGC 渠道安装身份隔离 + +| 字段 | 值 | +| --- | --- | +| Milestone | `docs/project-memory/plans/【里程碑】AGC渠道安装身份隔离-2026-09-21.md` | +| Status | ready | +| Owner | 当前 Agent | + +## 修改边界 + +- 允许修改: + - `apps/ai-game-creator-shell/scripts/channel-identity.mjs`(新增,渠道身份单点定义) + - `apps/ai-game-creator-shell/scripts/build-release.mjs`、`build-macos-ci.mjs`、`check-config.mjs`、`agent-swarm-test-chat.mjs` + - `apps/ai-game-creator-shell/src-tauri/src/main.rs`、`src-tauri/src/windows.rs`、`src-tauri/src/config.rs` + - 对应测试:`build-release.test.mjs`、`prepare-macos-codex.test.mjs` + - 文档:AGC 更新主规范、共享记忆与本计划对 +- 明确不修改:OSS 分区布局、官网下载接口与页面、Jenkins Job 参数、渠道版本发行逻辑、Apple 签名/公证、移动壳。 + +## 实现顺序 + +1. 抽出 `channel-identity.mjs`:渠道校验、渠道显示名、`resolveChannelInstallIdentity()`;`build-release.mjs` 复用并再导出渠道校验。 +2. 渠道 `--config` 同时注入 `productName` 与 `identifier`;macOS 发布入口按发布渠道解析产品名(`.app`、updater 归档、DMG 卷名与文件名)。 +3. Rust:主窗口/工作区/启动器窗口标题取构建期产品名;AGC 自有 AppData 目录的 ACL managed 识别覆盖 `<基线>` 与 `<基线>.<渠道>`。 +4. 门禁与测试:`check-config.mjs` 断言基线等于默认渠道身份、非默认渠道身份隔离;`build-release.test.mjs` 增补身份与首装包用例;`prepare-macos-codex.test.mjs` 改断言为消费渠道身份。 +5. 文档:更新 AGC 更新主规范与共享记忆,登记决策与踩坑。 + +## 验证命令 + +1. `node --check apps/ai-game-creator-shell/scripts/channel-identity.mjs apps/ai-game-creator-shell/scripts/build-release.mjs apps/ai-game-creator-shell/scripts/build-macos-ci.mjs apps/ai-game-creator-shell/scripts/check-config.mjs` +2. `node --test apps/ai-game-creator-shell/scripts/build-release.test.mjs apps/ai-game-creator-shell/scripts/release-oss.test.mjs apps/ai-game-creator-shell/scripts/prepare-macos-codex.test.mjs apps/ai-game-creator-shell/scripts/cargo-features.test.mjs` +3. `node apps/ai-game-creator-shell/scripts/check-config.mjs` +4. `cargo test --locked --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml --bin genarrative-ai-game-creator-shell -- config::tests:: --test-threads=1` +5. `npm run check:encoding`、`npm run check:doc-index`、`git diff --check` + +## 验证结果 + +- `node --test build-release.test.mjs release-oss.test.mjs prepare-macos-codex.test.mjs cargo-features.test.mjs`:64/64 通过(新增渠道身份、身份注入与渠道 DMG 首装选择三条用例)。 +- `node apps/ai-game-creator-shell/scripts/check-config.mjs`、`npm --prefix apps/ai-game-creator-shell run typecheck`:通过。 +- `cargo test --locked --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml --bin genarrative-ai-game-creator-shell -- config::private_path_elevation_policy_tests`:12/12 通过。 +- `AGC_UPDATE_CHANNEL=release npm --prefix apps/ai-game-creator-shell run build -- --no-bundle --debug`:Tauri 接受派生的 `productName` / `identifier` 并完成构建;产物字符串实测 `陶泥儿 Release` × 1、`agc/release-win/latest.json` × 1、`world.genarrative.ai-game-creator.release` × 1、`agc/dev-win/latest.json` × 0。 +- `cargo fmt --check`(AGC 壳)、`npm run check:encoding`、`npm run check:doc-index`、`git diff --check`:通过。 +- 未执行:真实渠道打包(需要签名私钥与发号/上传授权)、双渠道真机安装与并存、macOS 节点实跑。 + +## 风险与回滚点 + +- 风险:默认渠道身份若被改动,既有安装目录、卸载项与升级链会断。回滚点:基线 `tauri.conf.json` 与默认渠道映射不变,门禁用例钉住。 +- 风险:非默认渠道首次以新身份安装,老 `dev` 用户不会自动迁移本地数据。回滚点:渠道身份只影响非默认渠道构建,撤销该渠道的构建产物即可,仓库侧无数据迁移。 +- 风险:窗口标题改为构建期产品名后,标题不再等于配置里的字面量。回滚点:去掉 `main.rs` 的标题覆盖调用,行为回到配置标题。 +- 风险:ACL managed 识别放宽到前缀族。回滚点:`is_game_creator_packaged_app_data_leaf` 收紧回单一直线值,但非默认渠道的提权修复会重新失败关闭。 diff --git a/docs/project-memory/plans/【里程碑】AGC渠道安装身份隔离-2026-09-21.md b/docs/project-memory/plans/【里程碑】AGC渠道安装身份隔离-2026-09-21.md new file mode 100644 index 000000000..dea7272cd --- /dev/null +++ b/docs/project-memory/plans/【里程碑】AGC渠道安装身份隔离-2026-09-21.md @@ -0,0 +1,44 @@ +# 里程碑:AGC 渠道安装身份隔离 + +| 字段 | 值 | +| --- | --- | +| Version | 1.0 | +| Status | 代码与源码级验收已落地,等待真机双渠道安装验收 | +| Date | 2026-09-21 | +| Parent Spec | `docs/technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md` | + +## 目标 + +不同渠道的 AGC 包体在同一台设备上并存:安装、运行、客户端数据与更新互不覆盖、互不顶掉。 + +## 范围 + +- 构建期按渠道产出安装身份(`productName` / `identifier`),默认渠道 `dev` 身份保持不变。 +- 渠道产物命名、首装包选择、macOS 发布入口与配置门禁都消费同一份渠道身份。 +- 同机并存的可见区分(窗口标题)与 AGC 自有 AppData 目录的提权 ACL 识别范围。 + +## 不在范围内 + +- 不迁移、不共享既有本地项目、工程快照、模板、登录态与诊断数据。 +- 不新增渠道,不改 OSS 分区布局、官网下载页与后端接口。 +- 不做 Apple 代码签名/公证,不恢复 Intel 架构。 + +## 依赖与前置条件 + +- 渠道与更新端点合同已落地(`-win` / `-mac` 分区与 Tauri updater 端点)。 +- 构建入口统一在 Tauri 构建前注入渠道 `--config`。 + +## 验收标准 + +- [x] 默认渠道的 `productName` / `identifier` 与基线配置逐字一致,既有安装与升级链不断(`check-config.mjs` + 渠道身份用例)。 +- [x] `release` 与自定义渠道派生独立 `productName` 与 `identifier`,与 `dev` 可在同一台设备并存(源码级;真机安装见下方未完成项)。 +- [x] 渠道产物(NSIS `.exe`、`.app.tar.gz`、DMG)与首装包选择跟随渠道身份且唯一匹配(发布脚本用例)。 +- [x] 非默认渠道的客户端数据目录、WebView2 目录与窗口标题跟随渠道身份(构建产物字符串实测 + 运行期标题取产品名)。 +- [x] AGC 自有 AppData 目录的 ACL managed 范围覆盖全部渠道身份,且不扩大到相似前缀目录(Rust 定向 12/12)。 +- [ ] 真机:同一台设备同时安装 `dev` 与 `release`,二者可并存、可各自原地更新。 + +## 证据要求 + +- 自动化:`build-release.test.mjs`、`release-oss.test.mjs`、`prepare-macos-codex.test.mjs`、`check-config.mjs` 与 Rust `config::tests::` 定向测试。 +- 运行时:单渠道 `--no-bundle` 构建烟测;真机双渠道安装、并存与各自更新。 +- 边界:非法渠道失败关闭;默认渠道身份不变;相似前缀目录不进入 managed 赋权范围。 diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 77d706d4b..490aca0bc 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -1,5 +1,13 @@ # 决策记录 +## 2026-09-21 渠道进安装身份:不同渠道的 AGC 包体在同一台设备并存 + +- 背景:渠道此前只决定更新端点(`plugins.updater.endpoints`)与渲染层平台 origin,`productName` / `identifier` 与渠道无关,于是所有渠道共用 `%LOCALAPPDATA%\陶泥儿` 安装目录、同一个卸载项(`HKCU\Software\Microsoft\Windows\CurrentVersion\Uninstall\陶泥儿`,另有 `HKCU\Software\genarrative\陶泥儿`)以及同一份 `%APPDATA%\world.genarrative.ai-game-creator` 数据目录。本机 0.1.48 安装实测:主程序二进制里只有 1 处 `agc/dev-win/latest.json`、0 处 release 端点,说明渠道在产物里只体现为端点。后果是后装的渠道静默顶掉先装的渠道,并接管更新端点、平台服务器与本地登录态/项目数据。 +- 决策:渠道同时决定**安装身份**。默认渠道 `dev` 保持基线 `productName = 陶泥儿`、`identifier = world.genarrative.ai-game-creator`(既有安装目录、卸载项与升级链不断);其它渠道派生 `陶泥儿 <渠道显示名>`(`release` → `陶泥儿 Release`)与 `world.genarrative.ai-game-creator.<渠道>`。身份与更新端点必须在同一个构建期 `--config` 里注入,禁止分别回读默认值。窗口标题、macOS 产物名(`.app` / updater 归档 / DMG 卷名)、首装包选择与 Windows 提权 ACL 的 managed 识别范围同批跟随该身份。 +- 边界:不做本地数据迁移或共享——切渠道等于换一个客户端;`release` 与自定义渠道首次以新身份安装,不接管、不迁移既有 `dev` 安装与本地项目,由用户自行决定是否卸载其一。 +- 影响范围:新增 `apps/ai-game-creator-shell/scripts/channel-identity.mjs`;`build-release.mjs`、`build-macos-ci.mjs`、`check-config.mjs`、`agent-swarm-test-chat.mjs`、`src-tauri/src/{main.rs,windows.rs,config.rs}` 与对应测试;主规范 `docs/technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md`。 +- 验证方式:`node --test apps/ai-game-creator-shell/scripts/build-release.test.mjs release-oss.test.mjs prepare-macos-codex.test.mjs cargo-features.test.mjs`(64/64,新增渠道身份与渠道 DMG 首装选择用例)、`node apps/ai-game-creator-shell/scripts/check-config.mjs`(基线等于默认渠道身份、非默认渠道身份隔离)、`cargo test --locked --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml --bin genarrative-ai-game-creator-shell -- config::private_path_elevation_policy_tests`(12/12,含基线/`<基线>.release`/`<基线>.beta-2` 与相似前缀反向断言)、`AGC_UPDATE_CHANNEL=release npm --prefix apps/ai-game-creator-shell run build -- --no-bundle --debug`(产物字符串实测 `陶泥儿 Release` × 1、`agc/release-win/latest.json` × 1、`world.genarrative.ai-game-creator.release` × 1、`agc/dev-win/latest.json` × 0)。真机双渠道安装、并存与各自更新尚未执行,按未验证项记录。 + ## 2026-09-21 项目快照按部署渠道分区,后台按渠道查看并按素材查询口径展示用户 - 背景:AGC 项目快照此前统一写在 `agc/project-snapshots/v1/{user}/{project}/`,而开发与正式两套部署共用同一个 bucket(都默认 `agc-dev`)。结果是渠道混在一层前缀里:正式后台会列出开发渠道上传的项目,列表上也看不出项目属于哪个渠道;同时“项目工程”列表只有裸用户 ID,且用“加载更多”逐段追加,翻页与定位都困难。 @@ -11,6 +19,7 @@ - 未迁移的历史对象:`agc/project-snapshots/v1/` 下现存对象(只读核对过的两份历史清单)保留在 OSS,但不再写入、不再进入后台列表;需要取回时按旧前缀在 OSS 侧直接读取,确实要在后台看到时再单独开一个只读兼容视图。 - 验证方式:`cargo test -p platform-oss snapshot` 7 passed(渠道校验、键布局、v2 根渠道枚举、v1 历史键仍必须私有);`cargo test -p api-server project_snapshot` 18 passed/1 ignored(渠道失败关闭、游标跨渠道拒绝、用户昵称/陶泥号解析、归档与配额回归);`cargo test -p api-server protected_route_matrix`、`route_contract` 通过(新路由纳入后台鉴权矩阵);`npm run admin-web:typecheck` 与 `npx vitest run apps/admin-web/src` 202 passed。 + ## 2026-09-21 模板正文目录门禁:CLI 打包与后台上传同一份段名单 - 背景:模板包组织指南把 `.agent/`、`.git/`、`node_modules/`、根目录 `dist/` 等列为「不要放进 ZIP」,但两条发布路径此前只校验路径安全与 `entry` 是否存在,放进去的东西会跟着建到用户项目里(模板自带 `.agent/` 会让新项目继承一个陌生身份)。这条约定只靠作者自觉。 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 0205590b1..16e38d539 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -1,5 +1,14 @@ # 踩坑与排障记录 +## 2026-09-21 不同渠道的包体在同一台设备安装会互相顶掉 + +- **现象**:在一台已经装了某个渠道 AGC 客户端的设备上安装另一个渠道的安装包,装完后旧客户端直接消失(安装目录被覆盖、卸载项被接管),更新端点、平台服务器与本地登录态一起换成新渠道的;两个渠道的客户端无法共存。 +- **原因**:渠道此前只烘焙了 `plugins.updater.endpoints` 与 `VITE_AGC_PLATFORM_CHANNEL`(`apps/ai-game-creator-shell/scripts/build-release.mjs` 的 `createChannelConfig`),`productName` / `identifier` 用的是渠道无关的基线值。Tauri 的 Windows 安装目录与卸载项由 `productName` 决定,WebView2 数据目录与客户端数据目录由 `identifier` 决定,于是所有渠道落到 `%LOCALAPPDATA%\陶泥儿`、`HKCU\...\Uninstall\陶泥儿` 与 `%APPDATA%\world.genarrative.ai-game-creator`。 +- **处理(现行口径)**:渠道进入安装身份,默认渠道保持基线身份不变,其它渠道派生 `<产品名> <渠道显示名>` 与 `<基线>.<渠道>`;身份与端点在同一次构建期 `--config` 注入。见决策记录 2026-09-21 条目。 +- **核对方式**:装完任渠道的包后看 `HKCU\Software\Microsoft\Windows\CurrentVersion\Uninstall\<产品名>` 的 `InstallLocation`、`%APPDATA%\` 与主程序窗口标题是否按渠道分开;同名安装目录或同名数据目录说明身份没有生效。 +- **易错点**:只改安装包文件名或快捷方式名而不改 `identifier`,两个渠道仍会抢同一份 Agent Runner / 项目锁与登录态;反过来把渠道后缀加在默认渠道上,既有安装的升级链会断(客户端认不出旧安装)。相似前缀目录(如 `world.genarrative.ai-game-creator-backup`)不得进入提权 ACL 的 managed 范围。 +- **关联**:`apps/ai-game-creator-shell/scripts/channel-identity.mjs`、`build-release.mjs`、`build-macos-ci.mjs`、`src-tauri/src/config.rs`。 + ## 发布器守卫拒绝时不要把 process.exit 用在 fetch 句柄未关闭处 - 现象:`agc-template-library-publish.mjs --dry-run` 撞上「同一 `templateVersion` 的 ZIP 不得变」门禁时,终端只剩一句 `Assertion failed: !(handle->flags & UV_HANDLE_CLOSING), file src\win\async.c`,看不到任何拒绝原因,看起来像脚本崩溃而不是被拒绝。 diff --git a/docs/project-memory/shared-memory/project-overview.md b/docs/project-memory/shared-memory/project-overview.md index 3cd48c7d9..2729c69de 100644 --- a/docs/project-memory/shared-memory/project-overview.md +++ b/docs/project-memory/shared-memory/project-overview.md @@ -58,7 +58,7 @@ SpacetimeDB crate、SDK、CLI / standalone 与生成 bindings 按 `2.8.3` 对齐 - DirectProject 对话先在完整历史中按回合/原始 item 身份关联,再分页渲染;每个回合只有一个呈现入口。有流按 item `seq` 交替文本和工具,无流采用历史正文;禁止位置猜配或同时展示累计回复与 item 正文。流写入单调归并,收尾等待落盘任务,不按磁盘“最后一段”猜最终回复位置。详见 AGC 实施计划的“DirectProject 回合展示唯一归属”。 - 回合生命周期只由活动 client 回合快照和 Direct 事件恢复;Provider 的历史终态通知不能创建活动 client 回合。消息发送时间保存在历史信封,原始 item 不混入宿主字段;完成后的中间文本和工具默认收进“执行过程”,最终回复及失败提示保持可见。 -- AGC 安装产品名统一为“陶泥儿”,由 Tauri `productName` 控制安装项、快捷方式与 EXE 产品描述;Windows 内置 Codex 安装到顶层 `coding-agent/win-x64/`,打包资源映射与运行时查找路径必须一致。内部可执行文件名与应用 identifier 保持稳定。 +- AGC 安装产品名基线为“陶泥儿”,由 Tauri `productName` 控制安装项、快捷方式与 EXE 产品描述;默认渠道 `dev` 保持基线产品名与 identifier `world.genarrative.ai-game-creator` 不变,其它渠道派生 `陶泥儿 <渠道显示名>` 与 `<基线>.<渠道>`,让不同渠道的包体在同一台设备上并存而不互相顶掉(详见《AGC客户端更新检查与下载》的渠道与安装身份合同)。Windows 内置 Codex 安装到顶层 `coding-agent/win-x64/`,打包资源映射与运行时查找路径必须一致。内部可执行文件名保持稳定。 - 新 Web 游戏为 `game/` 下的 npm + Vite + Phaser 4.2.1 工程,使用包导入且允许其它依赖;npm 预览与导出只读取 dist,运行素材需纳入构建。单 HTML → Phaser 迁移固定走 DirectProject:文件落盘后先用受控 `project.bootstrap` 在 `game` 执行无参数 `npm install`,再用支持相对 cwd 的 `project.verify` 构建并确认 `game/dist/index.html`,已有单 HTML/Godot 不通过 JSON Generator 伪装成 npm 工程。 diff --git a/docs/project-memory/shared-memory/team-conventions.md b/docs/project-memory/shared-memory/team-conventions.md index aed755b62..41778637a 100644 --- a/docs/project-memory/shared-memory/team-conventions.md +++ b/docs/project-memory/shared-memory/team-conventions.md @@ -35,7 +35,7 @@ - AGC 批量追加素材标签由原生在一次项目写锁与 revision CAS 下合并各项原标签,先校验全批再写 manifest;前端不能循环单素材分类命令,不回传展示层推导的分类或旧标签全集,以免部分写入或覆盖未编辑字段。 -- AGC 正式包的平台服务跟随构建渠道:`release` 连接 `https://www.genarrative.world`,`dev` 连接 `https://dev.genarrative.world`;本地 debug 态保留 release/dev/custom 服务器选择,会话凭据始终按 origin 隔离。发布渠道为 `dev/release/自定义名称`,Windows/Mac 是系统,OSS 的 `-win/mac` 仅是延续既有地址的分区。官网通过服务端 `GENARRATIVE_CLIENT_DOWNLOAD_CHANNEL`(默认 dev)选择渠道,公开同源 `/api/client-downloads` 汇总其各系统首装包与真实版本;未发布隐藏,单系统失败不影响其它下载,不跨渠道补齐。发布先上传 EXE/DMG 再写对应分区清单,不维护会互相覆盖的共享 OSS 索引。主站 Vite 代理复用实际 `runtimeServerTarget`。完整约定见 AGC 客户端更新检查与下载专题。 +- AGC 正式包的平台服务跟随构建渠道:`release` 连接 `https://www.genarrative.world`,`dev` 连接 `https://dev.genarrative.world`;本地 debug 态保留 release/dev/custom 服务器选择,会话凭据始终按 origin 隔离。发布渠道为 `dev/release/自定义名称`,Windows/Mac 是系统,OSS 的 `-win/mac` 仅是延续既有地址的分区。官网通过服务端 `GENARRATIVE_CLIENT_DOWNLOAD_CHANNEL`(默认 dev)选择渠道,公开同源 `/api/client-downloads` 汇总其各系统首装包与真实版本;未发布隐藏,单系统失败不影响其它下载,不跨渠道补齐。发布先上传 EXE/DMG 再写对应分区清单,不维护会互相覆盖的共享 OSS 索引。主站 Vite 代理复用实际 `runtimeServerTarget`。渠道同时决定安装身份:默认渠道 `dev` 必须保持基线 `productName` / `identifier` 不变,其它渠道派生独立身份,使不同渠道的包体可在同一台设备并存且本地数据不共享;身份与更新端点必须在同一次构建期注入里确定,禁止分别回读默认值。完整约定见 AGC 客户端更新检查与下载专题。 - AGC 模板库灰度复用 `agc:template-library`:未配置关闭,已配置时遵循现有灰度启停、用户 ID/标签和比例规则;服务端返回权威结论,客户端入口和原生清单/下载/建项均执行门禁,主体切换丢弃旧异步结果。公开 OSS 不是保密边界,已创建项目不受影响。 - 画布卡片类型与信息角标共用 `CanvasCardCornerActions`;菜单收纳共用 `OverflowActions`,宿主决定展示数量和资源命令。AGC 选中菜单前 5 项直显,Web 默认不折叠;浮层 portal 继续接入现有画布关闭与滚轮归属判据。 diff --git a/docs/technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md b/docs/technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md index 69559ba2a..68388ccc4 100644 --- a/docs/technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md +++ b/docs/technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md @@ -1,6 +1,6 @@ # AGC 客户端更新检查与下载 -更新时间:`2026-09-20` +更新时间:`2026-09-21` 本文件是 AGC 客户端自动更新的主规范:更新能力由 Tauri 官方插件 `tauri-plugin-updater` 承担,并按下文渠道分发。 @@ -18,6 +18,17 @@ - 验收必须覆盖 dev/release/自定义渠道各自端点和对象地址、独立版本、非法名称、旧 dev 地址延续、release 不写旧迁移桥、网站配置贯通、跨渠道链接拒绝和部分失败。 - 更新链路的信任来源从「清单里的 sha256 + 受信域名」升级为「发布签名 + 受信域名」:清单里的 `signature` 由构建期私钥生成,客户端用内置公钥校验,校验不过就拒绝安装。 +### 渠道与安装身份合同 + +- 渠道同时决定**更新端点**与**安装身份**,两者都由构建期写入产物。默认渠道 `dev` 保持基线身份 `productName = 陶泥儿`、`identifier = world.genarrative.ai-game-creator`;其它渠道(`release` 与自定义渠道)派生 `productName = 陶泥儿 <渠道显示名>`(`release` → `陶泥儿 Release`、`beta-2` → `陶泥儿 Beta-2`)与 `identifier = world.genarrative.ai-game-creator.<渠道>`。渠道显示名按连字符分段首字母大写,不改动渠道本身。 +- 默认渠道身份**不可变更**:既有安装目录、卸载项、快捷方式与已发布客户端的升级链都建立在基线身份上。渠道身份由 `apps/ai-game-creator-shell/scripts/channel-identity.mjs` 单点定义,构建入口、macOS 发布入口与配置门禁共同消费;基线 `tauri.conf.json` 必须逐字等于默认渠道身份。 +- 安装身份决定的持久与可见事实:Windows 安装目录 `%LOCALAPPDATA%\<产品名>`、卸载项与 `HKCU\Software\genarrative\<产品名>`、WebView2 数据目录 `%LOCALAPPDATA%\`、客户端数据目录 `%APPDATA%\`;macOS `.app` 名、bundle id、DMG 卷名与菜单栏应用名。 +- 同机并存:不同渠道的包体可以在同一台设备上同时安装并同时运行,互不覆盖、互不顶掉;同一渠道的新版本仍是原地升级,因为更新端点与安装身份同属一个渠道。 +- 数据不跨渠道共享:本地项目、工程快照、模板、登录态、诊断日志与 Runner/项目锁按渠道身份分目录。切渠道等于换一个客户端,不迁移、不合并本地数据;渠道内的 origin 隔离规则不变。 +- 主窗口与工作区/启动器窗口标题取构建期产品名,让同机并存的渠道客户端在任务栏与 Alt-Tab 中可区分;默认渠道标题仍是「陶泥儿」。 +- 首装包与更新包的对象名包含产品名(如 `陶泥儿 Release_0.1.96_x64-setup.exe`、`陶泥儿 Release_0.1.96_aarch64.dmg`)。清单 `downloads` 地址由发布脚本按本次真实产物派生,禁止写死产品名;首装包选择按 `<版本>_<架构>.dmg` 唯一匹配,不依赖产品名字面量。 +- Windows 提权 ACL 修复助手按目录名识别安装身份:`<基线>` 与 `<基线>.<渠道>` 都在 AGC 自有的 managed 范围内;相似前缀(例如 `world.genarrative.ai-game-creator-backup`)不在范围内,落回 user-selected 范围或直接拒绝。 + ## 非目标 - 不做灰度放量、分批更新、强制更新和自动回滚;渠道只决定「取哪份清单」。 @@ -110,11 +121,21 @@ - 迁移起点:已发布客户端(含当前线上版本)内置自研清单地址 `agc/latest.json`(sha256 格式),下载与安装由自研 Rust 命令完成。 - 迁移策略见「未决问题与决策」。迁移完成后,自研清单解析、下载命令、下载进度事件以及为此放行的 CSP / HTTP 白名单条目按「四不写」整条删除,不留兼容分支与墓碑说明。 +- 渠道安装身份映射(`` 为 `dev`、`release` 或自定义名称;`` 为渠道显示名): + +| 渠道 | productName | identifier | Windows 安装目录 | 客户端数据目录 | +| -------------------- | ------------------ | ---------------------------------------------- | ----------------------------------- | ----------------------------------------------- | +| `dev`(默认) | `陶泥儿` | `world.genarrative.ai-game-creator` | `%LOCALAPPDATA%\陶泥儿` | `%APPDATA%\world.genarrative.ai-game-creator` | +| `release` / 自定义 | `陶泥儿 ` | `world.genarrative.ai-game-creator.` | `%LOCALAPPDATA%\陶泥儿 ` | `%APPDATA%\world.genarrative.ai-game-creator.` | + +- 安装身份迁移:`dev` 客户端保持原身份,升级链路连续;`release` 与自定义渠道首次以新身份安装,**不接管也不迁移**任何既有 `dev` 安装、本地项目或登录态,设备上因此可以同时存在两个渠道的客户端,由用户自行决定是否卸载其一。 + ## 构建与发布 - 发布入口:`npm run ai-game-creator-shell:release:upload`(构建 + 按渠道上传);仅构建不发布的 smoke 使用 `--no-bundle` 分支,不读远端版本、不改版本、不生成清单。 - 发布入口只解析一次目标,优先级为 CLI `--target value` / `--target=value` / `-t value`、`AGC_BUILD_TARGET`、Windows 默认值;重复/空目标与不支持目标失败关闭。版本高水位、构建 feature/渠道端点、bundle 路径、产物后缀、清单平台键及摘要必须消费同一个发布上下文,不能分别回读默认目标。 - 渠道由 `AGC_UPDATE_CHANNEL` 显式指定,默认 dev;Windows 与 macOS 目标均支持 dev、release 和自定义渠道,目标校验独立进行。 +- 渠道 `--config` 在 Tauri 构建前最后合并,同时注入 `productName`、`identifier` 与 updater 端点:安装身份与更新端点必须来自同一个渠道,不能各自回读默认值。macOS 发布入口构建 `*.app`、updater 归档与 DMG 前先按发布渠道解析产品名,产物名一律派生而不写死。 - 定时调度只在本轮到达的提交包含 AGC 相关路径(客户端、共享包、`server-rs/crates`、AGC 插件、桌面壳图标、根依赖清单)时才触发渠道发布;纯文档或流水线自身的提交只跑 Full Build,不推高客户端版本号。判定失败或勾选强制触发时按"需要发布"处理。 - 更新摘要自动生成:发布脚本用渠道清单里的 `commit` 字段(上一次发布的提交)到本次提交之间、且只覆盖客户端相关路径的提交列表生成 `notes`(每条 `- 提交标题(短 SHA)`,最多 12 条、主题 80 字、整体 900 字,超出折叠或截断),同时写入旧协议清单的 `releaseNotes` 和归档文件 `release-notes.txt`。`AGC_UPDATE_RELEASE_NOTES` 非空时以手动文案为准;无法判定起点(缺少上次 `commit` 或本地没有该提交)时不写摘要。清单缺少 `commit` 时回退用上一次成功构建的 `COMMIT_HASH`(CI 通过 `AGC_UPDATE_PREVIOUS_COMMIT` 传入)作为锚点,因此首次启用摘要或更换渠道后也能立即产出摘要。锚点仍不可得(清单读取失败或没有 CI 锚点)时降级为「最近客户端改动」列表并注明可能与上一版重复 —— 摘要属于附注,任何情况下都不允许因为它让发布失败。 - 清单里的 `commit` 是非标准字段:更新插件忽略未知字段,发布脚本用它定位下一次摘要的起点。 @@ -157,6 +178,25 @@ | 真实更新闭环(含升级后重启) | 0.1.47 客户端按提示下载安装并重启 | 通过(2026-09-17 用户实测:提示 → 下载 → 安装 → 关于页显示新版本,再次检查为已是最新) | | 更新摘要端到端展示 | 公网读取渠道清单 `notes` 与客户端更新提示 | 通过(2026-09-17 用户实测:0.1.62 清单带 8 条自动摘要,客户端提示正常显示多行内容) | +渠道安装身份隔离已于 `2026-09-21` 完成源码验收: + +| 条款 | 验收方式 | 结果 | +| --- | --- | --- | +| 渠道身份派生与默认渠道不变 | `node --test apps/ai-game-creator-shell/scripts/build-release.test.mjs` | 通过;`dev` 逐字等于基线 `陶泥儿` / `world.genarrative.ai-game-creator`,`release` / `beta-2` 派生独立产品名与 identifier,非法渠道失败关闭 | +| 身份与端点同批注入 | 同上的渠道 `--config` 用例 | 通过;`productName` / `identifier` 与 `/-win|mac/latest.json` 来自同一次解析 | +| 渠道产物首装包选择 | 同上的渠道 DMG 夹具用例 | 通过;`陶泥儿 Release_<版本>_aarch64.dmg` 仍按 `<版本>_<架构>.dmg` 唯一匹配 | +| 基线配置等于默认渠道身份 | `node apps/ai-game-creator-shell/scripts/check-config.mjs` | 通过;基线漂移与非默认渠道身份不隔离都会失败关闭 | +| 全量发布脚本回归 | `node --test build-release.test.mjs release-oss.test.mjs prepare-macos-codex.test.mjs cargo-features.test.mjs` | 通过(64/64,含 macOS 入口按渠道解析产品名的守卫) | +| AGC 自有 AppData 提权 ACL 范围 | `cargo test --locked --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml --bin genarrative-ai-game-creator-shell -- config::private_path_elevation_policy_tests` | 通过(12/12;含基线、`<基线>.release`、`<基线>.beta-2` 与相似前缀 `-backup` 的反向断言) | +| 渠道身份进入真实构建产物 | `AGC_UPDATE_CHANNEL=release npm --prefix apps/ai-game-creator-shell run build -- --no-bundle --debug` | 通过;Tauri 接受派生的 `productName` / `identifier` 并完成构建;产物字符串实测 `陶泥儿 Release` × 1、`agc/release-win/latest.json` × 1、`world.genarrative.ai-game-creator.release` × 1、`agc/dev-win/latest.json` × 0 | + +未验证项(不得按已通过处理): + +- 真实 Windows 双渠道安装与并存:尚未在同一台设备安装 `dev` 与 `release` 两个渠道的安装包,安装目录/卸载项/数据目录的分离与两个客户端同时运行属于发布验收,本次只到源码与脚本层级。 +- 未生成安装包:本轮构建烟测止于 `--no-bundle`,NSIS 安装目录 / 卸载项 / 快捷方式按渠道分开、以及「装完 release 后 dev 仍在」的现场证据需要一次真实渠道打包与安装。 +- macOS 侧只验证到入口派生逻辑:`.app` 名、bundle id 与 DMG 卷名的渠道派生没有在 macOS 节点实跑。 +- 客户端数据隔离的运行期事实(`%APPDATA%\` 分目录、登录态不跨渠道)未做真机对照。 + 待执行证据(首次渠道发布后回填): | 条款 | 验收方式 | 证据 | @@ -174,6 +214,7 @@ - macOS 发布方式:已接入专用 macOS Jenkins 节点(label `genarrative-agc-macos`,EXCLUSIVE 单 executor),由 `Jenkinsfile.ai-game-creator-shell-macos-build` 执行 `scripts/build-macos-ci.mjs` 完成 arm64 单架构构建、arm64 隔离 smoke、arm64 DMG(`<产品名>_<版本>_aarch64.dmg`)、分区清单生成、更新包验签与 OSS 上传。`AGC_RELEASE_DRY_RUN` 默认为关(与 Windows 渠道对称,即直接发布),只有勾选后才退化为「只打印上传计划、不写 OSS」的演练。 - macOS 代码签名与公证暂缺:产物为未签名 + 未公证,构建入口剥离 `APPLE_*` 凭据跳过 Apple 签名,不传 `--no-sign`(它还会跳过 updater 的 minisign 签名,产物将没有 `.sig`);构建清单实测记录 `appleSigned` 与签名类型,`latest.json` 侧固定记录 `notarized=false`,首装需用户在 Gatekeeper 中手动放行。该限制作为已知未验证项记录,不静默通过;「安装 → 重启接管新版本」的自动更新闭环仍需实机验收。 - 更新包验签门禁:构建完成、上传 OSS 之前,用产物内烘焙的 `plugins.updater.pubkey` 复核 `<更新包>.sig`(Tauri 使用 minisign 的 `ED` 预哈希模式)。keyId 不一致或校验失败立即失败关闭,禁止上传——客户端校验失败会直接拒绝安装,且公钥发布后不可更换。 +- 渠道安装身份(2026-09-21):渠道此前只决定更新端点,`productName` / `identifier` 与渠道无关,导致不同渠道的包体共用 `%LOCALAPPDATA%\陶泥儿`、同一个卸载项与同一份 `%APPDATA%\world.genarrative.ai-game-creator` 数据目录,后装的渠道静默顶掉先装的渠道并接管更新端点与本地登录态。现决策为「渠道进安装身份」:默认渠道保持基线身份不动,其它渠道派生 `<产品名> <渠道显示名>` 与 `<基线>.<渠道>`,渠道内仍原地升级,同机并存与数据隔离成立。窗口标题、macOS 产物名与 Windows 提权 ACL 的 managed 识别范围同批跟随该身份。 待办: diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index 7020c59e2..671f17ad6 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -578,7 +578,7 @@ Supervisor 认领该回执后,由父 run 自己为每个原 delivery 逐一创 - 模式合同:客户端 AppData 配置新增全局 `agentMode`,只接受 `codex_cli / provider`。缺省和新安装默认使用 `codex_cli`,原有 HTTP LLM Provider 路径完整保留并可显式切回 `provider`;切换只影响下一次节点请求,不新增 Runner、任务图、会话库、配置库或业务事实源。 - 调度边界:正式 DAG、manifest、Agent task/session/run 身份、队列、锁、委派、all-join、完成门、Provider lifecycle、持久 retry/handoff 与 `needs-reconciliation` 继续由现有 AGC Runtime 掌控。每个被调度节点在 `codex_cli` 模式下直接启动一次非交互 `codex exec` 充当该节点的推理 Agent;Codex 返回当前 Runtime 广告函数的结构化调用,Runtime 仍是唯一 ToolHost,不允许 CLI 自己写项目、执行命令、调用 MCP 或形成第二套 revision / verification 真相。 - 安装包侧车:Windows x64 release 固定随 Tauri resource 打包 `@openai/codex@0.155.1` 的原生 `codex.exe`;Rust build script 从 AGC 子包锁定依赖 stage 到 resource,并写入版本与 SHA-256 清单。固定版本只在 `build_support/codex_bundle.rs` 声明一次,构建脚本、宿主补丁执行器身份、逐次审批协议允许列表和模型目录捕获共同引用它,避免多处字面量漂移。Windows 侧车映射只写入 `tauri.windows.conf.json`,通用 `tauri.conf.json` 不得让 Linux / macOS 构建依赖未生成的 Windows 二进制。运行时只在文件摘要和 `codex-cli` 版本同时匹配清单时优先选内置侧车;缺失、损坏或版本漂移时跳过它,按既有 npm 安装、PATH 顺序回退。安装包同时携带 Apache-2.0 第三方声明;API Key、`auth.json`、Cookie、Token、用户 `CODEX_HOME`、用户配置和项目数据绝不打包。 -- Windows x64 release 安装包只生成 NSIS,不生成 MSI:`tauri.windows.conf.json` 的 `bundle.targets` 固定为 `["nsis"]`,通用配置继续保留其它平台的默认打包目标。安装后的产品名、开始菜单 / 桌面快捷方式和 EXE 产品描述统一由 `tauri.conf.json` 的 `productName: "陶泥儿"` 生成;应用 identifier 与内部可执行文件名保持稳定。内置 Codex 资源安装到顶层 `coding-agent/win-x64/`,运行时从同一路径查找 `bin/codex.exe` 与 `manifest.json`;仓库 staging 仍使用 `resources/codex/win-x64/`,包内子目录、组件名、版本和完整性校验保持原合同。 +- Windows x64 release 安装包只生成 NSIS,不生成 MSI:`tauri.windows.conf.json` 的 `bundle.targets` 固定为 `["nsis"]`,通用配置继续保留其它平台的默认打包目标。安装后的产品名、开始菜单 / 桌面快捷方式和 EXE 产品描述由 `tauri.conf.json` 的 `productName` 生成:基线是 `陶泥儿`,发布构建按渠道由 `--config` 覆盖为 `陶泥儿 <渠道显示名>`,identifier 同批派生 `<基线>.<渠道>`(默认渠道保持基线值),因此不同渠道的包体可在同一台设备并存;内部可执行文件名保持稳定。内置 Codex 资源安装到顶层 `coding-agent/win-x64/`,运行时从同一路径查找 `bin/codex.exe` 与 `manifest.json`;仓库 staging 仍使用 `resources/codex/win-x64/`,包内子目录、组件名、版本和完整性校验保持原合同。 - macOS 安装包必须携带锁定版本的原生 Codex、`codex-code-mode-host`、`rg`、上游 zsh、`codex-package.json` 和第三方声明,保留上游相对布局;构建时按 Cargo 目标选择 npm 原生依赖,缺文件、版本或目标不匹配立即失败,不借用开发机 PATH 里的 Codex。资源只在 `tauri.macos.conf.json` 映射到 `Contents/Resources/coding-agent/mac-native/darwin-arm64/` 与 `darwin-x64/`。构建与运行共享平台文件白名单,运行时由当前 `.app/Contents/MacOS` 定位相邻 `Resources`,完整性与版本验证通过后优先使用内置组件;失败沿既有外部安装回退,不能运行未校验的内置文件。macOS 当前只构建 arm64 单架构,但资源映射仍并列携带两套锁定原生 Codex 依赖(运行切片按 Cargo 目标只选择对应目录),恢复 Intel 时无需改动资源布局;随包 Node 只有宿主架构那一份,所以不得构建 universal 包。不读取全局 Codex。 - 内置插件的清单、运行入口与面板同时在 Windows/macOS 随包分发,继续由既有 PluginHost 的应用资源目录扫描入口发现;不携带开发依赖、缓存、测试或私有配置。插件文件随包不等于原生适配器跨平台:Cocos 进程桥接仍受现有 Windows 实现和 feature 门禁约束,macOS 原生桥接另行设计与验收,不复制 Windows DLL 冒充支持。系统 Node、用户 Cocos Creator、账号登录、网络和生成工程的 npm 工具链仍是现有外部前提,不在此次 Codex 侧车补齐中隐式变更。 - macOS 安装包验收必须包括:脱离仓库位置的 `.app` 资源与架构检查、受限 PATH/隔离 HOME 下内置 Codex 启动和 app-server 握手、必需文件缺失/篡改/平台错误的拒绝测试,以及 DMG 完整性检查。真实登录、Provider 对话、GUI 和 Cocos 操作必须独立列出证据,不能用压缩包生成或 `--version` 成功替代。未配置正式签名、公证的本地测试包不得作为公开发行包。 diff --git a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md index ccb2bbd32..23d4a3cd5 100644 --- a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md +++ b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md @@ -147,6 +147,8 @@ revision 变化时,调度管线把同一个完整 commit 通过 `COMMIT_HASH` 手工发布入口 `Genarrative-Manual-Build-And-Deploy` 的 `DEPLOY_TARGET` 与 AGC 更新渠道是两个独立维度。手工入口必须把 `release` 映射为 `AGC_UPDATE_CHANNEL=release`、把 `development` 映射为 `dev`,并把该参数同时透传给 `Genarrative-Agc-Windows-Build` 与 `Genarrative-Agc-MacOS-Build`;只传 `AGC_RELEASE_VERSION` 时下游会落回各自默认 `dev`。统一号 `0.1.95` 的 release 包应分别位于 `agc/release-win/0.1.95/` 与 `agc/release-mac/0.1.95/`,渠道清单为对应目录下的 `latest.json`,不存在 `agc/release/` 这一层。补发本轮已烧号的版本时,直接以相同 `AGC_RELEASE_VERSION` 重跑两条 AGC Job,不重新发号。 +渠道参数同时决定安装身份:默认渠道 `dev` 产出 `productName=陶泥儿` + `identifier=world.genarrative.ai-game-creator`(既有安装与升级链),其它渠道产出 `陶泥儿 <渠道显示名>` + `<基线>.<渠道>`,因此同一台设备可以同时装 `dev` 与 `release` 两个客户端,互不覆盖、数据不共享(渠道与安装身份合同见《AGC客户端更新检查与下载》)。发布后核对发布对象名(如 `agc/release-win/<版本>/陶泥儿 Release_<版本>_x64-setup.exe`)与装机后的卸载项、`%APPDATA%\` 是否按渠道分开。 + `Genarrative-Agc-Windows-Build` 的 `Tauri NSIS toolchain` 阶段必须在 Rust 编译前预置 NSIS 工具链并失败关闭:tauri-bundler 打包时现场从 GitHub 下载 `nsis-3.11.zip` 与 `nsis_tauri_utils.dll` 且不重试,构建机每次检出都会重下,响应一旦被截断就只能抛 `io: unexpected end of file`,让发布在编译数分钟后才失败。该阶段先跑 `node apps/ai-game-creator-shell/scripts/ensure-nsis-toolset.mjs`(固定 SHA1 校验、4 次重试、解压到 `target/.tauri/NSIS`),再执行 `makensis.exe -VERSION` 验证可执行性;Checkout 的 `git clean -fdx` 必须带 `-e apps/ai-game-creator-shell/src-tauri/target/.tauri`,只保留这份工具缓存、其余 `target/` 内容照常清空,否则工作区内缓存会被每个构建删掉,退回到「每次从 GitHub 重下」(实测裸 `git clean -fdx` 会输出 `Would remove apps/ai-game-creator-shell/src-tauri/target/`);原始归档缓存在工作区外的 `%ProgramData%\genarrative\tauri-nsis-cache`(可用 `AGC_TAURI_NSIS_CACHE_DIR` 覆盖),因此同一节点只有冷缓存才需要联网,离线补缓存时把这两个文件放进缓存目录即可;构建机确实无法访问 GitHub 时使用 bundler 自带的 `TAURI_BUNDLER_TOOLS_GITHUB_MIRROR_TEMPLATE` / `TAURI_BUNDLER_TOOLS_GITHUB_MIRROR` 指向可达镜像。升级 `@tauri-apps/cli` 时必须同步核对 `nsis-toolset.mjs` 里的归档地址、SHA1 与必需文件清单(与 tauri-bundler 的 `NSIS_REQUIRED_FILES` 逐条对齐),否则预置会被 bundler 判为不完整。 调度状态是调度 Job 工作区里的 `.jenkins-last-triggered-revision`,构建描述同时回显本次 revision 与结果。工作区被清理(例如 `Wipe Out Workspace`)或状态文件缺失时,下一次运行按“版本变化”处理并触发一次,之后恢复稳定;需要重建同一版本时勾选 `FORCE_TRIGGER`。Job 按仓库内 `jenkins/scheduled-revision-trigger-job-config.xml` 创建:`scriptPath=jenkins/Jenkinsfile.scheduled-revision-trigger`、Git 入口 `ssh://git@127.0.0.1:2222/GenarrativeAI/Genarrative.git`、凭据 `genarrative-local-gitea-ssh`、`` 留空(定时器写在 Jenkinsfile 里)。推送后必须让三个 live Job 各自加载一次新 Jenkinsfile,并只读核对 `config.xml`:Full 与 AGC 不再有 cron,定时只来自新调度 Job;只改 Jenkinsfile 而不确认 live 配置时,旧 cron 仍会继续触发。 @@ -744,7 +746,7 @@ Mac 单架构(arm64)构建脚本位于 `jenkins/Jenkinsfile.ai-game-creator- Job 名为 `Genarrative-Agc-MacOS-Build`,SCM 直接读取仓库内上述 Jenkinsfile,参数为 `SOURCE_BRANCH`、`COMMIT_HASH`、`AGC_UPDATE_CHANNEL`、`AGC_RELEASE_VERSION`、`AGC_RELEASE_DRY_RUN`、`AGC_UPDATE_RELEASE_NOTES`、`OSSUTIL_BIN`、`CARGO_BUILD_JOBS`、`NOTIFICATION_EMAILS`。渠道参数是基础名(不含系统,默认 `dev`),脚本不接受 `dev-mac` 这类系统后缀,写入分区固定推导为 `-mac`:这与 Windows Job 的 `-win` 对称,也延续已发布客户端的端点。 -该 Job 的职责是构建并发布 `-mac` 分区更新:执行 `npm ci` 后使用锁文件校验并补齐两种 macOS Codex 原生依赖,再调用 `scripts/build-macos-ci.mjs`(AGC 应用目录下)生成 arm64 单架构 app、arm64 隔离 smoke、arm64 DMG(`<产品名>_<版本>_aarch64.dmg`)与分区清单 `latest.json`,用产物内烘焙的公钥复核更新包签名(`verify-updater-signature.mjs`),最后按 `AGC_RELEASE_DRY_RUN` 决定是否上传 OSS。签到会同时取 `master`,让渠道清单里上一次发布的 commit 可解析——缺了它更新摘要会退化成「最近客户端改动」(该步失败只降级摘要,不阻断发布)。产物名(`*.app`、updater 归档、DMG、卷名)一律从 Tauri `productName` 推导,校验脚本从包内 `Info.plist` 读取可执行名,改产品名不会让入口静默找错对象;隔离 smoke 用 `ditto --clone` 复制副本(实测整轮 6.8 秒,此前整包复制约 1 分钟),并在构建前删除本次将写出的 DMG/更新包/签名,保证归档产物一定来自本次构建。归档限 `artifacts/` 下的 DMG、SHA-256、`latest.json`、更新包签名、更新摘要、非敏感构建清单和源码 commit;不归档用户 HOME、Jenkins secret、原始工作目录或全量日志。 +该 Job 的职责是构建并发布 `-mac` 分区更新:执行 `npm ci` 后使用锁文件校验并补齐两种 macOS Codex 原生依赖,再调用 `scripts/build-macos-ci.mjs`(AGC 应用目录下)生成 arm64 单架构 app、arm64 隔离 smoke、arm64 DMG(`<产品名>_<版本>_aarch64.dmg`)与分区清单 `latest.json`,用产物内烘焙的公钥复核更新包签名(`verify-updater-signature.mjs`),最后按 `AGC_RELEASE_DRY_RUN` 决定是否上传 OSS。签到会同时取 `master`,让渠道清单里上一次发布的 commit 可解析——缺了它更新摘要会退化成「最近客户端改动」(该步失败只降级摘要,不阻断发布)。产物名(`*.app`、updater 归档、DMG、卷名)一律从渠道安装身份派生的产品名推导(默认渠道即基线 `productName`,`release` 等渠道带渠道后缀),校验脚本从包内 `Info.plist` 读取可执行名,改产品名或换渠道都不会让入口静默找错对象;隔离 smoke 用 `ditto --clone` 复制副本(实测整轮 6.8 秒,此前整包复制约 1 分钟),并在构建前删除本次将写出的 DMG/更新包/签名,保证归档产物一定来自本次构建。归档限 `artifacts/` 下的 DMG、SHA-256、`latest.json`、更新包签名、更新摘要、非敏感构建清单和源码 commit;不归档用户 HOME、Jenkins secret、原始工作目录或全量日志。 发布凭据全部走 Jenkins 全局凭据,并在 `withCredentials` 内注入当前进程:`AgcUpdaterSigningKey`(与 `AgcUpdaterSigningKeyPassword`)映射为 `TAURI_SIGNING_PRIVATE_KEY` / `TAURI_SIGNING_PRIVATE_KEY_PASSWORD`,`AliyunAccessKeyId` / `AliyunaccessKeySecret` 映射为 `AGC_OSS_ACCESS_KEY_ID` / `AGC_OSS_ACCESS_KEY_SECRET`;私钥与凭据不写入 workspace、日志或归档产物。上传顺序为更新包、签名、首装包,三者全部成功后才覆盖 `agc/-mac/latest.json` 指针;`AGC_RELEASE_DRY_RUN` 与 Windows 渠道对称:默认关闭即真发布,勾选后才退化为演练(只打印将上传的对象、不写任何 OSS 对象)。本 Job 是正式发布入口,调度器在发号后与 Windows 一起触发它,并额外传 `SKIP_IF_SUPERSEDED=true`——Mac 节点是日常办公机,离线期间排队的旧构建在节点回来后若已被源码分支推进,直接跳过而不发布过期版本。Mac 节点需要 `ossutil`(实测 1.7.19 原生 arm64 可用,装在 `~/.local/bin`,已在 Job 的 PATH 内),可用 `OSSUTIL_BIN` 指定命令名或绝对路径。首次发布建议显式指定 `AGC_RELEASE_VERSION`,避免按渠道高水位递增时出现版本链回退。 From 34b1ad5d5ca192c4b4d834403580f30e93677ba1 Mon Sep 17 00:00:00 2001 From: kdletters <61648117+kdletters@users.noreply.github.com> Date: Mon, 21 Sep 2026 17:24:58 +0800 Subject: [PATCH 2/2] =?UTF-8?q?=E6=96=B0=E5=A2=9E=20Godot=20=E6=A8=A1?= =?UTF-8?q?=E6=9D=BF=E5=B9=B6=E8=A1=A5=E6=8C=89=E6=A8=A1=E6=9D=BF=E5=BB=BA?= =?UTF-8?q?=E9=A1=B9=E7=9A=84=20Godot=20=E5=88=86=E6=B5=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 4 个仓库内手写 Godot 4.7 模板源:godot-empty-2d、godot-empty-3d、godot-hello-world、godot-platformer-2d,entry 统一为 project.godot,各自带 meta.json 与 960×540 封面 - template_library.rs:复制模板后按 project.godot 识别 Godot 模板,改写工程显示名后走既有 Godot 导入并写入 godotProjectRoot,不再落到 Web 占位入口 - manifest.rs:新增 apply_godot_project_display_name,只改 [application] 段的 config/name 一行,其余字节逐字保留 - manifest.rs:放宽 Godot 工作区发现,一层子目录多命中按目录名排序取第一个,project.godot 允许是符号链接 / reparse point / 硬链接 - commands.rs:同步 Godot 导入错误文案,去掉「普通文件」限定 - RuntimeConfigDialog.tsx:内置插件行去掉手动启动 / 停止按钮,启动由前端自动启动承担、停止走该行启用开关 - import_tests.rs 与 tests/project.rs:补显示名改写、多命中确定性、链接标记与错误文案的定向回归 - 同步更新 PRD、模板包组织指南、模板库与模板建项技术方案、AGC Godot 编辑器插件接入方案、实施计划与共享决策记录 --- .../src-tauri/src/commands.rs | 2 +- .../src-tauri/src/project/manifest.rs | 133 +++++++++--------- .../src/project/manifest/import_tests.rs | 128 +++++++++++++---- .../src-tauri/src/template_library.rs | 53 +++++++ .../src-tauri/src/tests/project.rs | 25 ++-- .../runtime-config/RuntimeConfigDialog.tsx | 41 +++--- .../v1/godot-empty-2d/cover.svg | 12 ++ .../v1/godot-empty-2d/meta.json | 18 +++ .../v1/godot-empty-2d/project/.gitignore | 6 + .../v1/godot-empty-2d/project/icon.svg | 7 + .../v1/godot-empty-2d/project/project.godot | 16 +++ .../godot-empty-2d/project/scenes/main.tscn | 14 ++ .../v1/godot-empty-2d/project/scripts/main.gd | 12 ++ .../v1/godot-empty-3d/cover.svg | 11 ++ .../v1/godot-empty-3d/meta.json | 18 +++ .../v1/godot-empty-3d/project/.gitignore | 6 + .../v1/godot-empty-3d/project/icon.svg | 5 + .../v1/godot-empty-3d/project/project.godot | 16 +++ .../godot-empty-3d/project/scenes/main.tscn | 43 ++++++ .../v1/godot-empty-3d/project/scripts/main.gd | 13 ++ .../v1/godot-hello-world/cover.svg | 12 ++ .../v1/godot-hello-world/meta.json | 18 +++ .../v1/godot-hello-world/project/.gitignore | 6 + .../v1/godot-hello-world/project/icon.svg | 6 + .../godot-hello-world/project/project.godot | 16 +++ .../project/scenes/main.tscn | 34 +++++ .../godot-hello-world/project/scripts/main.gd | 53 +++++++ .../v1/godot-platformer-2d/cover.svg | 13 ++ .../v1/godot-platformer-2d/meta.json | 18 +++ .../v1/godot-platformer-2d/project/.gitignore | 6 + .../v1/godot-platformer-2d/project/icon.svg | 7 + .../godot-platformer-2d/project/project.godot | 16 +++ .../project/scenes/main.tscn | 97 +++++++++++++ .../project/scripts/main.gd | 51 +++++++ .../project/scripts/player.gd | 23 +++ ...AI游戏创作】项目开发工作台PRD-2026-07-20.md | 4 +- .../shared-memory/decision-log.md | 20 ++- ...方案】AGC Godot编辑器插件接入-2026-09-20.md | 10 +- ...技术方案】AGC模板库与模板建项-2026-09-17.md | 8 +- ...案】AI游戏创作智能体App实施计划-2026-06-24.md | 11 ++ ...模板规范】AGC模板包组织指南-2026-09-21.md | 12 +- 41 files changed, 883 insertions(+), 137 deletions(-) create mode 100644 apps/ai-game-creator-shell/template-library/v1/godot-empty-2d/cover.svg create mode 100644 apps/ai-game-creator-shell/template-library/v1/godot-empty-2d/meta.json create mode 100644 apps/ai-game-creator-shell/template-library/v1/godot-empty-2d/project/.gitignore create mode 100644 apps/ai-game-creator-shell/template-library/v1/godot-empty-2d/project/icon.svg create mode 100644 apps/ai-game-creator-shell/template-library/v1/godot-empty-2d/project/project.godot create mode 100644 apps/ai-game-creator-shell/template-library/v1/godot-empty-2d/project/scenes/main.tscn create mode 100644 apps/ai-game-creator-shell/template-library/v1/godot-empty-2d/project/scripts/main.gd create mode 100644 apps/ai-game-creator-shell/template-library/v1/godot-empty-3d/cover.svg create mode 100644 apps/ai-game-creator-shell/template-library/v1/godot-empty-3d/meta.json create mode 100644 apps/ai-game-creator-shell/template-library/v1/godot-empty-3d/project/.gitignore create mode 100644 apps/ai-game-creator-shell/template-library/v1/godot-empty-3d/project/icon.svg create mode 100644 apps/ai-game-creator-shell/template-library/v1/godot-empty-3d/project/project.godot create mode 100644 apps/ai-game-creator-shell/template-library/v1/godot-empty-3d/project/scenes/main.tscn create mode 100644 apps/ai-game-creator-shell/template-library/v1/godot-empty-3d/project/scripts/main.gd create mode 100644 apps/ai-game-creator-shell/template-library/v1/godot-hello-world/cover.svg create mode 100644 apps/ai-game-creator-shell/template-library/v1/godot-hello-world/meta.json create mode 100644 apps/ai-game-creator-shell/template-library/v1/godot-hello-world/project/.gitignore create mode 100644 apps/ai-game-creator-shell/template-library/v1/godot-hello-world/project/icon.svg create mode 100644 apps/ai-game-creator-shell/template-library/v1/godot-hello-world/project/project.godot create mode 100644 apps/ai-game-creator-shell/template-library/v1/godot-hello-world/project/scenes/main.tscn create mode 100644 apps/ai-game-creator-shell/template-library/v1/godot-hello-world/project/scripts/main.gd create mode 100644 apps/ai-game-creator-shell/template-library/v1/godot-platformer-2d/cover.svg create mode 100644 apps/ai-game-creator-shell/template-library/v1/godot-platformer-2d/meta.json create mode 100644 apps/ai-game-creator-shell/template-library/v1/godot-platformer-2d/project/.gitignore create mode 100644 apps/ai-game-creator-shell/template-library/v1/godot-platformer-2d/project/icon.svg create mode 100644 apps/ai-game-creator-shell/template-library/v1/godot-platformer-2d/project/project.godot create mode 100644 apps/ai-game-creator-shell/template-library/v1/godot-platformer-2d/project/scenes/main.tscn create mode 100644 apps/ai-game-creator-shell/template-library/v1/godot-platformer-2d/project/scripts/main.gd create mode 100644 apps/ai-game-creator-shell/template-library/v1/godot-platformer-2d/project/scripts/player.gd diff --git a/apps/ai-game-creator-shell/src-tauri/src/commands.rs b/apps/ai-game-creator-shell/src-tauri/src/commands.rs index a079cca8b..bf71cb451 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/commands.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/commands.rs @@ -652,7 +652,7 @@ pub(crate) fn import_local_godot_project( let root = Path::new(project_path.trim()); enforce_project_permission_policy(root, "project.create")?; if discover_local_godot_project_root(root)?.is_none() { - return Err("所选工作区未在根目录或一层子目录发现有效的普通文件 project.godot".to_string()); + return Err("所选工作区未在根目录或一层子目录发现 project.godot".to_string()); } let _lock = acquire_project_write_lock(root, "project.create")?; import_local_godot_project_at(root, project_id.trim(), name.trim()) 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 2094ef492..ae4ad0550 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 @@ -84,9 +84,15 @@ fn godot_metadata_is_link(metadata: &fs::Metadata) -> bool { metadata.file_type().is_symlink() || godot_metadata_is_reparse_point(metadata) } +/// Godot 工程标记:`project.godot` 解析为普通文件即命中。 +/// +/// 2026-09-21 解除“必须是普通文件”的限制:符号链接、Windows reparse point 与 +/// 硬链接一律跟随,不再因为 `project.godot` 本身是链接而拒绝整个工作区。判据只 +/// 保留“解析后仍是文件”,目录或悬空链接仍然不算命中。 fn inspect_godot_project_marker(root: &Path) -> Result { let project_file = root.join("project.godot"); - let metadata = match fs::symlink_metadata(&project_file) { + // `fs::metadata` 跟随符号链接 / reparse point,因此链接指向的真实对象才是判据。 + let metadata = match fs::metadata(&project_file) { Ok(metadata) => metadata, Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false), Err(error) => { @@ -96,64 +102,12 @@ fn inspect_godot_project_marker(root: &Path) -> Result { )); } }; - if godot_metadata_is_link(&metadata) { - return Err(format!( - "Godot 项目文件不能是符号链接或 reparse point:{}", - project_file.display() - )); - } if !metadata.is_file() { return Err(format!( - "Godot 项目文件必须是普通文件:{}", + "Godot 项目文件必须是文件:{}", project_file.display() )); } - #[cfg(unix)] - { - use std::os::unix::fs::MetadataExt; - if metadata.nlink() != 1 { - return Err(format!( - "Godot 项目文件不能是硬链接文件:{}", - project_file.display() - )); - } - } - #[cfg(windows)] - { - use std::os::windows::io::AsRawHandle; - use windows_sys::Win32::Storage::FileSystem::{ - GetFileInformationByHandle, BY_HANDLE_FILE_INFORMATION, - }; - - const FILE_ATTRIBUTE_DIRECTORY: u32 = 0x0000_0010; - const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x0000_0400; - let file = fs::File::open(&project_file).map_err(|error| { - format!( - "打开 Godot 项目文件失败:{}: {error}", - project_file.display() - ) - })?; - // SAFETY: the structure is plain data initialized by GetFileInformationByHandle. - let mut information = unsafe { std::mem::zeroed::() }; - // SAFETY: file owns a live handle and information is a valid output pointer. - // 取不到句柄信息、目录与 reparse point 一律按拒绝处理,保持 fail-closed。 - if unsafe { GetFileInformationByHandle(file.as_raw_handle().cast(), &mut information) } == 0 - || information.dwFileAttributes - & (FILE_ATTRIBUTE_DIRECTORY | FILE_ATTRIBUTE_REPARSE_POINT) - != 0 - { - return Err(format!( - "读取 Godot 项目文件 Windows 身份失败:{}", - std::io::Error::last_os_error() - )); - } - if information.nNumberOfLinks != 1 { - return Err(format!( - "Godot 项目文件不能是硬链接文件:{}", - project_file.display() - )); - } - } Ok(true) } @@ -166,6 +120,62 @@ fn validate_godot_project_child_name(name: &std::ffi::OsStr) -> Result Result<(), String> { + let path = root.join("project.godot"); + let text = match fs::read_to_string(&path) { + Ok(text) => text, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()), + Err(error) => { + return Err(format!( + "读取 Godot 工程配置失败:{}: {error}", + path.display() + )); + } + }; + let newline = if text.contains("\r\n") { "\r\n" } else { "\n" }; + let ends_with_newline = text.ends_with('\n'); + let mut replaced = false; + let mut lines = Vec::new(); + for line in text.lines() { + let trimmed = line.trim_start(); + if !replaced { + if let Some(rest) = trimmed.strip_prefix("config/name") { + let rest = rest.trim_start(); + if let Some(value) = rest.strip_prefix('=') { + let value = value.trim(); + if value.len() >= 2 && value.starts_with('"') && value.ends_with('"') { + let indent = &line[..line.len() - trimmed.len()]; + lines.push(format!( + "{indent}config/name=\"{}\"", + escape_godot_project_string(name) + )); + replaced = true; + continue; + } + } + } + } + lines.push(line.to_string()); + } + if !replaced { + return Ok(()); + } + let mut updated = lines.join(newline); + if ends_with_newline { + updated.push_str(newline); + } + write_game_creator_private_file(&path, updated.as_bytes(), "Godot 工程配置") +} + +fn escape_godot_project_string(value: &str) -> String { + value.replace('\\', "\\\\").replace('"', "\\\"") +} + fn validate_manifest_godot_project_root(value: Option<&str>) -> Result<(), String> { let Some(value) = value else { return Ok(()); @@ -346,13 +356,9 @@ pub(crate) fn discover_local_godot_project_root( matches.push(validate_godot_project_child_name(&entry.file_name())?); } matches.sort(); - if matches.len() > 1 { - return Err(format!( - "工作区一层子目录中发现多个 Godot 项目:{}", - matches.join("、") - )); - } - let Some(relative_root) = matches.pop() else { + // 2026-09-21 解除“必须唯一”的限制:一层子目录出现多个 Godot 工程时按名称排序 + // 取第一个,结果确定且可复现,不再因为存在第二个工程而整体失败关闭。 + let Some(relative_root) = matches.into_iter().next() else { return Ok(None); }; @@ -694,9 +700,8 @@ pub(crate) fn import_local_godot_project_at( if root_metadata.file_type().is_symlink() || !root_metadata.is_dir() { return Err("Godot 工作区目录不存在或不是普通文件夹".to_string()); } - let godot_project_root = discover_local_godot_project_root(root)?.ok_or_else(|| { - "所选工作区未在根目录或一层子目录发现有效的普通文件 project.godot".to_string() - })?; + let godot_project_root = discover_local_godot_project_root(root)? + .ok_or_else(|| "所选工作区未在根目录或一层子目录发现 project.godot".to_string())?; if project_id.is_empty() { return Err("项目 ID 不能为空".to_string()); } 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 434adb727..ab0f99690 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 @@ -133,17 +133,18 @@ fn root_godot_project_takes_priority_over_direct_child_projects() { } #[test] -fn rejects_multiple_direct_child_godot_projects_before_writing_agent_metadata() { +fn picks_the_first_direct_child_godot_project_in_name_order() { let workspace = godot_import_test_path("multiple-children"); write_godot_project(&workspace.join("alpha"), "Alpha"); write_godot_project(&workspace.join("beta"), "Beta"); - let error = import_local_godot_project_at(&workspace, "ambiguous", "Ambiguous") - .expect_err("multiple direct child Godot projects must fail"); + let result = import_local_godot_project_at(&workspace, "ambiguous", "Ambiguous") + .expect("multiple direct child Godot projects must resolve deterministically"); - assert!(error.contains("多个 Godot 项目"), "{error}"); - assert!(!workspace.join(".agent").exists()); - assert!(!workspace.join("alpha/.agent").exists()); + assert_eq!(result.manifest.godot_project_root.as_deref(), Some("alpha")); + assert_manifest_godot_root(&workspace, "alpha"); + assert!(workspace.join(".agent/manifest.json").is_file()); + // 未选中的候选工程不写任何 AGC 元数据。 assert!(!workspace.join("beta/.agent").exists()); fs::remove_dir_all(workspace).ok(); } @@ -187,23 +188,21 @@ fn calibrates_existing_manifest_to_the_discovered_godot_root() { } #[test] -fn ambiguous_layout_does_not_rewrite_an_existing_manifest() { +fn ambiguous_layout_calibrates_an_existing_manifest_deterministically() { let workspace = godot_import_test_path("ambiguous-existing"); init_local_game_project_at(&workspace, "existing-project", "Existing Project") .expect("initialize existing workspace"); write_godot_project(&workspace.join("game"), "Game"); write_godot_project(&workspace.join("other"), "Other"); - let manifest_path = workspace.join(".agent/manifest.json"); - let original = fs::read(&manifest_path).expect("read original manifest"); - let error = import_local_godot_project_at(&workspace, "ignored", "Ignored") - .expect_err("ambiguous existing workspace must fail"); + let result = import_local_godot_project_at(&workspace, "ignored", "Ignored") + .expect("ambiguous existing workspace must calibrate to one candidate"); - assert!(error.contains("多个 Godot 项目"), "{error}"); - assert_eq!( - fs::read(&manifest_path).expect("read unchanged manifest"), - original - ); + assert_eq!(result.manifest.project_id, "existing-project"); + assert_eq!(result.manifest.godot_project_root.as_deref(), Some("game")); + assert_manifest_godot_root(&workspace, "game"); + assert!(!workspace.join("game/.agent").exists()); + assert!(!workspace.join("other/.agent").exists()); fs::remove_dir_all(workspace).ok(); } @@ -286,7 +285,7 @@ fn manifest_read_rejects_unsafe_persisted_godot_project_root() { #[cfg(unix)] #[test] -fn rejects_symbolic_link_project_marker_without_writing_agent_metadata() { +fn accepts_symbolic_link_project_marker() { use std::os::unix::fs::symlink; let workspace = godot_import_test_path("linked-marker"); @@ -294,11 +293,51 @@ fn rejects_symbolic_link_project_marker_without_writing_agent_metadata() { fs::write(workspace.join("real.godot"), "[application]\n").expect("write real marker"); symlink("real.godot", workspace.join("project.godot")).expect("link project marker"); - let error = import_local_godot_project_at(&workspace, "linked", "Linked") - .expect_err("linked project.godot must fail"); + let result = import_local_godot_project_at(&workspace, "linked", "Linked") + .expect("linked project.godot must be accepted"); - assert!(error.contains("符号链接"), "{error}"); - assert!(!workspace.join(".agent").exists()); + assert_eq!(result.manifest.godot_project_root.as_deref(), Some(".")); + assert_manifest_godot_root(&workspace, "."); + fs::remove_dir_all(workspace).ok(); +} + +#[cfg(windows)] +#[test] +fn accepts_windows_hard_link_project_marker() { + let workspace = godot_import_test_path("windows-hard-link-marker"); + fs::create_dir_all(&workspace).expect("create hard link marker workspace"); + fs::write(workspace.join("real.godot"), "[application]\n").expect("write real marker"); + fs::hard_link( + workspace.join("real.godot"), + workspace.join("project.godot"), + ) + .expect("hard link project marker"); + + let result = import_local_godot_project_at(&workspace, "linked", "Linked") + .expect("hard linked project.godot must be accepted"); + + assert_eq!(result.manifest.godot_project_root.as_deref(), Some(".")); + assert_manifest_godot_root(&workspace, "."); + fs::remove_dir_all(workspace).ok(); +} + +#[cfg(windows)] +#[test] +fn accepts_windows_reparse_project_marker() { + let workspace = godot_import_test_path("windows-reparse-marker"); + fs::create_dir_all(&workspace).expect("create reparse marker workspace"); + fs::write(workspace.join("real.godot"), "[application]\n").expect("write real marker"); + if std::os::windows::fs::symlink_file("real.godot", workspace.join("project.godot")).is_err() { + // 未开启开发者模式的机器创建文件符号链接需要额外权限,跳过而不是误报通过。 + fs::remove_dir_all(workspace).ok(); + return; + } + + let result = import_local_godot_project_at(&workspace, "linked", "Linked") + .expect("reparse point project.godot must be accepted"); + + assert_eq!(result.manifest.godot_project_root.as_deref(), Some(".")); + assert_manifest_godot_root(&workspace, "."); fs::remove_dir_all(workspace).ok(); } @@ -344,7 +383,7 @@ fn ignores_unrelated_symbolic_link_while_importing_a_unique_regular_child() { #[cfg(unix)] #[test] -fn rejects_linked_marker_inside_a_regular_child_candidate() { +fn accepts_linked_marker_inside_a_regular_child_candidate() { use std::os::unix::fs::symlink; let workspace = godot_import_test_path("linked-child-marker"); @@ -353,11 +392,12 @@ fn rejects_linked_marker_inside_a_regular_child_candidate() { fs::write(godot_root.join("real.godot"), "[application]\n").expect("write real marker"); symlink("real.godot", godot_root.join("project.godot")).expect("link project marker"); - let error = import_local_godot_project_at(&workspace, "linked", "Linked") - .expect_err("linked marker in a regular child must fail"); + let result = import_local_godot_project_at(&workspace, "linked", "Linked") + .expect("linked marker in a regular child must be accepted"); - assert!(error.contains("符号链接"), "{error}"); - assert!(!workspace.join(".agent").exists()); + assert_eq!(result.manifest.godot_project_root.as_deref(), Some("game")); + assert_manifest_godot_root(&workspace, "game"); + assert!(!godot_root.join(".agent").exists()); fs::remove_dir_all(workspace).ok(); } @@ -397,6 +437,42 @@ fn rejects_non_godot_directory_without_writing_agent_metadata() { fs::remove_dir_all(root).ok(); } +#[test] +fn rewrites_only_the_godot_display_name_line() { + let root = godot_import_test_path("display-name"); + fs::create_dir_all(&root).expect("create project root"); + fs::write( + root.join("project.godot"), + "config_version=5\n\n[application]\n\nconfig/name=\"模板名\"\nrun/main_scene=\"res://scenes/main.tscn\"\n", + ) + .expect("write project.godot"); + + apply_godot_project_display_name(&root, "我的\"平台跳跃\"").expect("rewrite display name"); + + assert_eq!( + fs::read_to_string(root.join("project.godot")).expect("read project.godot"), + "config_version=5\n\n[application]\n\nconfig/name=\"我的\\\"平台跳跃\\\"\"\nrun/main_scene=\"res://scenes/main.tscn\"\n" + ); + fs::remove_dir_all(root).ok(); +} + +#[test] +fn keeps_a_godot_project_without_a_display_name_line_untouched() { + let root = godot_import_test_path("no-display-name"); + fs::create_dir_all(&root).expect("create project root"); + let original = + "config_version=5\n\n[application]\n\nrun/main_scene=\"res://scenes/main.tscn\"\n"; + fs::write(root.join("project.godot"), original).expect("write project.godot"); + + apply_godot_project_display_name(&root, "我的项目").expect("no display name is not an error"); + + assert_eq!( + fs::read_to_string(root.join("project.godot")).expect("read project.godot"), + original + ); + fs::remove_dir_all(root).ok(); +} + fn write_raw_manifest_fixture(workspace: &Path, payload: &serde_json::Value) -> (PathBuf, String) { let manifest_path = workspace.join(".agent/manifest.json"); fs::create_dir_all(manifest_path.parent().expect("manifest parent")) 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 ced19bec9..fbf8fbc10 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 @@ -990,6 +990,16 @@ pub(crate) fn create_project_from_installed_template_at( &project_name, ); } + // Godot 模板同样按工程文件识别:走既有 Godot 导入流程,写入 + // `godotProjectRoot` 并按用户输入改写工程显示名,不生成 Web 占位入口。 + if discover_local_godot_project_root(&project_root)?.is_some() { + apply_godot_project_display_name(&project_root, &project_name)?; + return import_local_godot_project_at( + &project_root, + &format!("gameagent-{workspace_id}"), + &project_name, + ); + } init_local_game_project_at( &project_root, &format!("gameagent-{workspace_id}"), @@ -1492,6 +1502,49 @@ mod tests { fs::remove_dir_all(&projects_root).ok(); } + #[test] + fn godot_template_creates_native_project_with_relative_root_and_display_name() { + let cache_root = tempfile::tempdir().expect("temp dir"); + let projects_root = unique_projects_root(); + let config_source = "config_version=5\n\n[application]\n\nconfig/name=\"Godot 模板\"\nrun/main_scene=\"res://scenes/main.tscn\"\n"; + let archive = build_archive(&[ + ("project.godot", config_source.as_bytes()), + ("scenes/main.tscn", b"[gd_scene format=3]\n"), + ]); + let mut summary = sample_summary(); + summary.id = "godot-fixture-template".to_string(); + summary.runtime = "godot".to_string(); + summary.entry = "project.godot".to_string(); + summary.zip_size_bytes = archive.len() as u64; + summary.zip_sha256 = sha256_hex(&archive); + let record = install_template_archive(cache_root.path(), &summary, &archive) + .expect("install Godot template"); + + let result = create_project_from_installed_template_at( + &projects_root, + Path::new(&record.project_dir), + Some("我的平台跳跃"), + false, + ) + .expect("create Godot project from template"); + + // Godot 模板走 Godot 导入:记录相对根,且不生成 Web 占位入口与并行目录。 + assert_eq!(result.manifest.godot_project_root.as_deref(), Some(".")); + assert_eq!(result.manifest.name, "我的平台跳跃"); + let project_root = Path::new(&result.project_path); + assert!(!project_root.join("game").exists()); + assert!(project_root.join("scenes/main.tscn").is_file()); + let config = + fs::read_to_string(project_root.join("project.godot")).expect("read project.godot"); + assert!(config.contains("config/name=\"我的平台跳跃\""), "{config}"); + assert!( + config.contains("run/main_scene=\"res://scenes/main.tscn\""), + "只改显示名,其余行逐字保留:{config}" + ); + assert!(!project_root.join(TEMPLATE_INSTALLED_MARKER_FILE).exists()); + fs::remove_dir_all(&projects_root).ok(); + } + #[test] fn refuses_to_create_project_when_template_is_not_installed() { let projects_root = tempfile::tempdir().expect("temp dir"); diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs index b722582a2..4a86e86cd 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs @@ -2532,42 +2532,45 @@ fn project_directory_status_reports_workspace_relative_godot_root() { } #[test] -fn project_directory_status_rejects_ambiguous_direct_child_godot_projects() { +fn project_directory_status_resolves_ambiguous_direct_child_godot_projects() { let root = unique_project_path(); - for child in ["alpha", "beta"] { + // 目录枚举顺序不可信:故意倒序创建,证明选择按名称而不是按创建顺序。 + for child in ["beta", "alpha"] { let godot_root = root.join(child); fs::create_dir_all(&godot_root).expect("create nested Godot project"); fs::write(godot_root.join("project.godot"), "[application]\n") .expect("write nested project.godot"); } - let error = inspect_local_project_directory_sync(root.to_string_lossy().to_string()) - .expect_err("ambiguous Godot workspace must fail inspection"); + let status = inspect_local_project_directory_sync(root.to_string_lossy().to_string()) + .expect("multiple direct child Godot projects must resolve deterministically"); - assert!(error.contains("多个 Godot 项目"), "{error}"); + assert!(status.is_godot_project); + assert_eq!(status.godot_project_root.as_deref(), Some("alpha")); fs::remove_dir_all(root).ok(); } #[test] -fn godot_import_command_rejects_ambiguity_before_creating_the_project_lock() { +fn godot_import_command_resolves_child_ambiguity_and_releases_the_lock() { let root = unique_project_path(); - for child in ["alpha", "beta"] { + for child in ["beta", "alpha"] { let godot_root = root.join(child); fs::create_dir_all(&godot_root).expect("create nested Godot project"); fs::write(godot_root.join("project.godot"), "[application]\n") .expect("write nested project.godot"); } - let error = import_local_godot_project( + let result = import_local_godot_project( root.to_string_lossy().into_owned(), "ambiguous".to_string(), "Ambiguous".to_string(), ) - .expect_err("ambiguous Godot workspace must fail before locking"); + .expect("ambiguous Godot workspace must import the first candidate deterministically"); - assert!(error.contains("多个 Godot 项目"), "{error}"); + assert_eq!(result.manifest.godot_project_root.as_deref(), Some("alpha")); + assert!(root.join(".agent/manifest.json").is_file()); + assert!(!root.join("beta/.agent").exists()); assert!(!root.join(PROJECT_WRITE_LOCK_PATH).exists()); - assert!(!root.join(".agent").exists()); fs::remove_dir_all(root).ok(); } diff --git a/apps/ai-game-creator-shell/src/features/runtime-config/RuntimeConfigDialog.tsx b/apps/ai-game-creator-shell/src/features/runtime-config/RuntimeConfigDialog.tsx index de412fabb..57bff26e3 100644 --- a/apps/ai-game-creator-shell/src/features/runtime-config/RuntimeConfigDialog.tsx +++ b/apps/ai-game-creator-shell/src/features/runtime-config/RuntimeConfigDialog.tsx @@ -78,7 +78,12 @@ const defaultRuntimeConfigDraft: GameCreatorAppConfig = { }; type RuntimeSettingsSection = - 'general' | 'workspace' | 'agents' | 'extensions' | 'advanced' | 'about'; + | 'general' + | 'workspace' + | 'agents' + | 'extensions' + | 'advanced' + | 'about'; type RuntimeConfigToast = { tone: 'success' | 'error'; @@ -1076,30 +1081,16 @@ export function RuntimeConfigDialog({ : '已启用'} {plugin.enabled && plugin.hasRuntime ? ( - <> - - - + ) : null} {plugin.status === 'running' ? plugin.panels.map((panel) => ( diff --git a/apps/ai-game-creator-shell/template-library/v1/godot-empty-2d/cover.svg b/apps/ai-game-creator-shell/template-library/v1/godot-empty-2d/cover.svg new file mode 100644 index 000000000..f22daa939 --- /dev/null +++ b/apps/ai-game-creator-shell/template-library/v1/godot-empty-2d/cover.svg @@ -0,0 +1,12 @@ + + + + + + + + + Godot 空白 2D 工程 + Godot 4.7 · GDScript + 1280×720 窗口 · GL Compatibility + diff --git a/apps/ai-game-creator-shell/template-library/v1/godot-empty-2d/meta.json b/apps/ai-game-creator-shell/template-library/v1/godot-empty-2d/meta.json new file mode 100644 index 000000000..c9befb571 --- /dev/null +++ b/apps/ai-game-creator-shell/template-library/v1/godot-empty-2d/meta.json @@ -0,0 +1,18 @@ +{ + "id": "godot-empty-2d", + "title": "Godot 空白 2D 工程", + "summary": "Godot 4.7 原生二维空白工程:1280×720 窗口、GL Compatibility 渲染、单场景入口与占位说明节点已就绪。", + "tags": [ + "空白", + "起步工程", + "2d", + "godot" + ], + "runtime": "godot", + "engine": "godot", + "engineVersion": "4.7", + "templateVersion": "0.1.0", + "entry": "project.godot", + "coverWidth": 960, + "coverHeight": 540 +} diff --git a/apps/ai-game-creator-shell/template-library/v1/godot-empty-2d/project/.gitignore b/apps/ai-game-creator-shell/template-library/v1/godot-empty-2d/project/.gitignore new file mode 100644 index 000000000..a643a2581 --- /dev/null +++ b/apps/ai-game-creator-shell/template-library/v1/godot-empty-2d/project/.gitignore @@ -0,0 +1,6 @@ +# Godot 4+ 编辑器与导入缓存 +.godot/ + +# 导出产物 +export/ +build/ diff --git a/apps/ai-game-creator-shell/template-library/v1/godot-empty-2d/project/icon.svg b/apps/ai-game-creator-shell/template-library/v1/godot-empty-2d/project/icon.svg new file mode 100644 index 000000000..4675426b7 --- /dev/null +++ b/apps/ai-game-creator-shell/template-library/v1/godot-empty-2d/project/icon.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/apps/ai-game-creator-shell/template-library/v1/godot-empty-2d/project/project.godot b/apps/ai-game-creator-shell/template-library/v1/godot-empty-2d/project/project.godot new file mode 100644 index 000000000..bc60b7c8e --- /dev/null +++ b/apps/ai-game-creator-shell/template-library/v1/godot-empty-2d/project/project.godot @@ -0,0 +1,16 @@ +; Engine configuration file. +; AGC 模板库:Godot 4.7 空白 2D 工程。解压后即为工程根。 + +config_version=5 + +[application] + +config/name="Godot 空白 2D 工程" +run/main_scene="res://scenes/main.tscn" +config/features=PackedStringArray("4.7", "GL Compatibility") +config/icon="res://icon.svg" + +[rendering] + +renderer/rendering_method="gl_compatibility" +renderer/rendering_method.mobile="gl_compatibility" diff --git a/apps/ai-game-creator-shell/template-library/v1/godot-empty-2d/project/scenes/main.tscn b/apps/ai-game-creator-shell/template-library/v1/godot-empty-2d/project/scenes/main.tscn new file mode 100644 index 000000000..52bbc79c8 --- /dev/null +++ b/apps/ai-game-creator-shell/template-library/v1/godot-empty-2d/project/scenes/main.tscn @@ -0,0 +1,14 @@ +[gd_scene load_steps=2 format=3] + +[ext_resource type="Script" path="res://scripts/main.gd" id="1_main"] + +[node name="Main" type="Node2D"] +script = ExtResource("1_main") + +[node name="Hint" type="Label" parent="."] +offset_left = 48.0 +offset_top = 48.0 +offset_right = 1232.0 +offset_bottom = 160.0 +text = "AGC · Godot 空白 2D 工程" +theme_override_font_sizes/font_size = 28 diff --git a/apps/ai-game-creator-shell/template-library/v1/godot-empty-2d/project/scripts/main.gd b/apps/ai-game-creator-shell/template-library/v1/godot-empty-2d/project/scripts/main.gd new file mode 100644 index 000000000..cadbd6801 --- /dev/null +++ b/apps/ai-game-creator-shell/template-library/v1/godot-empty-2d/project/scripts/main.gd @@ -0,0 +1,12 @@ +extends Node2D + +## AGC · Godot 空白 2D 工程 +## +## 只保留最小可运行骨架:一个 Node2D 根节点加一段说明文字。 +## 继续开发时把新场景放进 `scenes/`,其余节点从 `scenes/main.tscn` 挂载。 + +@onready var _hint: Label = $Hint + + +func _ready() -> void: + _hint.text = "AGC · Godot 空白 2D 工程\n把场景挂到 scenes/ 即可开始,入口在 project.godot 的 run/main_scene" diff --git a/apps/ai-game-creator-shell/template-library/v1/godot-empty-3d/cover.svg b/apps/ai-game-creator-shell/template-library/v1/godot-empty-3d/cover.svg new file mode 100644 index 000000000..c8ff1405e --- /dev/null +++ b/apps/ai-game-creator-shell/template-library/v1/godot-empty-3d/cover.svg @@ -0,0 +1,11 @@ + + + + + + + + Godot 空白 3D 场景 + Godot 4.7 · GDScript + 相机 · 平行光 · 天空环境 + diff --git a/apps/ai-game-creator-shell/template-library/v1/godot-empty-3d/meta.json b/apps/ai-game-creator-shell/template-library/v1/godot-empty-3d/meta.json new file mode 100644 index 000000000..3b3abd3ac --- /dev/null +++ b/apps/ai-game-creator-shell/template-library/v1/godot-empty-3d/meta.json @@ -0,0 +1,18 @@ +{ + "id": "godot-empty-3d", + "title": "Godot 空白 3D 场景", + "summary": "Godot 4.7 原生三维空白工程:相机、平行光、天空环境与一个自转立方体已就绪,可直接开始搭建场景。", + "tags": [ + "空白", + "起步工程", + "3d", + "godot" + ], + "runtime": "godot", + "engine": "godot", + "engineVersion": "4.7", + "templateVersion": "0.1.0", + "entry": "project.godot", + "coverWidth": 960, + "coverHeight": 540 +} diff --git a/apps/ai-game-creator-shell/template-library/v1/godot-empty-3d/project/.gitignore b/apps/ai-game-creator-shell/template-library/v1/godot-empty-3d/project/.gitignore new file mode 100644 index 000000000..a643a2581 --- /dev/null +++ b/apps/ai-game-creator-shell/template-library/v1/godot-empty-3d/project/.gitignore @@ -0,0 +1,6 @@ +# Godot 4+ 编辑器与导入缓存 +.godot/ + +# 导出产物 +export/ +build/ diff --git a/apps/ai-game-creator-shell/template-library/v1/godot-empty-3d/project/icon.svg b/apps/ai-game-creator-shell/template-library/v1/godot-empty-3d/project/icon.svg new file mode 100644 index 000000000..1f92223a4 --- /dev/null +++ b/apps/ai-game-creator-shell/template-library/v1/godot-empty-3d/project/icon.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/apps/ai-game-creator-shell/template-library/v1/godot-empty-3d/project/project.godot b/apps/ai-game-creator-shell/template-library/v1/godot-empty-3d/project/project.godot new file mode 100644 index 000000000..6a8bb2ea0 --- /dev/null +++ b/apps/ai-game-creator-shell/template-library/v1/godot-empty-3d/project/project.godot @@ -0,0 +1,16 @@ +; Engine configuration file. +; AGC 模板库:Godot 4.7 空白 3D 场景。解压后即为工程根。 + +config_version=5 + +[application] + +config/name="Godot 空白 3D 场景" +run/main_scene="res://scenes/main.tscn" +config/features=PackedStringArray("4.7", "GL Compatibility") +config/icon="res://icon.svg" + +[rendering] + +renderer/rendering_method="gl_compatibility" +renderer/rendering_method.mobile="gl_compatibility" diff --git a/apps/ai-game-creator-shell/template-library/v1/godot-empty-3d/project/scenes/main.tscn b/apps/ai-game-creator-shell/template-library/v1/godot-empty-3d/project/scenes/main.tscn new file mode 100644 index 000000000..cc3aa8ff1 --- /dev/null +++ b/apps/ai-game-creator-shell/template-library/v1/godot-empty-3d/project/scenes/main.tscn @@ -0,0 +1,43 @@ +[gd_scene load_steps=5 format=3] + +[ext_resource type="Script" path="res://scripts/main.gd" id="1_main"] + +[sub_resource type="Environment" id="Environment_main"] +background_mode = 1 +background_color = Color(0.09, 0.11, 0.16, 1) +ambient_light_source = 1 +ambient_light_color = Color(0.62, 0.71, 0.86, 1) +ambient_light_energy = 0.4 + +[sub_resource type="BoxMesh" id="BoxMesh_cube"] +size = Vector3(1.6, 1.6, 1.6) + +[sub_resource type="StandardMaterial3D" id="StandardMaterial3D_cube"] +albedo_color = Color(0.28, 0.55, 0.75, 1) +roughness = 0.4 + +[node name="Main" type="Node3D"] +script = ExtResource("1_main") + +[node name="WorldEnvironment" type="WorldEnvironment" parent="."] +environment = SubResource("Environment_main") + +[node name="DirectionalLight3D" type="DirectionalLight3D" parent="."] +transform = Transform3D(0.866025, -0.25, 0.433013, 0, 0.866025, 0.5, -0.5, -0.433013, 0.75, 3, 6, 4) + +[node name="Camera3D" type="Camera3D" parent="."] +transform = Transform3D(1, 0, 0, 0, 0.951057, 0.309017, 0, -0.309017, 0.951057, 0, 2.4, 6) + +[node name="Cube" type="MeshInstance3D" parent="."] +material_override = SubResource("StandardMaterial3D_cube") +mesh = SubResource("BoxMesh_cube") + +[node name="Hud" type="CanvasLayer" parent="."] + +[node name="Hint" type="Label" parent="Hud"] +offset_left = 40.0 +offset_top = 32.0 +offset_right = 1000.0 +offset_bottom = 96.0 +text = "AGC · Godot 空白 3D 场景" +theme_override_font_sizes/font_size = 26 diff --git a/apps/ai-game-creator-shell/template-library/v1/godot-empty-3d/project/scripts/main.gd b/apps/ai-game-creator-shell/template-library/v1/godot-empty-3d/project/scripts/main.gd new file mode 100644 index 000000000..bfe52ec9a --- /dev/null +++ b/apps/ai-game-creator-shell/template-library/v1/godot-empty-3d/project/scripts/main.gd @@ -0,0 +1,13 @@ +extends Node3D + +## AGC · Godot 空白 3D 场景 +## +## 只保留最小可运行骨架:相机、平行光、天空环境与一个缓慢自转的立方体。 +## 继续开发时把新场景放进 `scenes/`,或在 `Cube` 下挂模型与脚本。 + +@onready var _cube: MeshInstance3D = $Cube + + +func _process(delta: float) -> void: + _cube.rotate_y(delta * 0.6) + _cube.rotate_x(delta * 0.2) diff --git a/apps/ai-game-creator-shell/template-library/v1/godot-hello-world/cover.svg b/apps/ai-game-creator-shell/template-library/v1/godot-hello-world/cover.svg new file mode 100644 index 000000000..ae5f254b7 --- /dev/null +++ b/apps/ai-game-creator-shell/template-library/v1/godot-hello-world/cover.svg @@ -0,0 +1,12 @@ + + + + + + + + + Godot Hello World + Godot 4.7 · GDScript + 移动与收集 · 最小可玩示例 + diff --git a/apps/ai-game-creator-shell/template-library/v1/godot-hello-world/meta.json b/apps/ai-game-creator-shell/template-library/v1/godot-hello-world/meta.json new file mode 100644 index 000000000..ad0b84a73 --- /dev/null +++ b/apps/ai-game-creator-shell/template-library/v1/godot-hello-world/meta.json @@ -0,0 +1,18 @@ +{ + "id": "godot-hello-world", + "title": "Godot Hello World", + "summary": "Godot 4.7 最小可玩示例:方向键移动方块,碰到金色目标加分,空格重置;只用 GDScript 与内置输入动作。", + "tags": [ + "示例", + "起步工程", + "2d", + "godot" + ], + "runtime": "godot", + "engine": "godot", + "engineVersion": "4.7", + "templateVersion": "0.1.0", + "entry": "project.godot", + "coverWidth": 960, + "coverHeight": 540 +} diff --git a/apps/ai-game-creator-shell/template-library/v1/godot-hello-world/project/.gitignore b/apps/ai-game-creator-shell/template-library/v1/godot-hello-world/project/.gitignore new file mode 100644 index 000000000..a643a2581 --- /dev/null +++ b/apps/ai-game-creator-shell/template-library/v1/godot-hello-world/project/.gitignore @@ -0,0 +1,6 @@ +# Godot 4+ 编辑器与导入缓存 +.godot/ + +# 导出产物 +export/ +build/ diff --git a/apps/ai-game-creator-shell/template-library/v1/godot-hello-world/project/icon.svg b/apps/ai-game-creator-shell/template-library/v1/godot-hello-world/project/icon.svg new file mode 100644 index 000000000..b46265b60 --- /dev/null +++ b/apps/ai-game-creator-shell/template-library/v1/godot-hello-world/project/icon.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/apps/ai-game-creator-shell/template-library/v1/godot-hello-world/project/project.godot b/apps/ai-game-creator-shell/template-library/v1/godot-hello-world/project/project.godot new file mode 100644 index 000000000..4804660ed --- /dev/null +++ b/apps/ai-game-creator-shell/template-library/v1/godot-hello-world/project/project.godot @@ -0,0 +1,16 @@ +; Engine configuration file. +; AGC 模板库:Godot 4.7 二维移动与收集示例。解压后即为工程根。 + +config_version=5 + +[application] + +config/name="Godot Hello World" +run/main_scene="res://scenes/main.tscn" +config/features=PackedStringArray("4.7", "GL Compatibility") +config/icon="res://icon.svg" + +[rendering] + +renderer/rendering_method="gl_compatibility" +renderer/rendering_method.mobile="gl_compatibility" diff --git a/apps/ai-game-creator-shell/template-library/v1/godot-hello-world/project/scenes/main.tscn b/apps/ai-game-creator-shell/template-library/v1/godot-hello-world/project/scenes/main.tscn new file mode 100644 index 000000000..47043871e --- /dev/null +++ b/apps/ai-game-creator-shell/template-library/v1/godot-hello-world/project/scenes/main.tscn @@ -0,0 +1,34 @@ +[gd_scene load_steps=2 format=3] + +[ext_resource type="Script" path="res://scripts/main.gd" id="1_main"] + +[node name="Main" type="Node2D"] +script = ExtResource("1_main") + +[node name="Backdrop" type="Polygon2D" parent="."] +polygon = PackedVector2Array(0, 0, 1280, 0, 1280, 720, 0, 720) +color = Color(0.11, 0.15, 0.21, 1) + +[node name="Player" type="Node2D" parent="."] +position = Vector2(320, 360) + +[node name="Body" type="Polygon2D" parent="Player"] +polygon = PackedVector2Array(-24, -24, 24, -24, 24, 24, -24, 24) +color = Color(0.28, 0.55, 0.75, 1) + +[node name="Target" type="Node2D" parent="."] +position = Vector2(900, 240) + +[node name="Body" type="Polygon2D" parent="Target"] +polygon = PackedVector2Array(-18, -18, 18, -18, 18, 18, -18, 18) +color = Color(0.95, 0.76, 0.31, 1) + +[node name="Hud" type="CanvasLayer" parent="."] + +[node name="Score" type="Label" parent="Hud"] +offset_left = 40.0 +offset_top = 32.0 +offset_right = 900.0 +offset_bottom = 120.0 +text = "得分:0" +theme_override_font_sizes/font_size = 26 diff --git a/apps/ai-game-creator-shell/template-library/v1/godot-hello-world/project/scripts/main.gd b/apps/ai-game-creator-shell/template-library/v1/godot-hello-world/project/scripts/main.gd new file mode 100644 index 000000000..76a23f51b --- /dev/null +++ b/apps/ai-game-creator-shell/template-library/v1/godot-hello-world/project/scripts/main.gd @@ -0,0 +1,53 @@ +extends Node2D + +## AGC · Godot Hello World +## +## 最小可玩示例:方向键移动方块,碰到金色目标得分并随机刷新目标; +## 空格或回车重置得分。只使用 Godot 内置的 ui_* 输入动作,不需要额外输入映射。 + +const SPEED := 420.0 +const PICKUP_RADIUS := 42.0 +const PLAYER_BOUNDS := Rect2(28.0, 28.0, 1224.0, 664.0) + +var _score := 0 + +@onready var _player: Node2D = $Player +@onready var _target: Node2D = $Target +@onready var _hud: Label = $Hud/Score + + +func _ready() -> void: + _randomize_target() + _update_hud() + + +func _process(delta: float) -> void: + var direction := Input.get_vector("ui_left", "ui_right", "ui_up", "ui_down") + _player.position += direction * SPEED * delta + _player.position.x = clampf( + _player.position.x, PLAYER_BOUNDS.position.x, PLAYER_BOUNDS.end.x + ) + _player.position.y = clampf( + _player.position.y, PLAYER_BOUNDS.position.y, PLAYER_BOUNDS.end.y + ) + if _player.position.distance_to(_target.position) <= PICKUP_RADIUS: + _score += 1 + _randomize_target() + _update_hud() + + +func _unhandled_input(event: InputEvent) -> void: + if event.is_action_pressed("ui_accept"): + _score = 0 + _update_hud() + + +func _randomize_target() -> void: + _target.position = Vector2( + randf_range(PLAYER_BOUNDS.position.x, PLAYER_BOUNDS.end.x), + randf_range(PLAYER_BOUNDS.position.y, PLAYER_BOUNDS.end.y), + ) + + +func _update_hud() -> void: + _hud.text = "得分:%d\n方向键移动 · 空格/回车重置" % _score diff --git a/apps/ai-game-creator-shell/template-library/v1/godot-platformer-2d/cover.svg b/apps/ai-game-creator-shell/template-library/v1/godot-platformer-2d/cover.svg new file mode 100644 index 000000000..6ee82e63c --- /dev/null +++ b/apps/ai-game-creator-shell/template-library/v1/godot-platformer-2d/cover.svg @@ -0,0 +1,13 @@ + + + + + + + + + + Godot 2D 平台跳跃 + Godot 4.7 · GDScript + 角色 · 平台 · 相机跟随 · 金币 + diff --git a/apps/ai-game-creator-shell/template-library/v1/godot-platformer-2d/meta.json b/apps/ai-game-creator-shell/template-library/v1/godot-platformer-2d/meta.json new file mode 100644 index 000000000..4157bd441 --- /dev/null +++ b/apps/ai-game-creator-shell/template-library/v1/godot-platformer-2d/meta.json @@ -0,0 +1,18 @@ +{ + "id": "godot-platformer-2d", + "title": "Godot 2D 平台跳跃起步工程", + "summary": "Godot 4.7 原生二维平台跳跃骨架:CharacterBody2D 角色、地面与两级平台、相机跟随、三枚金币与重开,全部用 GDScript 与内置输入动作实现。", + "tags": [ + "起步工程", + "2d", + "平台跳跃", + "godot" + ], + "runtime": "godot", + "engine": "godot", + "engineVersion": "4.7", + "templateVersion": "0.1.0", + "entry": "project.godot", + "coverWidth": 960, + "coverHeight": 540 +} diff --git a/apps/ai-game-creator-shell/template-library/v1/godot-platformer-2d/project/.gitignore b/apps/ai-game-creator-shell/template-library/v1/godot-platformer-2d/project/.gitignore new file mode 100644 index 000000000..a643a2581 --- /dev/null +++ b/apps/ai-game-creator-shell/template-library/v1/godot-platformer-2d/project/.gitignore @@ -0,0 +1,6 @@ +# Godot 4+ 编辑器与导入缓存 +.godot/ + +# 导出产物 +export/ +build/ diff --git a/apps/ai-game-creator-shell/template-library/v1/godot-platformer-2d/project/icon.svg b/apps/ai-game-creator-shell/template-library/v1/godot-platformer-2d/project/icon.svg new file mode 100644 index 000000000..fd4ef4619 --- /dev/null +++ b/apps/ai-game-creator-shell/template-library/v1/godot-platformer-2d/project/icon.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/apps/ai-game-creator-shell/template-library/v1/godot-platformer-2d/project/project.godot b/apps/ai-game-creator-shell/template-library/v1/godot-platformer-2d/project/project.godot new file mode 100644 index 000000000..d6cb7b5b6 --- /dev/null +++ b/apps/ai-game-creator-shell/template-library/v1/godot-platformer-2d/project/project.godot @@ -0,0 +1,16 @@ +; Engine configuration file. +; AGC 模板库:Godot 4.7 二维平台跳跃起步工程。解压后即为工程根。 + +config_version=5 + +[application] + +config/name="Godot 2D 平台跳跃起步工程" +run/main_scene="res://scenes/main.tscn" +config/features=PackedStringArray("4.7", "GL Compatibility") +config/icon="res://icon.svg" + +[rendering] + +renderer/rendering_method="gl_compatibility" +renderer/rendering_method.mobile="gl_compatibility" diff --git a/apps/ai-game-creator-shell/template-library/v1/godot-platformer-2d/project/scenes/main.tscn b/apps/ai-game-creator-shell/template-library/v1/godot-platformer-2d/project/scenes/main.tscn new file mode 100644 index 000000000..4545bf2bd --- /dev/null +++ b/apps/ai-game-creator-shell/template-library/v1/godot-platformer-2d/project/scenes/main.tscn @@ -0,0 +1,97 @@ +[gd_scene load_steps=6 format=3] + +[ext_resource type="Script" path="res://scripts/main.gd" id="1_main"] +[ext_resource type="Script" path="res://scripts/player.gd" id="2_player"] + +[sub_resource type="RectangleShape2D" id="RectangleShape2D_player"] +size = Vector2(40, 56) + +[sub_resource type="RectangleShape2D" id="RectangleShape2D_ground"] +size = Vector2(1280, 64) + +[sub_resource type="RectangleShape2D" id="RectangleShape2D_platform"] +size = Vector2(280, 32) + +[node name="Main" type="Node2D"] +script = ExtResource("1_main") + +[node name="Backdrop" type="Polygon2D" parent="."] +polygon = PackedVector2Array(0, 0, 1280, 0, 1280, 720, 0, 720) +color = Color(0.11, 0.15, 0.21, 1) + +[node name="Ground" type="StaticBody2D" parent="."] +position = Vector2(640, 688) + +[node name="Shape" type="CollisionShape2D" parent="Ground"] +shape = SubResource("RectangleShape2D_ground") + +[node name="Visual" type="Polygon2D" parent="Ground"] +polygon = PackedVector2Array(-640, -32, 640, -32, 640, 32, -640, 32) +color = Color(0.2, 0.27, 0.35, 1) + +[node name="Platform" type="StaticBody2D" parent="."] +position = Vector2(360, 560) + +[node name="Shape" type="CollisionShape2D" parent="Platform"] +shape = SubResource("RectangleShape2D_platform") + +[node name="Visual" type="Polygon2D" parent="Platform"] +polygon = PackedVector2Array(-140, -16, 140, -16, 140, 16, -140, 16) +color = Color(0.24, 0.34, 0.44, 1) + +[node name="Platform2" type="StaticBody2D" parent="."] +position = Vector2(900, 470) + +[node name="Shape" type="CollisionShape2D" parent="Platform2"] +shape = SubResource("RectangleShape2D_platform") + +[node name="Visual" type="Polygon2D" parent="Platform2"] +polygon = PackedVector2Array(-140, -16, 140, -16, 140, 16, -140, 16) +color = Color(0.24, 0.34, 0.44, 1) + +[node name="Coins" type="Node2D" parent="."] + +[node name="Coin1" type="Node2D" parent="Coins"] +position = Vector2(360, 500) + +[node name="Visual" type="Polygon2D" parent="Coins/Coin1"] +polygon = PackedVector2Array(0, -16, 16, 0, 0, 16, -16, 0) +color = Color(0.95, 0.76, 0.31, 1) + +[node name="Coin2" type="Node2D" parent="Coins"] +position = Vector2(900, 410) + +[node name="Visual" type="Polygon2D" parent="Coins/Coin2"] +polygon = PackedVector2Array(0, -16, 16, 0, 0, 16, -16, 0) +color = Color(0.95, 0.76, 0.31, 1) + +[node name="Coin3" type="Node2D" parent="Coins"] +position = Vector2(1160, 620) + +[node name="Visual" type="Polygon2D" parent="Coins/Coin3"] +polygon = PackedVector2Array(0, -16, 16, 0, 0, 16, -16, 0) +color = Color(0.95, 0.76, 0.31, 1) + +[node name="Player" type="CharacterBody2D" parent="."] +position = Vector2(160, 560) +script = ExtResource("2_player") + +[node name="Shape" type="CollisionShape2D" parent="Player"] +shape = SubResource("RectangleShape2D_player") + +[node name="Visual" type="Polygon2D" parent="Player"] +polygon = PackedVector2Array(-20, -28, 20, -28, 20, 28, -20, 28) +color = Color(0.28, 0.55, 0.75, 1) + +[node name="Camera2D" type="Camera2D" parent="Player"] +position_smoothing_enabled = true + +[node name="Hud" type="CanvasLayer" parent="."] + +[node name="Status" type="Label" parent="Hud"] +offset_left = 40.0 +offset_top = 32.0 +offset_right = 1100.0 +offset_bottom = 130.0 +text = "已收集 0 / 3" +theme_override_font_sizes/font_size = 26 diff --git a/apps/ai-game-creator-shell/template-library/v1/godot-platformer-2d/project/scripts/main.gd b/apps/ai-game-creator-shell/template-library/v1/godot-platformer-2d/project/scripts/main.gd new file mode 100644 index 000000000..60c7593e0 --- /dev/null +++ b/apps/ai-game-creator-shell/template-library/v1/godot-platformer-2d/project/scripts/main.gd @@ -0,0 +1,51 @@ +extends Node2D + +## AGC · 平台跳跃起步工程 +## +## 收集三枚金币:踩平台跳上去即可。金币按玩家中心与目标的距离判定, +## 不依赖物理层设置,改关卡时直接挪 Coin 节点位置就行。Esc 重来。 + +const PICKUP_RADIUS := 34.0 +const SPAWN_POSITION := Vector2(160.0, 560.0) + +var _score := 0 + +@onready var _player: CharacterBody2D = $Player +@onready var _coins: Node2D = $Coins +@onready var _status: Label = $Hud/Status + + +func _ready() -> void: + _update_status() + + +func _process(_delta: float) -> void: + for coin in _coins.get_children(): + if not coin.visible: + continue + if _player.global_position.distance_to(coin.global_position) <= PICKUP_RADIUS: + coin.visible = false + _score += 1 + _update_status() + + +func _unhandled_input(event: InputEvent) -> void: + if event.is_action_pressed("ui_cancel"): + _restart() + + +func _restart() -> void: + for coin in _coins.get_children(): + coin.visible = true + _player.position = SPAWN_POSITION + _player.velocity = Vector2.ZERO + _score = 0 + _update_status() + + +func _update_status() -> void: + var total := _coins.get_child_count() + if _score >= total: + _status.text = "已收集 %d / %d —— 全部完成!Esc 重来" % [_score, total] + else: + _status.text = "已收集 %d / %d\n方向键移动 · 空格/回车跳跃 · Esc 重来" % [_score, total] diff --git a/apps/ai-game-creator-shell/template-library/v1/godot-platformer-2d/project/scripts/player.gd b/apps/ai-game-creator-shell/template-library/v1/godot-platformer-2d/project/scripts/player.gd new file mode 100644 index 000000000..cc01695ff --- /dev/null +++ b/apps/ai-game-creator-shell/template-library/v1/godot-platformer-2d/project/scripts/player.gd @@ -0,0 +1,23 @@ +extends CharacterBody2D + +## AGC · 平台跳跃玩家控制 +## +## 方向键左右移动,空格 / 回车跳跃。只使用 Godot 内置的 ui_* 输入动作; +## 需要 WASD 或手柄时在「项目设置 → 输入映射」里给这两个动作补事件即可。 + +const SPEED := 320.0 +const JUMP_VELOCITY := -700.0 +const GRAVITY := 1500.0 + + +func _physics_process(delta: float) -> void: + if not is_on_floor(): + velocity.y += GRAVITY * delta + if is_on_floor() and Input.is_action_just_pressed("ui_accept"): + velocity.y = JUMP_VELOCITY + var direction := Input.get_axis("ui_left", "ui_right") + if direction != 0.0: + velocity.x = direction * SPEED + else: + velocity.x = move_toward(velocity.x, 0.0, SPEED) + move_and_slide() diff --git a/docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md b/docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md index c45bb7ce1..cdf6b4e73 100644 --- a/docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md +++ b/docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md @@ -1,6 +1,6 @@ # AI 游戏创作项目开发工作台 PRD -更新时间:`2026-09-21`(2026-09-20 音频生成并入图片类那份后台任务账本、派生/修改类任务并入同一「生成任务」侧栏,2026-09-21 卡片浮层改为「提交即关」、**「生成任务」侧栏从画布左侧贴边改为画布右上角锚点(开关常驻)**、**资源卡可拖到对话栏批量 @ 引用**、**替换面板改为非模态浮层并支持在画布上点选目标**(验收现场两条修正:任务锚点改挂画布那一格、展开后开关让位),见 §3.10 / §5.3 / §7.8 / §7.9 与 [`【功能说明】AGC聊天素材引用`](../【功能说明】AGC聊天素材引用-2026-09-08.md);2026-09-14 图片类生成后台化:入口 IPC 为 `start_local_project_asset_generation` + 项目内任务账本 + 本地排队 + 非模态「生成任务」面板;2026-09-13 新增的功能画布底部工具栏入口矩阵 §3.10 / §7.9,以及右侧 Supervisor 对话气泡、可访问对比度与过程卡布局收口,资源卡预览、分区布局、非破坏性资源编辑、资源替换与 Godot 双根合同保持不变) +更新时间:`2026-09-21`(2026-09-20 音频生成并入图片类那份后台任务账本、派生/修改类任务并入同一「生成任务」侧栏,2026-09-21 卡片浮层改为「提交即关」、**「生成任务」侧栏从画布左侧贴边改为画布右上角锚点(开关常驻)**、**资源卡可拖到对话栏批量 @ 引用**、**替换面板改为非模态浮层并支持在画布上点选目标**(验收现场两条修正:任务锚点改挂画布那一格、展开后开关让位),见 §3.10 / §5.3 / §7.8 / §7.9 与 [`【功能说明】AGC聊天素材引用`](../【功能说明】AGC聊天素材引用-2026-09-08.md);2026-09-14 图片类生成后台化:入口 IPC 为 `start_local_project_asset_generation` + 项目内任务账本 + 本地排队 + 非模态「生成任务」面板;2026-09-13 新增的功能画布底部工具栏入口矩阵 §3.10 / §7.9,以及右侧 Supervisor 对话气泡、可访问对比度与过程卡布局收口,资源卡预览、分区布局、非破坏性资源编辑、资源替换与 Godot 双根合同保持不变;2026-09-21 Godot 工作区发现放宽:一层多命中按目录名排序取第一个,`project.godot` 允许是链接/reparse point/硬链接,见 §3.8) ## 1. 产品定位 @@ -136,7 +136,7 @@ ### 3.8 现有 Godot 项目 -- 首页和项目组统一使用“打开项目”,不提供独立 Godot 按钮。用户选择的目录作为工作区根;系统先检查根目录,再检查一层直接子目录中的普通文件 `project.godot`。根目录命中优先;一层发现多个 Godot 工程时必须提示歧义,不猜测目标。 +- 首页和项目组统一使用“打开项目”,不提供独立 Godot 按钮。用户选择的目录作为工作区根;系统先检查根目录,再检查一层直接子目录中的 `project.godot`。根目录命中优先;一层命中多个 Godot 工程时按目录名排序取第一个,不再因为存在第二个工程而失败或要求用户改选。`project.godot` 本身是符号链接、Windows reparse point 或硬链接时按链接目标判定,解析后是文件即命中;目录与悬空链接仍不算命中。 - 工作区根始终绑定文件读取、修改、命令 cwd、对话、Runtime 和最近项目记录;`.agent/`、Agent DB 与日志也只写在这里。`project.godot` 所在目录额外以 `godotProjectRoot` 记录为相对工作区的 `.` 或单层目录名,不把项目作用域切到 Godot 子目录。 - 首次打开只在工作区根创建 `.agent/` 元数据;已有 `.agent/manifest.json` 时读取并按当前唯一文件布局校准 `godotProjectRoot`。Godot 源码、场景、资源和项目配置继续使用原目录结构,不复制工程,也不建立 `game/`、`assets/`、`memory/`、`exports/` 平行目录。 - Godot 项目使用 `standard` Run Profile,避免套用 Web 原型的 `game/index.html`、本地 HTTP 预览和自主 Web 游戏完成门;本期不提供 Godot 内嵌运行预览。 diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 229407df7..5982eff9e 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -1,5 +1,23 @@ # 决策记录 +## 2026-09-21 Godot 模板入库与按模板建项的 Godot 分流 + +- 背景:模板库此前只有网页(html)、Cocos 与 Unity 占位,`runtime` 白名单早就接受 `godot`,但既没有 Godot 模板,也没有按模板建项的 Godot 分流——Godot 模板即使上传,建项也会落到 Web 分支,写出 `game/index.html` 占位入口并让 `godotProjectRoot` 为空。 +- 决策(模板内容):新增四个仓库内手写的 Godot 4.7 模板 `godot-empty-2d`、`godot-empty-3d`、`godot-hello-world`、`godot-platformer-2d`,`entry` 统一为 `project.godot`。模板只用内置 `ui_*` 输入动作、GL Compatibility 渲染,并用 `Polygon2D` / `BoxMesh` 搭可视骨架,不引入外部贴图或音频二进制;不打包 `.godot/` 缓存与导出产物。 +- 决策(建项分流):`create_project_from_installed_template_at` 在复制模板后按工程文件分流——Cocos 更新自身身份后走 Cocos 导入,Godot 先改写 `project.godot` 里 `[application]` 段的 `config/name`(只改这一行,其余字节逐字保留)再走既有 Godot 导入,写入 `godotProjectRoot: "."`,其余继续走 `init_local_game_project_at`。分流靠工程文件识别,不新增只读 `entry` 或 `runtime` 字段的契约。 +- 原因:Godot 工程身份是 `project.godot` 所在目录,与 Cocos 的 `package.json` 身份同一类问题;复用既有导入流程能同时拿到相对根记录、`.agent` 初始化与「不生成 Web 占位入口」这三条既有保证,并且与 Cocos 分支保持对称。 +- 影响范围:`apps/ai-game-creator-shell/template-library/v1/godot-*`(新增模板源)、`apps/ai-game-creator-shell/src-tauri/src/template_library.rs`、`.../src/project/manifest.rs`(`apply_godot_project_display_name`)、`.../src/project/manifest/import_tests.rs`、`docs/【模板规范】AGC模板包组织指南-2026-09-21.md`、`docs/technical/【技术方案】AGC模板库与模板建项-2026-09-17.md`。 +- 验证方式:本机 Godot `4.7.2.stable` 对四个模板逐一做「主场景实例化 + 3 帧 + GDScript `--check-only`」,平台跳跃模板另做真实物理试玩(落地 y=627.99、1 秒右移 320px、跳上平台 y=515.93、三枚金币全收集);Rust 定向回归 `import_tests::rewrites_only_the_godot_display_name_line`、`import_tests::keeps_a_godot_project_without_a_display_name_line_untouched`、`template_library::tests::godot_template_creates_native_project_with_relative_root_and_display_name` 与既有 Cocos/Web 建项回归;发布走 `--only godot-*` 定向合并。 + +## 2026-09-21 Godot 工作区发现放宽与内置插件行去掉手动启动 + +- 背景:2026-08-10 “发现与歧义决策”把「一层多命中」和「`project.godot` 必须是普通文件」两条定为失败关闭。这两种布局都会让整个工作区被判成「没有 Godot 工程」,而 `PluginHost::list` 只按发现结果过滤,结果是 Godot 内置插件在设置→扩展 里直接消失,用户看不到任何可诊断入口。 +- 决策(发现):根目录命中仍然优先;根未命中时一层直接子目录多命中改为按目录名排序取第一个,`godotProjectRoot` 记录该相对目录名,结果确定且可复现。`project.godot` 允许是符号链接 / Windows reparse point / 硬链接,判据改为「链接目标解析后是文件」,目录与悬空链接仍不算命中。工作区根本身是链接、候选子目录是链接、二层及更深不递归这三条边界不变。 +- 决策(界面):设置→扩展 的内置插件行去掉手动「启动 / 停止」按钮。启动本来就由项目切换时的 `startAvailableAgcEditorPlugins` 自动完成,手动按钮只是第二个可绕过入口;停止改走该行的启用/禁用开关,`set_agc_plugin_enabled(false)` 会停止插件并断开编辑器连接。导入扩展行的启动按钮保留,因为导入扩展没有自动启动路径。 +- 原因:Godot 插件列表是按项目过滤的,发现失败等于功能静默消失;把不确定性收敛成一个确定的排序选择,比让用户面对空列表更好。链接放宽只作用于只读的工程标记文件,受管描述文件、运行缓存与项目目录的链接拒绝规则不动。 +- 影响范围:`apps/ai-game-creator-shell/src-tauri/src/project/manifest.rs`、`.../src/commands.rs`(错误文案)、`.../src/project/manifest/import_tests.rs`、`.../src/tests/project.rs`、`apps/ai-game-creator-shell/src/features/runtime-config/RuntimeConfigDialog.tsx`、`docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md` §3.8、`docs/technical/【技术方案】AGC Godot编辑器插件接入-2026-09-20.md`、`docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md`。 +- 验证方式:`cargo test --locked -p genarrative-ai-game-creator-shell --bin genarrative-ai-game-creator-shell -- project::manifest::import_tests::` 与 `-- tests::project::`;前端 `agc:typecheck` 与 `runtime-settings` / `pluginHost` 套件。 + ## 2026-09-21 模板正文目录门禁:CLI 打包与后台上传同一份段名单 - 背景:模板包组织指南把 `.agent/`、`.git/`、`node_modules/`、根目录 `dist/` 等列为「不要放进 ZIP」,但两条发布路径此前只校验路径安全与 `entry` 是否存在,放进去的东西会跟着建到用户项目里(模板自带 `.agent/` 会让新项目继承一个陌生身份)。这条约定只靠作者自觉。 @@ -8239,7 +8257,7 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 ## 2026-08-10 AGC 打开现有 Godot 项目 - 项目双根决策(2026-08-14 更新):项目组只保留通用“打开项目 / 新建项目”,不再提供独立 Godot 入口。用户选择目录始终是工作区根,也是 `.agent`、Session、Runner、沙箱、通用文件工具和外围资料的唯一授权根;实际 Godot 根由根目录或一层直接子目录中的普通文件 `project.godot` 唯一确定,并以工作区相对 `godotProjectRoot` 记录,根目录使用 `.`。不得把 Runtime 根切换成 Godot 子目录,也不得复制工程或建立第二套工作区。 -- 发现与歧义决策:根目录命中优先;根未命中时只检查一层直接子目录,唯一命中才通过,多个命中在任何 `.agent` 写入前失败关闭。候选目录与工程文件拒绝符号链接和 Windows reparse point,二层及更深不递归。未来只有 Godot 专属命令显式使用经过校验的相对 Godot cwd。 +- 发现与歧义决策:根目录命中优先;根未命中时只检查一层直接子目录,唯一命中才通过,多个命中在任何 `.agent` 写入前失败关闭。候选目录与工程文件拒绝符号链接和 Windows reparse point,二层及更深不递归。未来只有 Godot 专属命令显式使用经过校验的相对 Godot cwd。(2026-09-21 部分取代:多命中改为按目录名排序取第一个,`project.godot` 允许链接并按目标判定;候选子目录与更深层的边界不变,见本文件「2026-09-21 Godot 工作区发现放宽与内置插件行去掉手动启动」。) - 元数据决策:首次导入只在工作区根创建并保留 `.agent/manifest.json`、`.agent/agent.db`、`.agent/logs/` 与 `.agent/runtime/`;不得创建默认 Web 原型的 `game/`、`assets/`、`memory/`、`exports/`。已有有效 `.agent` 项目继续复用身份;缺失或错误的可推导 `godotProjectRoot` 只在 Godot 打开边界按唯一文件布局校准,歧义时不改写。 - Windows 锁文件决策:提升权限进程新建 `.agent/.manifest.json.lock` 时,Windows 可能把 owner 设为 `Administrators`。仅在固定锁路径已取得不共享独占句柄并确认是普通、非 reparse、单链接文件后,才初始化为当前 `TokenUser`;随后再次复核句柄并执行原有 owner/DACL 校验,不放宽既有异常对象的安全规则。 - 运行决策:Godot 项目提交给 Project Supervisor 时使用 `standard` Run Profile,避免触发 Web 专用 `game/index.html`、HTTP preview 与自主 Web 完成门。Godot 编辑器启动和内嵌运行预览不在本切片范围。 diff --git a/docs/technical/【技术方案】AGC Godot编辑器插件接入-2026-09-20.md b/docs/technical/【技术方案】AGC Godot编辑器插件接入-2026-09-20.md index b33697696..5e3dbf114 100644 --- a/docs/technical/【技术方案】AGC Godot编辑器插件接入-2026-09-20.md +++ b/docs/technical/【技术方案】AGC Godot编辑器插件接入-2026-09-20.md @@ -3,7 +3,9 @@ > 文档状态:`current` > 规范关系:承接 AGC 通用插件宿主与编辑器适配主规范 -更新时间:`2026-09-20` +更新时间:`2026-09-21` + +2026-09-21 更新:解除工作区发现上的两条限制——一层子目录多命中时按目录名排序取第一个(不再整体失败关闭),`project.godot` 允许是符号链接 / Windows reparse point / 硬链接(按链接目标判定);同时设置→扩展 的内置插件行去掉手动「启动 / 停止」,启动统一由前端自动完成、停止改走该行启用开关。 ## 目标与边界 @@ -15,7 +17,7 @@ 将 Godot 编辑器操控接入现有 AGC PluginHost、EditorAdapter、Runner、内置插件开关、权限审计和 Agent 工具链。Windows x64 的 Godot 4.7 及以上标准编辑器是首个实现目标,实机验收使用 4.7.2;其他平台和 .NET 编辑器不得从该结果推断支持。 -初版工程路径支持 Windows 本地盘符目录;UNC/网络共享路径在准备描述文件前明确拒绝。链接/reparse point 继续按同一文件边界失败关闭。 +初版工程路径支持 Windows 本地盘符目录;UNC/网络共享路径在准备描述文件前明确拒绝。受管描述文件、缓存与运行缓存继续按同一文件边界对链接/reparse point 失败关闭;唯一例外是工作区发现读取的 `project.godot`,它允许是链接并按目标判定。 DLL 原件随 AGC 安装包放在插件资源目录中;Godot Windows 加载器会在被加载文件旁生成 `~DLL`,因此宿主在 AGC 私有配置目录的运行缓存中按编辑器实例和构建身份准备临时加载副本。AGC 向项目新增一个可扫描的受管 `.gdextension` 描述文件,通过绝对路径引用该实例的加载副本;Godot 可自动生成其同名 `.uid` 伴生文件。DLL 不复制进工程。重新聚焦 Godot 后,由官方文件扫描完成首次加载;不需要用户打开或运行脚本,不创建 EditorPlugin addon,不修改 project.godot 或业务场景文件。编译工具链只属于开发与打包环境,不要求终端用户安装编译器。 @@ -24,9 +26,9 @@ DLL 原件随 AGC 安装包放在插件资源目录中;Godot Windows 加载器 ## 入口与归属 - 插件 id 为 `agc-godot-editor`,适配器为 `godot-editor`;命令 `godot.editor.execute`、连接能力 `godot.editor.connection`,DirectProject 工具为 `agc_godot_execute`。复用已有扩展列表和启用开关,不建立平行插件管理页面。 -- 项目发现沿用现有 Godot 工作区合同:工作区根保持用户选定目录;实际 Godot 根由普通 project.godot 在根或唯一一层子目录中确定。准备描述文件和读取 Godot 缓存只作用于实际 Godot 根,通用文件工具/Runtime 的工作区根不改变。 +- 项目发现沿用现有 Godot 工作区合同:工作区根保持用户选定目录;实际 Godot 根由根目录或一层直接子目录中的 project.godot 确定,一层命中多个时按目录名排序取第一个(确定性,不再报歧义),`project.godot` 本身是链接时按链接目标判定。准备描述文件和读取 Godot 缓存只作用于实际 Godot 根,通用文件工具/Runtime 的工作区根不改变。 - 平台、内置开关、项目及目标身份必须在执行入口重新检查。插件只能处理宿主传入的当前受控项目,模型不能覆盖项目路径、DLL 路径、端口、令牌或目标实例。 -- Godot 的插件列表、启动和 Agent 工具目录继续按当前 Godot 项目过滤;Cocos/Unity 沿用各自不按工程类型过滤的合同。前端统一消费宿主列表自动启动三种编辑器插件,不在界面重复推断项目类型。Godot 的项目切换仍撤销旧插件上下文并停止旧实例。 +- Godot 的插件列表、启动和 Agent 工具目录继续按当前 Godot 项目过滤;Cocos/Unity 沿用各自不按工程类型过滤的合同。前端统一消费宿主列表自动启动三种编辑器插件,不在界面重复推断项目类型;设置→扩展 的内置插件行只保留启用/禁用与重载,不再提供手动启动/停止入口。Godot 的项目切换仍撤销旧插件上下文并停止旧实例。 - 插件启动先完成项目事件订阅并接收当前受控项目快照,再注册命令和连接能力;命令可见时必须已经具备执行上下文。订阅期间收到的新项目事件优先于迟到的初始快照。 - 只连接已打开且唯一匹配真实工程路径的 Godot Editor;校验 PID、进程启动身份、Godot 版本、握手中的工程路径与会话代次。多个候选、非编辑器、路径不符或已退出的进程均拒绝,不启动或关闭用户编辑器。 - GUI、DirectProject 和 Agent Runtime 的原生操作统一由长寿命 Runner 持有。项目切换使连接失效,迟到回执不能改变新项目状态。 diff --git a/docs/technical/【技术方案】AGC模板库与模板建项-2026-09-17.md b/docs/technical/【技术方案】AGC模板库与模板建项-2026-09-17.md index c320b6b0f..90aa75885 100644 --- a/docs/technical/【技术方案】AGC模板库与模板建项-2026-09-17.md +++ b/docs/technical/【技术方案】AGC模板库与模板建项-2026-09-17.md @@ -86,9 +86,11 @@ templates/ - 只释放本任务已明确获取且 owner 标识仍一致的锁。获取结果不明时不猜测删除。正文对象不可变,其写入失败可安全释放本任务的锁;清单 PUT 已发起后若遇到断连、超时或服务端 5xx 等不确定结果,必须保留锁并报错,防止旧在途请求晚于下一发布者写入。清单收到确定成功或确定拒绝响应后才进入正常解锁路径;不自动重发清单写入,也不凭一次 GET 猜测在途 PUT 已结束。遗留锁须在确认原请求及进程已终止或完成后由运维处理;释放失败必须报告,不伪装为发布成功。 - `--dry-run` 仅构造和读取合并计划,不读取凭据、不获取锁、不 PUT/DELETE。发布不删除历史对象,也不提供随发布清理的选项;旧客户端缓存和未完成下载可能仍引用旧键。 - 同一 ID、同一 `templateVersion` 的 ZIP 大小或摘要改变时,在上传正文前拒绝,要求更新模板版本;只把相同 ZIP 迁移到新键可保留版本。客户端现有 `/` 缓存行为不变。 -- 当前模板:`blank-web`(空白网页)、`blank-2d-canvas`(空白二维画布)、`blank-3d-scene`(空白三维场景)、`phaser-2d-starter`(Phaser 2D 起步工程)、`threejs-3d-starter`(Three.js 3D 起步工程),以及 Cocos Creator 3.8.8 的 `cocos-empty-2d`、`cocos-empty-3d`、`cocos-empty-3d-hq`、`cocos-hello-world`。 +- 当前模板:`blank-web`(空白网页)、`blank-2d-canvas`(空白二维画布)、`blank-3d-scene`(空白三维场景)、`phaser-2d-starter`(Phaser 2D 起步工程)、`threejs-3d-starter`(Three.js 3D 起步工程)、Cocos Creator 3.8.8 的 `cocos-empty-2d`、`cocos-empty-3d`、`cocos-empty-3d-hq`、`cocos-hello-world`,以及 Godot 4.7 的 `godot-empty-2d`、`godot-empty-3d`、`godot-hello-world`、`godot-platformer-2d`。 - Cocos 内容来自 Creator 3.8.8 随附的 `resources/templates/{empty-2d,empty,empty-quality,hello-3d-world}`,保留官方资源、`.meta`、设置和模板预设;补齐 `package.json.creator.version`,空模板以 `assets/.gitkeep` 保证资源目录进入 Git 和 ZIP。`entry` 为 `package.json`,不打包编辑器生成的缓存或用户项目数据。 - Cocos 建项在复制后按实际 `package.json.creator.version + assets/` 识别,复用既有 Cocos 导入流程,写入 `cocosProjectRoot: "."`;每个新项目重建 `package.json.uuid` 并写入所选项目名。只创建 `.agent` 管理目录,不生成 Web 占位入口;模板源与本机安装缓存不被改写。 +- Godot 内容为仓库内手写的 Godot 4.7 工程(`project.godot` + `scenes/` + `scripts/`,GL Compatibility 渲染,只用内置 `ui_*` 输入动作,不依赖外部贴图),`entry` 为 `project.godot`,不打包 `.godot/` 编辑器缓存与导出产物。 +- Godot 建项在复制后按 `project.godot` 识别,复用既有 Godot 导入流程,写入 `godotProjectRoot: "."`,并把工程显示名改写成用户选择的项目名(只改 `[application]` 段的 `config/name` 一行,其余字节逐字保留);不生成 `game/` 占位入口与 `assets/`、`memory/`、`exports/` 并行目录,也不改写模板源与本机安装缓存。 - 客户端可用 `AGC_TEMPLATE_LIBRARY_BASE_URL` 覆盖库地址;只接受 `https://agc-dev.oss-rg-china-mainland.aliyuncs.com`(拒绝其他主机、路径、http)。 ## 客户端实现 @@ -99,7 +101,7 @@ templates/ | --------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `fetch_game_template_library` | 读 `templates/index.json`(≤4 MiB),校验后缓存到 `/templates/index.json`;网络失败时回退本机缓存并在 `source` 标 `cache`。远端已经答话但正文不是合法 UTF-8 或不符合 schema、以及缓存自己损坏时,一律失败关闭,不用缓存掩盖远端错误 | | `download_game_template` | 取清单里对应条目,流式下载 zip(≤512 MiB),校验字节数与 SHA-256,解压到 `/templates/installed///`,最后写 `installed.json` 作为安装完成的唯一标记 | -| `create_automatic_local_game_project_from_template` | 需要时先安装模板,然后在自动工作区根目录下按既有自动工作区规则建目录:先复制模板文件;Cocos 项目更新自身身份后走既有 Cocos 导入,其余沿现有 `init_local_game_project_at` 初始化。根目录默认是 `/projects/`,用户可选 `projectsRoot` 覆盖(必须来自本机目录选择器并通过私有路径门禁),见 [`【实施计划】AGC项目创建目录可选-2026-09-17.md`](../project-memory/plans/【实施计划】AGC项目创建目录可选-2026-09-17.md) | +| `create_automatic_local_game_project_from_template` | 需要时先安装模板,然后在自动工作区根目录下按既有自动工作区规则建目录:先复制模板文件;Cocos 项目更新自身身份后走既有 Cocos 导入,Godot 项目改写工程显示名后走既有 Godot 导入,其余沿现有 `init_local_game_project_at` 初始化。根目录默认是 `/projects/`,用户可选 `projectsRoot` 覆盖(必须来自本机目录选择器并通过私有路径门禁),见 [`【实施计划】AGC项目创建目录可选-2026-09-17.md`](../project-memory/plans/【实施计划】AGC项目创建目录可选-2026-09-17.md) | 安全与健壮性: @@ -135,6 +137,8 @@ Cocos 回归分别覆盖仓库模板和线上真实 ZIP 的安装、连续建项 `2026-09-21` 模板正文目录门禁:CLI 打包(`readProjectFiles`)与后台上传(`validate_import_archive`)统一拒绝正文含 `.agent` / `.git` / `.svn` / `node_modules` 段(任意层级)与根目录 `dist` / `build` / `library` / `temp` / `local` / `.idea` / `.vscode` 的模板,两处用同一份段名单与同一句文案。同名目录段只在根目录受限:正文内 `game/dist/**` 与 `.gitignore` 仍合法(`.gitignore` 不等于 `.git`),Cocos 模板的 `.creator` / `.gitignore` 不受影响。证据:Node 发布回归 28 项通过(新增 1 项,13 条拒绝用例 + 2 条放行断言);Rust `admin_templates` 8 项回归通过(新增 `template_import_archive_rejects_identity_and_build_directories`);仓库现有 9 个模板源无一条命中门禁,本地重打包的 9 个 ZIP 摘要与线上 `index.json` 的 `zipSha256` 逐条一致(匿名读清单 200、9 个模板、0 个下架条目),即门禁未改变任何已发布字节。未执行真实 OSS 写入。 +`2026-09-21` Godot 模板入库:新增 `godot-empty-2d` / `godot-empty-3d` / `godot-hello-world` / `godot-platformer-2d` 四个仓库内手写模板,并给建项补 Godot 分流。模板内容用本机 Godot `4.7.2.stable` 逐项验证:四个模板的主场景都能实例化并跑满 3 帧、全部 GDScript 过 `--check-only`(同一条命令对故意写错的脚本报告 Parse Error,证明检查有效);平台跳跃模板另做真机物理试玩——角色落地 y=627.99(地面顶 656 − 半高 28)、按住右 1 秒位移 320px、连按跳跃后落在平台上 y=515.93(平台顶 544 − 28)、三枚金币全部收集后 HUD 变为「已收集 3 / 3 —— 全部完成!Esc 重来」。Rust 侧 4 项定向回归通过:`template_library::tests::godot_template_creates_native_project_with_relative_root_and_display_name`(Godot 模板建项得到 `godotProjectRoot: "."`、无 `game/` 占位入口、`config/name` 改写成所选名称且其余行逐字保留)、`import_tests::rewrites_only_the_godot_display_name_line`、`import_tests::keeps_a_godot_project_without_a_display_name_line_untouched`,以及既有 Cocos/Web 建项回归未受影响。发布保持 `--only` 定向:只新增四个 Godot 模板对象并重写清单,其余 9 个模板的键与版本不变;未在客户端「模板库」里实际建一次项目。 + ## 本地压测假数据注入(feature 控制) 模板库的数据源在 Rust 侧(清单校验、安装状态、下载与建项目都在这里),TS 只消费快照做渲染,所以假数据注入也放在 Rust 侧,走与真实完全一致的链路。 diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index 5417661f7..8c076c0e7 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -1,5 +1,16 @@ # AI 游戏创作智能体 App 实施计划 +## 2026-09-21 Godot 工作区发现放宽与内置插件行去掉手动启动 + +本节覆盖下文“打开项目自动识别 Godot”中的旧口径:判定从「唯一命中」放宽为「确定性命中」,`project.godot` 从「必须是普通文件」放宽为「按链接目标判定」。 + +| 要求 | 必须成立的行为 | 完成证据 | +| --- | --- | --- | +| 确定性多命中 | 根目录未命中时,一层直接子目录中多个 `project.godot` 命中按目录名排序取第一个;不再报「多个 Godot 项目」,也不再要求用户改选具体工程 | `cargo test --locked -p genarrative-ai-game-creator-shell --bin genarrative-ai-game-creator-shell -- project::manifest::import_tests::` 与 `-- tests::project::` | +| 链接标记 | `project.godot` 是符号链接 / Windows reparse point / 硬链接时按链接目标判定,目标解析为普通文件即命中;目录与悬空链接仍不算命中 | `import_tests::accepts_symbolic_link_project_marker`(unix)、`import_tests::accepts_windows_hard_link_project_marker`、`import_tests::accepts_windows_reparse_project_marker` | +| 保持不变的边界 | 工作区根本身是链接、候选子目录是链接、二层及更深目录不递归,这三条既有边界不动 | `import_tests::ignores_symbolic_link_child_candidate_without_writing_agent_metadata`、`import_tests::ignores_windows_reparse_child_candidate_without_writing_agent_metadata`、`import_tests::ignores_godot_projects_below_the_first_child_level` | +| 内置插件行 | 设置→扩展 的内置插件行不再渲染手动「启动 / 停止」按钮;启动由项目切换时的前端自动启动承担,停止走该行启用开关(禁用即停止并断开编辑器连接);导入扩展行的启动按钮保留 | `apps/ai-game-creator-shell/src/features/runtime-config/RuntimeConfigDialog.tsx`、`tests/pluginHost.test.ts` | + ## 2026-09-20 DirectProject 七项效率闭环(补齐合同) 本节补齐并覆盖下节中仅靠 Skill 要求预检、收尾、批读和原生命令预算的部分。完整目标仍为:自动预检、宿主验收与收尾、分层验证、统一执行/返修预算、稳定测试基线、请求耗时与批量读取、所有工具并行。已有代码及测试不等于全部目标已完成;按下表逐项验收。 diff --git a/docs/【模板规范】AGC模板包组织指南-2026-09-21.md b/docs/【模板规范】AGC模板包组织指南-2026-09-21.md index 933e30080..f92bd0183 100644 --- a/docs/【模板规范】AGC模板包组织指南-2026-09-21.md +++ b/docs/【模板规范】AGC模板包组织指南-2026-09-21.md @@ -39,6 +39,16 @@ assets/… # 可选 - 建项时会在复制模板文件之后补齐 `game/`、`assets/`、`memory/`、`memory/agents/`、`exports/`,并创建 `.agent/` 清单;这些目录不用在模板里手工占位。 - 模板没有 `game/index.html` 时,建项会写入一份默认入口占位页;所以 html 类模板应当自带 `game/index.html`,并把 `entry` 填成 `game/index.html`。根目录的 `index.html` 不会被当成游戏入口(建项仍会补默认 `game/index.html`),不要用这种结构。 +### Godot 项目(`runtime=godot`) + +- 根目录直接放 Godot 工程:`project.godot`(此时 `entry` 用 `project.godot`)、`scenes/`、`scripts/`、`icon.svg`、`.gitignore` 等,`.import` / `.uid` 之类由引擎生成的伴生文件不必手工准备。 +- `project.godot` 要能独立打开:`config_version=5`,且 `[application]` 段的 `run/main_scene` 指向工程内真实场景。建项会把 `config/name` 改写成用户选择的项目名(只改这一行),模板里的名字只是初始值。 +- 目标版本是 Godot 4.7:`config/features=PackedStringArray("4.7", "GL Compatibility")` 配套 `renderer/rendering_method="gl_compatibility"` 兼容面最大,2D 与轻量 3D 模板都用这一套。 +- 建项按 `project.godot` 识别 Godot 模板,写入 `godotProjectRoot: "."`,不生成 `game/` 占位入口与 `assets/`、`memory/`、`exports/` 并行目录。 +- 不要打包 `.godot/` 编辑器与导入缓存、导出产物(`export/`、`build/`);模板正文保持「克隆下来直接就能打开」的源状态。 +- 输入优先只用 Godot 内置的 `ui_*` 动作;需要自定义动作时把 `[input]` 段写进 `project.godot`,不要让模板依赖开发者本机的输入映射。 +- 建议不引入外部贴图与音频,用 `Polygon2D`、`ColorRect`、`BoxMesh` 一类节点搭出可见骨架,避免模板源里出现难以复核的二进制素材。 + ### Cocos Creator 项目(`runtime=cocos`) - 根目录直接放 Creator 工程:`package.json`(此时 `entry` 用 `package.json`)、`assets/`、`settings/`、`profiles/`、`.creator/`、`tsconfig.json`、`.gitignore` 等官方结构**原样保留**,`.meta` 与导入设置必须一起带上,否则导入后资源关系会丢。 @@ -94,7 +104,7 @@ assets/… # 可选 ## 发布前自检 -1. 核对 ZIP 结构:`unzip -l your-template.zip`,确认没有外层目录、`entry` 存在、没有 `.agent/`、`.git/`、`node_modules/`、`dist/`。 +1. 核对 ZIP 结构:`unzip -l your-template.zip`,确认没有外层目录、`entry` 存在、没有 `.agent/`、`.git/`、`node_modules/`、`dist/`(Godot 模板另需确认没有 `.godot/`)。 2. 核对体积与条目数(见上表)。 3. CLI 路径:`node scripts/agc-template-library-publish.mjs --source --dry-run` 查看合并计划与摘要,确认后再去掉 `--dry-run`。 4. 后台路径:上传页逐行核对 ID / 名称 / 版本 / 运行时 / entry,确认封面已按 ID 匹配。