Compare commits
40 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6dca066a87 | |||
| 02ae3d3cad | |||
| d04339ad7f | |||
| 6fe52a92bb | |||
| 75e3f1d041 | |||
| 8a4b71bf8e | |||
| 53e37ea361 | |||
| dc7c7dc6cc | |||
| 3ec3a09380 | |||
| 752505547d | |||
| 203ce5b9e7 | |||
| d1a47c0fe7 | |||
| 2e0ef02fda | |||
| ab16c87c5d | |||
| 6d8c7ae496 | |||
| 80bcb4ba0e | |||
| df4e61a208 | |||
| fef53b634e | |||
| ba996aad80 | |||
| d374f3292a | |||
| 2c65878b60 | |||
| 7339bb5da1 | |||
| c24e3010f8 | |||
| d8064eff49 | |||
| bda0d0d398 | |||
| b95da30721 | |||
| 7f038490f1 | |||
| 05c608214e | |||
| 37f4a63112 | |||
| fe34eee052 | |||
| cced839da5 | |||
| e77d187497 | |||
| 29dce59b35 | |||
| f582ecf032 | |||
| a431ab47cd | |||
| 11140b9fb6 | |||
| 246fd1d9d6 | |||
| 68ea9bdff3 | |||
| 0b6be155dd | |||
| 8531a9af3e |
@@ -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",
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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::<std::collections::HashMap<_, _>>();
|
||||
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::<Vec<_>>();
|
||||
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/<parameterId>、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}");
|
||||
}
|
||||
|
||||
+9
-3
@@ -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 必须持续更新 <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=\"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 必须再次严格推进 sequence,primary-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 始终不得回退。"
|
||||
"初始状态必须是 ready 且 level 为正整数;start 后状态必须推进并进入 playing,并先至少持续 2 秒保持 playing,让玩家获得可操作机会,在 primary-action 之前进入 ready、won 或 lost 都会失败;随后 primary-action 必须再次严格推进 sequence,primary-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 始终不得回退。",
|
||||
"还必须监听 genarrative:host-message 中的 host.slice.start / host.slice.stop;generic-v1 的正式 sliceId 固定为 core-gameplay,必须通过 window.genarrativeGameBridge.reportSlice 上报该 ID 的 playing、paused、completed 或 failed。游戏自身 hit-test / hover 只允许通过 reportTargetInspection 上报当前 .agent/manifest.json 已登记的 asset.id,或与该 asset.id 对应的资源槽位 slotId;离开对象调用 clearInspection,不得上报资源名称、路径、HTML 或宿主命令。"
|
||||
)
|
||||
}
|
||||
BrowserPlaytestScenario::TetrisV1 => {
|
||||
@@ -6533,7 +6538,8 @@ pub(in crate::agent) fn autonomous_playtest_contract_prompt(
|
||||
"完成合同要求 lane-defense-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、selectedDefenderId、defenders 数组、enemies 数组;每个 enemy 必须含非空 id、非负 lane、会随移动变化的 position、health 与正数 maxHealth。",
|
||||
"界面必须清晰显示一个原创项目标题、至少两个原创防御单位选项、资源与波次状态,以及开始、加速、下一关和重开等可理解操作;玩法类型不授权复刻现有游戏,不得沿用、翻译或近似改写现有作品的角色、单位名、Logo、贴图、标志性布局或受保护视觉语言。界面必须提供 data-playtest-id=\"start\"、data-playtest-id=\"defender-option\"、data-playtest-id=\"lane-cell\"、data-playtest-id=\"speed-up\"、data-playtest-id=\"next-level\"、data-playtest-id=\"restart\" 的真实可点击控件;每个固定 data-playtest-id 在对应受控试玩步骤都必须恰好匹配一个可见且启用(disabled=false)的真实可点击 HTMLElement,同一固定值不得出现在多个控件上。",
|
||||
"防御单位多选项 UI 只能给一个真实控件设置 data-playtest-id=\"defender-option\" 作为自动化入口,关卡多格 UI 只能给一个真实控件设置 data-playtest-id=\"lane-cell\" 作为自动化入口,其余选项和格子不得复用这两个固定值。",
|
||||
"受控试玩会依次开始、选择并放置防御单位、加速,要求敌人移动并受伤、关卡进入 won;随后 next-level 必须让 level 增加,restart 必须再次推进 sequence 并回到 ready 或 playing。"
|
||||
"受控试玩会依次开始、选择并放置防御单位、加速,要求敌人移动并受伤、关卡进入 won;随后 next-level 必须让 level 增加,restart 必须再次推进 sequence 并回到 ready 或 playing。",
|
||||
"还必须监听 genarrative:host-message 中的 host.slice.start / host.slice.stop;lane-defense-v1 的正式 sliceId 依次固定为 deploy-defender、resolve-wave、advance-level,必须通过 window.genarrativeGameBridge.reportSlice 上报当前 ID 的 playing、paused、completed 或 failed。游戏自身 hit-test / hover 只允许通过 reportTargetInspection 上报当前 .agent/manifest.json 已登记的 asset.id,或与该 asset.id 对应的资源槽位 slotId;离开对象调用 clearInspection,不得上报资源名称、路径、HTML 或宿主命令。"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+183
@@ -333,6 +333,16 @@ fn prepare_completed_autonomous_manifest_fixture(root: &Path) {
|
||||
.expect("write game design fixture");
|
||||
fs::write(root.join("game/balance.json"), br#"{"lives":3,"speed":1}"#)
|
||||
.expect("write balance fixture");
|
||||
fs::write(
|
||||
root.join("game/tunable-parameters.json"),
|
||||
br#"{"schemaVersion":"game-creator-tunable-parameters.v1","parameters":[]}"#,
|
||||
)
|
||||
.expect("write tunable parameter registry fixture");
|
||||
fs::write(
|
||||
root.join("game/tunable-values.json"),
|
||||
br#"{"schemaVersion":"game-creator-tunable-values.v1","values":{}}"#,
|
||||
)
|
||||
.expect("write tunable parameter values fixture");
|
||||
fs::write(
|
||||
root.join("assets/manifest.art.json"),
|
||||
br#"{"assets":["art-spritesheet.png"]}"#,
|
||||
@@ -1030,6 +1040,16 @@ fn game_chat_pure_continue_inherits_failed_root_semantics_and_manifest_progress(
|
||||
.expect("continued root must pass the scheduler contract gate");
|
||||
assert_eq!(scheduled.len(), 1);
|
||||
assert_eq!(scheduled[0].state.agent_id, "design-director");
|
||||
let release_deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
|
||||
while !game_creator_agent_runtime_task_lock_is_available(&root, "design-director")
|
||||
.expect("probe scheduled design child lane")
|
||||
{
|
||||
assert!(
|
||||
std::time::Instant::now() < release_deadline,
|
||||
"scheduled design child lane did not settle before continuation validation"
|
||||
);
|
||||
std::thread::yield_now();
|
||||
}
|
||||
update_manifest_task_status_at(
|
||||
&root,
|
||||
"design-director",
|
||||
@@ -5350,6 +5370,143 @@ fn autonomous_preview_manifest_tasks_accept_bound_current_revision_receipts() {
|
||||
assert!(autonomous_game_build_completion_blocker_at_locked(&root, &playtest_state).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preview_playtest_terminal_registers_a_runnable_snapshot_before_downstream_publish_tasks_complete(
|
||||
) {
|
||||
let (_temporary, root, parent_state, contract) =
|
||||
autonomous_fixture("做一个完整小游戏", "autonomous-runnable-version-parent");
|
||||
for task_id in ["publish-strategy", "publish-package"] {
|
||||
update_manifest_task_status_at(&root, task_id, GameCreationAppTaskStatus::Pending)
|
||||
.unwrap_or_else(|error| panic!("leave downstream {task_id} pending: {error}"));
|
||||
}
|
||||
let task_ids = read_manifest_for_project(&root)
|
||||
.expect("read autonomous manifest")
|
||||
.tasks
|
||||
.into_iter()
|
||||
.map(|task| task.id)
|
||||
.collect::<Vec<_>>();
|
||||
for task_id in task_ids {
|
||||
if !matches!(
|
||||
task_id.as_str(),
|
||||
"preview-readiness" | "preview-playtest" | "publish-strategy" | "publish-package"
|
||||
) {
|
||||
update_manifest_task_status_at(&root, &task_id, GameCreationAppTaskStatus::Completed)
|
||||
.unwrap_or_else(|error| panic!("complete prerequisite {task_id}: {error}"));
|
||||
}
|
||||
}
|
||||
|
||||
update_manifest_task_status_at(
|
||||
&root,
|
||||
"preview-readiness",
|
||||
GameCreationAppTaskStatus::Running,
|
||||
)
|
||||
.expect("mark preview readiness running");
|
||||
let readiness_child =
|
||||
queue_autonomous_manifest_child_fixture(&root, &parent_state, "preview-readiness");
|
||||
let revision = advance_game_index_revision(
|
||||
&root,
|
||||
&parent_state,
|
||||
"<!doctype html><title>可运行版本</title><canvas></canvas>",
|
||||
);
|
||||
let readiness_state = agent_runtime_state_from_task_record(&readiness_child);
|
||||
mark_verification_passed(&root, &readiness_state, "game.static_smoke");
|
||||
let readiness_terminal = AgentRuntimeTaskRecord {
|
||||
status: "completed".to_string(),
|
||||
phase: "completed".to_string(),
|
||||
updated_at: unix_timestamp(),
|
||||
..readiness_child
|
||||
};
|
||||
append_game_creator_agent_runtime_task_record(&root, &readiness_terminal)
|
||||
.expect("persist completed preview readiness record");
|
||||
// This test isolates runnable-version registration. Calling the production
|
||||
// terminal projector here would asynchronously schedule preview-playtest;
|
||||
// the explicit child fixture below could then race it and create a second
|
||||
// logical run with a generated `-dup-*` run id.
|
||||
update_manifest_task_status_at(
|
||||
&root,
|
||||
"preview-readiness",
|
||||
GameCreationAppTaskStatus::Completed,
|
||||
)
|
||||
.expect("project preview readiness completion without scheduling the next wave");
|
||||
|
||||
update_manifest_task_status_at(
|
||||
&root,
|
||||
"preview-playtest",
|
||||
GameCreationAppTaskStatus::Running,
|
||||
)
|
||||
.expect("mark preview playtest running");
|
||||
let playtest_child =
|
||||
queue_autonomous_manifest_child_fixture(&root, &parent_state, "preview-playtest");
|
||||
let playtest_state = agent_runtime_state_from_task_record(&playtest_child);
|
||||
let result = browser_result_fixture(
|
||||
&root,
|
||||
&parent_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, &playtest_state.current_task);
|
||||
let action_id =
|
||||
agent_runtime_tool_action_id(&playtest_state.run_id, 1, 0, 1, &action_fingerprint);
|
||||
write_autonomous_playtest_receipt_at(
|
||||
&root,
|
||||
&contract,
|
||||
&action_id,
|
||||
&action_fingerprint,
|
||||
revision,
|
||||
&result,
|
||||
)
|
||||
.expect("persist runnable playtest receipt");
|
||||
let playtest_terminal = AgentRuntimeTaskRecord {
|
||||
status: "completed".to_string(),
|
||||
phase: "completed".to_string(),
|
||||
updated_at: unix_timestamp(),
|
||||
..playtest_child
|
||||
};
|
||||
append_game_creator_agent_runtime_task_record(&root, &playtest_terminal)
|
||||
.expect("persist completed preview playtest record");
|
||||
project_autonomous_manifest_ready_task_terminal_at(
|
||||
&root,
|
||||
&agent_runtime_state_from_task_record(&playtest_terminal),
|
||||
)
|
||||
.expect("project preview playtest and register runnable version");
|
||||
|
||||
let manifest = read_manifest_for_project(&root).expect("read runnable manifest");
|
||||
assert_eq!(manifest.runnable_versions.len(), 1);
|
||||
let version = &manifest.runnable_versions[0];
|
||||
assert_eq!(version.project_revision, revision);
|
||||
assert_eq!(
|
||||
version.created_reason,
|
||||
RunnableGameVersionCreatedReason::Initial
|
||||
);
|
||||
assert_eq!(
|
||||
manifest.current_runnable_version_id.as_deref(),
|
||||
Some(version.version_id.as_str())
|
||||
);
|
||||
assert!(root
|
||||
.join(&version.artifact_path)
|
||||
.join("game/index.html")
|
||||
.is_file());
|
||||
for task_id in ["publish-strategy", "publish-package"] {
|
||||
let status = manifest
|
||||
.tasks
|
||||
.iter()
|
||||
.find(|task| task.id == task_id)
|
||||
.map(|task| &task.status)
|
||||
.unwrap_or_else(|| panic!("missing downstream task {task_id}"));
|
||||
assert_ne!(
|
||||
status,
|
||||
&GameCreationAppTaskStatus::Completed,
|
||||
"runnable registration must not wait for downstream {task_id} completion"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn autonomous_completion_rejects_formal_artifact_unchanged_from_run_baseline() {
|
||||
let baseline_bytes =
|
||||
@@ -5449,6 +5606,32 @@ fn autonomous_playtest_contract_requires_unique_visible_enabled_automation_contr
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn autonomous_playtest_contract_freezes_formal_slice_and_hover_identifiers() {
|
||||
let generic = autonomous_playtest_contract_prompt(BrowserPlaytestScenario::GenericV1);
|
||||
for requirement in [
|
||||
"host.slice.start / host.slice.stop",
|
||||
"sliceId 固定为 core-gameplay",
|
||||
"window.genarrativeGameBridge.reportSlice",
|
||||
".agent/manifest.json 已登记的 asset.id",
|
||||
"资源槽位 slotId",
|
||||
"clearInspection",
|
||||
] {
|
||||
assert!(
|
||||
generic.contains(requirement),
|
||||
"missing generic P4/P5 requirement: {requirement}"
|
||||
);
|
||||
}
|
||||
|
||||
let lane = autonomous_playtest_contract_prompt(BrowserPlaytestScenario::LaneDefenseV1);
|
||||
for slice_id in ["deploy-defender", "resolve-wave", "advance-level"] {
|
||||
assert!(
|
||||
lane.contains(slice_id),
|
||||
"missing lane-defense formal slice ID: {slice_id}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generic_playtest_contract_requires_play_opportunity_and_post_action_outcome() {
|
||||
let prompt = autonomous_playtest_contract_prompt(BrowserPlaytestScenario::GenericV1);
|
||||
|
||||
@@ -996,6 +996,61 @@ pub(crate) fn register_local_asset(
|
||||
)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) fn replace_local_game_runnable_resource(
|
||||
project_path: String,
|
||||
expected_project_id: String,
|
||||
parent_version_id: String,
|
||||
slot_id: String,
|
||||
replacement_resource_id: String,
|
||||
expected_project_revision: u64,
|
||||
) -> Result<ReplaceRunnableGameResourceResult, String> {
|
||||
let root = Path::new(project_path.trim());
|
||||
enforce_project_permission_policy(root, "file.write")?;
|
||||
let _lock = acquire_project_write_lock(root, "runnable.resource_replace")?;
|
||||
replace_runnable_game_resource_at(
|
||||
root,
|
||||
expected_project_id.trim(),
|
||||
parent_version_id.trim(),
|
||||
slot_id.trim(),
|
||||
replacement_resource_id.trim(),
|
||||
expected_project_revision,
|
||||
)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) fn read_local_game_tunable_parameters(
|
||||
project_path: String,
|
||||
expected_project_id: String,
|
||||
version_id: String,
|
||||
) -> Result<GameTunableParametersReadModel, String> {
|
||||
let root = Path::new(project_path.trim());
|
||||
enforce_project_permission_policy(root, "file.read")?;
|
||||
read_game_tunable_parameters_at(root, expected_project_id.trim(), version_id.trim())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) fn update_local_game_tunable_parameter(
|
||||
project_path: String,
|
||||
expected_project_id: String,
|
||||
version_id: String,
|
||||
parameter_id: String,
|
||||
value: serde_json::Value,
|
||||
expected_project_revision: u64,
|
||||
) -> Result<UpdateGameTunableParameterResult, String> {
|
||||
let root = Path::new(project_path.trim());
|
||||
enforce_project_permission_policy(root, "file.write")?;
|
||||
let _lock = acquire_project_write_lock(root, "tunable.update")?;
|
||||
update_game_tunable_parameter_at(
|
||||
root,
|
||||
expected_project_id.trim(),
|
||||
version_id.trim(),
|
||||
parameter_id.trim(),
|
||||
value,
|
||||
expected_project_revision,
|
||||
)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) fn import_canvas_asset(
|
||||
project_path: String,
|
||||
|
||||
@@ -23,20 +23,25 @@ use reqwest::header;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use shared_contracts::game_creation_app::{
|
||||
new_game_creation_app_manifest, new_game_creation_app_seed_tasks,
|
||||
validate_game_iteration_versions, GameCreationAgentArtifactTrace,
|
||||
GameCreationAgentCapabilityDescriptor, GameCreationAgentPassPlanTrace,
|
||||
GameCreationAgentRepairRouteTrace, GameCreationAgentRunStep,
|
||||
validate_game_iteration_versions, validate_runnable_game_versions,
|
||||
GameCreationAgentArtifactTrace, GameCreationAgentCapabilityDescriptor,
|
||||
GameCreationAgentPassPlanTrace, GameCreationAgentRepairRouteTrace, GameCreationAgentRunStep,
|
||||
GameCreationAgentRunTaskGraphTrace, GameCreationAgentRunTrace, GameCreationAgentToolCallTrace,
|
||||
GameCreationAppAgentGroup, GameCreationAppAssetManifestEntry, GameCreationAppAssetSource,
|
||||
GameCreationAppAssetSourceKind, GameCreationAppCommandRunState,
|
||||
GameCreationAppCommandRunStatus, GameCreationAppLimitedRunCommandDescriptor,
|
||||
GameCreationAppManifest, GameCreationAppPermission, GameCreationAppPreviewState,
|
||||
GameCreationAppPreviewStatus, GameCreationAppTaskState, GameCreationAppTaskStatus,
|
||||
ProjectResourceCanvasLayout, ProjectResourceCanvasLayoutMode, ProjectResourceCanvasPosition,
|
||||
GameIterationVersionResourceBinding, GameResourceCategory, GameResourceDescriptor,
|
||||
GameTestSlice, GameTestSliceStatus, GameTunableParameterDefinition,
|
||||
GameTunableParameterValueType, ProjectResourceCanvasLayout, ProjectResourceCanvasLayoutMode,
|
||||
ProjectResourceCanvasPosition, RunnableGameResourceReplacement, RunnableGameVersion,
|
||||
RunnableGameVersionCreatedReason, RunnableGameVersionValidation,
|
||||
UpdateProjectResourceCanvasLayoutResult, UpdateProjectResourceCanvasLayoutStatus,
|
||||
GAME_CREATION_AGENT_CAPABILITIES, GAME_CREATION_AGENT_RUN_SCHEMA_VERSION,
|
||||
GAME_CREATION_AGENT_TOOL_CALL_MAX, GAME_CREATION_APP_COMMANDS,
|
||||
GAME_CREATION_APP_LIMITED_RUN_COMMANDS, GAME_CREATION_RESOURCE_LAYOUT_SCHEMA_VERSION,
|
||||
RUNNABLE_GAME_VERSION_SCHEMA_VERSION,
|
||||
};
|
||||
use tauri::{Emitter, Manager};
|
||||
use tauri_plugin_dialog::DialogExt;
|
||||
@@ -138,6 +143,13 @@ struct LocalPreviewResult {
|
||||
root: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct LocalPreviewIdentity {
|
||||
preview_id: String,
|
||||
origin: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct LocalPreviewStatus {
|
||||
@@ -147,6 +159,15 @@ struct LocalPreviewStatus {
|
||||
root: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct RunnableGameVersionLaunchResult {
|
||||
manifest: GameCreationAppManifest,
|
||||
version: RunnableGameVersion,
|
||||
preview: LocalPreviewResult,
|
||||
preview_identity: LocalPreviewIdentity,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct LocalGameProjectRevisionStatus {
|
||||
@@ -2187,6 +2208,9 @@ fn main() {
|
||||
read_game_creator_mcp_catalog,
|
||||
upload_local_asset,
|
||||
register_local_asset,
|
||||
replace_local_game_runnable_resource,
|
||||
read_local_game_tunable_parameters,
|
||||
update_local_game_tunable_parameter,
|
||||
import_canvas_asset,
|
||||
import_canvas_export,
|
||||
sync_canvas_project_assets,
|
||||
@@ -2227,6 +2251,7 @@ fn main() {
|
||||
open_game_creator_launcher_window,
|
||||
open_project_supervisor_chat_window,
|
||||
start_local_game_preview,
|
||||
launch_local_game_runnable_version,
|
||||
activate_local_game_preview,
|
||||
stop_local_game_preview,
|
||||
stop_local_game_preview_if_matches,
|
||||
|
||||
@@ -7,6 +7,7 @@ pub(crate) struct PreviewRegistry {
|
||||
|
||||
struct PreviewServer {
|
||||
preview: LocalPreviewResult,
|
||||
identity: LocalPreviewIdentity,
|
||||
stop: mpsc::Sender<()>,
|
||||
}
|
||||
|
||||
@@ -16,6 +17,19 @@ impl PreviewRegistry {
|
||||
preview: LocalPreviewResult,
|
||||
stop: mpsc::Sender<()>,
|
||||
) -> (LocalPreviewResult, Option<LocalPreviewResult>) {
|
||||
let (preview, _, previous_preview) = self.set_running_with_identity(preview, stop);
|
||||
(preview, previous_preview)
|
||||
}
|
||||
|
||||
pub(crate) fn set_running_with_identity(
|
||||
&self,
|
||||
preview: LocalPreviewResult,
|
||||
stop: mpsc::Sender<()>,
|
||||
) -> (
|
||||
LocalPreviewResult,
|
||||
LocalPreviewIdentity,
|
||||
Option<LocalPreviewResult>,
|
||||
) {
|
||||
let mut current = self.current.lock().expect("preview registry lock");
|
||||
let previous_preview = if let Some(previous) = current.take() {
|
||||
let preview = previous.preview;
|
||||
@@ -24,11 +38,21 @@ impl PreviewRegistry {
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let identity = LocalPreviewIdentity {
|
||||
preview_id: format!("preview-{}", uuid::Uuid::new_v4().simple()),
|
||||
origin: format!("http://127.0.0.1:{}", preview.port),
|
||||
};
|
||||
*current = Some(PreviewServer {
|
||||
preview: preview.clone(),
|
||||
identity: identity.clone(),
|
||||
stop,
|
||||
});
|
||||
(preview, previous_preview)
|
||||
let registry_identity = current
|
||||
.as_ref()
|
||||
.expect("preview registry current server")
|
||||
.identity
|
||||
.clone();
|
||||
(preview, registry_identity, previous_preview)
|
||||
}
|
||||
|
||||
pub(crate) fn status(&self) -> LocalPreviewStatus {
|
||||
@@ -89,6 +113,150 @@ const PREVIEW_REQUEST_MAX_HEADER_BYTES: usize = 32 * 1024;
|
||||
const PREVIEW_REQUEST_MAX_HEADER_LINES: usize = 100;
|
||||
const PREVIEW_RESPONSE_DRAIN_TIMEOUT: Duration = Duration::from_millis(250);
|
||||
const PREVIEW_RESPONSE_DRAIN_MAX_BYTES: usize = 32 * 1024;
|
||||
const GAME_RUN_BRIDGE_SCRIPT_PATH: &str = "/.genarrative/game-bridge.v1.js";
|
||||
const GAME_RUN_BRIDGE_SCRIPT_TAG: &str =
|
||||
r#"<script src="/.genarrative/game-bridge.v1.js" data-genarrative-game-bridge="v1"></script>"#;
|
||||
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<String>,
|
||||
registry: tauri::State<'_, PreviewRegistry>,
|
||||
) -> Result<RunnableGameVersionLaunchResult, String> {
|
||||
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<u8>) -> Vec<u8> {
|
||||
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("<head")
|
||||
.and_then(|start| {
|
||||
lowercase[start..]
|
||||
.find('>')
|
||||
.map(|offset| start + offset + 1)
|
||||
})
|
||||
.or_else(|| lowercase.find("</body>"))
|
||||
.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<PathBuf, String> {
|
||||
let path = url_path.split('?').next().unwrap_or("/");
|
||||
let decoded = percent_decode_path(path).ok_or_else(|| "预览路径非法".to_string())?;
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
@@ -799,6 +799,12 @@ pub(crate) fn read_manifest(path: &Path) -> Result<GameCreationAppManifest, Stri
|
||||
.map_err(|error| format!("解析 {label} 失败:{}: {error}", source_path.display()))?;
|
||||
validate_game_iteration_versions(&manifest.versions)
|
||||
.map_err(|error| format!("校验 {label} 项目版本失败:{error}"))?;
|
||||
validate_runnable_game_versions(
|
||||
&manifest.project_id,
|
||||
&manifest.runnable_versions,
|
||||
manifest.current_runnable_version_id.as_deref(),
|
||||
)
|
||||
.map_err(|error| format!("校验 {label} 可运行版本失败:{error}"))?;
|
||||
Ok(manifest)
|
||||
}
|
||||
|
||||
@@ -889,6 +895,12 @@ where
|
||||
{
|
||||
validate_game_iteration_versions(&manifest.versions)
|
||||
.map_err(|error| format!("校验 manifest 项目版本失败:{error}"))?;
|
||||
validate_runnable_game_versions(
|
||||
&manifest.project_id,
|
||||
&manifest.runnable_versions,
|
||||
manifest.current_runnable_version_id.as_deref(),
|
||||
)
|
||||
.map_err(|error| format!("校验 manifest 可运行版本失败:{error}"))?;
|
||||
let payload = serde_json::to_string_pretty(manifest)
|
||||
.map_err(|error| format!("序列化 manifest 失败:{error}"))?;
|
||||
if let Some(parent) = path.parent() {
|
||||
@@ -908,6 +920,15 @@ where
|
||||
{
|
||||
return Err("项目版本记录写入后不可修改、删除或重排".to_string());
|
||||
}
|
||||
if existing.runnable_versions.len() > 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() => {
|
||||
|
||||
@@ -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");
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2433,6 +2433,41 @@ fn preview_registry_reports_status_and_stops_previous_server() {
|
||||
assert_eq!(registry.status().status, "stopped");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preview_registry_rotates_p3_identity_with_the_owned_server() {
|
||||
let registry = PreviewRegistry::default();
|
||||
let (first_stop, first_receiver) = mpsc::channel();
|
||||
let (_, first_identity, previous) = registry.set_running_with_identity(
|
||||
LocalPreviewResult {
|
||||
url: "http://127.0.0.1:4101/".to_string(),
|
||||
port: 4101,
|
||||
root: "/tmp/game-one".to_string(),
|
||||
},
|
||||
first_stop,
|
||||
);
|
||||
assert!(previous.is_none());
|
||||
assert_eq!(first_identity.origin, "http://127.0.0.1:4101");
|
||||
assert!(first_identity.preview_id.starts_with("preview-"));
|
||||
|
||||
let (second_stop, _) = mpsc::channel();
|
||||
let (_, second_identity, previous) = registry.set_running_with_identity(
|
||||
LocalPreviewResult {
|
||||
url: "http://127.0.0.1:4102/".to_string(),
|
||||
port: 4102,
|
||||
root: "/tmp/game-two".to_string(),
|
||||
},
|
||||
second_stop,
|
||||
);
|
||||
assert!(first_receiver.try_recv().is_ok());
|
||||
assert_eq!(
|
||||
previous.expect("replaced preview").url,
|
||||
"http://127.0.0.1:4101/"
|
||||
);
|
||||
assert_ne!(first_identity.preview_id, second_identity.preview_id);
|
||||
assert_eq!(second_identity.origin, "http://127.0.0.1:4102");
|
||||
registry.stop();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replaced_preview_records_stopped_state() {
|
||||
let root = unique_project_path();
|
||||
@@ -3240,6 +3275,74 @@ fn local_preview_server_serves_game_index() {
|
||||
fs::remove_dir_all(root).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_preview_can_serve_an_immutable_version_snapshot_instead_of_the_working_tree() {
|
||||
let root = unique_project_path();
|
||||
init_local_game_project_at(&root, "project-snapshot", "快照预览测试").expect("project init");
|
||||
fs::write(root.join("game/index.html"), "<main>working tree</main>")
|
||||
.expect("write working tree game");
|
||||
let snapshot_root = root.join(".agent/runnable-versions/runnable-r1/artifact");
|
||||
fs::create_dir_all(snapshot_root.join("game")).expect("create snapshot game directory");
|
||||
fs::create_dir_all(snapshot_root.join("assets")).expect("create snapshot assets directory");
|
||||
fs::write(
|
||||
snapshot_root.join("game/index.html"),
|
||||
"<main>immutable snapshot</main>",
|
||||
)
|
||||
.expect("write snapshot game");
|
||||
|
||||
let (preview, stop) = start_local_game_preview_for_served_root(&root, &snapshot_root)
|
||||
.expect("snapshot preview start");
|
||||
let mut stream = TcpStream::connect(("127.0.0.1", preview.port)).expect("preview connect");
|
||||
stream
|
||||
.write_all(b"GET / HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n")
|
||||
.expect("request");
|
||||
let mut response = String::new();
|
||||
stream.read_to_string(&mut response).expect("response");
|
||||
|
||||
assert!(response.contains("200 OK"), "{response}");
|
||||
assert!(response.contains("immutable snapshot"), "{response}");
|
||||
assert!(!response.contains("working tree"), "{response}");
|
||||
assert_eq!(preview.root, root.to_string_lossy());
|
||||
|
||||
let _ = stop.send(());
|
||||
fs::remove_dir_all(root).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_preview_injects_the_host_owned_runtime_bridge_without_mutating_html() {
|
||||
let root = unique_project_path();
|
||||
init_local_game_project_at(&root, "project-bridge", "运行桥预览测试").expect("project init");
|
||||
let original = "<!doctype html><html><head><title>Bridge</title></head><body></body></html>";
|
||||
fs::write(root.join("game/index.html"), original).expect("write bridge fixture");
|
||||
|
||||
let html_response =
|
||||
String::from_utf8(build_preview_response(&root, "GET", "/")).expect("html response");
|
||||
assert!(html_response.contains("data-genarrative-game-bridge=\"v1\""));
|
||||
assert!(html_response.contains("/.genarrative/game-bridge.v1.js"));
|
||||
assert_eq!(
|
||||
fs::read_to_string(root.join("game/index.html")).expect("read original html"),
|
||||
original
|
||||
);
|
||||
|
||||
let script_response = String::from_utf8(build_preview_response(
|
||||
&root,
|
||||
"GET",
|
||||
"/.genarrative/game-bridge.v1.js",
|
||||
))
|
||||
.expect("bridge response");
|
||||
assert!(script_response.contains("Content-Type: text/javascript; charset=utf-8"));
|
||||
assert!(script_response.contains("game-creator-run-bridge.v1"));
|
||||
assert!(script_response.contains("runtime.ready"));
|
||||
assert!(script_response.contains("event.source !== parent"));
|
||||
assert!(script_response.contains("host.slice.start"));
|
||||
assert!(script_response.contains("host.slice.stop"));
|
||||
assert!(script_response.contains("reportTargetInspection"));
|
||||
assert!(script_response.contains("clearInspection"));
|
||||
assert!(script_response.contains("Object.keys(message.payload).length !== 1"));
|
||||
|
||||
fs::remove_dir_all(root).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_preview_server_drains_split_browser_headers_before_response() {
|
||||
let root = unique_project_path();
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Genarrative AI Game Creator App Run",
|
||||
"identifier": "world.genarrative.ai-game-creator.app-run",
|
||||
"build": {
|
||||
"devUrl": "http://127.0.0.1:3081/"
|
||||
},
|
||||
"app": {
|
||||
"windows": [
|
||||
{
|
||||
"label": "client",
|
||||
"title": "AI 游戏创作 · App Run",
|
||||
"url": "index.html",
|
||||
"width": 1280,
|
||||
"height": 800,
|
||||
"minWidth": 1280,
|
||||
"minHeight": 800
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -831,9 +831,7 @@ export function App({
|
||||
projectSupervisorResponseStreamRef.current = projectSupervisorResponseStream;
|
||||
|
||||
const refreshManifest = useCallback(
|
||||
(
|
||||
nextProjectPath = localProjectPathRef.current ?? '',
|
||||
): Promise<void> => {
|
||||
(nextProjectPath = localProjectPathRef.current ?? ''): Promise<void> => {
|
||||
const invoke = resolveTauriInvoke();
|
||||
if (!invoke || !nextProjectPath) {
|
||||
return Promise.resolve();
|
||||
|
||||
@@ -52,6 +52,7 @@ export function WorkspaceLauncherShell({
|
||||
setAgentRuntimeSummaries: setActiveProjectAgentRuntimeSummaries,
|
||||
activeProjectAgentResults,
|
||||
setAgentResults: setActiveProjectAgentResults,
|
||||
updateCurrentProjectManifest,
|
||||
resetLauncherHomeDraft,
|
||||
createHomeDraft,
|
||||
openProject,
|
||||
@@ -176,6 +177,8 @@ export function WorkspaceLauncherShell({
|
||||
agentResults={activeProjectAgentResults}
|
||||
onHomeOpen={() => setLauncherView('home')}
|
||||
onProjectsOpen={() => setLauncherView('projects')}
|
||||
onManifestChange={updateCurrentProjectManifest}
|
||||
onPreviewChange={setActiveProjectPreview}
|
||||
supervisor={
|
||||
<ProjectSupervisor
|
||||
key={currentProjectContext.projectPath}
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
type Dispatch,
|
||||
type FormEvent,
|
||||
type SetStateAction,
|
||||
useCallback,
|
||||
useState,
|
||||
} from 'react';
|
||||
|
||||
@@ -96,6 +97,16 @@ export function useHomeProjectCreation({
|
||||
rememberRecentWorkspace(context.projectPath);
|
||||
}
|
||||
|
||||
const updateCurrentProjectManifest = useCallback((manifest: GameCreationAppManifest) => {
|
||||
setCurrentProjectContext((current) =>
|
||||
current && current.manifest.projectId === manifest.projectId
|
||||
? current.manifest === manifest
|
||||
? current
|
||||
: { ...current, manifest }
|
||||
: current,
|
||||
);
|
||||
}, []);
|
||||
|
||||
async function importHomeAttachments(
|
||||
invoke: TauriInvoke,
|
||||
nextProjectPath: string,
|
||||
@@ -437,6 +448,7 @@ export function useHomeProjectCreation({
|
||||
setAgentRuntimeSummaries,
|
||||
activeProjectAgentResults,
|
||||
setAgentResults,
|
||||
updateCurrentProjectManifest,
|
||||
pendingNonEmptyProject,
|
||||
resetLauncherHomeDraft,
|
||||
createHomeDraft,
|
||||
|
||||
+218
-11
@@ -1,10 +1,54 @@
|
||||
/* eslint-disable react-refresh/only-export-components -- The URL guard is exported with its small rendering adapter for focused tests. */
|
||||
/* eslint-disable react-refresh/only-export-components -- The URL guard is exported with its rendering adapter for focused tests. */
|
||||
|
||||
import {
|
||||
forwardRef,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useRef,
|
||||
} from 'react';
|
||||
|
||||
import type {
|
||||
GameHostMessageType,
|
||||
GameRunSession,
|
||||
GameRuntimeMessage,
|
||||
} from '../../../../../packages/shared/src/contracts/gameCreationApp';
|
||||
import {
|
||||
createGameHostMessage,
|
||||
createGameRunSession,
|
||||
type GameRunBridgeIdentity,
|
||||
GameRunBridgeRuntimeGuard,
|
||||
gameRunSessionFromRuntimeMessage,
|
||||
newGameRunBridgeRequestId,
|
||||
newGameRunSessionId,
|
||||
validateGameRunBridgeIdentity,
|
||||
} from './gameRunBridge';
|
||||
|
||||
export type LocalGamePreviewLike = {
|
||||
status?: string | null;
|
||||
url?: string | null;
|
||||
};
|
||||
|
||||
export type LocalGamePreviewFrameHandle = {
|
||||
pause: () => boolean;
|
||||
resume: () => boolean;
|
||||
stop: () => boolean;
|
||||
requestState: () => boolean;
|
||||
requestInspection: () => boolean;
|
||||
startSlice: (sliceId: string) => boolean;
|
||||
stopSlice: (sliceId: string) => boolean;
|
||||
markStale: (
|
||||
reason: 'project-revision-changed' | 'version-changed',
|
||||
) => boolean;
|
||||
};
|
||||
|
||||
type ActiveBridgeSession = {
|
||||
guard: GameRunBridgeRuntimeGuard;
|
||||
identity: GameRunBridgeIdentity;
|
||||
nextHostSequence: number;
|
||||
session: GameRunSession;
|
||||
};
|
||||
|
||||
export function resolveEmbeddedPreviewUrl(
|
||||
preview: LocalGamePreviewLike | null | undefined,
|
||||
) {
|
||||
@@ -25,26 +69,189 @@ export function resolveEmbeddedPreviewUrl(
|
||||
}
|
||||
}
|
||||
|
||||
export function LocalGamePreviewFrame({
|
||||
preview,
|
||||
title,
|
||||
className,
|
||||
}: {
|
||||
preview: LocalGamePreviewLike | null | undefined;
|
||||
title: string;
|
||||
className?: string;
|
||||
}) {
|
||||
export const LocalGamePreviewFrame = forwardRef<
|
||||
LocalGamePreviewFrameHandle,
|
||||
{
|
||||
preview: LocalGamePreviewLike | null | undefined;
|
||||
title: string;
|
||||
className?: string;
|
||||
runIdentity?: GameRunBridgeIdentity | null;
|
||||
onSessionChange?: (session: GameRunSession) => void;
|
||||
onRuntimeMessage?: (message: GameRuntimeMessage) => void;
|
||||
onBridgeError?: (message: string) => void;
|
||||
}
|
||||
>(function LocalGamePreviewFrame(
|
||||
{
|
||||
preview,
|
||||
title,
|
||||
className,
|
||||
runIdentity,
|
||||
onSessionChange,
|
||||
onRuntimeMessage,
|
||||
onBridgeError,
|
||||
},
|
||||
forwardedRef,
|
||||
) {
|
||||
const embeddedUrl = resolveEmbeddedPreviewUrl(preview);
|
||||
const iframeRef = useRef<HTMLIFrameElement>(null);
|
||||
const activeRef = useRef<ActiveBridgeSession | null>(null);
|
||||
const callbacksRef = useRef({
|
||||
onBridgeError,
|
||||
onRuntimeMessage,
|
||||
onSessionChange,
|
||||
});
|
||||
callbacksRef.current = {
|
||||
onBridgeError,
|
||||
onRuntimeMessage,
|
||||
onSessionChange,
|
||||
};
|
||||
|
||||
const sendHostMessage = useCallback(
|
||||
(type: GameHostMessageType, sliceId?: string) => {
|
||||
const active = activeRef.current;
|
||||
const targetWindow = iframeRef.current?.contentWindow;
|
||||
if (!active || !targetWindow) {
|
||||
return false;
|
||||
}
|
||||
const requestId = newGameRunBridgeRequestId('host');
|
||||
if (!requestId || !active.guard.registerHostRequest(requestId, type)) {
|
||||
callbacksRef.current.onBridgeError?.('运行会话无法创建安全请求身份');
|
||||
return false;
|
||||
}
|
||||
const message = createGameHostMessage({
|
||||
identity: active.identity,
|
||||
sessionId: active.session.sessionId,
|
||||
requestId,
|
||||
sequence: active.nextHostSequence,
|
||||
type,
|
||||
payload: sliceId ? { sliceId } : undefined,
|
||||
});
|
||||
active.nextHostSequence += 1;
|
||||
targetWindow.postMessage(message, active.identity.previewOrigin);
|
||||
return true;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const stopActiveSession = useCallback(
|
||||
(notify: boolean) => {
|
||||
const active = activeRef.current;
|
||||
if (!active) {
|
||||
return false;
|
||||
}
|
||||
sendHostMessage('host.stop');
|
||||
activeRef.current = null;
|
||||
if (notify) {
|
||||
callbacksRef.current.onSessionChange?.({
|
||||
...active.session,
|
||||
status: 'stopped',
|
||||
updatedAt: Date.now(),
|
||||
});
|
||||
}
|
||||
return true;
|
||||
},
|
||||
[sendHostMessage],
|
||||
);
|
||||
|
||||
useImperativeHandle(
|
||||
forwardedRef,
|
||||
() => ({
|
||||
pause: () => sendHostMessage('host.pause'),
|
||||
resume: () => sendHostMessage('host.resume'),
|
||||
stop: () => stopActiveSession(true),
|
||||
requestState: () => sendHostMessage('host.state.request'),
|
||||
requestInspection: () => sendHostMessage('host.inspection.request'),
|
||||
startSlice: (sliceId) => sendHostMessage('host.slice.start', sliceId),
|
||||
stopSlice: (sliceId) => sendHostMessage('host.slice.stop', sliceId),
|
||||
markStale: (reason) => {
|
||||
const active = activeRef.current;
|
||||
if (!active) {
|
||||
return false;
|
||||
}
|
||||
active.session = {
|
||||
...active.session,
|
||||
stale: true,
|
||||
staleReason: reason,
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
callbacksRef.current.onSessionChange?.(active.session);
|
||||
return true;
|
||||
},
|
||||
}),
|
||||
[sendHostMessage, stopActiveSession],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
function receiveRuntimeMessage(event: MessageEvent<unknown>) {
|
||||
const active = activeRef.current;
|
||||
if (
|
||||
!active ||
|
||||
event.source !== iframeRef.current?.contentWindow ||
|
||||
event.origin !== active.identity.previewOrigin
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const message = active.guard.accept(event.data);
|
||||
if (!message) {
|
||||
return;
|
||||
}
|
||||
active.session = gameRunSessionFromRuntimeMessage(
|
||||
active.session,
|
||||
message,
|
||||
Date.now(),
|
||||
);
|
||||
callbacksRef.current.onSessionChange?.(active.session);
|
||||
callbacksRef.current.onRuntimeMessage?.(message);
|
||||
}
|
||||
window.addEventListener('message', receiveRuntimeMessage);
|
||||
return () => {
|
||||
window.removeEventListener('message', receiveRuntimeMessage);
|
||||
stopActiveSession(false);
|
||||
};
|
||||
}, [stopActiveSession]);
|
||||
|
||||
function handleLoad() {
|
||||
stopActiveSession(true);
|
||||
if (!runIdentity || !embeddedUrl) {
|
||||
return;
|
||||
}
|
||||
const identity = validateGameRunBridgeIdentity(runIdentity);
|
||||
if (!identity || new URL(embeddedUrl).origin !== identity.previewOrigin) {
|
||||
callbacksRef.current.onBridgeError?.(
|
||||
'运行预览身份与 loopback origin 不一致',
|
||||
);
|
||||
return;
|
||||
}
|
||||
const sessionId = newGameRunSessionId();
|
||||
if (!sessionId) {
|
||||
callbacksRef.current.onBridgeError?.('当前 WebView 不支持安全运行会话');
|
||||
return;
|
||||
}
|
||||
const session = createGameRunSession(identity, sessionId, Date.now());
|
||||
activeRef.current = {
|
||||
guard: new GameRunBridgeRuntimeGuard(identity, sessionId),
|
||||
identity,
|
||||
nextHostSequence: 1,
|
||||
session,
|
||||
};
|
||||
callbacksRef.current.onSessionChange?.(session);
|
||||
if (!sendHostMessage('host.start')) {
|
||||
activeRef.current = null;
|
||||
}
|
||||
}
|
||||
|
||||
if (!embeddedUrl) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<iframe
|
||||
ref={iframeRef}
|
||||
className={className}
|
||||
title={title}
|
||||
src={embeddedUrl}
|
||||
sandbox="allow-scripts allow-same-origin allow-forms allow-pointer-lock"
|
||||
allow="autoplay; fullscreen; gamepad"
|
||||
onLoad={handleLoad}
|
||||
/>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,158 @@
|
||||
import type {
|
||||
GameRuntimeMessage,
|
||||
GameTestSlice,
|
||||
GameTestSliceStatus,
|
||||
RunnableGameVersion,
|
||||
} from '../../../../../packages/shared/src/contracts/gameCreationApp';
|
||||
|
||||
export type ActiveGameTestSlice = GameTestSlice & {
|
||||
stale: boolean;
|
||||
};
|
||||
|
||||
export type TrustedGameResourceInspection = {
|
||||
targetKind: 'resource' | 'entity';
|
||||
targetId: string;
|
||||
resourceId: string;
|
||||
slotId: string;
|
||||
name: string;
|
||||
category: 'document' | 'version' | 'art' | 'audio';
|
||||
subtype: string;
|
||||
format: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
durationMs?: number;
|
||||
versionId: string;
|
||||
};
|
||||
|
||||
const transitions: Record<
|
||||
GameTestSliceStatus,
|
||||
ReadonlySet<GameTestSliceStatus>
|
||||
> = {
|
||||
idle: new Set(['starting']),
|
||||
starting: new Set(['playing', 'failed']),
|
||||
playing: new Set(['paused', 'completed', 'failed']),
|
||||
paused: new Set(['playing', 'completed', 'failed']),
|
||||
completed: new Set(['starting']),
|
||||
failed: new Set(['starting']),
|
||||
};
|
||||
|
||||
function validSliceText(value: string, maxChars: number) {
|
||||
return value.length > 0 && value.length <= maxChars && value.trim() === value;
|
||||
}
|
||||
|
||||
export function formalGameTestSlices(version: RunnableGameVersion | null) {
|
||||
if (!version?.testSlices || version.testSlices.length > 64) {
|
||||
return [];
|
||||
}
|
||||
const ids = new Set<string>();
|
||||
const orders = new Set<number>();
|
||||
for (const slice of version.testSlices) {
|
||||
if (
|
||||
slice.versionId !== version.versionId ||
|
||||
slice.status !== 'idle' ||
|
||||
!validSliceText(slice.sliceId, 128) ||
|
||||
!validSliceText(slice.title, 128) ||
|
||||
!Number.isSafeInteger(slice.order) ||
|
||||
slice.order < 0 ||
|
||||
ids.has(slice.sliceId) ||
|
||||
orders.has(slice.order)
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
ids.add(slice.sliceId);
|
||||
orders.add(slice.order);
|
||||
}
|
||||
return [...version.testSlices].sort(
|
||||
(left, right) =>
|
||||
left.order - right.order || left.sliceId.localeCompare(right.sliceId),
|
||||
);
|
||||
}
|
||||
|
||||
export function beginGameTestSlice(slice: GameTestSlice): ActiveGameTestSlice {
|
||||
return { ...slice, status: 'starting', stale: false };
|
||||
}
|
||||
|
||||
export function projectGameTestSliceMessage(
|
||||
active: ActiveGameTestSlice | null,
|
||||
message: GameRuntimeMessage,
|
||||
): ActiveGameTestSlice | null {
|
||||
if (
|
||||
!active ||
|
||||
active.stale ||
|
||||
message.type !== 'runtime.slice' ||
|
||||
message.payload.sliceId !== active.sliceId
|
||||
) {
|
||||
return active;
|
||||
}
|
||||
const nextStatus = message.payload.status;
|
||||
if (
|
||||
nextStatus === active.status ||
|
||||
transitions[active.status].has(nextStatus)
|
||||
) {
|
||||
return { ...active, status: nextStatus };
|
||||
}
|
||||
return active;
|
||||
}
|
||||
|
||||
export function projectGameTestSliceSessionStatus(
|
||||
active: ActiveGameTestSlice | null,
|
||||
message: GameRuntimeMessage,
|
||||
): ActiveGameTestSlice | null {
|
||||
if (!active || active.stale || message.type !== 'runtime.state') {
|
||||
return active;
|
||||
}
|
||||
const nextStatus =
|
||||
message.payload.status === 'paused'
|
||||
? 'paused'
|
||||
: message.payload.status === 'playing'
|
||||
? 'playing'
|
||||
: null;
|
||||
if (!nextStatus || !transitions[active.status].has(nextStatus)) {
|
||||
return active;
|
||||
}
|
||||
return { ...active, status: nextStatus };
|
||||
}
|
||||
|
||||
export function trustedInspectionFromRuntimeMessage(
|
||||
version: RunnableGameVersion | null,
|
||||
message: GameRuntimeMessage,
|
||||
): TrustedGameResourceInspection | null | undefined {
|
||||
if (!version || message.type !== 'runtime.inspection') {
|
||||
return undefined;
|
||||
}
|
||||
const { targetKind, targetId } = message.payload;
|
||||
if (targetKind === 'none') {
|
||||
return null;
|
||||
}
|
||||
if ((targetKind !== 'resource' && targetKind !== 'entity') || !targetId) {
|
||||
return undefined;
|
||||
}
|
||||
const binding = version.resourceBindings.find((candidate) =>
|
||||
targetKind === 'resource'
|
||||
? candidate.resourceId === targetId
|
||||
: candidate.slotId === targetId,
|
||||
);
|
||||
if (!binding) {
|
||||
return null;
|
||||
}
|
||||
const descriptor = version.resourceDescriptors?.find(
|
||||
(candidate) => candidate.resourceId === binding.resourceId,
|
||||
);
|
||||
if (!descriptor) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
targetKind,
|
||||
targetId,
|
||||
resourceId: descriptor.resourceId,
|
||||
slotId: binding.slotId,
|
||||
name: descriptor.name,
|
||||
category: descriptor.category,
|
||||
subtype: descriptor.subtype,
|
||||
format: descriptor.format,
|
||||
width: descriptor.width,
|
||||
height: descriptor.height,
|
||||
durationMs: descriptor.durationMs,
|
||||
versionId: version.versionId,
|
||||
};
|
||||
}
|
||||
@@ -750,7 +750,9 @@ textarea {
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.launcher-agent-chat-waiting > span,
|
||||
.game-chat-runtime-status[data-tone='active'] .game-chat-runtime-state > span {
|
||||
.game-chat-runtime-status[data-tone='active']
|
||||
.game-chat-runtime-state
|
||||
> span {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
@@ -1538,7 +1540,9 @@ textarea {
|
||||
box-shadow: 0 0 0 4px rgb(240 68 56 / 15%);
|
||||
}
|
||||
|
||||
.game-chat-runtime-status[data-tone='complete'] .game-chat-runtime-state > span {
|
||||
.game-chat-runtime-status[data-tone='complete']
|
||||
.game-chat-runtime-state
|
||||
> span {
|
||||
background: #2e90fa;
|
||||
box-shadow: 0 0 0 4px rgb(46 144 250 / 14%);
|
||||
}
|
||||
@@ -3963,13 +3967,14 @@ iframe.preview-frame {
|
||||
.game-workbench-stage {
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
grid-template-rows: auto auto minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.game-workbench-toolbar {
|
||||
display: flex;
|
||||
display: grid;
|
||||
grid-template-columns: max-content minmax(0, 1fr);
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
min-height: 48px;
|
||||
padding: 8px 12px;
|
||||
@@ -3978,19 +3983,33 @@ iframe.preview-frame {
|
||||
}
|
||||
|
||||
.game-workbench-tabs,
|
||||
.game-workbench-view-actions {
|
||||
.game-workbench-view-actions,
|
||||
.game-resource-layout-switch {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
.game-workbench-tabs {
|
||||
flex: 0 0 auto;
|
||||
flex-wrap: wrap;
|
||||
padding: 3px;
|
||||
border-radius: 999px;
|
||||
background: #f3ded3;
|
||||
}
|
||||
|
||||
.game-workbench-view-actions {
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
flex-wrap: nowrap;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.game-resource-layout-switch {
|
||||
flex: 0 0 auto;
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
|
||||
.game-workbench-tabs button,
|
||||
.game-workbench-view-actions button {
|
||||
display: inline-flex;
|
||||
@@ -4028,11 +4047,19 @@ iframe.preview-frame {
|
||||
|
||||
.game-resource-reorder-status {
|
||||
min-width: 0;
|
||||
flex: 1 1 auto;
|
||||
max-width: min(240px, 28vw);
|
||||
overflow: hidden;
|
||||
color: #9a725f;
|
||||
font-size: 10px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.game-resource-reorder-status:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.game-run-unavailable {
|
||||
margin: 0;
|
||||
padding: 7px 14px;
|
||||
@@ -4045,7 +4072,9 @@ iframe.preview-frame {
|
||||
.game-resource-manager {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.game-resource-search {
|
||||
@@ -4099,14 +4128,42 @@ iframe.preview-frame {
|
||||
.game-resource-canvas {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
padding: 12px;
|
||||
overflow: auto;
|
||||
overflow-x: auto;
|
||||
overflow-y: auto;
|
||||
scrollbar-color: #d5a089 #f7e7df;
|
||||
scrollbar-width: thin;
|
||||
background-color: #fffdfa;
|
||||
background-image: radial-gradient(#eaded8 0.8px, transparent 0.8px);
|
||||
background-size: 18px 18px;
|
||||
}
|
||||
|
||||
.game-resource-canvas::-webkit-scrollbar {
|
||||
width: 10px;
|
||||
height: 12px;
|
||||
}
|
||||
|
||||
.game-resource-canvas::-webkit-scrollbar-track {
|
||||
border-radius: 999px;
|
||||
background: #f7e7df;
|
||||
}
|
||||
|
||||
.game-resource-canvas::-webkit-scrollbar-thumb {
|
||||
border: 2px solid #f7e7df;
|
||||
border-radius: 999px;
|
||||
background: #d5a089;
|
||||
}
|
||||
|
||||
.game-resource-canvas::-webkit-scrollbar-thumb:hover {
|
||||
background: #c98566;
|
||||
}
|
||||
|
||||
.game-resource-canvas::-webkit-scrollbar-corner {
|
||||
background: #f7e7df;
|
||||
}
|
||||
.game-resource-canvas-content {
|
||||
position: relative;
|
||||
display: grid;
|
||||
@@ -4137,7 +4194,6 @@ iframe.preview-frame {
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.game-resource-dependency-edge,
|
||||
.game-resource-dependency-edge path {
|
||||
fill: none;
|
||||
@@ -4152,21 +4208,6 @@ iframe.preview-frame {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.game-resource-dependency-edge--task path {
|
||||
stroke: #918b87;
|
||||
stroke-width: 1.4px;
|
||||
stroke-dasharray: 4 7;
|
||||
}
|
||||
|
||||
.game-resource-dependency-edge--task .game-resource-dependency-trunk {
|
||||
stroke-width: 1.7px;
|
||||
opacity: 0.88;
|
||||
}
|
||||
|
||||
.game-resource-dependency-edge--task .game-resource-dependency-branch {
|
||||
opacity: 0.72;
|
||||
}
|
||||
|
||||
.game-resource-dependency-edge.is-cyclic,
|
||||
.game-resource-dependency-edge.is-cyclic path {
|
||||
stroke-dashoffset: 4;
|
||||
@@ -4177,11 +4218,6 @@ iframe.preview-frame {
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
|
||||
.game-resource-dependency-marker--task path {
|
||||
fill: #918b87;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
|
||||
.game-resource-section {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
@@ -4277,6 +4313,14 @@ iframe.preview-frame {
|
||||
0 0 0 2px rgb(216 115 66 / 14%);
|
||||
}
|
||||
|
||||
.game-resource-card.is-current-version {
|
||||
border-color: #c85f31;
|
||||
background: #fff7f1;
|
||||
box-shadow:
|
||||
0 8px 22px rgb(195 105 62 / 18%),
|
||||
inset 0 0 0 2px rgb(216 115 66 / 16%);
|
||||
}
|
||||
|
||||
.game-resource-card-icon {
|
||||
display: grid;
|
||||
grid-row: 1 / 4;
|
||||
@@ -4426,6 +4470,18 @@ iframe.preview-frame {
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.game-resource-replace-trigger {
|
||||
justify-self: start;
|
||||
min-height: 32px;
|
||||
padding: 0 14px;
|
||||
border: 1px solid #d87a4d;
|
||||
border-radius: 999px;
|
||||
background: #fff6f0;
|
||||
color: #a8502c;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
.game-resource-focus-metadata dt {
|
||||
color: #a08377;
|
||||
}
|
||||
@@ -4655,13 +4711,32 @@ iframe.preview-frame {
|
||||
|
||||
.game-run-surface {
|
||||
display: grid;
|
||||
grid-template-rows: minmax(300px, 1fr) auto auto;
|
||||
grid-template-rows: minmax(300px, 1fr) auto auto auto;
|
||||
gap: 12px;
|
||||
min-height: 0;
|
||||
padding: 12px;
|
||||
background: #fffdfa;
|
||||
}
|
||||
|
||||
.game-run-version-picker {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: #76574a;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.game-run-version-picker select {
|
||||
min-width: 190px;
|
||||
height: 30px;
|
||||
border: 1px solid #e4cfc4;
|
||||
border-radius: 9px;
|
||||
background: #fff;
|
||||
color: #65483d;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.game-run-preview {
|
||||
position: relative;
|
||||
display: grid;
|
||||
@@ -4728,6 +4803,11 @@ iframe.preview-frame {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.game-run-slice-controls button:disabled {
|
||||
cursor: default;
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
.game-run-slice-controls span {
|
||||
color: #9a7d70;
|
||||
font-size: 11px;
|
||||
@@ -4740,6 +4820,13 @@ iframe.preview-frame {
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.game-run-status {
|
||||
margin: 0;
|
||||
color: #8b634f;
|
||||
font-size: 10px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.game-run-panels > section {
|
||||
display: grid;
|
||||
align-content: start;
|
||||
@@ -4807,6 +4894,144 @@ iframe.preview-frame {
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.game-run-panels button {
|
||||
justify-self: start;
|
||||
min-height: 28px;
|
||||
padding: 0 12px;
|
||||
border: 1px solid #dfb59f;
|
||||
border-radius: 999px;
|
||||
background: #fff8f4;
|
||||
color: #855c4c;
|
||||
font-size: 10px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.game-run-panels button:disabled {
|
||||
cursor: default;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.game-tunable-panel {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.game-tunable-natural-language {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.game-tunable-natural-language input {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.game-tunable-list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.game-tunable-item {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
align-items: end;
|
||||
gap: 8px;
|
||||
padding: 8px;
|
||||
border: 1px solid #eee0d9;
|
||||
border-radius: 9px;
|
||||
background: #fffdfa;
|
||||
}
|
||||
|
||||
.game-tunable-item.is-matched {
|
||||
border-color: #d87342;
|
||||
box-shadow: 0 0 0 2px rgb(216 115 66 / 12%);
|
||||
}
|
||||
|
||||
.game-tunable-item label {
|
||||
grid-template-columns: minmax(0, 1fr) 96px;
|
||||
}
|
||||
|
||||
.game-tunable-item select {
|
||||
min-width: 0;
|
||||
height: 30px;
|
||||
border: 1px solid #eadbd4;
|
||||
border-radius: 8px;
|
||||
background: #faf7f5;
|
||||
color: #62483d;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.game-resource-replacement-dialog {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
width: min(620px, calc(100vw - 32px));
|
||||
max-height: min(680px, calc(100vh - 32px));
|
||||
padding: 18px;
|
||||
overflow: auto;
|
||||
border: 1px solid #e3c9bb;
|
||||
border-radius: 18px;
|
||||
background: #fffdfb;
|
||||
box-shadow: 0 24px 80px rgb(80 43 28 / 25%);
|
||||
}
|
||||
|
||||
.game-resource-replacement-dialog > header,
|
||||
.game-resource-replacement-dialog > footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.game-resource-replacement-dialog h2,
|
||||
.game-resource-replacement-dialog p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.game-resource-replacement-dialog h2 {
|
||||
color: #5b4035;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.game-resource-replacement-dialog p,
|
||||
.game-resource-replacement-dialog small {
|
||||
color: #987a6d;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.game-resource-replacement-dialog button {
|
||||
min-height: 32px;
|
||||
padding: 0 12px;
|
||||
border: 1px solid #e2c5b6;
|
||||
border-radius: 10px;
|
||||
background: #fff7f2;
|
||||
color: #805747;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.game-resource-replacement-dialog button:disabled {
|
||||
cursor: default;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.game-resource-replacement-candidates {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.game-resource-replacement-candidates button {
|
||||
display: grid;
|
||||
justify-items: start;
|
||||
gap: 4px;
|
||||
min-height: 72px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.game-resource-replacement-candidates button.is-selected {
|
||||
border-color: #d87342;
|
||||
background: #fff0e7;
|
||||
}
|
||||
|
||||
.game-workbench-chat {
|
||||
display: grid;
|
||||
grid-template-rows: auto minmax(0, 1fr);
|
||||
@@ -5276,7 +5501,7 @@ iframe.preview-frame {
|
||||
|
||||
.game-workbench-toolbar {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.game-workbench-view-actions {
|
||||
@@ -5301,10 +5526,9 @@ iframe.preview-frame {
|
||||
min-height: 560px;
|
||||
}
|
||||
|
||||
.game-workbench-toolbar,
|
||||
.game-workbench-view-actions {
|
||||
.game-workbench-toolbar {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.game-workbench-tabs {
|
||||
@@ -5329,6 +5553,11 @@ iframe.preview-frame {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.game-tunable-list,
|
||||
.game-resource-replacement-candidates {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.game-workbench-chat .project-supervisor-message-list {
|
||||
max-height: 52vh;
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user