修复多智能体游戏验收边界
完善 Canvas 提交恢复顺序与快照读取预算。 收紧俄罗斯方块静态验收、可信交互与外部脚本边界。 补齐旧合同迁移、原生工具 schema 与动作审计白名单。 同步技术方案、共享决策记录与回归测试。
This commit is contained in:
@@ -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)
|
||||
|
||||
+331
-26
@@ -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);
|
||||
|
||||
+93
-4
@@ -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` marker,Canvas 资产登记成功并写 `committed` marker 后才可清理;恢复只在同一项目写锁内按 transaction id 整组回滚或幂等清理,远端下载阶段不得长期持锁,也不得在无锁 request 阶段修改 canonical 文件。
|
||||
- 图集本地提交以主图 staging 为线性化前置:任何新主图先写随机私有 staging 文件,替换时保留 previous,canonical 主图完整安装后才写四切片、公开清单、私有回执和项目资产登记。进程若在 backup/install 窗口退出,同一 accepted External generation 恢复先识别唯一同 suffix 的 previous/replacement 对并恢复旧主图,再按远端结果完成替换;若 canonical 已等于远端摘要,则不再要求替换授权,直接补齐其余合同。成功后清理主图、四切片、公开清单、私有回执和项目 manifest 的全部遗留 staging/backup。首次生成也禁止直接流式写 canonical 路径,避免部分 PNG 被误认为已安装结果。
|
||||
- 俄罗斯方块任务固定使用 `BrowserPlaytestScenario::TetrisV1`。`playable-web-game-state.v1.gameplay` 必须持续提供 `kind=tetris`、`activePieceId`、`rotation`、`row`、`lockedPieces`、`lineClearChecks`、`clearedLines` 和 `occupiedCells`;任何采样点删除字段都立即失败,但允许 `score / nextPieceId` 等额外 telemetry。静态连续性检查忽略字符串、注释和 `if(false)` / 明显恒假分支诱饵,并绑定真实 `fall -> lock -> clear` 调用链。浏览器因果探针要求 primary-action 在同一传播中旋转同一活动方块,start 后观察重力下落或真实锁定;锁定必须更换 `activePieceId`、只增加一个 `lockedPieces`,并使 `occupiedCells / clearedLines` 与四格落盘或实际消行相符,随后观察 `lineClearChecks`;restart 后棋盘和全部计数归零。旧/续跑合同在真实场景读取恢复路径按有效原任务迁移到该场景、重算 fingerprint 并回读一致,不能继续沿用 generic-v1。
|
||||
- 俄罗斯方块任务固定使用 `BrowserPlaytestScenario::TetrisV1`。`playable-web-game-state.v1.gameplay` 必须持续提供 `kind=tetris`、`activePieceId`、`rotation`、`row`、`lockedPieces`、`lineClearChecks`、`clearedLines` 和 `occupiedCells`;任何采样点删除字段都立即失败,但允许 `score / nextPieceId` 等额外 telemetry。静态连续性检查忽略字符串、注释、`template / noscript / 非 JavaScript script` 和 `if(false)` / 明显恒假分支诱饵,以标识符边界绑定真实 `fall -> lock -> clear` 调用链,并允许在 `game/` 下按累计 2 MiB 上限有界读取本地 `.js / .mjs` 外部脚本。浏览器因果探针运行于 Chromium 隔离执行上下文,trusted click 前后原始状态只保存在 Promise 闭包中,受测页面不能通过全局变量改写证据;其余同方块旋转、重力/锁定、四格落盘或消行、`lineClearChecks` 和 restart 归零约束保持不变。旧/续跑合同及 game-chat 快车道在回执读取前按有效原任务迁移到该场景、重算 fingerprint 并回读一致,旧 generic-v1 回执视为 stale,不能交付完成。
|
||||
- 泥点不足是确定性业务中断,不是瞬态 Provider 故障或未知副作用。钱包的 `泥点余额不足` 与 `可消费泥点不足:...` 两种领域文案统一映射为稳定原因 `mud-points-insufficient`,不得自动重试;即使 External Generation durable ledger 已存在,也必须落为 `failed`,不能误入 `needs-reconciliation`。game-chat 顶部状态、持久失败对话与 `【Supervisor 阶段记录】` 统一显示“泥点余额不足,本轮游戏生成已中断。请充值后发送“继续”,系统会从当前项目进度接着完成。”,并禁止透传 operationId、URL、路径、密钥或任意上游正文。
|
||||
- tool-plan 成功响应落账前,对内置 Runtime 原生函数与 legacy wrapper 的合法、无重复 key JSON arguments 按工具 schema 的精确位置做项目路径 canonicalization:`file.*.path`、`project.patchset.changes[*].path`、`project.git_commit.paths[*]`、`command.*.cwd`、`image.inspect.paths[*]` 与 `canvas.asset_generate.outputPath` 若是当前项目根目录内的完整绝对路径,转换为 `/` 分隔的项目相对路径后再校验、持久化并执行;源码/叙述字段、任务产物描述、动态 MCP arguments 和项目外绝对路径不得改写,后两者继续由绝对路径门禁失败关闭。项目根只允许搜索/列举范围与命令 cwd 规范化为 `.`,不能成为文件目标。当前进程与重启恢复都必须从同一份规范化 handoff 重放,禁止分别执行原响应和持久响应。
|
||||
|
||||
|
||||
Reference in New Issue
Block a user