修复多智能体游戏验收边界

完善 Canvas 提交恢复顺序与快照读取预算。

收紧俄罗斯方块静态验收、可信交互与外部脚本边界。

补齐旧合同迁移、原生工具 schema 与动作审计白名单。

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