T1 六款模板(_template + feiyi/puzzle/shop/story/trpg)起局/重开按钮命中矩形: 从 render 副作用回写(menuStartBtn = renderMenu(...).startBtn)改为抽纯几何 menuStartRect/overRestartRect + 在场景进入点 onEnter 确定性赋值、render 只画; 治并发 rAF 饥饿下 render 未跑过=命中矩形恒 null=卡 menu(归因案4)。 no-render 探针坐实:修复前六款均卡 menu、修复后均按坐标起局进 play。 T2 cheap-system.md 升 v1.6.1,红线段增两条硬约束:①点 target 玩法 state 键名 固定为 targets(不得改成 items 等,C5 驱动器族选择依赖)②起局命中矩形须在状态 进入点建立、不依赖渲染帧;各模板 _forensicsView 加同义契约注释。cheap_roles.py _SYSTEM_PROMPT 与 md 正文逐字节 parity 同步,registry.yaml 版本 bump 对齐。 T3 gen.mjs 顶层裸调 main() 加入口守卫(import.meta.url === pathToFileURL(argv[1])), 被 import 时不再触发真生成(footgun,曾 ~116s)、直接执行行为不变。 T4 puzzle-game-design.md §9 死链经核实在本 commit 不存在(唯一 play-spec 引用已 正确指向存在的 puzzle.play-spec.json),未伪造改动;littlejs-game-dev.md 补一条 起局 onEnter 指导闭合 doc-code。 验证:test_roles 12/12 + check_registry 17 对齐 + md↔_SYSTEM_PROMPT parity True + 6 模板 esbuild build 全绿 + amodel-gen tests 15+11 + 模板 node --test 除 _template-shop/headless-boot(既存红,与基线同)外全绿。 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
337 lines
17 KiB
JavaScript
337 lines
17 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, readFileSync, mkdirSync, cpSync, existsSync } from 'node:fs';
|
||
import { join } from 'node:path';
|
||
import { pathToFileURL } from 'node:url'; // 入口守卫用:判定本模块是否被「直接执行」(见文件末尾)
|
||
import { scaffold, scaffoldSaa, readFileTool, writeFileTool, listDirTool, check, build, buildSaa, stage, gameDir, ensurePlaySpec } from './tools.mjs';
|
||
import { chat, MODEL } from './m3.mjs';
|
||
import { TOOLS, buildSystemPrompt, DESIGN_TOOLS, buildDesignSystemPrompt } 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;
|
||
}
|
||
|
||
/** brief 取值:优先 --brief-file(读文件,容大段富化设计稿,避 argv 长度/引号坑;U3 Java 也用)→ 否则 --brief。 */
|
||
function briefArg(def) {
|
||
const f = arg('brief-file');
|
||
if (f) { try { return readFileSync(f, 'utf8'); } catch (e) { console.error('--brief-file 读失败,回退 --brief: ' + e.message); } }
|
||
return arg('brief', 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 略过 */ }
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/** 容错归一 gatespec(M3 偶把 assertAfterPlay 包成 {item:[…]} / expectLatch 写成字符串)→ 拍平成 {expectLatch:bool, assertAfterPlay:[]}。 */
|
||
function normGatespec(g) {
|
||
if (!g || typeof g !== 'object') return null;
|
||
let aap = g.assertAfterPlay;
|
||
if (aap && !Array.isArray(aap) && Array.isArray(aap.item)) aap = aap.item; // 拆 {item:[…]} 这层
|
||
if (!Array.isArray(aap)) aap = aap ? [aap] : [];
|
||
let latch = g.expectLatch;
|
||
if (typeof latch === 'string') latch = latch.trim().toLowerCase() === 'true';
|
||
return { expectLatch: !!latch, assertAfterPlay: aap };
|
||
}
|
||
|
||
/** design 模式:tool-using 策划 agent(read_file/list_dir/done),产玩家视角设计稿+gatespec,不写文件/不 build。
|
||
* 日志走 stderr;stdout **末行**单行 JSON {ok,designText,fail}(designNode shell-out 据此解析,同 saa 契约)。 */
|
||
async function runDesign({ brief, maxSteps, maxWallMs }) {
|
||
const t0 = Date.now();
|
||
const rec = (m) => console.error(`[+${((Date.now() - t0) / 1000).toFixed(1)}s] ${m}`); // ★ design 日志走 stderr,保 stdout 末行=JSON 契约
|
||
rec(`design model=${MODEL} brief=「${brief}」maxSteps=${maxSteps}`);
|
||
const messages = [
|
||
{ role: 'system', content: buildDesignSystemPrompt() },
|
||
{ role: 'user', content: `请按这个 brief 产一份玩家视角玩法设计稿(含 gatespec):「${brief}」。先 read_file 读 sim skill 再设计。` },
|
||
];
|
||
let designText = null, gatespec = null, steps = 0;
|
||
while (!designText && steps < maxSteps) {
|
||
if (Date.now() - t0 > maxWallMs) { rec('design wall-time cap 超时,截停'); break; }
|
||
steps++;
|
||
let resp;
|
||
try { resp = await chat(messages, DESIGN_TOOLS); } catch (e) { rec(`step${steps} M3 调用失败: ${e.message}`); break; }
|
||
const msg = resp.message; messages.push(msg);
|
||
const calls = msg.tool_calls || [];
|
||
if (calls.length === 0) {
|
||
rec(`step${steps}: 无 tool_calls(content="${(msg.content || '').slice(0, 80)}")`);
|
||
messages.push({ role: 'user', content: '请用 read_file/list_dir 读手册后,产出设计稿+gatespec 并调 done。' });
|
||
continue;
|
||
}
|
||
for (const c of calls) {
|
||
const name = c.function.name;
|
||
let a = {}; try { a = JSON.parse(c.function.arguments || '{}'); } catch { /* 坏 JSON 当空参 */ }
|
||
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 === 'done') {
|
||
designText = (a.designText || a.design || '').trim();
|
||
gatespec = normGatespec(a.gatespec || null);
|
||
result = `DONE 收到设计稿 ${designText.length}B + gatespec ${gatespec ? 'OK' : '缺'}`;
|
||
rec(result);
|
||
messages.push({ role: 'tool', tool_call_id: c.id, content: result });
|
||
break;
|
||
} else {
|
||
result = 'ERROR: 未知工具 ' + name;
|
||
}
|
||
messages.push({ role: 'tool', tool_call_id: c.id, content: String(result).slice(0, TOOL_RESULT_CAP) });
|
||
}
|
||
}
|
||
const ok = !!(designText && designText.length > 80);
|
||
// stdout 末行单行 JSON(designNode shell-out 解析;日志全在 stderr,不污染)。
|
||
console.log(JSON.stringify({ ok, designText: ok ? designText : null, gatespec: ok ? gatespec : null, fail: ok ? null : 'design agent 未产出设计稿' }));
|
||
return ok;
|
||
}
|
||
|
||
async function main() {
|
||
const mode = arg('mode', 'spike'); // 'spike'(默认)| 'saa'(__GameBundle 信封)| 'design'(策划 agent,只产设计稿+gatespec)
|
||
// design 模式:tool-using 策划 agent,只产玩家视角设计稿+gatespec(供 designNode shell-out 喂 code agent),不 scaffold/build/play。
|
||
if (mode === 'design') {
|
||
const ok = await runDesign({
|
||
brief: briefArg('一个简单的经营小游戏'),
|
||
maxSteps: Number(arg('max-steps', '25')),
|
||
maxWallMs: Number(arg('max-wall-ms', '600000')),
|
||
});
|
||
process.exit(ok ? 0 : 1);
|
||
}
|
||
const isSaa = mode === 'saa';
|
||
const id = isSaa ? arg('game-id', arg('id', 'run1')) : arg('id', 'run1');
|
||
const brief = briefArg('一个简单的点击得分小游戏');
|
||
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();
|
||
|
||
// 资产提供(「不让 M3 造资产、只让它决定怎么用」):--assets-dir 给定则把整目录(manifest+图+音)拷进 amgen/assets/,覆盖 _template 空清单。
|
||
// stage 会再把 assets/ 拷到 staged gameId 目录 → boot-game-host.loadHostAssets 据 manifest 载图注入 boot.assets,游戏经 boot.assets[ref].image 取图。
|
||
const assetsDir = arg('assets-dir', '');
|
||
if (assetsDir) {
|
||
if (existsSync(assetsDir)) {
|
||
cpSync(assetsDir, join(gameDir(id), 'assets'), { recursive: true });
|
||
rec(`assets 提供:${assetsDir} → amgen-${id}/assets/(M3 只决定怎么用,不造)`);
|
||
} else {
|
||
rec(`⚠ --assets-dir 不存在:${assetsDir}(跳过,游戏走程序化回退)`);
|
||
}
|
||
}
|
||
|
||
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';
|
||
// 链路真绿(终结假绿):据 smoke 抓到的 _forensicsView().state() 形态自动产 play-spec(缺失才补),
|
||
// 让下游九门 driven=true → E_live/H_progress 由 advisory 升为致命(纯 harness 裸跑不再假绿)。
|
||
const ps = ensurePlaySpec(id, lastSmoke.state || null);
|
||
if (ps.wrote) rec(`done 门:自动产 play-spec(driver=${ps.driverType};state.targets=${Array.isArray(lastSmoke.state && lastSmoke.state.targets) ? '有→tap-targets' : '无→key-cycle'})`);
|
||
else rec(`done 门:play-spec ${ps.ok ? '已存在不覆盖' : '产失败:' + ps.reason}`);
|
||
}
|
||
}
|
||
}
|
||
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' }));
|
||
}
|
||
}
|
||
|
||
// ── 入口守卫(footgun 兜底)────────────────────────────────────────────────
|
||
// 缘由:gen.mjs 曾因顶层裸调 main() 被「import 一次」就跑真生成(~116s、烧钱烧时)。
|
||
// 仅当作为脚本「直接执行」(node …/gen.mjs …)时跑 main;被 import(测试/工具引用)时不触发。
|
||
// idiom 同 scripts/build.mjs:比对 import.meta.url 与 process.argv[1] 的规范 file:// URL。
|
||
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||
main().catch((e) => { console.error('gen.mjs 顶层异常:', e); process.exit(1); });
|
||
}
|