From d1e3cc319d8111f2ad0add225e67ce07945f49ef Mon Sep 17 00:00:00 2001 From: kdletters Date: Tue, 4 Aug 2026 03:53:13 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E6=B8=B8=E6=88=8F=E9=AA=8C?= =?UTF-8?q?=E6=94=B6=E7=AB=9E=E6=80=81=E4=B8=8E=E9=9D=99=E6=80=81=E9=97=A8?= =?UTF-8?q?=E8=BE=B9=E7=95=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 图集事务使用跨平台可信有界双读并在恢复失败时逆序回滚 浏览器试玩在同一任务冻结输入尾部状态并绑定完整探针指纹 俄罗斯方块静态门按真实模块图和可达语义拒绝脚本诱饵 补齐事务、浏览器与俄罗斯方块回归测试及技术文档 --- .../src/agent/generation/canvas_generation.rs | 689 ++++++++++++++-- .../runtime_protocol/autonomous_completion.rs | 768 +++++++++++++++--- .../autonomous_completion_contract_tests.rs | 362 ++++++++- .../src-tauri/src/browser/playtest/generic.rs | 153 +++- .../src-tauri/src/browser/playtest/mod.rs | 17 +- .../src-tauri/src/browser/tests.rs | 131 ++- .../shared-memory/decision-log.md | 4 +- ...案】AI游戏创作智能体App实施计划-2026-06-24.md | 4 +- 8 files changed, 1877 insertions(+), 251 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 11baaec1c..406c37a29 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 @@ -1806,6 +1806,270 @@ fn write_atomic_platform_art_transaction_marker( sync_platform_art_directory(transaction_directory, "平台图集事务") } +fn platform_art_transaction_metadata_is_trusted(metadata: &fs::Metadata, max_bytes: u64) -> bool { + if !metadata.is_file() || metadata.len() > max_bytes { + return false; + } + #[cfg(windows)] + { + use std::os::windows::fs::MetadataExt; + + const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x0000_0400; + if metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 { + return false; + } + } + true +} + +fn platform_art_transaction_metadata_unchanged( + before: &fs::Metadata, + after: &fs::Metadata, +) -> bool { + #[cfg(unix)] + { + use std::os::unix::fs::MetadataExt; + + return before.dev() == after.dev() + && before.ino() == after.ino() + && before.len() == after.len() + && before.mtime() == after.mtime() + && before.mtime_nsec() == after.mtime_nsec() + && before.ctime() == after.ctime() + && before.ctime_nsec() == after.ctime_nsec(); + } + #[cfg(windows)] + { + use std::os::windows::fs::MetadataExt; + + return before.len() == after.len() + && before.file_attributes() == after.file_attributes() + && before.modified().ok() == after.modified().ok(); + } + #[cfg(not(any(unix, windows)))] + { + before.len() == after.len() && before.modified().ok() == after.modified().ok() + } +} + +fn open_platform_art_transaction_file_for_read(path: &Path) -> std::io::Result { + let mut options = fs::OpenOptions::new(); + options.read(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + + options.custom_flags(libc::O_NOFOLLOW); + } + #[cfg(windows)] + { + use std::os::windows::fs::OpenOptionsExt; + + const FILE_FLAG_OPEN_REPARSE_POINT: u32 = 0x0020_0000; + options.custom_flags(FILE_FLAG_OPEN_REPARSE_POINT); + } + options.open(path) +} + +#[cfg(windows)] +fn platform_art_transaction_windows_file_identity(file: &fs::File) -> Result<(u32, u64), String> { + use std::ffi::c_void; + use std::os::windows::io::AsRawHandle; + + #[repr(C)] + struct FileTime { + low_date_time: u32, + high_date_time: u32, + } + #[repr(C)] + struct ByHandleFileInformation { + file_attributes: u32, + creation_time: FileTime, + last_access_time: FileTime, + last_write_time: FileTime, + volume_serial_number: u32, + file_size_high: u32, + file_size_low: u32, + number_of_links: u32, + file_index_high: u32, + file_index_low: u32, + } + #[link(name = "kernel32")] + unsafe extern "system" { + fn GetFileInformationByHandle( + file: *mut c_void, + information: *mut ByHandleFileInformation, + ) -> i32; + } + + // SAFETY: the structure is plain data initialized by GetFileInformationByHandle. + let mut information = unsafe { std::mem::zeroed::() }; + // SAFETY: file owns a live handle and information is a valid output pointer. + if unsafe { GetFileInformationByHandle(file.as_raw_handle().cast(), &mut information) } == 0 { + return Err(format!( + "读取平台图集事务 Windows 文件身份失败:{}", + std::io::Error::last_os_error() + )); + } + Ok(( + information.volume_serial_number, + (u64::from(information.file_index_high) << 32) | u64::from(information.file_index_low), + )) +} + +fn platform_art_transaction_open_files_match( + left_file: &fs::File, + left: &fs::Metadata, + right_file: &fs::File, + right: &fs::Metadata, +) -> Result { + #[cfg(unix)] + { + use std::os::unix::fs::MetadataExt; + + let _ = (left_file, right_file); + return Ok(left.dev() == right.dev() && left.ino() == right.ino()); + } + #[cfg(windows)] + { + let _ = (left, right); + return Ok(platform_art_transaction_windows_file_identity(left_file)? + == platform_art_transaction_windows_file_identity(right_file)?); + } + #[cfg(not(any(unix, windows)))] + { + let _ = (left_file, right_file); + Ok(platform_art_transaction_metadata_unchanged(left, right)) + } +} + +fn read_platform_art_transaction_file_once( + file: &mut fs::File, + max_bytes: u64, + label: &str, + path: &Path, +) -> Result, String> { + use std::io::{Seek, SeekFrom}; + + file.seek(SeekFrom::Start(0)) + .map_err(|error| format!("定位{label}读取位置失败:{}: {error}", path.display()))?; + let mut bytes = Vec::new(); + file.take(max_bytes.saturating_add(1)) + .read_to_end(&mut bytes) + .map_err(|error| format!("读取{label}失败:{}: {error}", path.display()))?; + if u64::try_from(bytes.len()).unwrap_or(u64::MAX) > max_bytes { + return Err(format!("{label}超出大小上限")); + } + Ok(bytes) +} + +fn read_bounded_platform_art_transaction_file_with_hook( + path: &Path, + max_bytes: u64, + label: &str, + after_first_read: F, +) -> Result, String> +where + F: FnOnce() -> Result<(), String>, +{ + let path_metadata = fs::symlink_metadata(path) + .map_err(|error| format!("读取{label}元数据失败:{}: {error}", path.display()))?; + if path_metadata.file_type().is_symlink() + || !platform_art_transaction_metadata_is_trusted(&path_metadata, max_bytes) + { + return Err(format!("{label}不是可信普通文件或超出大小上限")); + } + let mut file = open_platform_art_transaction_file_for_read(path) + .map_err(|error| format!("打开{label}失败:{}: {error}", path.display()))?; + let opened_metadata_before = file + .metadata() + .map_err(|error| format!("读取已打开{label}元数据失败:{}: {error}", path.display()))?; + if !platform_art_transaction_metadata_is_trusted(&opened_metadata_before, max_bytes) { + return Err(format!("{label}不是可信普通文件或超出大小上限")); + } + #[cfg(unix)] + { + use std::os::unix::fs::MetadataExt; + + if path_metadata.dev() != opened_metadata_before.dev() + || path_metadata.ino() != opened_metadata_before.ino() + || !platform_art_transaction_metadata_unchanged(&path_metadata, &opened_metadata_before) + { + return Err(format!("{label}在读取期间发生变化,已拒绝继续")); + } + } + #[cfg(not(unix))] + if !platform_art_transaction_metadata_unchanged(&path_metadata, &opened_metadata_before) { + return Err(format!("{label}在读取期间发生变化,已拒绝继续")); + } + + let initial_current_file = open_platform_art_transaction_file_for_read(path) + .map_err(|error| format!("复核{label}路径失败:{}: {error}", path.display()))?; + let initial_current_metadata = initial_current_file + .metadata() + .map_err(|error| format!("读取复核{label}路径元数据失败:{}: {error}", path.display()))?; + if !platform_art_transaction_metadata_is_trusted(&initial_current_metadata, max_bytes) + || !platform_art_transaction_open_files_match( + &file, + &opened_metadata_before, + &initial_current_file, + &initial_current_metadata, + )? + { + return Err(format!("{label}在读取前发生变化,已拒绝继续")); + } + + let first = read_platform_art_transaction_file_once(&mut file, max_bytes, label, path)?; + after_first_read()?; + let second = read_platform_art_transaction_file_once(&mut file, max_bytes, label, path)?; + let opened_metadata_after = file.metadata().map_err(|error| { + format!( + "读取已打开{label}结束元数据失败:{}: {error}", + path.display() + ) + })?; + let current_path_metadata = fs::symlink_metadata(path) + .map_err(|error| format!("复核{label}元数据失败:{}: {error}", path.display()))?; + let current_file = open_platform_art_transaction_file_for_read(path) + .map_err(|error| format!("复核{label}当前路径失败:{}: {error}", path.display()))?; + let current_metadata = current_file.metadata().map_err(|error| { + format!( + "读取复核{label}当前路径元数据失败:{}: {error}", + path.display() + ) + })?; + if current_path_metadata.file_type().is_symlink() + || !platform_art_transaction_metadata_is_trusted(&opened_metadata_after, max_bytes) + || !platform_art_transaction_metadata_is_trusted(¤t_path_metadata, max_bytes) + || !platform_art_transaction_metadata_is_trusted(¤t_metadata, max_bytes) + || !platform_art_transaction_metadata_unchanged( + &opened_metadata_before, + &opened_metadata_after, + ) + || !platform_art_transaction_metadata_unchanged(&path_metadata, ¤t_path_metadata) + || !platform_art_transaction_metadata_unchanged(&opened_metadata_after, ¤t_metadata) + || !platform_art_transaction_open_files_match( + &file, + &opened_metadata_after, + ¤t_file, + ¤t_metadata, + )? + || first != second + || u64::try_from(second.len()).unwrap_or(u64::MAX) != opened_metadata_after.len() + { + return Err(format!("{label}在读取期间发生变化,已拒绝继续")); + } + Ok(second) +} + +fn read_bounded_platform_art_transaction_file( + path: &Path, + max_bytes: u64, + label: &str, +) -> Result, String> { + read_bounded_platform_art_transaction_file_with_hook(path, max_bytes, label, || Ok(())) +} + fn strict_platform_art_transaction_directory(root: &Path) -> Result { resolve_local_project_path(root, STRICT_PLATFORM_ART_TRANSACTION_PATH) } @@ -1842,8 +2106,11 @@ fn strict_platform_art_transaction_marker_exists( Err(format!("平台图集事务 {label} marker 超出大小上限")) } Ok(_) => { - let actual = fs::read(path) - .map_err(|error| format!("读取平台图集事务 {label} marker 失败:{error}"))?; + let actual = read_bounded_platform_art_transaction_file( + path, + 64, + &format!("平台图集事务 {label} marker"), + )?; if actual != expected { return Err(format!("平台图集事务 {label} marker 内容无效,已拒绝恢复")); } @@ -1854,6 +2121,47 @@ fn strict_platform_art_transaction_marker_exists( } } +fn preflight_platform_art_recovery_target(path: &Path) -> Result<(), String> { + match fs::symlink_metadata(path) { + Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_file() => { + return Err(format!( + "平台图集事务恢复目标不是可信普通文件:{}", + path.display() + )); + } + Ok(_) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => { + return Err(format!( + "检查平台图集事务恢复目标失败:{}: {error}", + path.display() + )); + } + } + let mut ancestor = path.parent(); + while let Some(parent) = ancestor { + match fs::symlink_metadata(parent) { + Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_dir() => { + return Err(format!( + "平台图集事务恢复目标父路径不是可信目录:{}", + parent.display() + )); + } + Ok(_) => return Ok(()), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + ancestor = parent.parent(); + } + Err(error) => { + return Err(format!( + "检查平台图集事务恢复目标父路径失败:{}: {error}", + parent.display() + )); + } + } + } + Err("平台图集事务恢复目标缺少可信父目录".to_string()) +} + fn sync_strict_platform_art_contract_state_at( root: &Path, require_complete: bool, @@ -1910,6 +2218,74 @@ fn restore_strict_platform_art_transaction_at( root: &Path, transaction_directory: &Path, ) -> Result { + restore_strict_platform_art_transaction_at_with_hook(root, transaction_directory, |_, _| Ok(())) +} + +fn rollback_applied_platform_art_recovery( + applied: &[(PathBuf, Option>)], + recovery_suffix: &str, +) -> Result<(), String> { + let rollback_suffix = format!("{recovery_suffix}.rollback"); + let mut errors = Vec::new(); + for (canonical, previous) in applied.iter().rev() { + let result = if let Some(previous) = previous { + if let Some(parent) = canonical.parent() { + if let Err(error) = fs::create_dir_all(parent) { + errors.push(format!( + "创建平台图集事务回滚目录失败:{}: {error}", + parent.display() + )); + continue; + } + } + replace_platform_art_slice_file(canonical, previous, &rollback_suffix) + } else { + match fs::remove_file(canonical) { + Ok(()) => Ok(()), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(format!( + "回滚平台图集事务恢复新增文件失败:{}: {error}", + canonical.display() + )), + } + }; + if let Err(error) = result { + errors.push(error); + } + } + if errors.is_empty() { + Ok(()) + } else { + Err(errors.join(";")) + } +} + +fn platform_art_recovery_error_after_rollback( + root: &Path, + applied: &[(PathBuf, Option>)], + recovery_suffix: &str, + error: String, +) -> String { + if applied.is_empty() { + return error; + } + match rollback_applied_platform_art_recovery(applied, recovery_suffix) { + Ok(()) => error, + Err(rollback_error) => format!( + "{PLATFORM_ART_LOCAL_RECONCILIATION_PREFIX} {error};反向回滚本轮平台图集恢复失败:{rollback_error};项目根目录:{}", + root.display() + ), + } +} + +fn restore_strict_platform_art_transaction_at_with_hook( + root: &Path, + transaction_directory: &Path, + mut before_apply: F, +) -> Result +where + F: FnMut(usize, &Path) -> Result<(), String>, +{ let transaction_metadata = fs::symlink_metadata(transaction_directory) .map_err(|error| format!("读取平台图集事务目录失败:{error}"))?; if transaction_metadata.file_type().is_symlink() || !transaction_metadata.is_dir() { @@ -1929,19 +2305,13 @@ fn restore_strict_platform_art_transaction_at( return Ok(false); } let journal_path = transaction_directory.join(STRICT_PLATFORM_ART_TRANSACTION_JOURNAL); - let journal_metadata = fs::symlink_metadata(&journal_path) - .map_err(|error| format!("读取平台图集事务 journal 元数据失败:{error}"))?; - if journal_metadata.file_type().is_symlink() - || !journal_metadata.is_file() - || journal_metadata.len() > 128 * 1024 - { - return Err("平台图集事务 journal 不是可信普通文件或超出大小上限".to_string()); - } - let journal: serde_json::Value = serde_json::from_slice( - &fs::read(&journal_path) - .map_err(|error| format!("读取平台图集事务 journal 失败:{error}"))?, - ) - .map_err(|error| format!("解析平台图集事务 journal 失败:{error}"))?; + let journal_bytes = read_bounded_platform_art_transaction_file( + &journal_path, + 128 * 1024, + "平台图集事务 journal", + )?; + let journal: serde_json::Value = serde_json::from_slice(&journal_bytes) + .map_err(|error| format!("解析平台图集事务 journal 失败:{error}"))?; if journal .get("schemaVersion") .and_then(serde_json::Value::as_str) @@ -1993,45 +2363,17 @@ fn restore_strict_platform_art_transaction_at( return Err("平台图集事务 journal 的快照文件名无效".to_string()); } let snapshot_path = transaction_directory.join(expected_snapshot); - let metadata = fs::symlink_metadata(&snapshot_path) - .map_err(|error| format!("读取平台图集事务快照失败:{error}"))?; - if metadata.file_type().is_symlink() || !metadata.is_file() { - return Err("平台图集事务快照不是可信普通文件".to_string()); - } 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 snapshot_file = options - .open(&snapshot_path) - .map_err(|error| format!("打开平台图集事务快照失败:{error}"))?; - let opened_metadata = snapshot_file - .metadata() - .map_err(|error| format!("读取已打开平台图集事务快照元数据失败:{error}"))?; - if !opened_metadata.is_file() || opened_metadata.len() != metadata.len() { - return Err("平台图集事务快照在恢复校验期间发生变化,已拒绝恢复".to_string()); - } - let mut snapshot = - Vec::with_capacity(usize::try_from(opened_metadata.len()).unwrap_or_default()); - (&mut snapshot_file) - .take(remaining.saturating_add(1)) - .read_to_end(&mut snapshot) - .map_err(|error| format!("读取平台图集事务快照失败:{error}"))?; + let snapshot = read_bounded_platform_art_transaction_file( + &snapshot_path, + remaining, + "平台图集事务快照", + )?; let snapshot_len = u64::try_from(snapshot.len()).unwrap_or(u64::MAX); if snapshot_len > remaining { return Err("平台图集事务快照累计超过 64 MiB,已拒绝恢复".to_string()); } - if snapshot_len != opened_metadata.len() { - return Err("平台图集事务快照大小在恢复校验期间发生变化,已拒绝恢复".to_string()); - } total_snapshot_bytes = total_snapshot_bytes .checked_add(snapshot_len) .ok_or_else(|| "平台图集事务快照累计大小溢出".to_string())?; @@ -2047,28 +2389,105 @@ fn restore_strict_platform_art_transaction_at( recovery_plan.push((canonical, None)); } } - for (canonical, snapshot) in recovery_plan { - if let Some(snapshot) = snapshot { - if let Some(parent) = canonical.parent() { - fs::create_dir_all(parent).map_err(|error| { - format!( - "创建平台图集事务恢复目录失败:{}: {error}", - parent.display() - ) - })?; - } - replace_platform_art_slice_file(&canonical, &snapshot, &recovery_suffix)?; - } else { - match fs::remove_file(&canonical) { - Ok(()) => {} - Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + for (canonical, _) in &recovery_plan { + preflight_platform_art_recovery_target(canonical)?; + } + let mut applied = Vec::with_capacity(recovery_plan.len()); + let mut total_rollback_bytes = 0_u64; + for (index, (canonical, snapshot)) in recovery_plan.into_iter().enumerate() { + if let Err(error) = before_apply(index, &canonical) { + return Err(platform_art_recovery_error_after_rollback( + root, + &applied, + &recovery_suffix, + error, + )); + } + if let Err(error) = preflight_platform_art_recovery_target(&canonical) { + return Err(platform_art_recovery_error_after_rollback( + root, + &applied, + &recovery_suffix, + error, + )); + } + let remaining = + STRICT_PLATFORM_ART_TRANSACTION_MAX_SNAPSHOT_BYTES.saturating_sub(total_rollback_bytes); + let previous = match fs::symlink_metadata(&canonical) { + Ok(_) => match read_bounded_platform_art_transaction_file( + &canonical, + remaining, + "平台图集事务恢复前合同", + ) { + Ok(previous) => Some(previous), Err(error) => { - return Err(format!( - "恢复平台图集事务的原始缺失状态失败:{}: {error}", - canonical.display() + return Err(platform_art_recovery_error_after_rollback( + root, + &applied, + &recovery_suffix, + error, )); } + }, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => None, + Err(error) => { + return Err(platform_art_recovery_error_after_rollback( + root, + &applied, + &recovery_suffix, + format!( + "读取平台图集事务恢复前合同失败:{}: {error}", + canonical.display() + ), + )); } + }; + if let Some(previous) = &previous { + total_rollback_bytes = match total_rollback_bytes + .checked_add(u64::try_from(previous.len()).unwrap_or(u64::MAX)) + { + Some(total) if total <= STRICT_PLATFORM_ART_TRANSACTION_MAX_SNAPSHOT_BYTES => total, + _ => { + return Err(platform_art_recovery_error_after_rollback( + root, + &applied, + &recovery_suffix, + "平台图集事务恢复前合同累计超过 64 MiB,已拒绝恢复".to_string(), + )); + } + }; + } + applied.push((canonical.clone(), previous)); + let apply_result = if let Some(snapshot) = snapshot { + if let Some(parent) = canonical.parent() { + if let Err(error) = fs::create_dir_all(parent) { + Err(format!( + "创建平台图集事务恢复目录失败:{}: {error}", + parent.display() + )) + } else { + replace_platform_art_slice_file(&canonical, &snapshot, &recovery_suffix) + } + } else { + replace_platform_art_slice_file(&canonical, &snapshot, &recovery_suffix) + } + } else { + match fs::remove_file(&canonical) { + Ok(()) => Ok(()), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(format!( + "恢复平台图集事务的原始缺失状态失败:{}: {error}", + canonical.display() + )), + } + }; + if let Err(error) = apply_result { + return Err(platform_art_recovery_error_after_rollback( + root, + &applied, + &recovery_suffix, + error, + )); } } cleanup_interrupted_platform_art_contract_files_at(root)?; @@ -4973,6 +5392,144 @@ mod canvas_generation_tests { drop(project_lock); } + #[test] + fn durable_strict_contract_transaction_preflights_every_target_before_restoring_any_path() { + let temporary = tempfile::tempdir().expect("create target preflight project"); + let root = temporary.path(); + init_local_game_project_at(root, "target-preflight", "图集恢复目标预检测试") + .expect("init project"); + let last_index = STRICT_PLATFORM_ART_CONTRACT_PATHS.len() - 1; + for (index, local_path) in STRICT_PLATFORM_ART_CONTRACT_PATHS.iter().enumerate() { + let path = root.join(local_path); + if index == last_index { + fs::remove_file(&path).ok(); + continue; + } + fs::create_dir_all(path.parent().expect("contract parent")) + .expect("create contract parent"); + fs::write(path, format!("old-contract-{index}")).expect("write original contract"); + } + + let project_lock = acquire_project_write_lock(root, "canvas.asset_generate.recover-test") + .expect("acquire project write lock"); + let transaction = PlatformArtSliceContractRollback::capture(root, "target-preflight") + .expect("persist complete contract snapshot"); + let mut partial_contract = Vec::new(); + for (index, local_path) in STRICT_PLATFORM_ART_CONTRACT_PATHS.iter().enumerate() { + let path = root.join(local_path); + if index == last_index { + fs::create_dir_all(&path).expect("create conflicting late target directory"); + continue; + } + let bytes = format!("partial-new-contract-{index}").into_bytes(); + fs::write(&path, &bytes).expect("write partial canonical contract"); + partial_contract.push((path, bytes)); + } + std::mem::forget(transaction); + + let error = + recover_interrupted_strict_platform_art_transaction_locked_at(root, &project_lock) + .expect_err("a conflicting late target must fail before canonical restoration"); + assert!( + error.contains("恢复目标不是可信普通文件"), + "unexpected error: {error}" + ); + for (path, expected) in partial_contract { + assert_eq!( + fs::read(&path).expect("read untouched partial contract"), + expected, + "target preflight failure must not modify {}", + path.display() + ); + } + assert!(root.join(STRICT_PLATFORM_ART_TRANSACTION_PATH).exists()); + drop(project_lock); + } + + #[test] + fn durable_strict_contract_transaction_rejects_same_length_snapshot_rewrite_during_read() { + let temporary = tempfile::tempdir().expect("create concurrent snapshot project"); + let snapshot_path = temporary.path().join("00.snapshot"); + fs::write(&snapshot_path, b"old-data").expect("write initial snapshot"); + + let error = read_bounded_platform_art_transaction_file_with_hook( + &snapshot_path, + 64, + "测试平台图集事务快照", + || { + fs::write(&snapshot_path, b"new-data") + .map_err(|error| format!("rewrite same-length snapshot: {error}")) + }, + ) + .expect_err("same-length rewrite between the two reads must fail closed"); + + assert!( + error.contains("读取期间发生变化"), + "unexpected error: {error}" + ); + } + + #[test] + fn durable_strict_contract_transaction_rolls_back_when_late_target_changes_after_preflight() { + let temporary = tempfile::tempdir().expect("create late target race project"); + let root = temporary.path(); + init_local_game_project_at(root, "late-target-race", "图集恢复晚序竞态测试") + .expect("init project"); + for (index, local_path) in STRICT_PLATFORM_ART_CONTRACT_PATHS.iter().enumerate() { + let path = root.join(local_path); + fs::create_dir_all(path.parent().expect("contract parent")) + .expect("create contract parent"); + fs::write(path, format!("old-contract-{index}")).expect("write original contract"); + } + + let transaction = PlatformArtSliceContractRollback::capture(root, "late-target-race") + .expect("persist complete contract snapshot"); + let transaction_directory = transaction.transaction_directory.clone(); + let mut partial_contract = Vec::new(); + for (index, local_path) in STRICT_PLATFORM_ART_CONTRACT_PATHS.iter().enumerate() { + let path = root.join(local_path); + let bytes = format!("partial-new-contract-{index}").into_bytes(); + fs::write(&path, &bytes).expect("write partial canonical contract"); + partial_contract.push((path, bytes)); + } + std::mem::forget(transaction); + + let late_index = STRICT_PLATFORM_ART_CONTRACT_PATHS.len() - 1; + let late_path = partial_contract[late_index].0.clone(); + let error = restore_strict_platform_art_transaction_at_with_hook( + root, + &transaction_directory, + |index, canonical| { + if index == late_index { + fs::remove_file(canonical) + .map_err(|error| format!("remove late target fixture: {error}"))?; + fs::create_dir(canonical) + .map_err(|error| format!("replace late target with directory: {error}"))?; + } + Ok(()) + }, + ) + .expect_err("late target change after global preflight must fail closed"); + + assert!( + error.contains("恢复目标不是可信普通文件"), + "unexpected error: {error}" + ); + for (path, expected) in partial_contract.iter().take(late_index) { + assert_eq!( + fs::read(path).expect("read rolled-back partial contract"), + *expected, + "late target failure must roll back earlier recovery at {}", + path.display() + ); + } + assert!( + late_path.is_dir(), + "the externally changed late target remains intact" + ); + assert!(transaction_directory.exists()); + } + #[test] fn durable_strict_contract_transaction_rejects_oversized_sparse_snapshot_before_reading() { let temporary = tempfile::tempdir().expect("create oversized snapshot project"); 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 97f8ddd51..45a28170e 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 @@ -567,6 +567,10 @@ fn relative_visual_url_resolves_to_asset(value: &str, asset_path: &str) -> bool components.join("/") == asset_path } +fn is_html_ascii_whitespace(byte: u8) -> bool { + matches!(byte, b'\t' | b'\n' | b'\x0c' | b'\r' | b' ') +} + fn html_attribute_value<'a>(tag: &'a str, attribute: &str) -> Option<&'a str> { let bytes = tag.as_bytes(); let attribute_bytes = attribute.as_bytes(); @@ -576,15 +580,15 @@ fn html_attribute_value<'a>(tag: &'a str, attribute: &str) -> Option<&'a str> { let start = cursor + offset; let end = start + attribute_bytes.len(); let left_boundary = - start == 0 || matches!(bytes[start - 1], b'<' | b' ' | b'\t' | b'\r' | b'\n'); + start == 0 || bytes[start - 1] == b'<' || is_html_ascii_whitespace(bytes[start - 1]); let right_boundary = - end == bytes.len() || matches!(bytes[end], b'=' | b' ' | b'\t' | b'\r' | b'\n'); + end == bytes.len() || bytes[end] == b'=' || is_html_ascii_whitespace(bytes[end]); if !left_boundary || !right_boundary { cursor = end; continue; } let mut value_start = end; - while value_start < bytes.len() && bytes[value_start].is_ascii_whitespace() { + while value_start < bytes.len() && is_html_ascii_whitespace(bytes[value_start]) { value_start += 1; } if bytes.get(value_start) != Some(&b'=') { @@ -592,7 +596,7 @@ fn html_attribute_value<'a>(tag: &'a str, attribute: &str) -> Option<&'a str> { continue; } value_start += 1; - while value_start < bytes.len() && bytes[value_start].is_ascii_whitespace() { + while value_start < bytes.len() && is_html_ascii_whitespace(bytes[value_start]) { value_start += 1; } let quote = bytes.get(value_start).copied(); @@ -606,7 +610,7 @@ fn html_attribute_value<'a>(tag: &'a str, attribute: &str) -> Option<&'a str> { } let value_end = bytes[value_start..] .iter() - .position(|byte| byte.is_ascii_whitespace() || *byte == b'>') + .position(|byte| is_html_ascii_whitespace(*byte) || *byte == b'>') .map(|offset| value_start + offset) .unwrap_or(bytes.len()); return (value_end > value_start).then(|| &tag[value_start..value_end]); @@ -625,12 +629,10 @@ fn html_has_attribute(tag: &str, attribute: &str) -> bool { let start = cursor + offset; let end = start + attribute_bytes.len(); let left_boundary = - start == 0 || matches!(bytes[start - 1], b'<' | b' ' | b'\t' | b'\r' | b'\n'); + start == 0 || bytes[start - 1] == b'<' || is_html_ascii_whitespace(bytes[start - 1]); let right_boundary = end == bytes.len() - || matches!( - bytes[end], - b'=' | b' ' | b'\t' | b'\r' | b'\n' | b'>' | b'/' - ); + || is_html_ascii_whitespace(bytes[end]) + || matches!(bytes[end], b'=' | b'>' | b'/'); if left_boundary && right_boundary { return true; } @@ -1455,7 +1457,7 @@ fn tag_visibly_uses_visual_asset( && trimmed .as_bytes() .get(name.len()) - .is_some_and(|byte| byte.is_ascii_whitespace() || *byte == b'>') + .is_some_and(|byte| is_html_ascii_whitespace(*byte) || *byte == b'>') }); let direct_source = visual_element && ["src", "href", "data", "poster"].iter().any(|attribute| { @@ -2609,6 +2611,15 @@ fn html_script_type_is_executable(script_tag: &str) -> bool { || script_type.starts_with("application/javascript;") } +fn html_script_is_module(script_tag: &str) -> bool { + html_attribute_value(script_tag, "type").is_some_and(|value| value.trim() == "module") +} + +fn html_script_executes_in_modern_browser(script_tag: &str) -> bool { + html_script_type_is_executable(script_tag) + && (html_script_is_module(script_tag) || !html_has_attribute(script_tag, "nomodule")) +} + fn html_tag_end(content: &str, tag_start: usize) -> Option { let bytes = content.as_bytes(); let mut quote = None; @@ -2630,7 +2641,7 @@ fn html_tag_name_has_boundary(content: &str, name_end: usize) -> bool { content .as_bytes() .get(name_end) - .is_none_or(|byte| byte.is_ascii_whitespace() || matches!(byte, b'>' | b'/')) + .is_none_or(|byte| is_html_ascii_whitespace(*byte) || matches!(byte, b'>' | b'/')) } fn next_named_html_tag( @@ -2702,13 +2713,21 @@ fn raw_text_html_element_end(content: &str, body_start: usize, name: &str) -> us .unwrap_or(content.len()) } +fn raw_text_html_element_close( + content: &str, + body_start: usize, + name: &str, +) -> Option<(usize, usize)> { + next_named_html_tag(content, body_start, name, true) +} + fn html_tag_starts_element(tag: &str, name: &str) -> bool { let marker = format!("<{name}"); tag.starts_with(&marker) && tag .as_bytes() .get(marker.len()) - .is_none_or(|byte| byte.is_ascii_whitespace() || matches!(byte, b'>' | b'/')) + .is_none_or(|byte| is_html_ascii_whitespace(*byte) || matches!(byte, b'>' | b'/')) } fn html_tag_ends_element(tag: &str, name: &str) -> bool { @@ -2717,7 +2736,7 @@ fn html_tag_ends_element(tag: &str, name: &str) -> bool { && tag .as_bytes() .get(marker.len()) - .is_none_or(|byte| byte.is_ascii_whitespace() || *byte == b'>') + .is_none_or(|byte| is_html_ascii_whitespace(*byte) || *byte == b'>') } fn html_raw_text_element_name(tag: &str) -> Option<&'static str> { @@ -2777,15 +2796,62 @@ fn executable_javascript_from_html(content: &str) -> String { continue; } let body_start = tag_end + 1; - let Some(close_offset) = lower[body_start..].find(" Vec { + let lower = content.to_ascii_lowercase(); + let mut executable = 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_after) = html_tag_end(&lower, tag_start) else { + break; + }; + let tag_end = tag_after - 1; + let tag = &lower[tag_start..=tag_end]; + if let Some(end) = html_non_executable_container_end(&lower, tag, tag_end) { + cursor = end; + continue; + } + if !html_tag_starts_element(tag, "script") { + cursor = tag_after; + continue; + } + let body_start = tag_after; + let Some((close_start, close_end)) = + raw_text_html_element_close(&lower, body_start, "script") + else { + break; + }; + if html_script_executes_in_modern_browser(tag) + && html_script_is_module(tag) + && !html_has_attribute(tag, "src") + { + executable.push(content[body_start..close_start].to_string()); + } + cursor = close_end; } executable } @@ -2824,27 +2890,58 @@ pub(in crate::agent) fn executable_external_script_sources_from_html( cursor = tag_end + 1; continue; } - if html_script_type_is_executable(tag) { + if html_script_executes_in_modern_browser(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(ExecutableExternalScriptSource { source: original_tag[value_offset..value_offset + source.len()].to_string(), - is_module: html_attribute_value(tag, "type") - .is_some_and(|value| value.trim() == "module"), + is_module: html_script_is_module(tag), }); } } let body_start = tag_end + 1; - let Some(close_offset) = lower[body_start..].find(" bool { + let lower = content.to_ascii_lowercase(); + 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_after) = html_tag_end(&lower, tag_start) else { + break; + }; + let tag_end = tag_after - 1; + let tag = &lower[tag_start..=tag_end]; + if let Some(end) = html_non_executable_container_end(&lower, tag, tag_end) { + cursor = end; + continue; + } + if html_tag_starts_element(tag, "base") && html_has_attribute(tag, "href") { + return true; + } + if html_tag_starts_element(tag, "script") { + cursor = raw_text_html_element_end(&lower, tag_after, "script"); + continue; + } + cursor = tag_after; + } + false +} + fn local_gameplay_script_path_from(importer_path: Option<&str>, source: &str) -> Option { let source = source.split(['?', '#']).next()?.trim(); if source.is_empty() || source.starts_with('/') || source.contains(['\\', '%', ':']) { @@ -2882,6 +2979,29 @@ enum JavascriptLexicalToken { Punct(char), } +fn javascript_tokens_end_control_parenthesis(tokens: &[JavascriptLexicalToken]) -> bool { + if tokens.last() != Some(&JavascriptLexicalToken::Punct(')')) { + return false; + } + let mut depth = 0usize; + for index in (0..tokens.len()).rev() { + match tokens[index] { + JavascriptLexicalToken::Punct(')') => depth += 1, + JavascriptLexicalToken::Punct('(') => { + depth = depth.saturating_sub(1); + if depth == 0 { + return index.checked_sub(1).is_some_and(|keyword| { + matches!(&tokens[keyword], JavascriptLexicalToken::Identifier(value) + if matches!(value.as_str(), "if" | "while" | "for" | "with" | "catch" | "switch")) + }); + } + } + _ => {} + } + } + false +} + fn javascript_lexical_tokens(content: &str) -> Vec { let bytes = content.as_bytes(); let mut tokens = Vec::new(); @@ -2931,26 +3051,29 @@ fn javascript_lexical_tokens(content: &str) -> Vec { | '~' | '<' | '>' + | '}' + ) + ) || (matches!(token, JavascriptLexicalToken::Punct(')')) + && javascript_tokens_end_control_parenthesis(&tokens)) + || matches!( + token, + JavascriptLexicalToken::Identifier(keyword) + if matches!( + keyword.as_str(), + "return" + | "case" + | "throw" + | "else" + | "do" + | "typeof" + | "void" + | "delete" + | "in" + | "of" + | "yield" + | "await" + ) ) - ) || matches!( - token, - JavascriptLexicalToken::Identifier(keyword) - if matches!( - keyword.as_str(), - "return" - | "case" - | "throw" - | "else" - | "do" - | "typeof" - | "void" - | "delete" - | "in" - | "of" - | "yield" - | "await" - ) - ) }) { cursor += 1; @@ -3055,7 +3178,7 @@ fn javascript_lexical_tokens(content: &str) -> Vec { tokens } -fn local_javascript_module_sources(content: &str) -> Vec { +fn local_javascript_module_sources(content: &str, allow_static_imports: bool) -> Vec { let tokens = javascript_lexical_tokens(content); let mut sources = Vec::new(); for (index, token) in tokens.iter().enumerate() { @@ -3070,9 +3193,16 @@ fn local_javascript_module_sources(content: &str) -> Vec { { continue; } + let starts_module_declaration = index == 0 + || index + .checked_sub(1) + .and_then(|previous| tokens.get(previous)) + .is_some_and(|token| matches!(token, JavascriptLexicalToken::Punct(';' | '}'))); if keyword == "import" { match tokens.get(index + 1) { - Some(JavascriptLexicalToken::StringLiteral(source)) => { + Some(JavascriptLexicalToken::StringLiteral(source)) + if allow_static_imports && starts_module_declaration => + { sources.push(source.clone()); continue; } @@ -3080,13 +3210,21 @@ fn local_javascript_module_sources(content: &str) -> Vec { if let Some(JavascriptLexicalToken::StringLiteral(source)) = tokens.get(index + 2) { - sources.push(source.clone()); + if matches!( + tokens.get(index + 3), + Some(JavascriptLexicalToken::Punct(')' | ',')) + ) { + sources.push(source.clone()); + } } continue; } _ => {} } } + if !allow_static_imports || !starts_module_declaration { + continue; + } let mut saw_from = false; for candidate in tokens.iter().skip(index + 1) { match candidate { @@ -3105,21 +3243,85 @@ fn local_javascript_module_sources(content: &str) -> Vec { sources } +fn javascript_without_obviously_unreachable_dynamic_imports(content: &str) -> String { + let ranges = named_javascript_function_ranges(content); + let mut bytes = content.as_bytes().to_vec(); + let mut cursor = 0usize; + while let Some(offset) = content[cursor..].find("import") { + let import_start = cursor + offset; + cursor = import_start + "import".len(); + if import_start > 0 + && (is_ascii_word_byte(content.as_bytes()[import_start - 1]) + || content.as_bytes()[import_start - 1] == b'$') + || content + .as_bytes() + .get(cursor) + .is_some_and(|byte| is_ascii_word_byte(*byte) || *byte == b'$') + || position_is_inside_javascript_string(content, import_start) + { + continue; + } + let mut open = cursor; + while content + .as_bytes() + .get(open) + .is_some_and(u8::is_ascii_whitespace) + { + open += 1; + } + if content.as_bytes().get(open) != Some(&b'(') + || javascript_position_is_reachable(content, &ranges, import_start) + { + continue; + } + bytes[import_start..cursor].fill(b' '); + } + String::from_utf8(bytes).expect("masking JavaScript bytes with spaces preserves UTF-8") +} + +fn reachable_local_javascript_module_sources( + content: &str, + allow_static_imports: bool, +) -> Vec { + let content = javascript_without_obvious_false_branches(content); + let content = javascript_without_obviously_unreachable_dynamic_imports(&content); + local_javascript_module_sources(&content, allow_static_imports) +} + +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub(in crate::agent) struct ExternalGameplayJavascript { + classic_global: String, + module_units: Vec, +} + +impl ExternalGameplayJavascript { + fn contains(&self, marker: &str) -> bool { + self.classic_global.contains(marker) + || self.module_units.iter().any(|unit| unit.contains(marker)) + } +} + pub(in crate::agent) fn read_external_gameplay_javascript_at( root: &Path, html: &str, -) -> Result { +) -> Result { const MAX_EXTERNAL_GAMEPLAY_JAVASCRIPT_BYTES: u64 = 2 * 1024 * 1024; const MAX_EXTERNAL_GAMEPLAY_JAVASCRIPT_FILES: usize = 256; - let mut output = String::new(); + if html_has_effective_base_href(html) { + return Err("自主构建 Tetris 静态门不允许改变 game/index.html 的 base URL".to_string()); + } + let mut output = ExternalGameplayJavascript::default(); let mut total_bytes = 0_u64; let mut pending = std::collections::VecDeque::new(); + let mut module_contents = std::collections::BTreeMap::::new(); + let mut module_edges = std::collections::BTreeMap::>::new(); for source in executable_external_script_sources_from_html(html) { let local_path = local_gameplay_script_path_from(None, &source.source) .ok_or_else(|| format!("自主构建外部脚本路径不受支持:{}", source.source))?; pending.push_back((local_path, source.is_module)); } - for imported_source in local_javascript_module_sources(&executable_javascript_from_html(html)) { + let inline_javascript = executable_javascript_from_html(html); + for imported_source in reachable_local_javascript_module_sources(&inline_javascript, false) { let imported_path = local_gameplay_script_path_from( Some("game/index.html"), &imported_source, @@ -3129,9 +3331,31 @@ pub(in crate::agent) fn read_external_gameplay_javascript_at( })?; pending.push_back((imported_path, true)); } + for (index, inline_module) in executable_inline_module_javascript_units_from_html(html) + .into_iter() + .enumerate() + { + let inline_id = format!("inline-module:{index}"); + module_contents.insert(inline_id.clone(), inline_module.to_ascii_lowercase()); + module_edges.entry(inline_id.clone()).or_default(); + for imported_source in reachable_local_javascript_module_sources(&inline_module, true) { + let imported_path = local_gameplay_script_path_from( + Some("game/index.html"), + &imported_source, + ) + .ok_or_else(|| { + format!("自主构建内联模块依赖路径不受支持:game/index.html -> {imported_source}") + })?; + module_edges + .entry(inline_id.clone()) + .or_default() + .insert(imported_path.clone()); + pending.push_back((imported_path, true)); + } + } let mut visited = BTreeSet::new(); while let Some((local_path, is_module)) = pending.pop_front() { - if !visited.insert(local_path.clone()) { + if !visited.insert((local_path.clone(), is_module)) { continue; } if visited.len() > MAX_EXTERNAL_GAMEPLAY_JAVASCRIPT_FILES { @@ -3167,20 +3391,72 @@ pub(in crate::agent) fn read_external_gameplay_javascript_at( return Err("自主构建外部脚本累计超过 2 MiB".to_string()); } total_bytes += script_bytes; + for imported_source in reachable_local_javascript_module_sources(&script, is_module) { + let imported_path = + local_gameplay_script_path_from(Some(&local_path), &imported_source).ok_or_else( + || format!("自主构建模块依赖路径不受支持:{local_path} -> {imported_source}"), + )?; + if is_module { + module_edges + .entry(local_path.clone()) + .or_default() + .insert(imported_path.clone()); + } + pending.push_back((imported_path, true)); + } + let script = script.to_ascii_lowercase(); if is_module { - for imported_source in local_javascript_module_sources(&script) { - let imported_path = - local_gameplay_script_path_from(Some(&local_path), &imported_source) - .ok_or_else(|| { - format!( - "自主构建模块依赖路径不受支持:{local_path} -> {imported_source}" - ) - })?; - pending.push_back((imported_path, true)); + module_contents.insert(local_path.clone(), script); + module_edges.entry(local_path).or_default(); + } else { + output.classic_global.push_str(&script); + output.classic_global.push('\n'); + } + } + let mut adjacency = std::collections::BTreeMap::>::new(); + for module in module_contents.keys() { + adjacency.entry(module.clone()).or_default(); + } + for (importer, dependencies) in module_edges { + for dependency in dependencies { + if !module_contents.contains_key(&dependency) { + continue; + } + adjacency + .entry(importer.clone()) + .or_default() + .insert(dependency.clone()); + adjacency + .entry(dependency) + .or_default() + .insert(importer.clone()); + } + } + let mut grouped = BTreeSet::new(); + for module in module_contents.keys() { + if !grouped.insert(module.clone()) { + continue; + } + let mut component = vec![module.clone()]; + let mut cursor = 0usize; + while cursor < component.len() { + let current = component[cursor].clone(); + cursor += 1; + for neighbor in adjacency.get(¤t).into_iter().flatten() { + if grouped.insert(neighbor.clone()) { + component.push(neighbor.clone()); + } } } - output.push_str(&script.to_ascii_lowercase()); - output.push('\n'); + component.sort(); + let mut unit = String::new(); + for member in component { + if let Some(content) = module_contents.get(&member) { + unit.push_str(content); + unit.push('\n'); + } + } + output.module_units.push(unit); } Ok(output) } @@ -3237,7 +3513,7 @@ fn compact_javascript_contains_simple_property_assignment( compact: &str, owners: &[&str], properties: &[&str], - rhs_is_meaningful: impl Fn(&str, &str, &str) -> bool, + rhs_is_meaningful: impl Fn(usize, &str, &str, &str) -> bool, ) -> bool { owners.iter().any(|owner| { properties.iter().any(|property| { @@ -3263,7 +3539,8 @@ fn compact_javascript_contains_simple_property_assignment( let rhs = &compact[assignment..rhs_end]; let target = format!("{owner}.{property}"); let normalized_rhs = rhs.trim_matches(|character| matches!(character, '(' | ')')); - if normalized_rhs != target && rhs_is_meaningful(owner, property, rhs) { + if normalized_rhs != target && rhs_is_meaningful(marker_start, owner, property, rhs) + { return true; } } @@ -3308,6 +3585,16 @@ fn javascript_contains_identifier_call(content: &str, name: &str) -> bool { }) } +fn javascript_contains_reachable_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'$')) + && !javascript_position_is_in_literal_false_block(content, position) + }) +} + fn javascript_contains_collision_call(content: &str) -> bool { content.match_indices('(').any(|(open, _)| { identifier_before(content, open).is_some_and(|name| { @@ -3425,14 +3712,33 @@ fn javascript_contains_simple_board_cell_assignment(compact: &str) -> bool { let lhs = &compact[board_start..position]; let rhs = &compact[rhs_start..rhs_end]; let normalized_rhs = rhs.trim_matches(|character| matches!(character, '(' | ')')); - let writes_piece_value = [ - "cell", "shape[", "matrix[", "cells[", "current", "active", "piece", - ] - .iter() - .any(|marker| normalized_rhs.contains(marker)); + let active_owners = ["current", "active", "activepiece", "piece"]; + let binds_active_position = active_owners.iter().any(|owner| { + ["y", "row"] + .iter() + .any(|vertical| lhs.contains(&format!("{owner}.{vertical}"))) + && ["x", "column", "col"] + .iter() + .any(|horizontal| lhs.contains(&format!("{owner}.{horizontal}"))) + }); + let binds_cell_position = ["cell.x", "cell.column", "cell.col"] + .iter() + .any(|horizontal| lhs.contains(horizontal)) + && ["cell.y", "cell.row"] + .iter() + .any(|vertical| lhs.contains(vertical)); + let writes_piece_value = javascript_lexical_tokens(normalized_rhs) + .iter() + .any(|token| { + matches!(token, JavascriptLexicalToken::Identifier(identifier) + if matches!(identifier.as_str(), "cell" | "shape" | "matrix" | "cells" | "current" | "active" | "activepiece" | "piece")) + }); let writes_nonzero_literal = normalized_rhs.parse::().is_ok_and(|value| value != 0); - if normalized_rhs != lhs && (writes_piece_value || writes_nonzero_literal) { + if normalized_rhs != lhs + && (binds_active_position || binds_cell_position) + && (writes_piece_value || writes_nonzero_literal) + { return true; } } @@ -3526,10 +3832,10 @@ fn tetris_rotation_body_is_meaningful(body: &str) -> bool { &compact, &active_piece_owners, &["shape", "matrix", "cells"], - |owner, property, rhs| { + |assignment_start, owner, property, rhs| { let source = format!("{owner}.{property}"); - if rhs.contains(&source) - && rhs.contains(".map(") + let normalized_expression = rhs.trim_start_matches('('); + if normalized_expression.starts_with(&format!("{source}.map(")) && (rhs.contains(".reverse(") || rhs.contains("[column][row]")) { return true; @@ -3542,24 +3848,28 @@ fn tetris_rotation_body_is_meaningful(body: &str) -> bool { return false; } let marker = format!("{candidate}={source}"); - compact.match_indices(&marker).any(|(start, marker)| { - let expression_start = start + marker.len(); - let expression_end = compact[expression_start..] - .find(';') - .map(|offset| expression_start + offset) - .unwrap_or(compact.len()); - let expression = &compact[expression_start..expression_end]; - expression.contains(".map(") - && (expression.contains(".reverse(") || expression.contains("[column][row]")) - }) + compact[..assignment_start] + .match_indices(&marker) + .any(|(start, marker)| { + let expression_start = start + marker.len(); + let expression_end = compact[expression_start..assignment_start] + .find(';') + .map(|offset| expression_start + offset) + .unwrap_or(assignment_start); + let expression = &compact[expression_start..expression_end]; + expression.starts_with(".map(") + && (expression.contains(".reverse(") + || expression.contains("[column][row]")) + }) }, ); let quarter_turn_counter = compact_javascript_contains_simple_property_assignment( &compact, &active_piece_owners, &["rotation"], - |_, _, rhs| { - (rhs.contains("rotation+1") || rhs.contains("rotation+3")) + |_, owner, property, rhs| { + let source = format!("{owner}.{property}"); + (rhs.contains(&format!("{source}+1")) || rhs.contains(&format!("{source}+3"))) && (rhs.contains("%4") || rhs.contains("&3")) }, ); @@ -3586,15 +3896,81 @@ 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 = javascript_contains_identifier_call(&compact, clear_function_name); + let invokes_line_clear = + javascript_contains_reachable_identifier_call(&compact, clear_function_name); writes_board_cell && invokes_line_clear } +fn javascript_callback_first_parameter(callback: &str) -> Option { + let compact = compact_javascript(callback); + if let Some(rest) = compact.strip_prefix("function(") { + return rest + .split_once(')') + .and_then(|(parameters, _)| parameters.split(',').next()) + .filter(|parameter| !parameter.is_empty()) + .map(str::to_string); + } + let (parameters, _) = compact.split_once("=>")?; + let parameter = parameters + .trim_matches(|character| matches!(character, '(' | ')')) + .split(',') + .next()?; + (!parameter.is_empty()).then(|| parameter.to_string()) +} + +fn tetris_every_callback_requires_occupied(callback: &str) -> bool { + let compact = compact_javascript(callback); + if compact.eq_ignore_ascii_case("boolean") { + return true; + } + let Some(parameter) = javascript_callback_first_parameter(&compact) else { + return false; + }; + let expression = compact + .split_once("=>") + .map(|(_, expression)| expression.trim_matches(|character| matches!(character, '(' | ')'))) + .or_else(|| { + compact + .split_once("return") + .map(|(_, expression)| expression.trim_end_matches([';', '}'])) + }) + .unwrap_or_default(); + expression == parameter + || expression == format!("{parameter}!==0") + || expression == format!("{parameter}!=0") + || expression == format!("{parameter}>0") +} + +fn tetris_some_callback_finds_empty(callback: &str) -> bool { + let compact = compact_javascript(callback); + let Some(parameter) = javascript_callback_first_parameter(&compact) else { + return false; + }; + let expression = compact + .split_once("=>") + .map(|(_, expression)| expression.trim_matches(|character| matches!(character, '(' | ')'))) + .or_else(|| { + compact + .split_once("return") + .map(|(_, expression)| expression.trim_end_matches([';', '}'])) + }) + .unwrap_or_default(); + expression == format!("!{parameter}") + || expression == format!("{parameter}===0") + || expression == format!("{parameter}==0") +} + fn tetris_filter_predicate_excludes_full_row(predicate: &str) -> bool { + let Some(row_parameter) = javascript_callback_first_parameter(predicate) else { + return false; + }; if let Some(every) = predicate.find(".every(") { let Some(owner) = identifier_before(predicate, every) else { return false; }; + if owner != row_parameter { + return false; + } let owner_start = every.saturating_sub(owner.len()); let expression_start = predicate[..owner_start] .rfind("=>") @@ -3609,12 +3985,25 @@ fn tetris_filter_predicate_excludes_full_row(predicate: &str) -> bool { .chars() .filter(|character| !matches!(character, '(' | ')')) .collect::(); - return prefix == "!"; + let open = every + ".every".len(); + let Some(close) = matching_javascript_parenthesis(predicate, open) else { + return false; + }; + let suffix = predicate[close + 1..] + .chars() + .filter(|character| !matches!(character, ')' | ';' | '}')) + .collect::(); + return prefix == "!" + && suffix.is_empty() + && tetris_every_callback_requires_occupied(&predicate[open + 1..close]); } if let Some(some) = predicate.find(".some(") { let Some(owner) = identifier_before(predicate, some) else { return false; }; + if owner != row_parameter { + return false; + } let owner_start = some.saturating_sub(owner.len()); let expression_start = predicate[..owner_start] .rfind("=>") @@ -3634,10 +4023,13 @@ fn tetris_filter_predicate_excludes_full_row(predicate: &str) -> bool { return false; }; let callback = &predicate[open + 1..close]; + let suffix = predicate[close + 1..] + .chars() + .filter(|character| !matches!(character, ')' | ';' | '}')) + .collect::(); return prefix.is_empty() - && (callback.contains("=>!") || callback.contains("return!")) - && !callback.contains("=>!!") - && !callback.contains("return!!"); + && suffix.is_empty() + && tetris_some_callback_finds_empty(callback); } false } @@ -3656,6 +4048,12 @@ fn javascript_identifier_assignment_count( } fn tetris_board_identifiers(tokens: &[JavascriptLexicalToken]) -> BTreeSet { + if tokens.windows(2).any(|window| { + matches!(&window[0], JavascriptLexicalToken::Identifier(declaration) if matches!(declaration.as_str(), "const" | "let" | "var")) + && matches!(&window[1], JavascriptLexicalToken::Identifier(identifier) if identifier == "board") + }) { + return BTreeSet::new(); + } let mut identifiers = BTreeSet::from(["board".to_string()]); loop { let mut changed = false; @@ -3679,6 +4077,10 @@ fn tetris_board_identifiers(tokens: &[JavascriptLexicalToken]) -> BTreeSet bool { + tokens.windows(2).any(|window| { + matches!(&window[0], JavascriptLexicalToken::Identifier(value) if value.eq_ignore_ascii_case("array")) + && window[1] == JavascriptLexicalToken::Punct('(') + }) || matches!(tokens.first(), Some(JavascriptLexicalToken::Punct('['))) +} + +fn tetris_expression_checks_full_board_row(compact: &str, owner: &str) -> bool { + let owner_marker = format!("{owner}["); + compact.match_indices(".every(").any(|(every, marker)| { + let Some(owner_start) = compact[..every].rfind(&owner_marker) else { + return false; + }; + if owner_start > 0 + && (is_ascii_word_byte(compact.as_bytes()[owner_start - 1]) + || compact.as_bytes()[owner_start - 1] == b'$') + { + return false; + } + let row_expression = &compact[owner_start + owner.len()..every]; + if !row_expression.starts_with('[') || !row_expression.ends_with(']') { + return false; + } + let statement_start = compact[..owner_start] + .rfind([';', '{', '}']) + .map(|position| position + 1) + .unwrap_or_default(); + if compact[statement_start..owner_start].ends_with('!') { + return false; + } + let open = every + marker.len() - 1; + matching_javascript_parenthesis(compact, open).is_some_and(|close| { + let prefix = compact[..owner_start].trim_matches('('); + let suffix = compact[close + 1..].trim_matches(')'); + prefix.is_empty() + && suffix.is_empty() + && tetris_every_callback_requires_occupied(&compact[open + 1..close]) + }) + }) +} + +fn tetris_board_owner_has_guarded_row_replacement(compact: &str, owner: &str) -> bool { + let owner_set = BTreeSet::from([owner.to_string()]); + 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 !tetris_expression_checks_full_board_row(&compact[open + 1..close], owner) { + continue; + } + let Some((consequent, _)) = javascript_statement_at(compact, close + 1) else { + continue; + }; + let tokens = javascript_lexical_tokens(consequent); + let removes_one_row = javascript_board_method_calls(&tokens, &owner_set, "splice") + .iter() + .any(|arguments| { + arguments.get(1) == Some(&vec![JavascriptLexicalToken::Number("1".to_string())]) + }); + let inserts_row = ["unshift", "push"].iter().any(|method| { + javascript_board_method_calls(&tokens, &owner_set, method) + .iter() + .any(|arguments| { + arguments + .first() + .is_some_and(|argument| javascript_tokens_create_replacement_row(argument)) + }) + }); + if removes_one_row && inserts_row { + return true; + } + } + false +} + fn tetris_clear_body_is_meaningful(body: &str) -> bool { let body = javascript_without_obvious_false_branches(body); let body = body.as_str(); @@ -3753,48 +4233,42 @@ fn tetris_clear_body_is_meaningful(body: &str) -> bool { if compact.contains(".splice(0,0)") || compact.contains(".splice(0,0,") { return false; } - let filter_clear = compact - .match_indices("board=board.filter(") - .any(|(start, marker)| { - let open = start + marker.len() - 1; - let Some(close) = matching_javascript_parenthesis(&compact, open) else { - return false; - }; - let predicate = &compact[open + 1..close]; - tetris_filter_predicate_excludes_full_row(predicate) - }); let tokens = javascript_lexical_tokens(body); let board_identifiers = tetris_board_identifiers(&tokens); - let removes_one_row = javascript_board_method_calls(&tokens, &board_identifiers, "splice") + let filter_clear = board_identifiers.contains("board") + && compact + .match_indices("board=board.filter(") + .any(|(start, marker)| { + let open = start + marker.len() - 1; + let Some(close) = matching_javascript_parenthesis(&compact, open) else { + return false; + }; + let predicate = &compact[open + 1..close]; + tetris_filter_predicate_excludes_full_row(predicate) + }); + let splice_clear = board_identifiers .iter() - .any(|arguments| { - arguments.get(1) == Some(&vec![JavascriptLexicalToken::Number("1".to_string())]) - }); - let inserts_row = ["unshift", "push"].iter().any(|method| { - javascript_board_method_calls(&tokens, &board_identifiers, method) - .iter() - .any(|arguments| { - arguments - .first() - .is_some_and(|argument| !argument.is_empty()) - }) - }); - let splice_clear = removes_one_row && inserts_row; + .any(|owner| tetris_board_owner_has_guarded_row_replacement(&compact, owner)); filter_clear || splice_clear } -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); +fn tetris_executable_unit_semantics_gap(executable: &str) -> Option<&'static str> { + let executable = javascript_without_string_literals_or_comments(executable); let ranges = named_javascript_function_ranges(&executable); - let has_board_state = ["array.from(", "array("] - .iter() - .any(|marker| executable.contains(marker)) - && executable.contains("board["); + let tokens = javascript_lexical_tokens(&executable); + let has_board_initializer = tokens.windows(6).any(|window| { + matches!(&window[0], JavascriptLexicalToken::Identifier(board) if board == "board") + && window[1] == JavascriptLexicalToken::Punct('=') + && ((matches!(&window[2], JavascriptLexicalToken::Identifier(array) if array == "array") + && (window[3] == JavascriptLexicalToken::Punct('(') + || window[3] == JavascriptLexicalToken::Punct('.') + && matches!(&window[4], JavascriptLexicalToken::Identifier(from) if from == "from") + && window[5] == JavascriptLexicalToken::Punct('('))) + || (matches!(&window[2], JavascriptLexicalToken::Identifier(new) if new == "new") + && matches!(&window[3], JavascriptLexicalToken::Identifier(array) if array == "array") + && window[4] == JavascriptLexicalToken::Punct('('))) + }); + let has_board_state = has_board_initializer && executable.contains("board["); if !has_board_state { return Some("board-state"); } @@ -3838,6 +4312,42 @@ fn tetris_executable_semantics_gap( }) { return Some("piece-fall"); } + None +} + +fn tetris_executable_semantics_gap( + content: &str, + external_javascript: &ExternalGameplayJavascript, +) -> Option<&'static str> { + let mut classic_global = executable_javascript_from_html(content); + classic_global.push_str(&external_javascript.classic_global); + let mut units = vec![classic_global]; + units.extend( + executable_inline_module_javascript_units_from_html(content) + .into_iter() + .map(|unit| unit.to_ascii_lowercase()), + ); + units.extend(external_javascript.module_units.iter().cloned()); + let gap_rank = |gap: &str| match gap { + "board-state" => 0, + "piece-rotation" => 1, + "line-clear" => 2, + "piece-lock" => 3, + "piece-fall" => 4, + _ => 0, + }; + let mut best_gap = "board-state"; + let mut complete_unit = false; + for unit in units { + match tetris_executable_unit_semantics_gap(&unit) { + None => complete_unit = true, + Some(gap) if gap_rank(gap) > gap_rank(best_gap) => best_gap = gap, + Some(_) => {} + } + } + if !complete_unit { + return Some(best_gap); + } let telemetry_fields = [ "gameplay", "kind", @@ -3862,7 +4372,7 @@ fn tetris_executable_semantics_gap( pub(in crate::agent) fn inherited_gameplay_semantics_gap_with_external_javascript( task: &str, html: &[u8], - external_javascript: &str, + external_javascript: &ExternalGameplayJavascript, ) -> Option { let gameplay = inherited_gameplay_semantics(task)?; let Ok(html) = std::str::from_utf8(html) else { @@ -3898,7 +4408,11 @@ pub(in crate::agent) fn inherited_gameplay_semantics_gap( task: &str, html: &[u8], ) -> Option { - inherited_gameplay_semantics_gap_with_external_javascript(task, html, "") + inherited_gameplay_semantics_gap_with_external_javascript( + task, + html, + &ExternalGameplayJavascript::default(), + ) } fn autonomous_inherited_gameplay_semantics_gap_at( @@ -3931,7 +4445,7 @@ fn autonomous_inherited_gameplay_semantics_gap_at( { read_external_gameplay_javascript_at(root, html_text)? } else { - String::new() + ExternalGameplayJavascript::default() }; Ok(inherited_gameplay_semantics_gap_with_external_javascript( &effective_task, 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 7efa79168..d9f830609 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 @@ -1516,6 +1516,53 @@ fn inherited_tetris_contract_scans_only_executable_html_scripts() { Some("board-state"), "the inline body of a sourced script is ignored by the browser", ); + + let nomodule = valid.replacen("", + "", + ); + let nomodule_external = read_external_gameplay_javascript_at(root, &nomodule_html) + .expect("ignore a nomodule external script in Chromium"); + assert_eq!( + inherited_gameplay_semantics_gap_with_external_javascript( + task, + nomodule_html.as_bytes(), + &nomodule_external, + ) + .as_deref(), + Some("board-state"), + "a nomodule external script must not contribute Tetris semantics", + ); + + let gameplay = &valid[body_start..script_end]; + let split_at = gameplay + .find("function lockPiece") + .expect("fixture contains a lock function split point"); + fs::write(root.join("game/scope-a.mjs"), &gameplay[..split_at]) + .expect("write first isolated module"); + fs::write(root.join("game/scope-b.mjs"), &gameplay[split_at..]) + .expect("write second isolated module"); + let split_module_html = external_html.replace( + "", + "", + ); + let split_modules = read_external_gameplay_javascript_at(root, &split_module_html) + .expect("read two isolated modules"); + assert!( + inherited_gameplay_semantics_gap_with_external_javascript( + task, + split_module_html.as_bytes(), + &split_modules, + ) + .is_some(), + "unimported bindings from separate modules must not be merged into one semantic chain", + ); + + fs::write( + root.join("game/scope-a.mjs"), + format!("{}\nimport './scope-b.mjs';", &gameplay[..split_at]), + ) + .expect("connect the split gameplay modules"); + let connected_module_html = external_html.replace( + "", + "", + ); + let connected_modules = read_external_gameplay_javascript_at(root, &connected_module_html) + .expect("read one connected module graph"); + assert_eq!( + inherited_gameplay_semantics_gap_with_external_javascript( + task, + connected_module_html.as_bytes(), + &connected_modules, + ), + None, + "modules connected by a real import edge must form one semantic unit", + ); } #[test] 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 d58e981fe..1b6a81e00 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 @@ -24,12 +24,12 @@ 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=trusted-event-isolated-world-promise-closure-mouseup-tail-baseline-to-click-bubble-sequence-advance\n", + "primary-action=trusted-event-isolated-world-promise-closure-pre-input-mutation-baseline-to-click-dispatch-tail-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=trusted-event-isolated-world-promise-closure-mouseup-tail-baseline-to-click-bubble-sequence-advance" + "restart=trusted-event-isolated-world-promise-closure-pre-input-mutation-baseline-to-click-dispatch-tail-sequence-advance" ); #[derive(Clone, Debug, Deserialize)] @@ -61,57 +61,110 @@ fn generic_action_sequence_probe_script(selector: &str, ready_key: &str) -> Resu 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)) {{ - finish({{ status: 'invalid-control', beforeState: null, afterState: null }}); + globalThis[readyKey] = 'invalid-control'; + resolve({{ status: 'invalid-control', beforeState: null, afterState: null }}); return; }} const control = controls[0]; + let settled = false; + let timeoutId = null; + let stateObserver = null; + const inputEventTypes = ['pointerdown', 'mousedown', 'pointerup', 'mouseup', 'click']; + const dispatchTailTargets = []; + const cleanup = () => {{ + if (timeoutId !== null) clearTimeout(timeoutId); + if (stateObserver !== null) stateObserver.disconnect(); + for (const type of inputEventTypes) {{ + window.removeEventListener(type, observeInput, true); + }} + for (const target of dispatchTailTargets) {{ + target.removeEventListener('click', observeClickDispatchTail, false); + }} + try {{ delete globalThis[readyKey]; }} catch (_) {{}} + }}; + const finish = (value) => {{ + if (settled) return; + settled = true; + cleanup(); + resolve(value); + }}; const readState = () => {{ const surface = document.querySelectorAll('script#playable-web-game-state'); if (surface.length !== 1 || surface[0].getAttribute('type') !== 'application/json') return null; - return String(surface[0].textContent || ''); + const content = String(surface[0].textContent || ''); + return content.length <= 131072 ? content : null; }}; - let beforeState = null; + let beforeState = readState(); let baselineCaptured = false; - const mouseup = (event) => {{ + let inputEventObserved = false; + const targetsControl = (event) => event.target === control + || (event.target instanceof Node && control.contains(event.target)); + function observeInput(event) {{ if (!event.isTrusted) return; - if (event.button !== 0) return; - if (event.target !== control && !(event.target instanceof Node && control.contains(event.target))) return; - beforeState = readState(); - baselineCaptured = true; - }}; - const bubble = (event) => {{ - if (!event.isTrusted) return; - if (event.target !== control && !(event.target instanceof Node && control.contains(event.target))) return; - window.removeEventListener('mouseup', mouseup, false); - document.removeEventListener('click', bubble, false); + if ('button' in event && event.button !== 0) return; + if (!targetsControl(event)) return; + inputEventObserved = true; + if (!baselineCaptured) {{ + baselineCaptured = true; + }} + }} + function observeClickDispatchTail(event) {{ + if (!event.isTrusted || !targetsControl(event)) return; + if (event.currentTarget !== window && !event.cancelBubble) return; finish({{ - status: baselineCaptured ? 'completed' : 'missing-mouseup-baseline', + status: baselineCaptured && inputEventObserved ? 'completed' : 'missing-input-baseline', beforeState, - afterState: baselineCaptured ? readState() : null, + afterState: baselineCaptured && inputEventObserved ? readState() : null, }}); - }}; - window.addEventListener('mouseup', mouseup, {{ capture: false }}); - document.addEventListener('click', bubble, {{ capture: false }}); + }} + stateObserver = new MutationObserver(() => {{ + const activeEvent = globalThis.event; + if (activeEvent instanceof Event + && activeEvent.isTrusted + && inputEventTypes.includes(activeEvent.type) + && targetsControl(activeEvent)) {{ + inputEventObserved = true; + if (!baselineCaptured) baselineCaptured = true; + return; + }} + if (!baselineCaptured) beforeState = readState(); + }}); + stateObserver.observe(document, {{ subtree: true, childList: true, characterData: true }}); + for (const type of inputEventTypes) {{ + window.addEventListener(type, observeInput, {{ capture: true }}); + }} + for (let target = control; target; target = target.parentNode) {{ + dispatchTailTargets.push(target); + target.addEventListener('click', observeClickDispatchTail, false); + }} + dispatchTailTargets.push(window); + window.addEventListener('click', observeClickDispatchTail, false); globalThis[readyKey] = 'armed'; timeoutId = setTimeout(() => {{ - window.removeEventListener('mouseup', mouseup, false); - document.removeEventListener('click', bubble, false); finish({{ status: 'timeout', beforeState, afterState: null }}); }}, 5000); }}))()"# )) } +fn generic_action_sequence_probe_ready_script(ready_key: &str) -> Result { + let ready_key = serde_json::to_string(ready_key) + .map_err(|_| "固定试玩动作因果探针 ready key 无法编码".to_string())?; + Ok(format!( + "(() => {{ const status = globalThis[{ready_key}] || null; if (status === 'invalid-control') delete globalThis[{ready_key}]; return status; }})()" + )) +} + +pub(in crate::browser) fn generic_action_sequence_probe_fingerprint_material() -> String { + let ready_key = "__genarrativeActionProbeFingerprintReady"; + let install = generic_action_sequence_probe_script(PLAYTEST_PRIMARY_ACTION_SELECTOR, ready_key) + .expect("fixed generic action probe fingerprint inputs must serialize"); + let ready = generic_action_sequence_probe_ready_script(ready_key) + .expect("fixed generic action probe ready fingerprint inputs must serialize"); + format!("install-and-finish-script:\n{install}\nready-script:\n{ready}") +} + fn validate_generic_action_sequence_probe( action: &str, probe: &GenericActionSequenceProbe, @@ -195,14 +248,13 @@ async fn click_with_generic_action_sequence_probe( .map_err(|_| format!("固定试玩动作 {action} 因果证据无效")) }; let click_when_ready = async { - let ready_key = serde_json::to_string(&ready_key) - .map_err(|_| "固定试玩动作因果探针 ready key 无法编码".to_string())?; + let ready_script = generic_action_sequence_probe_ready_script(&ready_key)?; loop { let remaining = deadline .checked_duration_since(Instant::now()) .ok_or_else(|| format!("固定试玩动作 {action} 因果探针安装超时"))?; let params = EvaluateParams::builder() - .expression(format!("globalThis[{ready_key}] === 'armed'")) + .expression(ready_script.clone()) .context_id(context_id.clone()) .return_by_value(true) .await_promise(false) @@ -212,8 +264,16 @@ async fn click_with_generic_action_sequence_probe( .await .map_err(|_| format!("固定试玩动作 {action} 因果探针安装超时"))? .map_err(|_| format!("固定试玩动作 {action} 因果探针安装失败"))?; - if evaluated.into_value::().unwrap_or(false) { - break; + match evaluated + .into_value::>() + .unwrap_or(None) + .as_deref() + { + Some("armed") => break, + Some("invalid-control") => { + return Err(format!("固定试玩控件 {action} 不存在或不唯一")); + } + _ => {} } tokio::time::sleep(Duration::from_millis(10)).await; } @@ -1081,10 +1141,21 @@ mod tests { .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("window.addEventListener('mouseup', mouseup")); - assert!(script.contains("status: baselineCaptured ? 'completed'")); - assert!(!script.contains("control.addEventListener('click'")); + assert!(script.contains("let beforeState = readState()")); + assert!(script.contains("new MutationObserver")); + assert!(script.contains("const activeEvent = globalThis.event")); + assert!(script.contains("window.addEventListener(type, observeInput")); + assert!( + script.contains("target.addEventListener('click', observeClickDispatchTail, false)") + ); + assert!( + script.contains("window.addEventListener('click', observeClickDispatchTail, false)") + ); + assert!(script.contains("event.currentTarget !== window && !event.cancelBubble")); + assert!(script.contains("afterState: baselineCaptured && inputEventObserved ? readState()")); + assert!(script.contains("content.length <= 131072")); + assert!(script.contains("status: baselineCaptured && inputEventObserved ? 'completed'")); + assert!(!script.contains("finishKey")); assert!(script.contains("resolve(value)")); assert!(!script.contains("__genarrativeGenericActionSequenceProbe")); assert!(!script.contains("beforeGameplay")); diff --git a/apps/ai-game-creator-shell/src-tauri/src/browser/playtest/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/browser/playtest/mod.rs index 36eac9291..3f4554416 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/browser/playtest/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/browser/playtest/mod.rs @@ -14,12 +14,13 @@ mod generic; mod lane_defense; pub(super) use generic::{ - finish_generic_stability_observation, generic_non_loss_progression_phase_is_valid, - generic_primary_action_phase_is_valid, generic_restart_phase_is_valid, - generic_start_phase_is_valid, validate_generic_stability_sample, - GENERIC_PLAYTEST_ACTION_CAUSALITY_FINGERPRINT, GENERIC_PLAYTEST_MAX_FINAL_SAMPLE_GAP, - GENERIC_PLAYTEST_POST_ACTION_MIN_STABILITY_SAMPLES, GENERIC_PLAYTEST_POST_ACTION_WINDOW, - GENERIC_PLAYTEST_RESTART_MIN_STABILITY_SAMPLES, GENERIC_PLAYTEST_RESTART_STABILITY_WINDOW, + finish_generic_stability_observation, generic_action_sequence_probe_fingerprint_material, + generic_non_loss_progression_phase_is_valid, generic_primary_action_phase_is_valid, + generic_restart_phase_is_valid, generic_start_phase_is_valid, + validate_generic_stability_sample, GENERIC_PLAYTEST_ACTION_CAUSALITY_FINGERPRINT, + GENERIC_PLAYTEST_MAX_FINAL_SAMPLE_GAP, GENERIC_PLAYTEST_POST_ACTION_MIN_STABILITY_SAMPLES, + GENERIC_PLAYTEST_POST_ACTION_WINDOW, GENERIC_PLAYTEST_RESTART_MIN_STABILITY_SAMPLES, + GENERIC_PLAYTEST_RESTART_STABILITY_WINDOW, GENERIC_PLAYTEST_START_OPPORTUNITY_MIN_STABILITY_SAMPLES, GENERIC_PLAYTEST_START_OPPORTUNITY_WINDOW, }; @@ -304,6 +305,10 @@ pub(crate) fn browser_playtest_scenario_fingerprint(scenario: BrowserPlaytestSce &mut hasher, GENERIC_PLAYTEST_ACTION_CAUSALITY_FINGERPRINT, ); + update_playtest_fingerprint_component( + &mut hasher, + &generic_action_sequence_probe_fingerprint_material(), + ); update_playtest_fingerprint_component(&mut hasher, PLAYTEST_START_SELECTOR); update_playtest_fingerprint_component(&mut hasher, PLAYTEST_PRIMARY_ACTION_SELECTOR); update_playtest_fingerprint_component(&mut hasher, PLAYTEST_RESTART_SELECTOR); 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 6fe435894..589e2bb94 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, - "6d6ce6843200da907427ef67d0bf0644b6499f76c221ab695b417309edf0fd33" + "adc95709431dc762d3293a580133a1a4dc671ced3e6d0db75d214bb1e7c73964" ); assert_eq!( tetris, - "742545caae05bb6a673d91a55b23055570358843bff5cbe0acb3e86958786c79" + "92e6eae0b8af8eca2baf0ddfc6d5084a1145e7be18f6f15e944289d4d9df98a6" ); assert_eq!( lane, @@ -1265,7 +1265,7 @@ async fn real_chrome_generic_playtest_rejects_one_frame_playing_state() { #[tokio::test] #[ignore = "requires an installed Chrome/Chromium/Edge and explicit local browser execution"] -async fn real_chrome_generic_playtest_accepts_capture_phase_action_flow() { +async fn real_chrome_generic_playtest_accepts_early_mouse_and_late_click_action_flow() { use std::io::{Read, Write}; use std::net::TcpListener; use std::sync::mpsc; @@ -1309,19 +1309,29 @@ async fn real_chrome_generic_playtest_accepts_capture_phase_action_flow() { }); const primary = document.querySelector('[data-playtest-id="primary-action"]'); const restart = document.querySelector('[data-playtest-id="restart"]'); - document.addEventListener('click', (event) => { + let restartReachedDocumentBubble = false; + window.addEventListener('pointerdown', (event) => { if (event.isTrusted && event.target === primary && state.phase === 'playing') { advance(() => { state.score += 1; }); } }, true); + primary.addEventListener('click', (event) => { + if (event.isTrusted) event.stopPropagation(); + }); document.addEventListener('click', (event) => { if (event.isTrusted && event.target === restart) { + restartReachedDocumentBubble = true; + } + }); + window.addEventListener('click', (event) => { + if (event.isTrusted && event.target === restart && restartReachedDocumentBubble) { advance(() => { state.phase = 'ready'; state.score = 0; }); + restartReachedDocumentBubble = false; } - }, true); + }); })(); @@ -1376,6 +1386,117 @@ async fn real_chrome_generic_playtest_accepts_capture_phase_action_flow() { assert!(playtest.assertions.iter().all(|assertion| assertion.passed)); } +#[tokio::test] +#[ignore = "requires an installed Chrome/Chromium/Edge and explicit local browser execution"] +async fn real_chrome_generic_playtest_rejects_timer_deferred_action_progress() { + use std::io::{Read, Write}; + use std::net::TcpListener; + use std::sync::mpsc; + use std::thread; + + discover_chrome_or_edge().expect("Chrome, Chromium, or Edge must be installed"); + let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).expect("bind preview"); + let port = listener.local_addr().expect("preview address").port(); + listener.set_nonblocking(true).expect("nonblocking preview"); + let html = br#" + +Deferred Generic Browser Fixture + +
Deferred generic fixture
+ + + + + + + +"#; + let (stop_tx, stop_rx) = mpsc::channel(); + let server = thread::spawn(move || { + while stop_rx.try_recv().is_err() { + match listener.accept() { + Ok((mut stream, _)) => { + let mut request = [0_u8; 2048]; + let _ = stream.read(&mut request); + let headers = format!( + "HTTP/1.1 200 OK\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", + html.len() + ); + let _ = stream.write_all(headers.as_bytes()); + let _ = stream.write_all(html); + } + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { + thread::sleep(Duration::from_millis(10)); + } + Err(error) => panic!("preview accept failed: {error}"), + } + } + }); + + let evidence = tempfile::tempdir().expect("evidence tempdir"); + let validation = validate_local_preview_in_browser(BrowserValidationInput { + url: format!("http://127.0.0.1:{port}/"), + viewports: REQUIRED_VIEWPORTS.to_vec(), + expected_text: vec!["Deferred generic fixture".to_string()], + settle_ms: 100, + fail_on_console_error: true, + playtest_scenario: Some(BrowserPlaytestScenario::GenericV1), + evidence_root: evidence.path().join("evidence"), + }) + .await; + let _ = stop_tx.send(()); + server.join().expect("preview server"); + + let result = validation.expect("real deferred generic browser validation"); + assert!(!result.passed, "deferred action must fail validation"); + let playtest = result.playtest.expect("generic playtest result"); + assert!(!playtest.passed, "{:#?}", playtest.diagnostics); + assert!( + playtest + .diagnostics + .iter() + .any(|message| message.contains("RAF/timer")), + "diagnostics={:#?}", + playtest.diagnostics + ); +} + #[tokio::test] #[ignore = "requires an installed Chrome/Chromium/Edge and explicit local browser execution"] async fn real_chrome_generic_playtest_binds_tetris_actions_to_gameplay_state() { diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 2a3f3f045..c27b58a63 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -5926,8 +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。 -- 图集事务退役补充:九路径快照在读取前先用不跟随符号链接的元数据核算 64 MiB 总预算,实际读取仍受剩余预算限制,稀疏或并发增长文件不能触发无界分配。恢复必须先把全部 journal 条目和九路径快照完成结构、大小与摘要校验并形成内存计划,随后才能修改任一 canonical 路径;末尾快照损坏不得留下新旧混合合同。`committed` 持久化后先删除并同步 `prepared`,再清理 `.previous / .replacement`、同步 canonical 合同并最后删除事务目录;递归删除中断后最多留下只有 `committed` 的可清理事务,不能重新落入 rollback 分支。 -- Tetris 连续性补充:明确俄罗斯方块任务固定分类为 `tetris-v1`,不再回退 generic 可选遥测。静态门移除字符串、注释、`template / noscript / textarea / title / style / xmp / iframe / noembed / plaintext`、带 `src` 脚本的非执行正文、非 JavaScript script 和 `if(false)` / 明显恒假分支诱饵,并要求有标识符边界的可达 `fall -> lock -> clear` 调用链;splice 消行必须绑定棋盘或不可重赋值的棋盘别名。同项目 `game/*.js / game/*.mjs` 外部脚本及本地 module 依赖图按去重文件数和累计 2 MiB 上限有界读取。浏览器状态固定含 `activePieceId / rotation / row / lockedPieces / lineClearChecks / clearedLines / occupiedCells`,同时允许 `score / nextPieceId` 等不影响固定合同的扩展 telemetry;受控试玩在 Chromium 隔离执行上下文的 Promise 闭包中,以同一次 trusted 鼠标输入的 mouseup 尾部状态作为 click 前基线,页面全局对象不能改写因果证据,capture-phase click 仍可被正确验收。锁定、消行与 restart 的既有严格约束保持不变。旧合同或纯继续 successor 以及 game-chat 快车道在读取回执前按有效原任务重新分类、重算指纹并回读迁移结果,旧 generic 回执只能视为 stale,不能交付完成。 +- 图集事务退役补充:九路径快照在读取前先用跨平台不跟随符号链接 / reparse point 的文件句柄核算 64 MiB 总预算,marker、journal 与快照均通过有界双次读取和句柄元数据复核拒绝同长度并发改写;实际读取仍受剩余预算限制,稀疏或并发增长文件不能触发无界分配。恢复必须先把全部 journal 条目和九路径快照完成结构、大小与摘要校验并形成内存计划,随后缓存全部 canonical 路径的恢复前状态;每项落盘前再次校验目标与父目录,后续项失败时按逆序回滚本轮已应用项,末尾路径竞态或快照损坏不得留下新旧混合合同。`committed` 持久化后先删除并同步 `prepared`,再清理 `.previous / .replacement`、同步 canonical 合同并最后删除事务目录;递归删除中断后最多留下只有 `committed` 的可清理事务,不能重新落入 rollback 分支。 +- Tetris 连续性补充:明确俄罗斯方块任务固定分类为 `tetris-v1`,不再回退 generic 可选遥测。HTML tokenizer 只把 TAB / LF / FF / CR / SPACE 视为标签空白;静态门移除字符串、注释、`template / noscript / textarea / title / style / xmp / iframe / noembed / plaintext`、带 `src` 脚本的非执行正文、非 JavaScript script、不可达函数、短路表达式和 `if(false)` / 明显恒假分支诱饵,并要求有标识符边界的可达 `fall -> lock -> clear` 调用链;filter / splice 消行必须由满行判断实际控制且绑定棋盘或不可重赋值的棋盘别名。同项目 `game/*.js / game/*.mjs` 外部脚本及本地 module 依赖图按真实连通单元聚合语义、隔离互不导入的 module,并按去重文件数和累计 2 MiB 上限有界读取;对象属性 `import / from` 与控制块后的正则正文不得伪造依赖。浏览器状态固定含 `activePieceId / rotation / row / lockedPieces / lineClearChecks / clearedLines / occupiedCells`,同时允许 `score / nextPieceId` 等不影响固定合同的扩展 telemetry;受控试玩在 Chromium 隔离执行上下文的 Promise 闭包中,以同一浏览器任务内 trusted 输入分发尾部冻结的状态作为 click 前基线,页面全局对象不能改写因果证据,真实 window bubble 处理器仍可被正确验收,0ms timer 不得抢入该因果窗口。探针 fingerprint 必须覆盖 install、ready 与 finish 三段真实脚本。锁定、消行与 restart 的既有严格约束保持不变。旧合同或纯继续 successor 以及 game-chat 快车道在读取回执前按有效原任务重新分类、重算 fingerprint 并回读迁移结果,旧 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 1be85aaca..ad2ba15fc 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -827,9 +827,9 @@ game-project/ - 2026-08-03 失败续跑收口:同一 `project-supervisor` Session、同一持久 source 的最近可信根 run 已失败、取消或预算耗尽,且新输入只是严格受限的继续意图(例如“继续”“接着做”“继续完成”“continue”“go on”)时,宿主仍创建新的 root run 身份,但必须把上一根 run 的原始任务作为继承目标和完成合同基线;首次和连续 successor 的 effective task、合同 SHA、Runtime hydration 与 scheduler 必须一致。不得把继续短语本身当游戏主题,也不得按真正新需求重置 seed manifest。跨 Session、跨 GUI / CLI / game-chat source、上一根 run 已正常完成、输入包含新的具体玩法要求或无法唯一识别前序根 run 时都不继承,继续按新任务执行。继承只复用目标与已有产物基线,不复用旧 Provider request、pending action 或副作用身份。 - game-chat 快车道只能在 `game/index.html` 缺失或仍是初始化占位,且当前 child run 尚未写入正式入口时使用首次 fallback `file.write`。项目已存在非占位入口时,后续 `code-prototype` 必须先保留并读取既有玩法,做真实局部修改并取得本人 `mutationRevision`,之后才能运行 `game.static_smoke` 与交付;禁止为了满足首版时限重新生成整份默认小游戏,也禁止连续只读 smoke。占位 fallback 仅允许俄罗斯方块和明确收集类等已有真实语义模板,未知玩法失败关闭。纯继续意图未能恢复唯一原始目标时同样失败关闭,不输出以“继续”为标题的兜底产物。 - `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 后才可清理;恢复只在同一项目写锁内先完整验证全部 journal 条目和快照、形成内存恢复计划,再按 transaction id 整组回滚或幂等清理,任一末尾快照损坏都不得先改写前面的 canonical 路径。远端下载阶段不得长期持锁,也不得在无锁 request 阶段修改 canonical 文件。 +- 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 后才可清理;marker、journal 和快照必须由跨平台不跟随 symlink / reparse point 的句柄进行有界双次读取,拒绝同长度并发改写。恢复只在同一项目写锁内先完整验证全部 journal 条目和快照、形成内存恢复计划并缓存全部 canonical 的恢复前状态,再按 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。静态连续性检查忽略字符串、注释、HTML raw-text/RCDATA 与其它非执行容器、带 `src` 脚本的内联正文、非 JavaScript script 和 `if(false)` / 明显恒假分支诱饵,以标识符边界绑定真实 `fall -> lock -> clear` 调用链;splice 消行必须作用于棋盘或可证明的常量别名。本地 `.js / .mjs` 入口、inline module import 与 module 传递依赖统一限制在 `game/`,按文件去重并受 256 文件、累计 2 MiB 上限约束。浏览器因果探针运行于 Chromium 隔离执行上下文,使用同一次 trusted 鼠标输入的 mouseup 尾部状态作为 click 前基线,前后原始状态只保存在 Promise 闭包中,因此受测页面不能通过全局变量改写证据,页面既有 capture-phase click 处理器也不会被误判;其余同方块旋转、重力/锁定、四格落盘或消行、`lineClearChecks` 和 restart 归零约束保持不变。旧/续跑合同及 game-chat 快车道在回执读取前按有效原任务迁移到该场景、重算 fingerprint 并回读一致,旧 generic-v1 回执视为 stale,不能交付完成。 +- 俄罗斯方块任务固定使用 `BrowserPlaytestScenario::TetrisV1`。`playable-web-game-state.v1.gameplay` 必须持续提供 `kind=tetris`、`activePieceId`、`rotation`、`row`、`lockedPieces`、`lineClearChecks`、`clearedLines` 和 `occupiedCells`;任何采样点删除字段都立即失败,但允许 `score / nextPieceId` 等额外 telemetry。静态连续性检查按 HTML 规定的五种空白解析标签,忽略字符串、注释、HTML raw-text/RCDATA 与其它非执行容器、带 `src` 脚本的内联正文、非 JavaScript script、不可达函数、短路动态 import 和 `if(false)` / 明显恒假分支诱饵,以标识符边界绑定真实 `fall -> lock -> clear` 调用链;filter / splice 消行必须由满行判断真实控制,并作用于棋盘或可证明的常量别名。本地 `.js / .mjs` 入口、inline module import 与 module 传递依赖统一限制在 `game/`,按真实 import graph 的连通单元聚合、按文件去重并受 256 文件、累计 2 MiB 上限约束;互不导入的 module 保持作用域隔离,对象属性和正则正文不能伪造依赖。浏览器因果探针运行于 Chromium 隔离执行上下文,在同一浏览器任务内冻结 trusted 输入分发尾部状态作为 click 前基线,前后原始状态只保存在 Promise 闭包中,因此受测页面不能通过全局变量改写证据,页面既有 capture-phase 或 window bubble click 处理器也不会被误判,0ms timer 不得抢入因果窗口;探针 fingerprint 覆盖 install、ready 与 finish 的真实脚本。其余同方块旋转、重力/锁定、四格落盘或消行、`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 重放,禁止分别执行原响应和持久响应。