Compare commits
42 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6dca066a87 | |||
| 02ae3d3cad | |||
| 1bc8ccd077 | |||
| e099a519ba | |||
| 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 |
@@ -8,7 +8,7 @@ All paths below are relative to `https://www.genarrative.world`. Discovery and S
|
||||
|
||||
| Operation | Method and path | Minimum input |
|
||||
| --- | --- | --- |
|
||||
| List projects | `GET /api/external/v1/editor/projects` | Authentication |
|
||||
| List projects | `GET /api/external/v1/editor/projects` | Authentication; optional `view=full\|summary` (default `full`) |
|
||||
| Create project | `POST /api/external/v1/editor/projects` | Optional `title` |
|
||||
| Load recent project | `GET /api/external/v1/editor/projects/recent` | Authentication |
|
||||
| Get project | `GET /api/external/v1/editor/projects/{projectId}` | `projectId` |
|
||||
@@ -19,6 +19,13 @@ All paths below are relative to `https://www.genarrative.world`. Discovery and S
|
||||
|
||||
Canvas save uses optimistic revision control. Pass the last authoritative `expectedRevision`; on conflict, reload instead of replaying a stale full layout.
|
||||
|
||||
Project listing supports two views:
|
||||
|
||||
- `view=full` is the REST default and returns the complete project, canvas, layers, and resources.
|
||||
- `view=summary` returns only `projectId`, `title`, `updatedAt`, and nullable `cover`, so callers can display, search, disambiguate same-name projects, and select a safe target without loading every canvas snapshot.
|
||||
- Hosted MCP `list_editor_projects` always uses `summary`; call `get_editor_project` after selecting a `projectId` when complete authoritative state is required.
|
||||
- `cover` contains only `resourceId`, stable `objectKey`, dimensions, and `updatedAt`. It never embeds image bytes, a Data URL, or a signed URL. To display it, pass `cover.objectKey` to `get_external_asset_read_url`; signed URLs are temporary and must not be persisted or reused as generation references.
|
||||
|
||||
## Asset and Upload Operations
|
||||
|
||||
| Operation | Method and path | Minimum input |
|
||||
|
||||
@@ -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;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -519,7 +519,7 @@ fn game_creator_art_asset_plan_tool_plan_prompt(
|
||||
);
|
||||
}
|
||||
format!(
|
||||
"{prompt}\n\n你负责首版美术素材实际生成。资产清单和美术计划只是中间结果;最终必须调用 canvas.asset_generate 生成并登记 assets/art-spritesheet.png,固定使用 1:1、1K、assetKind=art-spritesheet、assetLabel=游戏首版核心美术素材、replaceExisting=false,并写入可解析的 assets/manifest.art.json。调用前必须用 asset.list 确认 assets/art-spec.png 已登记为当前项目的 icon-spec 画布资源,并依据当前任务、game/game_design.md 与 manifest 逐项说明真实需要的玩家主体及朝向/状态、目标或收集物、障碍/场景元素和反馈特效,由 Runtime 形成 iconDescriptions;不得假设为塔防或加入合同中不存在的单位、敌人、波次、卡牌。Runtime 固定以规范图的权威 resourceId 作为 referenceImageSrc,调用 POST /api/external/v1/editor/icon-spritesheets/generations,并用 screenColor=auto 完成透明后处理;不得把 UI 原型、Data URL、Blob URL、本地路径或结构化 JSON 冒充规范图引用,不得回退普通生图或 UI extraction。缺少规范图时必须等待 art-director 依赖并如实阻塞。成功后回读 observation 与 asset.list,核对服务端返回的透明 spritesheet、真实 alpha、warning 和 sliceWarning。warning.code=postprocess-failed-source-preserved 时没有透明图集,不得登记、验收或自动重试;仅 sliceWarning 时可保留完整透明图集,但不得声称独立切片已生成。已有有效同路径资产时不得重复生成或扣费;只有带 repairOfDelegationId 的唯一返工轮可 replaceExisting=true 原位替换。当且仅当当前 Project Supervisor 的静态委派 expectedArtifacts 同时保留 assets/art-spritesheet.png 并声明另一个 UI 图集 PNG 时,可按委派语义额外生成该路径,固定使用 1:1、1K、assetKind=ui-spritesheet 和贴合任务的 assetLabel;初次生成 replaceExisting=false,只有完整继承同一 expectedArtifacts 的唯一返工委派可设 replaceExisting=true。不得自行发明额外路径。不得运行 game.static_smoke 或 preview.validate,也不得编辑 game/index.html。图片生成未配置、待确认、失败或透明证据不足时不得提交最终回复。"
|
||||
"{prompt}\n\n你负责首版美术素材实际生成。资产清单和美术计划只是中间结果;最终必须调用 canvas.asset_generate 生成并登记 assets/art-spritesheet.png,固定使用 1:1、1K、assetKind=art-spritesheet、assetLabel=游戏首版核心美术素材、replaceExisting=false,并写入可解析的 assets/manifest.art.json。调用前必须用 asset.list 确认 assets/art-spec.png 已登记为当前项目的 icon-spec 画布资源,并依据当前任务、game/game_design.md 与 manifest 逐项说明真实需要的玩家主体及朝向/状态、目标或收集物、障碍/场景元素和反馈特效,由 Runtime 形成 iconDescriptions;不得假设为塔防或加入合同中不存在的单位、敌人、波次、卡牌。Runtime 固定以规范图的权威 resourceId 作为 referenceImageSrc,调用 POST /api/external/v1/editor/icon-spritesheets/generations,并用 screenColor=auto 完成透明后处理;不得把 UI 原型、Data URL、Blob URL、本地路径或结构化 JSON 冒充规范图引用,不得回退普通生图或 UI extraction。缺少规范图时必须等待 art-director 依赖并如实阻塞。成功后回读 observation 与 asset.list,核对服务端返回的透明 spritesheet、真实 alpha、warning 和 sliceWarning。warning.code=postprocess-failed-source-preserved 时没有透明图集,不得登记、验收或自动重试;仅 sliceWarning 时可保留完整透明图集,但不得声称独立切片已生成。已有有效同路径资产时不得重复生成或扣费;只有带 repairOfDelegationId 的唯一返工轮可 replaceExisting=true 原位替换。不得运行 game.static_smoke 或 preview.validate,也不得编辑 game/index.html。图片生成未配置、待确认、失败或透明证据不足时不得提交最终回复。"
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1234,9 +1234,6 @@ mod tests {
|
||||
assert!(with_canvas.contains("warning.code=postprocess-failed-source-preserved"));
|
||||
assert!(with_canvas.contains("不得登记、验收或自动重试"));
|
||||
assert!(with_canvas.contains("仅 sliceWarning"));
|
||||
assert!(with_canvas.contains("Project Supervisor 的静态委派 expectedArtifacts"));
|
||||
assert!(with_canvas.contains("assetKind=ui-spritesheet"));
|
||||
assert!(with_canvas.contains("不得自行发明额外路径"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -629,11 +629,6 @@ pub(crate) fn validate_agent_runtime_autonomous_plan_liveness(
|
||||
.actions
|
||||
.iter()
|
||||
.any(|action| action.tool.trim() == "agent.delegate");
|
||||
let has_code_asset_route = agent_id == "code-director"
|
||||
&& plan
|
||||
.actions
|
||||
.iter()
|
||||
.any(|action| action.tool.trim() == "agent.route_manifest");
|
||||
if agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID {
|
||||
if let Some(failed_playtest_revision) = verification_gate.failed_playtest_revision {
|
||||
if project_revision < failed_playtest_revision {
|
||||
@@ -770,7 +765,6 @@ pub(crate) fn validate_agent_runtime_autonomous_plan_liveness(
|
||||
|| mutation_revision.is_some()
|
||||
|| !plan.response.trim().is_empty()
|
||||
|| has_mutation
|
||||
|| has_code_asset_route
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
@@ -819,7 +813,6 @@ pub(in crate::agent) fn validate_agent_runtime_autonomous_read_only_delivery_pla
|
||||
| "command.output_read"
|
||||
| "command.poll"
|
||||
| "image.inspect" => false,
|
||||
"agent.route_manifest" => agent_id != "code-director",
|
||||
"preview.validate" => agent_id != "preview-playtest",
|
||||
"command.run_limited" => {
|
||||
agent_id != "preview-readiness"
|
||||
@@ -1778,13 +1771,6 @@ mod tests {
|
||||
serde_json::json!({"commandId": "game.static_smoke"}),
|
||||
);
|
||||
let preview = plan_for("preview.validate", serde_json::json!({}));
|
||||
let route_manifest = plan_for(
|
||||
"agent.route_manifest",
|
||||
serde_json::json!({
|
||||
"strategy": "use-existing-art",
|
||||
"missingAssetSlots": [],
|
||||
}),
|
||||
);
|
||||
|
||||
assert!(validate_agent_runtime_autonomous_read_only_delivery_plan(
|
||||
"preview-readiness",
|
||||
@@ -1798,41 +1784,9 @@ mod tests {
|
||||
&preview,
|
||||
)
|
||||
.is_ok());
|
||||
assert!(validate_agent_runtime_autonomous_read_only_delivery_plan(
|
||||
"code-director",
|
||||
true,
|
||||
&route_manifest,
|
||||
)
|
||||
.is_ok());
|
||||
let verification_gate = AgentRuntimeVerificationGate {
|
||||
schema_version: "test".to_string(),
|
||||
project_id: "test".to_string(),
|
||||
agent_id: "code-director".to_string(),
|
||||
run_id: "test".to_string(),
|
||||
requires_verification: false,
|
||||
mutation_revision: None,
|
||||
verified_revision: None,
|
||||
last_mutation_tool: None,
|
||||
last_verification_tool: None,
|
||||
last_verification_status: None,
|
||||
failed_playtest_revision: None,
|
||||
updated_at: 0,
|
||||
};
|
||||
assert!(validate_agent_runtime_autonomous_plan_liveness(
|
||||
"code-director",
|
||||
AGENT_RUNTIME_AUTONOMOUS_PRE_MUTATION_LOOP_LIMIT + 1,
|
||||
0,
|
||||
&verification_gate,
|
||||
&[],
|
||||
&route_manifest,
|
||||
false,
|
||||
false,
|
||||
)
|
||||
.is_ok());
|
||||
for (agent_id, plan) in [
|
||||
("quality-review", &smoke),
|
||||
("quality-review", &preview),
|
||||
("quality-review", &route_manifest),
|
||||
("preview-readiness", &preview),
|
||||
("preview-playtest", &smoke),
|
||||
] {
|
||||
|
||||
+2
-133
@@ -19,22 +19,12 @@ pub(in crate::agent) fn validate_agent_runtime_pending_tool_action_content(
|
||||
"taskContext": task,
|
||||
}))
|
||||
.map_err(|error| format!("序列化待确认工具动作失败:{error}"))?;
|
||||
validate_agent_runtime_pending_sensitive_serialized_content(&content)?;
|
||||
let action_content = serde_json::to_string(action)
|
||||
.map_err(|error| format!("序列化待确认工具动作失败:{error}"))?;
|
||||
validate_agent_runtime_pending_project_path_content(root, &action_content)
|
||||
validate_agent_runtime_pending_serialized_content(root, &content)
|
||||
}
|
||||
|
||||
pub(in crate::agent) fn validate_agent_runtime_pending_serialized_content(
|
||||
root: &Path,
|
||||
content: &str,
|
||||
) -> Result<(), String> {
|
||||
validate_agent_runtime_pending_sensitive_serialized_content(content)?;
|
||||
validate_agent_runtime_pending_project_path_content(root, content)
|
||||
}
|
||||
|
||||
fn validate_agent_runtime_pending_sensitive_serialized_content(
|
||||
content: &str,
|
||||
) -> Result<(), String> {
|
||||
let lower = content.to_ascii_lowercase();
|
||||
let sensitive_rule = [
|
||||
@@ -56,13 +46,6 @@ fn validate_agent_runtime_pending_sensitive_serialized_content(
|
||||
"待确认工具输入命中敏感规则 #{rule},Runtime 已拒绝持久化"
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_agent_runtime_pending_project_path_content(
|
||||
root: &Path,
|
||||
content: &str,
|
||||
) -> Result<(), String> {
|
||||
let root_display = root.to_string_lossy();
|
||||
if !root_display.is_empty() && content.contains(root_display.as_ref()) {
|
||||
return Err("待确认工具输入包含项目绝对路径,Runtime 已拒绝持久化".to_string());
|
||||
@@ -391,12 +374,7 @@ pub(in crate::agent) fn validate_agent_runtime_pending_tool_action_record(
|
||||
}
|
||||
let serialized = serde_json::to_string(pending)
|
||||
.map_err(|error| format!("序列化 Agent Runtime 待确认动作失败:{error}"))?;
|
||||
validate_agent_runtime_pending_sensitive_serialized_content(&serialized)?;
|
||||
let mut project_path_record = pending.clone();
|
||||
project_path_record.task.clear();
|
||||
let project_path_serialized = serde_json::to_string(&project_path_record)
|
||||
.map_err(|error| format!("序列化 Agent Runtime 待确认路径校验记录失败:{error}"))?;
|
||||
validate_agent_runtime_pending_project_path_content(root, &project_path_serialized)?;
|
||||
validate_agent_runtime_pending_serialized_content(root, &serialized)?;
|
||||
let action_fingerprint = agent_runtime_pending_tool_action_fingerprint(
|
||||
&pending.action,
|
||||
&pending.task,
|
||||
@@ -784,115 +762,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pending_content_allows_project_root_in_task_context_when_action_is_relative() {
|
||||
let root = Path::new("/data/dsk/games/game01");
|
||||
let action = AgentRuntimeToolAction {
|
||||
tool: "file.list".to_string(),
|
||||
reason: Some("核对当前资产".to_string()),
|
||||
input: serde_json::json!({ "path": "assets" }),
|
||||
};
|
||||
|
||||
validate_agent_runtime_pending_tool_action_content(
|
||||
root,
|
||||
&action,
|
||||
"继续修复 /data/dsk/games/game01 中的现有项目",
|
||||
)
|
||||
.expect("task context may identify the current project while tool input stays relative");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pending_content_rejects_project_root_in_tool_action() {
|
||||
let root = Path::new("/data/dsk/games/game01");
|
||||
let action = AgentRuntimeToolAction {
|
||||
tool: "file.read".to_string(),
|
||||
reason: Some("读取当前入口".to_string()),
|
||||
input: serde_json::json!({
|
||||
"path": "/data/dsk/games/game01/game/index.html"
|
||||
}),
|
||||
};
|
||||
|
||||
let error =
|
||||
validate_agent_runtime_pending_tool_action_content(root, &action, "继续修复当前项目")
|
||||
.expect_err("tool action must keep using a project-relative path");
|
||||
assert!(error.contains("项目绝对路径"), "{error}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn provider_batch_round_trips_when_only_task_identifies_project_root() {
|
||||
let temporary = crate::tests::canonical_test_tempdir("pending-task-project-root-");
|
||||
let root = temporary.path();
|
||||
init_local_game_project_at(root, "pending-task-project-root", "待确认路径批次测试")
|
||||
.expect("init project");
|
||||
let task = format!("继续修复 {} 中的现有项目", root.display());
|
||||
let mut runtime = start_game_creator_agent_runtime_task_at(
|
||||
root,
|
||||
"art-director",
|
||||
&task,
|
||||
"pending-task-project-root-run",
|
||||
"agent-ready-task-scheduler",
|
||||
"核对当前项目",
|
||||
vec!["核对当前项目".to_string()],
|
||||
)
|
||||
.expect("start runtime");
|
||||
runtime.loop_iteration = 1;
|
||||
let plan = AgentRuntimeToolPlan {
|
||||
thinking_summary: "核对相对路径项目上下文".to_string(),
|
||||
plan_update: None,
|
||||
plan: vec!["核对资产".to_string(), "核对入口".to_string()],
|
||||
actions: vec![
|
||||
AgentRuntimeToolAction {
|
||||
tool: "file.list".to_string(),
|
||||
reason: Some("核对当前资产".to_string()),
|
||||
input: serde_json::json!({ "path": "assets" }),
|
||||
},
|
||||
AgentRuntimeToolAction {
|
||||
tool: "file.read".to_string(),
|
||||
reason: Some("核对当前入口".to_string()),
|
||||
input: serde_json::json!({
|
||||
"path": "game/index.html",
|
||||
"startLine": 1,
|
||||
"maxLines": 20
|
||||
}),
|
||||
},
|
||||
],
|
||||
response: String::new(),
|
||||
};
|
||||
let project_revision =
|
||||
read_game_creator_agent_runtime_project_revision(root).expect("read project revision");
|
||||
let repository_fingerprint = build_repository_startup_context_at(root)
|
||||
.expect("build repository context")
|
||||
.fingerprint;
|
||||
|
||||
let prepared = prepare_game_creator_agent_runtime_provider_action_batch(
|
||||
root,
|
||||
&runtime,
|
||||
&task,
|
||||
&plan,
|
||||
&[],
|
||||
&project_revision,
|
||||
&repository_fingerprint,
|
||||
)
|
||||
.await
|
||||
.expect("prepare provider batch");
|
||||
let prepared = match prepared {
|
||||
AgentRuntimeProviderActionBatchPreparation::Ready(batch) => batch,
|
||||
other => panic!("expected ready provider batch, got {other:?}"),
|
||||
};
|
||||
let persisted = read_game_creator_agent_runtime_provider_action_batch(
|
||||
root,
|
||||
&runtime.agent_id,
|
||||
&runtime.run_id,
|
||||
)
|
||||
.expect("read persisted provider batch");
|
||||
|
||||
assert_eq!(persisted.batch_id, prepared.batch_id);
|
||||
assert_eq!(persisted.actions.len(), 2);
|
||||
assert!(persisted.actions.iter().all(|pending| pending.task == task));
|
||||
assert_eq!(persisted.actions[0].action, plan.actions[0]);
|
||||
assert_eq!(persisted.actions[1].action, plan.actions[1]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pending_content_still_rejects_api_key_fields_and_secret_tokens() {
|
||||
let root = Path::new("C:\\workspace");
|
||||
|
||||
+3
-14
@@ -1,12 +1,7 @@
|
||||
use super::*;
|
||||
|
||||
pub(crate) const AGENT_RUNTIME_CANVAS_ASSET_KINDS: &[&str] = &[
|
||||
"game-art",
|
||||
"icon-spec",
|
||||
"ui-prototype",
|
||||
"art-spritesheet",
|
||||
"ui-spritesheet",
|
||||
];
|
||||
pub(crate) const AGENT_RUNTIME_CANVAS_ASSET_KINDS: &[&str] =
|
||||
&["game-art", "icon-spec", "ui-prototype", "art-spritesheet"];
|
||||
|
||||
#[cfg(test)]
|
||||
mod canvas_asset_kind_contract_tests {
|
||||
@@ -16,13 +11,7 @@ mod canvas_asset_kind_contract_tests {
|
||||
fn canvas_asset_kind_catalog_preserves_authoritative_contract() {
|
||||
assert_eq!(
|
||||
AGENT_RUNTIME_CANVAS_ASSET_KINDS,
|
||||
&[
|
||||
"game-art",
|
||||
"icon-spec",
|
||||
"ui-prototype",
|
||||
"art-spritesheet",
|
||||
"ui-spritesheet",
|
||||
]
|
||||
&["game-art", "icon-spec", "ui-prototype", "art-spritesheet"]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
-41
@@ -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);
|
||||
@@ -7495,47 +7678,6 @@ fn autonomous_ready_terminal_failures_are_projected_without_retry() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn autonomous_scheduler_reprojects_a_recovered_terminal_child_before_returning() {
|
||||
let (_temporary, root, parent_state, _contract) =
|
||||
autonomous_fixture("做一个完整小游戏", "autonomous-recovered-terminal-parent");
|
||||
update_manifest_task_status_at(&root, "design-director", GameCreationAppTaskStatus::Running)
|
||||
.expect("mark recovered autonomous child running");
|
||||
let record = queue_autonomous_manifest_child_fixture(&root, &parent_state, "design-director");
|
||||
append_game_creator_agent_runtime_task_record(
|
||||
&root,
|
||||
&AgentRuntimeTaskRecord {
|
||||
status: "completed".to_string(),
|
||||
phase: "completed".to_string(),
|
||||
current_action: "recovered child already completed".to_string(),
|
||||
terminal_detail: Some("completed before scheduler recovery".to_string()),
|
||||
error: None,
|
||||
updated_at: unix_timestamp(),
|
||||
..record
|
||||
},
|
||||
)
|
||||
.expect("append recovered autonomous child terminal");
|
||||
|
||||
let scheduled = schedule_autonomous_game_build_ready_tasks_at(
|
||||
&root,
|
||||
&parent_state.agent_id,
|
||||
&parent_state.run_id,
|
||||
3,
|
||||
)
|
||||
.expect("recover terminal autonomous child");
|
||||
|
||||
assert_eq!(scheduled.len(), 1);
|
||||
assert_eq!(
|
||||
read_manifest_for_project(&root)
|
||||
.expect("read reprojected manifest")
|
||||
.tasks
|
||||
.iter()
|
||||
.find(|task| task.id == "design-director")
|
||||
.map(|task| &task.status),
|
||||
Some(&GameCreationAppTaskStatus::Completed),
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn concurrent_autonomous_child_terminal_projection_preserves_all_manifest_updates() {
|
||||
let (_temporary, root, parent_state, _contract) =
|
||||
|
||||
@@ -705,19 +705,6 @@ pub(in crate::agent) fn continuation_for_game_creator_agent_runtime_steer(
|
||||
..AgentRuntimeContinuationContext::default()
|
||||
};
|
||||
context_tracker.apply_to_continuation(&mut continuation);
|
||||
if next_loop_index > 0
|
||||
&& next_loop_index % AGENT_RUNTIME_BACKGROUND_LOOP_LIMIT == 0
|
||||
&& continuation.window_completed_loops > 0
|
||||
{
|
||||
continuation.window_completed_loops = 0;
|
||||
continuation.window_observation_fingerprints.clear();
|
||||
continuation.last_window_fingerprint =
|
||||
super::context_window::agent_runtime_context_window_fingerprint(
|
||||
&context_tracker.observation_signatures,
|
||||
)
|
||||
.or_else(|| context_tracker.last_window_fingerprint.clone());
|
||||
continuation.context_stalled = false;
|
||||
}
|
||||
continuation
|
||||
}
|
||||
|
||||
@@ -889,41 +876,4 @@ mod tests {
|
||||
|
||||
fs::remove_dir_all(root).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn steer_at_a_skipped_checkpoint_boundary_starts_a_fresh_window() {
|
||||
let temporary = crate::tests::canonical_test_tempdir("steer-window-boundary-");
|
||||
let root = temporary.path().join("project");
|
||||
init_local_game_project_at(&root, "project-steer-window", "追加指令窗口边界")
|
||||
.expect("project init");
|
||||
let runtime = start_game_creator_agent_runtime_task_at(
|
||||
&root,
|
||||
"code-prototype",
|
||||
"验证追加指令窗口边界",
|
||||
"steer-context-window-boundary-run",
|
||||
"agent-background-task",
|
||||
"追加指令窗口边界测试",
|
||||
vec!["恢复时保持 context bundle 有效".to_string()],
|
||||
)
|
||||
.expect("start steer window boundary runtime state");
|
||||
let mut tracker = AgentRuntimeContextWindowTracker {
|
||||
completed_loops: AGENT_RUNTIME_BACKGROUND_LOOP_LIMIT - 1,
|
||||
..AgentRuntimeContextWindowTracker::default()
|
||||
};
|
||||
tracker
|
||||
.observation_signatures
|
||||
.insert("abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789".to_string());
|
||||
|
||||
let continuation = continuation_for_game_creator_agent_runtime_steer(
|
||||
&runtime,
|
||||
&AgentRuntimeToolPlan::default(),
|
||||
&[],
|
||||
AGENT_RUNTIME_BACKGROUND_LOOP_LIMIT,
|
||||
&tracker,
|
||||
);
|
||||
|
||||
assert_eq!(continuation.window_completed_loops, 0);
|
||||
assert!(continuation.window_observation_fingerprints.is_empty());
|
||||
assert!(!continuation.context_stalled);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,11 +33,7 @@ pub(in crate::agent) use run_status::*;
|
||||
pub(in crate::agent) use task_ops::*;
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) use media::{
|
||||
resolve_agent_runtime_platform_art_generation_options_at,
|
||||
validate_agent_runtime_canvas_delegated_ui_spritesheet_authorization_at,
|
||||
validate_agent_runtime_canvas_replacement_authorization_at, AGENT_RUNTIME_ART_SPRITESHEET_PATH,
|
||||
};
|
||||
pub(crate) use media::validate_agent_runtime_canvas_replacement_authorization_at;
|
||||
|
||||
pub(crate) use action_history::{
|
||||
is_valid_agent_runtime_action_id, observe_agent_runtime_action_history,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1257,6 +1257,7 @@ fn runtime_tool_input_schema(tool: &str) -> Value {
|
||||
"missingAssetSlots": {
|
||||
"type": "array",
|
||||
"maxItems": 2,
|
||||
"uniqueItems": true,
|
||||
"items": { "type": "string", "enum": ["art-spec", "core-spritesheet"] }
|
||||
}
|
||||
}
|
||||
@@ -1380,7 +1381,7 @@ mod tests {
|
||||
let Some(object) = schema.as_object() else {
|
||||
return;
|
||||
};
|
||||
for keyword in ["oneOf", "anyOf", "allOf", "not", "uniqueItems"] {
|
||||
for keyword in ["oneOf", "anyOf", "allOf", "not"] {
|
||||
if object.contains_key(keyword) {
|
||||
issues.push(format!(
|
||||
"strict schema contains unsupported {keyword} at {path}"
|
||||
|
||||
@@ -1026,131 +1026,9 @@ pub(crate) fn register_local_asset_entry(
|
||||
id_prefix: &str,
|
||||
source: GameCreationAppAssetSource,
|
||||
) -> Result<UploadLocalAssetResult, String> {
|
||||
register_local_asset_entry_internal(root, local_path, kind, media_type, id_prefix, source, None)
|
||||
.map(|(registered, _)| registered)
|
||||
.map_err(LocalAssetRegistrationError::into_message)
|
||||
}
|
||||
|
||||
#[derive(Debug, Eq, PartialEq)]
|
||||
pub(crate) struct CanvasAssetRegistrationAuditIdentity {
|
||||
pub(crate) asset_id: String,
|
||||
pub(crate) local_path: String,
|
||||
pub(crate) record_type: String,
|
||||
pub(crate) transaction_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Eq, PartialEq)]
|
||||
pub(crate) enum CanvasAssetRegistrationError {
|
||||
BeforeMutation(String),
|
||||
AuditAppendOutcomeUnknown {
|
||||
registered: UploadLocalAssetResult,
|
||||
audit: CanvasAssetRegistrationAuditIdentity,
|
||||
message: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl CanvasAssetRegistrationError {
|
||||
pub(crate) fn audit_identity(&self) -> Option<&CanvasAssetRegistrationAuditIdentity> {
|
||||
match self {
|
||||
Self::BeforeMutation(_) => None,
|
||||
Self::AuditAppendOutcomeUnknown { audit, .. } => Some(audit),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for CanvasAssetRegistrationError {
|
||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::BeforeMutation(message) => formatter.write_str(message),
|
||||
Self::AuditAppendOutcomeUnknown {
|
||||
audit, message, ..
|
||||
} => write!(
|
||||
formatter,
|
||||
"{message};素材登记审计追加结果未知:assetId={}, localPath={}, recordType={}, transactionId={}",
|
||||
audit.asset_id, audit.local_path, audit.record_type, audit.transaction_id
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn register_local_asset_entry_for_canvas_transaction(
|
||||
root: &Path,
|
||||
local_path: &str,
|
||||
kind: &str,
|
||||
media_type: &str,
|
||||
id_prefix: &str,
|
||||
source: GameCreationAppAssetSource,
|
||||
transaction_id: &str,
|
||||
) -> Result<(UploadLocalAssetResult, String), CanvasAssetRegistrationError> {
|
||||
let transaction_id = transaction_id.trim();
|
||||
validate_agent_db_canvas_asset_transaction_id(transaction_id)
|
||||
.map_err(CanvasAssetRegistrationError::BeforeMutation)?;
|
||||
register_local_asset_entry_internal(
|
||||
root,
|
||||
local_path,
|
||||
kind,
|
||||
media_type,
|
||||
id_prefix,
|
||||
source,
|
||||
Some(transaction_id),
|
||||
)
|
||||
.map_err(|error| error.into_canvas_error(transaction_id))
|
||||
}
|
||||
|
||||
enum LocalAssetRegistrationError {
|
||||
BeforeMutation(String),
|
||||
AuditAppendOutcomeUnknown {
|
||||
registered: UploadLocalAssetResult,
|
||||
record_type: String,
|
||||
message: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl LocalAssetRegistrationError {
|
||||
fn into_message(self) -> String {
|
||||
match self {
|
||||
Self::BeforeMutation(message) | Self::AuditAppendOutcomeUnknown { message, .. } => {
|
||||
message
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn into_canvas_error(self, transaction_id: &str) -> CanvasAssetRegistrationError {
|
||||
match self {
|
||||
Self::BeforeMutation(message) => CanvasAssetRegistrationError::BeforeMutation(message),
|
||||
Self::AuditAppendOutcomeUnknown {
|
||||
registered,
|
||||
record_type,
|
||||
message,
|
||||
} => CanvasAssetRegistrationError::AuditAppendOutcomeUnknown {
|
||||
audit: CanvasAssetRegistrationAuditIdentity {
|
||||
asset_id: registered.id.clone(),
|
||||
local_path: registered.local_path.clone(),
|
||||
record_type,
|
||||
transaction_id: transaction_id.to_string(),
|
||||
},
|
||||
registered,
|
||||
message,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn register_local_asset_entry_internal(
|
||||
root: &Path,
|
||||
local_path: &str,
|
||||
kind: &str,
|
||||
media_type: &str,
|
||||
id_prefix: &str,
|
||||
source: GameCreationAppAssetSource,
|
||||
transaction_id: Option<&str>,
|
||||
) -> Result<(UploadLocalAssetResult, String), LocalAssetRegistrationError> {
|
||||
let normalized_path =
|
||||
normalize_relative_path(local_path).map_err(LocalAssetRegistrationError::BeforeMutation)?;
|
||||
let absolute_path = resolve_local_project_path(root, &normalized_path)
|
||||
.map_err(LocalAssetRegistrationError::BeforeMutation)?;
|
||||
let (manifest_path, mut manifest) =
|
||||
read_or_create_manifest(root).map_err(LocalAssetRegistrationError::BeforeMutation)?;
|
||||
let normalized_path = normalize_relative_path(local_path)?;
|
||||
let absolute_path = resolve_local_project_path(root, &normalized_path)?;
|
||||
let (manifest_path, mut manifest) = read_or_create_manifest(root)?;
|
||||
let kind = if kind.is_empty() { "asset" } else { kind };
|
||||
let media_type = if media_type.is_empty() {
|
||||
"application/octet-stream"
|
||||
@@ -1184,49 +1062,25 @@ fn register_local_asset_entry_internal(
|
||||
});
|
||||
(id, "asset.register")
|
||||
};
|
||||
if let Some(transaction_id) = transaction_id {
|
||||
validate_agent_db_canvas_asset_audit_identity(transaction_id, &id, &normalized_path)
|
||||
.map_err(LocalAssetRegistrationError::BeforeMutation)?;
|
||||
}
|
||||
write_manifest(&manifest_path, &manifest)
|
||||
.map_err(LocalAssetRegistrationError::BeforeMutation)?;
|
||||
let mut audit = serde_json::json!({
|
||||
"recordType": record_type,
|
||||
"assetId": id.clone(),
|
||||
"localPath": normalized_path.clone(),
|
||||
"kind": kind,
|
||||
"mediaType": media_type,
|
||||
"source": source_for_record,
|
||||
});
|
||||
if let Some(transaction_id) = transaction_id {
|
||||
audit
|
||||
.as_object_mut()
|
||||
.expect("asset registration audit is an object")
|
||||
.insert(
|
||||
"transactionId".to_string(),
|
||||
serde_json::Value::String(transaction_id.to_string()),
|
||||
);
|
||||
}
|
||||
let registered = UploadLocalAssetResult {
|
||||
write_manifest(&manifest_path, &manifest)?;
|
||||
append_agent_db_record(
|
||||
root,
|
||||
serde_json::json!({
|
||||
"recordType": record_type,
|
||||
"assetId": id.clone(),
|
||||
"localPath": normalized_path.clone(),
|
||||
"kind": kind,
|
||||
"mediaType": media_type,
|
||||
"source": source_for_record,
|
||||
}),
|
||||
)?;
|
||||
|
||||
Ok(UploadLocalAssetResult {
|
||||
id,
|
||||
local_path: normalized_path.clone(),
|
||||
absolute_path: absolute_path.to_string_lossy().into_owned(),
|
||||
manifest_path: manifest_path.to_string_lossy().into_owned(),
|
||||
};
|
||||
let append_result = if transaction_id.is_some() {
|
||||
append_agent_db_canvas_asset_transaction_audit_idempotent(root, audit).map(|_| ())
|
||||
} else {
|
||||
append_agent_db_record(root, audit)
|
||||
};
|
||||
if let Err(message) = append_result {
|
||||
return Err(LocalAssetRegistrationError::AuditAppendOutcomeUnknown {
|
||||
registered,
|
||||
record_type: record_type.to_string(),
|
||||
message,
|
||||
});
|
||||
}
|
||||
|
||||
Ok((registered, record_type.to_string()))
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -1234,152 +1088,6 @@ mod tests {
|
||||
use super::*;
|
||||
use std::io::{Read, Write};
|
||||
|
||||
fn unique_asset_registration_test_root(label: &str) -> PathBuf {
|
||||
std::env::temp_dir().join(format!(
|
||||
"genarrative-asset-registration-{label}-{}-{}",
|
||||
std::process::id(),
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_nanos()
|
||||
))
|
||||
}
|
||||
|
||||
fn canvas_registration_source() -> GameCreationAppAssetSource {
|
||||
GameCreationAppAssetSource {
|
||||
kind: GameCreationAppAssetSourceKind::Canvas,
|
||||
canvas_project_id: Some("canvas-project-1".to_string()),
|
||||
resource_id: Some("resource-1".to_string()),
|
||||
asset_object_id: Some("asset-object-1".to_string()),
|
||||
task_id: Some("task-1".to_string()),
|
||||
prompt: Some("test prompt".to_string()),
|
||||
model: Some("test-model".to_string()),
|
||||
generation_route: Some("test-route".to_string()),
|
||||
generation_kind: Some("test-kind".to_string()),
|
||||
reference_resource_ids: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn canvas_registration_sync_unknown_error_retains_complete_audit_identity() {
|
||||
let root = unique_asset_registration_test_root("post-sync-identity");
|
||||
init_local_game_project_at(&root, "asset-registration-test", "素材登记测试")
|
||||
.expect("initialize asset registration fixture");
|
||||
let local_path = "assets/ui-spritesheet.png";
|
||||
fs::write(root.join(local_path), b"test-image").expect("write asset registration fixture");
|
||||
fs::write(
|
||||
root.join(".agent/runtime/test-fail-after-agent-db-record-sync"),
|
||||
"asset.register",
|
||||
)
|
||||
.expect("inject post-sync Agent DB failure");
|
||||
|
||||
let error = register_local_asset_entry_for_canvas_transaction(
|
||||
&root,
|
||||
local_path,
|
||||
"ui-spritesheet",
|
||||
"image/png",
|
||||
"platform-art",
|
||||
canvas_registration_source(),
|
||||
"canvas-transaction-1",
|
||||
)
|
||||
.expect_err("post-sync append outcome must be surfaced as unknown");
|
||||
let audit = error
|
||||
.audit_identity()
|
||||
.expect("unknown append error carries audit identity");
|
||||
let registered = match &error {
|
||||
CanvasAssetRegistrationError::AuditAppendOutcomeUnknown { registered, .. } => {
|
||||
registered
|
||||
}
|
||||
CanvasAssetRegistrationError::BeforeMutation(_) => {
|
||||
panic!("post-sync error must carry registered asset")
|
||||
}
|
||||
};
|
||||
assert_eq!(audit.asset_id, registered.id);
|
||||
assert_eq!(audit.local_path, local_path);
|
||||
assert_eq!(audit.record_type, "asset.register");
|
||||
assert_eq!(audit.transaction_id, "canvas-transaction-1");
|
||||
assert!(error.to_string().contains("追加结果未知"));
|
||||
|
||||
let records = fs::read_to_string(root.join(".agent/agent.db"))
|
||||
.expect("read durably appended asset audit");
|
||||
let matching = records
|
||||
.lines()
|
||||
.filter_map(|line| serde_json::from_str::<serde_json::Value>(line).ok())
|
||||
.find(|record| {
|
||||
record.get("recordType").and_then(serde_json::Value::as_str)
|
||||
== Some("asset.register")
|
||||
&& record
|
||||
.get("transactionId")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
== Some("canvas-transaction-1")
|
||||
})
|
||||
.expect("post-sync failure occurs after the audit is durable");
|
||||
assert_eq!(matching["assetId"], audit.asset_id);
|
||||
assert_eq!(matching["localPath"], audit.local_path);
|
||||
|
||||
fs::remove_dir_all(root).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn canvas_registration_rejects_non_compensatable_identity_before_manifest_mutation() {
|
||||
let cases = [
|
||||
(
|
||||
"unsafe-transaction",
|
||||
"assets/ui-spritesheet.png",
|
||||
"platform-art",
|
||||
"../unsafe",
|
||||
),
|
||||
(
|
||||
"outside-assets",
|
||||
"ui/ui-spritesheet.png",
|
||||
"platform-art",
|
||||
"canvas-transaction-2",
|
||||
),
|
||||
(
|
||||
"unsafe-asset-id",
|
||||
"assets/ui-spritesheet.png",
|
||||
"../unsafe",
|
||||
"canvas-transaction-3",
|
||||
),
|
||||
];
|
||||
for (label, local_path, id_prefix, transaction_id) in cases {
|
||||
let root = unique_asset_registration_test_root(label);
|
||||
init_local_game_project_at(&root, "asset-registration-test", "素材登记测试")
|
||||
.expect("initialize asset registration fixture");
|
||||
let absolute_path = root.join(local_path);
|
||||
fs::create_dir_all(
|
||||
absolute_path
|
||||
.parent()
|
||||
.expect("asset identity fixture has parent"),
|
||||
)
|
||||
.expect("create asset identity fixture parent");
|
||||
fs::write(&absolute_path, b"test-image").expect("write asset identity fixture");
|
||||
let (manifest_path, manifest_before) =
|
||||
read_or_create_manifest(&root).expect("read manifest before rejected registration");
|
||||
|
||||
let error = register_local_asset_entry_for_canvas_transaction(
|
||||
&root,
|
||||
local_path,
|
||||
"ui-spritesheet",
|
||||
"image/png",
|
||||
id_prefix,
|
||||
canvas_registration_source(),
|
||||
transaction_id,
|
||||
)
|
||||
.expect_err("non-compensatable Canvas identity must be rejected");
|
||||
assert!(
|
||||
matches!(error, CanvasAssetRegistrationError::BeforeMutation(_)),
|
||||
"{error}"
|
||||
);
|
||||
assert_eq!(
|
||||
read_manifest(&manifest_path).expect("read manifest after rejected registration"),
|
||||
manifest_before
|
||||
);
|
||||
|
||||
fs::remove_dir_all(root).ok();
|
||||
}
|
||||
}
|
||||
|
||||
fn read_asset_test_request(stream: &mut std::net::TcpStream) {
|
||||
stream
|
||||
.set_read_timeout(Some(Duration::from_secs(2)))
|
||||
|
||||
@@ -193,7 +193,7 @@ pub(crate) fn get_local_game_manifest(
|
||||
return Err(format!("不支持通过 manifest 执行命令:{command_id}"));
|
||||
}
|
||||
enforce_project_permission_policy(root, command_id)?;
|
||||
read_existing_manifest_for_project(root)
|
||||
read_manifest_for_project(root)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -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::*;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user