From 38afa8ae21313e5bf441425908c1d2710b7e3157 Mon Sep 17 00:00:00 2001 From: kdletters Date: Tue, 4 Aug 2026 02:17:47 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=A4=9A=E6=99=BA=E8=83=BD?= =?UTF-8?q?=E4=BD=93=E6=B8=B8=E6=88=8F=E9=AA=8C=E6=94=B6=E8=BE=B9=E7=95=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 完善 Canvas 提交恢复顺序与快照读取预算。 收紧俄罗斯方块静态验收、可信交互与外部脚本边界。 补齐旧合同迁移、原生工具 schema 与动作审计白名单。 同步技术方案、共享决策记录与回归测试。 --- .../src/agent/generation/canvas_generation.rs | 89 ++++- .../src/agent/runtime_actions/action_audit.rs | 6 +- .../runtime_driver/game_chat_fast_path.rs | 2 + .../agent/runtime_driver/main_loop_tests.rs | 77 +++- .../runtime_protocol/autonomous_completion.rs | 357 ++++++++++++++++-- .../autonomous_completion_contract_tests.rs | 97 ++++- .../src-tauri/src/agent_native_tools.rs | 2 +- .../src-tauri/src/browser/playtest/generic.rs | 240 +++++++----- .../src-tauri/src/browser/tests.rs | 13 +- .../src-tauri/src/tests/provider.rs | 10 + .../tests/runtime_actions/action_execution.rs | 4 +- .../shared-memory/decision-log.md | 3 +- ...案】AI游戏创作智能体App实施计划-2026-06-24.md | 2 +- 13 files changed, 769 insertions(+), 133 deletions(-) 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 7432f409a..f0775ff3c 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 @@ -2101,12 +2101,52 @@ impl PlatformArtSliceContractRollback { let mut total_snapshot_bytes = 0_u64; for (index, local_path) in STRICT_PLATFORM_ART_CONTRACT_PATHS.iter().enumerate() { let canonical = resolve_local_project_path(root, local_path)?; - let bytes = match fs::read(&canonical) { - Ok(bytes) => Some(bytes), + let bytes = match fs::symlink_metadata(&canonical) { + Ok(metadata) => { + if metadata.file_type().is_symlink() || !metadata.is_file() { + return Err(format!( + "平台图集事务快照来源不是可信普通文件:{}", + canonical.display() + )); + } + let remaining = STRICT_PLATFORM_ART_TRANSACTION_MAX_SNAPSHOT_BYTES + .saturating_sub(total_snapshot_bytes); + if metadata.len() > remaining { + return Err("平台图集事务快照累计超过 64 MiB,已拒绝提交".to_string()); + } + let mut options = fs::OpenOptions::new(); + options.read(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.custom_flags(libc::O_NOFOLLOW); + } + let mut file = options.open(&canonical).map_err(|error| { + format!( + "打开既有平台图集切片合同失败:{}: {error}", + canonical.display() + ) + })?; + let mut bytes = + Vec::with_capacity(usize::try_from(metadata.len()).unwrap_or_default()); + (&mut file) + .take(remaining.saturating_add(1)) + .read_to_end(&mut bytes) + .map_err(|error| { + format!( + "读取既有平台图集切片合同失败:{}: {error}", + canonical.display() + ) + })?; + if u64::try_from(bytes.len()).unwrap_or(u64::MAX) > remaining { + return Err("平台图集事务快照累计超过 64 MiB,已拒绝提交".to_string()); + } + Some(bytes) + } Err(error) if error.kind() == std::io::ErrorKind::NotFound => None, Err(error) => { return Err(format!( - "读取既有平台图集切片合同失败:{}: {error}", + "读取既有平台图集切片合同元数据失败:{}: {error}", canonical.display() )); } @@ -2184,6 +2224,25 @@ impl PlatformArtSliceContractRollback { "平台图集事务 committed marker", )?; self.armed = false; + let prepared_path = self + .transaction_directory + .join(STRICT_PLATFORM_ART_TRANSACTION_PREPARED); + match fs::remove_file(&prepared_path) { + Ok(()) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => { + return Err(format!( + "{PLATFORM_ART_LOCAL_RECONCILIATION_PREFIX} 平台图集合同已提交,但清理 prepared marker 失败:{error}" + )); + } + } + sync_platform_art_directory(&self.transaction_directory, "平台图集已提交事务").map_err( + |error| { + format!( + "{PLATFORM_ART_LOCAL_RECONCILIATION_PREFIX} 平台图集合同已提交,但同步 prepared marker 清理失败:{error}" + ) + }, + )?; cleanup_interrupted_platform_art_contract_files_at(&self.root).map_err(|error| { format!( "{PLATFORM_ART_LOCAL_RECONCILIATION_PREFIX} 平台图集合同已提交,但清理原子替换残留失败:{error}" @@ -4826,6 +4885,30 @@ mod canvas_generation_tests { assert!(!root.join(STRICT_PLATFORM_ART_TRANSACTION_PATH).exists()); } + #[test] + fn durable_strict_contract_transaction_rejects_oversized_sparse_snapshot_before_reading() { + let temporary = tempfile::tempdir().expect("create oversized snapshot project"); + let root = temporary.path(); + init_local_game_project_at(root, "oversized-snapshot", "超大事务快照测试") + .expect("init project"); + let main_path = root.join("assets/art-spritesheet.png"); + let main = fs::OpenOptions::new() + .write(true) + .create(true) + .truncate(true) + .open(&main_path) + .expect("open sparse main sheet"); + main.set_len(STRICT_PLATFORM_ART_TRANSACTION_MAX_SNAPSHOT_BYTES + 1) + .expect("create oversized sparse main sheet"); + + let error = match PlatformArtSliceContractRollback::capture(root, "oversized-snapshot") { + Ok(_) => panic!("oversized sparse snapshot must fail before an unbounded read"), + Err(error) => error, + }; + assert!(error.contains("64 MiB"), "unexpected error: {error}"); + assert!(!root.join(STRICT_PLATFORM_ART_TRANSACTION_PATH).exists()); + } + #[test] fn durable_strict_contract_transaction_preserves_committed_crash_residue() { let temporary = tempfile::tempdir().expect("create committed crash recovery project"); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/action_audit.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/action_audit.rs index cd10cd51f..4e02a26a2 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/action_audit.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/action_audit.rs @@ -431,9 +431,9 @@ fn agent_runtime_action_receipt_safe_detail_with_owner( None | Some(serde_json::Value::Null) => None, Some(value) => Some(value.get("scenario")?.as_str()?), }; - if playtest_scenario - .is_some_and(|scenario| !matches!(scenario, "generic-v1" | "lane-defense-v1")) - { + if playtest_scenario.is_some_and(|scenario| { + !matches!(scenario, "generic-v1" | "tetris-v1" | "lane-defense-v1") + }) { return None; } let diagnostics_count = detail 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 f5045be2e..90578f9a4 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 @@ -674,6 +674,7 @@ pub(crate) fn game_chat_fast_path_fallback_write_plan_for_budget_at( let contract = read_autonomous_completion_contract(root, &budget.root_agent_id, &budget.root_run_id)? .ok_or_else(|| "game-chat 首版快车道缺少 root 完成合同".to_string())?; + let contract = migrate_legacy_tetris_completion_contract_for_run_at(root, contract)?; 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)?; @@ -750,6 +751,7 @@ fn game_chat_fast_path_current_revision_has_playtest_receipt( let contract = read_autonomous_completion_contract(root, &budget.root_agent_id, &budget.root_run_id)? .ok_or_else(|| "game-chat 首版快车道缺少 root 完成合同".to_string())?; + let contract = migrate_legacy_tetris_completion_contract_for_run_at(root, contract)?; let revision = read_game_creator_agent_runtime_project_revision(root)?; Ok(read_autonomous_playtest_receipt(root, &contract)? .is_some_and(|receipt| receipt.revision == revision.revision)) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop_tests.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop_tests.rs index 279d7848d..5f1996351 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop_tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop_tests.rs @@ -222,7 +222,11 @@ fn autonomous_parent_keeps_planning_before_scheduling_registered_legacy_derived_ fs::remove_dir_all(root).ok(); } -fn prepare_autonomous_completion_evidence(root: &Path, state: &AgentRuntimeState) -> u64 { +fn prepare_autonomous_completion_evidence( + root: &Path, + state: &AgentRuntimeState, + expect_complete: bool, +) -> u64 { let contract = read_autonomous_completion_contract(root, &state.agent_id, &state.run_id) .expect("read autonomous completion contract") .expect("autonomous completion contract exists"); @@ -345,7 +349,7 @@ fn prepare_autonomous_completion_evidence(root: &Path, state: &AgentRuntimeState passed: true, diagnostics: Vec::new(), }; - let scenario = BrowserPlaytestScenario::LaneDefenseV1; + let scenario = contract.playtest_scenario; let result = BrowserValidationResult { schema_version: "browser-validation.v1".to_string(), url: "http://127.0.0.1:34567/".to_string(), @@ -407,10 +411,71 @@ fn prepare_autonomous_completion_evidence(root: &Path, state: &AgentRuntimeState &result, ) .expect("write autonomous playtest receipt"); - assert!(autonomous_game_build_completion_blocker_at_locked(root, state).is_none()); + if expect_complete { + let blocker = autonomous_game_build_completion_blocker_at_locked(root, state); + assert!( + blocker.is_none(), + "unexpected completion blocker: {blocker:?}" + ); + } revision } +#[test] +fn game_chat_preview_playtest_migrates_legacy_generic_tetris_receipt_before_delivery() { + let temporary = tempfile::tempdir().expect("create legacy game-chat Tetris root"); + let root = temporary.path().join("project"); + init_local_game_project_at(&root, "legacy-game-chat-tetris", "水晶俄罗斯方块") + .expect("init project"); + let (root_state, preview_state) = queue_game_chat_fast_path_child( + &root, + "legacy-game-chat-tetris-root", + "做一个俄罗斯方块,包含旋转、重力下落、锁定和消行", + "preview-playtest", + ); + let mut legacy_contract = + read_autonomous_completion_contract(&root, &root_state.agent_id, &root_state.run_id) + .expect("read Tetris completion contract") + .expect("Tetris completion contract exists"); + legacy_contract.playtest_scenario = BrowserPlaytestScenario::GenericV1; + legacy_contract.contract_fingerprint = + autonomous_completion_contract_fingerprint(&legacy_contract); + write_agent_runtime_json_sidecar( + &root, + &autonomous_completion_contract_relative_path( + &legacy_contract.agent_id, + &legacy_contract.run_id, + ), + "旧 game-chat Generic Tetris 合同 fixture", + &legacy_contract, + ) + .expect("persist legacy Generic Tetris contract"); + prepare_autonomous_completion_evidence(&root, &root_state, false); + + let bound_at = read_game_creator_agent_runtime_run_profile_binding( + &root, + &root_state.agent_id, + &root_state.run_id, + ) + .expect("read root binding") + .expect("root binding exists") + .bound_at; + let plan = + game_chat_fast_path_plan_at(&root, &preview_state, &preview_state.current_task, bound_at) + .expect("evaluate migrated preview-playtest fast path") + .expect("preview-playtest emits a deterministic plan"); + assert_eq!(plan.actions.len(), 1, "unexpected plan: {plan:?}"); + assert_eq!(plan.actions[0].tool, "preview.validate"); + let migrated = + read_autonomous_completion_contract(&root, &root_state.agent_id, &root_state.run_id) + .expect("read migrated root contract") + .expect("migrated root contract exists"); + assert_eq!( + migrated.playtest_scenario, + BrowserPlaytestScenario::TetrisV1 + ); +} + #[test] fn autonomous_visual_ready_tasks_only_require_images_when_editor_api_key_is_configured() { { @@ -990,7 +1055,7 @@ fn game_chat_single_round_converges_without_another_provider_plan_after_playtest ) .expect("apply structured game-chat plan"); assert!(runtime.plan_revision > 0); - let revision = prepare_autonomous_completion_evidence(&root, &runtime); + let revision = prepare_autonomous_completion_evidence(&root, &runtime, true); for task in new_game_creation_app_seed_tasks() { if !matches!( task.id.as_str(), @@ -1063,7 +1128,7 @@ fn game_chat_single_round_cannot_converge_after_the_hard_budget() { ) .expect("queue game-chat Supervisor task"); let mut runtime = agent_runtime_state_from_task_record(&task_record); - prepare_autonomous_completion_evidence(&root, &runtime); + prepare_autonomous_completion_evidence(&root, &runtime, true); for task in new_game_creation_app_seed_tasks() { if !matches!( task.id.as_str(), @@ -1408,7 +1473,7 @@ async fn autonomous_supervisor_converged_final_reply_deserialize_commits_fallbac .expect("read queued Supervisor task") .expect("queued Supervisor task exists"); let state = agent_runtime_state_from_task_record(&task_record); - let revision = prepare_autonomous_completion_evidence(&root, &state); + let revision = prepare_autonomous_completion_evidence(&root, &state, true); drop(lane_lock); resume_game_creator_agent_background_tasks_at(&root) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/autonomous_completion.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/autonomous_completion.rs index c03ba261c..edf63ba67 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/autonomous_completion.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/autonomous_completion.rs @@ -1133,11 +1133,18 @@ fn javascript_named_function_is_reachable( format!(",{name})"), format!(", {name})"), ]; - for marker in invocation_markers { + for (marker_index, marker) in invocation_markers.into_iter().enumerate() { let mut cursor = 0; while let Some(offset) = content[cursor..].find(&marker) { let call = cursor + offset; cursor = call + marker.len(); + if marker_index <= 1 + && call > 0 + && (is_ascii_word_byte(content.as_bytes()[call - 1]) + || content.as_bytes()[call - 1] == b'$') + { + continue; + } if (*definition_start..*definition_end).contains(&call) || position_is_inside_javascript_string(content, call) || javascript_position_is_in_literal_false_block(content, call) @@ -2313,7 +2320,7 @@ fn migrate_legacy_tetris_completion_contract_at( Ok(contract) } -fn migrate_legacy_tetris_completion_contract_for_run_at( +pub(in crate::agent) fn migrate_legacy_tetris_completion_contract_for_run_at( root: &Path, contract: AgentRuntimeAutonomousCompletionContract, ) -> Result { @@ -2641,13 +2648,23 @@ fn executable_javascript_from_html(content: &str) -> String { }; let tag_end = tag_start + tag_end_offset; let tag = &content[tag_start..=tag_end]; - if tag.starts_with("' | b'/')) - { - cursor = nested_html_element_end(content, tag_end + 1, "template"); + for inert_element in ["template", "noscript"] { + let marker = format!("<{inert_element}"); + if tag.starts_with(&marker) + && tag + .as_bytes() + .get(marker.len()) + .is_none_or(|byte| byte.is_ascii_whitespace() || matches!(byte, b'>' | b'/')) + { + cursor = if tag.trim_end_matches('>').trim_end().ends_with('/') { + tag_end + 1 + } else { + nested_html_element_end(content, tag_end + 1, inert_element) + }; + break; + } + } + if cursor >= tag_end + 1 && !tag.starts_with(" String { executable } +fn executable_external_script_sources_from_html(content: &str) -> Vec { + let lower = content.to_ascii_lowercase(); + let mut sources = Vec::new(); + let mut cursor = 0usize; + while let Some(offset) = lower[cursor..].find('<') { + let tag_start = cursor + offset; + if lower[tag_start..].starts_with("") + .map(|end| tag_start + 4 + end + 3) + .unwrap_or(lower.len()); + continue; + } + let Some(tag_end_offset) = lower[tag_start..].find('>') else { + break; + }; + let tag_end = tag_start + tag_end_offset; + let tag = &lower[tag_start..=tag_end]; + let mut inert = false; + for inert_element in ["template", "noscript"] { + let marker = format!("<{inert_element}"); + if tag.starts_with(&marker) + && tag + .as_bytes() + .get(marker.len()) + .is_none_or(|byte| byte.is_ascii_whitespace() || matches!(byte, b'>' | b'/')) + { + cursor = if tag.trim_end_matches('>').trim_end().ends_with('/') { + tag_end + 1 + } else { + nested_html_element_end(&lower, tag_end + 1, inert_element) + }; + inert = true; + break; + } + } + if inert { + continue; + } + if !tag.starts_with("' | b'/')) + { + cursor = tag_end + 1; + continue; + } + if html_script_type_is_executable(tag) { + if let Some(source) = html_attribute_value(tag, "src") { + let value_offset = source.as_ptr() as usize - tag.as_ptr() as usize; + let original_tag = &content[tag_start..=tag_end]; + sources.push(original_tag[value_offset..value_offset + source.len()].to_string()); + } + } + let body_start = tag_end + 1; + let Some(close_offset) = lower[body_start..].find("') + .map(|end| close_start + end + 1) + .unwrap_or(lower.len()); + } + sources +} + +fn local_external_gameplay_script_path(source: &str) -> Option { + let source = source.split(['?', '#']).next()?.trim(); + if source.is_empty() || source.starts_with('/') || source.contains(['\\', '%', ':']) { + return None; + } + let mut components = vec!["game"]; + for component in source.split('/') { + match component { + "" | "." => {} + ".." if components.len() > 1 => { + components.pop(); + } + ".." => return None, + value => components.push(value), + } + } + let path = components.join("/"); + (path.starts_with("game/") && (path.ends_with(".js") || path.ends_with(".mjs"))).then_some(path) +} + +pub(in crate::agent) fn read_external_gameplay_javascript_at( + root: &Path, + html: &str, +) -> Result { + const MAX_EXTERNAL_GAMEPLAY_JAVASCRIPT_BYTES: u64 = 2 * 1024 * 1024; + let mut output = String::new(); + let mut total_bytes = 0_u64; + for source in executable_external_script_sources_from_html(html) { + let local_path = local_external_gameplay_script_path(&source) + .ok_or_else(|| format!("自主构建外部脚本路径不受支持:{source}"))?; + let path = resolve_local_project_path(root, &local_path)?; + let metadata = fs::symlink_metadata(&path) + .map_err(|error| format!("读取自主构建外部脚本元数据失败:{local_path}: {error}"))?; + if metadata.file_type().is_symlink() || !metadata.is_file() { + return Err(format!("自主构建外部脚本不是可信普通文件:{local_path}")); + } + let remaining = MAX_EXTERNAL_GAMEPLAY_JAVASCRIPT_BYTES.saturating_sub(total_bytes); + if metadata.len() > remaining { + return Err("自主构建外部脚本累计超过 2 MiB".to_string()); + } + let mut options = fs::OpenOptions::new(); + options.read(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.custom_flags(libc::O_NOFOLLOW); + } + let mut file = options + .open(&path) + .map_err(|error| format!("打开自主构建外部脚本失败:{local_path}: {error}"))?; + let mut script = String::new(); + (&mut file) + .take(remaining.saturating_add(1)) + .read_to_string(&mut script) + .map_err(|error| format!("读取自主构建外部脚本失败:{local_path}: {error}"))?; + let script_bytes = u64::try_from(script.len()).unwrap_or(u64::MAX); + if script_bytes > remaining { + return Err("自主构建外部脚本累计超过 2 MiB".to_string()); + } + total_bytes += script_bytes; + output.push_str(&script.to_ascii_lowercase()); + output.push('\n'); + } + Ok(output) +} + fn reachable_named_javascript_functions<'a>( content: &'a str, ranges: &'a [(String, usize, usize)], @@ -2790,6 +2941,87 @@ fn compact_javascript_contains_unit_property_increment( }) } +fn javascript_contains_identifier_call(content: &str, name: &str) -> bool { + let marker = format!("{name}("); + content.match_indices(&marker).any(|(position, _)| { + position == 0 + || (!is_ascii_word_byte(content.as_bytes()[position - 1]) + && content.as_bytes()[position - 1] != b'$') + }) +} + +fn javascript_contains_collision_call(content: &str) -> bool { + content.match_indices('(').any(|(open, _)| { + identifier_before(content, open).is_some_and(|name| { + let name = name.to_ascii_lowercase(); + ["collision", "collide", "canmove", "validposition", "fits"] + .iter() + .any(|marker| name.contains(marker)) + }) + }) +} + +fn javascript_statement_at(content: &str, start: usize) -> Option<(&str, usize)> { + if content.as_bytes().get(start) == Some(&b'{') { + let end = matching_javascript_brace(content, start)?; + Some((&content[start + 1..end], end + 1)) + } else { + let offset = content[start..].find(';')?; + let end = start + offset + 1; + Some((&content[start..end], end)) + } +} + +fn tetris_fall_control_flow_is_meaningful(compact: &str, lock_function_name: &str) -> bool { + let mut cursor = 0usize; + while let Some(offset) = compact[cursor..].find("if(") { + let open = cursor + offset + 2; + cursor = open + 1; + let Some(close) = matching_javascript_parenthesis(compact, open) else { + continue; + }; + if !javascript_contains_collision_call(&compact[open + 1..close]) { + continue; + } + let Some((consequent, consequent_end)) = javascript_statement_at(compact, close + 1) else { + continue; + }; + let consequent_locks = javascript_contains_identifier_call(consequent, lock_function_name); + let consequent_falls = compact_javascript_contains_unit_property_increment( + consequent, + &["current", "active", "activepiece", "piece"], + &["y", "row"], + ); + if compact[consequent_end..].starts_with("else") { + let Some((alternate, _)) = + javascript_statement_at(compact, consequent_end + "else".len()) + else { + continue; + }; + let alternate_locks = + javascript_contains_identifier_call(alternate, lock_function_name); + let alternate_falls = compact_javascript_contains_unit_property_increment( + alternate, + &["current", "active", "activepiece", "piece"], + &["y", "row"], + ); + if consequent_locks && alternate_falls || consequent_falls && alternate_locks { + return true; + } + } else if consequent_locks + && consequent.contains("return") + && compact_javascript_contains_unit_property_increment( + &compact[consequent_end..], + &["current", "active", "activepiece", "piece"], + &["y", "row"], + ) + { + return true; + } + } + false +} + fn javascript_contains_simple_board_cell_assignment(compact: &str) -> bool { let bytes = compact.as_bytes(); let mut cursor = 0usize; @@ -2985,11 +3217,7 @@ fn tetris_fall_body_is_meaningful(body: &str, lock_function_name: &str) -> bool &["current", "active", "activepiece", "piece"], &["y", "row"], ); - let checks_collision = ["collision", "collide", "canmove", "validposition", "fits"] - .iter() - .any(|marker| body.contains(marker)); - let can_lock = compact.contains(&format!("{lock_function_name}(")); - mutates_active_piece && checks_collision && can_lock + mutates_active_piece && tetris_fall_control_flow_is_meaningful(&compact, lock_function_name) } fn tetris_lock_body_is_meaningful(body: &str, clear_function_name: &str) -> bool { @@ -3000,10 +3228,62 @@ fn tetris_lock_body_is_meaningful(body: &str, clear_function_name: &str) -> bool return false; } let writes_board_cell = tetris_piece_traversal_writes_board(&compact); - let invokes_line_clear = compact.contains(&format!("{clear_function_name}(")); + let invokes_line_clear = javascript_contains_identifier_call(&compact, clear_function_name); writes_board_cell && invokes_line_clear } +fn tetris_filter_predicate_excludes_full_row(predicate: &str) -> bool { + if let Some(every) = predicate.find(".every(") { + let Some(owner) = identifier_before(predicate, every) else { + return false; + }; + let owner_start = every.saturating_sub(owner.len()); + let expression_start = predicate[..owner_start] + .rfind("=>") + .map(|start| start + 2) + .or_else(|| { + predicate[..owner_start] + .rfind("return") + .map(|start| start + "return".len()) + }) + .unwrap_or_default(); + let prefix = predicate[expression_start..owner_start] + .chars() + .filter(|character| !matches!(character, '(' | ')')) + .collect::(); + return prefix == "!"; + } + if let Some(some) = predicate.find(".some(") { + let Some(owner) = identifier_before(predicate, some) else { + return false; + }; + let owner_start = some.saturating_sub(owner.len()); + let expression_start = predicate[..owner_start] + .rfind("=>") + .map(|start| start + 2) + .or_else(|| { + predicate[..owner_start] + .rfind("return") + .map(|start| start + "return".len()) + }) + .unwrap_or_default(); + let prefix = predicate[expression_start..owner_start] + .chars() + .filter(|character| !matches!(character, '(' | ')')) + .collect::(); + let open = some + ".some".len(); + let Some(close) = matching_javascript_parenthesis(predicate, open) else { + return false; + }; + let callback = &predicate[open + 1..close]; + return prefix.is_empty() + && (callback.contains("=>!") || callback.contains("return!")) + && !callback.contains("=>!!") + && !callback.contains("return!!"); + } + false +} + fn tetris_clear_body_is_meaningful(body: &str) -> bool { let body = javascript_without_obvious_false_branches(body); let body = body.as_str(); @@ -3019,10 +3299,7 @@ fn tetris_clear_body_is_meaningful(body: &str) -> bool { return false; }; let predicate = &compact[open + 1..close]; - (predicate.contains("=>!") || predicate.contains("return!")) - && predicate.contains(".every(") - || predicate.contains(".some(") - && (predicate.contains("=>!") || predicate.contains("return!")) + tetris_filter_predicate_excludes_full_row(predicate) }); let splice_clear = body.contains(".splice(") && !compact.contains(".splice(0,0") @@ -3031,8 +3308,12 @@ fn tetris_clear_body_is_meaningful(body: &str) -> bool { filter_clear || splice_clear } -fn tetris_executable_semantics_gap(content: &str) -> Option<&'static str> { - let executable = executable_javascript_from_html(content); +fn tetris_executable_semantics_gap( + content: &str, + external_javascript: &str, +) -> Option<&'static str> { + let mut executable = executable_javascript_from_html(content); + executable.push_str(external_javascript); let executable = javascript_without_string_literals_or_comments(&executable); let ranges = named_javascript_function_ranges(&executable); let has_board_state = ["array.from(", "array("] @@ -3096,16 +3377,17 @@ fn tetris_executable_semantics_gap(content: &str) -> Option<&'static str> { ]; if telemetry_fields .iter() - .any(|field| !content.contains(field)) + .any(|field| !content.contains(field) && !external_javascript.contains(field)) { return Some("browser-tetris-state"); } None } -pub(in crate::agent) fn inherited_gameplay_semantics_gap( +pub(in crate::agent) fn inherited_gameplay_semantics_gap_with_external_javascript( task: &str, html: &[u8], + external_javascript: &str, ) -> Option { let gameplay = inherited_gameplay_semantics(task)?; let Ok(html) = std::str::from_utf8(html) else { @@ -3118,7 +3400,7 @@ pub(in crate::agent) fn inherited_gameplay_semantics_gap( if !contains_any(&["俄罗斯方块", "tetromino", "tetris"]) { Some("tetris-identity") } else { - tetris_executable_semantics_gap(&content) + tetris_executable_semantics_gap(&content, external_javascript) } } AutonomousInheritedGameplaySemantics::Collection => [ @@ -3137,6 +3419,13 @@ pub(in crate::agent) fn inherited_gameplay_semantics_gap( missing.map(str::to_string) } +pub(in crate::agent) fn inherited_gameplay_semantics_gap( + task: &str, + html: &[u8], +) -> Option { + inherited_gameplay_semantics_gap_with_external_javascript(task, html, "") +} + fn autonomous_inherited_gameplay_semantics_gap_at( root: &Path, contract: &AgentRuntimeAutonomousCompletionContract, @@ -3160,8 +3449,21 @@ fn autonomous_inherited_gameplay_semantics_gap_at( if format!("{:x}", Sha256::digest(effective_task.as_bytes())) != contract.task_sha256 { return Err("自主构建续跑的有效任务与完成合同不一致".to_string()); } - Ok(inherited_gameplay_semantics_gap(&effective_task, html) - .map(|gap| format!("game/index.html(inherited-gameplay-semantic-gap:{gap})"))) + let html_text = std::str::from_utf8(html) + .map_err(|_| "自主构建玩法连续性核对的 game/index.html 不是 UTF-8".to_string())?; + let external_javascript = if inherited_gameplay_semantics(&effective_task) + == Some(AutonomousInheritedGameplaySemantics::Tetris) + { + read_external_gameplay_javascript_at(root, html_text)? + } else { + String::new() + }; + Ok(inherited_gameplay_semantics_gap_with_external_javascript( + &effective_task, + html, + &external_javascript, + ) + .map(|gap| format!("game/index.html(inherited-gameplay-semantic-gap:{gap})"))) } pub(in crate::agent) fn ensure_autonomous_completion_contract_for_task_at( @@ -3439,6 +3741,9 @@ pub(in crate::agent) fn read_autonomous_playtest_receipt( "自主试玩回执", )?; if let Some(receipt) = receipt.as_ref() { + if receipt.playtest_scenario != contract.playtest_scenario { + return Ok(None); + } validate_autonomous_playtest_receipt_integrity(root, contract, receipt)?; let expected_scenario_fingerprint = browser_playtest_scenario_fingerprint(contract.playtest_scenario); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/autonomous_completion_contract_tests.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/autonomous_completion_contract_tests.rs index c90d0f6ca..b066f180a 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/autonomous_completion_contract_tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/autonomous_completion_contract_tests.rs @@ -755,11 +755,11 @@ fn preview_child_migrates_legacy_generic_tetris_contract_and_invalidates_its_rec migrated.playtest_scenario, BrowserPlaytestScenario::TetrisV1 ); - let receipt_error = read_autonomous_playtest_receipt(&root, &migrated) - .expect_err("the old Generic receipt must not satisfy the migrated Tetris contract"); assert!( - receipt_error.contains("身份无效") || receipt_error.contains("场景"), - "unexpected legacy receipt error: {receipt_error}", + read_autonomous_playtest_receipt(&root, &migrated) + .expect("legacy Generic receipt is a recoverable stale scenario") + .is_none(), + "the old Generic receipt must not satisfy the migrated Tetris contract", ); } @@ -1420,6 +1420,18 @@ fn inherited_tetris_contract_scans_only_executable_html_scripts() { Some("board-state"), "HTML-commented script content must not count as executable gameplay", ); + + let noscript = valid.replacen("") + .expect("executable fixture has a final script close"); + let mut noscript = noscript; + noscript.insert_str(noscript_close + "".len(), ""); + assert_eq!( + inherited_gameplay_semantics_gap(task, noscript.as_bytes()).as_deref(), + Some("board-state"), + "noscript content must not count as executable gameplay in a scripting browser", + ); } #[test] @@ -1447,6 +1459,16 @@ fn inherited_tetris_contract_binds_fall_clear_and_rotation_semantics() { "an always-true filter must not count as line clearing", ); + let double_negation_filter = valid.replace( + "board=board.filter((row)=>!row.every(Boolean));", + "board=board.filter((row)=>!!row.every(Boolean));", + ); + assert_eq!( + inherited_gameplay_semantics_gap(task, double_negation_filter.as_bytes()).as_deref(), + Some("line-clear"), + "a double-negated full-row predicate keeps full rows and must not count as clearing", + ); + let stale_rotation_assignment = valid.replace( "const rotated=current.shape.map((row)=>row.slice()).reverse();current.shape=rotated;current.rotation=(current.rotation+1)%4;", "const rotated=current.shape.map((row)=>row.slice()).reverse();current.shape=current.shape;current.rotation=0;", @@ -1505,6 +1527,73 @@ fn inherited_tetris_contract_accepts_arrow_object_and_class_methods() { ); } +#[test] +fn inherited_tetris_contract_rejects_identifier_suffix_call_decoys() { + let task = "做一个俄罗斯方块,完成旋转、重力下落、锁定和消行"; + let valid = executable_tetris_game_html(); + let unreachable_rotation = valid.replace( + "document.querySelector('[data-playtest-id=\"primary-action\"]').addEventListener('click',rotatePiece);", + "document.querySelector('[data-playtest-id=\"primary-action\"]').addEventListener('click',()=>notrotatePiece());", + ); + assert_eq!( + inherited_gameplay_semantics_gap(task, unreachable_rotation.as_bytes()).as_deref(), + Some("piece-rotation"), + "notrotatePiece() must not make rotatePiece() reachable", + ); + + let unlock_decoy = valid.replace( + "if(hasCollision()){lockPiece();}", + "if(hasCollision()){unlockPiece();}", + ); + assert_eq!( + inherited_gameplay_semantics_gap(task, unlock_decoy.as_bytes()).as_deref(), + Some("piece-fall"), + "unlockPiece() must not bind the fall path to lockPiece()", + ); + + let clear_decoy = valid.replace( + "lockedPieces+=1;clearLines();", + "lockedPieces+=1;notclearLines();", + ); + assert_eq!( + inherited_gameplay_semantics_gap(task, clear_decoy.as_bytes()).as_deref(), + Some("piece-lock"), + "notclearLines() must not bind lockPiece() to clearLines()", + ); +} + +#[test] +fn inherited_tetris_contract_accepts_bounded_local_external_script() { + let task = "做一个俄罗斯方块,完成旋转、重力下落、锁定和消行"; + let valid = executable_tetris_game_html(); + let script_start = valid + .rfind("").expect("fixture closes script"); + let external_html = format!( + "{}{}", + &valid[..script_start], + &valid[script_end + "".len()..] + ); + let temporary = tempfile::tempdir().expect("create external Tetris project"); + let root = temporary.path(); + init_local_game_project_at(root, "external-tetris", task).expect("init project"); + fs::write(root.join("game/game.js"), &valid[body_start..script_end]) + .expect("write external gameplay script"); + let external = read_external_gameplay_javascript_at(root, &external_html) + .expect("read bounded local gameplay script"); + assert_eq!( + inherited_gameplay_semantics_gap_with_external_javascript( + task, + external_html.as_bytes(), + &external, + ), + None, + "a local external script must satisfy the same Tetris semantics as inline code", + ); +} + #[test] fn game_chat_pure_continue_does_not_inherit_across_sessions() { let (_temporary, root, original_state, original_contract) = diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent_native_tools.rs b/apps/ai-game-creator-shell/src-tauri/src/agent_native_tools.rs index 61c728897..3eab3542b 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent_native_tools.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent_native_tools.rs @@ -1173,7 +1173,7 @@ fn runtime_tool_input_schema(tool: &str) -> Value { "expectedText": string_array_schema(16), "settleMs": { "type": "integer", "minimum": 0, "maximum": 10000 }, "failOnConsoleError": { "type": "boolean" }, - "playtestScenario": { "type": ["string", "null"], "enum": ["generic-v1", "lane-defense-v1", null] } + "playtestScenario": { "type": ["string", "null"], "enum": ["generic-v1", "tetris-v1", "lane-defense-v1", null] } } }), "image.inspect" => json!({ diff --git a/apps/ai-game-creator-shell/src-tauri/src/browser/playtest/generic.rs b/apps/ai-game-creator-shell/src-tauri/src/browser/playtest/generic.rs index edf6ac39c..a814e9cbc 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/browser/playtest/generic.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/browser/playtest/generic.rs @@ -1,14 +1,16 @@ +use std::sync::atomic::{AtomicU64, Ordering}; use std::time::Duration; +use chromiumoxide::cdp::js_protocol::runtime::EvaluateParams; use chromiumoxide::Page; use serde::Deserialize; use tokio::time::Instant; use super::{ - click_playtest_control, poll_playable_web_game_state, read_playable_web_game_state, - BrowserPlaytestPhase, BrowserPlaytestResult, BrowserPlaytestScenario, PlayableTetrisState, - PlayableWebGameState, PLAYTEST_POLL_INTERVAL, PLAYTEST_PRIMARY_ACTION_SELECTOR, - PLAYTEST_RESTART_SELECTOR, PLAYTEST_START_SELECTOR, + click_playtest_control, parse_playable_web_game_state, poll_playable_web_game_state, + read_playable_web_game_state, BrowserPlaytestPhase, BrowserPlaytestResult, + BrowserPlaytestScenario, PlayableTetrisState, PlayableWebGameState, PLAYTEST_POLL_INTERVAL, + PLAYTEST_PRIMARY_ACTION_SELECTOR, PLAYTEST_RESTART_SELECTOR, PLAYTEST_START_SELECTOR, }; pub(in crate::browser) const GENERIC_PLAYTEST_START_OPPORTUNITY_WINDOW: Duration = @@ -22,16 +24,14 @@ pub(in crate::browser) const GENERIC_PLAYTEST_START_OPPORTUNITY_MIN_STABILITY_SA pub(in crate::browser) const GENERIC_PLAYTEST_POST_ACTION_MIN_STABILITY_SAMPLES: usize = 12; pub(in crate::browser) const GENERIC_PLAYTEST_RESTART_MIN_STABILITY_SAMPLES: usize = 12; pub(in crate::browser) const GENERIC_PLAYTEST_ACTION_CAUSALITY_FINGERPRINT: &str = concat!( - "primary-action=capture-to-document-bubble-sequence-advance\n", + "primary-action=trusted-event-isolated-world-promise-closure-capture-to-document-bubble-sequence-advance\n", "tetris-primary-action=same-piece-rotation-change\n", "tetris-start-opportunity=same-piece-gravity-row-or-semantic-lock-progress\n", "tetris-post-action=probe-before-gameplay-to-new-piece-lock-board-and-line-check-progress\n", "tetris-restart=board-counters-reset\n", - "restart=capture-to-document-bubble-sequence-advance" + "restart=trusted-event-isolated-world-promise-closure-capture-to-document-bubble-sequence-advance" ); -const GENERIC_ACTION_SEQUENCE_PROBE_KEY: &str = "__genarrativeGenericActionSequenceProbe"; - #[derive(Clone, Debug, Deserialize)] #[serde(rename_all = "camelCase")] struct GenericActionSequenceProbe { @@ -42,73 +42,68 @@ struct GenericActionSequenceProbe { after_gameplay: Option, } -fn generic_action_sequence_probe_script(selector: &str) -> Result { +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct GenericActionSequenceProbeRaw { + status: String, + before_state: Option, + after_state: Option, +} + +static GENERIC_ACTION_SEQUENCE_PROBE_NONCE: AtomicU64 = AtomicU64::new(1); + +fn generic_action_sequence_probe_script(selector: &str, ready_key: &str) -> Result { let selector = serde_json::to_string(selector) .map_err(|_| "固定试玩动作因果探针 selector 无法编码".to_string())?; - let key = serde_json::to_string(GENERIC_ACTION_SEQUENCE_PROBE_KEY) - .map_err(|_| "固定试玩动作因果探针 key 无法编码".to_string())?; + let ready_key = serde_json::to_string(ready_key) + .map_err(|_| "固定试玩动作因果探针 ready key 无法编码".to_string())?; Ok(format!( - r#"(() => {{ - const key = {key}; + r#"(() => new Promise((resolve) => {{ + const readyKey = {ready_key}; const controls = document.querySelectorAll({selector}); + let settled = false; + let timeoutId = null; + const finish = (value) => {{ + if (settled) return; + settled = true; + if (timeoutId !== null) clearTimeout(timeoutId); + try {{ delete globalThis[readyKey]; }} catch (_) {{}} + resolve(value); + }}; if (controls.length !== 1 || !(controls[0] instanceof HTMLElement)) {{ - globalThis[key] = {{ status: 'invalid-control', beforeSequence: null, afterSequence: null, beforeGameplay: null, afterGameplay: null }}; - return 'invalid-control'; + finish({{ status: 'invalid-control', beforeState: null, afterState: null }}); + return; }} const control = controls[0]; const readState = () => {{ const surface = document.querySelectorAll('script#playable-web-game-state'); if (surface.length !== 1 || surface[0].getAttribute('type') !== 'application/json') return null; - try {{ - const value = JSON.parse(String(surface[0].textContent || '')); - if (!Number.isSafeInteger(value.sequence) || value.sequence < 0) return null; - const gameplay = value.gameplay && value.gameplay.kind === 'tetris' - ? JSON.parse(JSON.stringify(value.gameplay)) - : null; - return {{ sequence: value.sequence, gameplay }}; - }} catch (_) {{ - return null; - }} + return String(surface[0].textContent || ''); }}; - globalThis[key] = {{ status: 'armed', beforeSequence: null, afterSequence: null, beforeGameplay: null, afterGameplay: null }}; - control.addEventListener('click', () => {{ - const before = readState(); - globalThis[key] = {{ status: 'captured', beforeSequence: before?.sequence ?? null, afterSequence: null, beforeGameplay: before?.gameplay ?? null, afterGameplay: null }}; - }}, {{ capture: true, once: true }}); - document.addEventListener('click', (event) => {{ + let beforeState = null; + const capture = (event) => {{ + if (!event.isTrusted) return; + beforeState = readState(); + }}; + const bubble = (event) => {{ + if (!event.isTrusted) return; if (event.target !== control && !(event.target instanceof Node && control.contains(event.target))) return; - const before = globalThis[key]; - const after = readState(); - globalThis[key] = {{ status: 'completed', beforeSequence: before?.beforeSequence ?? null, afterSequence: after?.sequence ?? null, beforeGameplay: before?.beforeGameplay ?? null, afterGameplay: after?.gameplay ?? null }}; - }}, {{ once: true }}); - return 'armed'; -}})()"# + control.removeEventListener('click', capture, true); + document.removeEventListener('click', bubble, false); + finish({{ status: 'completed', beforeState, afterState: readState() }}); + }}; + control.addEventListener('click', capture, {{ capture: true }}); + document.addEventListener('click', bubble, {{ capture: false }}); + globalThis[readyKey] = 'armed'; + timeoutId = setTimeout(() => {{ + control.removeEventListener('click', capture, true); + document.removeEventListener('click', bubble, false); + finish({{ status: 'timeout', beforeState, afterState: null }}); + }}, 5000); +}}))()"# )) } -async fn arm_generic_action_sequence_probe( - page: &Page, - selector: &'static str, - action: &'static str, - deadline: Instant, -) -> Result<(), String> { - let remaining = deadline - .checked_duration_since(Instant::now()) - .ok_or_else(|| format!("固定试玩动作 {action} 已超过总时限"))?; - let script = generic_action_sequence_probe_script(selector)?; - let evaluated = tokio::time::timeout(remaining, page.evaluate(script)) - .await - .map_err(|_| format!("固定试玩动作 {action} 因果探针安装超时"))? - .map_err(|_| format!("固定试玩动作 {action} 因果探针安装失败"))?; - let status = evaluated - .into_value::() - .map_err(|_| format!("固定试玩动作 {action} 因果探针安装结果无效"))?; - if status != "armed" { - return Err(format!("固定试玩动作 {action} 因果探针无法绑定唯一控件")); - } - Ok(()) -} - fn validate_generic_action_sequence_probe( action: &str, probe: &GenericActionSequenceProbe, @@ -154,24 +149,88 @@ fn validate_generic_action_sequence_probe( Ok(()) } -async fn verify_generic_action_sequence_probe( +async fn click_with_generic_action_sequence_probe( page: &Page, + scenario: BrowserPlaytestScenario, + selector: &'static str, action: &'static str, deadline: Instant, ) -> Result { + let nonce = GENERIC_ACTION_SEQUENCE_PROBE_NONCE.fetch_add(1, Ordering::Relaxed); + let ready_key = format!( + "__genarrativeActionProbeReady-{}-{nonce}", + std::process::id() + ); + let script = generic_action_sequence_probe_script(selector, &ready_key)?; + let context_id = page + .secondary_execution_context() + .await + .map_err(|_| format!("固定试玩动作 {action} 无法取得隔离执行上下文"))? + .ok_or_else(|| format!("固定试玩动作 {action} 缺少隔离执行上下文"))?; let remaining = deadline .checked_duration_since(Instant::now()) .ok_or_else(|| format!("固定试玩动作 {action} 已超过总时限"))?; - let key = serde_json::to_string(GENERIC_ACTION_SEQUENCE_PROBE_KEY) - .map_err(|_| "固定试玩动作因果探针 key 无法编码".to_string())?; - let script = format!("globalThis[{key}] || null"); - let evaluated = tokio::time::timeout(remaining, page.evaluate(script)) - .await - .map_err(|_| format!("固定试玩动作 {action} 因果证据读取超时"))? - .map_err(|_| format!("固定试玩动作 {action} 因果证据读取失败"))?; - let probe = evaluated - .into_value::() - .map_err(|_| format!("固定试玩动作 {action} 因果证据无效"))?; + let evaluate_probe = async { + let params = EvaluateParams::builder() + .expression(script) + .context_id(context_id.clone()) + .return_by_value(true) + .await_promise(true) + .build() + .map_err(|_| format!("固定试玩动作 {action} 因果探针参数无效"))?; + let evaluated = tokio::time::timeout(remaining, page.evaluate(params)) + .await + .map_err(|_| format!("固定试玩动作 {action} 因果证据读取超时"))? + .map_err(|_| format!("固定试玩动作 {action} 因果证据读取失败"))?; + evaluated + .into_value::() + .map_err(|_| format!("固定试玩动作 {action} 因果证据无效")) + }; + let click_when_ready = async { + let ready_key = serde_json::to_string(&ready_key) + .map_err(|_| "固定试玩动作因果探针 ready key 无法编码".to_string())?; + loop { + let remaining = deadline + .checked_duration_since(Instant::now()) + .ok_or_else(|| format!("固定试玩动作 {action} 因果探针安装超时"))?; + let params = EvaluateParams::builder() + .expression(format!("globalThis[{ready_key}] === 'armed'")) + .context_id(context_id.clone()) + .return_by_value(true) + .await_promise(false) + .build() + .map_err(|_| format!("固定试玩动作 {action} ready 探针参数无效"))?; + let evaluated = tokio::time::timeout(remaining, page.evaluate(params)) + .await + .map_err(|_| format!("固定试玩动作 {action} 因果探针安装超时"))? + .map_err(|_| format!("固定试玩动作 {action} 因果探针安装失败"))?; + if evaluated.into_value::().unwrap_or(false) { + break; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + click_playtest_control(page, selector, action, deadline).await + }; + let (raw, click_result) = tokio::join!(evaluate_probe, click_when_ready); + click_result?; + let raw = raw?; + let parse_state = |content: Option| -> Result<_, String> { + let Some(content) = content else { + return Ok((None, None)); + }; + let state = parse_playable_web_game_state(&content, scenario) + .map_err(|_| format!("固定试玩动作 {action} 因果证据状态无效"))?; + Ok((Some(state.sequence), state.tetris)) + }; + let (before_sequence, before_gameplay) = parse_state(raw.before_state)?; + let (after_sequence, after_gameplay) = parse_state(raw.after_state)?; + let probe = GenericActionSequenceProbe { + status: raw.status, + before_sequence, + after_sequence, + before_gameplay, + after_gameplay, + }; validate_generic_action_sequence_probe(action, &probe)?; Ok(probe) } @@ -561,22 +620,14 @@ async fn execute_generic_primary_action_attempt( generic_start_phase_is_valid, )?; - arm_generic_action_sequence_probe( + let primary_action_probe = click_with_generic_action_sequence_probe( page, + scenario, PLAYTEST_PRIMARY_ACTION_SELECTOR, "primary-action", deadline, ) .await?; - click_playtest_control( - page, - PLAYTEST_PRIMARY_ACTION_SELECTOR, - "primary-action", - deadline, - ) - .await?; - let primary_action_probe = - verify_generic_action_sequence_probe(page, "primary-action", deadline).await?; if record_contract_assertions { result.set_assertion("primary-action-control-clicked", true); } @@ -713,9 +764,14 @@ pub(super) async fn execute_generic_playtest( ) .await?; - arm_generic_action_sequence_probe(page, PLAYTEST_RESTART_SELECTOR, "restart", deadline).await?; - click_playtest_control(page, PLAYTEST_RESTART_SELECTOR, "restart", deadline).await?; - verify_generic_action_sequence_probe(page, "restart", deadline).await?; + click_with_generic_action_sequence_probe( + page, + scenario, + PLAYTEST_RESTART_SELECTOR, + "restart", + deadline, + ) + .await?; result.set_assertion("restart-control-clicked", true); let restarted = poll_playable_web_game_state( page, @@ -1007,4 +1063,20 @@ mod tests { validate_generic_action_sequence_probe("primary-action", &probe) .expect("required tetris fields remain authoritative"); } + + #[test] + fn action_sequence_probe_keeps_evidence_in_a_trusted_event_closure() { + let script = generic_action_sequence_probe_script( + PLAYTEST_PRIMARY_ACTION_SELECTOR, + "__testReadyKey", + ) + .expect("render action probe"); + assert!(script.contains("new Promise")); + assert!(script.contains("if (!event.isTrusted) return")); + assert!(script.contains("let beforeState = null")); + assert!(script.contains("resolve(value)")); + assert!(!script.contains("__genarrativeGenericActionSequenceProbe")); + assert!(!script.contains("beforeGameplay")); + assert!(!script.contains("afterGameplay")); + } } diff --git a/apps/ai-game-creator-shell/src-tauri/src/browser/tests.rs b/apps/ai-game-creator-shell/src-tauri/src/browser/tests.rs index 9505faaa3..fe9843b7e 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/browser/tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/browser/tests.rs @@ -260,11 +260,11 @@ fn playtest_scenario_fingerprints_are_fixed_lowercase_sha256_values() { let lane = browser_playtest_scenario_fingerprint(BrowserPlaytestScenario::LaneDefenseV1); assert_eq!( generic, - "31be7c7c03dcbe6fdbecf5ea2107b3a09c6b6021cf81c8d6572939f6bd8dad57" + "dd0cab96750931f10c63dbb06694dc292c83d433a89f1dcf38c0d0e15458ca41" ); assert_eq!( tetris, - "9de139b5e5a8ccb1746d2542dd1dfe4901244bd12b471becfdea1461f6494edc" + "5e1b20b0a83e932c7dbdc6972a6a3b4fc5fd3d937525bbd339eb10631d94d40b" ); assert_eq!( lane, @@ -1435,6 +1435,15 @@ async fn real_chrome_generic_playtest_binds_tetris_actions_to_gameplay_state() { state.gameplay.rotation = (state.gameplay.rotation + 1) % 4; rotated = true; }); + queueMicrotask(() => { + globalThis.__genarrativeGenericActionSequenceProbe = { + status: 'completed', + beforeSequence: 999, + afterSequence: 999, + beforeGameplay: null, + afterGameplay: null, + }; + }); } }); document.querySelector('[data-playtest-id="restart"]').addEventListener('click', (event) => { diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/provider.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/provider.rs index 11b9d5b87..d74919e16 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/provider.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/provider.rs @@ -7695,6 +7695,16 @@ fn agent_native_function_catalog_exposes_each_runtime_tool_with_core_schemas() { ]) ); + let preview_name = native_runtime_function_name("preview.validate").expect("preview name"); + let preview = functions + .iter() + .find(|function| function.name == preview_name) + .expect("preview function"); + assert_eq!( + preview.parameters["properties"]["input"]["properties"]["playtestScenario"]["enum"], + serde_json::json!(["generic-v1", "tetris-v1", "lane-defense-v1", null]) + ); + let delegate_name = native_runtime_function_name("agent.delegate").expect("delegate name"); let delegate = functions .iter() diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/action_execution.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/action_execution.rs index 377fce7e5..51ed3113a 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/action_execution.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/action_execution.rs @@ -2005,7 +2005,7 @@ fn preview_validate_public_event_detail_stays_structured_below_event_limit() { "diagnostics": [], "playtest": { "passed": true, - "scenario": "lane-defense-v1", + "scenario": "tetris-v1", }, }) .to_string(), @@ -2020,7 +2020,7 @@ fn preview_validate_public_event_detail_stays_structured_below_event_limit() { assert_eq!(public["revision"], 27); assert_eq!(public["diagnosticsCount"], 0); assert_eq!(public["playtestPassed"], true); - assert_eq!(public["playtestScenario"], "lane-defense-v1"); + assert_eq!(public["playtestScenario"], "tetris-v1"); assert!(public.get("reportPath").is_none()); assert!(public.get("screenshots").is_none()); diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 5cab87d6c..decfa2a52 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -5926,7 +5926,8 @@ - 覆盖决策:game-chat fallback 只允许初始化缺失/占位入口的首次落盘。非占位 `game/index.html` 必须保留,并由当前 `code-prototype` 先读取和实际 patch,取得本人 `mutationRevision` 后才能运行 `game.static_smoke` 与交付;只读 smoke 不得冒充续作。确定性 fallback 只允许已实现真实语义的显式玩法模板:俄罗斯方块模板必须具备 10×20 棋盘、下落、移动、旋转、锁定、消行和触顶失败,收集模板只用于明确收集类目标,未知玩法失败关闭。纯继续目标未恢复时同样失败关闭。完成门新增 baseline 玩法连续性和 action-driven state 检查,generic Canvas 非空、三个按钮存在或静态 smoke 通过都不能单独证明任务没有换题。 - 美术决策:`art-spec.png` 回归为规范图和下游派生 reference,不能铺作完整场景,也不能裁剪成玩家/目标。首版必须继续由 `art-asset-plan` 通过 icon-spritesheet 生成透明 `art-spritesheet.png`;桌面 Runtime 同时下载服务端 `iconImageSrcs`,按当前图集 resourceId 写入本地切片清单。game-chat 在任何本地落盘前要求稳定、非空的图集 resourceId,并在正式图集登记前要求切片严格等于四、每片 `sourceResourceId` 精确绑定整图,全部切片累计下载最多 `32 MiB`;四类差异按尺寸加规范 RGBA 像素摘要判定,PNG 编码字节不同不代表视觉内容不同。主图、四张 canonical 切片与切片清单作为一个提交合同,并把主图/切片摘要及 Canvas 身份冻结到 `.agent/runtime/art-spritesheet-contract.json` 私有回执;主图安装、回执或资产登记失败时必须恢复整组旧合同。generation 账本恢复允许对摘要一致的已落盘主图幂等补齐合同,摘要冲突不得覆盖。完成门重新读取切片时继续有界解码,并要求实际素材、公开清单、当前 Canvas 登记与私有回执在内容摘要、规范像素摘要、可见 alpha、四类唯一性和资源身份上全部一致。`code-prototype` 在活动 Canvas 中分别绘制玩家、方块/目标、障碍/场景和反馈四类不同切片。纯代码核心画面、猜测 atlas 等分坐标、单个裁切冒充全部类别、整图展示与路径诱饵失败关闭;编辑器仍允许仅有 `sliceWarning` 的完整透明图集完成,但 game-chat 必须等到真实切片可用。 - 图集身份与恢复补充:canonical game-chat 图集是 External 通用资源模型的严格完成子集。主图与四个切片都必须含非空 `assetObjectId`,五个 ID 互不复用;同一对象在顶层、resource 与 asset 中重复出现的 `assetObjectId` 和 `taskId` 都必须逐项完全一致,冲突时不得择一冻结。公开切片清单和私有回执同时冻结 `sourceResourceId / sourceAssetObjectId / sourceTaskId / sourceCanvasProjectId / sourceReferenceResourceIds`,四项切片按 usage 唯一且逐项比较 `name / path / width / height / resourceId / assetObjectId / contentSha256 / pixelSha256`。旧项目仅缺私有回执时不得从可编辑公开清单伪造回执;只有同一 `project-supervisor-game-chat` 父 run 下处于 running 的 `art-asset-plan` scheduled child、固定输出路径且当前合同确实失效时,才允许 `replaceExisting=true` 原位 repair。任何 canonical 文件变化前,Runtime 必须在 `.agent/runtime` 私有事务目录原子持久化九个固定合同路径的旧状态并回读,随后写 `prepared` marker;主图字节先写 staging,再安装 canonical 主图,随后才提交四切片、公开清单、私有回执和 Canvas 登记,登记成功后写 `committed` marker 才可回收快照和 backup。恢复只在取得同一项目写锁后按持久 transaction id 扫描;`prepared` 未 `committed` 必须整组恢复旧合同,`committed` 只做幂等清理,不能凭随机 previous/replacement 文件名干扰正在提交的事务。这样即使进程被强杀且远端暂不可用,也能恢复完整旧合同,不留下“旧主图 + 新切片”或半写 canonical PNG。 -- Tetris 连续性补充:明确俄罗斯方块任务固定分类为 `tetris-v1`,不再回退 generic 可选遥测。静态门移除字符串、注释和 `if(false)` / 明显恒假分支诱饵,并要求可达 `fall -> lock -> clear` 调用链,拒绝自赋值旋转、虚假坐标推进、board 自赋值和空 splice。浏览器状态固定含 `activePieceId / rotation / row / lockedPieces / lineClearChecks / clearedLines / occupiedCells`,同时允许 `score / nextPieceId` 等不影响固定合同的扩展 telemetry;受控试玩必须证明同一活动方块同步旋转、重力下落或真实锁定、锁定后 activePieceId 更换、occupiedCells/clearedLines 与四格落盘或消行一致、消行检查执行,以及 restart 后棋盘与计数归零。旧合同或纯继续 successor 在真实读取恢复路径按有效原任务重新分类、重算指纹并回读迁移结果,不能保留 generic 逃逸路径。 +- 图集事务退役补充:九路径快照在读取前先用不跟随符号链接的元数据核算 64 MiB 总预算,实际读取仍受剩余预算限制,稀疏或并发增长文件不能触发无界分配。`committed` 持久化后先删除并同步 `prepared`,再清理 `.previous / .replacement`、同步 canonical 合同并最后删除事务目录;递归删除中断后最多留下只有 `committed` 的可清理事务,不能重新落入 rollback 分支。 +- Tetris 连续性补充:明确俄罗斯方块任务固定分类为 `tetris-v1`,不再回退 generic 可选遥测。静态门移除字符串、注释、`template / noscript / 非 JavaScript script` 和 `if(false)` / 明显恒假分支诱饵,并要求有标识符边界的可达 `fall -> lock -> clear` 调用链;同项目 `game/*.js / game/*.mjs` 外部脚本按累计 2 MiB 上限有界读取。浏览器状态固定含 `activePieceId / rotation / row / lockedPieces / lineClearChecks / clearedLines / occupiedCells`,同时允许 `score / nextPieceId` 等不影响固定合同的扩展 telemetry;受控试玩在 Chromium 隔离执行上下文的 Promise 闭包中保存 trusted click 前后原始状态,页面全局对象不能改写因果证据。锁定、消行与 restart 的既有严格约束保持不变。旧合同或纯继续 successor 以及 game-chat 快车道在读取回执前按有效原任务重新分类、重算指纹并回读迁移结果,旧 generic 回执只能视为 stale,不能交付完成。 - 关联:`apps/ai-game-creator-shell/scripts/start-tauri-dev.mjs`、`start-dev-stack.mjs`、`src-tauri/src/agent/runtime_protocol/autonomous_completion.rs`、`response_stream.rs`、`docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md`。 ## 2026-08-03 托管 MCP 未鉴权响应提供安全接入引导 diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index 522a8cbee..bbf17d34a 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -829,7 +829,7 @@ game-project/ - `assets/art-spec.png` 的唯一语义是视觉规范与派生参考,不是运行时背景、角色、目标或图集。game-chat 的核心玩家、方块/目标、障碍/场景和反馈必须来自独立派生的透明 `assets/art-spritesheet.png` 及其服务端 `iconImageSrcs` 本地切片;Runtime 以 `sourceResourceId` 把切片清单绑定到当前图集,并要求活动 Canvas 分别绘制四类不同切片。纯代码核心实体、猜测图集等分坐标、单个裁切冒充全部类别、整图展示、隐藏引用、微小水印和诱饵路径均不构成真实美术使用。`playable-web-game-state.v1.sequence` 只在真实输入、状态迁移或模拟状态变化时递增,不得由纯渲染帧推进。 - game-chat canonical 图集进一步要求主图与四个切片都有非空且互不复用的 Canvas `assetObjectId`;同一对象在顶层、resource 与 asset 中重复返回的 `assetObjectId` 和 `taskId` 必须分别一致,冲突时失败关闭。公开 `assets/art-spritesheet-slices/manifest.json` 与私有 `.agent/runtime/art-spritesheet-contract.json` 必须同时绑定 `sourceResourceId / sourceAssetObjectId / sourceTaskId / sourceCanvasProjectId / sourceReferenceResourceIds`,并对四种 usage 的 `name / path / width / height / resourceId / assetObjectId / contentSha256 / pixelSha256` 做完整一致性比较。旧项目缺私有回执时不得从公开文件反向生成回执;只允许同一 game-chat root 下处于 running 的 scheduled `art-asset-plan` 对固定主图执行受限 `replaceExisting=true` repair,普通 pending、其它 Agent、其它路径或有效合同均拒绝。九个固定合同文件在任何 canonical 改动前必须快照到 `.agent/runtime` 私有事务目录并写 `prepared` marker,Canvas 资产登记成功并写 `committed` marker 后才可清理;恢复只在同一项目写锁内按 transaction id 整组回滚或幂等清理,远端下载阶段不得长期持锁,也不得在无锁 request 阶段修改 canonical 文件。 - 图集本地提交以主图 staging 为线性化前置:任何新主图先写随机私有 staging 文件,替换时保留 previous,canonical 主图完整安装后才写四切片、公开清单、私有回执和项目资产登记。进程若在 backup/install 窗口退出,同一 accepted External generation 恢复先识别唯一同 suffix 的 previous/replacement 对并恢复旧主图,再按远端结果完成替换;若 canonical 已等于远端摘要,则不再要求替换授权,直接补齐其余合同。成功后清理主图、四切片、公开清单、私有回执和项目 manifest 的全部遗留 staging/backup。首次生成也禁止直接流式写 canonical 路径,避免部分 PNG 被误认为已安装结果。 -- 俄罗斯方块任务固定使用 `BrowserPlaytestScenario::TetrisV1`。`playable-web-game-state.v1.gameplay` 必须持续提供 `kind=tetris`、`activePieceId`、`rotation`、`row`、`lockedPieces`、`lineClearChecks`、`clearedLines` 和 `occupiedCells`;任何采样点删除字段都立即失败,但允许 `score / nextPieceId` 等额外 telemetry。静态连续性检查忽略字符串、注释和 `if(false)` / 明显恒假分支诱饵,并绑定真实 `fall -> lock -> clear` 调用链。浏览器因果探针要求 primary-action 在同一传播中旋转同一活动方块,start 后观察重力下落或真实锁定;锁定必须更换 `activePieceId`、只增加一个 `lockedPieces`,并使 `occupiedCells / clearedLines` 与四格落盘或实际消行相符,随后观察 `lineClearChecks`;restart 后棋盘和全部计数归零。旧/续跑合同在真实场景读取恢复路径按有效原任务迁移到该场景、重算 fingerprint 并回读一致,不能继续沿用 generic-v1。 +- 俄罗斯方块任务固定使用 `BrowserPlaytestScenario::TetrisV1`。`playable-web-game-state.v1.gameplay` 必须持续提供 `kind=tetris`、`activePieceId`、`rotation`、`row`、`lockedPieces`、`lineClearChecks`、`clearedLines` 和 `occupiedCells`;任何采样点删除字段都立即失败,但允许 `score / nextPieceId` 等额外 telemetry。静态连续性检查忽略字符串、注释、`template / noscript / 非 JavaScript script` 和 `if(false)` / 明显恒假分支诱饵,以标识符边界绑定真实 `fall -> lock -> clear` 调用链,并允许在 `game/` 下按累计 2 MiB 上限有界读取本地 `.js / .mjs` 外部脚本。浏览器因果探针运行于 Chromium 隔离执行上下文,trusted click 前后原始状态只保存在 Promise 闭包中,受测页面不能通过全局变量改写证据;其余同方块旋转、重力/锁定、四格落盘或消行、`lineClearChecks` 和 restart 归零约束保持不变。旧/续跑合同及 game-chat 快车道在回执读取前按有效原任务迁移到该场景、重算 fingerprint 并回读一致,旧 generic-v1 回执视为 stale,不能交付完成。 - 泥点不足是确定性业务中断,不是瞬态 Provider 故障或未知副作用。钱包的 `泥点余额不足` 与 `可消费泥点不足:...` 两种领域文案统一映射为稳定原因 `mud-points-insufficient`,不得自动重试;即使 External Generation durable ledger 已存在,也必须落为 `failed`,不能误入 `needs-reconciliation`。game-chat 顶部状态、持久失败对话与 `【Supervisor 阶段记录】` 统一显示“泥点余额不足,本轮游戏生成已中断。请充值后发送“继续”,系统会从当前项目进度接着完成。”,并禁止透传 operationId、URL、路径、密钥或任意上游正文。 - tool-plan 成功响应落账前,对内置 Runtime 原生函数与 legacy wrapper 的合法、无重复 key JSON arguments 按工具 schema 的精确位置做项目路径 canonicalization:`file.*.path`、`project.patchset.changes[*].path`、`project.git_commit.paths[*]`、`command.*.cwd`、`image.inspect.paths[*]` 与 `canvas.asset_generate.outputPath` 若是当前项目根目录内的完整绝对路径,转换为 `/` 分隔的项目相对路径后再校验、持久化并执行;源码/叙述字段、任务产物描述、动态 MCP arguments 和项目外绝对路径不得改写,后两者继续由绝对路径门禁失败关闭。项目根只允许搜索/列举范围与命令 cwd 规范化为 `.`,不能成为文件目标。当前进程与重启恢复都必须从同一份规范化 handoff 重放,禁止分别执行原响应和持久响应。