合并最新 AI 游戏创作 App 分支
Project CI / Frontend tests (pull_request) Successful in 3m18s
Project CI / Repository checks (pull_request) Failing after 22s
Project CI / Backend tests (pull_request) Failing after 22s
Project CI / Native shell tests (pull_request) Failing after 22s

This commit is contained in:
2026-07-31 15:34:36 +08:00
57 changed files with 6949 additions and 379 deletions
+1
View File
@@ -9,6 +9,7 @@
"dev-server": "node scripts/start-dev-server.mjs",
"dev-stack": "node scripts/start-dev-stack.mjs",
"build": "npm --prefix ../.. exec tauri -- build",
"build:game-chat-release": "npm --prefix ../.. exec tauri -- build --config src-tauri/tauri.game-chat-release.conf.json --bundles nsis --features game-chat-release",
"llm-status": "node scripts/run-cli-with-config.mjs --llm-status",
"agent-task": "node scripts/run-cli-with-config.mjs --agent-task",
"chat": "node scripts/run-cli-with-config.mjs --swarm-chat",
@@ -0,0 +1,39 @@
import { spawnSync } from 'node:child_process';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
const appRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
const repoRoot = resolve(appRoot, '../..');
const npmCli =
process.env.npm_execpath ??
resolve(dirname(process.execPath), 'node_modules/npm/bin/npm-cli.js');
function run(args, extraEnv = {}) {
const result = spawnSync(process.execPath, [npmCli, ...args], {
cwd: appRoot,
env: { ...process.env, ...extraEnv },
stdio: 'inherit',
});
if (result.error) {
throw result.error;
}
if (result.status !== 0) {
process.exit(result.status ?? 1);
}
}
run(['--prefix', repoRoot, 'run', 'ai-game-creator-shell:typecheck']);
run(
[
'--prefix',
repoRoot,
'exec',
'vite',
'--',
'build',
'--config',
'vite.config.ts',
],
{ VITE_AGC_GAME_CHAT_ONLY: 'true' },
);
@@ -27,6 +27,20 @@ const tauriConfig = JSON.parse(
'utf8',
),
);
const gameChatReleaseTauriConfig = JSON.parse(
fs.readFileSync(
new URL('../src-tauri/tauri.game-chat-release.conf.json', import.meta.url),
'utf8',
),
);
const cargoManifestSource = fs.readFileSync(
new URL('../src-tauri/Cargo.toml', import.meta.url),
'utf8',
);
const cargoPackageVersion = cargoManifestSource
.split(/\r?\n(?=\[)/u)
.find((section) => section.startsWith('[package]'))
?.match(/^version\s*=\s*"([^"]+)"\s*$/mu)?.[1];
const eventCapabilityPath = new URL(
'../src-tauri/capabilities/events.json',
import.meta.url,
@@ -57,6 +71,14 @@ const appEntrypointSource = fs.readFileSync(
new URL('../src/main.tsx', import.meta.url),
'utf8',
);
const appModuleSource = fs.readFileSync(
new URL('../src/App.tsx', import.meta.url),
'utf8',
);
const gameChatReleaseBuildSource = fs.readFileSync(
new URL('../scripts/build-game-chat-release.mjs', import.meta.url),
'utf8',
);
const tauriHandlerSource = fs.readFileSync(
new URL('../src-tauri/src/main.rs', import.meta.url),
'utf8',
@@ -414,6 +436,28 @@ async function runConfigWizardRegressionChecks() {
path.join(os.tmpdir(), 'genarrative-agc-config-check-'),
);
try {
const canonicalTestRoot = fs.realpathSync.native(testRoot);
const realConfigAncestor = path.join(testRoot, 'real-config-ancestor');
const linkedConfigAncestor = path.join(testRoot, 'linked-config-ancestor');
fs.mkdirSync(realConfigAncestor);
fs.symlinkSync(
realConfigAncestor,
linkedConfigAncestor,
process.platform === 'win32' ? 'junction' : 'dir',
);
const missingLinkedConfigDir = path.join(
linkedConfigAncestor,
'missing-appdata',
);
assert.equal(fs.existsSync(missingLinkedConfigDir), false);
assert.equal(
await assertSafeGameCreatorConfigDestination(missingLinkedConfigDir),
path.join(
fs.realpathSync.native(realConfigAncestor),
'missing-appdata',
),
);
const gitRoot = path.join(testRoot, 'tracked-repository');
const trackedConfigDir = path.join(gitRoot, 'runtime-config');
fs.mkdirSync(trackedConfigDir, { recursive: true });
@@ -441,7 +485,7 @@ async function runConfigWizardRegressionChecks() {
const outsideConfigDir = path.join(testRoot, 'outside-appdata');
assert.equal(
await assertSafeGameCreatorConfigDestination(outsideConfigDir),
outsideConfigDir,
path.join(canonicalTestRoot, 'outside-appdata'),
);
await assert.rejects(
assertSafeGameCreatorConfigDestination(outsideConfigDir, {
@@ -454,7 +498,7 @@ async function runConfigWizardRegressionChecks() {
await assertSafeGameCreatorConfigDestination(dedicatedConfigDir, {
requireDedicatedLeaf: true,
}),
dedicatedConfigDir,
path.join(canonicalTestRoot, appIdentifier),
);
const injectedNonGitConfigDir = path.join(testRoot, 'injected-non-git');
@@ -467,7 +511,7 @@ async function runConfigWizardRegressionChecks() {
'fatal: not a git repository (or any of the parent directories): .git\n',
}),
}),
injectedNonGitConfigDir,
path.join(canonicalTestRoot, 'injected-non-git'),
);
await assert.rejects(
assertSafeGameCreatorConfigDestination(
@@ -916,6 +960,29 @@ if (
);
}
const gameChatReleaseAppIndex = appModuleSource.indexOf(
'export function GameChatReleaseApp(',
);
const gameChatReleaseBranchIndex = appEntrypointSource.indexOf(
'{gameChatReleaseMode ? (',
);
const authenticatedClientIndex = appEntrypointSource.indexOf(
'<AuthenticatedClient>',
gameChatReleaseBranchIndex,
);
if (
gameChatReleaseAppIndex === -1 ||
gameChatReleaseBranchIndex === -1 ||
!appEntrypointSource
.slice(gameChatReleaseBranchIndex, authenticatedClientIndex)
.includes('gameChatApp') ||
authenticatedClientIndex < gameChatReleaseBranchIndex
) {
throw new Error(
'AI game creator game-chat release must render the local chat App before the platform authentication boundary',
);
}
if (
packageConfig.scripts?.['agent-run'] !==
'node scripts/run-cli-with-config.mjs --agent-run'
@@ -1271,8 +1338,6 @@ for (const requiredSnippet of [
'fn apply_game_chat_initial_window_url(',
'.find(|window| window.label == "client")',
'client.url = game_chat_window_url(',
'.build(tauri_context)',
'app.run(|_, event| handle_game_creator_gui_run_event(&event))',
]) {
if (
!`${tauriHandlerSource}\n${tauriWindowSource}`.includes(requiredSnippet)
@@ -1283,6 +1348,15 @@ for (const requiredSnippet of [
}
}
if (
!tauriHandlerSource.includes('.build(tauri_context)') ||
!tauriHandlerSource.includes('handle_game_creator_gui_run_event(&event)')
) {
throw new Error(
'AI game creator Tauri runtime must build from the prepared Context and preserve the generic GUI exit hook',
);
}
if (
!tauriConfig.build?.beforeBuildCommand?.includes('--config vite.config.ts')
) {
@@ -1291,6 +1365,66 @@ if (
);
}
if (
!appEntrypointSource.includes(
"import.meta.env.VITE_AGC_GAME_CHAT_ONLY === 'true'",
) ||
!appEntrypointSource.includes(
"import.meta.env.DEV && initialSearchParams.has('game-chat')",
)
) {
throw new Error(
'AI game creator game-chat release must be compile-time fixed while preserving the dev query entry',
);
}
if (
packageConfig.scripts?.['build:game-chat-release'] !==
'npm --prefix ../.. exec tauri -- build --config src-tauri/tauri.game-chat-release.conf.json --bundles nsis --features game-chat-release' ||
rootPackageConfig.scripts?.['agc:build:game-chat-release'] !==
'npm --prefix apps/ai-game-creator-shell run build:game-chat-release --'
) {
throw new Error(
'AI game creator game-chat release build commands must stay wired through the dedicated Tauri config',
);
}
if (
gameChatReleaseTauriConfig.productName !== 'Genarrative Game Chat' ||
gameChatReleaseTauriConfig.version !== '0.1.1' ||
gameChatReleaseTauriConfig.identifier === tauriConfig.identifier ||
gameChatReleaseTauriConfig.build?.beforeBuildCommand !==
'node scripts/build-game-chat-release.mjs' ||
!gameChatReleaseTauriConfig.bundle?.targets?.includes('nsis')
) {
throw new Error(
'AI game creator game-chat release must keep version 0.1.1, its independent identity, frontend build, and NSIS target',
);
}
if (
tauriConfig.version !== '0.1.0' ||
packageConfig.version !== '0.1.0' ||
cargoPackageVersion !== '0.1.0'
) {
throw new Error(
'AI game creator standard release must remain version 0.1.0 while game-chat uses its dedicated version',
);
}
for (const requiredSnippet of [
"'ai-game-creator-shell:typecheck'",
"VITE_AGC_GAME_CHAT_ONLY: 'true'",
"'--config'",
"'vite.config.ts'",
]) {
if (!gameChatReleaseBuildSource.includes(requiredSnippet)) {
throw new Error(
`AI game creator game-chat release build guardrail drifted: ${requiredSnippet}`,
);
}
}
const devServerSource = fs.readFileSync(
new URL('../scripts/start-dev-server.mjs', import.meta.url),
'utf8',
@@ -1380,7 +1514,6 @@ for (const snippet of [
'fn merge_game_creator_config_file(',
'.join("apps")',
'.join("ai-game-creator-shell")',
'configure_game_creator_runtime_config_dir(app.handle())?',
'read_game_creator_app_config,',
'write_game_creator_app_config,',
'resolve_game_creator_llm_config_for_agent(app_config, "planner")',
@@ -1400,6 +1533,31 @@ for (const snippet of [
}
}
const runtimeConfigSetupStart = tauriHandlerSource.indexOf(
'configure_game_creator_runtime_config_dir(app.handle()).inspect_err(|error| {',
);
const runtimeConfigSetupEnd = tauriHandlerSource.indexOf(
'})?;',
runtimeConfigSetupStart,
);
const runtimeConfigSetupSource = tauriHandlerSource.slice(
runtimeConfigSetupStart,
runtimeConfigSetupEnd,
);
if (
runtimeConfigSetupStart === -1 ||
runtimeConfigSetupEnd === -1 ||
!runtimeConfigSetupSource.includes('sanitize_diagnostic_message(') ||
!runtimeConfigSetupSource.includes('append_bounded_diagnostic_line(') ||
!runtimeConfigSetupSource.includes(
'startup.appdata.configure.failed details={details}',
)
) {
throw new Error(
'AI game creator setup must configure the runtime AppData directory and log sanitized setup failures',
);
}
for (const snippet of [
'import.meta.env.DEV',
'#[cfg(all(debug_assertions, not(test)))]',
@@ -4,6 +4,10 @@ version = "0.1.0"
edition = "2021"
publish = false
[features]
default = []
game-chat-release = []
[build-dependencies]
tauri-build = { version = "2.6.2", features = [] }
@@ -39,4 +43,4 @@ tauri-plugin-clipboard-manager = "2.3.2"
libc = "0.2"
[target.'cfg(windows)'.dependencies]
windows-sys = { version = "0.61", features = ["Wdk_Storage_FileSystem", "Win32_Foundation", "Win32_Storage_FileSystem", "Win32_System_IO", "Win32_System_JobObjects"] }
windows-sys = { version = "0.61", features = ["Wdk_Storage_FileSystem", "Win32_Foundation", "Win32_Storage_FileSystem", "Win32_System_Diagnostics_ToolHelp", "Win32_System_IO", "Win32_System_JobObjects", "Win32_System_Threading", "Win32_UI_WindowsAndMessaging"] }
@@ -514,22 +514,25 @@ pub(in crate::agent) fn build_game_creator_agent_runtime_llm_client(
pub(crate) fn game_creator_agent_llm_error_public_summary(
error: &platform_llm::LlmError,
) -> String {
let kind = match error {
platform_llm::LlmError::Timeout { .. } => "timeout".to_string(),
platform_llm::LlmError::Connectivity { .. } => "connectivity".to_string(),
platform_llm::LlmError::Transport(_) => "transport".to_string(),
let (kind, http_status) = match error {
platform_llm::LlmError::Timeout { .. } => ("timeout".to_string(), None),
platform_llm::LlmError::Connectivity { .. } => ("connectivity".to_string(), None),
platform_llm::LlmError::Transport(_) => ("transport".to_string(), None),
platform_llm::LlmError::Upstream { status_code, .. } => {
format!("upstream-{status_code}")
(format!("upstream-{status_code}"), Some(*status_code))
}
platform_llm::LlmError::InvalidConfig(_) => "invalid-config".to_string(),
platform_llm::LlmError::InvalidRequest(_) => "invalid-request".to_string(),
platform_llm::LlmError::StreamUnavailable => "stream-unavailable".to_string(),
platform_llm::LlmError::EmptyResponse => "empty-response".to_string(),
platform_llm::LlmError::Deserialize(_) => "deserialize".to_string(),
platform_llm::LlmError::InvalidConfig(_) => ("invalid-config".to_string(), None),
platform_llm::LlmError::InvalidRequest(_) => ("invalid-request".to_string(), None),
platform_llm::LlmError::StreamUnavailable => ("stream-unavailable".to_string(), None),
platform_llm::LlmError::EmptyResponse => ("empty-response".to_string(), None),
platform_llm::LlmError::Deserialize(_) => ("deserialize".to_string(), None),
};
let raw = error.to_string();
let http_status = http_status
.map(|status| format!(" httpStatus={status}"))
.unwrap_or_default();
format!(
"kind={kind} fingerprint={:x} chars={}",
"kind={kind}{http_status} fingerprint={:x} chars={}",
Sha256::digest(raw.as_bytes()),
raw.chars().count()
)
@@ -44,6 +44,8 @@ pub(in crate::agent) use run_status_observation::*;
pub(in crate::agent) use structured_plan::*;
pub(in crate::agent) use tool_plan_protocol::*;
#[cfg(test)]
pub(crate) use action_audit::agent_runtime_action_receipt_public_safe_detail_for_test;
#[cfg(test)]
pub(crate) use action_audit::agent_runtime_action_receipt_safe_detail_for_owner_for_test;
pub(crate) use action_audit::{
@@ -194,6 +194,14 @@ pub(crate) fn agent_runtime_action_receipt_safe_detail_for_owner_for_test(
)
}
#[cfg(test)]
pub(crate) fn agent_runtime_action_receipt_public_safe_detail_for_test(
root: &Path,
observation: &AgentRuntimeToolObservation,
) -> Option<String> {
agent_runtime_action_receipt_safe_detail(root, observation)
}
fn agent_runtime_action_receipt_safe_detail_with_owner(
root: &Path,
receipt_owner: Option<(&str, &str)>,
@@ -428,16 +436,27 @@ fn agent_runtime_action_receipt_safe_detail_with_owner(
{
return None;
}
let diagnostics_count = detail
.get("diagnostics")
.and_then(serde_json::Value::as_array)
.map(Vec::len)
.unwrap_or(0);
if receipt_owner.is_none() {
return serde_json::to_string(&serde_json::json!({
"passed": passed,
"revision": revision,
"diagnosticsCount": diagnostics_count,
"playtestPassed": playtest_passed,
"playtestScenario": playtest_scenario,
}))
.ok();
}
return serde_json::to_string(&serde_json::json!({
"passed": passed,
"revision": revision,
"reportPath": report_path,
"screenshots": screenshots,
"diagnosticsCount": detail
.get("diagnostics")
.and_then(serde_json::Value::as_array)
.map(Vec::len)
.unwrap_or(0),
"diagnosticsCount": diagnostics_count,
"playtestPassed": playtest_passed,
"playtestScenario": playtest_scenario,
}))
@@ -480,11 +480,10 @@ pub(crate) fn resume_game_creator_agent_runtime_for_goal_at(
if let Some(retry) = waiting_provider_retry.as_ref() {
state.status = "running".to_string();
state.phase = "waiting-for-provider-retry".to_string();
state.current_action = format!(
"Goal 已恢复,继续等待 Provider 瞬态重试 {}/{}",
retry.next_attempt, retry.max_retries
);
state.waiting_on = format!("Provider {} 瞬态故障退避到期", retry.error_kind);
let (current_action, waiting_on) =
game_creator_agent_runtime_provider_retry_waiting_presentation(retry);
state.current_action = format!("Goal 已恢复,{current_action}");
state.waiting_on = waiting_on;
state.next_step = "到期后恢复同一 Session/run/loop 和 retry attempt".to_string();
} else if pending_action.as_ref().is_some_and(|pending| {
pending.status == AGENT_RUNTIME_PENDING_ACTION_STATUS_WAITING_FOR_USER_INPUT
@@ -629,7 +629,9 @@ pub(in crate::agent) async fn run_game_creator_agent_background_task_pass_with_c
Some(&session_id),
LocalConversationMessage {
role: "assistant".to_string(),
content: format!("后台任务失败:{error}"),
content: game_creator_agent_runtime_failure_conversation_message(
&agent_id, &error,
),
agent_id: None,
},
);
@@ -1817,7 +1819,9 @@ pub(in crate::agent) async fn run_game_creator_agent_background_task_pass_with_c
Some(&session_id),
LocalConversationMessage {
role: "assistant".to_string(),
content: format!("后台任务失败:{error}"),
content: game_creator_agent_runtime_failure_conversation_message(
&agent_id, &error,
),
agent_id: None,
},
);
@@ -1941,7 +1945,9 @@ pub(in crate::agent) async fn run_game_creator_agent_background_task_pass_with_c
Some(&session_id),
LocalConversationMessage {
role: "assistant".to_string(),
content: format!("后台任务失败:{error}"),
content: game_creator_agent_runtime_failure_conversation_message(
&agent_id, &error,
),
agent_id: None,
},
);
@@ -2201,7 +2207,9 @@ pub(in crate::agent) async fn run_game_creator_agent_background_task_pass_with_c
Some(&session_id),
LocalConversationMessage {
role: "assistant".to_string(),
content: format!("后台任务失败:{error}"),
content: game_creator_agent_runtime_failure_conversation_message(
&agent_id, &error,
),
agent_id: None,
},
);
@@ -3002,7 +3010,9 @@ pub(in crate::agent) async fn run_game_creator_agent_background_task_pass_with_c
Some(&session_id),
LocalConversationMessage {
role: "assistant".to_string(),
content: format!("后台任务失败:{error}"),
content: game_creator_agent_runtime_failure_conversation_message(
&agent_id, &error,
),
agent_id: None,
},
);
@@ -190,10 +190,14 @@ fn prepare_autonomous_completion_evidence(root: &Path, state: &AgentRuntimeState
final_sequence: Some(8),
final_phase: Some(BrowserPlaytestPhase::Playing),
final_level: Some(2),
assertions: vec![BrowserPlaytestAssertion {
name: "fixture-passed".to_string(),
passed: true,
}],
assertions: scenario
.assertion_names()
.iter()
.map(|name| BrowserPlaytestAssertion {
name: (*name).to_string(),
passed: true,
})
.collect(),
diagnostics: Vec::new(),
}),
diagnostics: Vec::new(),
@@ -684,11 +684,10 @@ pub(in crate::agent) fn persist_waiting_provider_retry_context_at(
}
runtime.status = "running".to_string();
runtime.phase = "waiting-for-provider-retry".to_string();
runtime.current_action = format!(
"等待 Provider 瞬态重试 {}/{}",
retry.next_attempt, retry.max_retries
);
runtime.waiting_on = format!("Provider {} 瞬态故障退避到期", retry.error_kind);
let (current_action, waiting_on) =
game_creator_agent_runtime_provider_retry_waiting_presentation(retry);
runtime.current_action = current_action;
runtime.waiting_on = waiting_on;
runtime.next_step = "到期后自动恢复同一 Session/run/loop,并重新检查控制状态".to_string();
runtime.error = None;
runtime.updated_at = unix_timestamp();
@@ -228,7 +228,7 @@ pub(in crate::agent) fn fail_game_creator_agent_background_context_at(
Some(session_id),
LocalConversationMessage {
role: "assistant".to_string(),
content: format!("后台任务失败:{error}"),
content: game_creator_agent_runtime_failure_conversation_message(agent_id, &error),
agent_id: None,
},
);
@@ -664,8 +664,8 @@ pub(in crate::agent) fn autonomous_playtest_contract_prompt(
BrowserPlaytestScenario::GenericV1 => {
concat!(
"完成合同要求 generic-v1 交互试玩。game/index.html 必须持续更新 <script id=\"playable-web-game-state\" type=\"application/json\">JSON 固定包含 schemaVersion=playable-web-game-state.v1、单调递增 sequence、phase=ready|playing|won|lost、正整数 level",
"界面必须提供 data-playtest-id=\"start\" 与 data-playtest-id=\"restart\" 的真实可点击控件;每个固定 data-playtest-id 在对应受控试玩步骤都必须恰好匹配一个可见且启用(disabled=false)的真实可点击 HTMLElement,同一固定值不得出现在多个控件上。",
"start 后状态必须推进并进入 playing 或 won,restart 后必须再次推进并回到 ready 或 playing"
"界面必须提供 data-playtest-id=\"start\"、data-playtest-id=\"primary-action\" 与 data-playtest-id=\"restart\" 的真实可点击控件;primary-action 必须映射游戏的真实主要玩法操作,并在动作发生时推进 state sequence,不能使用空操作或仅更新装饰 UI 的按钮;每个固定 data-playtest-id 在对应受控试玩步骤都必须恰好匹配一个可见且启用(disabled=false)的真实可点击 HTMLElement,同一固定值不得出现在多个控件上。",
"初始状态必须是 ready 且 level 为正整数;start 后状态必须推进并进入 playing,并先至少持续 2 秒保持 playing,让玩家获得可操作机会,在 primary-action 之前进入 ready、won 或 lost 都会失败;随后 primary-action 必须再次严格推进 sequenceprimary-action 后 phase 可为 playing、won 或 lost,单次 won 或 lost 都是正常游戏终态,不会仅凭一次 lost 判定试玩失败;若动作后仍为 playing,则最多继续观察 3 秒,期间 won/lost 可提前形成首轮结果,始终 playing 也可在观察完成后证明非失败推进。restart 后必须再次推进,且至少持续 3 秒的稳定观察窗口内只能保持 ready 或 playing,进入 won 或 lost 都会失败;如果首轮 primary-action 结果为 lost,重开稳定后必须自动执行第二次受控尝试,恢复为 ready 时先 start 推进到 playing,随后无论重开结果原本是 ready 还是 playing,都必须再次完成至少 2 秒的 playing 操作机会,再次点击 primary-action 且严格推进 sequence;第二次必须进入 won,或保持 playing 并完成 3 秒观察,观察期间可进入 won 但不得进入 lost。两次受控尝试都进入 lost 说明游戏存在无法正常推进的固定失败,必须判定试玩失败;全部观察期间 sequence 始终不得回退"
)
}
BrowserPlaytestScenario::LaneDefenseV1 => {
@@ -921,7 +921,7 @@ pub(in crate::agent) fn validate_autonomous_evidence_digest(
Ok(())
}
pub(in crate::agent) fn validate_autonomous_playtest_receipt(
fn validate_autonomous_playtest_receipt_integrity(
root: &Path,
contract: &AgentRuntimeAutonomousCompletionContract,
receipt: &AgentRuntimeAutonomousPlaytestReceipt,
@@ -992,6 +992,20 @@ pub(in crate::agent) fn validate_autonomous_playtest_receipt(
Ok(())
}
pub(in crate::agent) fn validate_autonomous_playtest_receipt(
root: &Path,
contract: &AgentRuntimeAutonomousCompletionContract,
receipt: &AgentRuntimeAutonomousPlaytestReceipt,
) -> Result<(), String> {
validate_autonomous_playtest_receipt_integrity(root, contract, receipt)?;
let expected_scenario_fingerprint =
browser_playtest_scenario_fingerprint(contract.playtest_scenario);
if receipt.scenario_fingerprint != expected_scenario_fingerprint {
return Err("自主试玩回执场景指纹与当前固定试玩合同不匹配".to_string());
}
Ok(())
}
pub(in crate::agent) fn read_autonomous_playtest_receipt(
root: &Path,
contract: &AgentRuntimeAutonomousCompletionContract,
@@ -1004,7 +1018,12 @@ pub(in crate::agent) fn read_autonomous_playtest_receipt(
"自主试玩回执",
)?;
if let Some(receipt) = receipt.as_ref() {
validate_autonomous_playtest_receipt(root, contract, receipt)?;
validate_autonomous_playtest_receipt_integrity(root, contract, receipt)?;
let expected_scenario_fingerprint =
browser_playtest_scenario_fingerprint(contract.playtest_scenario);
if receipt.scenario_fingerprint != expected_scenario_fingerprint {
return Ok(None);
}
}
Ok(receipt)
}
@@ -1043,6 +1062,7 @@ pub(in crate::agent) fn verify_autonomous_playtest_evidence_files_at(
.ok_or_else(|| "自主试玩浏览器报告缺少交互试玩结果".to_string())?;
if !report.passed
|| !playtest.passed
|| !playtest.matches_scenario_contract()
|| playtest.scenario != receipt.playtest_scenario
|| playtest.scenario_fingerprint != receipt.scenario_fingerprint
{
@@ -1069,10 +1089,13 @@ pub(in crate::agent) fn write_autonomous_playtest_receipt_at(
.playtest
.as_ref()
.ok_or_else(|| "自主构建 preview.validate 缺少交互试玩结果".to_string())?;
let expected_scenario_fingerprint =
browser_playtest_scenario_fingerprint(contract.playtest_scenario);
if !result.passed
|| !playtest.passed
|| !playtest.matches_scenario_contract()
|| playtest.scenario != contract.playtest_scenario
|| !is_lowercase_sha256(&playtest.scenario_fingerprint)
|| playtest.scenario_fingerprint != expected_scenario_fingerprint
{
return Err("自主构建交互试玩未通过或场景身份不匹配".to_string());
}
@@ -1102,6 +1125,7 @@ pub(in crate::agent) fn write_autonomous_playtest_receipt_at(
.ok_or_else(|| "持久浏览器报告缺少交互试玩结果".to_string())?;
if !persisted_report.passed
|| !persisted_playtest.passed
|| !persisted_playtest.matches_scenario_contract()
|| persisted_playtest.scenario != playtest.scenario
|| persisted_playtest.scenario_fingerprint != playtest.scenario_fingerprint
{
@@ -288,10 +288,14 @@ fn browser_result_fixture(
final_sequence: Some(8),
final_phase: Some(BrowserPlaytestPhase::Playing),
final_level: Some(2),
assertions: vec![BrowserPlaytestAssertion {
name: "fixture-passed".to_string(),
passed: true,
}],
assertions: scenario
.assertion_names()
.iter()
.map(|name| BrowserPlaytestAssertion {
name: (*name).to_string(),
passed: true,
})
.collect(),
diagnostics: Vec::new(),
}),
diagnostics: Vec::new(),
@@ -555,6 +559,234 @@ fn autonomous_playtest_contract_requires_unique_visible_enabled_automation_contr
}
}
#[test]
fn generic_playtest_contract_requires_play_opportunity_and_post_action_outcome() {
let prompt = autonomous_playtest_contract_prompt(BrowserPlaytestScenario::GenericV1);
for requirement in [
"start 后状态必须推进并进入 playing",
"先至少持续 2 秒保持 playing",
"玩家获得可操作机会",
"data-playtest-id=\"primary-action\"",
"真实主要玩法操作",
"动作发生时推进 state sequence",
"primary-action 后 phase 可为 playing、won 或 lost",
"单次 won 或 lost 都是正常游戏终态",
"不会仅凭一次 lost 判定试玩失败",
"若动作后仍为 playing,则最多继续观察 3 秒",
"restart 后必须再次推进",
"至少持续 3 秒",
"稳定观察窗口内只能保持 ready 或 playing",
"首轮 primary-action 结果为 lost",
"自动执行第二次受控尝试",
"无论重开结果原本是 ready 还是 playing",
"再次完成至少 2 秒的 playing 操作机会",
"观察期间可进入 won 但不得进入 lost",
"两次受控尝试都进入 lost",
"无法正常推进的固定失败",
"sequence 始终不得回退",
] {
assert!(
prompt.contains(requirement),
"missing generic stability requirement: {requirement}"
);
}
assert!(!prompt.contains("进入 playing 或 won"));
}
#[test]
fn autonomous_playtest_receipt_rejects_previous_scenario_fingerprint() {
let (_temporary, root, state, contract) = autonomous_fixture(
"做一个完整小游戏",
"autonomous-stale-playtest-fingerprint-run",
);
let revision = advance_game_index_revision(
&root,
&state,
"<!doctype html><title>新游戏</title><canvas></canvas>",
);
let result =
browser_result_fixture(&root, &state, revision, BrowserPlaytestScenario::GenericV1);
let action = AgentRuntimeToolAction {
tool: "preview.validate".to_string(),
reason: Some("验证真实可玩闭环".to_string()),
input: serde_json::json!({}),
};
let action_fingerprint = agent_runtime_tool_action_fingerprint(&action, &state.current_task);
let action_id = agent_runtime_tool_action_id(&state.run_id, 1, 0, 1, &action_fingerprint);
let mut stale_result = result.clone();
stale_result
.playtest
.as_mut()
.expect("stale browser result playtest")
.scenario_fingerprint =
"a6ac4da3a698881b175f754ee4660d5ebc8b3486e357c2ab1e5df2add3d3349a".to_string();
write_autonomous_playtest_receipt_at(
&root,
&contract,
&action_id,
&action_fingerprint,
revision,
&stale_result,
)
.expect_err("previous scenario fingerprint must be rejected before receipt persistence");
let mut receipt = write_autonomous_playtest_receipt_at(
&root,
&contract,
&action_id,
&action_fingerprint,
revision,
&result,
)
.expect("persist current autonomous playtest receipt");
let mut legacy_report = result.clone();
legacy_report
.playtest
.as_mut()
.expect("legacy browser report playtest")
.assertions = [
"state-surface-valid",
"start-control-clicked",
"start-sequence-advanced",
"start-phase-playing-or-won",
"restart-control-clicked",
"restart-sequence-advanced",
"restart-phase-ready-or-playing",
]
.into_iter()
.map(|name| BrowserPlaytestAssertion {
name: name.to_string(),
passed: true,
})
.collect();
let legacy_report_bytes =
serde_json::to_vec_pretty(&legacy_report).expect("encode legacy browser report");
fs::write(root.join(&receipt.report.path), &legacy_report_bytes)
.expect("persist legacy browser report");
receipt.report.sha256 = format!("{:x}", Sha256::digest(&legacy_report_bytes));
receipt.report.size_bytes = legacy_report_bytes.len() as u64;
receipt.receipt_fingerprint = autonomous_playtest_receipt_fingerprint(&receipt);
validate_autonomous_playtest_receipt(&root, &contract, &receipt)
.expect("legacy report keeps a structurally valid receipt");
let legacy_error = verify_autonomous_playtest_evidence_files_at(&root, &receipt)
.expect_err("legacy weak assertion set must fail current playtest verification");
assert!(
legacy_error.contains("未通过"),
"unexpected error: {legacy_error}"
);
receipt.scenario_fingerprint =
"a6ac4da3a698881b175f754ee4660d5ebc8b3486e357c2ab1e5df2add3d3349a".to_string();
receipt.receipt_fingerprint = autonomous_playtest_receipt_fingerprint(&receipt);
let error = validate_autonomous_playtest_receipt(&root, &contract, &receipt)
.expect_err("previous generic-v1 scenario fingerprint must fail closed");
assert!(error.contains("场景指纹"), "unexpected error: {error}");
}
#[test]
fn stale_scenario_receipt_reads_as_missing_and_can_be_replaced() {
let (_temporary, root, state, contract) = autonomous_fixture(
"做一个完整小游戏",
"autonomous-recover-stale-playtest-fingerprint-run",
);
let revision = advance_game_index_revision(
&root,
&state,
"<!doctype html><title>可恢复试玩</title><canvas></canvas>",
);
let result =
browser_result_fixture(&root, &state, revision, BrowserPlaytestScenario::GenericV1);
let action = AgentRuntimeToolAction {
tool: "preview.validate".to_string(),
reason: Some("验证可恢复试玩回执".to_string()),
input: serde_json::json!({}),
};
let action_fingerprint = agent_runtime_tool_action_fingerprint(&action, &state.current_task);
let action_id = agent_runtime_tool_action_id(&state.run_id, 1, 0, 1, &action_fingerprint);
let current = write_autonomous_playtest_receipt_at(
&root,
&contract,
&action_id,
&action_fingerprint,
revision,
&result,
)
.expect("persist current autonomous playtest receipt");
let mut stale = current.clone();
stale.scenario_fingerprint =
"b48e3189a0765d82b84b56ce88cff8d05db7d1c0cc218a5d068e6470b83010d3".to_string();
stale.receipt_fingerprint = autonomous_playtest_receipt_fingerprint(&stale);
write_agent_runtime_json_sidecar(
&root,
&autonomous_playtest_receipt_relative_path(&contract.agent_id, &contract.run_id),
"旧自主试玩回执 fixture",
&stale,
)
.expect("persist stale autonomous playtest receipt fixture");
assert!(
read_autonomous_playtest_receipt(&root, &contract)
.expect("stale scenario fingerprint is recoverable")
.is_none(),
"stale scenario receipt must behave like missing evidence"
);
let receipt_path =
autonomous_playtest_receipt_relative_path(&contract.agent_id, &contract.run_id);
let mut digest_tampered = stale.clone();
digest_tampered.report.sha256 = "0".repeat(64);
write_agent_runtime_json_sidecar(
&root,
&receipt_path,
"摘要篡改自主试玩回执 fixture",
&digest_tampered,
)
.expect("persist digest-tampered receipt fixture");
assert!(
read_autonomous_playtest_receipt(&root, &contract).is_err(),
"digest tampering must remain a hard read error"
);
let mut binding_tampered = stale.clone();
binding_tampered.run_profile_binding_fingerprint = "1".repeat(64);
binding_tampered.receipt_fingerprint =
autonomous_playtest_receipt_fingerprint(&binding_tampered);
write_agent_runtime_json_sidecar(
&root,
&receipt_path,
"绑定篡改自主试玩回执 fixture",
&binding_tampered,
)
.expect("persist binding-tampered receipt fixture");
assert!(
read_autonomous_playtest_receipt(&root, &contract).is_err(),
"binding tampering must remain a hard read error"
);
write_agent_runtime_json_sidecar(&root, &receipt_path, "旧自主试玩回执 fixture", &stale)
.expect("restore stale autonomous playtest receipt fixture");
assert!(read_autonomous_playtest_receipt(&root, &contract)
.expect("restored stale scenario fingerprint is recoverable")
.is_none());
let replacement = write_autonomous_playtest_receipt_at(
&root,
&contract,
&action_id,
&action_fingerprint,
revision,
&result,
)
.expect("replace stale autonomous playtest receipt");
assert_eq!(
read_autonomous_playtest_receipt(&root, &contract)
.expect("read replacement autonomous playtest receipt"),
Some(replacement)
);
}
#[test]
fn delegated_autonomous_agent_inherits_parent_playtest_contract() {
let (_temporary, root, parent_state, _contract) = autonomous_fixture(
@@ -76,13 +76,131 @@ pub(in crate::agent) fn game_creator_agent_runtime_provider_error_with_transient
error,
retry_autonomous_upstream_400,
) {
Some(kind) => {
format!("{AGENT_RUNTIME_PROVIDER_TRANSIENT_ERROR_PREFIX}{kind}\n{public_error}")
Some(transient_kind) => {
let error_kind = match error {
platform_llm::LlmError::Upstream { status_code, .. } => {
format!("upstream-{status_code}")
}
_ => transient_kind.to_string(),
};
format!("{AGENT_RUNTIME_PROVIDER_TRANSIENT_ERROR_PREFIX}{error_kind}\n{public_error}")
}
None => public_error,
}
}
fn game_creator_agent_runtime_provider_upstream_http_status(error_kind: &str) -> Option<u16> {
let status = error_kind.strip_prefix("upstream-")?;
if status.len() != 3 || !status.bytes().all(|byte| byte.is_ascii_digit()) {
return None;
}
status
.parse::<u16>()
.ok()
.filter(|status| (100..=599).contains(status))
}
pub(in crate::agent) fn game_creator_agent_runtime_provider_retry_waiting_presentation(
retry: &AgentRuntimeProviderRetryRecord,
) -> (String, String) {
let current_action =
match game_creator_agent_runtime_provider_upstream_http_status(&retry.error_kind) {
Some(status) => format!(
"Provider 上游返回 HTTP {status},准备自动重试 {}/{}",
retry.next_attempt, retry.max_retries
),
None => format!(
"Provider 瞬态故障,准备自动重试 {}/{}",
retry.next_attempt, retry.max_retries
),
};
let remaining_ms = crate::provider_retry::remaining_ms(retry);
let remaining_seconds = game_creator_agent_runtime_provider_retry_wait_seconds(remaining_ms);
(current_action, format!("预计 {remaining_seconds} 秒后重试"))
}
fn game_creator_agent_runtime_provider_retry_exhausted_error(
public_error: &str,
retry_attempt: u32,
max_retries: u32,
) -> String {
format!(
"{public_error} retryAttempt={retry_attempt} maxRetries={max_retries} retryState=exhausted"
)
}
fn game_creator_agent_runtime_provider_retry_wait_seconds(remaining_ms: u64) -> u64 {
remaining_ms / 1_000 + u64::from(remaining_ms % 1_000 != 0)
}
fn game_creator_agent_runtime_exhausted_upstream_retry_fields(
error: &str,
) -> Option<(u16, u32, u32)> {
const KIND_PREFIX: &str = "kind=upstream-";
let start = error.rfind(KIND_PREFIX)?;
if let Some(boundary) = error[..start].chars().next_back() {
if !boundary.is_whitespace() && boundary != ':' && boundary != '' {
return None;
}
}
let mut fields = error[start..].split_ascii_whitespace();
let kind_status = fields.next()?.strip_prefix(KIND_PREFIX)?;
let http_status = fields.next()?.strip_prefix("httpStatus=")?;
let fingerprint = fields.next()?.strip_prefix("fingerprint=")?;
let chars = fields.next()?.strip_prefix("chars=")?;
let retry_attempt = fields.next()?.strip_prefix("retryAttempt=")?;
let max_retries = fields.next()?.strip_prefix("maxRetries=")?;
if fields.next()? != "retryState=exhausted" || fields.next().is_some() {
return None;
}
if kind_status.len() != 3
|| http_status.len() != 3
|| fingerprint.len() != 64
|| !fingerprint
.bytes()
.all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
|| chars.is_empty()
|| !chars.bytes().all(|byte| byte.is_ascii_digit())
{
return None;
}
let kind_status = kind_status.parse::<u16>().ok()?;
let http_status = http_status.parse::<u16>().ok()?;
let retry_attempt = retry_attempt.parse::<u32>().ok()?;
let max_retries = max_retries.parse::<u32>().ok()?;
if kind_status != http_status
|| !(500..=599).contains(&http_status)
|| retry_attempt != max_retries
{
return None;
}
Some((http_status, retry_attempt, max_retries))
}
pub(in crate::agent) fn game_creator_agent_runtime_failure_conversation_message(
agent_id: &str,
error: &str,
) -> String {
let subject = if agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID {
"项目总控 Agent"
} else {
"专业 Agent"
};
if let Some((http_status, retry_attempt, max_retries)) =
game_creator_agent_runtime_exhausted_upstream_retry_fields(error)
{
return format!(
"{subject} 上游服务返回 HTTP {http_status};自动重试已耗尽({retry_attempt}/{max_retries}"
);
}
if error.contains("kind=upstream-")
|| (error.contains("kind=") && error.contains(" fingerprint=") && error.contains(" chars="))
{
return format!("{subject} 服务请求失败,请稍后重试");
}
format!("{subject} 执行失败,请稍后重试")
}
pub(in crate::agent) fn append_game_creator_agent_runtime_provider_retry_audit(
root: &Path,
provider_snapshot: &AgentRuntimeProviderRequestSnapshot,
@@ -1097,7 +1215,11 @@ where
&provider_snapshot.agent_id,
&provider_snapshot.run_id,
)?;
return Err(public_error.to_string());
return Err(game_creator_agent_runtime_provider_retry_exhausted_error(
public_error,
attempt,
error_max_retries,
));
}
let control_lock = acquire_game_creator_agent_runtime_project_write_lock_with_wait(
root,
@@ -1232,7 +1354,11 @@ pub(in crate::agent) async fn request_game_creator_agent_runtime_llm_with_transi
max_retries
};
if attempt >= error_max_retries {
return Err(public_error.to_string());
return Err(game_creator_agent_runtime_provider_retry_exhausted_error(
public_error,
attempt,
error_max_retries,
));
}
let retry_attempt = attempt.saturating_add(1);
let backoff_ms = append_game_creator_agent_runtime_provider_retry_audit(
@@ -1432,4 +1558,122 @@ mod tests {
);
}
}
#[test]
fn retryable_upstream_503_keeps_generic_classification_and_specific_safe_code() {
let secret = ["sk", "provider-body-secret"].join("-");
let error = platform_llm::LlmError::Upstream {
status_code: 503,
message: format!(
"unavailable url=https://provider.example/private?token={secret} path=C:\\private\\provider.txt"
),
};
assert_eq!(
game_creator_agent_runtime_transient_provider_error_kind(&error, false),
Some("upstream-5xx")
);
let encoded = game_creator_agent_runtime_provider_error_with_transient_kind(
&error,
"agentLlm.project-supervisor",
"规划",
false,
);
let encoded = encoded
.strip_prefix(AGENT_RUNTIME_PROVIDER_TRANSIENT_ERROR_PREFIX)
.expect("retryable error prefix");
let (error_kind, public_error) = encoded.split_once('\n').expect("encoded public error");
assert_eq!(error_kind, "upstream-503");
assert!(public_error.contains("kind=upstream-503 httpStatus=503 fingerprint="));
assert!(public_error.contains(" chars="));
for forbidden in [
"provider.example",
"token=",
secret.as_str(),
"C:\\private\\provider.txt",
"unavailable",
] {
assert!(
!public_error.contains(forbidden),
"public Provider error leaked {forbidden}"
);
}
}
#[test]
fn provider_retry_exhausted_error_has_stable_machine_fields() {
let error = game_creator_agent_runtime_provider_retry_exhausted_error(
"kind=upstream-503 httpStatus=503 fingerprint=abc chars=42",
3,
3,
);
assert_eq!(
error,
"kind=upstream-503 httpStatus=503 fingerprint=abc chars=42 retryAttempt=3 maxRetries=3 retryState=exhausted"
);
assert_eq!(
game_creator_agent_runtime_provider_upstream_http_status("upstream-503"),
Some(503)
);
assert_eq!(
game_creator_agent_runtime_provider_upstream_http_status("upstream-5xx"),
None
);
assert_eq!(
game_creator_agent_runtime_provider_upstream_http_status(
"upstream-503 malicious provider body"
),
None
);
assert_eq!(game_creator_agent_runtime_provider_retry_wait_seconds(0), 0);
assert_eq!(game_creator_agent_runtime_provider_retry_wait_seconds(1), 1);
assert_eq!(
game_creator_agent_runtime_provider_retry_wait_seconds(1_000),
1
);
assert_eq!(
game_creator_agent_runtime_provider_retry_wait_seconds(1_001),
2
);
assert_eq!(
game_creator_agent_runtime_provider_retry_wait_seconds(30_001),
31
);
let canonical = format!(
"agentLlm.project-supervisor 调用 LLM 失败:kind=upstream-503 httpStatus=503 fingerprint={} chars=42 retryAttempt=3 maxRetries=3 retryState=exhausted",
"a".repeat(64)
);
assert_eq!(
game_creator_agent_runtime_failure_conversation_message(
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
&canonical,
),
"项目总控 Agent 上游服务返回 HTTP 503;自动重试已耗尽(3/3)"
);
assert_eq!(
game_creator_agent_runtime_failure_conversation_message("design-director", &canonical),
"专业 Agent 上游服务返回 HTTP 503;自动重试已耗尽(3/3)"
);
let malicious = format!("{canonical} provider body https://secret.example?api_key=sk-x");
let visible = game_creator_agent_runtime_failure_conversation_message(
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
&malicious,
);
assert_eq!(visible, "项目总控 Agent 服务请求失败,请稍后重试");
assert!(!visible.contains("secret.example"));
assert!(!visible.contains("sk-x"));
let unrelated_private_error =
"failed path=C:\\Users\\victim\\private.txt <absolute-path> [redacted-secret]";
let unrelated_visible = game_creator_agent_runtime_failure_conversation_message(
"design-director",
unrelated_private_error,
);
assert_eq!(unrelated_visible, "专业 Agent 执行失败,请稍后重试");
assert!(!unrelated_visible.contains("victim"));
assert!(!unrelated_visible.contains("absolute-path"));
assert!(!unrelated_visible.contains("redacted-secret"));
}
}
@@ -283,17 +283,36 @@ impl AgentRuntimeRealE2eCheckpointAppData {
{
use std::os::windows::fs::OpenOptionsExt;
const FILE_SHARE_READ: u32 = 0x0000_0001;
const FILE_SHARE_DELETE: u32 = 0x0000_0004;
const FILE_FLAG_OPEN_REPARSE_POINT: u32 = 0x0020_0000;
let path = self.path.join(name);
let file = fs::OpenOptions::new()
.read(true)
.write(true)
.create_new(true)
// The ACK protocol reads the completed temp file through a second handle and
// atomically renames it while this validated handle remains live. Deny writers,
// but permit those two operations so the handle continues to pin file identity.
.share_mode(FILE_SHARE_READ | FILE_SHARE_DELETE)
.custom_flags(FILE_FLAG_OPEN_REPARSE_POINT)
.open(&path)
.map_err(|_| ())?;
secure_windows_game_creator_path_for_current_user(&path, false, true)
.map_err(|_| ())?;
let secured = (|| {
crate::runner::validate_windows_regular_file_handle(&file, "real E2E 私有文件")
.map_err(|_| ())?;
crate::initialize_windows_game_creator_file_owner_for_current_user(&path)
.map_err(|_| ())?;
crate::runner::validate_windows_regular_file_handle(&file, "real E2E 私有文件")
.map_err(|_| ())?;
secure_windows_game_creator_path_for_current_user(&path, false, false)
.map_err(|_| ())
})();
if secured.is_err() {
drop(file);
let _ = fs::remove_file(path);
return Err(());
}
return Ok(file);
}
@@ -1068,9 +1068,8 @@ fn game_creator_agent_runtime_failure_metadata(error: &str) -> (String, usize) {
)
}
fn game_creator_agent_runtime_public_failure_detail(error: &str) -> String {
let (error_sha256, error_chars) = game_creator_agent_runtime_failure_metadata(error);
format!("errorSha256={error_sha256} · errorChars={error_chars}")
fn game_creator_agent_runtime_public_failure_detail(agent_id: &str, error: &str) -> String {
game_creator_agent_runtime_failure_conversation_message(agent_id, error)
}
pub(crate) fn append_game_creator_agent_background_task_failed_audit(
@@ -2334,7 +2333,10 @@ pub(super) fn append_game_creator_agent_runtime_event_with_action(
event_type,
"error" | "turn.failed" | "turn.budget_exhausted"
) {
return game_creator_agent_runtime_public_failure_detail(value);
return game_creator_agent_runtime_public_failure_detail(
&state.agent_id,
value,
);
}
let max_chars = if event_type == "observation"
&& summary.starts_with("agent.action_history")
File diff suppressed because it is too large Load Diff
@@ -13,6 +13,16 @@ use super::model::{
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_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,
};
pub(super) use lane_defense::{lane_enemy_state_changes, LaneBattleProgress};
pub(super) const PLAYABLE_GAME_STATE_SCHEMA_VERSION: &str = "playable-web-game-state.v1";
@@ -24,12 +34,21 @@ const PLAYTEST_POLL_INTERVAL: Duration = Duration::from_millis(50);
const GENERIC_PLAYTEST_ASSERTIONS: &[&str] = &[
"state-surface-valid",
"initial-phase-ready",
"level-positive",
"start-control-clicked",
"start-sequence-advanced",
"start-phase-playing-or-won",
"start-phase-playing",
"start-opportunity-stable-playing",
"primary-action-control-clicked",
"primary-action-sequence-advanced",
"primary-action-phase-playing-or-terminal",
"primary-action-outcome-valid",
"non-loss-progression-observed",
"restart-control-clicked",
"restart-sequence-advanced",
"restart-phase-ready-or-playing",
"restart-phase-stable-ready-or-playing",
];
const LANE_DEFENSE_PLAYTEST_ASSERTIONS: &[&str] = &[
@@ -111,7 +130,7 @@ pub(super) struct PlaytestPollOutcome {
}
impl BrowserPlaytestScenario {
pub(super) fn assertion_names(self) -> &'static [&'static str] {
pub(crate) fn assertion_names(self) -> &'static [&'static str] {
match self {
Self::GenericV1 => GENERIC_PLAYTEST_ASSERTIONS,
Self::LaneDefenseV1 => LANE_DEFENSE_PLAYTEST_ASSERTIONS,
@@ -191,9 +210,20 @@ impl BrowserPlaytestResult {
failed_assertions.join("")
));
}
self.passed = browser_playtest_assertions_passed(&self.assertions, &self.diagnostics);
self.passed = self.matches_scenario_contract();
self
}
pub(crate) fn matches_scenario_contract(&self) -> bool {
let required = self.scenario.assertion_names();
self.assertions.len() == required.len()
&& self
.assertions
.iter()
.zip(required)
.all(|(assertion, required_name)| assertion.name == *required_name)
&& browser_playtest_assertions_passed(&self.assertions, &self.diagnostics)
}
}
pub(super) fn browser_playtest_assertions_passed(
@@ -241,7 +271,44 @@ pub(crate) fn browser_playtest_scenario_fingerprint(scenario: BrowserPlaytestSce
match scenario {
BrowserPlaytestScenario::GenericV1 => {
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);
update_playtest_fingerprint_component(
&mut hasher,
&format!(
concat!(
"startOpportunityWindowMs={}\n",
"postActionWindowMs={}\n",
"restartStabilityWindowMs={}\n",
"startOpportunityPhase=playing\n",
"postActionPhase=playing|won|lost\n",
"firstPostActionTerminal=won|lost-early-outcome\n",
"restartStablePhase=ready|playing\n",
"firstLostRetry=after-restart-stability\n",
"retryReadyFlow=start-then-opportunity-window\n",
"retryPlayingFlow=additional-opportunity-window\n",
"retryPostActionPhase=playing|won\n",
"retryWon=early-success\n",
"retryPlaying=full-post-action-window\n",
"doubleControlledLost=fixed-failure\n",
"nonLossProgression=required\n",
"stabilitySequence=monotonic\n",
"windowEndSample=forced\n",
"maxFinalSampleGapMs={}\n",
"startOpportunityMinSamples={}\n",
"postActionMinSamplesIfPlaying={}\n",
"restartStabilityMinSamples={}\n",
"primaryActionSequence=strict-advance"
),
GENERIC_PLAYTEST_START_OPPORTUNITY_WINDOW.as_millis(),
GENERIC_PLAYTEST_POST_ACTION_WINDOW.as_millis(),
GENERIC_PLAYTEST_RESTART_STABILITY_WINDOW.as_millis(),
GENERIC_PLAYTEST_MAX_FINAL_SAMPLE_GAP.as_millis(),
GENERIC_PLAYTEST_START_OPPORTUNITY_MIN_STABILITY_SAMPLES,
GENERIC_PLAYTEST_POST_ACTION_MIN_STABILITY_SAMPLES,
GENERIC_PLAYTEST_RESTART_MIN_STABILITY_SAMPLES
),
);
}
BrowserPlaytestScenario::LaneDefenseV1 => {
for selector in [
@@ -330,6 +397,7 @@ pub(super) const PROBE_PLAYTEST_CONTROL_SCRIPT: &str = r#"function() {
}"#;
pub(super) const PLAYTEST_START_SELECTOR: &str = r#"[data-playtest-id="start"]"#;
pub(super) const PLAYTEST_PRIMARY_ACTION_SELECTOR: &str = r#"[data-playtest-id="primary-action"]"#;
pub(super) const PLAYTEST_RESTART_SELECTOR: &str = r#"[data-playtest-id="restart"]"#;
pub(super) const PLAYTEST_DEFENDER_OPTION_SELECTOR: &str =
r#"[data-playtest-id="defender-option"]"#;
File diff suppressed because it is too large Load Diff
@@ -1316,11 +1316,7 @@ fn configure_project_command_process_group(command: &mut tokio::process::Command
}
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
const CREATE_NEW_PROCESS_GROUP: u32 = 0x0000_0200;
command
.as_std_mut()
.creation_flags(CREATE_NEW_PROCESS_GROUP);
crate::configure_windows_background_tokio_command(command, true);
}
}
@@ -1501,13 +1497,16 @@ async fn request_project_command_process_group_termination(
if !taskkill.is_absolute() || !taskkill.is_file() {
return Err("请求终止受控进程组失败:taskkill.exe 不是绝对普通文件".to_string());
}
let status = tokio::process::Command::new(taskkill)
let mut command = tokio::process::Command::new(taskkill);
command
.args(["/PID", &process_id.to_string(), "/T", "/F"])
.env_clear()
.env("SystemRoot", &system_root)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.stderr(Stdio::null());
crate::configure_windows_background_tokio_command(&mut command, false);
let status = command
.status()
.await
.map_err(|error| format!("请求终止受控进程组失败:启动 taskkill.exe 失败:{error}"))?;
@@ -368,6 +368,7 @@ pub(crate) fn game_creator_llm_reasoning_effort_name(
fn validate_game_creator_runtime_config_dir_metadata(
path: &Path,
tighten: bool,
initialize_windows_owner: bool,
) -> Result<(), String> {
let metadata = fs::symlink_metadata(path).map_err(|error| {
format!(
@@ -382,6 +383,7 @@ fn validate_game_creator_runtime_config_dir_metadata(
#[cfg(unix)]
{
use std::os::unix::fs::{MetadataExt, PermissionsExt};
let _ = initialize_windows_owner;
// SAFETY: geteuid takes no arguments and has no memory safety preconditions.
let effective_user_id = unsafe { libc::geteuid() };
@@ -418,7 +420,12 @@ fn validate_game_creator_runtime_config_dir_metadata(
if metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 {
return Err("客户端 AppData 配置目录不能是 Windows reparse point".to_string());
}
secure_windows_game_creator_path_for_current_user(path, true, tighten)?;
secure_windows_game_creator_path_for_current_user_with_owner_policy(
path,
true,
tighten,
initialize_windows_owner,
)?;
}
#[cfg(not(any(unix, windows)))]
@@ -437,24 +444,155 @@ fn resolve_game_creator_runtime_config_dir(
if !path.is_absolute() {
return Err("客户端 AppData 配置目录必须是绝对路径".to_string());
}
let mut created = false;
if create_and_tighten {
fs::create_dir_all(path).map_err(|error| {
format!(
"创建客户端 AppData 配置目录失败:{}: {error}",
path.display()
)
})?;
match fs::symlink_metadata(path) {
Ok(_) => {}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).map_err(|create_error| {
format!(
"创建客户端 AppData 配置父目录失败:{}: {create_error}",
parent.display()
)
})?;
}
match fs::create_dir(path) {
Ok(()) => created = true,
// 与其他启动进程竞争时,不把对方创建的目录误判为本进程的新对象。
Err(create_error)
if create_error.kind() == std::io::ErrorKind::AlreadyExists => {}
Err(create_error) => {
return Err(format!(
"创建客户端 AppData 配置目录失败:{}: {create_error}",
path.display()
));
}
}
}
Err(error) => {
return Err(format!(
"检查客户端 AppData 配置目录失败:{}: {error}",
path.display()
));
}
}
}
// canonicalize 会跟随目录链接,因此必须先检查用户给出的目录项本身。
validate_game_creator_runtime_config_dir_entry_type(path)?;
let canonical = fs::canonicalize(path).map_err(|error| {
format!(
"解析客户端 AppData 配置目录失败:{}: {error}",
path.display()
)
})?;
validate_game_creator_runtime_config_dir_metadata(&canonical, create_and_tighten)?;
match validate_game_creator_runtime_config_dir_metadata(&canonical, create_and_tighten, created)
{
Ok(()) => {}
#[cfg(windows)]
Err(error)
if create_and_tighten
&& !created
&& error.starts_with("Windows 安全对象不属于当前用户:") =>
{
let backup = migrate_windows_foreign_owner_config_dir(path)?;
fs::create_dir(path).map_err(|create_error| {
format!(
"旧 AppData 配置已安全保留在 {},但重新创建当前用户配置目录失败:{}: {create_error}",
backup.display(),
path.display()
)
})?;
validate_game_creator_runtime_config_dir_metadata(path, true, true).map_err(
|validation_error| {
format!(
"旧 AppData 配置已安全保留在 {},但新配置目录安全初始化失败:{validation_error}",
backup.display()
)
},
)?;
return fs::canonicalize(path).map_err(|canonicalize_error| {
format!(
"旧 AppData 配置已安全保留在 {},但解析新配置目录失败:{}: {canonicalize_error}",
backup.display(),
path.display()
)
});
}
Err(error) => return Err(error),
}
Ok(canonical)
}
fn validate_game_creator_runtime_config_dir_entry_type(path: &Path) -> Result<(), String> {
let metadata = fs::symlink_metadata(path).map_err(|error| {
format!(
"读取客户端 AppData 配置目录元数据失败:{}: {error}",
path.display()
)
})?;
if metadata.file_type().is_symlink() || !metadata.is_dir() {
return Err("客户端 AppData 配置目录必须是普通目录,不能是链接或其他文件".to_string());
}
#[cfg(windows)]
{
use std::os::windows::fs::MetadataExt;
const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x0000_0400;
if metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 {
return Err("客户端 AppData 配置目录不能是 Windows reparse point".to_string());
}
}
Ok(())
}
#[cfg(windows)]
fn migrate_windows_foreign_owner_config_dir(path: &Path) -> Result<PathBuf, String> {
validate_game_creator_runtime_config_dir_entry_type(path)?;
let parent = path.parent().ok_or_else(|| {
format!(
"AppData 配置目录没有可用于安全迁移的父目录:{}",
path.display()
)
})?;
let name = path
.file_name()
.ok_or_else(|| format!("AppData 配置目录名称无效,无法安全迁移:{}", path.display()))?;
let timestamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis();
for attempt in 0..100_u32 {
let backup = parent.join(format!(
"{}.owner-mismatch-backup-{timestamp}-{}-{attempt}",
name.to_string_lossy(),
std::process::id()
));
match fs::symlink_metadata(&backup) {
Ok(_) => continue,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
Err(error) => {
return Err(format!(
"检查旧 AppData 配置备份路径失败:{}: {error}",
backup.display()
));
}
}
// 同一父目录内 rename 是原子目录项替换;目标已确认不存在,旧配置不会被覆盖。
fs::rename(path, &backup).map_err(|error| {
format!(
"AppData 配置目录 owner 不匹配,无法安全迁移。请保留并手动恢复 {};计划备份路径为 {}{error}",
path.display(),
backup.display()
)
})?;
return Ok(backup);
}
Err(format!(
"AppData 配置目录 owner 不匹配,但无法找到不冲突的备份路径;请手动保留并恢复 {}",
path.display()
))
}
pub(crate) fn prepare_game_creator_runtime_config_dir(path: &Path) -> Result<PathBuf, String> {
resolve_game_creator_runtime_config_dir(path, true)
}
@@ -480,6 +618,28 @@ pub(crate) fn secure_windows_game_creator_path_for_current_user(
path: &Path,
is_directory: bool,
tighten: bool,
) -> Result<(), String> {
secure_windows_game_creator_path_for_current_user_with_owner_policy(
path,
is_directory,
tighten,
false,
)
}
#[cfg(windows)]
pub(crate) fn initialize_windows_game_creator_file_owner_for_current_user(
path: &Path,
) -> Result<(), String> {
secure_windows_game_creator_path_for_current_user_with_owner_policy(path, false, true, true)
}
#[cfg(windows)]
fn secure_windows_game_creator_path_for_current_user_with_owner_policy(
path: &Path,
is_directory: bool,
tighten: bool,
initialize_owner: bool,
) -> Result<(), String> {
use std::ffi::c_void;
use std::os::windows::ffi::OsStrExt;
@@ -661,6 +821,41 @@ pub(crate) fn secure_windows_game_creator_path_for_current_user(
.encode_wide()
.chain(std::iter::once(0))
.collect::<Vec<_>>();
let mut initial_owner = std::ptr::null_mut();
let mut initial_descriptor = std::ptr::null_mut();
// 先验证 owner,再修改 DACL,避免对其他用户持有的旧配置做任何权限变更。
let owner_status = unsafe {
GetNamedSecurityInfoW(
wide_path.as_mut_ptr(),
SE_FILE_OBJECT,
OWNER_SECURITY_INFORMATION,
&mut initial_owner,
std::ptr::null_mut(),
std::ptr::null_mut(),
std::ptr::null_mut(),
&mut initial_descriptor,
)
};
if owner_status != 0 || initial_owner.is_null() || initial_descriptor.is_null() {
if !initial_descriptor.is_null() {
unsafe { LocalFree(initial_descriptor) };
}
return Err(format!(
"读取 Windows owner 失败:{}: error {owner_status}",
path.display()
));
}
let owner_matches = unsafe { IsValidSid(initial_owner) } != 0
&& unsafe { EqualSid(initial_owner, current_user_sid) } != 0;
unsafe { LocalFree(initial_descriptor) };
if !owner_matches {
if !(initialize_owner && tighten) {
return Err(format!(
"Windows 安全对象不属于当前用户:{}",
path.display()
));
}
}
if tighten {
let mut entry = ExplicitAccessW {
access_permissions: FILE_ALL_ACCESS,
@@ -693,8 +888,18 @@ pub(crate) fn secure_windows_game_creator_path_for_current_user(
SetNamedSecurityInfoW(
wide_path.as_mut_ptr(),
SE_FILE_OBJECT,
DACL_SECURITY_INFORMATION | PROTECTED_DACL_SECURITY_INFORMATION,
std::ptr::null_mut(),
DACL_SECURITY_INFORMATION
| PROTECTED_DACL_SECURITY_INFORMATION
| if initialize_owner {
OWNER_SECURITY_INFORMATION
} else {
0
},
if initialize_owner {
current_user_sid
} else {
std::ptr::null_mut()
},
std::ptr::null_mut(),
private_dacl,
std::ptr::null_mut(),
@@ -704,7 +909,7 @@ pub(crate) fn secure_windows_game_creator_path_for_current_user(
unsafe { LocalFree(private_dacl) };
if set_status != 0 {
return Err(format!(
"收紧 Windows 当前用户私有 DACL 失败:{}: error {set_status}",
"初始化 Windows 当前用户 owner/私有 DACL 失败:{}: error {set_status}",
path.display()
));
}
@@ -2263,6 +2263,7 @@ fn build_sandboxed_git_command(
let null_device = if cfg!(windows) { "NUL" } else { "/dev/null" };
let sandbox = context.sandbox.path();
let mut command = Command::new(&context.executable);
crate::configure_windows_background_std_command(&mut command, false);
command.env_clear();
for key in ["SystemRoot", "WINDIR", "PATHEXT"] {
if let Some(value) = std::env::var_os(key) {
File diff suppressed because it is too large Load Diff
@@ -631,6 +631,7 @@ fn apply_game_creator_mcp_platform_environment(command: &mut tokio::process::Com
command.env(name, value);
}
}
crate::configure_windows_background_tokio_command(command, false);
}
#[cfg(not(windows))]

Some files were not shown because too many files have changed in this diff Show More