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);
+ });
})();
+Deferred generic fixture
+
+Start
+Collect later
+Restart
+
+
+
@@ -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#"
+
+