diff --git a/apps/ai-game-creator-shell/package.json b/apps/ai-game-creator-shell/package.json
index e2982bbc3..bc54417c2 100644
--- a/apps/ai-game-creator-shell/package.json
+++ b/apps/ai-game-creator-shell/package.json
@@ -5,6 +5,7 @@
"type": "module",
"scripts": {
"dev": "node scripts/start-tauri-dev.mjs",
+ "dev:app-run": "node scripts/start-tauri-dev.mjs --app-run",
"game-chat": "node scripts/start-tauri-dev.mjs --game-chat",
"dev-server": "node scripts/start-dev-server.mjs",
"dev-stack": "node scripts/start-dev-stack.mjs",
diff --git a/apps/ai-game-creator-shell/scripts/check-config.mjs b/apps/ai-game-creator-shell/scripts/check-config.mjs
index 91e8f5e2d..cf0288064 100644
--- a/apps/ai-game-creator-shell/scripts/check-config.mjs
+++ b/apps/ai-game-creator-shell/scripts/check-config.mjs
@@ -27,6 +27,12 @@ const tauriConfig = JSON.parse(
'utf8',
),
);
+const appRunTauriConfig = JSON.parse(
+ fs.readFileSync(
+ new URL('../src-tauri/tauri.app-run-dev.conf.json', import.meta.url),
+ 'utf8',
+ ),
+);
const gameChatReleaseTauriConfig = JSON.parse(
fs.readFileSync(
new URL('../src-tauri/tauri.game-chat-release.conf.json', import.meta.url),
@@ -1263,6 +1269,33 @@ if (packageConfig.scripts?.dev !== 'node scripts/start-tauri-dev.mjs') {
);
}
+if (
+ packageConfig.scripts?.['dev:app-run'] !==
+ 'node scripts/start-tauri-dev.mjs --app-run' ||
+ rootPackageConfig.scripts?.['agc:app-run'] !==
+ 'npm --prefix apps/ai-game-creator-shell run dev:app-run'
+) {
+ throw new Error(
+ 'AI game creator app-run dev profile must use the managed Tauri dev launcher',
+ );
+}
+
+const appRunWindow = appRunTauriConfig.app?.windows?.[0];
+if (
+ appRunTauriConfig.productName !==
+ 'Genarrative AI Game Creator App Run' ||
+ appRunTauriConfig.identifier !==
+ 'world.genarrative.ai-game-creator.app-run' ||
+ appRunTauriConfig.identifier === tauriConfig.identifier ||
+ appRunTauriConfig.build?.devUrl !== 'http://127.0.0.1:3081/' ||
+ appRunWindow?.label !== 'client' ||
+ appRunWindow?.title !== 'AI 游戏创作 · App Run'
+) {
+ throw new Error(
+ 'AI game creator app-run profile must keep an independent identity, title, and Vite port',
+ );
+}
+
if (
packageConfig.scripts?.['game-chat'] !==
'node scripts/start-tauri-dev.mjs --game-chat'
diff --git a/apps/ai-game-creator-shell/scripts/start-dev-stack.mjs b/apps/ai-game-creator-shell/scripts/start-dev-stack.mjs
index 562e9baaf..6d15e497a 100644
--- a/apps/ai-game-creator-shell/scripts/start-dev-stack.mjs
+++ b/apps/ai-game-creator-shell/scripts/start-dev-stack.mjs
@@ -8,17 +8,55 @@ import { fileURLToPath } from 'node:url';
const appRoot = fileURLToPath(new URL('..', import.meta.url));
const repoRoot = resolve(appRoot, '../..');
const devStackStatePath = resolve(repoRoot, '.app/dev-stack.json');
-const viteHost = '127.0.0.1';
-const vitePort = 3080;
+const devProfileName = process.env.GENARRATIVE_AGC_DEV_PROFILE || 'default';
+
+function resolveDevStackProfile(name = 'default') {
+ switch (name) {
+ case 'default':
+ return {
+ name,
+ viteHost: '127.0.0.1',
+ vitePort: 3080,
+ apiPort: 8082,
+ bgfilterWorkerPort: 8083,
+ spacetimePort: 3101,
+ backendDatabase: 'genarrative-game-creator-dev',
+ backendSpacetimeDataDir: resolve(
+ repoRoot,
+ 'server-rs/.spacetimedb/ai-game-creator/data',
+ ),
+ };
+ case 'app-run':
+ return {
+ name,
+ viteHost: '127.0.0.1',
+ vitePort: 3081,
+ apiPort: 8084,
+ bgfilterWorkerPort: 8085,
+ spacetimePort: 3103,
+ backendDatabase: 'genarrative-game-creator-app-run-dev',
+ backendSpacetimeDataDir: resolve(
+ repoRoot,
+ 'server-rs/.spacetimedb/ai-game-creator-app-run/data',
+ ),
+ };
+ default:
+ throw new Error(`未知 AI 游戏创作开发 profile: ${name}`);
+ }
+}
+
+const devProfile = resolveDevStackProfile(devProfileName);
+const {
+ viteHost,
+ vitePort,
+ apiPort,
+ spacetimePort,
+ backendDatabase,
+ backendSpacetimeDataDir,
+} = devProfile;
const viteUrl = `http://${viteHost}:${vitePort}/`;
-const viteMarkerUrl = `${viteUrl}__agc_dev_server.json`;
const defaultApiTarget =
- process.env.RUST_SERVER_TARGET || 'http://127.0.0.1:8082';
-const backendDatabase = 'genarrative-game-creator-dev';
-const backendSpacetimeDataDir = resolve(
- repoRoot,
- 'server-rs/.spacetimedb/ai-game-creator/data',
-);
+ process.env.RUST_SERVER_TARGET || `http://127.0.0.1:${apiPort}`;
const npm = process.platform === 'win32' ? 'npm.cmd' : 'npm';
const childLifecycles = new WeakMap();
@@ -72,6 +110,7 @@ function resolveBackendTargetsFromState(
expectedDatabase = backendDatabase,
expectedSpacetimeDataDir = backendSpacetimeDataDir,
fallbackApiTarget = defaultApiTarget,
+ fallbackSpacetimeTarget = `http://127.0.0.1:${spacetimePort}`,
} = {},
) {
const apiServer = state?.services?.['api-server'];
@@ -100,7 +139,7 @@ function resolveBackendTargetsFromState(
? spacetime.url
: requireAgcBackend
? ''
- : 'http://127.0.0.1:3101';
+ : fallbackSpacetimeTarget;
return {
apiUrl,
spacetimeUrl,
@@ -131,13 +170,24 @@ async function isBackendReady() {
);
}
-async function readExistingViteServer() {
- return httpGetText(viteUrl);
+function resolveViteProfileUrls(profile = devProfile) {
+ const profileViteUrl = `http://${profile.viteHost}:${profile.vitePort}/`;
+ return {
+ viteUrl: profileViteUrl,
+ viteMarkerUrl: `${profileViteUrl}__agc_dev_server.json`,
+ };
}
-function isVitePortListening() {
+async function readExistingViteServer(profile = devProfile) {
+ return httpGetText(resolveViteProfileUrls(profile).viteUrl);
+}
+
+function isVitePortListening(profile = devProfile) {
return new Promise((resolveRequest) => {
- const socket = net.connect({ host: viteHost, port: vitePort });
+ const socket = net.connect({
+ host: profile.viteHost,
+ port: profile.vitePort,
+ });
socket.once('connect', () => {
socket.destroy();
resolveRequest(true);
@@ -160,8 +210,11 @@ function isAiGameCreatorServer(response) {
);
}
-async function readExistingViteMarker() {
- const response = await httpGetText(viteMarkerUrl, 2000);
+async function readExistingViteMarker(profile = devProfile) {
+ const response = await httpGetText(
+ resolveViteProfileUrls(profile).viteMarkerUrl,
+ 2000,
+ );
if (!response || response.statusCode !== 200) {
return null;
}
@@ -173,15 +226,17 @@ async function readExistingViteMarker() {
}
async function preflightExistingVite({
- readServer = readExistingViteServer,
- portListening = isVitePortListening,
- readMarker = readExistingViteMarker,
+ profile = devProfile,
+ readServer = () => readExistingViteServer(profile),
+ portListening = () => isVitePortListening(profile),
+ readMarker = () => readExistingViteMarker(profile),
} = {}) {
+ const { viteUrl: profileViteUrl } = resolveViteProfileUrls(profile);
const existing = await readServer();
if (!existing) {
if (await portListening()) {
throw new Error(
- `${viteUrl} is already in use by a non-HTTP or unrecognized server. Stop it before starting Tauri dev.`,
+ `${profileViteUrl} is already in use by a non-HTTP or unrecognized server. Stop it before starting Tauri dev.`,
);
}
return { status: 'available', apiTarget: '' };
@@ -189,7 +244,7 @@ async function preflightExistingVite({
if (!isAiGameCreatorServer(existing)) {
throw new Error(
- `${viteUrl} is already in use by another server. Stop it before starting Tauri dev.`,
+ `${profileViteUrl} is already in use by another server. Stop it before starting Tauri dev.`,
);
}
@@ -202,7 +257,7 @@ async function preflightExistingVite({
: '';
const actualTarget = markerApiTarget || 'unknown';
throw new Error(
- `${viteUrl} is already running with API target ${actualTarget}. Its owning worktree cannot be proven, so it will not be reused. Stop that Vite dev server before starting Tauri dev.`,
+ `${profileViteUrl} is already running with API target ${actualTarget}. Its owning worktree cannot be proven, so it will not be reused. Stop that Vite dev server before starting Tauri dev.`,
);
}
@@ -507,27 +562,50 @@ async function waitForBackendReady(backendChild, timeoutMs = 600_000) {
throw new Error('等待配套后端和数据库启动超时');
}
+function buildBackendStartArguments(profile = devProfile) {
+ return [
+ '--prefix',
+ '../..',
+ 'run',
+ 'agc:backend',
+ '--',
+ '--database',
+ profile.backendDatabase,
+ '--spacetime-data-dir',
+ profile.backendSpacetimeDataDir,
+ '--api-port',
+ String(profile.apiPort),
+ '--bgfilter-worker-port',
+ String(profile.bgfilterWorkerPort),
+ '--spacetime-port',
+ String(profile.spacetimePort),
+ '--no-interactive',
+ ];
+}
+
+function buildViteStartArguments(profile = devProfile) {
+ return [
+ '--prefix',
+ '../..',
+ 'exec',
+ 'vite',
+ '--',
+ '--config',
+ 'vite.config.ts',
+ '--host',
+ profile.viteHost,
+ '--port',
+ String(profile.vitePort),
+ '--strictPort',
+ ];
+}
+
async function ensureBackend({
onBackendChild = () => {},
checkBackendReady = isBackendReady,
resolveTargets = readBackendTargets,
spawnBackend = () =>
- spawnChild(
- npm,
- [
- '--prefix',
- '../..',
- 'run',
- 'agc:backend',
- '--',
- '--database',
- backendDatabase,
- '--spacetime-data-dir',
- backendSpacetimeDataDir,
- '--no-interactive',
- ],
- { cwd: appRoot },
- ),
+ spawnChild(npm, buildBackendStartArguments(), { cwd: appRoot }),
waitUntilReady = waitForBackendReady,
} = {}) {
if (await checkBackendReady()) {
@@ -569,11 +647,7 @@ async function startVite(apiTarget) {
);
}
- return spawnChild(
- npm,
- ['--prefix', '../..', 'exec', 'vite', '--', '--config', 'vite.config.ts'],
- { cwd: appRoot },
- );
+ return spawnChild(npm, buildViteStartArguments(), { cwd: appRoot });
}
async function main() {
@@ -593,6 +667,9 @@ async function main() {
}
try {
+ console.log(
+ `[ai-game-creator-shell] dev profile ${devProfile.name}: vite=${viteUrl} api=${apiPort} spacetime=${spacetimePort}`,
+ );
await preflightExistingVite();
const backend = await ensureBackend({
onBackendChild(child) {
@@ -650,6 +727,8 @@ function isDirectModuleExecution() {
}
export {
+ buildBackendStartArguments,
+ buildViteStartArguments,
ensureBackend,
formatChildFailure,
isDirectModuleExecution,
@@ -658,6 +737,7 @@ export {
readChildFailure,
readLinuxProcessGroupAlive,
resolveBackendTargetsFromState,
+ resolveDevStackProfile,
runWindowsTaskkill,
spawnChild,
stopChild,
diff --git a/apps/ai-game-creator-shell/scripts/start-tauri-dev.mjs b/apps/ai-game-creator-shell/scripts/start-tauri-dev.mjs
index 5c6bba17e..6419a9f13 100644
--- a/apps/ai-game-creator-shell/scripts/start-tauri-dev.mjs
+++ b/apps/ai-game-creator-shell/scripts/start-tauri-dev.mjs
@@ -3,6 +3,7 @@ import { fileURLToPath } from 'node:url';
import {
preflightExistingVite,
+ resolveDevStackProfile,
spawnChild,
stopChild,
terminateChildTree,
@@ -15,24 +16,43 @@ const tauriCliPath = resolve(repoRoot, 'node_modules/@tauri-apps/cli/tauri.js');
function parseLauncherArguments(argv) {
const args = [...argv];
+ const appRun = args[0] === '--app-run';
+ if (appRun) {
+ args.shift();
+ }
const gameChat = args[0] === '--game-chat';
if (gameChat) {
args.shift();
}
- return { gameChat, args };
+ if (appRun && gameChat) {
+ throw new Error('app-run profile 不能与 game-chat 入口同时使用');
+ }
+ return { appRun, gameChat, args };
}
function buildTauriArguments(argv) {
- const { gameChat, args } = parseLauncherArguments(argv);
+ const { appRun, gameChat, args } = parseLauncherArguments(argv);
+ if (appRun) {
+ return [
+ 'dev',
+ '--config',
+ 'src-tauri/tauri.app-run-dev.conf.json',
+ ...args,
+ ];
+ }
if (gameChat) {
return ['dev', '--', '--', '--game-chat', ...args];
}
return ['dev', ...args];
}
-function spawnTauriCli(argv) {
+function spawnTauriCli(argv, { profileName = 'default' } = {}) {
return spawnChild(process.execPath, [tauriCliPath, ...argv], {
cwd: appRoot,
+ env: {
+ ...process.env,
+ GENARRATIVE_AGC_DEV_PROFILE: profileName,
+ },
shell: false,
});
}
@@ -46,10 +66,12 @@ async function runTauriDev(
terminateTree = terminateChildTree,
} = {},
) {
- await preflight();
+ const { appRun } = parseLauncherArguments(argv);
+ const profile = resolveDevStackProfile(appRun ? 'app-run' : 'default');
+ await preflight({ profile });
const tauriArguments = buildTauriArguments(argv);
- const child = spawnCli(tauriArguments);
+ const child = spawnCli(tauriArguments, { profileName: profile.name });
let resolveShutdown;
let shutdownSignal = '';
let repeatedSignal = false;
diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs
index 5c9613bdd..56603b1b0 100644
--- a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs
+++ b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs
@@ -5710,10 +5710,22 @@ mod canvas_generation_tests {
stream
.set_read_timeout(Some(Duration::from_secs(10)))
.expect("set request read timeout");
+ let deadline = std::time::Instant::now() + Duration::from_secs(10);
let mut bytes = Vec::new();
let mut buffer = [0_u8; 4096];
loop {
- let read = stream.read(&mut buffer).expect("read request bytes");
+ let read = match stream.read(&mut buffer) {
+ Ok(read) => read,
+ Err(error)
+ if matches!(
+ error.kind(),
+ std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut
+ ) && std::time::Instant::now() < deadline =>
+ {
+ continue;
+ }
+ Err(error) => panic!("read request bytes: {error}"),
+ };
if read == 0 {
break;
}
diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop_tests.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop_tests.rs
index 4ffb0d73f..a811f5085 100644
--- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop_tests.rs
+++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop_tests.rs
@@ -204,14 +204,16 @@ fn queue_game_chat_fast_path_child(
#[test]
fn autonomous_parent_waits_for_active_child_while_registered_derived_visuals_need_repair() {
- let root = std::env::temp_dir().join(format!(
- "genarrative-agent-main-loop-legacy-{}-{}",
- std::process::id(),
- std::time::SystemTime::now()
- .duration_since(std::time::UNIX_EPOCH)
- .expect("system clock")
- .as_nanos()
- ));
+ let root = fs::canonicalize(std::env::temp_dir())
+ .expect("canonicalize temporary root")
+ .join(format!(
+ "genarrative-agent-main-loop-legacy-{}-{}",
+ std::process::id(),
+ std::time::SystemTime::now()
+ .duration_since(std::time::UNIX_EPOCH)
+ .expect("system clock")
+ .as_nanos()
+ ));
init_local_game_project_at(&root, "legacy-derived-visuals", "旧派生视觉返工门禁")
.expect("project init");
assert!(!autonomous_registered_derived_visuals_need_repair_at(&root));
@@ -308,6 +310,14 @@ fn prepare_autonomous_completion_evidence(
("memory/project.md", "# 项目记忆\n\n正式约束。\n"),
("game/game_design.md", "# 游戏设计\n\n核心循环。\n"),
("game/balance.json", r#"{"lives":3,"speed":1}"#),
+ (
+ "game/tunable-parameters.json",
+ r#"{"schemaVersion":"game-creator-tunable-parameters.v1","parameters":[]}"#,
+ ),
+ (
+ "game/tunable-values.json",
+ r#"{"schemaVersion":"game-creator-tunable-values.v1","values":{}}"#,
+ ),
(
"assets/manifest.art.json",
r#"{"assets":[{"path":"assets/art-spritesheet.png"}],"sliceManifest":"assets/art-spritesheet-slices/manifest.json","status":"generated"}"#,
@@ -869,6 +879,16 @@ fn game_chat_code_completion_blocker_reopens_active_repair_before_delivery() {
render_game_chat_fast_path_html("制作水晶俄罗斯方块小游戏"),
)
.expect("write code entry with all art slices");
+ fs::write(
+ root.join("game/tunable-parameters.json"),
+ r#"{"schemaVersion":"game-creator-tunable-parameters.v1","parameters":[]}"#,
+ )
+ .expect("write repaired tunable parameter registry");
+ fs::write(
+ root.join("game/tunable-values.json"),
+ r#"{"schemaVersion":"game-creator-tunable-values.v1","values":{}}"#,
+ )
+ .expect("write repaired tunable parameter values");
let mut repaired_but_failed_plan = child_state.clone();
repaired_but_failed_plan.plan_steps[0].status = AGENT_RUNTIME_PLAN_STATUS_FAILED.to_string();
let repaired_failed_error = game_chat_fast_path_plan_at(
diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/task_start.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/task_start.rs
index 7ee37cb57..eb1eb8d0f 100644
--- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/task_start.rs
+++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/task_start.rs
@@ -1444,6 +1444,89 @@ pub(in crate::agent) fn project_autonomous_manifest_ready_task_terminal_at_locke
}),
)?;
}
+ if status == GameCreationAppTaskStatus::Completed && state.agent_id == "preview-playtest" {
+ let manifest_before_completion = read_manifest_for_project(root)?;
+ let required_tasks = autonomous_manifest_seed_tasks_for_source(&root_parent_binding.source);
+ let required_by_id = required_tasks
+ .iter()
+ .map(|task| (task.id.as_str(), task))
+ .collect::>();
+ let mut prerequisite_ids = std::collections::BTreeSet::new();
+ let mut pending_ids = vec![state.agent_id.as_str()];
+ while let Some(task_id) = pending_ids.pop() {
+ if !prerequisite_ids.insert(task_id) {
+ continue;
+ }
+ let task = required_by_id
+ .get(task_id)
+ .ok_or_else(|| format!("可运行版本项目完整性合同缺少任务:{task_id}"))?;
+ pending_ids.extend(task.dependencies.iter().map(String::as_str));
+ }
+ let incomplete = required_tasks
+ .iter()
+ .filter(|required| prerequisite_ids.contains(required.id.as_str()))
+ .filter(|required| required.id != state.agent_id)
+ .filter(|required| {
+ manifest_before_completion
+ .tasks
+ .iter()
+ .find(|task| task.id == required.id)
+ .is_none_or(|task| task.status != GameCreationAppTaskStatus::Completed)
+ })
+ .map(|task| task.id.as_str())
+ .collect::>();
+ if !incomplete.is_empty() {
+ return Err(format!(
+ "可运行版本项目完整性检查未通过:{}",
+ incomplete.join("、")
+ ));
+ }
+ let readiness_records =
+ latest_game_creator_agent_runtime_tasks(read_all_game_creator_agent_runtime_tasks(
+ &game_creator_agent_runtime_task_path(root, "preview-readiness"),
+ )?);
+ let readiness = readiness_records
+ .iter()
+ .rev()
+ .find(|record| {
+ record.parent_agent_id.as_deref() == Some(parent_agent_id.as_str())
+ && record.parent_run_id.as_deref() == Some(parent_run_id.as_str())
+ && record.status == "completed"
+ })
+ .ok_or_else(|| "可运行版本缺少 preview-readiness 完成回执".to_string())?;
+ let current_revision = read_game_creator_agent_runtime_project_revision(root)?;
+ let readiness_gate = read_game_creator_agent_runtime_verification_gate(
+ root,
+ &readiness.agent_id,
+ &readiness.run_id,
+ )?;
+ if readiness_gate.last_verification_tool.as_deref() != Some("game.static_smoke")
+ || readiness_gate.last_verification_status.as_deref()
+ != Some(AGENT_RUNTIME_VERIFICATION_STATUS_PASSED)
+ || readiness_gate.verified_revision != Some(current_revision.revision)
+ {
+ return Err("可运行版本缺少当前 revision 的 game.static_smoke 通过凭证".to_string());
+ }
+ let contract = autonomous_playtest_completion_contract_for_state_at(root, state)?
+ .ok_or_else(|| "可运行版本缺少 preview.validate 完成合同".to_string())?;
+ let receipt = read_autonomous_playtest_receipt(root, &contract)?
+ .ok_or_else(|| "可运行版本缺少 preview.validate 成功回执".to_string())?;
+ verify_autonomous_playtest_evidence_files_at(root, &receipt)?;
+ if receipt.revision != current_revision.revision {
+ return Err(format!(
+ "可运行版本 revision 不一致:receipt={} current={}",
+ receipt.revision, current_revision.revision
+ ));
+ }
+ register_current_runnable_game_version_at(
+ root,
+ current_revision.revision,
+ &receipt.agent_id,
+ &receipt.run_id,
+ &receipt.report.path,
+ receipt.playtest_scenario,
+ )?;
+ }
update_manifest_task_status_at(root, &state.agent_id, status.clone())?;
append_agent_db_record(
root,
@@ -1529,8 +1612,13 @@ pub(in crate::agent) fn render_autonomous_manifest_ready_task_background_prompt(
} else {
""
};
+ let code_tunable_parameter_requirement = if task.id == "code-prototype" {
+ " 数值微调合同固定为 game/tunable-parameters.json 与 game/tunable-values.json。注册表 schemaVersion 必须为 game-creator-tunable-parameters.v1,parameters 最多 128 项;每项包含稳定 parameterId、label、valueType、defaultValue、currentValue、可选 min/max/step/enumValues/unit、固定 writePath=game/tunable-values.json#/values/、effectMode=next-relaunch、editablePhase=paused、codeMutationAllowed=false。值文件 schemaVersion 必须为 game-creator-tunable-values.v1,values 只包含已登记 parameterId。只登记当前游戏确实读取的少量数值;game/index.html 必须在每次新启动时读取值文件并应用,运行中的 iframe 不得监听或热写该文件。"
+ } else {
+ ""
+ };
let owner_prompt = format!(
- "{base}\n\n这是 autonomous-game-build 的正式 owner 写入任务。必须实际生成并写入非空正式产物:{paths};JSON 文件必须是可解析 JSON,code-prototype 的 game/index.html 不能沿用初始化占位。{publish_package_requirement}任务声明中的视觉图片继续按现有 visual gate 生成、登记并验收。完成修改后按当前 run 的验证门完成验证并直接交付结论;不要调用 task.update,Runtime 会在子 Run 终态后幂等投影 manifest。"
+ "{base}\n\n这是 autonomous-game-build 的正式 owner 写入任务。必须实际生成并写入非空正式产物:{paths};JSON 文件必须是可解析 JSON,code-prototype 的 game/index.html 不能沿用初始化占位。{publish_package_requirement}{code_tunable_parameter_requirement}任务声明中的视觉图片继续按现有 visual gate 生成、登记并验收。完成修改后按当前 run 的验证门完成验证并直接交付结论;不要调用 task.update,Runtime 会在子 Run 终态后幂等投影 manifest。"
);
return format!("{owner_prompt}{code_visual_asset_requirement}");
}
diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/autonomous_completion.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/autonomous_completion.rs
index f6d81dd65..645c54a64 100644
--- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/autonomous_completion.rs
+++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/autonomous_completion.rs
@@ -75,7 +75,11 @@ pub(in crate::agent) fn autonomous_manifest_owner_artifact_paths(
"balance-seed" => &["game/balance.json"],
"art-asset-plan" => &["assets/manifest.art.json"],
"audio-asset-plan" => &["assets/manifest.audio.json"],
- "code-prototype" => &[AGENT_RUNTIME_GAME_INDEX_PATH],
+ "code-prototype" => &[
+ AGENT_RUNTIME_GAME_INDEX_PATH,
+ "game/tunable-parameters.json",
+ "game/tunable-values.json",
+ ],
"publish-package" => &["exports/README.md"],
_ => &[],
}
@@ -6518,7 +6522,8 @@ pub(in crate::agent) fn autonomous_playtest_contract_prompt(
concat!(
"完成合同要求 generic-v1 交互试玩。game/index.html 必须持续更新 "#;
+const GAME_RUN_BRIDGE_BOOTSTRAP: &str = r#"(() => {
+ 'use strict';
+ const protocolVersion = 'game-creator-run-bridge.v1';
+ const hostSchema = 'game-creator-host-message.v1';
+ const runtimeSchema = 'game-creator-runtime-message.v1';
+ const hostTypes = new Set(['host.start', 'host.pause', 'host.resume', 'host.stop', 'host.state.request', 'host.inspection.request', 'host.slice.start', 'host.slice.stop']);
+ let session = null;
+ let hostOrigin = null;
+ let hostSequence = 0;
+ let runtimeSequence = 0;
+ let state = 'starting';
+ let currentSlice = null;
+ const hostRequestIds = new Set();
+
+ const randomId = () => {
+ const bytes = new Uint8Array(16);
+ crypto.getRandomValues(bytes);
+ return `runtime-request-${Array.from(bytes, value => value.toString(16).padStart(2, '0')).join('')}`;
+ };
+ const sameIdentity = message => session &&
+ message.sessionId === session.sessionId &&
+ message.previewId === session.previewId &&
+ message.projectId === session.projectId &&
+ message.versionId === session.versionId &&
+ message.projectRevision === session.projectRevision;
+ const send = (type, payload, responseTo) => {
+ if (!session || !hostOrigin) return false;
+ const message = {
+ schemaVersion: runtimeSchema,
+ protocolVersion,
+ requestId: randomId(),
+ sessionId: session.sessionId,
+ previewId: session.previewId,
+ projectId: session.projectId,
+ sequence: ++runtimeSequence,
+ type,
+ versionId: session.versionId,
+ projectRevision: session.projectRevision,
+ payload,
+ };
+ if (responseTo) message.responseTo = responseTo;
+ parent.postMessage(message, hostOrigin);
+ return true;
+ };
+ const reportState = (nextState, summary) => {
+ state = nextState;
+ return send('runtime.state', summary === undefined ? { status: nextState } : { status: nextState, summary });
+ };
+ const reportError = (code, message, recoverable = false) =>
+ send('runtime.error', { code, message, recoverable });
+ const reportSlice = (sliceId, title, status, summary) => {
+ if (currentSlice && currentSlice.sliceId === sliceId) currentSlice.status = status;
+ return send('runtime.slice', summary === undefined ? { sliceId, title, status } : { sliceId, title, status, summary });
+ };
+ const reportInspection = (inspectionId, label, metrics, summary) =>
+ send('runtime.inspection', summary === undefined ? { inspectionId, label, metrics } : { inspectionId, label, metrics, summary });
+ const reportTargetInspection = (targetKind, targetId) =>
+ send('runtime.inspection', { inspectionId: `hover-${targetKind}`, label: '', metrics: {}, targetKind, targetId });
+ const clearInspection = () =>
+ send('runtime.inspection', { inspectionId: 'hover-clear', label: '', metrics: {}, targetKind: 'none' });
+
+ Object.defineProperty(window, 'genarrativeGameBridge', {
+ configurable: false,
+ enumerable: false,
+ writable: false,
+ value: Object.freeze({ protocolVersion, reportState, reportError, reportSlice, reportInspection, reportTargetInspection, clearInspection }),
+ });
+
+ addEventListener('message', event => {
+ const message = event.data;
+ if (event.source !== parent || !message || typeof message !== 'object' ||
+ message.schemaVersion !== hostSchema || message.protocolVersion !== protocolVersion ||
+ !hostTypes.has(message.type) || !Number.isSafeInteger(message.sequence) ||
+ typeof message.requestId !== 'string' || hostRequestIds.has(message.requestId)) return;
+ if (message.type === 'host.start') {
+ if (session || message.sequence !== 1 || typeof message.sessionId !== 'string' ||
+ typeof message.previewId !== 'string' || typeof message.projectId !== 'string' ||
+ typeof message.versionId !== 'string' || !Number.isSafeInteger(message.projectRevision)) return;
+ hostOrigin = event.origin;
+ hostSequence = 1;
+ hostRequestIds.add(message.requestId);
+ session = {
+ sessionId: message.sessionId,
+ previewId: message.previewId,
+ projectId: message.projectId,
+ versionId: message.versionId,
+ projectRevision: message.projectRevision,
+ };
+ send('runtime.ready', { capabilities: ['state', 'slice', 'inspection'] }, message.requestId);
+ state = 'playing';
+ send('runtime.state', { status: state });
+ dispatchEvent(new CustomEvent('genarrative:host-message', { detail: message }));
+ return;
+ }
+ if (event.origin !== hostOrigin || !sameIdentity(message) || message.sequence !== hostSequence + 1) return;
+ if ((message.type === 'host.slice.start' || message.type === 'host.slice.stop') &&
+ (!message.payload || typeof message.payload !== 'object' || Object.keys(message.payload).length !== 1 ||
+ typeof message.payload.sliceId !== 'string' || !message.payload.sliceId)) return;
+ hostSequence = message.sequence;
+ hostRequestIds.add(message.requestId);
+ if (message.type === 'host.pause') {
+ state = 'paused';
+ send('runtime.state', { status: state }, message.requestId);
+ } else if (message.type === 'host.resume') {
+ state = 'playing';
+ send('runtime.state', { status: state }, message.requestId);
+ } else if (message.type === 'host.state.request') {
+ send('runtime.state', { status: state }, message.requestId);
+ } else if (message.type === 'host.inspection.request') {
+ send('runtime.inspection', {
+ inspectionId: 'host-baseline',
+ label: '运行画面',
+ metrics: { state, viewportWidth: innerWidth, viewportHeight: innerHeight },
+ }, message.requestId);
+ } else if (message.type === 'host.slice.start') {
+ currentSlice = { sliceId: message.payload.sliceId, status: 'starting' };
+ send('runtime.slice', { sliceId: currentSlice.sliceId, title: '', status: 'starting' }, message.requestId);
+ dispatchEvent(new CustomEvent('genarrative:host-message', { detail: message }));
+ if (currentSlice && currentSlice.sliceId === message.payload.sliceId && currentSlice.status === 'starting') {
+ currentSlice.status = 'playing';
+ send('runtime.slice', { sliceId: currentSlice.sliceId, title: '', status: 'playing' });
+ }
+ return;
+ } else if (message.type === 'host.slice.stop') {
+ const sliceId = message.payload.sliceId;
+ if (currentSlice && currentSlice.sliceId === sliceId) currentSlice.status = 'paused';
+ send('runtime.slice', { sliceId, title: '', status: 'paused' }, message.requestId);
+ currentSlice = null;
+ clearInspection();
+ } else if (message.type === 'host.stop') {
+ state = 'stopped';
+ send('runtime.state', { status: state }, message.requestId);
+ }
+ dispatchEvent(new CustomEvent('genarrative:host-message', { detail: message }));
+ if (message.type === 'host.stop') {
+ session = null;
+ hostOrigin = null;
+ }
+ });
+})();
+"#;
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) enum PreviewListenerAcceptDisposition {
@@ -382,6 +550,14 @@ pub(crate) fn activate_local_game_preview(
pub(crate) fn start_local_game_preview_for_project(
root: &Path,
) -> Result<(LocalPreviewResult, mpsc::Sender<()>), String> {
+ start_local_game_preview_for_served_root(root, root)
+}
+
+pub(crate) fn start_local_game_preview_for_served_root(
+ project_root: &Path,
+ served_root: &Path,
+) -> Result<(LocalPreviewResult, mpsc::Sender<()>), String> {
+ let root = project_root;
if root.as_os_str().is_empty() {
return Err("项目目录不能为空".to_string());
}
@@ -389,7 +565,10 @@ pub(crate) fn start_local_game_preview_for_project(
return Err("项目目录必须是绝对路径".to_string());
}
- let game_root = root.join("game");
+ if served_root.as_os_str().is_empty() || !served_root.is_absolute() {
+ return Err("预览产物目录无效".to_string());
+ }
+ let game_root = served_root.join("game");
if !game_root.is_dir() {
return Err(format!("游戏目录不存在:{}", game_root.display()));
}
@@ -409,7 +588,7 @@ pub(crate) fn start_local_game_preview_for_project(
listener
.set_nonblocking(true)
.map_err(|error| format!("设置预览监听失败:{error}"))?;
- let served_root = root.to_path_buf();
+ let served_root = served_root.to_path_buf();
let (stop_sender, stop_receiver) = mpsc::channel();
thread::spawn(move || loop {
@@ -443,6 +622,82 @@ pub(crate) fn start_local_game_preview_for_project(
))
}
+#[tauri::command]
+pub(crate) fn launch_local_game_runnable_version(
+ project_path: String,
+ expected_project_id: String,
+ version_id: Option,
+ registry: tauri::State<'_, PreviewRegistry>,
+) -> Result {
+ let root = Path::new(project_path.trim());
+ enforce_project_permission_policy(root, "preview.start")?;
+ let _lock = acquire_project_write_lock(root, "preview.start")?;
+ let current_manifest = read_existing_manifest_for_project(root)?;
+ if current_manifest.project_id != expected_project_id.trim() {
+ return Err("可运行版本项目身份不一致".to_string());
+ }
+ let version_id = version_id
+ .as_deref()
+ .map(str::trim)
+ .filter(|value| !value.is_empty())
+ .map(str::to_string)
+ .or(current_manifest.current_runnable_version_id.clone())
+ .ok_or_else(|| "当前无可运行版本".to_string())?;
+
+ let _ = registry.stop_for_project(Some(root));
+ let (_, version, artifact_root) =
+ resolve_runnable_game_version_at(root, expected_project_id.trim(), &version_id)?;
+ let (preview, stop) = match start_local_game_preview_for_served_root(root, &artifact_root) {
+ Ok(result) => result,
+ Err(error) => {
+ let _ = record_preview_state(root, GameCreationAppPreviewStatus::Failed, None, None);
+ return Err(format!("可运行版本预览启动失败:{error}"));
+ }
+ };
+ if let Err(error) = record_preview_state(
+ root,
+ GameCreationAppPreviewStatus::Running,
+ Some(preview.url.clone()),
+ Some(preview.port),
+ ) {
+ let _ = stop.send(());
+ return Err(error);
+ }
+ if let Err(error) = append_preview_log(root, "running", Some(&preview.url)) {
+ let _ = stop.send(());
+ let _ = record_preview_state(root, GameCreationAppPreviewStatus::Failed, None, None);
+ return Err(error);
+ }
+ let (preview, preview_identity, previous_preview) =
+ registry.set_running_with_identity(preview, stop);
+ if let Some(previous_preview) = previous_preview.as_ref() {
+ record_replaced_preview_stop(previous_preview);
+ }
+ if let Err(error) = append_preview_start_trace_step(root, &preview) {
+ let _ = registry.stop();
+ let _ = record_preview_state(root, GameCreationAppPreviewStatus::Failed, None, None);
+ return Err(error);
+ }
+ let (manifest, selected_version, _) = match select_current_runnable_game_version_at(
+ root,
+ expected_project_id.trim(),
+ &version.version_id,
+ ) {
+ Ok(selected) => selected,
+ Err(error) => {
+ let _ = registry.stop();
+ let _ = record_preview_state(root, GameCreationAppPreviewStatus::Failed, None, None);
+ return Err(error);
+ }
+ };
+ Ok(RunnableGameVersionLaunchResult {
+ manifest,
+ version: selected_version,
+ preview,
+ preview_identity,
+ })
+}
+
fn handle_preview_stream(mut stream: TcpStream, root: &Path) {
// The listener is nonblocking so its accept loop can observe the stop channel. Windows may
// inherit that mode on accepted sockets; switch each connection back to blocking mode before
@@ -560,6 +815,21 @@ pub(crate) fn build_preview_response(root: &Path, method: &str, url_path: &str)
);
}
+ if url_path.split('?').next() == Some(GAME_RUN_BRIDGE_SCRIPT_PATH) {
+ let content_length = GAME_RUN_BRIDGE_BOOTSTRAP.len();
+ let body = if is_head {
+ Vec::new()
+ } else {
+ GAME_RUN_BRIDGE_BOOTSTRAP.as_bytes().to_vec()
+ };
+ return http_response(
+ "200 OK",
+ "text/javascript; charset=utf-8",
+ &body,
+ content_length,
+ );
+ }
+
let file_path = match resolve_preview_path(root, url_path) {
Ok(path) => path,
Err(_) => {
@@ -574,11 +844,41 @@ pub(crate) fn build_preview_response(root: &Path, method: &str, url_path: &str)
return http_response("404 Not Found", "text/plain", body, b"not found".len());
}
};
+ let body = if file_path.extension().and_then(|value| value.to_str()) == Some("html") {
+ inject_game_run_bridge_tag(body)
+ } else {
+ body
+ };
let content_length = body.len();
let body = if is_head { Vec::new() } else { body };
http_response("200 OK", content_type(&file_path), &body, content_length)
}
+fn inject_game_run_bridge_tag(body: Vec) -> Vec {
+ let html = match String::from_utf8(body) {
+ Ok(html) => html,
+ Err(error) => return error.into_bytes(),
+ };
+ if html.contains("data-genarrative-game-bridge=") {
+ return html.into_bytes();
+ }
+ let lowercase = html.to_ascii_lowercase();
+ let insertion_index = lowercase
+ .find("')
+ .map(|offset| start + offset + 1)
+ })
+ .or_else(|| lowercase.find(""))
+ .unwrap_or(html.len());
+ let mut injected = String::with_capacity(html.len() + GAME_RUN_BRIDGE_SCRIPT_TAG.len());
+ injected.push_str(&html[..insertion_index]);
+ injected.push_str(GAME_RUN_BRIDGE_SCRIPT_TAG);
+ injected.push_str(&html[insertion_index..]);
+ injected.into_bytes()
+}
+
pub(crate) fn resolve_preview_path(root: &Path, url_path: &str) -> Result {
let path = url_path.split('?').next().unwrap_or("/");
let decoded = percent_decode_path(path).ok_or_else(|| "预览路径非法".to_string())?;
diff --git a/apps/ai-game-creator-shell/src-tauri/src/project.rs b/apps/ai-game-creator-shell/src-tauri/src/project.rs
index 1099d78e8..fb877c095 100644
--- a/apps/ai-game-creator-shell/src-tauri/src/project.rs
+++ b/apps/ai-game-creator-shell/src-tauri/src/project.rs
@@ -12,6 +12,7 @@ mod manifest;
mod memory;
mod resource_dependency_graph;
mod resource_layout;
+mod runnable_versions;
mod verification;
pub(crate) use agent_db::*;
@@ -23,4 +24,5 @@ pub(crate) use manifest::*;
pub(crate) use memory::*;
pub(crate) use resource_dependency_graph::*;
pub(crate) use resource_layout::*;
+pub(crate) use runnable_versions::*;
pub(crate) use verification::*;
diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/manifest.rs b/apps/ai-game-creator-shell/src-tauri/src/project/manifest.rs
index 80216aa36..aa396aaf0 100644
--- a/apps/ai-game-creator-shell/src-tauri/src/project/manifest.rs
+++ b/apps/ai-game-creator-shell/src-tauri/src/project/manifest.rs
@@ -799,6 +799,12 @@ pub(crate) fn read_manifest(path: &Path) -> Result manifest.runnable_versions.len()
+ || existing
+ .runnable_versions
+ .iter()
+ .zip(&manifest.runnable_versions)
+ .any(|(existing, candidate)| existing != candidate)
+ {
+ return Err("可运行版本记录写入后不可修改、删除或重排".to_string());
+ }
}
match fs::symlink_metadata(path) {
Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_file() => {
diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/manifest/recovery_tests.rs b/apps/ai-game-creator-shell/src-tauri/src/project/manifest/recovery_tests.rs
index cf4c331fc..19dc1006e 100644
--- a/apps/ai-game-creator-shell/src-tauri/src/project/manifest/recovery_tests.rs
+++ b/apps/ai-game-creator-shell/src-tauri/src/project/manifest/recovery_tests.rs
@@ -1,6 +1,8 @@
use super::*;
use shared_contracts::game_creation_app::{
GameIterationVersion, GameIterationVersionCreatedReason, GameIterationVersionResourceBinding,
+ RunnableGameVersion, RunnableGameVersionCreatedReason, RunnableGameVersionValidation,
+ RUNNABLE_GAME_VERSION_SCHEMA_VERSION,
};
fn unique_manifest_test_root(test_name: &str) -> PathBuf {
@@ -62,6 +64,41 @@ fn version_fixture(
}
}
+fn runnable_version_fixture(
+ project_id: &str,
+ version_id: &str,
+ parent_version_id: Option<&str>,
+ project_revision: u64,
+ created_reason: RunnableGameVersionCreatedReason,
+) -> RunnableGameVersion {
+ RunnableGameVersion {
+ schema_version: RUNNABLE_GAME_VERSION_SCHEMA_VERSION.to_string(),
+ version_id: version_id.to_string(),
+ project_id: project_id.to_string(),
+ parent_version_id: parent_version_id.map(str::to_string),
+ project_revision,
+ artifact_path: format!(".agent/runnable-versions/{version_id}/artifact"),
+ artifact_sha256: "a".repeat(64),
+ entry_path: "game/index.html".to_string(),
+ resource_bindings: Vec::new(),
+ test_slices: Vec::new(),
+ resource_descriptors: Vec::new(),
+ resource_replacement: None,
+ created_reason,
+ validation: RunnableGameVersionValidation {
+ static_smoke_passed: true,
+ preview_validate_passed: true,
+ playtest_passed: true,
+ agent_id: "program-agent".to_string(),
+ run_id: format!("run-{project_revision}"),
+ report_path: format!(
+ ".agent/runtime/browser-validations/program-agent/run-{project_revision}/1/validation.json"
+ ),
+ },
+ created_at: project_revision,
+ }
+}
+
#[test]
fn manifest_versions_are_append_only_at_the_storage_boundary() {
let root = unique_manifest_test_root("versions-append-only");
@@ -163,6 +200,73 @@ fn concurrent_manifest_write_cannot_overwrite_an_installed_version_with_a_stale_
fs::remove_dir_all(root).ok();
}
+#[test]
+fn concurrent_manifest_write_cannot_overwrite_an_installed_runnable_version() {
+ let root = unique_manifest_test_root("runnable-versions-concurrent-append-only");
+ let manifest_path = root.join(".agent/manifest.json");
+ let project_id = "project-runnable-versioned";
+ let mut stale_manifest = new_game_creation_app_manifest(project_id, "并发可运行版本项目");
+ stale_manifest
+ .runnable_versions
+ .push(runnable_version_fixture(
+ project_id,
+ "runnable-root",
+ None,
+ 1,
+ RunnableGameVersionCreatedReason::Initial,
+ ));
+ stale_manifest.current_runnable_version_id = Some("runnable-root".to_string());
+ write_manifest(&manifest_path, &stale_manifest).expect("write initial runnable version");
+
+ let mut newer_manifest = stale_manifest.clone();
+ newer_manifest
+ .runnable_versions
+ .push(runnable_version_fixture(
+ project_id,
+ "runnable-child",
+ Some("runnable-root"),
+ 2,
+ RunnableGameVersionCreatedReason::AgentRevision,
+ ));
+ newer_manifest.current_runnable_version_id = Some("runnable-child".to_string());
+ let (newer_locked_tx, newer_locked_rx) = mpsc::channel();
+ let (release_newer_tx, release_newer_rx) = mpsc::channel();
+ let newer_path = manifest_path.clone();
+ let newer_writer = std::thread::spawn(move || {
+ write_manifest_with_lock_hook(&newer_path, &newer_manifest, || {
+ newer_locked_tx
+ .send(())
+ .expect("signal newer lock acquired");
+ release_newer_rx.recv().expect("release newer writer");
+ })
+ });
+ newer_locked_rx
+ .recv_timeout(Duration::from_secs(2))
+ .expect("newer writer acquires manifest lock");
+
+ let stale_path = manifest_path.clone();
+ let stale_writer = std::thread::spawn(move || write_manifest(&stale_path, &stale_manifest));
+ release_newer_tx.send(()).expect("release newer writer");
+
+ newer_writer
+ .join()
+ .expect("join newer writer")
+ .expect("install newer runnable manifest");
+ let stale_error = stale_writer
+ .join()
+ .expect("join stale writer")
+ .expect_err("reject stale runnable manifest");
+ assert!(
+ stale_error.contains("可运行版本记录写入后不可修改、删除或重排"),
+ "{stale_error}"
+ );
+ let installed = read_manifest(&manifest_path).expect("read final manifest");
+ assert_eq!(installed.runnable_versions.len(), 2);
+ assert_eq!(installed.runnable_versions[1].version_id, "runnable-child");
+
+ fs::remove_dir_all(root).ok();
+}
+
#[test]
fn manifest_install_uses_previous_when_direct_replace_fails() {
let root = unique_manifest_test_root("replace-fallback");
diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/runnable_versions.rs b/apps/ai-game-creator-shell/src-tauri/src/project/runnable_versions.rs
new file mode 100644
index 000000000..6223e1dce
--- /dev/null
+++ b/apps/ai-game-creator-shell/src-tauri/src/project/runnable_versions.rs
@@ -0,0 +1,1818 @@
+use super::*;
+
+const RUNNABLE_ARTIFACT_DESCRIPTOR_SCHEMA_VERSION: &str = "game-creator-runnable-artifact.v1";
+const RUNNABLE_ARTIFACT_MAX_FILES: usize = 4_096;
+const RUNNABLE_ARTIFACT_MAX_FILE_BYTES: u64 = 64 * 1024 * 1024;
+const RUNNABLE_ARTIFACT_MAX_TOTAL_BYTES: u64 = 512 * 1024 * 1024;
+const TUNABLE_PARAMETER_REGISTRY_SCHEMA_VERSION: &str = "game-creator-tunable-parameters.v1";
+const TUNABLE_PARAMETER_VALUES_SCHEMA_VERSION: &str = "game-creator-tunable-values.v1";
+const TUNABLE_PARAMETER_REGISTRY_PATH: &str = "game/tunable-parameters.json";
+const TUNABLE_PARAMETER_VALUES_PATH: &str = "game/tunable-values.json";
+const TUNABLE_PARAMETER_MAX_COUNT: usize = 128;
+const TUNABLE_PARAMETER_FILE_MAX_BYTES: u64 = 256 * 1024;
+
+#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
+#[serde(deny_unknown_fields, rename_all = "camelCase")]
+struct RunnableArtifactDescriptor {
+ schema_version: String,
+ project_id: String,
+ version_id: String,
+ project_revision: u64,
+ artifact_sha256: String,
+ created_at: u64,
+}
+
+#[derive(Clone, Debug, Eq, PartialEq)]
+struct WrittenRunnableArtifactSnapshot {
+ artifact_sha256: String,
+ created_at: u64,
+}
+
+#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
+#[serde(deny_unknown_fields, rename_all = "camelCase")]
+struct TunableParameterRegistryFile {
+ schema_version: String,
+ parameters: Vec,
+}
+
+#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
+#[serde(deny_unknown_fields, rename_all = "camelCase")]
+struct TunableParameterValuesFile {
+ schema_version: String,
+ values: BTreeMap,
+}
+
+#[derive(Clone, Debug, PartialEq, Serialize)]
+#[serde(rename_all = "camelCase")]
+pub(crate) struct GameTunableParametersReadModel {
+ pub(crate) version_id: String,
+ pub(crate) project_revision: u64,
+ pub(crate) parameters: Vec,
+}
+
+#[derive(Clone, Debug, PartialEq, Serialize)]
+#[serde(rename_all = "camelCase")]
+pub(crate) struct UpdateGameTunableParameterResult {
+ pub(crate) revision: u64,
+ pub(crate) parameters: Vec,
+}
+
+#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
+#[serde(rename_all = "camelCase")]
+pub(crate) struct ReplaceRunnableGameResourceResult {
+ pub(crate) manifest: GameCreationAppManifest,
+ pub(crate) version: RunnableGameVersion,
+}
+
+fn runnable_version_id(revision: u64) -> String {
+ format!("runnable-r{revision}")
+}
+
+fn runnable_version_root(root: &Path, version_id: &str) -> PathBuf {
+ root.join(".agent/runnable-versions").join(version_id)
+}
+
+fn runnable_test_slices(version_id: &str, scenario: BrowserPlaytestScenario) -> Vec {
+ let definitions: &[(&str, &str, &str, &str)] = match scenario {
+ BrowserPlaytestScenario::GenericV1 | BrowserPlaytestScenario::TetrisV1 => &[((
+ "core-gameplay",
+ "核心玩法",
+ "游戏进入 ready",
+ "游戏进入 won 或 lost",
+ ))],
+ BrowserPlaytestScenario::LaneDefenseV1 => &[
+ (
+ "deploy-defender",
+ "部署防御单位",
+ "关卡进入 ready",
+ "防御单位完成部署",
+ ),
+ (
+ "resolve-wave",
+ "推进当前波次",
+ "防御单位已部署",
+ "当前波次进入 won 或 lost",
+ ),
+ (
+ "advance-level",
+ "进入下一关",
+ "当前波次已完成",
+ "下一关恢复 ready",
+ ),
+ ],
+ };
+ definitions
+ .iter()
+ .enumerate()
+ .map(
+ |(order, (slice_id, title, start_condition, end_condition))| GameTestSlice {
+ slice_id: (*slice_id).to_string(),
+ version_id: version_id.to_string(),
+ title: (*title).to_string(),
+ order: u32::try_from(order).unwrap_or(u32::MAX),
+ start_condition: (*start_condition).to_string(),
+ end_condition: (*end_condition).to_string(),
+ status: GameTestSliceStatus::Idle,
+ },
+ )
+ .collect()
+}
+
+fn runnable_resource_category(
+ media_type: &str,
+ kind: &str,
+ local_path: &str,
+) -> Option {
+ let media_type = media_type.to_ascii_lowercase();
+ let kind = kind.to_ascii_lowercase();
+ let local_path = local_path.to_ascii_lowercase();
+ if media_type.starts_with("audio/")
+ || [".mp3", ".wav", ".ogg", ".m4a", ".aac", ".flac", ".opus"]
+ .iter()
+ .any(|extension| local_path.ends_with(extension))
+ || ["audio", "bgm", "music", "sfx", "sound", "voice"]
+ .iter()
+ .any(|token| kind.contains(token))
+ {
+ return Some(GameResourceCategory::Audio);
+ }
+ if media_type.starts_with("image/")
+ || media_type.starts_with("video/")
+ || [
+ ".png", ".jpg", ".jpeg", ".webp", ".gif", ".svg", ".avif", ".bmp", ".mp4", ".webm",
+ ".mov",
+ ]
+ .iter()
+ .any(|extension| local_path.ends_with(extension))
+ || [
+ "art",
+ "animation",
+ "character",
+ "icon",
+ "image",
+ "scene",
+ "sprite",
+ "ui",
+ "video",
+ ]
+ .iter()
+ .any(|token| kind.contains(token))
+ {
+ return Some(GameResourceCategory::Art);
+ }
+ if media_type.starts_with("text/")
+ || media_type.contains("json")
+ || media_type.contains("yaml")
+ || [
+ ".md",
+ ".markdown",
+ ".txt",
+ ".json",
+ ".yaml",
+ ".yml",
+ ".toml",
+ ]
+ .iter()
+ .any(|extension| local_path.ends_with(extension))
+ {
+ return Some(GameResourceCategory::Document);
+ }
+ None
+}
+
+fn runnable_resource_descriptors(
+ root: &Path,
+ manifest: &GameCreationAppManifest,
+) -> Vec {
+ manifest
+ .assets
+ .iter()
+ .filter_map(|asset| runnable_resource_descriptor(root, asset))
+ .collect()
+}
+
+fn runnable_resource_descriptor(
+ root: &Path,
+ asset: &GameCreationAppAssetManifestEntry,
+) -> Option {
+ let category = runnable_resource_category(&asset.media_type, &asset.kind, &asset.local_path)?;
+ let bytes = normalize_relative_path(&asset.local_path)
+ .ok()
+ .and_then(|relative| resolve_local_project_path(root, &relative).ok())
+ .and_then(|path| {
+ let metadata = fs::metadata(&path).ok()?;
+ if !metadata.is_file() || metadata.len() > RUNNABLE_ARTIFACT_MAX_FILE_BYTES {
+ return None;
+ }
+ fs::read(path).ok()
+ });
+ let (width, height) = if asset.media_type.starts_with("image/") {
+ bytes
+ .as_deref()
+ .and_then(|bytes| {
+ image::ImageReader::new(std::io::Cursor::new(bytes))
+ .with_guessed_format()
+ .ok()?
+ .into_dimensions()
+ .ok()
+ })
+ .map_or((None, None), |(width, height)| (Some(width), Some(height)))
+ } else {
+ (None, None)
+ };
+ let audio_spec = if category == GameResourceCategory::Audio {
+ bytes.as_deref().and_then(parse_wav_size_spec)
+ } else {
+ None
+ };
+ let name = Path::new(&asset.local_path)
+ .file_name()
+ .and_then(|value| value.to_str())
+ .unwrap_or(asset.id.as_str())
+ .to_string();
+ Some(GameResourceDescriptor {
+ resource_id: asset.id.clone(),
+ name,
+ category,
+ subtype: asset.kind.clone(),
+ width,
+ height,
+ duration_ms: audio_spec.as_ref().map(|spec| spec.duration_ms),
+ channel_count: audio_spec.as_ref().map(|spec| spec.channel_count),
+ sample_rate_hz: audio_spec.as_ref().map(|spec| spec.sample_rate_hz),
+ codec: audio_spec.map(|spec| spec.codec),
+ format: asset.media_type.clone(),
+ })
+}
+
+#[derive(Clone, Debug, Eq, PartialEq)]
+struct WavSizeSpec {
+ channel_count: u16,
+ sample_rate_hz: u32,
+ duration_ms: u64,
+ codec: String,
+}
+
+fn parse_wav_size_spec(bytes: &[u8]) -> Option {
+ if bytes.len() < 12 || &bytes[..4] != b"RIFF" || &bytes[8..12] != b"WAVE" {
+ return None;
+ }
+ let mut offset = 12usize;
+ let mut format = None;
+ let mut data_bytes = None;
+ while offset.checked_add(8)? <= bytes.len() {
+ let chunk_id = &bytes[offset..offset + 4];
+ let chunk_len = u32::from_le_bytes(bytes[offset + 4..offset + 8].try_into().ok()?) as usize;
+ let content_start = offset.checked_add(8)?;
+ let content_end = content_start.checked_add(chunk_len)?;
+ if content_end > bytes.len() {
+ return None;
+ }
+ if chunk_id == b"fmt " && chunk_len >= 16 {
+ let audio_format =
+ u16::from_le_bytes(bytes[content_start..content_start + 2].try_into().ok()?);
+ let channel_count = u16::from_le_bytes(
+ bytes[content_start + 2..content_start + 4]
+ .try_into()
+ .ok()?,
+ );
+ let sample_rate_hz = u32::from_le_bytes(
+ bytes[content_start + 4..content_start + 8]
+ .try_into()
+ .ok()?,
+ );
+ let byte_rate = u32::from_le_bytes(
+ bytes[content_start + 8..content_start + 12]
+ .try_into()
+ .ok()?,
+ );
+ if channel_count == 0 || sample_rate_hz == 0 || byte_rate == 0 {
+ return None;
+ }
+ let codec = match audio_format {
+ 1 => "pcm".to_string(),
+ 3 => "ieee-float".to_string(),
+ value => format!("wav-format-{value}"),
+ };
+ format = Some((channel_count, sample_rate_hz, byte_rate, codec));
+ } else if chunk_id == b"data" {
+ data_bytes = Some(chunk_len as u64);
+ }
+ offset = content_end.checked_add(chunk_len % 2)?;
+ }
+ let (channel_count, sample_rate_hz, byte_rate, codec) = format?;
+ let duration_ms = data_bytes?.checked_mul(1_000)? / u64::from(byte_rate);
+ Some(WavSizeSpec {
+ channel_count,
+ sample_rate_hz,
+ duration_ms,
+ codec,
+ })
+}
+
+fn collect_runnable_artifact_files(
+ base: &Path,
+ directory: &Path,
+ output: &mut Vec<(String, PathBuf, u64)>,
+ total_bytes: &mut u64,
+) -> Result<(), String> {
+ let metadata =
+ fs::symlink_metadata(directory).map_err(|_| "可运行版本源目录不存在".to_string())?;
+ if metadata.file_type().is_symlink() || !metadata.is_dir() {
+ return Err("可运行版本源目录必须是普通目录".to_string());
+ }
+ let mut entries = fs::read_dir(directory)
+ .map_err(|_| "读取可运行版本源目录失败".to_string())?
+ .collect::, _>>()
+ .map_err(|_| "读取可运行版本源目录失败".to_string())?;
+ entries.sort_by_key(|entry| entry.file_name());
+ for entry in entries {
+ let path = entry.path();
+ let metadata =
+ fs::symlink_metadata(&path).map_err(|_| "读取可运行版本源文件失败".to_string())?;
+ if metadata.file_type().is_symlink() {
+ return Err("可运行版本不允许包含符号链接".to_string());
+ }
+ if metadata.is_dir() {
+ collect_runnable_artifact_files(base, &path, output, total_bytes)?;
+ continue;
+ }
+ if !metadata.is_file() {
+ return Err("可运行版本只允许包含普通文件".to_string());
+ }
+ if metadata.len() > RUNNABLE_ARTIFACT_MAX_FILE_BYTES {
+ return Err("可运行版本包含超限文件".to_string());
+ }
+ *total_bytes = total_bytes
+ .checked_add(metadata.len())
+ .ok_or_else(|| "可运行版本总大小已溢出".to_string())?;
+ if *total_bytes > RUNNABLE_ARTIFACT_MAX_TOTAL_BYTES {
+ return Err("可运行版本总大小超过 512 MiB".to_string());
+ }
+ let relative = path
+ .strip_prefix(base)
+ .map_err(|_| "可运行版本源路径越界".to_string())?
+ .components()
+ .map(|component| component.as_os_str().to_string_lossy())
+ .collect::>()
+ .join("/");
+ if relative.is_empty() || relative.chars().any(char::is_control) {
+ return Err("可运行版本源路径无效".to_string());
+ }
+ output.push((relative, path, metadata.len()));
+ if output.len() > RUNNABLE_ARTIFACT_MAX_FILES {
+ return Err("可运行版本文件数量超过 4096".to_string());
+ }
+ }
+ Ok(())
+}
+
+fn read_runnable_source_file(path: &Path, expected_len: u64) -> Result, String> {
+ let mut options = fs::OpenOptions::new();
+ options.read(true);
+ #[cfg(unix)]
+ {
+ use std::os::unix::fs::OpenOptionsExt;
+ options.custom_flags(libc::O_NOFOLLOW);
+ }
+ let mut file = options
+ .open(path)
+ .map_err(|_| "读取可运行版本源文件失败".to_string())?;
+ let metadata = file
+ .metadata()
+ .map_err(|_| "读取可运行版本源文件元数据失败".to_string())?;
+ if !metadata.is_file() || metadata.len() != expected_len {
+ return Err("可运行版本源文件在登记期间发生变化".to_string());
+ }
+ let mut bytes = Vec::with_capacity(usize::try_from(expected_len).unwrap_or(0));
+ file.read_to_end(&mut bytes)
+ .map_err(|_| "读取可运行版本源文件失败".to_string())?;
+ if u64::try_from(bytes.len()).ok() != Some(expected_len) {
+ return Err("可运行版本源文件在登记期间发生变化".to_string());
+ }
+ Ok(bytes)
+}
+
+fn hash_runnable_artifact_files(files: &[(String, Vec)]) -> String {
+ let mut hasher = Sha256::new();
+ for (relative, bytes) in files {
+ hasher.update((relative.len() as u64).to_be_bytes());
+ hasher.update(relative.as_bytes());
+ hasher.update((bytes.len() as u64).to_be_bytes());
+ hasher.update(bytes);
+ }
+ format!("{:x}", hasher.finalize())
+}
+
+fn read_runnable_artifact_files(root: &Path) -> Result)>, String> {
+ let mut sources = Vec::new();
+ let mut total_bytes = 0;
+ collect_runnable_artifact_files(root, &root.join("game"), &mut sources, &mut total_bytes)?;
+ let assets = root.join("assets");
+ if assets.exists() {
+ collect_runnable_artifact_files(root, &assets, &mut sources, &mut total_bytes)?;
+ }
+ sources.sort_by(|left, right| left.0.cmp(&right.0));
+ sources
+ .into_iter()
+ .map(|(relative, path, expected_len)| {
+ read_runnable_source_file(&path, expected_len).map(|bytes| (relative, bytes))
+ })
+ .collect()
+}
+
+fn write_runnable_artifact_snapshot(
+ root: &Path,
+ project_id: &str,
+ version_id: &str,
+ revision: u64,
+ created_at: u64,
+) -> Result {
+ let files = read_runnable_artifact_files(root)?;
+ write_runnable_artifact_snapshot_from_files(
+ root, project_id, version_id, revision, created_at, files,
+ )
+}
+
+fn write_runnable_artifact_snapshot_from_files(
+ root: &Path,
+ project_id: &str,
+ version_id: &str,
+ revision: u64,
+ created_at: u64,
+ files: Vec<(String, Vec)>,
+) -> Result {
+ if !files.iter().any(|(path, _)| path == "game/index.html") {
+ return Err("项目完整性检查失败:缺少 game/index.html".to_string());
+ }
+ let artifact_sha256 = hash_runnable_artifact_files(&files);
+ let versions_root = root.join(".agent/runnable-versions");
+ fs::create_dir_all(&versions_root).map_err(|_| "创建可运行版本目录失败".to_string())?;
+ let final_root = runnable_version_root(root, version_id);
+ if final_root.exists() {
+ let final_metadata = fs::symlink_metadata(&final_root)
+ .map_err(|_| "可运行版本快照冲突:无法读取已有版本目录".to_string())?;
+ if final_metadata.file_type().is_symlink() || !final_metadata.is_dir() {
+ return Err("可运行版本快照冲突:已有版本目录无效".to_string());
+ }
+ let descriptor_path = final_root.join("version.json");
+ let descriptor_metadata = fs::symlink_metadata(&descriptor_path)
+ .map_err(|_| "可运行版本快照冲突:无法读取已有版本描述".to_string())?;
+ if descriptor_metadata.file_type().is_symlink() || !descriptor_metadata.is_file() {
+ return Err("可运行版本快照冲突:已有版本描述不是普通文件".to_string());
+ }
+ let descriptor = serde_json::from_str::(
+ &fs::read_to_string(&descriptor_path)
+ .map_err(|_| "可运行版本快照冲突:无法读取已有版本描述".to_string())?,
+ )
+ .map_err(|_| "可运行版本快照冲突:已有版本描述无效".to_string())?;
+ if descriptor.schema_version != RUNNABLE_ARTIFACT_DESCRIPTOR_SCHEMA_VERSION
+ || descriptor.project_id != project_id
+ || descriptor.version_id != version_id
+ || descriptor.project_revision != revision
+ {
+ return Err("可运行版本快照冲突:已有版本身份或 revision 不一致".to_string());
+ }
+ let existing_files = read_runnable_artifact_files(&final_root.join("artifact"))
+ .map_err(|error| format!("可运行版本快照冲突:{error}"))?;
+ let existing_sha256 = hash_runnable_artifact_files(&existing_files);
+ if existing_sha256 != descriptor.artifact_sha256
+ || descriptor.artifact_sha256 != artifact_sha256
+ {
+ return Err("可运行版本快照冲突:已有版本产物摘要不一致".to_string());
+ }
+ return Ok(WrittenRunnableArtifactSnapshot {
+ artifact_sha256: descriptor.artifact_sha256,
+ created_at: descriptor.created_at,
+ });
+ }
+ let stage_root = versions_root.join(format!(
+ ".tmp-{version_id}-{}-{}",
+ std::process::id(),
+ SystemTime::now()
+ .duration_since(UNIX_EPOCH)
+ .unwrap_or_default()
+ .as_nanos()
+ ));
+ let stage_artifact = stage_root.join("artifact");
+ let install_result = (|| {
+ for (relative, bytes) in &files {
+ let target = stage_artifact.join(relative);
+ if let Some(parent) = target.parent() {
+ fs::create_dir_all(parent).map_err(|_| "创建可运行版本快照目录失败".to_string())?;
+ }
+ fs::write(&target, bytes).map_err(|_| "写入可运行版本快照失败".to_string())?;
+ }
+ let descriptor = RunnableArtifactDescriptor {
+ schema_version: RUNNABLE_ARTIFACT_DESCRIPTOR_SCHEMA_VERSION.to_string(),
+ project_id: project_id.to_string(),
+ version_id: version_id.to_string(),
+ project_revision: revision,
+ artifact_sha256: artifact_sha256.clone(),
+ created_at,
+ };
+ let payload = serde_json::to_string_pretty(&descriptor)
+ .map_err(|_| "序列化可运行版本描述失败".to_string())?;
+ fs::write(stage_root.join("version.json"), format!("{payload}\n"))
+ .map_err(|_| "写入可运行版本描述失败".to_string())?;
+ fs::rename(&stage_root, &final_root).map_err(|_| "安装可运行版本快照失败".to_string())?;
+ Ok(())
+ })();
+ if install_result.is_err() {
+ let _ = fs::remove_dir_all(&stage_root);
+ }
+ install_result.map(|()| WrittenRunnableArtifactSnapshot {
+ artifact_sha256,
+ created_at,
+ })
+}
+
+pub(crate) fn validate_runnable_game_version_artifact_at(
+ root: &Path,
+ version: &RunnableGameVersion,
+) -> Result {
+ let version_root = runnable_version_root(root, &version.version_id);
+ let version_root_metadata = fs::symlink_metadata(&version_root)
+ .map_err(|_| "可运行版本已损坏:版本目录不存在".to_string())?;
+ if version_root_metadata.file_type().is_symlink() || !version_root_metadata.is_dir() {
+ return Err("可运行版本已损坏:版本目录无效".to_string());
+ }
+ let descriptor_path = version_root.join("version.json");
+ let metadata = fs::symlink_metadata(&descriptor_path)
+ .map_err(|_| "可运行版本已损坏:版本描述不存在".to_string())?;
+ if metadata.file_type().is_symlink() || !metadata.is_file() {
+ return Err("可运行版本已损坏:版本描述不是普通文件".to_string());
+ }
+ let descriptor = serde_json::from_str::(
+ &fs::read_to_string(&descriptor_path)
+ .map_err(|_| "可运行版本已损坏:无法读取版本描述".to_string())?,
+ )
+ .map_err(|_| "可运行版本已损坏:版本描述无效".to_string())?;
+ if descriptor.schema_version != RUNNABLE_ARTIFACT_DESCRIPTOR_SCHEMA_VERSION
+ || descriptor.project_id != version.project_id
+ || descriptor.version_id != version.version_id
+ {
+ return Err("可运行版本已损坏:版本身份不一致".to_string());
+ }
+ if descriptor.project_revision != version.project_revision {
+ return Err("可运行版本 revision 不一致".to_string());
+ }
+ if descriptor.artifact_sha256 != version.artifact_sha256
+ || descriptor.created_at != version.created_at
+ {
+ return Err("可运行版本已损坏:版本描述与 manifest 不一致".to_string());
+ }
+ let artifact_root = version_root.join("artifact");
+ let files = read_runnable_artifact_files(&artifact_root)
+ .map_err(|error| format!("可运行版本已损坏:{error}"))?;
+ if hash_runnable_artifact_files(&files) != version.artifact_sha256 {
+ return Err("可运行版本已损坏:产物摘要不一致".to_string());
+ }
+ Ok(artifact_root)
+}
+
+pub(crate) fn register_current_runnable_game_version_at(
+ root: &Path,
+ revision: u64,
+ validation_agent_id: &str,
+ validation_run_id: &str,
+ report_path: &str,
+ playtest_scenario: BrowserPlaytestScenario,
+) -> Result {
+ let manifest_path = root.join(".agent/manifest.json");
+ let mut manifest = read_manifest(&manifest_path)?;
+ if let Some(existing) = manifest
+ .runnable_versions
+ .iter()
+ .find(|version| version.project_revision == revision)
+ {
+ validate_runnable_game_version_artifact_at(root, existing)?;
+ return Ok(existing.clone());
+ }
+ if manifest
+ .runnable_versions
+ .last()
+ .is_some_and(|version| version.project_revision >= revision)
+ {
+ return Err("可运行版本 revision 必须严格递增".to_string());
+ }
+ if root.join(TUNABLE_PARAMETER_REGISTRY_PATH).exists()
+ || root.join(TUNABLE_PARAMETER_VALUES_PATH).exists()
+ {
+ let mut definitions = read_tunable_parameter_registry(root)?;
+ let values = read_editable_tunable_values(root)?;
+ overlay_editable_tunable_values(&mut definitions, values.as_ref())?;
+ }
+ let version_id = runnable_version_id(revision);
+ let created_at = unix_timestamp_millis();
+ let snapshot = write_runnable_artifact_snapshot(
+ root,
+ &manifest.project_id,
+ &version_id,
+ revision,
+ created_at,
+ )?;
+ let created_at = snapshot.created_at;
+ let parent_version_id = manifest
+ .runnable_versions
+ .last()
+ .map(|version| version.version_id.clone());
+ let created_reason = if parent_version_id.is_some() {
+ RunnableGameVersionCreatedReason::AgentRevision
+ } else {
+ RunnableGameVersionCreatedReason::Initial
+ };
+ let mut resource_bindings = manifest
+ .assets
+ .iter()
+ .map(|asset| GameIterationVersionResourceBinding {
+ slot_id: asset.id.clone(),
+ resource_id: asset.id.clone(),
+ })
+ .collect::>();
+ resource_bindings.sort_by(|left, right| left.slot_id.cmp(&right.slot_id));
+ let test_slices = runnable_test_slices(&version_id, playtest_scenario);
+ let resource_descriptors = runnable_resource_descriptors(root, &manifest);
+ let version = RunnableGameVersion {
+ schema_version: RUNNABLE_GAME_VERSION_SCHEMA_VERSION.to_string(),
+ version_id: version_id.clone(),
+ project_id: manifest.project_id.clone(),
+ parent_version_id,
+ project_revision: revision,
+ artifact_path: format!(".agent/runnable-versions/{version_id}/artifact"),
+ artifact_sha256: snapshot.artifact_sha256,
+ entry_path: "game/index.html".to_string(),
+ resource_bindings,
+ test_slices,
+ resource_descriptors,
+ resource_replacement: None,
+ created_reason,
+ validation: RunnableGameVersionValidation {
+ static_smoke_passed: true,
+ preview_validate_passed: true,
+ playtest_passed: true,
+ agent_id: validation_agent_id.to_string(),
+ run_id: validation_run_id.to_string(),
+ report_path: report_path.to_string(),
+ },
+ created_at,
+ };
+ manifest.runnable_versions.push(version.clone());
+ manifest.current_runnable_version_id = Some(version.version_id.clone());
+ write_manifest(&manifest_path, &manifest)?;
+ // manifest 是可运行版本的唯一事实源;快照和 manifest 已提交后,审计写入失败
+ // 不能再把一次成功登记伪装成失败,否则重试会看到版本存在却缺少首轮成功结果。
+ let _ = append_agent_db_record(
+ root,
+ serde_json::json!({
+ "recordType": "project.runnable_version.registered",
+ "versionId": version.version_id,
+ "projectRevision": version.project_revision,
+ "parentVersionId": version.parent_version_id,
+ "artifactSha256": version.artifact_sha256,
+ "validationAgentId": validation_agent_id,
+ "validationRunId": validation_run_id,
+ }),
+ );
+ Ok(version)
+}
+
+pub(crate) fn select_current_runnable_game_version_at(
+ root: &Path,
+ expected_project_id: &str,
+ version_id: &str,
+) -> Result<(GameCreationAppManifest, RunnableGameVersion, PathBuf), String> {
+ let (mut manifest, version, artifact_root) =
+ resolve_runnable_game_version_at(root, expected_project_id, version_id)?;
+ let manifest_path = root.join(".agent/manifest.json");
+ manifest.current_runnable_version_id = Some(version.version_id.clone());
+ write_manifest(&manifest_path, &manifest)?;
+ Ok((manifest, version, artifact_root))
+}
+
+pub(crate) fn resolve_runnable_game_version_at(
+ root: &Path,
+ expected_project_id: &str,
+ version_id: &str,
+) -> Result<(GameCreationAppManifest, RunnableGameVersion, PathBuf), String> {
+ let manifest_path = root.join(".agent/manifest.json");
+ let manifest = read_manifest(&manifest_path)?;
+ if manifest.project_id != expected_project_id {
+ return Err("可运行版本项目身份不一致".to_string());
+ }
+ let version = manifest
+ .runnable_versions
+ .iter()
+ .find(|version| version.version_id == version_id)
+ .cloned()
+ .ok_or_else(|| "当前无可运行版本".to_string())?;
+ let artifact_root = validate_runnable_game_version_artifact_at(root, &version)?;
+ Ok((manifest, version, artifact_root))
+}
+
+fn compatible_resource_size_spec(
+ previous: &GameResourceDescriptor,
+ replacement: &GameResourceDescriptor,
+) -> bool {
+ if previous.format != replacement.format {
+ return false;
+ }
+ match previous.category {
+ GameResourceCategory::Art if previous.format.starts_with("image/") => {
+ previous.width.is_some()
+ && previous.height.is_some()
+ && previous.width == replacement.width
+ && previous.height == replacement.height
+ }
+ GameResourceCategory::Art => {
+ previous.width.is_some()
+ && previous.height.is_some()
+ && previous.codec.is_some()
+ && previous.width == replacement.width
+ && previous.height == replacement.height
+ && previous.codec == replacement.codec
+ }
+ GameResourceCategory::Audio => {
+ previous.duration_ms.is_some()
+ && previous.channel_count.is_some()
+ && previous.sample_rate_hz.is_some()
+ && previous.codec.is_some()
+ && previous.duration_ms == replacement.duration_ms
+ && previous.channel_count == replacement.channel_count
+ && previous.sample_rate_hz == replacement.sample_rate_hz
+ && previous.codec == replacement.codec
+ }
+ GameResourceCategory::Document | GameResourceCategory::Version => false,
+ }
+}
+
+fn read_manifest_asset_bytes(
+ root: &Path,
+ asset: &GameCreationAppAssetManifestEntry,
+) -> Result, String> {
+ let relative =
+ normalize_relative_path(&asset.local_path).map_err(|_| "候选资源路径无效".to_string())?;
+ let path = resolve_local_project_path(root, &relative)?;
+ let metadata = fs::symlink_metadata(&path).map_err(|_| "候选资源不存在".to_string())?;
+ if metadata.file_type().is_symlink() || !metadata.is_file() {
+ return Err("候选资源必须是项目内普通文件".to_string());
+ }
+ if metadata.len() > RUNNABLE_ARTIFACT_MAX_FILE_BYTES {
+ return Err("候选资源文件大小超过限制".to_string());
+ }
+ read_runnable_source_file(&path, metadata.len())
+ .map_err(|_| "候选资源读取期间发生变化".to_string())
+}
+
+pub(crate) fn replace_runnable_game_resource_at(
+ root: &Path,
+ expected_project_id: &str,
+ parent_version_id: &str,
+ slot_id: &str,
+ replacement_resource_id: &str,
+ expected_project_revision: u64,
+) -> Result {
+ let manifest_path = root.join(".agent/manifest.json");
+ let mut manifest = read_manifest(&manifest_path)?;
+ if manifest.project_id != expected_project_id {
+ return Err("资源替换项目身份不一致".to_string());
+ }
+ if manifest.current_runnable_version_id.as_deref() != Some(parent_version_id) {
+ return Err("只能从当前可运行版本发起资源替换".to_string());
+ }
+ let current_revision = read_game_creator_agent_runtime_project_revision(root)?.revision;
+ if current_revision != expected_project_revision {
+ return Err("项目 revision 已变化,请重新选择资源后再试".to_string());
+ }
+ let parent = manifest
+ .runnable_versions
+ .iter()
+ .find(|version| version.version_id == parent_version_id)
+ .cloned()
+ .ok_or_else(|| "资源替换父版本不存在".to_string())?;
+ let parent_artifact = validate_runnable_game_version_artifact_at(root, &parent)?;
+ let previous_binding = parent
+ .resource_bindings
+ .iter()
+ .find(|binding| binding.slot_id == slot_id)
+ .cloned()
+ .ok_or_else(|| "当前版本不存在该资源绑定槽位".to_string())?;
+ if previous_binding.resource_id == replacement_resource_id {
+ return Err("候选资源与当前资源相同".to_string());
+ }
+ let previous_asset = manifest
+ .assets
+ .iter()
+ .find(|asset| asset.id == previous_binding.resource_id)
+ .cloned()
+ .ok_or_else(|| "替换前资源已不在当前项目清单中".to_string())?;
+ let replacement_asset = manifest
+ .assets
+ .iter()
+ .find(|asset| asset.id == replacement_resource_id)
+ .cloned()
+ .ok_or_else(|| "候选资源未登记在当前项目清单中".to_string())?;
+ let previous_descriptor = parent
+ .resource_descriptors
+ .iter()
+ .find(|descriptor| descriptor.resource_id == previous_binding.resource_id)
+ .cloned()
+ .ok_or_else(|| "当前版本缺少替换前资源的可信元数据".to_string())?;
+ let replacement_descriptor = runnable_resource_descriptor(root, &replacement_asset)
+ .ok_or_else(|| "候选资源类型不支持正式替换".to_string())?;
+ if !matches!(
+ previous_descriptor.category,
+ GameResourceCategory::Art | GameResourceCategory::Audio
+ ) {
+ return Err("当前仅支持替换美术或音乐音效资源".to_string());
+ }
+ if previous_descriptor.category != replacement_descriptor.category
+ || previous_descriptor.subtype != replacement_descriptor.subtype
+ || !compatible_resource_size_spec(&previous_descriptor, &replacement_descriptor)
+ {
+ return Err("资源类型不一致,无法替换(类别、subtype 或尺寸规格不匹配)".to_string());
+ }
+ let previous_path = normalize_relative_path(&previous_asset.local_path)
+ .map_err(|_| "替换前资源路径无效".to_string())?;
+ if !previous_path.starts_with("assets/") {
+ return Err("替换前资源不属于可替换的 assets 产物".to_string());
+ }
+ let replacement_bytes = read_manifest_asset_bytes(root, &replacement_asset)?;
+ let mut files = read_runnable_artifact_files(&parent_artifact)?;
+ let Some((_, previous_bytes)) = files.iter_mut().find(|(path, _)| path == &previous_path)
+ else {
+ return Err("父版本快照中不存在替换前资源文件".to_string());
+ };
+ *previous_bytes = replacement_bytes;
+
+ let next_revision = advance_agent_runtime_project_revision_locked(root)?;
+ let version_id = runnable_version_id(next_revision);
+ if manifest
+ .runnable_versions
+ .iter()
+ .any(|version| version.version_id == version_id)
+ {
+ return Err("资源替换目标版本身份已存在".to_string());
+ }
+ let created_at = unix_timestamp_millis();
+ let snapshot = write_runnable_artifact_snapshot_from_files(
+ root,
+ &manifest.project_id,
+ &version_id,
+ next_revision,
+ created_at,
+ files,
+ )?;
+ let mut resource_bindings = parent.resource_bindings.clone();
+ let binding = resource_bindings
+ .iter_mut()
+ .find(|binding| binding.slot_id == slot_id)
+ .ok_or_else(|| "资源替换槽位在提交前消失".to_string())?;
+ binding.resource_id = replacement_resource_id.to_string();
+ let retained_resource_ids = resource_bindings
+ .iter()
+ .map(|binding| binding.resource_id.as_str())
+ .collect::>();
+ let mut resource_descriptors = parent
+ .resource_descriptors
+ .iter()
+ .filter(|descriptor| retained_resource_ids.contains(descriptor.resource_id.as_str()))
+ .cloned()
+ .collect::>();
+ if !resource_descriptors
+ .iter()
+ .any(|descriptor| descriptor.resource_id == replacement_descriptor.resource_id)
+ {
+ resource_descriptors.push(replacement_descriptor);
+ }
+ resource_descriptors.sort_by(|left, right| left.resource_id.cmp(&right.resource_id));
+ let test_slices = parent
+ .test_slices
+ .iter()
+ .cloned()
+ .map(|mut slice| {
+ slice.version_id = version_id.clone();
+ slice.status = GameTestSliceStatus::Idle;
+ slice
+ })
+ .collect();
+ let version = RunnableGameVersion {
+ schema_version: RUNNABLE_GAME_VERSION_SCHEMA_VERSION.to_string(),
+ version_id: version_id.clone(),
+ project_id: manifest.project_id.clone(),
+ parent_version_id: Some(parent.version_id.clone()),
+ project_revision: next_revision,
+ artifact_path: format!(".agent/runnable-versions/{version_id}/artifact"),
+ artifact_sha256: snapshot.artifact_sha256,
+ entry_path: "game/index.html".to_string(),
+ resource_bindings,
+ test_slices,
+ resource_descriptors,
+ resource_replacement: Some(RunnableGameResourceReplacement {
+ slot_id: slot_id.to_string(),
+ previous_resource_id: previous_binding.resource_id,
+ replacement_resource_id: replacement_resource_id.to_string(),
+ }),
+ created_reason: RunnableGameVersionCreatedReason::ResourceReplacement,
+ validation: parent.validation,
+ created_at: snapshot.created_at,
+ };
+ manifest.runnable_versions.push(version.clone());
+ write_manifest(&manifest_path, &manifest)?;
+ let _ = append_agent_db_record(
+ root,
+ serde_json::json!({
+ "recordType": "project.runnable_version.resource_replaced",
+ "versionId": version.version_id,
+ "parentVersionId": version.parent_version_id,
+ "projectRevision": version.project_revision,
+ "slotId": slot_id,
+ "previousResourceId": version.resource_replacement.as_ref().map(|value| &value.previous_resource_id),
+ "replacementResourceId": replacement_resource_id,
+ }),
+ );
+ Ok(ReplaceRunnableGameResourceResult { manifest, version })
+}
+
+fn valid_tunable_parameter_id(value: &str) -> bool {
+ !value.is_empty()
+ && value.len() <= 128
+ && value
+ .bytes()
+ .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-'))
+}
+
+fn validate_tunable_value(
+ definition: &GameTunableParameterDefinition,
+ value: &serde_json::Value,
+) -> Result<(), String> {
+ match definition.value_type {
+ GameTunableParameterValueType::Boolean => {
+ if !value.is_boolean() {
+ return Err("参数值必须是布尔值".to_string());
+ }
+ }
+ GameTunableParameterValueType::Enum => {
+ let candidate = value
+ .as_str()
+ .ok_or_else(|| "枚举参数值必须是字符串".to_string())?;
+ if !definition
+ .enum_values
+ .as_ref()
+ .is_some_and(|values| values.iter().any(|value| value == candidate))
+ {
+ return Err("枚举参数值不在允许列表中".to_string());
+ }
+ }
+ GameTunableParameterValueType::Integer | GameTunableParameterValueType::Number => {
+ let number = value
+ .as_f64()
+ .filter(|number| number.is_finite())
+ .ok_or_else(|| "数值参数必须是有限数字".to_string())?;
+ if definition.value_type == GameTunableParameterValueType::Integer
+ && number.fract() != 0.0
+ {
+ return Err("整数参数不能包含小数".to_string());
+ }
+ if definition.min.is_some_and(|min| number < min)
+ || definition.max.is_some_and(|max| number > max)
+ {
+ return Err("参数值超出允许范围".to_string());
+ }
+ if let Some(step) = definition.step {
+ let base = definition.min.unwrap_or(0.0);
+ let steps = (number - base) / step;
+ if (steps - steps.round()).abs() > 1e-8 {
+ return Err("参数值不符合步长约束".to_string());
+ }
+ }
+ }
+ }
+ Ok(())
+}
+
+fn validate_tunable_definition(definition: &GameTunableParameterDefinition) -> Result<(), String> {
+ if !valid_tunable_parameter_id(&definition.parameter_id)
+ || definition.label.trim().is_empty()
+ || definition.label.len() > 128
+ {
+ return Err("参数 ID 或显示名称无效".to_string());
+ }
+ let expected_write_path = format!(
+ "{TUNABLE_PARAMETER_VALUES_PATH}#/values/{}",
+ definition.parameter_id
+ );
+ if definition.write_path != expected_write_path
+ || definition.effect_mode != "next-relaunch"
+ || definition.editable_phase != "paused"
+ {
+ return Err("参数 writePath、生效方式或可编辑阶段不在白名单中".to_string());
+ }
+ if definition.min.is_some_and(|value| !value.is_finite())
+ || definition.max.is_some_and(|value| !value.is_finite())
+ || definition
+ .step
+ .is_some_and(|value| !value.is_finite() || value <= 0.0)
+ || matches!((definition.min, definition.max), (Some(min), Some(max)) if min > max)
+ {
+ return Err("参数范围或步长无效".to_string());
+ }
+ match definition.value_type {
+ GameTunableParameterValueType::Enum => {
+ let values = definition
+ .enum_values
+ .as_ref()
+ .filter(|values| !values.is_empty() && values.len() <= 64)
+ .ok_or_else(|| "枚举参数缺少允许值".to_string())?;
+ let mut unique = std::collections::HashSet::new();
+ if values
+ .iter()
+ .any(|value| value.trim().is_empty() || value.len() > 128 || !unique.insert(value))
+ {
+ return Err("枚举参数允许值无效或重复".to_string());
+ }
+ }
+ _ if definition.enum_values.is_some() => {
+ return Err("非枚举参数不能声明 enumValues".to_string());
+ }
+ _ => {}
+ }
+ validate_tunable_value(definition, &definition.default_value)?;
+ validate_tunable_value(definition, &definition.current_value)
+}
+
+fn read_tunable_parameter_registry(
+ artifact_root: &Path,
+) -> Result, String> {
+ let path = artifact_root.join(TUNABLE_PARAMETER_REGISTRY_PATH);
+ let metadata = match fs::symlink_metadata(&path) {
+ Ok(metadata) => metadata,
+ Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
+ Err(_) => return Err("读取数值参数注册表失败".to_string()),
+ };
+ if metadata.file_type().is_symlink()
+ || !metadata.is_file()
+ || metadata.len() > TUNABLE_PARAMETER_FILE_MAX_BYTES
+ {
+ return Err("数值参数注册表必须是受限普通文件".to_string());
+ }
+ let payload = fs::read_to_string(&path).map_err(|_| "读取数值参数注册表失败".to_string())?;
+ let registry = serde_json::from_str::(&payload)
+ .map_err(|_| "数值参数注册表格式无效".to_string())?;
+ if registry.schema_version != TUNABLE_PARAMETER_REGISTRY_SCHEMA_VERSION
+ || registry.parameters.len() > TUNABLE_PARAMETER_MAX_COUNT
+ {
+ return Err("数值参数注册表合同或数量无效".to_string());
+ }
+ let mut ids = std::collections::HashSet::new();
+ for definition in ®istry.parameters {
+ validate_tunable_definition(definition)?;
+ if !ids.insert(definition.parameter_id.as_str()) {
+ return Err("数值参数注册表包含重复参数 ID".to_string());
+ }
+ }
+ Ok(registry.parameters)
+}
+
+fn read_editable_tunable_values(root: &Path) -> Result
) : null}
@@ -1433,6 +2099,17 @@ export default function ProjectDevelopmentView({
>
) : null}
+ {focusedReplacementBindings.length > 0 &&
+ (focusedResource.category === 'art' ||
+ focusedResource.category === 'audio') ? (
+ void openResourceReplacement()}
+ >
+ 替换资源
+
+ ) : null}
) : mode === 'resources' ? (
@@ -1494,7 +2171,10 @@ export default function ProjectDevelopmentView({
) : null}
-
+
{sortMode === 'dependency' ? (
{embeddedPreviewUrl ? (
) : (
- 客户端运行画面尚未载入
- 发送 /run 或 /preview 后将在这里直接运行游戏
+
+ {runSwitching ? '正在切换可运行版本' : '运行画面未启动'}
+
+ {runStatus || '请重新启动当前可运行版本'}
)}
@@ -1597,83 +2288,328 @@ export default function ProjectDevelopmentView({
>
- setActiveSlice((current) => Math.max(0, current - 1))
+ disabled={
+ !selectedTestSlice ||
+ selectedTestSliceIndex === 0 ||
+ activeTestSlice?.stale === true ||
+ gameRunSession?.stale === true
}
+ onClick={() => switchTestSlice(-1)}
>
-
+
上一项
setRunPlaying((playing) => !playing)}
+ disabled={
+ !selectedTestSlice ||
+ gameRunSession?.status === 'failed' ||
+ activeTestSlice?.stale === true ||
+ gameRunSession?.stale === true
+ }
+ aria-label={
+ activeTestSlice?.status === 'playing'
+ ? '暂停测试切片'
+ : activeTestSlice?.status === 'paused'
+ ? '继续测试切片'
+ : '播放测试切片'
+ }
+ onClick={toggleTestSlicePlayback}
>
- {runPlaying ? (
-
+ {activeTestSlice?.status === 'playing' ? (
+
) : (
-
+
)}
-
{`测试切片 ${activeSlice + 1}`}
+
+ {selectedTestSlice
+ ? `${selectedTestSliceIndex + 1} / ${testSlices.length} · ${selectedTestSlice.title} · ${
+ activeTestSlice?.sliceId === selectedTestSlice.sliceId
+ ? activeTestSlice.status
+ : 'idle'
+ }`
+ : '当前版本未登记正式测试切片'}
+
setActiveSlice((current) => current + 1)}
+ disabled={
+ !selectedTestSlice ||
+ selectedTestSliceIndex >= testSlices.length - 1 ||
+ activeTestSlice?.stale === true ||
+ gameRunSession?.stale === true
+ }
+ onClick={() => switchTestSlice(1)}
>
下一项
-
+
-
+
- {selectedResource ? (
+ {currentRunnableVersion ? (
-
- 名称
- - {selectedResource.label}
+ - 版本
+ - {currentRunnableVersion.versionId}
-
- 路径
- - {selectedResource.path}
+ - 修订
+ - {currentRunnableVersion.projectRevision}
-
- 类型
- - {selectedResource.mediaType}
+ - 验证
+ - 静态检查与交互试玩均通过
) : (
- 暂停后选择资源可查看已登记信息
+ 当前无可运行版本
)}
-
+
+ {gameRunSession ? (
+
+
+
- 状态
+ - {gameRunSession.status}
+
+
+
- 协议
+ - {gameRunSession.protocolVersion}
+
+
+
- 消息
+ - {latestRuntimeMessage?.type ?? '等待 ready'}
+
+
+ ) : (
+ 等待 iframe 建立会话
+ )}
+
+ {gameRunSession?.status === 'paused' ? '继续' : '暂停'}
+
+
+
+
+ {trustedInspection ? (
+ <>
+
+
+
- 名称
+ - {trustedInspection.name}
+
+
+
- 类别
+ -
+ {categoryLabels[trustedInspection.category]} ·{' '}
+ {trustedInspection.format}
+
+
+
+
- 规格
+ -
+ {trustedInspection.width && trustedInspection.height
+ ? `${trustedInspection.width} × ${trustedInspection.height}`
+ : trustedInspection.durationMs
+ ? `${Math.round(trustedInspection.durationMs / 100) / 10} 秒`
+ : '未登记'}
+
+
+
+
- 版本
+ - {trustedInspection.versionId}
+
+
+
- 绑定
+ - {trustedInspection.slotId}
+
+
+
+ 在资源管理中定位
+
+ >
+ ) : (
+ 暂无悬停资源
+ )}
+
+
+ {runStatus ? (
+
+ {runStatus}
+
+ ) : null}
)}
+ {mode !== 'run' && runStatus && (runAvailable || focusedResource) ? (
+
+ {runStatus}
+
+ ) : null}