import { spawn } from 'node:child_process'; import { accessSync } from 'node:fs'; import fs from 'node:fs/promises'; import http from 'node:http'; import os from 'node:os'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; const appRoot = fileURLToPath(new URL('..', import.meta.url)); const inheritedChildEnvironment = { ...globalThis['process']['env'] }; const localConfigPath = path.join(appRoot, 'game-creator.config.local.json'); const projectRoot = path.join( os.tmpdir(), `genarrative-ai-game-creator-smoke-${Date.now()}`, ); const prompt = '像素风反弹弹幕厨房'; const smokeAssetPath = 'assets/uploads/smoke-chef.png'; const smokeAssetMarker = 'SMOKE_LOCAL_ASSET:chef'; const smokeAssetBytes = Buffer.concat([ Buffer.from( 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=', 'base64', ), Buffer.from(smokeAssetMarker), ]); const smokeAudioAssetPath = 'assets/uploads/smoke-bounce.mp3'; const smokeAudioAssetBytes = 'SMOKE_LOCAL_AUDIO:bounce'; const smokeProjectConversationMarker = 'SMOKE_CONVERSATION_CONTEXT:moonlight-wok'; const smokeAgentConversationMarker = 'SMOKE_AGENT_CONVERSATION_CONTEXT:neon-kitchen'; function handoffs() { return [ [ 'design', 'Gameplay', '定义反弹厨房核心循环', ['game/game_design.md'], '交给数值、美术、音乐、程序组', ], [ 'balance', 'Difficulty', '给出锅盖反弹速度、生命和得分口径', ['game/balance.json'], '交给程序组读取', ], [ 'art', 'Asset', '规划像素厨师、夜间厨房和锅盖弹幕资产', ['assets/manifest.art.json'], '进入画板链路', ], [ 'audio', 'SFX', '规划锅盖反弹音效和厨房节奏 BGM', ['assets/manifest.audio.json'], '进入音频链路', ], [ 'code', 'Code', '生成 canvas 可玩原型', ['game/index.html'], '交给 Playtest', ], [ 'publishing', 'Publish', '整理标题、标签和发布说明', ['exports/README.md'], '等待预览验收', ], ].map(([group, role, summary, outputs, next]) => ({ group, role, summary, outputs, next, })); } function draft({ withInput }) { const inputCode = withInput ? "window.addEventListener('keydown', (event) => { if (event.key.toLowerCase() === 'r') resetGame(); player.x += event.key === 'ArrowRight' ? 8 : event.key === 'ArrowLeft' ? -8 : 0; });" : ''; return { title: '反弹弹幕厨房', designMarkdown: '玩家控制像素厨师移动锅盖反弹月光弹幕,点亮三口锅后获胜,被弹幕击中耗尽生命则失败。', balance: { playerSpeed: 220, playerLives: 3, scorePerPot: 100, difficultyRamp: '每 20 秒增加一枚弹幕', }, artManifest: { source: 'local-provider', items: [ { kind: 'character', title: '像素厨师', status: 'needs-canvas' }, { kind: 'scene', title: '夜间厨房', status: 'needs-canvas' }, ], }, audioManifest: { source: 'local-provider', items: [ { kind: 'background-music', title: '厨房节奏 BGM', status: 'needs-canvas', }, { kind: 'sound-effect', title: '锅盖反弹音', status: 'needs-canvas' }, ], }, publishReadme: '## 标签\n\n弹幕 / 反弹 / 厨房\n\n## 下一步\n\n试玩锅盖反弹手感。', handoffs: handoffs(), handoffSummary: '策划组 / Gameplay:反弹厨房核心循环\n数值组 / Difficulty:生命、速度和得分\n美术组 / Asset:像素厨房资产\n音乐组 / SFX:反弹音效\n程序组 / Code:canvas 原型\n运营组 / Publish:发布包装', gameHtml: ` 反弹弹幕厨房 `, }; } const responses = [ '## 核心循环\n\n反弹弹幕点亮三口锅。\n\n## Evaluator 验收\n\n必须有输入监听、主循环、胜负状态和重开路径。', JSON.stringify(draft({ withInput: false })), JSON.stringify(draft({ withInput: true })), ]; let responseIndex = 0; const requestBodies = []; const server = http.createServer((request, response) => { let requestBody = ''; request.setEncoding('utf8'); request.on('data', (chunk) => { requestBody += chunk; }); request.on('end', () => { requestBodies.push(requestBody); let requestJson = null; try { requestJson = JSON.parse(requestBody); } catch { // Keep the request invalid so the provider fixture can return its normal error path. } const content = responses[responseIndex++]; if (!content) { response.writeHead(500, { 'content-type': 'application/json' }); response.end(JSON.stringify({ error: 'local provider exhausted' })); return; } if (requestJson?.stream === true) { writeStreamingChatCompletion(response, content); return; } const body = JSON.stringify({ id: `chatcmpl-local-${responseIndex}`, model: 'local-game-creator-smoke', choices: [ { message: { content }, finish_reason: 'stop', }, ], usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }, }); response.writeHead(200, { 'content-type': 'application/json', 'content-length': Buffer.byteLength(body), }); response.end(body); }); }); function writeStreamingChatCompletion(response, content) { response.writeHead(200, { 'content-type': 'text/event-stream; charset=utf-8', 'cache-control': 'no-cache', }); const splitAt = Math.max(1, Math.floor(content.length / 2)); for (const chunk of [content.slice(0, splitAt), content.slice(splitAt)]) { if (!chunk) { continue; } response.write( `data: ${JSON.stringify({ id: `chatcmpl-local-${responseIndex}`, model: 'local-game-creator-smoke', choices: [{ delta: { content: chunk }, finish_reason: null }], })}\n\n`, ); } response.write( `data: ${JSON.stringify({ id: `chatcmpl-local-${responseIndex}`, model: 'local-game-creator-smoke', choices: [{ delta: {}, finish_reason: 'stop' }], })}\n\n`, ); response.end('data: [DONE]\n\n'); } await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); const address = server.address(); const baseUrl = `http://127.0.0.1:${address.port}`; const previousLocalConfig = await readOptionalFile(localConfigPath); try { await seedLocalAsset(); await writeSmokeLocalConfig(baseUrl); const { output, previewHtml, previewUrl, previewAsset, previewAssetHead, previewAudio, previewAudioHead, previewDom, } = await runAgent(); const tracePath = path.join(projectRoot, '.agent/run.latest.json'); const trace = JSON.parse(await fs.readFile(tracePath, 'utf8')); const pass2TaskGraph = JSON.parse( await fs.readFile( path.join(projectRoot, '.agent/passes/pass-2/task-graph.json'), 'utf8', ), ); const agentDbRecords = ( await fs.readFile(path.join(projectRoot, '.agent/agent.db'), 'utf8') ) .trim() .split('\n') .map((line) => JSON.parse(line)); const previewLog = await fs.readFile( path.join(projectRoot, '.agent/logs/preview.log'), 'utf8', ); const gameHtml = await fs.readFile( path.join(projectRoot, 'game/index.html'), 'utf8', ); const agenda2 = await fs.readFile( path.join(projectRoot, '.agent/passes/pass-2/agenda.md'), 'utf8', ); assert(output.includes('agent.run.completed'), 'CLI did not complete'); assert( previewUrl?.startsWith('http://127.0.0.1:'), 'CLI did not print local preview URL', ); assert( previewHtml.includes('LOCAL_E2E_MECHANIC:reflect-kitchen'), 'local preview did not serve generated game', ); assert( previewDom?.includes('data-smoke-frame=') && previewDom.includes('data-smoke-audio="ready"'), 'headless browser did not run generated preview frame', ); const canvasPixels = extractDomNumber(previewDom, 'smoke-canvas-pixels'); const canvasColors = extractDomNumber(previewDom, 'smoke-canvas-colors'); assert( canvasPixels > 40 && canvasColors > 2, `headless browser preview canvas looks blank: pixels=${canvasPixels}, colors=${canvasColors}`, ); assert( previewAsset.includes(smokeAssetMarker), 'local preview did not serve referenced project asset', ); assert( previewAudio.includes(smokeAudioAssetBytes), 'local preview did not serve referenced audio asset', ); assert( previewAssetHead?.statusCode === 200 && previewAssetHead.contentLength === String(smokeAssetBytes.length) && previewAssetHead.contentType === 'image/png' && previewAssetHead.body === '', 'local preview HEAD did not preserve referenced asset content length', ); assert( previewAudioHead?.statusCode === 200 && previewAudioHead.contentLength === String(Buffer.byteLength(smokeAudioAssetBytes)) && previewAudioHead.contentType === 'audio/mpeg' && previewAudioHead.body === '', 'local preview HEAD did not preserve referenced audio asset metadata', ); assert( responseIndex === responses.length, `provider calls ${responseIndex}/${responses.length}`, ); assert( requestBodies.every((body) => body.includes('"stream":true')), 'provider requests did not use streaming LLM mode', ); assert( requestBodies.some((body) => body.includes('"model":"planner-smoke-model"'), ), 'provider requests did not use planner agent LLM override', ); assert( requestBodies.some((body) => body.includes('"model":"generator-smoke-model"'), ), 'provider requests did not use generator agent LLM override', ); assert( requestBodies.every((body) => !body.includes('global-smoke-model-unused')), 'provider requests unexpectedly used global LLM config', ); assert( requestBodies.some( (body) => body.includes('# 本地项目资产') && body.includes(smokeAssetPath) && body.includes(smokeAudioAssetPath), ), 'provider requests missing local asset prompt context', ); assert( requestBodies.some((body) => body.includes(smokeProjectConversationMarker), ) && requestBodies.some((body) => body.includes(smokeAgentConversationMarker)), 'provider requests missing recent conversation prompt context', ); assert( requestBodies.every((body) => !body.includes('sk-smoke-secret')), 'provider requests leaked sensitive conversation context', ); assert(trace.status === 'preview-stopped', `trace status ${trace.status}`); assert(trace.passes === 2, `trace passes ${trace.passes}`); const expectedToolCallCount = trace.steps.reduce( (total, step) => total + (step.toolCalls?.length ?? 0), 0, ); assert( trace.toolCallCount === expectedToolCallCount, `trace toolCallCount ${trace.toolCallCount}/${expectedToolCallCount}`, ); assert( trace.toolCallCount <= trace.maxToolCalls, `trace tool budget ${trace.toolCallCount}/${trace.maxToolCalls}`, ); const tracedGroups = new Set( trace.steps.map((step) => step.group).filter(Boolean), ); for (const group of [ 'design', 'balance', 'art', 'audio', 'code', 'publishing', ]) { assert( tracedGroups.has(group), `trace missing professional group ${group}`, ); } assert( trace.taskGraph?.repairRoutes?.some( (route) => route.reason === 'code-runtime+dependency-impact' && route.taskIds.includes('code-prototype') && route.taskIds.includes('preview-readiness') && route.taskIds.includes('publish-package'), ), 'trace missing code-runtime dependency-impact repair route', ); assert( pass2TaskGraph.repairRoutes?.some( (route) => route.reason === 'code-runtime+dependency-impact' && route.taskIds.includes('code-prototype') && route.taskIds.includes('preview-readiness') && route.taskIds.includes('publish-package'), ), 'pass task graph missing downstream-impact code repair route', ); assert( trace.artifacts?.some((artifact) => artifact.path === '.agent/agent.db'), 'trace artifacts missing .agent/agent.db', ); assert( trace.artifacts?.some( (artifact) => artifact.path === '.agent/passes/pass-2/task-graph.json', ), 'trace artifacts missing pass-2 task graph', ); assert( trace.artifacts?.some( (artifact) => artifact.path === '.agent/logs/preview.log', ), 'trace artifacts missing preview log', ); assert( trace.artifacts?.some( (artifact) => artifact.path === '.agent/logs/agent.log', ) && trace.artifacts?.some( (artifact) => artifact.path === '.agent/logs/command.log', ), 'trace artifacts missing agent or command logs', ); assert( previewLog.includes('preview.running') && previewLog.includes('preview.stopped'), 'preview log missing start or stop entries', ); assert( agentDbRecords.some((record) => record.recordType === 'project.init') && agentDbRecords.some( (record) => record.recordType === 'game.generate_draft', ), 'agent.db missing init or generation records', ); assert( agenda2.includes('activeTasks: code-director'), 'repair agenda did not target code tasks', ); assert( agenda2.includes('publish-package'), 'repair agenda did not target downstream publishing tasks', ); assert( agenda2.includes('carriedTasks: design-director'), 'repair agenda did not carry design tasks', ); assert( trace.steps.some( (step) => step.pass === 2 && step.agent === '策划组 / Director' && step.status === 'carried-over', ), 'trace missing carried-over design role', ); assert( trace.steps.some( (step) => step.pass === 2 && step.agent === '程序组 / Director' && step.toolCalls?.[0]?.toolId === 'agent.role.brief.code.director', ), 'trace missing code repair role', ); assert( trace.steps.some( (step) => step.pass === 2 && step.agent === '运营组 / Publish' && step.toolCalls?.[0]?.toolId === 'agent.role.brief.publishing.publish', ), 'trace missing downstream publishing repair role', ); assert( trace.steps.some( (step) => step.agent === 'Planner' && step.inputPaths?.includes('memory/session.md') && step.inputPaths?.includes('memory/project.md') && step.inputPaths?.includes('memory/blackboard.md') && step.inputPaths?.includes('.agent/conversations/project.jsonl') && step.inputPaths?.includes('.agent/conversations/agents/') && step.inputPaths?.includes('.agent/manifest.json'), ), 'trace missing planner memory, conversation or manifest inputs', ); assert( trace.steps.some( (step) => step.agent === '策划组 / Director' && step.pass === 1 && step.inputPaths?.includes('memory/blackboard.md') && step.inputPaths?.includes('.agent/conversations/project.jsonl') && step.inputPaths?.includes('.agent/conversations/agents/') && step.inputPaths?.includes('memory/agents/design/director.md') && step.inputPaths?.includes('.agent/manifest.json') && step.inputPaths?.includes('.agent/passes/pass-1/agenda.md'), ), 'trace missing role brief conversation, manifest or agenda inputs', ); assert( trace.steps.some( (step) => step.agent === 'Generator' && step.pass === 2 && step.inputPaths?.includes('memory/blackboard.md') && step.inputPaths?.includes('.agent/conversations/project.jsonl') && step.inputPaths?.includes('.agent/conversations/agents/') && step.inputPaths?.includes('.agent/manifest.json') && step.inputPaths?.includes('.agent/passes/pass-2/agenda.md') && step.inputPaths?.includes('.agent/passes/pass-2/task-graph.json'), ), 'trace missing generator conversation, manifest, agenda or task graph inputs', ); assert( gameHtml.includes('LOCAL_E2E_MECHANIC:reflect-kitchen'), 'game html marker missing', ); assert( gameHtml.includes('addEventListener'), 'game html input listener missing', ); assert( gameHtml.includes(`/${smokeAssetPath}`), 'game html did not reference seeded local asset', ); assert( gameHtml.includes(`/${smokeAudioAssetPath}`), 'game html did not reference seeded local audio asset', ); console.log('ai-game-creator-shell.agent-run.smoke=passed'); console.log(`projectPath=${projectRoot}`); console.log(`tracePath=${tracePath}`); } finally { await restoreOptionalFile(localConfigPath, previousLocalConfig); server.close(); } async function readOptionalFile(filePath) { try { return await fs.readFile(filePath); } catch (error) { if (error?.code === 'ENOENT') { return null; } throw error; } } async function restoreOptionalFile(filePath, previous) { if (previous === null) { await fs.rm(filePath, { force: true }); return; } await fs.writeFile(filePath, previous); } async function writeSmokeLocalConfig(baseUrl) { await fs.writeFile( localConfigPath, `${JSON.stringify( { llm: { apiKey: 'global-smoke-key-unused', baseUrl: 'http://127.0.0.1:1/v1', model: 'global-smoke-model-unused', apiKind: 'openai_chat', stream: true, }, agentLlm: { planner: { apiKey: 'planner-smoke-key', baseUrl, model: 'planner-smoke-model', apiKind: 'openai_chat', stream: true, }, generator: { apiKey: 'generator-smoke-key', baseUrl, model: 'generator-smoke-model', apiKind: 'openai_chat', stream: true, }, }, }, null, 2, )}\n`, ); } function runAgent() { return new Promise((resolve, reject) => { let previewReadStarted = false; let previewUrl = ''; let previewHtml = ''; let previewAsset = ''; let previewAssetHead = null; let previewAudio = ''; let previewAudioHead = null; let previewDom = ''; const child = spawn( 'cargo', [ 'run', '--manifest-path', 'src-tauri/Cargo.toml', '--', '--agent-run', projectRoot, prompt, ], { cwd: appRoot, stdio: ['pipe', 'pipe', 'pipe'], env: { ...inheritedChildEnvironment, // 该 smoke 只使用一次性 loopback Provider;生产路由仍保持锁定。 GENARRATIVE_AGC_DEBUG_PROVIDER_E2E: '1', }, }, ); let stdout = ''; let stderr = ''; let previewReadError = null; child.stdout.on('data', (chunk) => { stdout += chunk.toString(); if (previewReadStarted) { return; } const line = stdout .split('\n') .find((entry) => entry.startsWith('previewUrl=')); if (!line) { return; } previewReadStarted = true; previewUrl = line.slice('previewUrl='.length).trim(); Promise.all([ readHttpText(previewUrl), readHttpText(new URL(smokeAssetPath, previewUrl).toString()), readHttpHead(new URL(smokeAssetPath, previewUrl).toString()), readHttpText(new URL(smokeAudioAssetPath, previewUrl).toString()), readHttpHead(new URL(smokeAudioAssetPath, previewUrl).toString()), readBrowserDom(previewUrl), ]) .then(([html, asset, assetHead, audio, audioHead, dom]) => { previewHtml = html; previewAsset = asset; previewAssetHead = assetHead; previewAudio = audio; previewAudioHead = audioHead; previewDom = dom; }) .catch((error) => { previewReadError = error; }) .finally(() => { child.stdin.write('\n'); }); }); child.stderr.on('data', (chunk) => { stderr += chunk.toString(); }); child.on('error', reject); child.on('close', (code, signal) => { const output = `${stdout}${stderr}`; if (previewReadError) { reject(previewReadError); return; } if (code === 0) { resolve({ output, previewHtml, previewUrl, previewAsset, previewAssetHead, previewAudio, previewAudioHead, previewDom, }); } else { reject( new Error( `agent run failed: exitCode=${code}, signal=${signal ?? 'none'}\n` + `stderr tail (last 8000 characters):\n${stderr.slice(-8000)}\n` + `stdout tail (last 4000 characters):\n${stdout.slice(-4000)}`, ), ); } }); }); } async function seedLocalAsset() { await fs.mkdir(path.join(projectRoot, 'game'), { recursive: true }); await fs.writeFile( path.join(projectRoot, 'game/index.html'), '还没有生成游戏', ); await fs.mkdir(path.join(projectRoot, 'assets/uploads'), { recursive: true }); await fs.mkdir(path.join(projectRoot, '.agent'), { recursive: true }); await seedConversationContext(); await fs.writeFile(path.join(projectRoot, smokeAssetPath), smokeAssetBytes); await fs.writeFile( path.join(projectRoot, smokeAudioAssetPath), smokeAudioAssetBytes, ); await fs.writeFile( path.join(projectRoot, '.agent/manifest.json'), JSON.stringify( { schemaVersion: 'game-creation-app.manifest.v1', projectId: 'local-provider-smoke', name: '本地 Provider Smoke', assets: [ { id: 'smoke-chef', kind: 'character', mediaType: 'image/png', localPath: smokeAssetPath, source: { kind: 'uploaded' }, }, { id: 'smoke-bounce', kind: 'sound-effect', mediaType: 'audio/mpeg', localPath: smokeAudioAssetPath, source: { kind: 'uploaded' }, }, ], }, null, 2, ), ); } async function seedConversationContext() { const projectConversationPath = path.join( projectRoot, '.agent/conversations/project.jsonl', ); const agentConversationPath = path.join( projectRoot, '.agent/conversations/agents/art-asset-plan.jsonl', ); await fs.mkdir(path.dirname(projectConversationPath), { recursive: true }); await fs.mkdir(path.dirname(agentConversationPath), { recursive: true }); await fs.writeFile( projectConversationPath, `${JSON.stringify({ schemaVersion: 'game-creator-conversation.v1', role: 'user', content: `玩家坚持使用 ${smokeProjectConversationMarker}`, agentId: null, updatedAt: 1, })}\n`, ); await fs.writeFile( agentConversationPath, `${JSON.stringify({ schemaVersion: 'game-creator-conversation.v1', role: 'assistant', content: `API Key sk-smoke-secret\n美术方向 ${smokeAgentConversationMarker}`, agentId: 'art-asset-plan', updatedAt: 2, })}\n`, ); } function readHttpText(url) { return new Promise((resolve, reject) => { http .get(url, (response) => { let body = ''; response.setEncoding('utf8'); response.on('data', (chunk) => { body += chunk; }); response.on('end', () => resolve(body)); }) .on('error', reject); }); } function readHttpHead(url) { return new Promise((resolve, reject) => { const request = http.request(url, { method: 'HEAD' }, (response) => { let body = ''; response.setEncoding('utf8'); response.on('data', (chunk) => { body += chunk; }); response.on('end', () => resolve({ statusCode: response.statusCode, contentLength: response.headers['content-length'], contentType: response.headers['content-type'], body, }), ); }); request.on('error', reject); request.end(); }); } function readBrowserDom(url) { return new Promise((resolve, reject) => { const chrome = resolveChromeBin(); const child = spawn( chrome, [ '--headless', '--disable-gpu', '--disable-dev-shm-usage', '--no-sandbox', '--virtual-time-budget=1000', '--dump-dom', url, ], { stdio: ['ignore', 'pipe', 'pipe'] }, ); let stdout = ''; let stderr = ''; child.stdout.on('data', (chunk) => { stdout += chunk.toString(); }); child.stderr.on('data', (chunk) => { stderr += chunk.toString(); }); child.on('error', reject); child.on('close', (code) => { if (code === 0) { resolve(stdout); } else { reject(new Error(stderr || `headless Chrome exited with ${code}`)); } }); }); } function resolveChromeBin() { const windowsRoot = path.parse(os.homedir()).root; for (const candidate of [ path.join( windowsRoot, 'Program Files/Google/Chrome/Application/chrome.exe', ), path.join( windowsRoot, 'Program Files (x86)/Google/Chrome/Application/chrome.exe', ), path.join( os.homedir(), 'AppData/Local/Google/Chrome/Application/chrome.exe', ), path.join( windowsRoot, 'Program Files/Microsoft/Edge/Application/msedge.exe', ), path.join( windowsRoot, 'Program Files (x86)/Microsoft/Edge/Application/msedge.exe', ), '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', '/Applications/Chromium.app/Contents/MacOS/Chromium', '/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge', '/opt/google/chrome/chrome', '/usr/bin/google-chrome', '/usr/bin/google-chrome-stable', '/usr/bin/chromium', '/usr/bin/chromium-browser', ]) { try { accessSync(candidate); return candidate; } catch { // Try the next supported system browser path. } } return 'google-chrome'; } function extractDomNumber(dom, kebabName) { const match = dom?.match(new RegExp(`data-${kebabName}="(\\d+)"`)); return match ? Number(match[1]) : 0; } function assert(condition, message) { if (!condition) { throw new Error(message); } }