diff --git a/.codex/skills/genarrative-external-editor-api/references/capability-routing.md b/.codex/skills/genarrative-external-editor-api/references/capability-routing.md
index b89050080..6b7091c3c 100644
--- a/.codex/skills/genarrative-external-editor-api/references/capability-routing.md
+++ b/.codex/skills/genarrative-external-editor-api/references/capability-routing.md
@@ -80,6 +80,8 @@ Keep the existing autonomous-build task graph. Do not add a parallel task system
2. `design-foundation` generates `assets/ui-prototype.png` with `kind: "ui-design"`, using the registered art-spec resource ID in `referenceImageSrcs`.
3. `art-asset-plan` generates transparent `assets/art-spritesheet.png` through icon spritesheet generation, using the same registered art-spec resource ID as `referenceImageSrc` plus concrete `iconDescriptions`.
+For a playable Canvas game, do not stop at generation. Make `code-prototype` depend on `art-asset-plan` and consume the persisted `iconImageSrcs` slices for core players, blocks or targets, scene obstacles, and feedback. For the four-category game-chat contract, require exactly four slices before registering the local runtime sheet; both fewer and extra components fail closed. Treat `art-spec.png` as reference-only. A full-sheet `
`, CSS background, path-only mention, guessed equal-grid crop, or code-drawn replacement for core entities is not runtime asset use. If slicing produces `sliceWarning`, keep the complete transparent sheet as a valid editor artifact, but fail the playable game asset gate until real slice files or verified atlas coordinates exist; never invent coordinates or replace the icon-spritesheet route with ordinary image generation.
+
Never use `assets/ui-prototype.png` as the spritesheet visual-spec reference. UI extraction is outside this canonical DAG.
## Scope Boundary
diff --git a/apps/ai-game-creator-shell/src-tauri/prompts/runtime/manifest.json b/apps/ai-game-creator-shell/src-tauri/prompts/runtime/manifest.json
index c0acdb5eb..e16a9f558 100644
--- a/apps/ai-game-creator-shell/src-tauri/prompts/runtime/manifest.json
+++ b/apps/ai-game-creator-shell/src-tauri/prompts/runtime/manifest.json
@@ -1,11 +1,14 @@
{
"id": "genarrative.agent-runtime",
- "version": "2026-08-03.3",
+ "version": "2026-08-03.4",
"sections": {
"common": "common.md",
"isolatedAgentContract": "isolated-agent-contract.md",
"platformDefault": "platform/default.md",
"platformLinux": "platform/linux.md",
+ "roles": {
+ "codePrototypeGameChat": "roles/code-prototype-game-chat.md"
+ },
"supervisor": {
"intro": "supervisor/intro.md",
"visualContractWithoutEditor": "supervisor/visual-contract-without-editor.md",
diff --git a/apps/ai-game-creator-shell/src-tauri/prompts/runtime/roles/code-prototype-game-chat.md b/apps/ai-game-creator-shell/src-tauri/prompts/runtime/roles/code-prototype-game-chat.md
new file mode 100644
index 000000000..7c16a2da9
--- /dev/null
+++ b/apps/ai-game-creator-shell/src-tauri/prompts/runtime/roles/code-prototype-game-chat.md
@@ -0,0 +1,3 @@
+game-chat 使用素材完整快车道。必须先保护既有游戏语义和已完成产物:如果 observation 尚未包含 game/index.html 的当前正文与摘要,本响应第一步必须调用 file.read(path=game/index.html),不得猜测入口仍是初始化占位。只有文件缺失或正文与初始化页面一致、且当前 run 尚未写入项目时,才允许用一次 file.write 生成完整、自包含、可运行的 game/index.html;检测到非占位入口、已有可玩实现、当前 revision 已验证,或当前 run 已发生修改时,禁止整文件 file.write,必须保留原玩法,优先执行静态检查/试玩,确需修复时只能依据已读取的精确原文做最小 file.patch。不得把“继续”、continue 或其它纯续跑词当作游戏主题;缺少可继承的具体目标时必须失败关闭,不得另造新玩法。
+
+HTML 必须满足固定试玩合同,包含真实 Canvas 游戏循环、键盘与触控输入、开始、主要操作、重开、胜负状态和移动端布局。assets/art-spec.png 只能作为视觉规范与派生参考,不得在运行时加载、铺作背景或裁切实体。必须等待已登记且透明有效的 assets/art-spritesheet.png 与 assets/art-spritesheet-slices/manifest.json,从切片清单按 player、blocks-and-targets、obstacles-and-scene、feedback-effects 四种 usage 加载四个不同的独立透明素材,并在活动 Canvas 中通过 drawImage 绘制对应核心玩家、方块/目标、障碍/场景和反馈;不得猜测整张图集是等分网格,不得把整张图集作为 img、CSS background 或完整 drawImage 展示,也不得以纯代码几何替代核心实体。图集、四类切片或其可见使用任一缺失时不得交付。首次实现或最小修复后不要继续扩写功能;Runtime 会自动执行静态自检并在通过后立即试玩。
diff --git a/apps/ai-game-creator-shell/src-tauri/prompts/runtime/supervisor/visual-contract-with-editor.md b/apps/ai-game-creator-shell/src-tauri/prompts/runtime/supervisor/visual-contract-with-editor.md
index b0727c8e7..7b24ba9b2 100644
--- a/apps/ai-game-creator-shell/src-tauri/prompts/runtime/supervisor/visual-contract-with-editor.md
+++ b/apps/ai-game-creator-shell/src-tauri/prompts/runtime/supervisor/visual-contract-with-editor.md
@@ -1,3 +1,3 @@
-视觉产物始终按 owner 隔离:art-director 只声明 assets/art-spec.png,design-foundation 只声明 assets/ui-prototype.png,art-asset-plan 只声明 assets/manifest.art.json 与 assets/art-spritesheet.png;不得把 UI 与图集合并交给 art-director。旧派生图需要原位替换时,先在同一批次分别交给 design-foundation 与 art-asset-plan 建立精确原合同并取得 needs-repair,认领后再在同一批次分别发起各自唯一、完全继承原合同的 repair,两个 repair 共同构成一个显式视觉返工阶段。只有没有匹配专业角色、纯协调工作或一两步轻量读取时才由总控直接处理。
+视觉产物始终按 owner 隔离:art-director 只声明 assets/art-spec.png,design-foundation 只声明 assets/ui-prototype.png,art-asset-plan 只声明 assets/manifest.art.json 与 assets/art-spritesheet.png;不得把 UI 与图集合并交给 art-director。art-spec 只作规范/reference;art-asset-plan 必须以它的稳定资源 ID 走 icon-spritesheet 生成透明图集并持久化服务端 iconImageSrcs,code-prototype 必须等待切片清单并使用玩家、目标、场景和反馈四类独立素材,不能猜测图集网格或跳过为纯代码核心画面。旧派生图需要原位替换时,先在同一批次分别交给 design-foundation 与 art-asset-plan 建立精确原合同并取得 needs-repair,认领后再在同一批次分别发起各自唯一、完全继承原合同的 repair,两个 repair 共同构成一个显式视觉返工阶段。只有没有匹配专业角色、纯协调工作或一两步轻量读取时才由总控直接处理。
-art-director 是规范图产物型任务,expectedArtifacts 必须包含 assets/art-spec.png;design-foundation 是图片产物型任务,expectedArtifacts 必须包含 assets/ui-prototype.png;art-asset-plan 也是图片产物型任务,负责透明图集,expectedArtifacts 必须同时包含 assets/manifest.art.json 与 assets/art-spritesheet.png。三者都不能用空 expectedArtifacts 或纯文本回执代替图片。
+art-director 是规范图产物型任务,expectedArtifacts 必须包含 assets/art-spec.png;design-foundation 是图片产物型任务,expectedArtifacts 必须包含 assets/ui-prototype.png;art-asset-plan 也是图片产物型任务,负责透明图集,expectedArtifacts 必须同时包含 assets/manifest.art.json 与 assets/art-spritesheet.png。三者都不能用空 expectedArtifacts 或纯文本回执代替图片。code-prototype 必须依赖 art-asset-plan,并在活动 Canvas 中按 assets/art-spritesheet-slices/manifest.json 使用玩家、目标、场景和反馈四类独立切片;纯代码核心画面、猜测图集网格、整图展示和只引用路径均不满足交付。
diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/generation.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/generation.rs
index a643fc1d6..68665ace3 100644
--- a/apps/ai-game-creator-shell/src-tauri/src/agent/generation.rs
+++ b/apps/ai-game-creator-shell/src-tauri/src/agent/generation.rs
@@ -14,7 +14,8 @@ mod tests;
mod trace;
pub(in crate::agent) use canvas_generation::{
- commit_prepared_platform_art_asset_at, platform_art_generation_error_needs_reconciliation,
+ commit_prepared_platform_art_asset_at, commit_prepared_platform_art_asset_strict_slices_at,
+ platform_art_generation_error_needs_reconciliation,
request_platform_art_asset_with_runtime_options_at,
};
pub(in crate::agent) use draft_validation::validate_closed_game_script_blocks;
diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs
index 6f7f7d55f..718bf8f31 100644
--- a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs
+++ b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs
@@ -634,6 +634,16 @@ pub(crate) async fn generate_platform_art_asset_at(
.await
}
+struct PreparedPlatformArtAssetSlice {
+ name: String,
+ width: u32,
+ height: u32,
+ download: CanvasResourceDownload,
+ resource_id: Option,
+ asset_object_id: Option,
+ extension: String,
+}
+
pub(in crate::agent) struct PreparedPlatformArtAssetGeneration {
requested_output_path: Option,
replacement_fingerprint: Option,
@@ -648,12 +658,19 @@ pub(in crate::agent) struct PreparedPlatformArtAssetGeneration {
provider: Option,
warning: Option,
slice_warning: Option,
+ slices: Vec,
generation_route: String,
generation_kind: String,
reference_resource_ids: Vec,
extension: String,
}
+impl PreparedPlatformArtAssetGeneration {
+ pub(in crate::agent) fn slice_count(&self) -> usize {
+ self.slices.len()
+ }
+}
+
fn canonical_art_spec_reference_at(
root: &Path,
expected_canvas_project_id: &str,
@@ -700,10 +717,10 @@ fn canonical_art_spec_reference_at(
fn canonical_art_spritesheet_icon_descriptions(prompt: &str) -> Vec {
let project_context = truncate_inline(prompt.trim(), 320);
[
- "当前玩法的玩家主体、朝向或动作状态与可组合部件",
- "当前玩法的目标物、收集物、敌对实体或危险物",
- "当前玩法需要的地块、障碍、资源物件与场景装饰",
- "得分、受击、成长、失败、胜利与操作反馈特效",
+ "第 1 类(左上):当前玩法的玩家主体或主要操作对象;只生成一个轮廓连贯、可独立使用的完整素材",
+ "第 2 类(右上):当前玩法的方块、目标物、收集物、敌对实体或危险物;只生成一个完整素材",
+ "第 3 类(左下):当前玩法需要的地块、障碍、资源物件或场景装饰;只生成一个完整素材",
+ "第 4 类(右下):得分、受击、成长、失败、胜利或操作反馈特效;只生成一个完整素材",
]
.into_iter()
.map(|category| format!("{category};遵循同一项目视觉规范:{project_context}"))
@@ -742,6 +759,87 @@ fn platform_art_generation_warning(generated: &serde_json::Value) -> Option Result, String> {
+ let Some(icons) = generated
+ .get("iconImageSrcs")
+ .and_then(serde_json::Value::as_array)
+ else {
+ return Ok(Vec::new());
+ };
+ if icons.len() > 64 {
+ return Err("External Editor 返回的图集切片超过 64 个,已拒绝同步".to_string());
+ }
+ let mut prepared = Vec::with_capacity(icons.len());
+ let mut total_download_bytes = 0usize;
+ for (index, icon) in icons.iter().enumerate() {
+ let resource = icon
+ .get("resource")
+ .filter(|value| value.is_object())
+ .unwrap_or(icon);
+ let download = resolve_canvas_resource_download(client, api_base_url, api_key, resource)
+ .await?
+ .ok_or_else(|| format!("平台图集第 {} 个切片缺少可下载图片", index + 1))?;
+ total_download_bytes = total_download_bytes
+ .checked_add(download.bytes.len())
+ .ok_or_else(|| "平台图集切片累计大小溢出".to_string())?;
+ if total_download_bytes > 32 * 1024 * 1024 {
+ return Err("External Editor 返回的图集切片累计超过 32 MiB,已拒绝同步".to_string());
+ }
+ let decoded = image::load_from_memory(&download.bytes)
+ .map_err(|error| format!("平台图集第 {} 个切片无法解码:{error}", index + 1))?;
+ let declared_width = icon
+ .get("width")
+ .and_then(serde_json::Value::as_u64)
+ .and_then(|value| u32::try_from(value).ok())
+ .unwrap_or(decoded.width());
+ let declared_height = icon
+ .get("height")
+ .and_then(serde_json::Value::as_u64)
+ .and_then(|value| u32::try_from(value).ok())
+ .unwrap_or(decoded.height());
+ if declared_width != decoded.width() || declared_height != decoded.height() {
+ return Err(format!(
+ "平台图集第 {} 个切片尺寸与响应不一致:declared={}x{} actual={}x{}",
+ index + 1,
+ declared_width,
+ declared_height,
+ decoded.width(),
+ decoded.height()
+ ));
+ }
+ let source_hint = json_string_field(resource, "objectKey")
+ .or_else(|| json_string_field(resource, "imageSrc"))
+ .or_else(|| json_string_field(icon, "objectKey"))
+ .or_else(|| json_string_field(icon, "imageSrc"));
+ let extension = infer_file_extension(source_hint.as_deref(), &download.media_type);
+ if extension != "png" {
+ return Err(format!(
+ "平台图集第 {} 个切片不是 PNG,已拒绝写入游戏切片合同",
+ index + 1
+ ));
+ }
+ prepared.push(PreparedPlatformArtAssetSlice {
+ name: json_string_field(icon, "name")
+ .filter(|name| !name.trim().is_empty())
+ .unwrap_or_else(|| format!("素材 {}", index + 1)),
+ width: decoded.width(),
+ height: decoded.height(),
+ resource_id: json_string_field(resource, "resourceId")
+ .or_else(|| json_string_field(icon, "resourceId")),
+ asset_object_id: json_string_field(resource, "assetObjectId")
+ .or_else(|| json_string_field(icon, "assetObjectId")),
+ extension: extension.to_string(),
+ download,
+ });
+ }
+ Ok(prepared)
+}
+
pub(in crate::agent) async fn generate_platform_art_asset_with_options_at(
root: &Path,
prompt: &str,
@@ -1085,6 +1183,11 @@ pub(in crate::agent) async fn request_platform_art_asset_with_runtime_options_at
.get("sliceWarning")
.filter(|warning| !warning.is_null())
.and_then(|warning| json_string_field(warning, "reason"));
+ let slices = if is_canonical_art_spritesheet {
+ prepare_platform_art_spritesheet_slices(&client, &api_base_url, &api_key, generated).await?
+ } else {
+ Vec::new()
+ };
let warning = platform_art_generation_warning(generated);
let resource_id = json_string_field(resource, "resourceId");
let task_id =
@@ -1124,6 +1227,7 @@ pub(in crate::agent) async fn request_platform_art_asset_with_runtime_options_at
provider,
warning,
slice_warning,
+ slices,
generation_route,
generation_kind,
reference_resource_ids,
@@ -1152,6 +1256,23 @@ pub(in crate::agent) fn commit_prepared_platform_art_asset_at(
root,
prepared,
options,
+ false,
+ before_replace,
+ |_| Ok(()),
+ )
+}
+
+pub(in crate::agent) fn commit_prepared_platform_art_asset_strict_slices_at(
+ root: &Path,
+ prepared: PreparedPlatformArtAssetGeneration,
+ options: &PlatformArtAssetGenerationOptions,
+ before_replace: impl FnOnce(&Path) -> Result<(), String>,
+) -> Result {
+ commit_prepared_platform_art_asset_with_before_replace_hook(
+ root,
+ prepared,
+ options,
+ true,
before_replace,
|_| Ok(()),
)
@@ -1180,10 +1301,237 @@ fn move_platform_art_asset_without_replacing(
Ok(())
}
+fn write_new_platform_art_slice(path: &Path, bytes: &[u8]) -> Result {
+ let mut output = fs::OpenOptions::new();
+ output.write(true).create_new(true);
+ #[cfg(unix)]
+ {
+ use std::os::unix::fs::OpenOptionsExt;
+ output.custom_flags(libc::O_NOFOLLOW);
+ output.mode(0o600);
+ }
+ let mut output = match output.open(path) {
+ Ok(output) => output,
+ Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {
+ let existing = fs::read(path)
+ .map_err(|read_error| format!("回读既有平台图集切片失败:{read_error}"))?;
+ if existing == bytes {
+ return Ok(false);
+ }
+ return Err(format!(
+ "平台图集切片路径已存在且内容冲突:{}",
+ path.display()
+ ));
+ }
+ Err(error) => {
+ return Err(format!("创建平台图集切片失败:{}: {error}", path.display()));
+ }
+ };
+ output.write_all(bytes).map_err(|error| {
+ let _ = fs::remove_file(path);
+ format!("写入平台图集切片失败:{}: {error}", path.display())
+ })?;
+ Ok(true)
+}
+
+fn replace_platform_art_slice_file(path: &Path, bytes: &[u8], suffix: &str) -> Result<(), String> {
+ let file_name = path
+ .file_name()
+ .and_then(|value| value.to_str())
+ .unwrap_or("manifest.json");
+ let temporary = path.with_file_name(format!(".{file_name}.replacement.{suffix}"));
+ let backup = path.with_file_name(format!(".{file_name}.previous.{suffix}"));
+ write_new_platform_art_slice(&temporary, bytes)?;
+ let had_previous = path.is_file();
+ if had_previous {
+ if let Err(error) = fs::rename(path, &backup) {
+ let _ = fs::remove_file(&temporary);
+ return Err(format!("准备替换平台图集切片文件失败:{error}"));
+ }
+ }
+ if let Err(error) = fs::rename(&temporary, path) {
+ if had_previous {
+ let _ = fs::rename(&backup, path);
+ }
+ let _ = fs::remove_file(&temporary);
+ return Err(format!("安装平台图集切片文件失败:{error}"));
+ }
+ if had_previous {
+ let _ = fs::remove_file(backup);
+ }
+ Ok(())
+}
+
+struct PlatformArtSliceContractRollback {
+ snapshots: Vec<(PathBuf, Option>)>,
+ suffix: String,
+ armed: bool,
+}
+
+impl PlatformArtSliceContractRollback {
+ fn capture(root: &Path, suffix: &str) -> Result {
+ let paths = [
+ "assets/art-spritesheet-slices/player.png",
+ "assets/art-spritesheet-slices/blocks-and-targets.png",
+ "assets/art-spritesheet-slices/obstacles-and-scene.png",
+ "assets/art-spritesheet-slices/feedback-effects.png",
+ "assets/art-spritesheet-slices/manifest.json",
+ ];
+ let snapshots = paths
+ .into_iter()
+ .map(|local_path| {
+ let path = resolve_local_project_path(root, local_path)?;
+ let bytes = match fs::read(&path) {
+ Ok(bytes) => Some(bytes),
+ Err(error) if error.kind() == std::io::ErrorKind::NotFound => None,
+ Err(error) => {
+ return Err(format!(
+ "读取既有平台图集切片合同失败:{}: {error}",
+ path.display()
+ ));
+ }
+ };
+ Ok((path, bytes))
+ })
+ .collect::, String>>()?;
+ Ok(Self {
+ snapshots,
+ suffix: format!("rollback.{suffix}"),
+ armed: true,
+ })
+ }
+
+ fn disarm(&mut self) {
+ self.armed = false;
+ }
+
+ fn restore(&mut self) {
+ if !self.armed {
+ return;
+ }
+ for (path, bytes) in &self.snapshots {
+ match bytes {
+ Some(bytes) => {
+ let _ = replace_platform_art_slice_file(path, bytes, &self.suffix);
+ }
+ None => {
+ let _ = fs::remove_file(path);
+ }
+ }
+ }
+ self.armed = false;
+ }
+}
+
+impl Drop for PlatformArtSliceContractRollback {
+ fn drop(&mut self) {
+ self.restore();
+ }
+}
+
+fn commit_prepared_platform_art_slices_at(
+ root: &Path,
+ slices: Vec,
+ generation_key: &str,
+ source_resource_id: Option<&str>,
+ suffix: &str,
+) -> Result, String> {
+ if slices.is_empty() {
+ return Ok(Vec::new());
+ }
+ let directory = format!(
+ "assets/art-spritesheet-slices/{}",
+ sanitize_file_name(generation_key)
+ );
+ let directory_path = resolve_local_project_path(root, &directory)?;
+ fs::create_dir_all(&directory_path).map_err(|error| {
+ format!(
+ "创建平台图集切片目录失败:{}: {error}",
+ directory_path.display()
+ )
+ })?;
+ let mut generated = Vec::with_capacity(slices.len());
+ let mut created_paths = Vec::with_capacity(slices.len());
+ let mut slice_paths = Vec::with_capacity(slices.len());
+ for (index, slice) in slices.into_iter().enumerate() {
+ let local_path = format!("{directory}/{:02}.{}", index + 1, slice.extension);
+ let absolute_path = resolve_local_project_path(root, &local_path)?;
+ let created = match write_new_platform_art_slice(&absolute_path, &slice.download.bytes) {
+ Ok(created) => created,
+ Err(error) => {
+ for created in &created_paths {
+ let _ = fs::remove_file(created);
+ }
+ return Err(error);
+ }
+ };
+ if created {
+ created_paths.push(absolute_path.clone());
+ }
+ slice_paths.push(absolute_path);
+ generated.push(GeneratedPlatformArtAssetSlice {
+ name: slice.name,
+ width: slice.width,
+ height: slice.height,
+ local_path,
+ resource_id: slice.resource_id,
+ asset_object_id: slice.asset_object_id,
+ });
+ }
+ let usages = [
+ "player",
+ "blocks-and-targets",
+ "obstacles-and-scene",
+ "feedback-effects",
+ ];
+ for (index, usage) in usages.iter().enumerate() {
+ let Some(slice) = generated.get_mut(index) else {
+ break;
+ };
+ let canonical_local_path = format!("assets/art-spritesheet-slices/{usage}.png");
+ let canonical_path = resolve_local_project_path(root, &canonical_local_path)?;
+ replace_platform_art_slice_file(
+ &canonical_path,
+ &fs::read(&slice_paths[index]).map_err(|error| {
+ format!(
+ "回读平台图集切片失败:{}: {error}",
+ slice_paths[index].display()
+ )
+ })?,
+ suffix,
+ )?;
+ slice.local_path = canonical_local_path;
+ }
+ let manifest = serde_json::json!({
+ "schemaVersion": "game-art-slices.v1",
+ "source": "assets/art-spritesheet.png",
+ "sourceResourceId": source_resource_id,
+ "slices": generated.iter().enumerate().map(|(index, slice)| serde_json::json!({
+ "name": slice.name,
+ "path": slice.local_path,
+ "width": slice.width,
+ "height": slice.height,
+ "usage": usages.get(index).copied(),
+ })).collect::>(),
+ });
+ let manifest_bytes = serde_json::to_vec_pretty(&manifest)
+ .map_err(|error| format!("序列化平台图集切片清单失败:{error}"))?;
+ let manifest_path =
+ resolve_local_project_path(root, "assets/art-spritesheet-slices/manifest.json")?;
+ if let Err(error) = replace_platform_art_slice_file(&manifest_path, &manifest_bytes, suffix) {
+ for created in &created_paths {
+ let _ = fs::remove_file(created);
+ }
+ return Err(error);
+ }
+ Ok(generated)
+}
+
fn commit_prepared_platform_art_asset_with_before_replace_hook(
root: &Path,
prepared: PreparedPlatformArtAssetGeneration,
options: &PlatformArtAssetGenerationOptions,
+ require_complete_core_slices: bool,
before_replace: impl FnOnce(&Path) -> Result<(), String>,
after_backup_before_install: impl FnOnce(&Path) -> Result<(), String>,
) -> Result {
@@ -1200,7 +1548,8 @@ fn commit_prepared_platform_art_asset_with_before_replace_hook(
model,
provider,
warning,
- slice_warning,
+ mut slice_warning,
+ slices,
generation_route,
generation_kind,
reference_resource_ids,
@@ -1240,6 +1589,23 @@ fn commit_prepared_platform_art_asset_with_before_replace_hook(
(local_path, absolute_path)
}
};
+ if require_complete_core_slices && slices.len() != 4 {
+ return Err(format!(
+ "game-chat 图集必须恰好包含 4 个独立切片,实际为 {} 个",
+ slices.len()
+ ));
+ }
+ if require_complete_core_slices
+ && resource_id
+ .as_deref()
+ .map(str::trim)
+ .filter(|resource_id| !resource_id.is_empty())
+ .is_none()
+ {
+ return Err(
+ "game-chat 图集必须包含稳定的 Canvas resourceId,已在本地落盘前拒绝提交".to_string(),
+ );
+ }
if let Some(parent) = absolute_path.parent() {
fs::create_dir_all(parent)
.map_err(|error| format!("创建平台生成素材目录失败:{}: {error}", parent.display()))?;
@@ -1253,6 +1619,41 @@ fn commit_prepared_platform_art_asset_with_before_replace_hook(
.unwrap_or_default()
.as_nanos()
);
+ let mut before_replace = Some(before_replace);
+ if require_complete_core_slices && options.replace_existing {
+ let authorize_replace = before_replace
+ .take()
+ .expect("strict replacement authorization is available");
+ if let Err(error) = authorize_replace(&absolute_path) {
+ return Err(format!("准备替换平台生成素材失败:{error}"));
+ }
+ let final_fingerprint = read_existing_platform_art_asset_fingerprint(root, &local_path);
+ if final_fingerprint.as_ref().ok() != replacement_fingerprint.as_ref() {
+ return Err("待替换图片在提交临界点发生变化,已拒绝覆盖".to_string());
+ }
+ }
+ let mut strict_slice_rollback = if require_complete_core_slices {
+ Some(PlatformArtSliceContractRollback::capture(
+ root,
+ &replacement_suffix,
+ )?)
+ } else {
+ None
+ };
+ let (slices, strict_generated_slices) = if require_complete_core_slices {
+ (
+ Vec::new(),
+ commit_prepared_platform_art_slices_at(
+ root,
+ slices,
+ file_stem,
+ resource_id.as_deref(),
+ &replacement_suffix,
+ )?,
+ )
+ } else {
+ (slices, Vec::new())
+ };
let output_path = if options.replace_existing {
absolute_path.with_file_name(format!(
".{}.replacement.{replacement_suffix}",
@@ -1290,14 +1691,16 @@ fn commit_prepared_platform_art_asset_with_before_replace_hook(
))
});
if let Some(backup_path) = replacement_backup_path.as_ref() {
- if let Err(error) = before_replace(&absolute_path) {
- let _ = fs::remove_file(&output_path);
- return Err(format!("准备替换平台生成素材失败:{error}"));
- }
- let final_fingerprint = read_existing_platform_art_asset_fingerprint(root, &local_path);
- if final_fingerprint.as_ref().ok() != replacement_fingerprint.as_ref() {
- let _ = fs::remove_file(&output_path);
- return Err("待替换图片在提交临界点发生变化,已拒绝覆盖".to_string());
+ if let Some(authorize_replace) = before_replace.take() {
+ if let Err(error) = authorize_replace(&absolute_path) {
+ let _ = fs::remove_file(&output_path);
+ return Err(format!("准备替换平台生成素材失败:{error}"));
+ }
+ let final_fingerprint = read_existing_platform_art_asset_fingerprint(root, &local_path);
+ if final_fingerprint.as_ref().ok() != replacement_fingerprint.as_ref() {
+ let _ = fs::remove_file(&output_path);
+ return Err("待替换图片在提交临界点发生变化,已拒绝覆盖".to_string());
+ }
}
if let Err(error) = fs::rename(&absolute_path, backup_path) {
let _ = fs::remove_file(&output_path);
@@ -1373,6 +1776,30 @@ fn commit_prepared_platform_art_asset_with_before_replace_hook(
return Err(error);
}
};
+ let generated_slices = if require_complete_core_slices {
+ strict_generated_slices
+ } else {
+ match commit_prepared_platform_art_slices_at(
+ root,
+ slices,
+ file_stem,
+ resource_id.as_deref(),
+ &replacement_suffix,
+ ) {
+ Ok(slices) => slices,
+ Err(error) => {
+ slice_warning = Some(
+ slice_warning
+ .map(|warning| format!("{warning};本地切片持久化失败:{error}"))
+ .unwrap_or_else(|| format!("本地切片持久化失败:{error}")),
+ );
+ Vec::new()
+ }
+ }
+ };
+ if let Some(rollback) = strict_slice_rollback.as_mut() {
+ rollback.disarm();
+ }
if let Some(backup_path) = replacement_backup_path.as_ref() {
let _ = fs::remove_file(backup_path);
}
@@ -1391,6 +1818,14 @@ fn commit_prepared_platform_art_asset_with_before_replace_hook(
"assetFolderId": canvas_context.asset_folder_id,
"canvasName": canvas_context.canvas_name,
"sliceWarning": slice_warning.clone(),
+ "slices": generated_slices.iter().map(|slice| serde_json::json!({
+ "name": slice.name,
+ "localPath": slice.local_path,
+ "width": slice.width,
+ "height": slice.height,
+ "resourceId": slice.resource_id,
+ "assetObjectId": slice.asset_object_id,
+ })).collect::>(),
"generationRoute": generation_route,
"generationKind": generation_kind,
"referenceResourceIds": reference_resource_ids,
@@ -1398,6 +1833,7 @@ fn commit_prepared_platform_art_asset_with_before_replace_hook(
)?;
Ok(GeneratedPlatformArtAsset {
asset: registered,
+ slices: generated_slices,
resource_id,
asset_object_id,
task_id,
@@ -1537,6 +1973,56 @@ mod canvas_generation_tests {
}
}
+ #[tokio::test]
+ async fn prepares_all_external_icon_image_slices_for_local_persistence() {
+ let listener =
+ std::net::TcpListener::bind("127.0.0.1:0").expect("bind icon slice download fixture");
+ let base_url = format!("http://{}", listener.local_addr().expect("fixture address"));
+ let png = rgba_test_png(120).bytes;
+ let server = std::thread::spawn(move || {
+ for _ in 0..4 {
+ let (mut stream, _) = listener.accept().expect("accept slice download");
+ let _ = read_test_http_request(&mut stream);
+ write!(
+ stream,
+ "HTTP/1.1 200 OK\r\nContent-Type: image/png\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
+ png.len()
+ )
+ .expect("write slice response headers");
+ stream.write_all(&png).expect("write slice response body");
+ }
+ });
+ let generated = serde_json::json!({
+ "iconImageSrcs": (0..4).map(|index| serde_json::json!({
+ "name": format!("素材 {}", index + 1),
+ "imageSrc": format!("{base_url}/slice-{index}.png"),
+ "width": 1,
+ "height": 1,
+ "resource": {
+ "resourceId": format!("slice-resource-{index}"),
+ "assetObjectId": format!("slice-object-{index}"),
+ "imageSrc": format!("{base_url}/slice-{index}.png"),
+ }
+ })).collect::>()
+ });
+
+ let slices = prepare_platform_art_spritesheet_slices(
+ &reqwest::Client::new(),
+ &base_url,
+ "test-api-key",
+ &generated,
+ )
+ .await
+ .expect("prepare external icon slices");
+ server.join().expect("join slice download fixture");
+
+ assert_eq!(slices.len(), 4);
+ assert_eq!(slices[0].name, "素材 1");
+ assert_eq!(slices[0].resource_id.as_deref(), Some("slice-resource-0"));
+ assert_eq!(slices[3].asset_object_id.as_deref(), Some("slice-object-3"));
+ assert!(slices.iter().all(|slice| slice.extension == "png"));
+ }
+
#[tokio::test]
async fn generation_submit_response_loss_is_not_retried() {
let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind retry fixture");
@@ -2495,6 +2981,7 @@ mod canvas_generation_tests {
provider: Some("test-provider".to_string()),
warning: None,
slice_warning: None,
+ slices: Vec::new(),
generation_route: "/api/external/v1/editor/icon-spritesheets/generations".to_string(),
generation_kind: "icon-spritesheet".to_string(),
reference_resource_ids: vec!["art-spec-resource".to_string()],
@@ -2502,6 +2989,269 @@ mod canvas_generation_tests {
}
}
+ fn prepared_replacement_with_core_slices(
+ root: &Path,
+ bytes: &[u8],
+ ) -> PreparedPlatformArtAssetGeneration {
+ let mut prepared = prepared_replacement(root, bytes);
+ prepared.slices = ["玩家主体", "目标物", "场景障碍", "反馈特效"]
+ .into_iter()
+ .enumerate()
+ .map(|(index, name)| PreparedPlatformArtAssetSlice {
+ name: name.to_string(),
+ width: 1,
+ height: 1,
+ download: rgba_test_png(100 + index as u8),
+ resource_id: Some(format!("replacement-slice-resource-{index}")),
+ asset_object_id: Some(format!("replacement-slice-object-{index}")),
+ extension: "png".to_string(),
+ })
+ .collect();
+ prepared
+ }
+
+ fn existing_core_slice_contract(root: &Path) -> Vec<(PathBuf, Vec)> {
+ let directory = root.join("assets/art-spritesheet-slices");
+ fs::create_dir_all(&directory).expect("create existing slice contract directory");
+ let mut snapshots = [
+ "player.png",
+ "blocks-and-targets.png",
+ "obstacles-and-scene.png",
+ "feedback-effects.png",
+ ]
+ .into_iter()
+ .enumerate()
+ .map(|(index, name)| {
+ let path = directory.join(name);
+ let bytes = format!("old-slice-{index}").into_bytes();
+ fs::write(&path, &bytes).expect("write existing canonical slice");
+ (path, bytes)
+ })
+ .collect::>();
+ let manifest_path = directory.join("manifest.json");
+ let manifest_bytes = b"old-slice-manifest".to_vec();
+ fs::write(&manifest_path, &manifest_bytes).expect("write existing slice manifest");
+ snapshots.push((manifest_path, manifest_bytes));
+ snapshots
+ }
+
+ fn assert_core_slice_contract_unchanged(snapshots: &[(PathBuf, Vec)]) {
+ for (path, expected) in snapshots {
+ assert_eq!(
+ fs::read(path).expect("read preserved slice contract file"),
+ *expected,
+ "{} changed before the spritesheet replacement was authorized",
+ path.display()
+ );
+ }
+ }
+
+ #[test]
+ fn strict_slice_commit_rejects_non_four_slice_results_before_replacing_the_sheet() {
+ let temporary = tempfile::tempdir().expect("create strict slice project");
+ let root = temporary.path();
+ init_local_game_project_at(root, "strict-slices", "严格切片测试")
+ .expect("init strict slice project");
+ let path = root.join("assets/art-spritesheet.png");
+ fs::write(&path, b"old-image").expect("write old spritesheet");
+ let prepared = prepared_replacement(root, b"new-image");
+
+ let error = commit_prepared_platform_art_asset_strict_slices_at(
+ root,
+ prepared,
+ &replacement_options(),
+ |_| Ok(()),
+ )
+ .expect_err("strict game-chat commit must require exactly four slices");
+
+ assert!(error.contains("恰好包含 4 个独立切片"));
+ assert_eq!(fs::read(path).expect("read preserved sheet"), b"old-image");
+ assert!(!root
+ .join("assets/art-spritesheet-slices/manifest.json")
+ .exists());
+ }
+
+ #[test]
+ fn strict_slice_commit_rejects_missing_resource_id_before_writing_any_artifact() {
+ let temporary = tempfile::tempdir().expect("create strict resource project");
+ let root = temporary.path();
+ init_local_game_project_at(root, "strict-resource", "严格资源测试")
+ .expect("init strict resource project");
+ let sheet_path = root.join("assets/art-spritesheet.png");
+ fs::write(&sheet_path, b"temporary-old-image").expect("write setup sheet");
+ let mut prepared = prepared_replacement_with_core_slices(root, b"new-image");
+ fs::remove_file(&sheet_path).expect("remove setup sheet");
+ prepared.replacement_fingerprint = None;
+ prepared.resource_id = None;
+ let mut options = replacement_options();
+ options.replace_existing = false;
+
+ let error =
+ commit_prepared_platform_art_asset_strict_slices_at(root, prepared, &options, |_| {
+ Ok(())
+ })
+ .expect_err("strict game-chat commit must require a stable resource id");
+
+ assert!(error.contains("resourceId"));
+ assert!(!sheet_path.exists());
+ assert!(!root.join("assets/art-spritesheet-slices").exists());
+ }
+
+ #[test]
+ fn strict_slice_replacement_preserves_existing_contract_when_authorization_fails() {
+ let temporary = tempfile::tempdir().expect("create strict authorization project");
+ let root = temporary.path();
+ init_local_game_project_at(root, "strict-authorization", "严格授权测试")
+ .expect("init strict authorization project");
+ let sheet_path = root.join("assets/art-spritesheet.png");
+ fs::write(&sheet_path, b"old-image").expect("write old spritesheet");
+ let prepared = prepared_replacement_with_core_slices(root, b"new-image");
+ let snapshots = existing_core_slice_contract(root);
+
+ let error = commit_prepared_platform_art_asset_strict_slices_at(
+ root,
+ prepared,
+ &replacement_options(),
+ |_| Err("replacement authorization expired".to_string()),
+ )
+ .expect_err("strict replacement must stop before changing canonical slices");
+
+ assert!(error.contains("replacement authorization expired"));
+ assert_eq!(fs::read(sheet_path).expect("read old sheet"), b"old-image");
+ assert_core_slice_contract_unchanged(&snapshots);
+ }
+
+ #[test]
+ fn strict_slice_replacement_preserves_existing_contract_when_sheet_fingerprint_drifts() {
+ let temporary = tempfile::tempdir().expect("create strict fingerprint project");
+ let root = temporary.path();
+ init_local_game_project_at(root, "strict-fingerprint", "严格指纹测试")
+ .expect("init strict fingerprint project");
+ let sheet_path = root.join("assets/art-spritesheet.png");
+ fs::write(&sheet_path, b"old-image").expect("write old spritesheet");
+ let prepared = prepared_replacement_with_core_slices(root, b"new-image");
+ let snapshots = existing_core_slice_contract(root);
+ fs::write(&sheet_path, b"concurrent-image").expect("simulate concurrent replacement");
+
+ let error = commit_prepared_platform_art_asset_strict_slices_at(
+ root,
+ prepared,
+ &replacement_options(),
+ |_| Ok(()),
+ )
+ .expect_err("strict replacement must reject the stale original fingerprint");
+
+ assert!(error.contains("发生变化"));
+ assert_eq!(
+ fs::read(sheet_path).expect("read concurrent sheet"),
+ b"concurrent-image"
+ );
+ assert_core_slice_contract_unchanged(&snapshots);
+ }
+
+ #[test]
+ fn strict_slice_replacement_rolls_back_slice_contract_when_sheet_install_fails() {
+ let temporary = tempfile::tempdir().expect("create strict atomic project");
+ let root = temporary.path();
+ init_local_game_project_at(root, "strict-atomic", "严格原子提交测试")
+ .expect("init strict atomic project");
+ let sheet_path = root.join("assets/art-spritesheet.png");
+ fs::write(&sheet_path, b"old-image").expect("write old spritesheet");
+ let prepared = prepared_replacement_with_core_slices(root, b"new-image");
+ let snapshots = existing_core_slice_contract(root);
+
+ let error = commit_prepared_platform_art_asset_with_before_replace_hook(
+ root,
+ prepared,
+ &replacement_options(),
+ true,
+ |_| Ok(()),
+ |_| Err("simulated sheet install failure".to_string()),
+ )
+ .expect_err("strict replacement must roll back slices when sheet install fails");
+
+ assert!(error.contains("simulated sheet install failure"));
+ assert_eq!(
+ fs::read(sheet_path).expect("read restored sheet"),
+ b"old-image"
+ );
+ assert_core_slice_contract_unchanged(&snapshots);
+ }
+
+ #[test]
+ fn committed_art_spritesheet_persists_server_slices_and_usage_manifest() {
+ let temporary = tempfile::tempdir().expect("create sliced spritesheet project");
+ let root = temporary.path();
+ init_local_game_project_at(root, "canvas-slices", "图集切片测试")
+ .expect("init sliced spritesheet project");
+ let main_download = rgba_test_png(0);
+ let slices = ["玩家主体", "目标物", "场景障碍", "反馈特效"]
+ .into_iter()
+ .enumerate()
+ .map(|(index, name)| PreparedPlatformArtAssetSlice {
+ name: name.to_string(),
+ width: 1,
+ height: 1,
+ download: rgba_test_png(100 + index as u8),
+ resource_id: Some(format!("slice-resource-{index}")),
+ asset_object_id: Some(format!("slice-object-{index}")),
+ extension: "png".to_string(),
+ })
+ .collect::>();
+ let prepared = PreparedPlatformArtAssetGeneration {
+ requested_output_path: Some("assets/art-spritesheet.png".to_string()),
+ replacement_fingerprint: None,
+ download: main_download,
+ canvas_context: ExternalCanvasGenerationContext {
+ project_id: "canvas-project".to_string(),
+ asset_folder_id: "asset-folder".to_string(),
+ canvas_name: "canvas-slices".to_string(),
+ },
+ resource_id: Some("spritesheet-resource".to_string()),
+ task_id: Some("spritesheet-task".to_string()),
+ asset_object_id: Some("spritesheet-object".to_string()),
+ canvas_project_id: Some("canvas-project".to_string()),
+ generated_prompt: Some("四类原创素材".to_string()),
+ model: Some("test-image-model".to_string()),
+ provider: Some("test-provider".to_string()),
+ warning: None,
+ slice_warning: None,
+ slices,
+ generation_route: "/api/external/v1/editor/icon-spritesheets/generations".to_string(),
+ generation_kind: "icon-spritesheet".to_string(),
+ reference_resource_ids: vec!["art-spec-resource".to_string()],
+ extension: "png".to_string(),
+ };
+ let mut options = replacement_options();
+ options.replace_existing = false;
+
+ let generated =
+ commit_prepared_platform_art_asset_strict_slices_at(root, prepared, &options, |_| {
+ Ok(())
+ })
+ .expect("commit sliced spritesheet");
+
+ assert_eq!(generated.slices.len(), 4);
+ for slice in &generated.slices {
+ assert!(
+ root.join(&slice.local_path).is_file(),
+ "{}",
+ slice.local_path
+ );
+ }
+ let manifest: serde_json::Value = serde_json::from_slice(
+ &fs::read(root.join("assets/art-spritesheet-slices/manifest.json"))
+ .expect("read slice manifest"),
+ )
+ .expect("parse slice manifest");
+ assert_eq!(manifest["schemaVersion"], "game-art-slices.v1");
+ assert_eq!(manifest["source"], "assets/art-spritesheet.png");
+ assert_eq!(manifest["sourceResourceId"], "spritesheet-resource");
+ assert_eq!(manifest["slices"].as_array().map(Vec::len), Some(4));
+ assert_eq!(manifest["slices"][0]["usage"], "player");
+ assert_eq!(manifest["slices"][3]["usage"], "feedback-effects");
+ }
+
#[test]
fn committed_canvas_replacement_updates_the_fixed_path_without_delete_first() {
let temporary = tempfile::tempdir().expect("create replacement project");
@@ -2606,6 +3356,7 @@ mod canvas_generation_tests {
root,
prepared,
&replacement_options(),
+ false,
|_| Ok(()),
|output_path| {
fs::write(output_path, b"concurrent-image")
diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/prompt.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/prompt.rs
index cba6238dc..9d2ec9aef 100644
--- a/apps/ai-game-creator-shell/src-tauri/src/agent/prompt.rs
+++ b/apps/ai-game-creator-shell/src-tauri/src/agent/prompt.rs
@@ -1,6 +1,6 @@
use super::*;
-const RUNTIME_PROMPT_BUNDLE_VERSION: &str = "2026-08-03.3";
+const RUNTIME_PROMPT_BUNDLE_VERSION: &str = "2026-08-03.4";
const RUNTIME_PROMPT_BUNDLE_MANIFEST: &str = include_str!("../../prompts/runtime/manifest.json");
const RUNTIME_PROMPT_COMMON: &str = include_str!("../../prompts/runtime/common.md");
const RUNTIME_PROMPT_ISOLATED_AGENT_CONTRACT: &str =
@@ -10,6 +10,9 @@ const RUNTIME_PROMPT_PLATFORM_DEFAULT: &str =
include_str!("../../prompts/runtime/platform/default.md");
#[cfg(any(test, target_os = "linux"))]
const RUNTIME_PROMPT_PLATFORM_LINUX: &str = include_str!("../../prompts/runtime/platform/linux.md");
+#[cfg(test)]
+const RUNTIME_PROMPT_CODE_PROTOTYPE_GAME_CHAT: &str =
+ include_str!("../../prompts/runtime/roles/code-prototype-game-chat.md");
#[cfg(not(target_os = "linux"))]
const RUNTIME_PROMPT_PLATFORM: &str = RUNTIME_PROMPT_PLATFORM_DEFAULT;
#[cfg(target_os = "linux")]
@@ -654,6 +657,9 @@ mod tests {
"isolatedAgentContract": "isolated-agent-contract.md",
"platformDefault": "platform/default.md",
"platformLinux": "platform/linux.md",
+ "roles": {
+ "codePrototypeGameChat": "roles/code-prototype-game-chat.md"
+ },
"supervisor": {
"intro": "supervisor/intro.md",
"visualContractWithoutEditor": "supervisor/visual-contract-without-editor.md",
@@ -669,6 +675,7 @@ mod tests {
RUNTIME_PROMPT_ISOLATED_AGENT_CONTRACT,
RUNTIME_PROMPT_PLATFORM_DEFAULT,
RUNTIME_PROMPT_PLATFORM_LINUX,
+ RUNTIME_PROMPT_CODE_PROTOTYPE_GAME_CHAT,
SUPERVISOR_INTRO,
SUPERVISOR_VISUAL_CONTRACT_WITHOUT_EDITOR,
SUPERVISOR_VISUAL_CONTRACT_WITH_EDITOR,
diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_request_builders.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_request_builders.rs
index 77af35355..441981ab9 100644
--- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_request_builders.rs
+++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_request_builders.rs
@@ -3,7 +3,8 @@ use crate::mcp::GAME_CREATOR_MCP_CALL_TOOL;
const AGENT_RUNTIME_COMPLETION_BLOCKER_TOOL_PLAN_PROTOCOL: &str = "通用完成阻断规则:如果最新 observation 的 tool 为 runtime.autonomous_completion 且 status 为 blocked,本轮禁止调用 respond_to_user;必须先读取该 observation.detail 的 nextRequiredAction,并据此调用合适的读取、修复和验证工具。只有完成要求的动作、取得后续可信 observation 且完成门禁不再阻断后,才能给最终回复;不得反复提交 final response,也不得按项目正文硬编码某一种 blocker 的处理方式。";
-const GAME_CHAT_CODE_PROTOTYPE_FAST_PATH_PROMPT: &str = "game-chat 首版使用五分钟快车道。必须先保护既有游戏语义和已完成产物:如果已有 observation 尚未包含 game/index.html 的当前正文与摘要,本响应第一步必须调用 file.read(path=game/index.html),不得猜测入口仍是初始化占位。只有文件缺失或正文与初始化页面一致、且当前 run 尚未写入项目时,才允许用一次 file.write 生成完整、自包含、可运行的 game/index.html;检测到非占位入口、已有可玩实现、当前 revision 已验证,或当前 run 已发生修改时,禁止整文件 file.write,必须保留原玩法,优先执行静态检查/试玩,确需修复时只能依据已读取的精确原文做最小 file.patch。不得把“继续”、continue 或其它纯续跑词当作游戏主题;缺少可继承的具体目标时必须失败关闭,不得另造收集能量等新玩法。HTML 必须满足下方固定试玩合同,包含真实 Canvas 游戏循环、键盘与触控输入、开始、主要操作、重开、胜负状态和移动端布局。../assets/art-spec.png 是视觉规范图,只能用于理解风格、构图与色板,禁止把整张规范图铺成游戏背景,禁止从中裁切或绘制玩家、目标和场景实体,也不能把它当作运行时必需图片;只有存在已登记的独立派生 spritesheet 时才可加载该派生素材,否则使用与规范一致的自包含原创 Canvas 视觉,并保证规范图加载失败不影响游戏启动。首次实现或最小修复后不要继续扩写功能;Runtime 会自动执行静态自检并在通过后立即试玩。";
+const GAME_CHAT_CODE_PROTOTYPE_FAST_PATH_PROMPT: &str =
+ include_str!("../../../prompts/runtime/roles/code-prototype-game-chat.md");
fn game_chat_fast_path_prompt_for_root_source(
agent_id: &str,
@@ -741,14 +742,14 @@ mod tests {
}
#[test]
- fn game_chat_fast_path_prompt_protects_existing_game_and_treats_art_spec_as_reference() {
+ fn game_chat_fast_path_prompt_protects_existing_game_and_requires_cropped_spritesheet_use() {
let prompt = game_chat_fast_path_prompt_for_root_source(
"code-prototype",
AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE,
)
.expect("game-chat code fast path prompt");
- assert!(prompt.contains("五分钟快车道"));
+ assert!(prompt.contains("素材完整快车道"));
assert!(prompt.contains("第一步必须调用 file.read(path=game/index.html)"));
assert!(prompt.contains("正文与初始化页面一致"));
assert!(prompt.contains("当前 run 尚未写入项目"));
@@ -756,13 +757,14 @@ mod tests {
assert!(prompt.contains("禁止整文件 file.write"));
assert!(prompt.contains("最小 file.patch"));
assert!(prompt.contains("不得把“继续”"));
- assert!(prompt.contains("../assets/art-spec.png"));
- assert!(prompt.contains("只能用于理解风格、构图与色板"));
- assert!(prompt.contains("禁止把整张规范图铺成游戏背景"));
- assert!(prompt.contains("禁止从中裁切或绘制玩家、目标和场景实体"));
- assert!(prompt.contains("自包含原创 Canvas 视觉"));
- assert!(prompt.contains("规范图加载失败不影响游戏启动"));
- assert!(prompt.contains("独立派生 spritesheet"));
+ assert!(prompt.contains("assets/art-spec.png"));
+ assert!(prompt.contains("不得在运行时加载"));
+ assert!(prompt.contains("assets/art-spritesheet.png"));
+ assert!(prompt.contains("assets/art-spritesheet-slices/manifest.json"));
+ assert!(prompt.contains("四个不同的独立透明素材"));
+ assert!(prompt.contains("不得猜测整张图集是等分网格"));
+ assert!(prompt.contains("不得以纯代码几何替代核心实体"));
+ assert!(prompt.contains("四类切片或其可见使用任一缺失时不得交付"));
assert!(game_chat_fast_path_prompt_for_root_source(
"quality-review",
AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE,
diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/game_chat_fast_path.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/game_chat_fast_path.rs
index 5275693a4..00aa1f684 100644
--- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/game_chat_fast_path.rs
+++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/game_chat_fast_path.rs
@@ -1,13 +1,13 @@
-//! A deterministic, dependency-free game-chat fallback.
+//! A deterministic game-chat fallback that consumes the generated local art slices.
//!
//! This module deliberately does not start the runtime or write project files. It only
-//! renders a small, self-contained HTML document that the runtime can use when it needs to
+//! renders a small HTML document that the runtime can use when it needs to
//! make a first playable version available before the normal generation pass finishes.
use super::*;
-pub(crate) const GAME_CHAT_FIRST_PLAYABLE_SOFT_BUDGET_SECONDS: u64 = 240;
-pub(crate) const GAME_CHAT_FIRST_PLAYABLE_HARD_BUDGET_SECONDS: u64 = 300;
+pub(crate) const GAME_CHAT_FIRST_PLAYABLE_SOFT_BUDGET_SECONDS: u64 = 4_200;
+pub(crate) const GAME_CHAT_FIRST_PLAYABLE_HARD_BUDGET_SECONDS: u64 = 4_500;
pub(crate) const GAME_CHAT_FIRST_PLAYABLE_HARD_BUDGET_ERROR_PREFIX: &str =
"game-chat-first-playable-hard-budget-exhausted";
@@ -76,12 +76,12 @@ pub(crate) fn game_chat_fast_path_provider_timeout(
fn game_chat_fast_path_action(tool: &str, input: serde_json::Value) -> AgentRuntimeToolPlan {
AgentRuntimeToolPlan {
- thinking_summary: "game-chat 首版快车道正在按固定最短路径推进。".to_string(),
+ thinking_summary: "game-chat 素材完整快车道正在按固定路径推进。".to_string(),
plan_update: None,
plan: Vec::new(),
actions: vec![AgentRuntimeToolAction {
tool: tool.to_string(),
- reason: Some("在五分钟预算内形成并验证首个可玩版本".to_string()),
+ reason: Some("在素材完整预算内生成、接入并验证首个可玩版本".to_string()),
input,
}],
response: String::new(),
@@ -94,6 +94,7 @@ fn game_chat_fast_path_fallback_write_plan_for_root(
) -> Result {
ensure_game_chat_fallback_theme_is_specific(task)?;
ensure_game_chat_fallback_targets_initial_placeholder(root)?;
+ ensure_game_chat_fallback_gameplay_is_supported(task)?;
Ok(game_chat_fast_path_action(
"file.write",
serde_json::json!({
@@ -103,20 +104,42 @@ fn game_chat_fast_path_fallback_write_plan_for_root(
))
}
-fn ensure_game_chat_fallback_theme_is_specific(task: &str) -> Result<(), String> {
- let normalized = task
- .trim()
- .trim_matches(|character: char| {
- character.is_whitespace()
- || matches!(character, '。' | ',' | ',' | '.' | '!' | '!' | '?' | '?')
- })
- .to_ascii_lowercase();
- if normalized.is_empty()
- || matches!(
- normalized.as_str(),
- "继续" | "继续吧" | "继续做" | "接着" | "接着做" | "continue" | "go on"
- )
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+enum GameChatFallbackGameplay {
+ Collection,
+ Tetris,
+}
+
+fn game_chat_fallback_gameplay(task: &str) -> Option {
+ let normalized = task.trim().to_ascii_lowercase();
+ if ["俄罗斯方块", "方块下落", "tetromino", "tetris"]
+ .iter()
+ .any(|keyword| normalized.contains(keyword))
{
+ return Some(GameChatFallbackGameplay::Tetris);
+ }
+ if ["收集", "能量", "采集"]
+ .iter()
+ .any(|keyword| normalized.contains(keyword))
+ {
+ return Some(GameChatFallbackGameplay::Collection);
+ }
+ None
+}
+
+fn ensure_game_chat_fallback_gameplay_is_supported(task: &str) -> Result<(), String> {
+ if game_chat_fallback_gameplay(task).is_some() {
+ Ok(())
+ } else {
+ Err(
+ "game-chat 确定性 fallback 没有当前玩法的真实语义模板,拒绝生成名称不同但玩法固定的收集游戏;应由 code-prototype 继续实现原任务"
+ .to_string(),
+ )
+ }
+}
+
+fn ensure_game_chat_fallback_theme_is_specific(task: &str) -> Result<(), String> {
+ if task.trim().is_empty() || is_pure_autonomous_continuation_intent(task) {
return Err(
"game-chat 首版缺少可继承的具体游戏目标,拒绝把纯续跑指令当作新游戏主题".to_string(),
);
@@ -206,6 +229,16 @@ fn game_chat_fast_path_canvas_asset_plan(task_id: &str, root_task: &str) -> Agen
"icon-spec",
"游戏统一视觉规范图",
),
+ "art-asset-plan" => (
+ format!(
+ "为原创小游戏“{theme}”从已登记的统一视觉规范图派生首版透明核心素材图集:严格只生成四个彼此分离的完整主体,并按左上玩家主体、右上方块/目标/危险物、左下障碍/场景、右下得分/受击/胜负反馈的阅读顺序排列;每类只占一个连通主体,附属部件必须贴合主体,边界清晰、透明背景,适合拆成四张独立素材,不得生成额外装饰、完整游戏截图、海报、UI 面板或不透明背景。"
+ ),
+ "assets/art-spritesheet.png",
+ "1:1",
+ "1K",
+ "art-spritesheet",
+ "游戏首版核心美术素材",
+ ),
_ => unreachable!("only deterministic game-chat art tasks use this helper"),
};
game_chat_fast_path_action(
@@ -222,34 +255,129 @@ fn game_chat_fast_path_canvas_asset_plan(task_id: &str, root_task: &str) -> Agen
)
}
+pub(in crate::agent) fn game_chat_fast_path_art_manifest_content() -> String {
+ serde_json::json!({
+ "schemaVersion": "game-art-manifest.v1",
+ "status": "generated",
+ "assets": [{
+ "path": "assets/art-spritesheet.png",
+ "kind": "art-spritesheet",
+ "usage": ["player", "blocks-and-targets", "obstacles-and-scene", "feedback-effects"]
+ }],
+ "sliceManifest": "assets/art-spritesheet-slices/manifest.json",
+ "requiredSliceUsages": ["player", "blocks-and-targets", "obstacles-and-scene", "feedback-effects"]
+ })
+ .to_string()
+}
+
+fn game_chat_fast_path_has_art_manifest(root: &Path) -> bool {
+ read_local_project_file_at(root, "assets/manifest.art.json")
+ .ok()
+ .and_then(|file| serde_json::from_str::(&file.content).ok())
+ .is_some_and(|manifest| {
+ manifest.get("status").and_then(serde_json::Value::as_str) == Some("generated")
+ && manifest
+ .get("assets")
+ .and_then(serde_json::Value::as_array)
+ .is_some_and(|assets| {
+ assets.iter().any(|asset| {
+ asset.get("path").and_then(serde_json::Value::as_str)
+ == Some("assets/art-spritesheet.png")
+ })
+ })
+ && manifest
+ .get("sliceManifest")
+ .and_then(serde_json::Value::as_str)
+ == Some("assets/art-spritesheet-slices/manifest.json")
+ && game_chat_fast_path_art_slice_paths(root).is_ok()
+ })
+}
+
+pub(in crate::agent) fn game_chat_fast_path_art_slice_paths(
+ root: &Path,
+) -> Result, String> {
+ let file = read_local_project_file_at(root, "assets/art-spritesheet-slices/manifest.json")?;
+ let manifest: serde_json::Value = serde_json::from_str(&file.content)
+ .map_err(|error| format!("game-chat 图集切片清单不是有效 JSON:{error}"))?;
+ if manifest
+ .get("schemaVersion")
+ .and_then(serde_json::Value::as_str)
+ != Some("game-art-slices.v1")
+ || manifest.get("source").and_then(serde_json::Value::as_str)
+ != Some("assets/art-spritesheet.png")
+ {
+ return Err("game-chat 图集切片清单 schema 或 source 无效".to_string());
+ }
+ let project_manifest = read_manifest_for_project(root)?;
+ let current_resource_id = project_manifest
+ .assets
+ .iter()
+ .find(|asset| asset.local_path == "assets/art-spritesheet.png")
+ .and_then(|asset| asset.source.resource_id.as_deref())
+ .ok_or_else(|| "game-chat 当前图集缺少 Canvas resourceId".to_string())?;
+ if manifest
+ .get("sourceResourceId")
+ .and_then(serde_json::Value::as_str)
+ != Some(current_resource_id)
+ {
+ return Err("game-chat 图集切片清单不属于当前 art-spritesheet resource".to_string());
+ }
+ let slices = manifest
+ .get("slices")
+ .and_then(serde_json::Value::as_array)
+ .ok_or_else(|| "game-chat 图集切片清单缺少 slices".to_string())?;
+ let required_usages = [
+ "player",
+ "blocks-and-targets",
+ "obstacles-and-scene",
+ "feedback-effects",
+ ];
+ let mut paths = Vec::with_capacity(required_usages.len());
+ for usage in required_usages {
+ let slice = slices
+ .iter()
+ .find(|slice| slice.get("usage").and_then(serde_json::Value::as_str) == Some(usage))
+ .ok_or_else(|| format!("game-chat 图集切片清单缺少 {usage} 素材"))?;
+ let expected_path = format!("assets/art-spritesheet-slices/{usage}.png");
+ let path = slice
+ .get("path")
+ .and_then(serde_json::Value::as_str)
+ .filter(|path| *path == expected_path)
+ .ok_or_else(|| format!("game-chat {usage} 切片路径无效"))?;
+ let absolute = resolve_local_project_path(root, path)?;
+ let bytes = fs::read(&absolute)
+ .map_err(|error| format!("game-chat {usage} 切片无法读取:{error}"))?;
+ image::load_from_memory(&bytes)
+ .map_err(|error| format!("game-chat {usage} 切片无法解码:{error}"))?;
+ paths.push(path.to_string());
+ }
+ Ok(paths)
+}
+
pub(crate) fn game_chat_fast_path_fallback_write_plan_for_budget_at(
root: &Path,
budget: &GameChatFastPathBudget,
_fallback_task: &str,
) -> Result {
let root_task = game_chat_fast_path_root_task(root, budget)?;
+ if !game_chat_fast_path_has_visual_asset(root, "art-asset-plan") {
+ return Err(
+ "game-chat 首版缺少已登记的 assets/art-spritesheet.png,拒绝生成纯代码核心画面"
+ .to_string(),
+ );
+ }
+ game_chat_fast_path_art_slice_paths(root)?;
let contract =
read_autonomous_completion_contract(root, &budget.root_agent_id, &budget.root_run_id)?
.ok_or_else(|| "game-chat 首版快车道缺少 root 完成合同".to_string())?;
- let default_index_sha256 = format!("{:x}", Sha256::digest(DEFAULT_GAME_INDEX_HTML.as_bytes()));
- if contract
- .baseline_index_sha256
- .as_deref()
- .is_some_and(|sha256| sha256 != default_index_sha256)
- {
- return Ok(game_chat_fast_path_action(
- "command.run_limited",
- serde_json::json!({ "commandId": "game.static_smoke" }),
- ));
- }
let code_run_id = autonomous_manifest_ready_task_run_id(&budget.root_run_id, "code-prototype");
let code_gate =
read_game_creator_agent_runtime_verification_gate(root, "code-prototype", &code_run_id)?;
- if !game_chat_fallback_targets_initial_placeholder(root)? {
- return Ok(game_chat_fast_path_action(
- "command.run_limited",
- serde_json::json!({ "commandId": "game.static_smoke" }),
- ));
+ if game_chat_fast_path_existing_game_requires_code_mutation(root, &contract)? {
+ return Err(
+ "game-chat 检测到既有非占位游戏;code-prototype 必须先读取并实际 patch 现有玩法,取得本人 mutationRevision 后再运行 game.static_smoke,拒绝用只读 smoke 冒充续作"
+ .to_string(),
+ );
}
if code_gate.mutation_revision.is_some() {
return Err(
@@ -269,6 +397,18 @@ pub(crate) fn game_chat_fast_path_fallback_write_plan_for_budget_at(
game_chat_fast_path_fallback_write_plan_for_root(root, &root_task)
}
+fn game_chat_fast_path_existing_game_requires_code_mutation(
+ root: &Path,
+ contract: &AgentRuntimeAutonomousCompletionContract,
+) -> Result {
+ let default_index_sha256 = format!("{:x}", Sha256::digest(DEFAULT_GAME_INDEX_HTML.as_bytes()));
+ Ok(contract
+ .baseline_index_sha256
+ .as_deref()
+ .is_some_and(|sha256| sha256 != default_index_sha256)
+ || !game_chat_fallback_targets_initial_placeholder(root)?)
+}
+
fn game_chat_fast_path_verified_delivery_plan(
runtime: &AgentRuntimeState,
response: &str,
@@ -351,6 +491,42 @@ pub(crate) fn game_chat_fast_path_plan_at(
&root_task,
)))
}
+ "art-asset-plan" => {
+ if game_chat_fast_path_has_visual_asset(root, "art-asset-plan") {
+ if game_chat_fast_path_has_art_manifest(root) {
+ return Ok(Some(game_chat_fast_path_verified_delivery_plan(
+ runtime,
+ "透明核心美术图集已生成、登记并形成资产清单。",
+ )));
+ }
+ return Err(
+ "game-chat 已有透明图集但缺少 assets/manifest.art.json,必须走显式 repair 补齐同一 owner 合同"
+ .to_string(),
+ );
+ }
+ if !editor_api_key_is_configured() {
+ return Err(
+ "game-chat 首版必须配置 External Editor API Key 才能生成透明核心美术图集"
+ .to_string(),
+ );
+ }
+ if !game_chat_fast_path_has_visual_asset(root, "art-director") {
+ return Err(
+ "game-chat 透明图集生成前缺少已登记的 assets/art-spec.png,拒绝绕过规范图派生"
+ .to_string(),
+ );
+ }
+ if game_chat_fast_path_canvas_generation_failed(runtime) {
+ return Err(
+ "game-chat 透明核心美术图集生成失败,拒绝退回纯代码核心画面".to_string()
+ );
+ }
+ let root_task = game_chat_fast_path_root_task(root, &budget)?;
+ Ok(Some(game_chat_fast_path_canvas_asset_plan(
+ "art-asset-plan",
+ &root_task,
+ )))
+ }
"preview-readiness" => {
if game_chat_fast_path_current_revision_is_verified(root, runtime)? {
Ok(Some(game_chat_fast_path_verified_delivery_plan(
@@ -414,6 +590,17 @@ pub(crate) fn game_chat_fast_path_plan_at(
if current_revision_failed {
return Ok(None);
}
+ let contract = read_autonomous_completion_contract(
+ root,
+ &budget.root_agent_id,
+ &budget.root_run_id,
+ )?
+ .ok_or_else(|| "game-chat 首版快车道缺少 root 完成合同".to_string())?;
+ if !owns_current_mutation
+ && game_chat_fast_path_existing_game_requires_code_mutation(root, &contract)?
+ {
+ return Ok(None);
+ }
if runtime.loop_iteration > 1
|| budget.elapsed_seconds >= GAME_CHAT_FIRST_PLAYABLE_SOFT_BUDGET_SECONDS
{
@@ -433,7 +620,11 @@ pub(crate) fn game_chat_fast_path_plan_at(
/// attribute and text nodes; it is never interpolated into JavaScript source.
pub(crate) fn render_game_chat_fast_path_html(prompt: &str) -> String {
let theme = html_escape(&safe_theme_summary(prompt));
- FALLBACK_GAME_HTML.replace(FALLBACK_THEME_MARKER, &theme)
+ let template = match game_chat_fallback_gameplay(prompt) {
+ Some(GameChatFallbackGameplay::Tetris) => FALLBACK_TETRIS_GAME_HTML,
+ _ => FALLBACK_GAME_HTML,
+ };
+ template.replace(FALLBACK_THEME_MARKER, &theme)
}
fn safe_theme_summary(prompt: &str) -> String {
@@ -541,10 +732,41 @@ const FALLBACK_GAME_HTML: &str = r###"
const objectiveLabel = document.getElementById('objective-label');
const theme = root.dataset.theme || '轻量互动挑战';
const state = { schemaVersion: 'playable-web-game-state.v1', sequence: 0, phase: 'ready', level: 1, score: 0 };
+ const playerArt = new Image();
+ const targetArt = new Image();
+ const sceneArt = new Image();
+ const feedbackArt = new Image();
+ const artSlices = [playerArt, targetArt, sceneArt, feedbackArt];
+ let artReady = false;
+ let loadedArtSlices = 0;
let playerX = 180;
let targetX = 690;
let pulse = 0;
+ function failArtLoad() {
+ artReady = false;
+ state.phase = 'lost';
+ stateLabel.textContent = '美术素材加载失败';
+ phaseLabel.textContent = '素材失败';
+ objectiveLabel.textContent = '请修复 assets/art-spritesheet-slices/manifest.json 后重试';
+ }
+
+ function markArtLoaded() {
+ loadedArtSlices += 1;
+ if (loadedArtSlices === artSlices.length) {
+ artReady = true;
+ objectiveLabel.textContent = '四类核心美术素材已就绪,点击开始';
+ }
+ }
+ for (const image of artSlices) {
+ image.addEventListener('load', markArtLoaded, { once: true });
+ image.addEventListener('error', failArtLoad, { once: true });
+ }
+ playerArt.src = '../assets/art-spritesheet-slices/player.png';
+ targetArt.src = '../assets/art-spritesheet-slices/blocks-and-targets.png';
+ sceneArt.src = '../assets/art-spritesheet-slices/obstacles-and-scene.png';
+ feedbackArt.src = '../assets/art-spritesheet-slices/feedback-effects.png';
+
function publish() {
state.sequence += 1;
stateNode.textContent = JSON.stringify(state);
@@ -609,30 +831,19 @@ const FALLBACK_GAME_HTML: &str = r###"
context.strokeStyle = '#2a5f85';
context.lineWidth = 3;
context.strokeRect(20, 20, canvas.width - 40, canvas.height - 132);
- context.save();
- context.translate(playerX, canvas.height - 142);
- context.fillStyle = '#6ff3d1';
- context.beginPath();
- context.moveTo(48, 0);
- context.lineTo(-34, -30);
- context.lineTo(-18, 0);
- context.lineTo(-34, 30);
- context.closePath();
- context.fill();
- context.fillStyle = '#d8fff5';
- context.beginPath();
- context.arc(4, 0, 11, 0, Math.PI * 2);
- context.fill();
- context.restore();
+ if (artReady) {
+ for (let tile = 0; tile < 8; tile += 1) {
+ context.drawImage(sceneArt, tile * 120 + 12, canvas.height - 106, 96, 80);
+ }
+ context.drawImage(playerArt, playerX - 56, canvas.height - 198, 112, 112);
+ }
const targetY = 192 + Math.sin(pulse) * 5;
- const targetGradient = context.createRadialGradient(targetX, targetY, 4, targetX, targetY, 48);
- targetGradient.addColorStop(0, '#fff5a8');
- targetGradient.addColorStop(.45, '#ffba5a');
- targetGradient.addColorStop(1, '#ff6a4d22');
- context.fillStyle = targetGradient;
- context.beginPath();
- context.arc(targetX, targetY, 48, 0, Math.PI * 2);
- context.fill();
+ if (artReady) {
+ context.drawImage(targetArt, targetX - 56, targetY - 56, 112, 112);
+ if (state.score > 0) {
+ context.drawImage(feedbackArt, playerX + 32, canvas.height - 222, 72, 72);
+ }
+ }
context.fillStyle = '#eaf4ff';
context.font = 'bold 22px system-ui, sans-serif';
context.fillText(state.phase === 'playing' ? '继续前进' : state.phase === 'won' ? '挑战成功' : state.phase === 'lost' ? '挑战失败' : '点击开始', 38, 58);
@@ -648,6 +859,278 @@ const FALLBACK_GAME_HTML: &str = r###"