lili 34923812c3 fix(gen): 模板起局鲁棒性 menuStartBtn 解耦 render + targets 键/起局约定进 prompt v1.6.1 + gen.mjs 入口守卫 (W-S1 修复单②)
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>
2026-07-03 14:17:53 -07:00

337 lines
17 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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); });
}