修复策划切换游戏时清单版本错配
策划产物登记仅在实际变更时推进项目 revision 切换到 GameAgent 时保持 manifest 与 revision 配对 补充幂等切换回归测试与技术文档
This commit is contained in:
@@ -1142,7 +1142,9 @@ pub(crate) fn set_design_agent_runtime_mode(
|
||||
"design.runtime-mode",
|
||||
)?;
|
||||
if active_runtime.trim() == "game" {
|
||||
crate::assets::register_design_artifacts_at(root)?;
|
||||
if crate::assets::register_design_artifacts_at(root)? {
|
||||
advance_agent_runtime_project_revision_locked(root)?;
|
||||
}
|
||||
}
|
||||
write_design_runtime_mode(root, active_runtime.trim())
|
||||
}
|
||||
@@ -1378,6 +1380,38 @@ mod tests {
|
||||
let error = ensure_design_runtime_active(root).expect_err("game mode must reject design");
|
||||
assert!(error.contains("游戏运行态"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn switching_to_game_pairs_design_artifact_registration_with_revision() {
|
||||
let temporary = tempfile::tempdir().expect("create runtime mode root");
|
||||
let root = temporary.path();
|
||||
crate::project::init_local_game_project_at(root, "design-switch-test", "策划切换")
|
||||
.expect("init project");
|
||||
fs::create_dir_all(root.join("design_artifacts/project")).expect("create artifacts");
|
||||
fs::write(root.join("design_artifacts/project/design.md"), "设计内容")
|
||||
.expect("write artifact");
|
||||
|
||||
let before = read_game_creator_agent_runtime_project_revision(root)
|
||||
.expect("read initial revision")
|
||||
.revision;
|
||||
assert_eq!(
|
||||
set_design_agent_runtime_mode(root.to_string_lossy().into_owned(), "game".to_string(),)
|
||||
.expect("switch to game")
|
||||
.active_runtime,
|
||||
"game"
|
||||
);
|
||||
let after = read_game_creator_agent_runtime_project_revision(root)
|
||||
.expect("read committed revision")
|
||||
.revision;
|
||||
assert_eq!(after, before + 1);
|
||||
|
||||
set_design_agent_runtime_mode(root.to_string_lossy().into_owned(), "game".to_string())
|
||||
.expect("repeat switch to game");
|
||||
let repeated = read_game_creator_agent_runtime_project_revision(root)
|
||||
.expect("read repeated revision")
|
||||
.revision;
|
||||
assert_eq!(repeated, after);
|
||||
}
|
||||
use serde_json::json;
|
||||
use std::fs;
|
||||
|
||||
|
||||
@@ -604,10 +604,10 @@ pub(crate) fn register_local_asset_at(
|
||||
register_local_asset_entry(root, local_path, kind, media_type, id_prefix, source)
|
||||
}
|
||||
|
||||
pub(crate) fn register_design_artifacts_at(root: &Path) -> Result<usize, String> {
|
||||
pub(crate) fn register_design_artifacts_at(root: &Path) -> Result<bool, String> {
|
||||
let design_root = root.join("design_artifacts");
|
||||
if !design_root.exists() {
|
||||
return Ok(0);
|
||||
return Ok(false);
|
||||
}
|
||||
let mut files = Vec::new();
|
||||
let mut directories = vec![design_root];
|
||||
@@ -630,7 +630,7 @@ pub(crate) fn register_design_artifacts_at(root: &Path) -> Result<usize, String>
|
||||
}
|
||||
}
|
||||
files.sort();
|
||||
let mut registered = 0;
|
||||
let mut changed = false;
|
||||
for path in files {
|
||||
let relative = path
|
||||
.strip_prefix(root)
|
||||
@@ -644,7 +644,7 @@ pub(crate) fn register_design_artifacts_at(root: &Path) -> Result<usize, String>
|
||||
Some("yaml" | "yml") => "text/yaml",
|
||||
_ => "application/octet-stream",
|
||||
};
|
||||
register_local_asset_at(
|
||||
let (_, asset_changed) = register_local_asset_entry_with_change(
|
||||
root,
|
||||
&relative,
|
||||
"document",
|
||||
@@ -663,9 +663,9 @@ pub(crate) fn register_design_artifacts_at(root: &Path) -> Result<usize, String>
|
||||
reference_resource_ids: Vec::new(),
|
||||
},
|
||||
)?;
|
||||
registered += 1;
|
||||
changed |= asset_changed;
|
||||
}
|
||||
Ok(registered)
|
||||
Ok(changed)
|
||||
}
|
||||
|
||||
pub(crate) fn import_canvas_asset_at(
|
||||
@@ -1876,6 +1876,18 @@ pub(crate) fn register_local_asset_entry(
|
||||
id_prefix: &str,
|
||||
source: GameCreationAppAssetSource,
|
||||
) -> Result<UploadLocalAssetResult, String> {
|
||||
register_local_asset_entry_with_change(root, local_path, kind, media_type, id_prefix, source)
|
||||
.map(|(result, _)| result)
|
||||
}
|
||||
|
||||
fn register_local_asset_entry_with_change(
|
||||
root: &Path,
|
||||
local_path: &str,
|
||||
kind: &str,
|
||||
media_type: &str,
|
||||
id_prefix: &str,
|
||||
source: GameCreationAppAssetSource,
|
||||
) -> Result<(UploadLocalAssetResult, bool), String> {
|
||||
let normalized_path = normalize_relative_path(local_path)?;
|
||||
let absolute_path = resolve_local_project_path(root, &normalized_path)?;
|
||||
let manifest_path = root.join(".agent/manifest.json");
|
||||
@@ -1888,7 +1900,7 @@ pub(crate) fn register_local_asset_entry(
|
||||
let mut source_for_record = source.clone();
|
||||
source_for_record.prompt = None;
|
||||
|
||||
let (id, record_type) = mutate_manifest_at(root, |manifest| {
|
||||
let (id, record_type, changed) = mutate_manifest_at(root, |manifest| {
|
||||
if let Some(existing) = manifest
|
||||
.assets
|
||||
.iter_mut()
|
||||
@@ -1898,13 +1910,16 @@ pub(crate) fn register_local_asset_entry(
|
||||
// 而陈旧的非 unclassified 值会被读侧无条件信任(自愈只在落盘值是 unclassified
|
||||
// 时才触发),于是这个资产永远停在错误栏目。
|
||||
// kind 没变时刻意不动 category——落盘分类是权威值,同 kind 重登记不得抹掉它。
|
||||
let changed = existing.kind != kind
|
||||
|| existing.media_type != media_type
|
||||
|| existing.source != source;
|
||||
if existing.kind != kind {
|
||||
existing.kind = kind.to_string();
|
||||
existing.category = game_creation_app_asset_category_for_kind(kind);
|
||||
}
|
||||
existing.media_type = media_type.to_string();
|
||||
existing.source = source;
|
||||
Ok((existing.id.clone(), "asset.update"))
|
||||
Ok((existing.id.clone(), "asset.update", changed))
|
||||
} else {
|
||||
let id = format!(
|
||||
"{id_prefix}-{}-{}",
|
||||
@@ -1922,7 +1937,7 @@ pub(crate) fn register_local_asset_entry(
|
||||
tags: Vec::new(),
|
||||
source,
|
||||
});
|
||||
Ok((id, "asset.register"))
|
||||
Ok((id, "asset.register", true))
|
||||
}
|
||||
})?;
|
||||
append_agent_db_record(
|
||||
@@ -1937,12 +1952,15 @@ pub(crate) fn register_local_asset_entry(
|
||||
}),
|
||||
)?;
|
||||
|
||||
Ok(UploadLocalAssetResult {
|
||||
id,
|
||||
local_path: normalized_path.clone(),
|
||||
absolute_path: absolute_path.to_string_lossy().into_owned(),
|
||||
manifest_path: manifest_path.to_string_lossy().into_owned(),
|
||||
})
|
||||
Ok((
|
||||
UploadLocalAssetResult {
|
||||
id,
|
||||
local_path: normalized_path.clone(),
|
||||
absolute_path: absolute_path.to_string_lossy().into_owned(),
|
||||
manifest_path: manifest_path.to_string_lossy().into_owned(),
|
||||
},
|
||||
changed,
|
||||
))
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
@@ -2137,6 +2155,27 @@ mod tests {
|
||||
use super::*;
|
||||
use std::io::{Read, Write};
|
||||
|
||||
#[test]
|
||||
fn design_artifact_registration_reports_only_real_manifest_changes() {
|
||||
let temporary = tempfile::tempdir().expect("tempdir");
|
||||
let root = temporary.path();
|
||||
crate::project::init_local_game_project_at(root, "design-artifact-test", "策划产物登记")
|
||||
.expect("init project");
|
||||
fs::create_dir_all(root.join("design_artifacts/project")).expect("create artifacts");
|
||||
fs::write(root.join("design_artifacts/project/design.md"), "设计内容")
|
||||
.expect("write artifact");
|
||||
|
||||
assert!(register_design_artifacts_at(root).expect("register first time"));
|
||||
assert_eq!(
|
||||
read_existing_manifest_for_project(root)
|
||||
.unwrap()
|
||||
.assets
|
||||
.len(),
|
||||
1
|
||||
);
|
||||
assert!(!register_design_artifacts_at(root).expect("register idempotently"));
|
||||
}
|
||||
|
||||
/// 画板导出推断出的 kind 必须已经是 canonical 值。
|
||||
///
|
||||
/// 这个值会被原样写进 manifest 并据以派生落盘 `category`;一旦写出非 canonical 值
|
||||
|
||||
@@ -308,6 +308,7 @@
|
||||
|
||||
- 背景:立项策划 GDD 批准后需要给用户一个进入做游戏的自然出口,产品决策改为点击按钮后直接开始建造。
|
||||
- 决策:批准态 GDD 交付行提供“做成游戏”按钮。点击后读取当前项目的权威 `game/fast_gdd.md`,直接创建自动游戏工作区、导入 `text/markdown` 参考附件,并以固定建造指令自动启动 Direct Codex;不再回首页等待用户二次提交。该动作不复制原项目的 `approvedGddRef`、planning sidecar 或 approval receipt。
|
||||
- 补充:策划项目切换到 GameAgent 时,`design_artifacts` 的新增或登记信息实际变化必须与一次项目 revision 推进配对;重复切换不重复推进,避免 manifest 已变化而 revision 仍停留在旧值,触发前端同 revision 清单冲突提示。
|
||||
- 影响范围:AGC 前端 GDD 交付行与现有自动建项/附件导入/Direct Codex 链路;移除首页 RichInputArea 的 GDD 一次性预填链路;不新增 HTTP API、SpacetimeDB schema、迁移、OpenAPI 或正式构建绑定。
|
||||
- 验证方式:批准态按钮直接创建工作区、导入附件、携带固定首条指令进入项目工作台且重复点击不重复创建的 appSurface 回归;类型检查、编码检查和 `git diff --check` 通过。
|
||||
- 关联文档:`docs/technical/【技术方案】立项策划Agent(Fast GDD)-2026-08-10.md`。
|
||||
|
||||
@@ -342,7 +342,7 @@ UI 使用“批准”和“继续修改”两个文字按钮,分别配 Lucide
|
||||
|
||||
## 14. 开发调试入口
|
||||
|
||||
项目运行模式通过 `.agent/runtime-mode.json` 持久化。新建策划项目在进入工作台前写入 `design`;“做成游戏”写入 `game`。重新打开项目时通过 `get_design_agent_runtime_mode` 读取模式,完成后一次性挂载对应工作台,不能先挂载 GameAgent 再切回策划。旧项目缺少模式文件但存在策划会话时按 `design` 恢复;明确的 `game` 标记优先于残留策划会话。无模式也无策划会话的项目仍使用游戏工作台。
|
||||
项目运行模式通过 `.agent/runtime-mode.json` 持久化。新建策划项目在进入工作台前写入 `design`;“做成游戏”先登记 `design_artifacts` 中尚未登记或登记信息已变化的策划产物,若 manifest 实际发生变化则在同一项目写锁范围内推进一次项目 revision,再写入 `game`。重复切换不重复登记或推进 revision。重新打开项目时通过 `get_design_agent_runtime_mode` 读取模式,完成后一次性挂载对应工作台,不能先挂载 GameAgent 再切回策划。旧项目缺少模式文件但存在策划会话时按 `design` 恢复;明确的 `game` 标记优先于残留策划会话。无模式也无策划会话的项目仍使用游戏工作台。
|
||||
|
||||
开发构建的策划工作区页头在“刷新”旁提供“快速准备做成游戏测试”按钮。该入口与策划 Debug 日志共用 `GENARRATIVE_AGC_DESIGN_DEBUG=1` 开关:开关未启用时按钮不显示,命令也不可执行。入口仅进行本地 fixture 和会话状态写入,不调用 Provider;完成后自动刷新文件树与阶段,通过 `design-agent-update` 状态事件同步右侧审批/阶段操作区。随后仍需点击正常的“做成游戏”按钮执行资产登记与运行时切换。
|
||||
|
||||
|
||||
Reference in New Issue
Block a user