修复游戏完成门与事务恢复原子性

完整校验九路径事务快照后再执行 Canvas 恢复。

排除 HTML 非执行容器、src 正文与非棋盘消行诱饵。

递归读取受限本地模块依赖并覆盖内联模块入口。

兼容 capture-phase 点击处理并更新试玩场景指纹。

补齐真实浏览器、静态合同、事务恢复测试与架构文档。
This commit is contained in:
2026-08-04 02:51:15 +08:00
parent 38afa8ae21
commit 2b41a9a1ac
7 changed files with 915 additions and 141 deletions
@@ -1974,6 +1974,7 @@ fn restore_strict_platform_art_transaction_at(
.as_nanos()
);
let mut total_snapshot_bytes = 0_u64;
let mut recovery_plan = Vec::with_capacity(STRICT_PLATFORM_ART_CONTRACT_PATHS.len());
for (index, local_path) in STRICT_PLATFORM_ART_CONTRACT_PATHS.iter().enumerate() {
let entry = &entries[index];
if entry.get("localPath").and_then(serde_json::Value::as_str) != Some(*local_path) {
@@ -1997,14 +1998,43 @@ fn restore_strict_platform_art_transaction_at(
if metadata.file_type().is_symlink() || !metadata.is_file() {
return Err("平台图集事务快照不是可信普通文件".to_string());
}
total_snapshot_bytes = total_snapshot_bytes
.checked_add(metadata.len())
.ok_or_else(|| "平台图集事务快照累计大小溢出".to_string())?;
if total_snapshot_bytes > STRICT_PLATFORM_ART_TRANSACTION_MAX_SNAPSHOT_BYTES {
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 snapshot = fs::read(&snapshot_path)
let mut options = fs::OpenOptions::new();
options.read(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
options.custom_flags(libc::O_NOFOLLOW);
}
let mut snapshot_file = options
.open(&snapshot_path)
.map_err(|error| format!("打开平台图集事务快照失败:{error}"))?;
let opened_metadata = snapshot_file
.metadata()
.map_err(|error| format!("读取已打开平台图集事务快照元数据失败:{error}"))?;
if !opened_metadata.is_file() || opened_metadata.len() != metadata.len() {
return Err("平台图集事务快照在恢复校验期间发生变化,已拒绝恢复".to_string());
}
let mut snapshot =
Vec::with_capacity(usize::try_from(opened_metadata.len()).unwrap_or_default());
(&mut snapshot_file)
.take(remaining.saturating_add(1))
.read_to_end(&mut snapshot)
.map_err(|error| format!("读取平台图集事务快照失败:{error}"))?;
let snapshot_len = u64::try_from(snapshot.len()).unwrap_or(u64::MAX);
if snapshot_len > remaining {
return Err("平台图集事务快照累计超过 64 MiB,已拒绝恢复".to_string());
}
if snapshot_len != opened_metadata.len() {
return Err("平台图集事务快照大小在恢复校验期间发生变化,已拒绝恢复".to_string());
}
total_snapshot_bytes = total_snapshot_bytes
.checked_add(snapshot_len)
.ok_or_else(|| "平台图集事务快照累计大小溢出".to_string())?;
let expected_sha256 = entry
.get("sha256")
.and_then(serde_json::Value::as_str)
@@ -2012,6 +2042,13 @@ fn restore_strict_platform_art_transaction_at(
if format!("{:x}", Sha256::digest(&snapshot)) != expected_sha256 {
return Err("平台图集事务快照摘要不一致,已拒绝恢复".to_string());
}
recovery_plan.push((canonical, Some(snapshot)));
} else {
recovery_plan.push((canonical, None));
}
}
for (canonical, snapshot) in recovery_plan {
if let Some(snapshot) = snapshot {
if let Some(parent) = canonical.parent() {
fs::create_dir_all(parent).map_err(|error| {
format!(
@@ -4885,6 +4922,57 @@ mod canvas_generation_tests {
assert!(!root.join(STRICT_PLATFORM_ART_TRANSACTION_PATH).exists());
}
#[test]
fn durable_strict_contract_transaction_validates_every_snapshot_before_restoring_any_path() {
let temporary = tempfile::tempdir().expect("create atomic recovery project");
let root = temporary.path();
init_local_game_project_at(root, "atomic-recovery", "原子图集事务恢复测试")
.expect("init project");
for (index, local_path) in STRICT_PLATFORM_ART_CONTRACT_PATHS.iter().enumerate() {
let path = root.join(local_path);
fs::create_dir_all(path.parent().expect("contract parent"))
.expect("create contract parent");
fs::write(path, format!("old-contract-{index}")).expect("write original contract");
}
let project_lock = acquire_project_write_lock(root, "canvas.asset_generate.recover-test")
.expect("acquire project write lock");
let transaction = PlatformArtSliceContractRollback::capture(root, "atomic-recovery")
.expect("persist complete contract snapshot");
let mut partial_contract = Vec::new();
for (index, local_path) in STRICT_PLATFORM_ART_CONTRACT_PATHS.iter().enumerate() {
let path = root.join(local_path);
let bytes = format!("partial-new-contract-{index}").into_bytes();
fs::write(&path, &bytes).expect("write partial canonical contract");
partial_contract.push((path, bytes));
}
let last_snapshot = transaction.transaction_directory.join(format!(
"{:02}.snapshot",
STRICT_PLATFORM_ART_CONTRACT_PATHS.len() - 1
));
fs::write(last_snapshot, b"corrupted-last-snapshot")
.expect("corrupt the final durable snapshot");
std::mem::forget(transaction);
let error =
recover_interrupted_strict_platform_art_transaction_locked_at(root, &project_lock)
.expect_err("a corrupt late snapshot must fail before canonical restoration");
assert!(
error.contains("快照摘要不一致"),
"unexpected error: {error}"
);
for (path, expected) in partial_contract {
assert_eq!(
fs::read(&path).expect("read untouched partial contract"),
expected,
"validation failure must not modify {}",
path.display()
);
}
assert!(root.join(STRICT_PLATFORM_ART_TRANSACTION_PATH).exists());
drop(project_lock);
}
#[test]
fn durable_strict_contract_transaction_rejects_oversized_sparse_snapshot_before_reading() {
let temporary = tempfile::tempdir().expect("create oversized snapshot project");
@@ -1432,6 +1432,90 @@ fn inherited_tetris_contract_scans_only_executable_html_scripts() {
Some("board-state"),
"noscript content must not count as executable gameplay in a scripting browser",
);
for container in [
"textarea",
"title",
"style",
"xmp",
"iframe",
"noembed",
"plaintext",
] {
let wrapped = valid.replacen(
"<script>\nlet board",
&format!("<{container}><script>\nlet board"),
1,
);
let mut wrapped = wrapped;
if container != "plaintext" {
let script_close = wrapped
.rfind("</script>")
.expect("executable fixture has a final script close");
wrapped.insert_str(script_close + "</script>".len(), &format!("</{container}>"));
}
assert_eq!(
inherited_gameplay_semantics_gap(task, wrapped.as_bytes()).as_deref(),
Some("board-state"),
"{container} content must not count as executable gameplay",
);
assert!(
executable_external_script_sources_from_html(&format!(
"<{container}><script src=\"./missing.js\"></script>{}",
if container == "plaintext" {
""
} else {
match container {
"textarea" => "</textarea>",
"title" => "</title>",
"style" => "</style>",
"xmp" => "</xmp>",
"iframe" => "</iframe>",
"noembed" => "</noembed>",
_ => unreachable!(),
}
}
))
.is_empty(),
"{container} content must not expose external gameplay scripts",
);
}
let self_closing_textarea =
valid.replacen("<script>\nlet board", "<textarea/><script>\nlet board", 1);
let textarea_script_close = self_closing_textarea
.rfind("</script>")
.expect("executable fixture has a final script close");
let mut self_closing_textarea = self_closing_textarea;
self_closing_textarea.insert_str(textarea_script_close + "</script>".len(), "</textarea>");
assert_eq!(
inherited_gameplay_semantics_gap(task, self_closing_textarea.as_bytes()).as_deref(),
Some("board-state"),
"a slash does not make a non-void textarea self-closing in HTML",
);
let template_with_fake_close = valid.replacen(
"<script>\nlet board",
"<template><script type=\"text/plain\">const bait='</template>';</script><script>\nlet board",
1,
);
let template_script_close = template_with_fake_close
.rfind("</script>")
.expect("executable fixture has a final script close");
let mut template_with_fake_close = template_with_fake_close;
template_with_fake_close.insert_str(template_script_close + "</script>".len(), "</template>");
assert_eq!(
inherited_gameplay_semantics_gap(task, template_with_fake_close.as_bytes()).as_deref(),
Some("board-state"),
"a template close marker inside script text must not expose later inert scripts",
);
let sourced_inline_body = valid.replacen("<script>\nlet board", "<script src>\nlet board", 1);
assert_eq!(
inherited_gameplay_semantics_gap(task, sourced_inline_body.as_bytes()).as_deref(),
Some("board-state"),
"the inline body of a sourced script is ignored by the browser",
);
}
#[test]
@@ -1488,6 +1572,36 @@ fn inherited_tetris_contract_binds_fall_clear_and_rotation_semantics() {
Some("piece-lock"),
"an unrelated traversal plus an unbound board write must not count as piece locking",
);
let unrelated_splice = valid.replace(
"function clearLines(){lineClearChecks+=1;const before=board.length;board=board.filter((row)=>!row.every(Boolean));clearedLines+=before-board.length;while(board.length<20)board.unshift(Array(10).fill(0));}",
"function clearLines(){lineClearChecks+=1;const junk=[];junk.splice(4,1);junk.unshift(Array(10).fill(0));}",
);
assert_eq!(
inherited_gameplay_semantics_gap(task, unrelated_splice.as_bytes()).as_deref(),
Some("line-clear"),
"splice and replacement-row operations on an unrelated array must not count as clearing",
);
let board_alias_splice = valid.replace(
"function clearLines(){lineClearChecks+=1;const before=board.length;board=board.filter((row)=>!row.every(Boolean));clearedLines+=before-board.length;while(board.length<20)board.unshift(Array(10).fill(0));}",
"function clearLines(){lineClearChecks+=1;const grid=board;grid.splice(4,1);grid.unshift(Array(10).fill(0));}",
);
assert_eq!(
inherited_gameplay_semantics_gap(task, board_alias_splice.as_bytes()),
None,
"a non-reassigned const alias of board may perform the splice clear",
);
let reassigned_board_alias = valid.replace(
"function clearLines(){lineClearChecks+=1;const before=board.length;board=board.filter((row)=>!row.every(Boolean));clearedLines+=before-board.length;while(board.length<20)board.unshift(Array(10).fill(0));}",
"function clearLines(){lineClearChecks+=1;let grid=board;grid=[];grid.splice(4,1);grid.unshift(Array(10).fill(0));}",
);
assert_eq!(
inherited_gameplay_semantics_gap(task, reassigned_board_alias.as_bytes()).as_deref(),
Some("line-clear"),
"a reassigned board copy is not a provable alias",
);
}
#[test]
@@ -1594,6 +1708,90 @@ fn inherited_tetris_contract_accepts_bounded_local_external_script() {
);
}
#[test]
fn inherited_tetris_contract_recursively_reads_local_module_dependencies() {
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 type=\"module\" src=\"./main.js\"></script>{}",
&valid[..script_start],
&valid[script_end + "</script>".len()..]
);
let temporary = tempfile::tempdir().expect("create module Tetris project");
let root = temporary.path();
init_local_game_project_at(root, "module-tetris", task).expect("init project");
fs::write(
root.join("game/main.js"),
r#"const stringBait = "import './missing-string.js'";
// import './missing-comment.js';
const regexBait = /x import '.\/missing-regex.js'/;
import './gameplay/tetris.mjs';"#,
)
.expect("write module entry");
fs::create_dir_all(root.join("game/gameplay")).expect("create module directory");
fs::write(
root.join("game/gameplay/tetris.mjs"),
format!(
"export * from './telemetry.js';\n{}",
&valid[body_start..script_end]
),
)
.expect("write module gameplay");
fs::write(
root.join("game/gameplay/telemetry.js"),
"export const telemetry = true;",
)
.expect("write nested module dependency");
let external = read_external_gameplay_javascript_at(root, &external_html)
.expect("recursively read bounded local module graph");
assert_eq!(
inherited_gameplay_semantics_gap_with_external_javascript(
task,
external_html.as_bytes(),
&external,
),
None,
"a local module dependency must contribute executable Tetris semantics",
);
let classic_html = external_html.replace(" type=\"module\"", "");
let classic = read_external_gameplay_javascript_at(root, &classic_html)
.expect("read classic entry without traversing invalid static imports");
assert_eq!(
inherited_gameplay_semantics_gap_with_external_javascript(
task,
classic_html.as_bytes(),
&classic,
)
.as_deref(),
Some("board-state"),
"a classic script must not gain semantics from a static-import decoy",
);
let inline_module_html = format!(
"{}<script type=\"module\">import './main.js';</script>{}",
&valid[..script_start],
&valid[script_end + "</script>".len()..]
);
let inline_external = read_external_gameplay_javascript_at(root, &inline_module_html)
.expect("recursively read an inline module entry");
assert_eq!(
inherited_gameplay_semantics_gap_with_external_javascript(
task,
inline_module_html.as_bytes(),
&inline_external,
),
None,
"an inline module's local dependency graph must contribute Tetris semantics",
);
}
#[test]
fn game_chat_pure_continue_does_not_inherit_across_sessions() {
let (_temporary, root, original_state, original_contract) =
@@ -24,12 +24,12 @@ pub(in crate::browser) const GENERIC_PLAYTEST_START_OPPORTUNITY_MIN_STABILITY_SA
pub(in crate::browser) const GENERIC_PLAYTEST_POST_ACTION_MIN_STABILITY_SAMPLES: usize = 12;
pub(in crate::browser) const GENERIC_PLAYTEST_RESTART_MIN_STABILITY_SAMPLES: usize = 12;
pub(in crate::browser) const GENERIC_PLAYTEST_ACTION_CAUSALITY_FINGERPRINT: &str = concat!(
"primary-action=trusted-event-isolated-world-promise-closure-capture-to-document-bubble-sequence-advance\n",
"primary-action=trusted-event-isolated-world-promise-closure-mouseup-tail-baseline-to-click-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=trusted-event-isolated-world-promise-closure-capture-to-document-bubble-sequence-advance"
"restart=trusted-event-isolated-world-promise-closure-mouseup-tail-baseline-to-click-bubble-sequence-advance"
);
#[derive(Clone, Debug, Deserialize)]
@@ -81,22 +81,30 @@ fn generic_action_sequence_probe_script(selector: &str, ready_key: &str) -> Resu
return String(surface[0].textContent || '');
}};
let beforeState = null;
const capture = (event) => {{
let baselineCaptured = false;
const mouseup = (event) => {{
if (!event.isTrusted) return;
if (event.button !== 0) return;
if (event.target !== control && !(event.target instanceof Node && control.contains(event.target))) return;
beforeState = readState();
baselineCaptured = true;
}};
const bubble = (event) => {{
if (!event.isTrusted) return;
if (event.target !== control && !(event.target instanceof Node && control.contains(event.target))) return;
control.removeEventListener('click', capture, true);
window.removeEventListener('mouseup', mouseup, false);
document.removeEventListener('click', bubble, false);
finish({{ status: 'completed', beforeState, afterState: readState() }});
finish({{
status: baselineCaptured ? 'completed' : 'missing-mouseup-baseline',
beforeState,
afterState: baselineCaptured ? readState() : null,
}});
}};
control.addEventListener('click', capture, {{ capture: true }});
window.addEventListener('mouseup', mouseup, {{ capture: false }});
document.addEventListener('click', bubble, {{ capture: false }});
globalThis[readyKey] = 'armed';
timeoutId = setTimeout(() => {{
control.removeEventListener('click', capture, true);
window.removeEventListener('mouseup', mouseup, false);
document.removeEventListener('click', bubble, false);
finish({{ status: 'timeout', beforeState, afterState: null }});
}}, 5000);
@@ -1074,6 +1082,9 @@ mod tests {
assert!(script.contains("new Promise"));
assert!(script.contains("if (!event.isTrusted) return"));
assert!(script.contains("let beforeState = null"));
assert!(script.contains("window.addEventListener('mouseup', mouseup"));
assert!(script.contains("status: baselineCaptured ? 'completed'"));
assert!(!script.contains("control.addEventListener('click'"));
assert!(script.contains("resolve(value)"));
assert!(!script.contains("__genarrativeGenericActionSequenceProbe"));
assert!(!script.contains("beforeGameplay"));
@@ -260,11 +260,11 @@ fn playtest_scenario_fingerprints_are_fixed_lowercase_sha256_values() {
let lane = browser_playtest_scenario_fingerprint(BrowserPlaytestScenario::LaneDefenseV1);
assert_eq!(
generic,
"dd0cab96750931f10c63dbb06694dc292c83d433a89f1dcf38c0d0e15458ca41"
"6d6ce6843200da907427ef67d0bf0644b6499f76c221ab695b417309edf0fd33"
);
assert_eq!(
tetris,
"5e1b20b0a83e932c7dbdc6972a6a3b4fc5fd3d937525bbd339eb10631d94d40b"
"742545caae05bb6a673d91a55b23055570358843bff5cbe0acb3e86958786c79"
);
assert_eq!(
lane,
@@ -1265,7 +1265,7 @@ async fn real_chrome_generic_playtest_rejects_one_frame_playing_state() {
#[tokio::test]
#[ignore = "requires an installed Chrome/Chromium/Edge and explicit local browser execution"]
async fn real_chrome_generic_playtest_accepts_stable_primary_action_flow() {
async fn real_chrome_generic_playtest_accepts_capture_phase_action_flow() {
use std::io::{Read, Write};
use std::net::TcpListener;
use std::sync::mpsc;
@@ -1307,19 +1307,21 @@ async fn real_chrome_generic_playtest_accepts_stable_primary_action_flow() {
advance(() => { state.phase = 'playing'; });
}
});
document.querySelector('[data-playtest-id="primary-action"]').addEventListener('click', (event) => {
if (event.isTrusted && state.phase === 'playing') {
const primary = document.querySelector('[data-playtest-id="primary-action"]');
const restart = document.querySelector('[data-playtest-id="restart"]');
document.addEventListener('click', (event) => {
if (event.isTrusted && event.target === primary && state.phase === 'playing') {
advance(() => { state.score += 1; });
}
});
document.querySelector('[data-playtest-id="restart"]').addEventListener('click', (event) => {
if (event.isTrusted) {
}, true);
document.addEventListener('click', (event) => {
if (event.isTrusted && event.target === restart) {
advance(() => {
state.phase = 'ready';
state.score = 0;
});
}
});
}, true);
})();
</script>
</body>
@@ -5926,8 +5926,8 @@
- 覆盖决策:game-chat fallback 只允许初始化缺失/占位入口的首次落盘。非占位 `game/index.html` 必须保留,并由当前 `code-prototype` 先读取和实际 patch,取得本人 `mutationRevision` 后才能运行 `game.static_smoke` 与交付;只读 smoke 不得冒充续作。确定性 fallback 只允许已实现真实语义的显式玩法模板:俄罗斯方块模板必须具备 10×20 棋盘、下落、移动、旋转、锁定、消行和触顶失败,收集模板只用于明确收集类目标,未知玩法失败关闭。纯继续目标未恢复时同样失败关闭。完成门新增 baseline 玩法连续性和 action-driven state 检查,generic Canvas 非空、三个按钮存在或静态 smoke 通过都不能单独证明任务没有换题。
- 美术决策:`art-spec.png` 回归为规范图和下游派生 reference,不能铺作完整场景,也不能裁剪成玩家/目标。首版必须继续由 `art-asset-plan` 通过 icon-spritesheet 生成透明 `art-spritesheet.png`;桌面 Runtime 同时下载服务端 `iconImageSrcs`,按当前图集 resourceId 写入本地切片清单。game-chat 在任何本地落盘前要求稳定、非空的图集 resourceId,并在正式图集登记前要求切片严格等于四、每片 `sourceResourceId` 精确绑定整图,全部切片累计下载最多 `32 MiB`;四类差异按尺寸加规范 RGBA 像素摘要判定,PNG 编码字节不同不代表视觉内容不同。主图、四张 canonical 切片与切片清单作为一个提交合同,并把主图/切片摘要及 Canvas 身份冻结到 `.agent/runtime/art-spritesheet-contract.json` 私有回执;主图安装、回执或资产登记失败时必须恢复整组旧合同。generation 账本恢复允许对摘要一致的已落盘主图幂等补齐合同,摘要冲突不得覆盖。完成门重新读取切片时继续有界解码,并要求实际素材、公开清单、当前 Canvas 登记与私有回执在内容摘要、规范像素摘要、可见 alpha、四类唯一性和资源身份上全部一致。`code-prototype` 在活动 Canvas 中分别绘制玩家、方块/目标、障碍/场景和反馈四类不同切片。纯代码核心画面、猜测 atlas 等分坐标、单个裁切冒充全部类别、整图展示与路径诱饵失败关闭;编辑器仍允许仅有 `sliceWarning` 的完整透明图集完成,但 game-chat 必须等到真实切片可用。
- 图集身份与恢复补充:canonical game-chat 图集是 External 通用资源模型的严格完成子集。主图与四个切片都必须含非空 `assetObjectId`,五个 ID 互不复用;同一对象在顶层、resource 与 asset 中重复出现的 `assetObjectId``taskId` 都必须逐项完全一致,冲突时不得择一冻结。公开切片清单和私有回执同时冻结 `sourceResourceId / sourceAssetObjectId / sourceTaskId / sourceCanvasProjectId / sourceReferenceResourceIds`,四项切片按 usage 唯一且逐项比较 `name / path / width / height / resourceId / assetObjectId / contentSha256 / pixelSha256`。旧项目仅缺私有回执时不得从可编辑公开清单伪造回执;只有同一 `project-supervisor-game-chat` 父 run 下处于 running 的 `art-asset-plan` scheduled child、固定输出路径且当前合同确实失效时,才允许 `replaceExisting=true` 原位 repair。任何 canonical 文件变化前,Runtime 必须在 `.agent/runtime` 私有事务目录原子持久化九个固定合同路径的旧状态并回读,随后写 `prepared` marker;主图字节先写 staging,再安装 canonical 主图,随后才提交四切片、公开清单、私有回执和 Canvas 登记,登记成功后写 `committed` marker 才可回收快照和 backup。恢复只在取得同一项目写锁后按持久 transaction id 扫描;`prepared``committed` 必须整组恢复旧合同,`committed` 只做幂等清理,不能凭随机 previous/replacement 文件名干扰正在提交的事务。这样即使进程被强杀且远端暂不可用,也能恢复完整旧合同,不留下“旧主图 + 新切片”或半写 canonical PNG。
- 图集事务退役补充:九路径快照在读取前先用不跟随符号链接的元数据核算 64 MiB 总预算,实际读取仍受剩余预算限制,稀疏或并发增长文件不能触发无界分配。`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,不能交付完成。
- 图集事务退役补充:九路径快照在读取前先用不跟随符号链接的元数据核算 64 MiB 总预算,实际读取仍受剩余预算限制,稀疏或并发增长文件不能触发无界分配。恢复必须先把全部 journal 条目和九路径快照完成结构、大小与摘要校验并形成内存计划,随后才能修改任一 canonical 路径;末尾快照损坏不得留下新旧混合合同。`committed` 持久化后先删除并同步 `prepared`,再清理 `.previous / .replacement`、同步 canonical 合同并最后删除事务目录;递归删除中断后最多留下只有 `committed` 的可清理事务,不能重新落入 rollback 分支。
- Tetris 连续性补充:明确俄罗斯方块任务固定分类为 `tetris-v1`,不再回退 generic 可选遥测。静态门移除字符串、注释、`template / noscript / textarea / title / style / xmp / iframe / noembed / plaintext`、带 `src` 脚本的非执行正文、非 JavaScript script 和 `if(false)` / 明显恒假分支诱饵,并要求有标识符边界的可达 `fall -> lock -> clear` 调用链;splice 消行必须绑定棋盘或不可重赋值的棋盘别名。同项目 `game/*.js / game/*.mjs` 外部脚本及本地 module 依赖图按去重文件数和累计 2 MiB 上限有界读取。浏览器状态固定含 `activePieceId / rotation / row / lockedPieces / lineClearChecks / clearedLines / occupiedCells`,同时允许 `score / nextPieceId` 等不影响固定合同的扩展 telemetry;受控试玩在 Chromium 隔离执行上下文的 Promise 闭包中,以同一次 trusted 鼠标输入的 mouseup 尾部状态作为 click 前基线,页面全局对象不能改写因果证据capture-phase click 仍可被正确验收。锁定、消行与 restart 的既有严格约束保持不变。旧合同或纯继续 successor 以及 game-chat 快车道在读取回执前按有效原任务重新分类、重算指纹并回读迁移结果,旧 generic 回执只能视为 stale,不能交付完成。
- 关联:`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 未鉴权响应提供安全接入引导
@@ -827,9 +827,9 @@ game-project/
- 2026-08-03 失败续跑收口:同一 `project-supervisor` Session、同一持久 source 的最近可信根 run 已失败、取消或预算耗尽,且新输入只是严格受限的继续意图(例如“继续”“接着做”“继续完成”“continue”“go on”)时,宿主仍创建新的 root run 身份,但必须把上一根 run 的原始任务作为继承目标和完成合同基线;首次和连续 successor 的 effective task、合同 SHA、Runtime hydration 与 scheduler 必须一致。不得把继续短语本身当游戏主题,也不得按真正新需求重置 seed manifest。跨 Session、跨 GUI / CLI / game-chat source、上一根 run 已正常完成、输入包含新的具体玩法要求或无法唯一识别前序根 run 时都不继承,继续按新任务执行。继承只复用目标与已有产物基线,不复用旧 Provider request、pending action 或副作用身份。
- game-chat 快车道只能在 `game/index.html` 缺失或仍是初始化占位,且当前 child run 尚未写入正式入口时使用首次 fallback `file.write`。项目已存在非占位入口时,后续 `code-prototype` 必须先保留并读取既有玩法,做真实局部修改并取得本人 `mutationRevision`,之后才能运行 `game.static_smoke` 与交付;禁止为了满足首版时限重新生成整份默认小游戏,也禁止连续只读 smoke。占位 fallback 仅允许俄罗斯方块和明确收集类等已有真实语义模板,未知玩法失败关闭。纯继续意图未能恢复唯一原始目标时同样失败关闭,不输出以“继续”为标题的兜底产物。
- `assets/art-spec.png` 的唯一语义是视觉规范与派生参考,不是运行时背景、角色、目标或图集。game-chat 的核心玩家、方块/目标、障碍/场景和反馈必须来自独立派生的透明 `assets/art-spritesheet.png` 及其服务端 `iconImageSrcs` 本地切片;Runtime 以 `sourceResourceId` 把切片清单绑定到当前图集,并要求活动 Canvas 分别绘制四类不同切片。纯代码核心实体、猜测图集等分坐标、单个裁切冒充全部类别、整图展示、隐藏引用、微小水印和诱饵路径均不构成真实美术使用。`playable-web-game-state.v1.sequence` 只在真实输入、状态迁移或模拟状态变化时递增,不得由纯渲染帧推进。
- game-chat canonical 图集进一步要求主图与四个切片都有非空且互不复用的 Canvas `assetObjectId`;同一对象在顶层、resource 与 asset 中重复返回的 `assetObjectId``taskId` 必须分别一致,冲突时失败关闭。公开 `assets/art-spritesheet-slices/manifest.json` 与私有 `.agent/runtime/art-spritesheet-contract.json` 必须同时绑定 `sourceResourceId / sourceAssetObjectId / sourceTaskId / sourceCanvasProjectId / sourceReferenceResourceIds`,并对四种 usage 的 `name / path / width / height / resourceId / assetObjectId / contentSha256 / pixelSha256` 做完整一致性比较。旧项目缺私有回执时不得从公开文件反向生成回执;只允许同一 game-chat root 下处于 running 的 scheduled `art-asset-plan` 对固定主图执行受限 `replaceExisting=true` repair,普通 pending、其它 Agent、其它路径或有效合同均拒绝。九个固定合同文件在任何 canonical 改动前必须快照到 `.agent/runtime` 私有事务目录并写 `prepared` markerCanvas 资产登记成功并写 `committed` marker 后才可清理;恢复只在同一项目写锁内按 transaction id 整组回滚或幂等清理,远端下载阶段不得长期持锁,也不得在无锁 request 阶段修改 canonical 文件。
- game-chat canonical 图集进一步要求主图与四个切片都有非空且互不复用的 Canvas `assetObjectId`;同一对象在顶层、resource 与 asset 中重复返回的 `assetObjectId``taskId` 必须分别一致,冲突时失败关闭。公开 `assets/art-spritesheet-slices/manifest.json` 与私有 `.agent/runtime/art-spritesheet-contract.json` 必须同时绑定 `sourceResourceId / sourceAssetObjectId / sourceTaskId / sourceCanvasProjectId / sourceReferenceResourceIds`,并对四种 usage 的 `name / path / width / height / resourceId / assetObjectId / contentSha256 / pixelSha256` 做完整一致性比较。旧项目缺私有回执时不得从公开文件反向生成回执;只允许同一 game-chat root 下处于 running 的 scheduled `art-asset-plan` 对固定主图执行受限 `replaceExisting=true` repair,普通 pending、其它 Agent、其它路径或有效合同均拒绝。九个固定合同文件在任何 canonical 改动前必须快照到 `.agent/runtime` 私有事务目录并写 `prepared` markerCanvas 资产登记成功并写 `committed` marker 后才可清理;恢复只在同一项目写锁内先完整验证全部 journal 条目和快照、形成内存恢复计划,再按 transaction id 整组回滚或幂等清理,任一末尾快照损坏都不得先改写前面的 canonical 路径。远端下载阶段不得长期持锁,也不得在无锁 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。静态连续性检查忽略字符串、注释、`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,不能交付完成。
- 俄罗斯方块任务固定使用 `BrowserPlaytestScenario::TetrisV1``playable-web-game-state.v1.gameplay` 必须持续提供 `kind=tetris``activePieceId``rotation``row``lockedPieces``lineClearChecks``clearedLines``occupiedCells`;任何采样点删除字段都立即失败,但允许 `score / nextPieceId` 等额外 telemetry。静态连续性检查忽略字符串、注释、HTML raw-text/RCDATA 与其它非执行容器、带 `src` 脚本的内联正文、非 JavaScript script 和 `if(false)` / 明显恒假分支诱饵,以标识符边界绑定真实 `fall -> lock -> clear` 调用链;splice 消行必须作用于棋盘或可证明的常量别名。本地 `.js / .mjs` 入口、inline module import 与 module 传递依赖统一限制在 `game/`,按文件去重并受 256 文件、累计 2 MiB 上限约束。浏览器因果探针运行于 Chromium 隔离执行上下文,使用同一次 trusted 鼠标输入的 mouseup 尾部状态作为 click 前基线,前后原始状态只保存在 Promise 闭包中,因此受测页面不能通过全局变量改写证据,页面既有 capture-phase click 处理器也不会被误判;其余同方块旋转、重力/锁定、四格落盘或消行、`lineClearChecks` 和 restart 归零约束保持不变。旧/续跑合同及 game-chat 快车道在回执读取前按有效原任务迁移到该场景、重算 fingerprint 并回读一致,旧 generic-v1 回执视为 stale,不能交付完成。
- 泥点不足是确定性业务中断,不是瞬态 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 重放,禁止分别执行原响应和持久响应。