创始人指令:游戏要有日志,读日志诊断运行时问题,模板+可选插件自带日志。 日志基建: - plugin.js:受控面 ctx 加 log(tag,...args) → window.__gameLog 环形(800)+ console;用受控 time.nowMs。 - boot-game-host.js:B2 插件 Proxy wrap 顺手记插件语义调用进 __gameLog(跳 draw/update/current/get 等高频)→ 插件自带日志免逐个改;recHook 仍记 __engineCalls 供 F_wiring。 - _template game-logic.js:ctx.log 生命周期(init/scene play/over)→ 模板自带。 - play.mjs smokeBoot:抓 __gameLog 写 staged evidence/game-log.json + 末12条进 out.logTail(boot 成败都抓,失败时最有诊断价值)。 - 实证:scaffold→smoke logtest → logCount:165、game-log.json 有 [p:sceneFsm:define/start]+[game:init];读日志诊断可用。 九门对齐(C 之 A+B2,recon ad3c564): - A(SaaGenNodes validateAmodelStaged):amodel 路 validate-ok→play 短路了 scaffoldNode(唯一写 play-spec.json 处)→ 补写 play-spec.json(从 design 产 K_PLAY_SPEC)→ 九门可驱动(治 E_live/G_input/H_progress 机制)。 - B2(boot-game-host):插件 recHook wrap → F_wiring callCount>0(实证 e2e 从 [E_live,F_wiring] → [E_live,H_progress],F_wiring 已过)。叠加式,factory/gamedef 零回归。 预算(gen.mjs):maxSteps 45→70 / maxWallMs 12→20min —— SAA design 富化 brief 比 raw(7步)需更多步。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
230 lines
11 KiB
JavaScript
230 lines
11 KiB
JavaScript
/**
|
||
* gen.mjs — agentic A-model 生成 orchestrator(L2 ReAct 循环 · spike)
|
||
* owner:amodel-gen spike
|
||
*
|
||
* 流程:scaffold(_template→amgen-<id>) → ReAct(M3 + 5 工具,done 门=check+build 绿)
|
||
* → play(循环外·权威门) → 落 verdict / run-log / run-summary。
|
||
*
|
||
* 用法(cwd=game-runtime,mini-desktop):
|
||
* spike:node tools/amodel-gen/gen.mjs --id run1 --brief "..." [--max-steps 40] [--port 4320] [--cdp 9222]
|
||
* SAA :node tools/amodel-gen/gen.mjs --mode saa --game-id <gameId> --brief "..."(__GameBundle 信封 + stage 到 _wg1-gen/ + done 门含 smoke-boot;stdout 末行 JSON)
|
||
*
|
||
* 注:本文件是 harness 侧,允许 Date.now/setTimeout(红线只约束游戏 src)。
|
||
*/
|
||
|
||
'use strict';
|
||
|
||
import { writeFileSync, mkdirSync } from 'node:fs';
|
||
import { join } from 'node:path';
|
||
import { scaffold, scaffoldSaa, readFileTool, writeFileTool, listDirTool, check, build, buildSaa, stage, gameDir } from './tools.mjs';
|
||
import { chat, MODEL } from './m3.mjs';
|
||
import { TOOLS, buildSystemPrompt } from './prompt.mjs';
|
||
import { play, smokeBoot } from './play.mjs';
|
||
|
||
function arg(name, def) {
|
||
const i = process.argv.indexOf(`--${name}`);
|
||
return i >= 0 ? process.argv[i + 1] : def;
|
||
}
|
||
|
||
const TOOL_RESULT_CAP = 30000; // 单次工具结果注入上限(够一份 skill/api.d.ts,控 context)
|
||
|
||
/** 历史压缩:折叠旧的大 tool 结果 / write 内容,控 token(主流 context 工程:循环越长越要压)。占位 <400 字符 → 幂等。 */
|
||
function compactHistory(messages, keepLast = 8) {
|
||
const head = 2; // system + 首个 user-brief 保全
|
||
const tailStart = Math.max(head, messages.length - keepLast);
|
||
for (let i = head; i < tailStart; i++) {
|
||
const m = messages[i];
|
||
if (!m) continue;
|
||
if (m.role === 'tool' && typeof m.content === 'string' && m.content.length > 400) {
|
||
m.content = '[旧工具结果已折叠省 token;需要可重新 read_file]';
|
||
}
|
||
if (m.role === 'assistant' && Array.isArray(m.tool_calls)) {
|
||
for (const c of m.tool_calls) {
|
||
if (c.function && c.function.name === 'write_file' && c.function.arguments && c.function.arguments.length > 400) {
|
||
try {
|
||
const a = JSON.parse(c.function.arguments);
|
||
c.function.arguments = JSON.stringify({ path: a.path, content: '[旧 write 内容已折叠;文件在磁盘,需要可重新 read_file]' });
|
||
} catch { /* 坏 JSON 略过 */ }
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
async function main() {
|
||
const mode = arg('mode', 'spike'); // 'spike'(默认,_template/__gameBooted 信封)| 'saa'(__GameBundle 信封 + stage 到 _wg1-gen)
|
||
const isSaa = mode === 'saa';
|
||
const id = isSaa ? arg('game-id', arg('id', 'run1')) : arg('id', 'run1');
|
||
const brief = arg('brief', '一个简单的点击得分小游戏');
|
||
const maxSteps = Number(arg('max-steps', '70')); // 默认 70:raw brief 7 步(3层架构 newarch1),但 SAA design 富化 brief(juice/音效/屏震/gatespec)需更多步;留余量
|
||
const maxWallMs = Number(arg('max-wall-ms', '1200000')); // 单次 attempt 墙钟上限 20min:design 富化 brief + M3 大上下文 turn 慢,raw 102s 但富化需更久;bound thrash 而不误杀富化正常 run
|
||
const port = Number(arg('port', '4320'));
|
||
const cdpPort = Number(arg('cdp', '9222'));
|
||
// 模式分派:SAA 用 __GameBundle 信封 + buildSaa + stage + smoke;spike 用 _template 信封 + build + play。
|
||
const doScaffold = isSaa ? () => scaffoldSaa(id) : () => scaffold(id);
|
||
const doBuild = isSaa ? () => buildSaa(id) : () => build(id);
|
||
|
||
const log = [];
|
||
const t0 = Date.now();
|
||
const rec = (m) => {
|
||
const line = `[+${((Date.now() - t0) / 1000).toFixed(1)}s] ${m}`;
|
||
console.log(line);
|
||
log.push(line);
|
||
};
|
||
|
||
rec(`model=${MODEL} mode=${mode} id=${id} brief=「${brief}」maxSteps=${maxSteps}`);
|
||
rec(`scaffold amgen-${id}(clone _template${isSaa ? ' + SAA 信封' : ''})`);
|
||
doScaffold();
|
||
|
||
const messages = [
|
||
{ role: 'system', content: buildSystemPrompt(id) },
|
||
{ role: 'user', content: `请按这个 brief 造一款游戏:「${brief}」。先读手册再动手。` },
|
||
];
|
||
|
||
let lastCheck = null;
|
||
let lastBuild = null;
|
||
let doneSummary = null;
|
||
let finished = false;
|
||
let nudgedDone = false;
|
||
let lastSmoke = null;
|
||
let steps = 0;
|
||
let totalTokens = 0;
|
||
const callSig = new Map(); // loop-detection:同一 (name,args) 计数
|
||
|
||
while (!finished && steps < maxSteps) {
|
||
if (Date.now() - t0 > maxWallMs) { rec(`wall-time cap ${Math.round(maxWallMs / 1000)}s 超时,截停(防 thrash;失败交 repair fresh 重试)`); break; }
|
||
steps++;
|
||
compactHistory(messages); // 每轮先压旧大块,控 token
|
||
let resp;
|
||
try {
|
||
resp = await chat(messages, TOOLS);
|
||
} catch (e) {
|
||
rec(`step${steps} M3 调用失败: ${e.message}`);
|
||
break;
|
||
}
|
||
totalTokens += (resp.usage && resp.usage.total_tokens) || 0;
|
||
const msg = resp.message;
|
||
messages.push(msg);
|
||
const calls = msg.tool_calls || [];
|
||
|
||
if (calls.length === 0) {
|
||
rec(`step${steps}: 无 tool_calls(finish=${resp.finishReason}; content="${(msg.content || '').slice(0, 80)}")`);
|
||
messages.push({ role: 'user', content: '请用工具继续(read_file/write_file/check/build);全绿后调 done。' });
|
||
continue;
|
||
}
|
||
|
||
for (const c of calls) {
|
||
const name = c.function.name;
|
||
let a = {};
|
||
try { a = JSON.parse(c.function.arguments || '{}'); } catch { /* 容错:坏 JSON 当空参 */ }
|
||
const sig = name + ':' + (c.function.arguments || '');
|
||
callSig.set(sig, (callSig.get(sig) || 0) + 1);
|
||
|
||
let result;
|
||
if (name === 'read_file') {
|
||
const r = readFileTool(a.path);
|
||
result = r.ok ? (r.truncated ? '[内容已截断]\n' : '') + r.content : 'ERROR: ' + r.error;
|
||
rec(`read ${a.path} ${r.ok ? 'ok' : 'ERR ' + r.error}`);
|
||
} else if (name === 'list_dir') {
|
||
const r = listDirTool(a.path);
|
||
result = r.ok ? r.entries.join('\n') : 'ERROR: ' + r.error;
|
||
rec(`list ${a.path} ${r.ok ? 'ok' : 'ERR'}`);
|
||
} else if (name === 'write_file') {
|
||
const r = writeFileTool(id, a.path, a.content || '');
|
||
result = r.ok ? `OK ${r.bytes}B 已写` : 'ERROR: ' + r.error;
|
||
rec(`write ${a.path} ${r.ok ? r.bytes + 'B' : 'ERR ' + r.error}`);
|
||
} else if (name === 'check') {
|
||
lastCheck = check(id);
|
||
result = lastCheck.ok ? 'PASS' : 'FAIL:\n' + lastCheck.errors.join('\n');
|
||
rec(`check ${lastCheck.ok ? 'PASS' : 'FAIL(' + lastCheck.errors.length + ')'}`);
|
||
} else if (name === 'build') {
|
||
lastBuild = doBuild();
|
||
result = lastBuild.ok ? 'PASS' : 'FAIL:\n' + lastBuild.error;
|
||
rec(`build ${lastBuild.ok ? 'PASS' : 'FAIL'}`);
|
||
} else if (name === 'done') {
|
||
// done 门:复核 check+build 必须都绿;SAA 模式再加 stage + 同进程 smoke-boot(治 review HIGH-1:boot 失败挡在 generate 内,不漏到 SAA play)。
|
||
const c2 = check(id);
|
||
const b2 = c2.ok ? doBuild() : { ok: false, error: 'check 未过,先修 check' };
|
||
lastCheck = c2; if (c2.ok) lastBuild = b2;
|
||
let gate = c2.ok && b2.ok;
|
||
let gateMsg = `check=${c2.ok ? 'PASS' : 'FAIL: ' + c2.errors.join('; ')} build=${b2.ok ? 'PASS' : 'FAIL: ' + (b2.error || '')}`;
|
||
if (gate && isSaa) {
|
||
const st = stage(id);
|
||
if (!st.ok) { gate = false; gateMsg += ` stage=FAIL: ${st.error}`; }
|
||
else {
|
||
rec('done 门:stage OK → smoke-boot…');
|
||
lastSmoke = await smokeBoot(id, { port, cdpPort });
|
||
if (!lastSmoke.ok) { gate = false; gateMsg += ` smoke=FAIL(booted=${lastSmoke.booted} framesAdvanced=${lastSmoke.framesAdvanced} err=${lastSmoke.bootErr || '-'})`; }
|
||
else { gateMsg += ' stage=OK smoke=PASS'; }
|
||
}
|
||
}
|
||
if (gate) {
|
||
doneSummary = a.summary || '';
|
||
result = 'DONE 已接受' + (isSaa ? '(已 stage + smoke-boot 绿)' : '');
|
||
rec(`done 接受: ${doneSummary}`);
|
||
messages.push({ role: 'tool', tool_call_id: c.id, content: result });
|
||
finished = true;
|
||
break;
|
||
}
|
||
result = `DONE 被拒:${isSaa ? 'done 前 check+build+stage+smoke 必须都绿' : 'done 前 check+build 必须都绿'}。${gateMsg}`;
|
||
rec('done 被拒(' + gateMsg + ')');
|
||
} else {
|
||
result = 'ERROR: 未知工具 ' + name;
|
||
}
|
||
messages.push({ role: 'tool', tool_call_id: c.id, content: String(result).slice(0, TOOL_RESULT_CAP) });
|
||
}
|
||
|
||
// check+build 都绿 → 一次性强提醒 done(治"绿了还乱写测试/额外文件")
|
||
if (!finished && !nudgedDone && lastCheck && lastCheck.ok && lastBuild && lastBuild.ok) {
|
||
messages.push({ role: 'user', content: 'check 与 build 都已 PASS。若已实现 brief 的核心玩法,请立即调 done(summary 一句话);不要再写测试或额外文件。' });
|
||
nudgedDone = true;
|
||
rec('已提示 done(check+build 绿)');
|
||
}
|
||
|
||
// loop-detection:同一调用重复过多 → 截停(防卡死烧 token)
|
||
if ([...callSig.values()].some((n) => n > 6)) {
|
||
rec('loop-detection:同一工具调用重复过多,截停');
|
||
break;
|
||
}
|
||
}
|
||
|
||
// 收口:SAA 模式已在 done 门 stage+smoke(SAA play 九门是 batch B 的 Java 节点职责,本 harness 不跑);spike 模式跑循环外真玩门。
|
||
let verdict = null;
|
||
if (finished && !isSaa) {
|
||
rec('play(循环外·权威门:boot+截图+交互 = 能跑能玩)...');
|
||
try {
|
||
verdict = await play(id, { port, cdpPort });
|
||
rec(`play ${verdict.pass ? 'PASS(能跑)' : 'FAIL'} — ${verdict.notes.join(' | ')}`);
|
||
} catch (e) {
|
||
rec('play 异常: ' + (e.message || e));
|
||
}
|
||
} else if (finished && isSaa) {
|
||
rec(`SAA 模式收口:已 stage → games/_wg1-gen/${id}/ + smoke-boot 绿;SAA play 九门交 batch B Java 节点。`);
|
||
} else {
|
||
rec('未合格 done,跳过收口(本 run 未产出合格游戏)');
|
||
}
|
||
|
||
const summary = {
|
||
id, mode, brief, model: MODEL, steps, finished, doneSummary,
|
||
check: lastCheck ? { ok: lastCheck.ok, errors: lastCheck.errors } : null,
|
||
build: lastBuild ? { ok: lastBuild.ok } : null,
|
||
smoke: lastSmoke ? { ok: lastSmoke.ok, booted: lastSmoke.booted, framesAdvanced: lastSmoke.framesAdvanced, bootErr: lastSmoke.bootErr } : null,
|
||
verdict: verdict ? { pass: verdict.pass, notes: verdict.notes, error: verdict.error || null } : null,
|
||
stagedDir: isSaa && finished ? `games/_wg1-gen/${id}` : null,
|
||
approxTokens: totalTokens,
|
||
wallSec: Number(((Date.now() - t0) / 1000).toFixed(1)),
|
||
};
|
||
const evDir = join(gameDir(id), 'evidence');
|
||
mkdirSync(evDir, { recursive: true });
|
||
writeFileSync(join(evDir, 'run-log.txt'), log.join('\n'));
|
||
writeFileSync(join(evDir, 'run-summary.json'), JSON.stringify(summary, null, 2));
|
||
console.log('\n=== RUN SUMMARY ===');
|
||
console.log(JSON.stringify(summary, null, 2));
|
||
// SAA↔harness CLI 契约(§5):stdout 末行单行 JSON,SAA generate 节点专用 runner 解析。
|
||
if (isSaa) {
|
||
console.log(JSON.stringify({ ok: !!finished, gameId: id, stagedDir: finished ? `games/_wg1-gen/${id}` : null, fail: finished ? null : 'agent 未合格 done' }));
|
||
}
|
||
}
|
||
|
||
main().catch((e) => { console.error('gen.mjs 顶层异常:', e); process.exit(1); });
|