修复游戏验收竞态与静态门边界
图集事务使用跨平台可信有界双读并在恢复失败时逆序回滚 浏览器试玩在同一任务冻结输入尾部状态并绑定完整探针指纹 俄罗斯方块静态门按真实模块图和可达语义拒绝脚本诱饵 补齐事务、浏览器与俄罗斯方块回归测试及技术文档
This commit is contained in:
File diff suppressed because it is too large
Load Diff
+641
-127
File diff suppressed because it is too large
Load Diff
+360
-2
@@ -1516,6 +1516,53 @@ fn inherited_tetris_contract_scans_only_executable_html_scripts() {
|
||||
Some("board-state"),
|
||||
"the inline body of a sourced script is ignored by the browser",
|
||||
);
|
||||
|
||||
let nomodule = valid.replacen("<script>\nlet board", "<script nomodule>\nlet board", 1);
|
||||
assert_eq!(
|
||||
inherited_gameplay_semantics_gap(task, nomodule.as_bytes()).as_deref(),
|
||||
Some("board-state"),
|
||||
"Chromium must not count nomodule classic script content",
|
||||
);
|
||||
|
||||
let form_feed_src = valid.replacen(
|
||||
"<script>\nlet board",
|
||||
"<script\u{000c}src=\"./missing.js\">\nlet board",
|
||||
1,
|
||||
);
|
||||
assert_eq!(
|
||||
inherited_gameplay_semantics_gap(task, form_feed_src.as_bytes()).as_deref(),
|
||||
Some("board-state"),
|
||||
"HTML form-feed must delimit the src attribute and suppress inline content",
|
||||
);
|
||||
|
||||
let form_feed_type = valid.replacen(
|
||||
"<script>\nlet board",
|
||||
"<script\u{000c}type=\"text/plain\">\nlet board",
|
||||
1,
|
||||
);
|
||||
assert_eq!(
|
||||
inherited_gameplay_semantics_gap(task, form_feed_type.as_bytes()).as_deref(),
|
||||
Some("board-state"),
|
||||
"HTML form-feed must delimit a non-executable type attribute",
|
||||
);
|
||||
|
||||
let vertical_tab_tag = valid.replacen("<script>\nlet board", "<script\u{000b}>\nlet board", 1);
|
||||
assert_eq!(
|
||||
inherited_gameplay_semantics_gap(task, vertical_tab_tag.as_bytes()).as_deref(),
|
||||
Some("board-state"),
|
||||
"ASCII vertical tab is not HTML whitespace and must not delimit a script tag",
|
||||
);
|
||||
|
||||
let fake_script_close = valid.replacen(
|
||||
"<script>\nlet board",
|
||||
"<script type=\"text/plain\">bait</script-decoy><script>\nlet board",
|
||||
1,
|
||||
);
|
||||
assert_eq!(
|
||||
inherited_gameplay_semantics_gap(task, fake_script_close.as_bytes()).as_deref(),
|
||||
Some("board-state"),
|
||||
"a non-boundary script close marker must not expose raw-text content as executable",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1553,6 +1600,16 @@ fn inherited_tetris_contract_binds_fall_clear_and_rotation_semantics() {
|
||||
"a double-negated full-row predicate keeps full rows and must not count as clearing",
|
||||
);
|
||||
|
||||
let always_true_filter = valid.replace(
|
||||
"board=board.filter((row)=>!row.every(Boolean));",
|
||||
"board=board.filter((row)=>!row.every(Boolean)||true);",
|
||||
);
|
||||
assert_eq!(
|
||||
inherited_gameplay_semantics_gap(task, always_true_filter.as_bytes()).as_deref(),
|
||||
Some("line-clear"),
|
||||
"a full-row check inside an always-true filter 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;",
|
||||
@@ -1585,7 +1642,7 @@ fn inherited_tetris_contract_binds_fall_clear_and_rotation_semantics() {
|
||||
|
||||
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));}",
|
||||
"function clearLines(){lineClearChecks+=1;const grid=board;if(grid[4].every(Boolean)){grid.splice(4,1);grid.unshift(Array(10).fill(0));}}",
|
||||
);
|
||||
assert_eq!(
|
||||
inherited_gameplay_semantics_gap(task, board_alias_splice.as_bytes()),
|
||||
@@ -1593,15 +1650,116 @@ fn inherited_tetris_contract_binds_fall_clear_and_rotation_semantics() {
|
||||
"a non-reassigned const alias of board may perform the splice clear",
|
||||
);
|
||||
|
||||
let unguarded_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;if(true||grid[4].every(Boolean)){grid.splice(4,1);grid.unshift(Array(10).fill(0));}}",
|
||||
);
|
||||
assert_eq!(
|
||||
inherited_gameplay_semantics_gap(task, unguarded_board_alias_splice.as_bytes()).as_deref(),
|
||||
Some("line-clear"),
|
||||
"an always-true condition must not make a splice dependent on a full row",
|
||||
);
|
||||
|
||||
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));}",
|
||||
"function clearLines(){lineClearChecks+=1;let grid=board;grid=[];if(grid[4].every(Boolean)){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",
|
||||
);
|
||||
|
||||
let shadowed_board = 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(){let board=Array.from({length:20},()=>Array(10).fill(1));board=board.filter((row)=>!row.every(Boolean));}",
|
||||
);
|
||||
assert_eq!(
|
||||
inherited_gameplay_semantics_gap(task, shadowed_board.as_bytes()).as_deref(),
|
||||
Some("line-clear"),
|
||||
"a function-local board binding must not prove mutation of the gameplay board",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inherited_tetris_contract_rejects_cross_bound_and_dead_semantic_decoys() {
|
||||
let task = "做一个俄罗斯方块,完成旋转、重力下落、锁定和消行";
|
||||
let valid = executable_tetris_game_html();
|
||||
|
||||
let cases = [
|
||||
(
|
||||
"board-state",
|
||||
valid
|
||||
.replace("Array.from({length:20},()=>Array(10).fill(0))", "[]")
|
||||
.replacen(
|
||||
"let board=[];",
|
||||
"let board=[];let preview=Array.from({length:20},()=>Array(10).fill(0));",
|
||||
1,
|
||||
),
|
||||
"an unrelated Array initializer must not establish board state",
|
||||
),
|
||||
(
|
||||
"piece-rotation",
|
||||
valid.replace(
|
||||
"function rotatePiece(){const rotated=current.shape.map((row)=>row.slice()).reverse();current.shape=rotated;current.rotation=(current.rotation+1)%4;sequence+=1;publish();}",
|
||||
"function rotatePiece(){current.rotation=(preview.rotation+1)%4;sequence+=1;publish();}",
|
||||
),
|
||||
"a different object's quarter turn must not rotate the active piece",
|
||||
),
|
||||
(
|
||||
"piece-rotation",
|
||||
valid.replace(
|
||||
"function rotatePiece(){const rotated=current.shape.map((row)=>row.slice()).reverse();current.shape=rotated;current.rotation=(current.rotation+1)%4;sequence+=1;publish();}",
|
||||
"function rotatePiece(){current.shape=rotated;rotated=current.shape.map((row)=>row.slice()).reverse();sequence+=1;publish();}",
|
||||
),
|
||||
"a candidate computed only after assignment must not prove rotation",
|
||||
),
|
||||
(
|
||||
"piece-lock",
|
||||
valid.replace(
|
||||
"function lockPiece(){current.shape.forEach((row,rowIndex)=>row.forEach((cell,columnIndex)=>{if(cell)board[current.y+rowIndex][current.x+columnIndex]=cell;}));lockedPieces+=1;clearLines();current={id:'piece-'+(lockedPieces+1),shape:[[1,1],[1,1]],rotation:0,y:0,x:4};sequence+=1;publish();}",
|
||||
"function lockPiece(){current.shape.forEach(()=>board[0][0]=1);lockedPieces+=1;clearLines();sequence+=1;publish();}",
|
||||
),
|
||||
"a constant board write inside an active-piece traversal must not prove locking",
|
||||
),
|
||||
(
|
||||
"piece-lock",
|
||||
valid.replace("lockedPieces+=1;clearLines();", "lockedPieces+=1;false&&clearLines();"),
|
||||
"a short-circuited line-clear call must not bind lock to clear",
|
||||
),
|
||||
(
|
||||
"line-clear",
|
||||
valid.replace(
|
||||
"board=board.filter((row)=>!row.every(Boolean));",
|
||||
"board=board.filter((row)=>!preview.every(Boolean));",
|
||||
),
|
||||
"the filter predicate must inspect its own board row",
|
||||
),
|
||||
(
|
||||
"line-clear",
|
||||
valid.replace(
|
||||
"board=board.filter((row)=>!row.every(Boolean));",
|
||||
"board=board.filter((row)=>!row.every(()=>true));",
|
||||
),
|
||||
"an always-true cell callback must not prove a full-row test",
|
||||
),
|
||||
(
|
||||
"line-clear",
|
||||
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;board.splice(4,1);board.unshift(Array(10).fill(0));}",
|
||||
),
|
||||
"an unconditional row replacement must not prove a full-row clear",
|
||||
),
|
||||
];
|
||||
|
||||
for (expected_gap, html, message) in cases {
|
||||
assert_eq!(
|
||||
inherited_gameplay_semantics_gap(task, html.as_bytes()).as_deref(),
|
||||
Some(expected_gap),
|
||||
"{message}",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1730,6 +1888,10 @@ fn inherited_tetris_contract_recursively_reads_local_module_dependencies() {
|
||||
r#"const stringBait = "import './missing-string.js'";
|
||||
// import './missing-comment.js';
|
||||
const regexBait = /x import '.\/missing-regex.js'/;
|
||||
if (true) /x import '.\/missing-control-regex.js'/.test('safe');
|
||||
if (true) {} /x import '.\/missing-control-block-regex.js'/.test('safe');
|
||||
const metadata = {export: 0, from: './missing-object-property.js'};
|
||||
const importMetadata = {import: 0, from: './missing-import-property.js'};
|
||||
import './gameplay/tetris.mjs';"#,
|
||||
)
|
||||
.expect("write module entry");
|
||||
@@ -1790,6 +1952,202 @@ import './gameplay/tetris.mjs';"#,
|
||||
None,
|
||||
"an inline module's local dependency graph must contribute Tetris semantics",
|
||||
);
|
||||
|
||||
let inline_classic_html = inline_module_html.replace(" type=\"module\"", "");
|
||||
let inline_classic = read_external_gameplay_javascript_at(root, &inline_classic_html)
|
||||
.expect("ignore an invalid static import in a classic inline script");
|
||||
assert_eq!(
|
||||
inherited_gameplay_semantics_gap_with_external_javascript(
|
||||
task,
|
||||
inline_classic_html.as_bytes(),
|
||||
&inline_classic,
|
||||
)
|
||||
.as_deref(),
|
||||
Some("board-state"),
|
||||
"a classic inline script must not gain semantics from an invalid static import",
|
||||
);
|
||||
|
||||
let computed_dynamic_import_html = inline_classic_html.replace(
|
||||
"import './main.js';",
|
||||
"const suffix=''; import('./main.js' + suffix);",
|
||||
);
|
||||
let computed_dynamic_import =
|
||||
read_external_gameplay_javascript_at(root, &computed_dynamic_import_html)
|
||||
.expect("a computed dynamic import is not a statically fixed local dependency");
|
||||
assert_eq!(
|
||||
inherited_gameplay_semantics_gap_with_external_javascript(
|
||||
task,
|
||||
computed_dynamic_import_html.as_bytes(),
|
||||
&computed_dynamic_import,
|
||||
)
|
||||
.as_deref(),
|
||||
Some("board-state"),
|
||||
"a literal prefix of a computed import must not load a static-analysis decoy",
|
||||
);
|
||||
|
||||
let false_dynamic_import_html = inline_classic_html.replace(
|
||||
"import './main.js';",
|
||||
"if (false) import('./gameplay/tetris.mjs');",
|
||||
);
|
||||
let false_dynamic = read_external_gameplay_javascript_at(root, &false_dynamic_import_html)
|
||||
.expect("a literal-false dynamic import is ignored");
|
||||
assert_eq!(
|
||||
inherited_gameplay_semantics_gap_with_external_javascript(
|
||||
task,
|
||||
false_dynamic_import_html.as_bytes(),
|
||||
&false_dynamic,
|
||||
)
|
||||
.as_deref(),
|
||||
Some("board-state"),
|
||||
"a literal-false dynamic import must not contribute static semantics",
|
||||
);
|
||||
|
||||
let short_circuit_dynamic_import_html = inline_classic_html.replace(
|
||||
"import './main.js';",
|
||||
"false && import('./gameplay/tetris.mjs');",
|
||||
);
|
||||
let short_circuit_dynamic =
|
||||
read_external_gameplay_javascript_at(root, &short_circuit_dynamic_import_html)
|
||||
.expect("a short-circuited dynamic import is ignored");
|
||||
assert_eq!(
|
||||
inherited_gameplay_semantics_gap_with_external_javascript(
|
||||
task,
|
||||
short_circuit_dynamic_import_html.as_bytes(),
|
||||
&short_circuit_dynamic,
|
||||
)
|
||||
.as_deref(),
|
||||
Some("board-state"),
|
||||
"a short-circuited dynamic import must not contribute static semantics",
|
||||
);
|
||||
|
||||
let uncalled_dynamic_import_html = inline_classic_html.replace(
|
||||
"import './main.js';",
|
||||
"function loadGameplay(){return import('./gameplay/tetris.mjs');}",
|
||||
);
|
||||
let uncalled_dynamic =
|
||||
read_external_gameplay_javascript_at(root, &uncalled_dynamic_import_html)
|
||||
.expect("a dynamic import in an uncalled function is ignored");
|
||||
assert_eq!(
|
||||
inherited_gameplay_semantics_gap_with_external_javascript(
|
||||
task,
|
||||
uncalled_dynamic_import_html.as_bytes(),
|
||||
&uncalled_dynamic,
|
||||
)
|
||||
.as_deref(),
|
||||
Some("board-state"),
|
||||
"an uncalled function's dynamic import must not contribute static semantics",
|
||||
);
|
||||
|
||||
let called_dynamic_import_html = inline_classic_html.replace(
|
||||
"import './main.js';",
|
||||
"function loadGameplay(){return import('./gameplay/tetris.mjs');} loadGameplay();",
|
||||
);
|
||||
let called_dynamic = read_external_gameplay_javascript_at(root, &called_dynamic_import_html)
|
||||
.expect("follow a dynamic import in a called function");
|
||||
assert_eq!(
|
||||
inherited_gameplay_semantics_gap_with_external_javascript(
|
||||
task,
|
||||
called_dynamic_import_html.as_bytes(),
|
||||
&called_dynamic,
|
||||
),
|
||||
None,
|
||||
"a called function's fixed dynamic import must remain reachable",
|
||||
);
|
||||
|
||||
fs::write(
|
||||
root.join("game/bootstrap.js"),
|
||||
"import('./gameplay/tetris.mjs');",
|
||||
)
|
||||
.expect("write classic dynamic bootstrap");
|
||||
let classic_dynamic_html = external_html.replace(
|
||||
"type=\"module\" src=\"./main.js\"",
|
||||
"src=\"./bootstrap.js\"",
|
||||
);
|
||||
let classic_dynamic = read_external_gameplay_javascript_at(root, &classic_dynamic_html)
|
||||
.expect("follow a fixed dynamic import from a classic script");
|
||||
assert_eq!(
|
||||
inherited_gameplay_semantics_gap_with_external_javascript(
|
||||
task,
|
||||
classic_dynamic_html.as_bytes(),
|
||||
&classic_dynamic,
|
||||
),
|
||||
None,
|
||||
"a fixed reachable dynamic import from a classic script must be analyzed",
|
||||
);
|
||||
|
||||
let based_html = external_html.replacen(
|
||||
"<script type=\"module\"",
|
||||
"<base href=\"./runtime/\"><script type=\"module\"",
|
||||
1,
|
||||
);
|
||||
let base_error = read_external_gameplay_javascript_at(root, &based_html)
|
||||
.expect_err("an HTML base URL must not redirect static analysis to another file");
|
||||
assert!(
|
||||
base_error.contains("base URL"),
|
||||
"unexpected error: {base_error}"
|
||||
);
|
||||
|
||||
let nomodule_html = external_html.replace(
|
||||
"<script type=\"module\" src=\"./main.js\"></script>",
|
||||
"<script nomodule src=\"./gameplay/tetris.mjs\"></script>",
|
||||
);
|
||||
let nomodule_external = read_external_gameplay_javascript_at(root, &nomodule_html)
|
||||
.expect("ignore a nomodule external script in Chromium");
|
||||
assert_eq!(
|
||||
inherited_gameplay_semantics_gap_with_external_javascript(
|
||||
task,
|
||||
nomodule_html.as_bytes(),
|
||||
&nomodule_external,
|
||||
)
|
||||
.as_deref(),
|
||||
Some("board-state"),
|
||||
"a nomodule external script must not contribute Tetris semantics",
|
||||
);
|
||||
|
||||
let gameplay = &valid[body_start..script_end];
|
||||
let split_at = gameplay
|
||||
.find("function lockPiece")
|
||||
.expect("fixture contains a lock function split point");
|
||||
fs::write(root.join("game/scope-a.mjs"), &gameplay[..split_at])
|
||||
.expect("write first isolated module");
|
||||
fs::write(root.join("game/scope-b.mjs"), &gameplay[split_at..])
|
||||
.expect("write second isolated module");
|
||||
let split_module_html = external_html.replace(
|
||||
"<script type=\"module\" src=\"./main.js\"></script>",
|
||||
"<script type=\"module\" src=\"./scope-a.mjs\"></script><script type=\"module\" src=\"./scope-b.mjs\"></script>",
|
||||
);
|
||||
let split_modules = read_external_gameplay_javascript_at(root, &split_module_html)
|
||||
.expect("read two isolated modules");
|
||||
assert!(
|
||||
inherited_gameplay_semantics_gap_with_external_javascript(
|
||||
task,
|
||||
split_module_html.as_bytes(),
|
||||
&split_modules,
|
||||
)
|
||||
.is_some(),
|
||||
"unimported bindings from separate modules must not be merged into one semantic chain",
|
||||
);
|
||||
|
||||
fs::write(
|
||||
root.join("game/scope-a.mjs"),
|
||||
format!("{}\nimport './scope-b.mjs';", &gameplay[..split_at]),
|
||||
)
|
||||
.expect("connect the split gameplay modules");
|
||||
let connected_module_html = external_html.replace(
|
||||
"<script type=\"module\" src=\"./main.js\"></script>",
|
||||
"<script type=\"module\" src=\"./scope-a.mjs\"></script>",
|
||||
);
|
||||
let connected_modules = read_external_gameplay_javascript_at(root, &connected_module_html)
|
||||
.expect("read one connected module graph");
|
||||
assert_eq!(
|
||||
inherited_gameplay_semantics_gap_with_external_javascript(
|
||||
task,
|
||||
connected_module_html.as_bytes(),
|
||||
&connected_modules,
|
||||
),
|
||||
None,
|
||||
"modules connected by a real import edge must form one semantic unit",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -24,12 +24,12 @@ pub(in crate::browser) const GENERIC_PLAYTEST_START_OPPORTUNITY_MIN_STABILITY_SA
|
||||
pub(in crate::browser) const GENERIC_PLAYTEST_POST_ACTION_MIN_STABILITY_SAMPLES: usize = 12;
|
||||
pub(in crate::browser) const GENERIC_PLAYTEST_RESTART_MIN_STABILITY_SAMPLES: usize = 12;
|
||||
pub(in crate::browser) const GENERIC_PLAYTEST_ACTION_CAUSALITY_FINGERPRINT: &str = concat!(
|
||||
"primary-action=trusted-event-isolated-world-promise-closure-mouseup-tail-baseline-to-click-bubble-sequence-advance\n",
|
||||
"primary-action=trusted-event-isolated-world-promise-closure-pre-input-mutation-baseline-to-click-dispatch-tail-sequence-advance\n",
|
||||
"tetris-primary-action=same-piece-rotation-change\n",
|
||||
"tetris-start-opportunity=same-piece-gravity-row-or-semantic-lock-progress\n",
|
||||
"tetris-post-action=probe-before-gameplay-to-new-piece-lock-board-and-line-check-progress\n",
|
||||
"tetris-restart=board-counters-reset\n",
|
||||
"restart=trusted-event-isolated-world-promise-closure-mouseup-tail-baseline-to-click-bubble-sequence-advance"
|
||||
"restart=trusted-event-isolated-world-promise-closure-pre-input-mutation-baseline-to-click-dispatch-tail-sequence-advance"
|
||||
);
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
@@ -61,57 +61,110 @@ fn generic_action_sequence_probe_script(selector: &str, ready_key: &str) -> Resu
|
||||
r#"(() => new Promise((resolve) => {{
|
||||
const readyKey = {ready_key};
|
||||
const controls = document.querySelectorAll({selector});
|
||||
let settled = false;
|
||||
let timeoutId = null;
|
||||
const finish = (value) => {{
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
if (timeoutId !== null) clearTimeout(timeoutId);
|
||||
try {{ delete globalThis[readyKey]; }} catch (_) {{}}
|
||||
resolve(value);
|
||||
}};
|
||||
if (controls.length !== 1 || !(controls[0] instanceof HTMLElement)) {{
|
||||
finish({{ status: 'invalid-control', beforeState: null, afterState: null }});
|
||||
globalThis[readyKey] = 'invalid-control';
|
||||
resolve({{ status: 'invalid-control', beforeState: null, afterState: null }});
|
||||
return;
|
||||
}}
|
||||
const control = controls[0];
|
||||
let settled = false;
|
||||
let timeoutId = null;
|
||||
let stateObserver = null;
|
||||
const inputEventTypes = ['pointerdown', 'mousedown', 'pointerup', 'mouseup', 'click'];
|
||||
const dispatchTailTargets = [];
|
||||
const cleanup = () => {{
|
||||
if (timeoutId !== null) clearTimeout(timeoutId);
|
||||
if (stateObserver !== null) stateObserver.disconnect();
|
||||
for (const type of inputEventTypes) {{
|
||||
window.removeEventListener(type, observeInput, true);
|
||||
}}
|
||||
for (const target of dispatchTailTargets) {{
|
||||
target.removeEventListener('click', observeClickDispatchTail, false);
|
||||
}}
|
||||
try {{ delete globalThis[readyKey]; }} catch (_) {{}}
|
||||
}};
|
||||
const finish = (value) => {{
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
cleanup();
|
||||
resolve(value);
|
||||
}};
|
||||
const readState = () => {{
|
||||
const surface = document.querySelectorAll('script#playable-web-game-state');
|
||||
if (surface.length !== 1 || surface[0].getAttribute('type') !== 'application/json') return null;
|
||||
return String(surface[0].textContent || '');
|
||||
const content = String(surface[0].textContent || '');
|
||||
return content.length <= 131072 ? content : null;
|
||||
}};
|
||||
let beforeState = null;
|
||||
let beforeState = readState();
|
||||
let baselineCaptured = false;
|
||||
const mouseup = (event) => {{
|
||||
let inputEventObserved = false;
|
||||
const targetsControl = (event) => event.target === control
|
||||
|| (event.target instanceof Node && control.contains(event.target));
|
||||
function observeInput(event) {{
|
||||
if (!event.isTrusted) return;
|
||||
if (event.button !== 0) return;
|
||||
if (event.target !== control && !(event.target instanceof Node && control.contains(event.target))) return;
|
||||
beforeState = readState();
|
||||
baselineCaptured = true;
|
||||
}};
|
||||
const bubble = (event) => {{
|
||||
if (!event.isTrusted) return;
|
||||
if (event.target !== control && !(event.target instanceof Node && control.contains(event.target))) return;
|
||||
window.removeEventListener('mouseup', mouseup, false);
|
||||
document.removeEventListener('click', bubble, false);
|
||||
if ('button' in event && event.button !== 0) return;
|
||||
if (!targetsControl(event)) return;
|
||||
inputEventObserved = true;
|
||||
if (!baselineCaptured) {{
|
||||
baselineCaptured = true;
|
||||
}}
|
||||
}}
|
||||
function observeClickDispatchTail(event) {{
|
||||
if (!event.isTrusted || !targetsControl(event)) return;
|
||||
if (event.currentTarget !== window && !event.cancelBubble) return;
|
||||
finish({{
|
||||
status: baselineCaptured ? 'completed' : 'missing-mouseup-baseline',
|
||||
status: baselineCaptured && inputEventObserved ? 'completed' : 'missing-input-baseline',
|
||||
beforeState,
|
||||
afterState: baselineCaptured ? readState() : null,
|
||||
afterState: baselineCaptured && inputEventObserved ? readState() : null,
|
||||
}});
|
||||
}};
|
||||
window.addEventListener('mouseup', mouseup, {{ capture: false }});
|
||||
document.addEventListener('click', bubble, {{ capture: false }});
|
||||
}}
|
||||
stateObserver = new MutationObserver(() => {{
|
||||
const activeEvent = globalThis.event;
|
||||
if (activeEvent instanceof Event
|
||||
&& activeEvent.isTrusted
|
||||
&& inputEventTypes.includes(activeEvent.type)
|
||||
&& targetsControl(activeEvent)) {{
|
||||
inputEventObserved = true;
|
||||
if (!baselineCaptured) baselineCaptured = true;
|
||||
return;
|
||||
}}
|
||||
if (!baselineCaptured) beforeState = readState();
|
||||
}});
|
||||
stateObserver.observe(document, {{ subtree: true, childList: true, characterData: true }});
|
||||
for (const type of inputEventTypes) {{
|
||||
window.addEventListener(type, observeInput, {{ capture: true }});
|
||||
}}
|
||||
for (let target = control; target; target = target.parentNode) {{
|
||||
dispatchTailTargets.push(target);
|
||||
target.addEventListener('click', observeClickDispatchTail, false);
|
||||
}}
|
||||
dispatchTailTargets.push(window);
|
||||
window.addEventListener('click', observeClickDispatchTail, false);
|
||||
globalThis[readyKey] = 'armed';
|
||||
timeoutId = setTimeout(() => {{
|
||||
window.removeEventListener('mouseup', mouseup, false);
|
||||
document.removeEventListener('click', bubble, false);
|
||||
finish({{ status: 'timeout', beforeState, afterState: null }});
|
||||
}}, 5000);
|
||||
}}))()"#
|
||||
))
|
||||
}
|
||||
|
||||
fn generic_action_sequence_probe_ready_script(ready_key: &str) -> Result<String, String> {
|
||||
let ready_key = serde_json::to_string(ready_key)
|
||||
.map_err(|_| "固定试玩动作因果探针 ready key 无法编码".to_string())?;
|
||||
Ok(format!(
|
||||
"(() => {{ const status = globalThis[{ready_key}] || null; if (status === 'invalid-control') delete globalThis[{ready_key}]; return status; }})()"
|
||||
))
|
||||
}
|
||||
|
||||
pub(in crate::browser) fn generic_action_sequence_probe_fingerprint_material() -> String {
|
||||
let ready_key = "__genarrativeActionProbeFingerprintReady";
|
||||
let install = generic_action_sequence_probe_script(PLAYTEST_PRIMARY_ACTION_SELECTOR, ready_key)
|
||||
.expect("fixed generic action probe fingerprint inputs must serialize");
|
||||
let ready = generic_action_sequence_probe_ready_script(ready_key)
|
||||
.expect("fixed generic action probe ready fingerprint inputs must serialize");
|
||||
format!("install-and-finish-script:\n{install}\nready-script:\n{ready}")
|
||||
}
|
||||
|
||||
fn validate_generic_action_sequence_probe(
|
||||
action: &str,
|
||||
probe: &GenericActionSequenceProbe,
|
||||
@@ -195,14 +248,13 @@ async fn click_with_generic_action_sequence_probe(
|
||||
.map_err(|_| format!("固定试玩动作 {action} 因果证据无效"))
|
||||
};
|
||||
let click_when_ready = async {
|
||||
let ready_key = serde_json::to_string(&ready_key)
|
||||
.map_err(|_| "固定试玩动作因果探针 ready key 无法编码".to_string())?;
|
||||
let ready_script = generic_action_sequence_probe_ready_script(&ready_key)?;
|
||||
loop {
|
||||
let remaining = deadline
|
||||
.checked_duration_since(Instant::now())
|
||||
.ok_or_else(|| format!("固定试玩动作 {action} 因果探针安装超时"))?;
|
||||
let params = EvaluateParams::builder()
|
||||
.expression(format!("globalThis[{ready_key}] === 'armed'"))
|
||||
.expression(ready_script.clone())
|
||||
.context_id(context_id.clone())
|
||||
.return_by_value(true)
|
||||
.await_promise(false)
|
||||
@@ -212,8 +264,16 @@ async fn click_with_generic_action_sequence_probe(
|
||||
.await
|
||||
.map_err(|_| format!("固定试玩动作 {action} 因果探针安装超时"))?
|
||||
.map_err(|_| format!("固定试玩动作 {action} 因果探针安装失败"))?;
|
||||
if evaluated.into_value::<bool>().unwrap_or(false) {
|
||||
break;
|
||||
match evaluated
|
||||
.into_value::<Option<String>>()
|
||||
.unwrap_or(None)
|
||||
.as_deref()
|
||||
{
|
||||
Some("armed") => break,
|
||||
Some("invalid-control") => {
|
||||
return Err(format!("固定试玩控件 {action} 不存在或不唯一"));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
@@ -1081,10 +1141,21 @@ mod tests {
|
||||
.expect("render action probe");
|
||||
assert!(script.contains("new Promise"));
|
||||
assert!(script.contains("if (!event.isTrusted) return"));
|
||||
assert!(script.contains("let beforeState = null"));
|
||||
assert!(script.contains("window.addEventListener('mouseup', mouseup"));
|
||||
assert!(script.contains("status: baselineCaptured ? 'completed'"));
|
||||
assert!(!script.contains("control.addEventListener('click'"));
|
||||
assert!(script.contains("let beforeState = readState()"));
|
||||
assert!(script.contains("new MutationObserver"));
|
||||
assert!(script.contains("const activeEvent = globalThis.event"));
|
||||
assert!(script.contains("window.addEventListener(type, observeInput"));
|
||||
assert!(
|
||||
script.contains("target.addEventListener('click', observeClickDispatchTail, false)")
|
||||
);
|
||||
assert!(
|
||||
script.contains("window.addEventListener('click', observeClickDispatchTail, false)")
|
||||
);
|
||||
assert!(script.contains("event.currentTarget !== window && !event.cancelBubble"));
|
||||
assert!(script.contains("afterState: baselineCaptured && inputEventObserved ? readState()"));
|
||||
assert!(script.contains("content.length <= 131072"));
|
||||
assert!(script.contains("status: baselineCaptured && inputEventObserved ? 'completed'"));
|
||||
assert!(!script.contains("finishKey"));
|
||||
assert!(script.contains("resolve(value)"));
|
||||
assert!(!script.contains("__genarrativeGenericActionSequenceProbe"));
|
||||
assert!(!script.contains("beforeGameplay"));
|
||||
|
||||
@@ -14,12 +14,13 @@ mod generic;
|
||||
mod lane_defense;
|
||||
|
||||
pub(super) use generic::{
|
||||
finish_generic_stability_observation, generic_non_loss_progression_phase_is_valid,
|
||||
generic_primary_action_phase_is_valid, generic_restart_phase_is_valid,
|
||||
generic_start_phase_is_valid, validate_generic_stability_sample,
|
||||
GENERIC_PLAYTEST_ACTION_CAUSALITY_FINGERPRINT, GENERIC_PLAYTEST_MAX_FINAL_SAMPLE_GAP,
|
||||
GENERIC_PLAYTEST_POST_ACTION_MIN_STABILITY_SAMPLES, GENERIC_PLAYTEST_POST_ACTION_WINDOW,
|
||||
GENERIC_PLAYTEST_RESTART_MIN_STABILITY_SAMPLES, GENERIC_PLAYTEST_RESTART_STABILITY_WINDOW,
|
||||
finish_generic_stability_observation, generic_action_sequence_probe_fingerprint_material,
|
||||
generic_non_loss_progression_phase_is_valid, generic_primary_action_phase_is_valid,
|
||||
generic_restart_phase_is_valid, generic_start_phase_is_valid,
|
||||
validate_generic_stability_sample, GENERIC_PLAYTEST_ACTION_CAUSALITY_FINGERPRINT,
|
||||
GENERIC_PLAYTEST_MAX_FINAL_SAMPLE_GAP, GENERIC_PLAYTEST_POST_ACTION_MIN_STABILITY_SAMPLES,
|
||||
GENERIC_PLAYTEST_POST_ACTION_WINDOW, GENERIC_PLAYTEST_RESTART_MIN_STABILITY_SAMPLES,
|
||||
GENERIC_PLAYTEST_RESTART_STABILITY_WINDOW,
|
||||
GENERIC_PLAYTEST_START_OPPORTUNITY_MIN_STABILITY_SAMPLES,
|
||||
GENERIC_PLAYTEST_START_OPPORTUNITY_WINDOW,
|
||||
};
|
||||
@@ -304,6 +305,10 @@ pub(crate) fn browser_playtest_scenario_fingerprint(scenario: BrowserPlaytestSce
|
||||
&mut hasher,
|
||||
GENERIC_PLAYTEST_ACTION_CAUSALITY_FINGERPRINT,
|
||||
);
|
||||
update_playtest_fingerprint_component(
|
||||
&mut hasher,
|
||||
&generic_action_sequence_probe_fingerprint_material(),
|
||||
);
|
||||
update_playtest_fingerprint_component(&mut hasher, PLAYTEST_START_SELECTOR);
|
||||
update_playtest_fingerprint_component(&mut hasher, PLAYTEST_PRIMARY_ACTION_SELECTOR);
|
||||
update_playtest_fingerprint_component(&mut hasher, PLAYTEST_RESTART_SELECTOR);
|
||||
|
||||
@@ -260,11 +260,11 @@ fn playtest_scenario_fingerprints_are_fixed_lowercase_sha256_values() {
|
||||
let lane = browser_playtest_scenario_fingerprint(BrowserPlaytestScenario::LaneDefenseV1);
|
||||
assert_eq!(
|
||||
generic,
|
||||
"6d6ce6843200da907427ef67d0bf0644b6499f76c221ab695b417309edf0fd33"
|
||||
"adc95709431dc762d3293a580133a1a4dc671ced3e6d0db75d214bb1e7c73964"
|
||||
);
|
||||
assert_eq!(
|
||||
tetris,
|
||||
"742545caae05bb6a673d91a55b23055570358843bff5cbe0acb3e86958786c79"
|
||||
"92e6eae0b8af8eca2baf0ddfc6d5084a1145e7be18f6f15e944289d4d9df98a6"
|
||||
);
|
||||
assert_eq!(
|
||||
lane,
|
||||
@@ -1265,7 +1265,7 @@ async fn real_chrome_generic_playtest_rejects_one_frame_playing_state() {
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "requires an installed Chrome/Chromium/Edge and explicit local browser execution"]
|
||||
async fn real_chrome_generic_playtest_accepts_capture_phase_action_flow() {
|
||||
async fn real_chrome_generic_playtest_accepts_early_mouse_and_late_click_action_flow() {
|
||||
use std::io::{Read, Write};
|
||||
use std::net::TcpListener;
|
||||
use std::sync::mpsc;
|
||||
@@ -1309,19 +1309,29 @@ async fn real_chrome_generic_playtest_accepts_capture_phase_action_flow() {
|
||||
});
|
||||
const primary = document.querySelector('[data-playtest-id="primary-action"]');
|
||||
const restart = document.querySelector('[data-playtest-id="restart"]');
|
||||
document.addEventListener('click', (event) => {
|
||||
let restartReachedDocumentBubble = false;
|
||||
window.addEventListener('pointerdown', (event) => {
|
||||
if (event.isTrusted && event.target === primary && state.phase === 'playing') {
|
||||
advance(() => { state.score += 1; });
|
||||
}
|
||||
}, true);
|
||||
primary.addEventListener('click', (event) => {
|
||||
if (event.isTrusted) event.stopPropagation();
|
||||
});
|
||||
document.addEventListener('click', (event) => {
|
||||
if (event.isTrusted && event.target === restart) {
|
||||
restartReachedDocumentBubble = true;
|
||||
}
|
||||
});
|
||||
window.addEventListener('click', (event) => {
|
||||
if (event.isTrusted && event.target === restart && restartReachedDocumentBubble) {
|
||||
advance(() => {
|
||||
state.phase = 'ready';
|
||||
state.score = 0;
|
||||
});
|
||||
restartReachedDocumentBubble = false;
|
||||
}
|
||||
}, true);
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
@@ -1376,6 +1386,117 @@ async fn real_chrome_generic_playtest_accepts_capture_phase_action_flow() {
|
||||
assert!(playtest.assertions.iter().all(|assertion| assertion.passed));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "requires an installed Chrome/Chromium/Edge and explicit local browser execution"]
|
||||
async fn real_chrome_generic_playtest_rejects_timer_deferred_action_progress() {
|
||||
use std::io::{Read, Write};
|
||||
use std::net::TcpListener;
|
||||
use std::sync::mpsc;
|
||||
use std::thread;
|
||||
|
||||
discover_chrome_or_edge().expect("Chrome, Chromium, or Edge must be installed");
|
||||
let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).expect("bind preview");
|
||||
let port = listener.local_addr().expect("preview address").port();
|
||||
listener.set_nonblocking(true).expect("nonblocking preview");
|
||||
let html = br#"<!doctype html>
|
||||
<html>
|
||||
<head><meta charset="utf-8"><link rel="icon" href="data:"><title>Deferred Generic Browser Fixture</title></head>
|
||||
<body>
|
||||
<main>Deferred generic fixture</main>
|
||||
<canvas id="stage" width="320" height="180" style="width:320px;max-width:100%;height:auto"></canvas>
|
||||
<button type="button" data-playtest-id="start">Start</button>
|
||||
<button type="button" data-playtest-id="primary-action">Collect later</button>
|
||||
<button type="button" data-playtest-id="restart">Restart</button>
|
||||
<script id="playable-web-game-state" type="application/json">{"schemaVersion":"playable-web-game-state.v1","sequence":0,"phase":"ready","level":1,"score":0}</script>
|
||||
<script>
|
||||
(() => {
|
||||
'use strict';
|
||||
const stateNode = document.querySelector('#playable-web-game-state');
|
||||
const state = JSON.parse(stateNode.textContent);
|
||||
const context = document.querySelector('#stage').getContext('2d');
|
||||
context.fillStyle = '#0f172a';
|
||||
context.fillRect(0, 0, 320, 180);
|
||||
context.fillStyle = '#a855f7';
|
||||
context.fillRect(48, 48, 96, 64);
|
||||
|
||||
const publish = () => { stateNode.textContent = JSON.stringify(state); };
|
||||
const advance = (mutation) => {
|
||||
mutation();
|
||||
state.sequence += 1;
|
||||
publish();
|
||||
};
|
||||
document.querySelector('[data-playtest-id="start"]').addEventListener('click', (event) => {
|
||||
if (event.isTrusted && state.phase === 'ready') {
|
||||
advance(() => { state.phase = 'playing'; });
|
||||
}
|
||||
});
|
||||
document.querySelector('[data-playtest-id="primary-action"]').addEventListener('click', (event) => {
|
||||
if (event.isTrusted && state.phase === 'playing') {
|
||||
setTimeout(() => advance(() => { state.score += 1; }), 0);
|
||||
}
|
||||
});
|
||||
document.querySelector('[data-playtest-id="restart"]').addEventListener('click', (event) => {
|
||||
if (event.isTrusted) {
|
||||
advance(() => {
|
||||
state.phase = 'ready';
|
||||
state.score = 0;
|
||||
});
|
||||
}
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>"#;
|
||||
let (stop_tx, stop_rx) = mpsc::channel();
|
||||
let server = thread::spawn(move || {
|
||||
while stop_rx.try_recv().is_err() {
|
||||
match listener.accept() {
|
||||
Ok((mut stream, _)) => {
|
||||
let mut request = [0_u8; 2048];
|
||||
let _ = stream.read(&mut request);
|
||||
let headers = format!(
|
||||
"HTTP/1.1 200 OK\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
|
||||
html.len()
|
||||
);
|
||||
let _ = stream.write_all(headers.as_bytes());
|
||||
let _ = stream.write_all(html);
|
||||
}
|
||||
Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {
|
||||
thread::sleep(Duration::from_millis(10));
|
||||
}
|
||||
Err(error) => panic!("preview accept failed: {error}"),
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let evidence = tempfile::tempdir().expect("evidence tempdir");
|
||||
let validation = validate_local_preview_in_browser(BrowserValidationInput {
|
||||
url: format!("http://127.0.0.1:{port}/"),
|
||||
viewports: REQUIRED_VIEWPORTS.to_vec(),
|
||||
expected_text: vec!["Deferred generic fixture".to_string()],
|
||||
settle_ms: 100,
|
||||
fail_on_console_error: true,
|
||||
playtest_scenario: Some(BrowserPlaytestScenario::GenericV1),
|
||||
evidence_root: evidence.path().join("evidence"),
|
||||
})
|
||||
.await;
|
||||
let _ = stop_tx.send(());
|
||||
server.join().expect("preview server");
|
||||
|
||||
let result = validation.expect("real deferred generic browser validation");
|
||||
assert!(!result.passed, "deferred action must fail validation");
|
||||
let playtest = result.playtest.expect("generic playtest result");
|
||||
assert!(!playtest.passed, "{:#?}", playtest.diagnostics);
|
||||
assert!(
|
||||
playtest
|
||||
.diagnostics
|
||||
.iter()
|
||||
.any(|message| message.contains("RAF/timer")),
|
||||
"diagnostics={:#?}",
|
||||
playtest.diagnostics
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "requires an installed Chrome/Chromium/Edge and explicit local browser execution"]
|
||||
async fn real_chrome_generic_playtest_binds_tetris_actions_to_gameplay_state() {
|
||||
|
||||
@@ -5926,8 +5926,8 @@
|
||||
- 覆盖决策:game-chat fallback 只允许初始化缺失/占位入口的首次落盘。非占位 `game/index.html` 必须保留,并由当前 `code-prototype` 先读取和实际 patch,取得本人 `mutationRevision` 后才能运行 `game.static_smoke` 与交付;只读 smoke 不得冒充续作。确定性 fallback 只允许已实现真实语义的显式玩法模板:俄罗斯方块模板必须具备 10×20 棋盘、下落、移动、旋转、锁定、消行和触顶失败,收集模板只用于明确收集类目标,未知玩法失败关闭。纯继续目标未恢复时同样失败关闭。完成门新增 baseline 玩法连续性和 action-driven state 检查,generic Canvas 非空、三个按钮存在或静态 smoke 通过都不能单独证明任务没有换题。
|
||||
- 美术决策:`art-spec.png` 回归为规范图和下游派生 reference,不能铺作完整场景,也不能裁剪成玩家/目标。首版必须继续由 `art-asset-plan` 通过 icon-spritesheet 生成透明 `art-spritesheet.png`;桌面 Runtime 同时下载服务端 `iconImageSrcs`,按当前图集 resourceId 写入本地切片清单。game-chat 在任何本地落盘前要求稳定、非空的图集 resourceId,并在正式图集登记前要求切片严格等于四、每片 `sourceResourceId` 精确绑定整图,全部切片累计下载最多 `32 MiB`;四类差异按尺寸加规范 RGBA 像素摘要判定,PNG 编码字节不同不代表视觉内容不同。主图、四张 canonical 切片与切片清单作为一个提交合同,并把主图/切片摘要及 Canvas 身份冻结到 `.agent/runtime/art-spritesheet-contract.json` 私有回执;主图安装、回执或资产登记失败时必须恢复整组旧合同。generation 账本恢复允许对摘要一致的已落盘主图幂等补齐合同,摘要冲突不得覆盖。完成门重新读取切片时继续有界解码,并要求实际素材、公开清单、当前 Canvas 登记与私有回执在内容摘要、规范像素摘要、可见 alpha、四类唯一性和资源身份上全部一致。`code-prototype` 在活动 Canvas 中分别绘制玩家、方块/目标、障碍/场景和反馈四类不同切片。纯代码核心画面、猜测 atlas 等分坐标、单个裁切冒充全部类别、整图展示与路径诱饵失败关闭;编辑器仍允许仅有 `sliceWarning` 的完整透明图集完成,但 game-chat 必须等到真实切片可用。
|
||||
- 图集身份与恢复补充:canonical game-chat 图集是 External 通用资源模型的严格完成子集。主图与四个切片都必须含非空 `assetObjectId`,五个 ID 互不复用;同一对象在顶层、resource 与 asset 中重复出现的 `assetObjectId` 和 `taskId` 都必须逐项完全一致,冲突时不得择一冻结。公开切片清单和私有回执同时冻结 `sourceResourceId / sourceAssetObjectId / sourceTaskId / sourceCanvasProjectId / sourceReferenceResourceIds`,四项切片按 usage 唯一且逐项比较 `name / path / width / height / resourceId / assetObjectId / contentSha256 / pixelSha256`。旧项目仅缺私有回执时不得从可编辑公开清单伪造回执;只有同一 `project-supervisor-game-chat` 父 run 下处于 running 的 `art-asset-plan` scheduled child、固定输出路径且当前合同确实失效时,才允许 `replaceExisting=true` 原位 repair。任何 canonical 文件变化前,Runtime 必须在 `.agent/runtime` 私有事务目录原子持久化九个固定合同路径的旧状态并回读,随后写 `prepared` marker;主图字节先写 staging,再安装 canonical 主图,随后才提交四切片、公开清单、私有回执和 Canvas 登记,登记成功后写 `committed` marker 才可回收快照和 backup。恢复只在取得同一项目写锁后按持久 transaction id 扫描;`prepared` 未 `committed` 必须整组恢复旧合同,`committed` 只做幂等清理,不能凭随机 previous/replacement 文件名干扰正在提交的事务。这样即使进程被强杀且远端暂不可用,也能恢复完整旧合同,不留下“旧主图 + 新切片”或半写 canonical PNG。
|
||||
- 图集事务退役补充:九路径快照在读取前先用不跟随符号链接的元数据核算 64 MiB 总预算,实际读取仍受剩余预算限制,稀疏或并发增长文件不能触发无界分配。恢复必须先把全部 journal 条目和九路径快照完成结构、大小与摘要校验并形成内存计划,随后才能修改任一 canonical 路径;末尾快照损坏不得留下新旧混合合同。`committed` 持久化后先删除并同步 `prepared`,再清理 `.previous / .replacement`、同步 canonical 合同并最后删除事务目录;递归删除中断后最多留下只有 `committed` 的可清理事务,不能重新落入 rollback 分支。
|
||||
- Tetris 连续性补充:明确俄罗斯方块任务固定分类为 `tetris-v1`,不再回退 generic 可选遥测。静态门移除字符串、注释、`template / noscript / textarea / title / style / xmp / iframe / noembed / plaintext`、带 `src` 脚本的非执行正文、非 JavaScript script 和 `if(false)` / 明显恒假分支诱饵,并要求有标识符边界的可达 `fall -> lock -> clear` 调用链;splice 消行必须绑定棋盘或不可重赋值的棋盘别名。同项目 `game/*.js / game/*.mjs` 外部脚本及本地 module 依赖图按去重文件数和累计 2 MiB 上限有界读取。浏览器状态固定含 `activePieceId / rotation / row / lockedPieces / lineClearChecks / clearedLines / occupiedCells`,同时允许 `score / nextPieceId` 等不影响固定合同的扩展 telemetry;受控试玩在 Chromium 隔离执行上下文的 Promise 闭包中,以同一次 trusted 鼠标输入的 mouseup 尾部状态作为 click 前基线,页面全局对象不能改写因果证据,capture-phase click 仍可被正确验收。锁定、消行与 restart 的既有严格约束保持不变。旧合同或纯继续 successor 以及 game-chat 快车道在读取回执前按有效原任务重新分类、重算指纹并回读迁移结果,旧 generic 回执只能视为 stale,不能交付完成。
|
||||
- 图集事务退役补充:九路径快照在读取前先用跨平台不跟随符号链接 / reparse point 的文件句柄核算 64 MiB 总预算,marker、journal 与快照均通过有界双次读取和句柄元数据复核拒绝同长度并发改写;实际读取仍受剩余预算限制,稀疏或并发增长文件不能触发无界分配。恢复必须先把全部 journal 条目和九路径快照完成结构、大小与摘要校验并形成内存计划,随后缓存全部 canonical 路径的恢复前状态;每项落盘前再次校验目标与父目录,后续项失败时按逆序回滚本轮已应用项,末尾路径竞态或快照损坏不得留下新旧混合合同。`committed` 持久化后先删除并同步 `prepared`,再清理 `.previous / .replacement`、同步 canonical 合同并最后删除事务目录;递归删除中断后最多留下只有 `committed` 的可清理事务,不能重新落入 rollback 分支。
|
||||
- Tetris 连续性补充:明确俄罗斯方块任务固定分类为 `tetris-v1`,不再回退 generic 可选遥测。HTML tokenizer 只把 TAB / LF / FF / CR / SPACE 视为标签空白;静态门移除字符串、注释、`template / noscript / textarea / title / style / xmp / iframe / noembed / plaintext`、带 `src` 脚本的非执行正文、非 JavaScript script、不可达函数、短路表达式和 `if(false)` / 明显恒假分支诱饵,并要求有标识符边界的可达 `fall -> lock -> clear` 调用链;filter / splice 消行必须由满行判断实际控制且绑定棋盘或不可重赋值的棋盘别名。同项目 `game/*.js / game/*.mjs` 外部脚本及本地 module 依赖图按真实连通单元聚合语义、隔离互不导入的 module,并按去重文件数和累计 2 MiB 上限有界读取;对象属性 `import / from` 与控制块后的正则正文不得伪造依赖。浏览器状态固定含 `activePieceId / rotation / row / lockedPieces / lineClearChecks / clearedLines / occupiedCells`,同时允许 `score / nextPieceId` 等不影响固定合同的扩展 telemetry;受控试玩在 Chromium 隔离执行上下文的 Promise 闭包中,以同一浏览器任务内 trusted 输入分发尾部冻结的状态作为 click 前基线,页面全局对象不能改写因果证据,真实 window bubble 处理器仍可被正确验收,0ms timer 不得抢入该因果窗口。探针 fingerprint 必须覆盖 install、ready 与 finish 三段真实脚本。锁定、消行与 restart 的既有严格约束保持不变。旧合同或纯继续 successor 以及 game-chat 快车道在读取回执前按有效原任务重新分类、重算 fingerprint 并回读迁移结果,旧 generic 回执只能视为 stale,不能交付完成。
|
||||
- 关联:`apps/ai-game-creator-shell/scripts/start-tauri-dev.mjs`、`start-dev-stack.mjs`、`src-tauri/src/agent/runtime_protocol/autonomous_completion.rs`、`response_stream.rs`、`docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md`。
|
||||
|
||||
## 2026-08-03 托管 MCP 未鉴权响应提供安全接入引导
|
||||
|
||||
@@ -827,9 +827,9 @@ game-project/
|
||||
- 2026-08-03 失败续跑收口:同一 `project-supervisor` Session、同一持久 source 的最近可信根 run 已失败、取消或预算耗尽,且新输入只是严格受限的继续意图(例如“继续”“接着做”“继续完成”“continue”“go on”)时,宿主仍创建新的 root run 身份,但必须把上一根 run 的原始任务作为继承目标和完成合同基线;首次和连续 successor 的 effective task、合同 SHA、Runtime hydration 与 scheduler 必须一致。不得把继续短语本身当游戏主题,也不得按真正新需求重置 seed manifest。跨 Session、跨 GUI / CLI / game-chat source、上一根 run 已正常完成、输入包含新的具体玩法要求或无法唯一识别前序根 run 时都不继承,继续按新任务执行。继承只复用目标与已有产物基线,不复用旧 Provider request、pending action 或副作用身份。
|
||||
- game-chat 快车道只能在 `game/index.html` 缺失或仍是初始化占位,且当前 child run 尚未写入正式入口时使用首次 fallback `file.write`。项目已存在非占位入口时,后续 `code-prototype` 必须先保留并读取既有玩法,做真实局部修改并取得本人 `mutationRevision`,之后才能运行 `game.static_smoke` 与交付;禁止为了满足首版时限重新生成整份默认小游戏,也禁止连续只读 smoke。占位 fallback 仅允许俄罗斯方块和明确收集类等已有真实语义模板,未知玩法失败关闭。纯继续意图未能恢复唯一原始目标时同样失败关闭,不输出以“继续”为标题的兜底产物。
|
||||
- `assets/art-spec.png` 的唯一语义是视觉规范与派生参考,不是运行时背景、角色、目标或图集。game-chat 的核心玩家、方块/目标、障碍/场景和反馈必须来自独立派生的透明 `assets/art-spritesheet.png` 及其服务端 `iconImageSrcs` 本地切片;Runtime 以 `sourceResourceId` 把切片清单绑定到当前图集,并要求活动 Canvas 分别绘制四类不同切片。纯代码核心实体、猜测图集等分坐标、单个裁切冒充全部类别、整图展示、隐藏引用、微小水印和诱饵路径均不构成真实美术使用。`playable-web-game-state.v1.sequence` 只在真实输入、状态迁移或模拟状态变化时递增,不得由纯渲染帧推进。
|
||||
- game-chat canonical 图集进一步要求主图与四个切片都有非空且互不复用的 Canvas `assetObjectId`;同一对象在顶层、resource 与 asset 中重复返回的 `assetObjectId` 和 `taskId` 必须分别一致,冲突时失败关闭。公开 `assets/art-spritesheet-slices/manifest.json` 与私有 `.agent/runtime/art-spritesheet-contract.json` 必须同时绑定 `sourceResourceId / sourceAssetObjectId / sourceTaskId / sourceCanvasProjectId / sourceReferenceResourceIds`,并对四种 usage 的 `name / path / width / height / resourceId / assetObjectId / contentSha256 / pixelSha256` 做完整一致性比较。旧项目缺私有回执时不得从公开文件反向生成回执;只允许同一 game-chat root 下处于 running 的 scheduled `art-asset-plan` 对固定主图执行受限 `replaceExisting=true` repair,普通 pending、其它 Agent、其它路径或有效合同均拒绝。九个固定合同文件在任何 canonical 改动前必须快照到 `.agent/runtime` 私有事务目录并写 `prepared` marker,Canvas 资产登记成功并写 `committed` marker 后才可清理;恢复只在同一项目写锁内先完整验证全部 journal 条目和快照、形成内存恢复计划,再按 transaction id 整组回滚或幂等清理,任一末尾快照损坏都不得先改写前面的 canonical 路径。远端下载阶段不得长期持锁,也不得在无锁 request 阶段修改 canonical 文件。
|
||||
- game-chat canonical 图集进一步要求主图与四个切片都有非空且互不复用的 Canvas `assetObjectId`;同一对象在顶层、resource 与 asset 中重复返回的 `assetObjectId` 和 `taskId` 必须分别一致,冲突时失败关闭。公开 `assets/art-spritesheet-slices/manifest.json` 与私有 `.agent/runtime/art-spritesheet-contract.json` 必须同时绑定 `sourceResourceId / sourceAssetObjectId / sourceTaskId / sourceCanvasProjectId / sourceReferenceResourceIds`,并对四种 usage 的 `name / path / width / height / resourceId / assetObjectId / contentSha256 / pixelSha256` 做完整一致性比较。旧项目缺私有回执时不得从公开文件反向生成回执;只允许同一 game-chat root 下处于 running 的 scheduled `art-asset-plan` 对固定主图执行受限 `replaceExisting=true` repair,普通 pending、其它 Agent、其它路径或有效合同均拒绝。九个固定合同文件在任何 canonical 改动前必须快照到 `.agent/runtime` 私有事务目录并写 `prepared` marker,Canvas 资产登记成功并写 `committed` marker 后才可清理;marker、journal 和快照必须由跨平台不跟随 symlink / reparse point 的句柄进行有界双次读取,拒绝同长度并发改写。恢复只在同一项目写锁内先完整验证全部 journal 条目和快照、形成内存恢复计划并缓存全部 canonical 的恢复前状态,再按 transaction id 整组回滚或幂等清理;每项写入前重新校验目标与父目录,晚序目标竞态或任一末尾快照损坏时必须逆序撤销本轮已应用项,不得留下部分恢复。远端下载阶段不得长期持锁,也不得在无锁 request 阶段修改 canonical 文件。
|
||||
- 图集本地提交以主图 staging 为线性化前置:任何新主图先写随机私有 staging 文件,替换时保留 previous,canonical 主图完整安装后才写四切片、公开清单、私有回执和项目资产登记。进程若在 backup/install 窗口退出,同一 accepted External generation 恢复先识别唯一同 suffix 的 previous/replacement 对并恢复旧主图,再按远端结果完成替换;若 canonical 已等于远端摘要,则不再要求替换授权,直接补齐其余合同。成功后清理主图、四切片、公开清单、私有回执和项目 manifest 的全部遗留 staging/backup。首次生成也禁止直接流式写 canonical 路径,避免部分 PNG 被误认为已安装结果。
|
||||
- 俄罗斯方块任务固定使用 `BrowserPlaytestScenario::TetrisV1`。`playable-web-game-state.v1.gameplay` 必须持续提供 `kind=tetris`、`activePieceId`、`rotation`、`row`、`lockedPieces`、`lineClearChecks`、`clearedLines` 和 `occupiedCells`;任何采样点删除字段都立即失败,但允许 `score / nextPieceId` 等额外 telemetry。静态连续性检查忽略字符串、注释、HTML raw-text/RCDATA 与其它非执行容器、带 `src` 脚本的内联正文、非 JavaScript script 和 `if(false)` / 明显恒假分支诱饵,以标识符边界绑定真实 `fall -> lock -> clear` 调用链;splice 消行必须作用于棋盘或可证明的常量别名。本地 `.js / .mjs` 入口、inline module import 与 module 传递依赖统一限制在 `game/`,按文件去重并受 256 文件、累计 2 MiB 上限约束。浏览器因果探针运行于 Chromium 隔离执行上下文,使用同一次 trusted 鼠标输入的 mouseup 尾部状态作为 click 前基线,前后原始状态只保存在 Promise 闭包中,因此受测页面不能通过全局变量改写证据,页面既有 capture-phase click 处理器也不会被误判;其余同方块旋转、重力/锁定、四格落盘或消行、`lineClearChecks` 和 restart 归零约束保持不变。旧/续跑合同及 game-chat 快车道在回执读取前按有效原任务迁移到该场景、重算 fingerprint 并回读一致,旧 generic-v1 回执视为 stale,不能交付完成。
|
||||
- 俄罗斯方块任务固定使用 `BrowserPlaytestScenario::TetrisV1`。`playable-web-game-state.v1.gameplay` 必须持续提供 `kind=tetris`、`activePieceId`、`rotation`、`row`、`lockedPieces`、`lineClearChecks`、`clearedLines` 和 `occupiedCells`;任何采样点删除字段都立即失败,但允许 `score / nextPieceId` 等额外 telemetry。静态连续性检查按 HTML 规定的五种空白解析标签,忽略字符串、注释、HTML raw-text/RCDATA 与其它非执行容器、带 `src` 脚本的内联正文、非 JavaScript script、不可达函数、短路动态 import 和 `if(false)` / 明显恒假分支诱饵,以标识符边界绑定真实 `fall -> lock -> clear` 调用链;filter / splice 消行必须由满行判断真实控制,并作用于棋盘或可证明的常量别名。本地 `.js / .mjs` 入口、inline module import 与 module 传递依赖统一限制在 `game/`,按真实 import graph 的连通单元聚合、按文件去重并受 256 文件、累计 2 MiB 上限约束;互不导入的 module 保持作用域隔离,对象属性和正则正文不能伪造依赖。浏览器因果探针运行于 Chromium 隔离执行上下文,在同一浏览器任务内冻结 trusted 输入分发尾部状态作为 click 前基线,前后原始状态只保存在 Promise 闭包中,因此受测页面不能通过全局变量改写证据,页面既有 capture-phase 或 window bubble click 处理器也不会被误判,0ms timer 不得抢入因果窗口;探针 fingerprint 覆盖 install、ready 与 finish 的真实脚本。其余同方块旋转、重力/锁定、四格落盘或消行、`lineClearChecks` 和 restart 归零约束保持不变。旧/续跑合同及 game-chat 快车道在回执读取前按有效原任务迁移到该场景、重算 fingerprint 并回读一致,旧 generic-v1 回执视为 stale,不能交付完成。
|
||||
- 泥点不足是确定性业务中断,不是瞬态 Provider 故障或未知副作用。钱包的 `泥点余额不足` 与 `可消费泥点不足:...` 两种领域文案统一映射为稳定原因 `mud-points-insufficient`,不得自动重试;即使 External Generation durable ledger 已存在,也必须落为 `failed`,不能误入 `needs-reconciliation`。game-chat 顶部状态、持久失败对话与 `【Supervisor 阶段记录】` 统一显示“泥点余额不足,本轮游戏生成已中断。请充值后发送“继续”,系统会从当前项目进度接着完成。”,并禁止透传 operationId、URL、路径、密钥或任意上游正文。
|
||||
- tool-plan 成功响应落账前,对内置 Runtime 原生函数与 legacy wrapper 的合法、无重复 key JSON arguments 按工具 schema 的精确位置做项目路径 canonicalization:`file.*.path`、`project.patchset.changes[*].path`、`project.git_commit.paths[*]`、`command.*.cwd`、`image.inspect.paths[*]` 与 `canvas.asset_generate.outputPath` 若是当前项目根目录内的完整绝对路径,转换为 `/` 分隔的项目相对路径后再校验、持久化并执行;源码/叙述字段、任务产物描述、动态 MCP arguments 和项目外绝对路径不得改写,后两者继续由绝对路径门禁失败关闭。项目根只允许搜索/列举范围与命令 cwd 规范化为 `.`,不能成为文件目标。当前进程与重启恢复都必须从同一份规范化 handoff 重放,禁止分别执行原响应和持久响应。
|
||||
|
||||
|
||||
Reference in New Issue
Block a user