fix(amodel-gen): 治便宜档生成幻觉 + 补 play-spec 链路(九门基线剩两层根因)
便宜档九门基线诊断后,治创始人选的剩两层:
- 层1 生成幻觉:M3 对插件参数形状/返回值幻觉(drawButton 返 void 误赋值、
sceneFsm.define 单对象批量),check 门只查方法名存在 → 带病过 check+build+smoke。修:
· prompt.mjs + littlejs-game-dev.md 补两条 footgun 正反例(drawButton 返 void、
命中用 pointInRect;define 逐场景调)
· tools.mjs check 加形状静态门(拦 drawButton 返回值赋值 + define 单对象批量)
- 层2 链路假绿:stage 不产 play-spec → 裸跑 driven=false → E/H 降 advisory → 假绿。修:
· tools.mjs ensurePlaySpec 据 _forensicsView 形态自动推 driver(有 targets→tap-targets/否则 key-cycle)
· play.mjs smokeBoot 抓 _forensicsView().state();gen.mjs done 门 smoke 绿后调用
验证(vf-* 干净重跑):
- check 形状门对 base1/4/5 好游戏零误伤、对 base2/3 幻觉精准命中
- vf-mole 打地鼠(base2 同类):卡menu/E·G·H挂 → 九门全过、进 play(drawButton 幻觉治好)
- vf-2048 合成(base3 同类):define 已写对(幻觉治好);仍卡 menu = 新发现 key-cycle driver
缺起局点击(menu 用 tap、play 用 key)= 第三层 driver bug、非本两层,下一步小修
- 两款 playspec 自动产、driven=true(假绿终结)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
8b491d1330
commit
9fe50a4fee
@ -133,6 +133,8 @@ export function createGame({ plugins, bundle, viewport }) {
|
||||
> **铁律:插件方法名一律以 `src/plugins/<name>/api.d.ts` 为准,别按"常见库套路"臆测。** 以下是 M3 即便读了 api.d.ts 仍反复臆测出的错名(HJ-AGI-003 saae 实测),逐条记牢:
|
||||
|
||||
- ❌ `sceneFsm.defineScenes({...})` → ✅ `sceneFsm.define(name, {onEnter,onExit,update,render})` **逐个**定义(**没有** `defineScenes`)。
|
||||
- ❌ `sceneFsm.define({ menu:{...}, play:{...}, over:{...} })` 单对象批量注册 → ✅ **每个场景各调一次** `sceneFsm.define('menu', {...}); sceneFsm.define('play', {...}); sceneFsm.define('over', {...})`(`define(name, handlers)` 第一参恒为场景名**字符串**;传对象 → 第一参变成 `[object Object]`、menu/play/over 全没注册 → `transition('play')` 找不到目标态被忽略 → 卡菜单进不去 play。实测合成/2048 类翻车点)。可链式 `.define(...).define(...)`。
|
||||
- ❌ `const btn = hudUi.drawButton(g, rect, {label}); if (btn && hudUi.pointInRect(x,y,btn)) {...}` → ✅ **`drawButton` 返 `void`(只绘制、返 undefined),绝不接其返回值判命中**:先自己定义按钮矩形 `const btn = {x,y,w,h};` 单独调 `hudUi.drawButton(g, btn, {label})` 绘制,命中用 `hudUi.pointInRect(x, y, btn)`(或 `collision.pointInRect(x, y, btn)`;两者签名都是 `(px, py, rect)`)。接返回值 → `btn===undefined` → 命中永远短路 → 按钮点不动、卡菜单(实测打地鼠类:`menuStartBtn = drawButton(...)` → undefined → 菜单点击短路 → 进不去 play)。另:**`drawButton` 第二参是 rect 对象 `{x,y,w,h}`、不是 label/坐标位置参**——别写 `drawButton(g, '开始', x, y, w, h)`。
|
||||
- ❌ scene 的 update 回调臆造首参写成 `update(running, dt)` → ✅ **scene 的 `update` 回调只收一个参数 `(dt)`**(`sceneFsm.define(name, { update(dt){…} })`;`sceneFsm.update(dt)` 只透传 dt,没有 running 之类首参)。写错首参 → 真正的 dt 落到第二参=undefined → `state.elapsedMs += undefined = NaN` → 不再 spawn/计时、计时显示「NaNs」、游戏空转看着像没 bug 实则永不推进(实测 wanglanmei-v2:菜单能进 play 但无顾客、营业额恒 0)。**任何回调签名都以 `src/plugins/<name>/api.d.ts` 为准,别臆造参数。**
|
||||
- ❌ `update(dt)` 把 dt 当毫秒直接累加 `elapsedMs += dt` → ✅ **dt 单位是「秒」(≈1/60)**;游戏内毫秒计时(倒计时/spawn 间隔/耐心,常量如 60000/1300/4800)累加要 **`elapsedMs += dt * 1000`**。漏 ×1000 → 计时慢 1000 倍 → 永不到 spawn 阈值=零顾客、一局永不结束(实测 wanglanmei-v2 翻车点;`_template` 范例里 `remainMs - dt*1000` 就是这个换算)。
|
||||
- ❌ `_forensicsView()` 在**外层**先算好 phase/targets 再闭包返回(`const phase=...; return { state(){ return { phase } } }`)→ ✅ **phase/targets/score 必须在 `state()` 函数体内部实时计算**(host 只在 boot 调一次 `_forensicsView()`,外层算的值定格成 boot 快照=永远 menu、targets 永远空 → 自动验收/驱动看不到目标、永判死菜单。实测 wanglanmei-v2:游戏真在 play 但门读到的 phase 恒 menu)。照 `_template/src/game-logic.js` 的 `state: () => ({ phase: sceneFsm.current(), ... })` 写。
|
||||
|
||||
@ -16,7 +16,7 @@
|
||||
|
||||
import { writeFileSync, readFileSync, mkdirSync, cpSync, existsSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { scaffold, scaffoldSaa, readFileTool, writeFileTool, listDirTool, check, build, buildSaa, stage, gameDir } from './tools.mjs';
|
||||
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';
|
||||
@ -248,7 +248,14 @@ async function main() {
|
||||
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'; }
|
||||
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) {
|
||||
|
||||
@ -207,6 +207,12 @@ export async function smokeBoot(gameId, opts = {}) {
|
||||
await sleep(1000);
|
||||
out.f1 = await cdp.evalJs('window.__genHost ? window.__genHost.frameCount() : -1');
|
||||
out.framesAdvanced = typeof out.f1 === 'number' && out.f1 > out.f0;
|
||||
// 抓游戏 _forensicsView().state() 快照(host.state() 透传;index.template 已挂 __genHost.state / __gameState 别名)。
|
||||
// 用途:done 门据此推断 driver 形态(有 targets 数组=点击类→tap-targets;否则按键类→key-cycle)自动产 play-spec。读不到返 null,不影响 smoke 判定。
|
||||
out.state = await cdp.evalJs(
|
||||
'(window.__genHost && typeof window.__genHost.state === "function") ? window.__genHost.state() : ' +
|
||||
'((typeof window.__gameState === "function") ? window.__gameState() : null)',
|
||||
).catch(() => null);
|
||||
}
|
||||
// 抓运行时日志(window.__gameLog:ctx.log 游戏语义 + 插件自带日志)→ 写 staged evidence + 末段进 out。
|
||||
// 无论 boot 成败都抓(boot 失败时日志最有诊断价值)。让"读日志诊断运行时问题"成立。
|
||||
|
||||
@ -88,6 +88,8 @@ A 读手册(1)+ 读你的起点 game-logic.js(4)→ B 按 brief 定玩法(核心
|
||||
- **插件扁平注入:直接 const { sceneFsm, sessionScore, hudUi, timerScheduler } = plugins(用到才解构)**;**绝不写 opts.runtime.plugins**(那是 L1 内部的,你见不到 → 写了就 undefined → boot 崩 reading 'define')。游戏只用插件、不 new、不 register。
|
||||
- **可用插件键(全 11 已注入,用到才取)**:sceneFsm / sessionScore / hudUi / timerScheduler / save / gamefeel / juice / palettePost / audioMusic / collision / physics。
|
||||
- **插件方法名一律以 api.d.ts 为准、别臆测**(M3 实测幻觉:scene-fsm 写 defineScenes〔应 define〕;particles-juice 没有 burst〔事件 API 名以其 api.d.ts 为准〕)。用到某插件先 read_file 它的 api.d.ts;skill「⚠️ M3 实测易犯的幻觉 API」逐条列 ❌→✅。
|
||||
- **hudUi.drawButton(g, rect, opts) 返回 void(只绘制、返 undefined)——绝不把它的返回值赋给变量再判命中**:❌ btn = hudUi.drawButton(g, rect, {label}); if (btn && hudUi.pointInRect(x,y,btn)) ... → btn 永远 undefined → 命中判定永远短路 → 卡菜单/按钮点不动(实测打地鼠类翻车点:menuStartBtn = drawButton(...) → undefined → 菜单点击短路 → 进不去 play)。✅ 先**自己定义按钮矩形** const btn = { x, y, w, h };**单独**调 hudUi.drawButton(g, btn, {label}) 绘制(不接返回值);命中用 hudUi.pointInRect(x, y, btn)(或 collision.pointInRect(x, y, btn);两者签名都是 (px, py, rect))。**drawButton 第二参是 rect 对象 { x, y, w, h }、不是 label/坐标位置参**——别写 drawButton(g, '开始', x, y, w, h) 这种位置参形态。
|
||||
- **sceneFsm.define(name, handlers) 逐场景调一次——绝不单对象批量注册**:❌ sceneFsm.define({ menu: {...}, play: {...}, over: {...} }) → 第一参当成了场景名「[object Object]」、menu/play/over 全没真注册 → 后续 transition('play') 找不到目标态被忽略 → 卡菜单(实测合成/2048 类翻车点)。✅ **每个场景各调一次**:sceneFsm.define('menu', { onEnter(){...} }); sceneFsm.define('play', { onEnter(){...}, update(dt){...} }); sceneFsm.define('over', { onEnter(){...} });(define 第一参恒为场景名字符串、第二参才是回调表 { onEnter, onExit, update, render };可链式 .define(...).define(...))。
|
||||
- **画面读 viewport.w/h**(viewport 是入参;别硬编码 390×844)。
|
||||
- **美术资产(若 assets/manifest.json 非空)**:先 read_file('assets/manifest.json') 看有哪些资产;init(boot) 里 const assets = boot.assets || {};render 里 const img = assets['ref名']?.image; if (img) g.drawImage(img, x, y, w, h); else 程序化回退。**ref = manifest 条目 file 去扩展名**(shopkeeper-idle.jpg → 'shopkeeper-idle')。缺图/加载失败时 assets[ref].image 为 null(host 已容错)→ 务必走程序化回退、别崩。BGM/音效经 plugins.audioMusic(不在 boot.assets)。无 manifest/无美术 → 全程序化绘制即可。
|
||||
- **必须实现 _forensicsView()**(可测性红线 + 自动驱动契约):返回 { state(){…}, measures(){…} };state() 至少 { phase, score }。**若游戏靠点击推进(经营点客/打地鼠/点离散目标等):state().targets:[{x,y,occupied}] 列当前屏上目标,可点的(等待的客/冒头的鼠)置 occupied:true、不可点置 false** —— 自动验收据此点 occupied:true 的目标真驱动你的游戏。**score 用累计进展(营收/得分),随推进上升** —— 自动验收据 score 上升判"真有进展"。不写则验证读不到状态(check 会拦)。**钉死:phase/targets/score 必须在 state() 函数体内部实时读取/计算——绝不在 _forensicsView() 外层先算好再闭包返回**(host 只在 boot 调一次 _forensicsView,外层算的值会被定格成 boot 快照=永远 menu、targets 永远空 → 自动验收看不到顾客、永判死菜单;实测 wanglanmei 翻车点)。
|
||||
|
||||
@ -275,6 +275,24 @@ export function check(id) {
|
||||
}
|
||||
}
|
||||
|
||||
// 5) 形状静态门(治"方法名对但参数形状/返回值幻觉、带病过 check"两类实测翻车):
|
||||
// ① drawButton 返 void——把它的返回值赋给变量再判命中 = 命中永远短路 → 卡菜单(base2 打地鼠实证)。
|
||||
// ② sceneFsm.define(name,handlers) 逐场景调——单对象批量 define({menu,play}) → 场景没真注册 → transition 被忽略 → 卡菜单(base3 2048 实证)。
|
||||
// 只查 agent 写的游戏文件;stripCode 已去注释/字符串避免误伤文档示例。正则刻意收窄(已对 base1/4/5 好游戏验不误伤)。
|
||||
for (const f of ['game-logic.js', 'core.js', 'render.js']) {
|
||||
const abs = join(gd, 'src', f);
|
||||
if (!existsSync(abs)) continue;
|
||||
const s = stripCode(readFileSync(abs, 'utf8'));
|
||||
// ① 赋值使用 drawButton 返回值(返 void):匹配「= … drawButton(」(含 const/let x = drawButton、x = hudUi.drawButton)。
|
||||
if (/=\s*[\w.]*\bdrawButton\s*\(/.test(s)) {
|
||||
errors.push(`形状门 ${f}: drawButton(g,rect,opts) 返 void(只绘制、返 undefined)——别把它赋给变量再判命中(赋值后变量恒 undefined → 命中永远短路 → 卡菜单)。改:先定义按钮矩形 const btn={x,y,w,h};单独调 hudUi.drawButton(g,btn,{label}) 绘制;命中用 hudUi.pointInRect(x,y,btn) 或 collision.pointInRect(x,y,btn)`);
|
||||
}
|
||||
// ② define 单对象批量(第一参应是场景名字符串):匹配「.define( {」(name 写成对象)。
|
||||
if (/\.define\s*\(\s*\{/.test(s)) {
|
||||
errors.push(`形状门 ${f}: sceneFsm.define(name, handlers) 第一参须是场景名【字符串】、逐场景各调一次——别 define({menu:{},play:{}}) 单对象批量(第一参变 [object Object]、场景全没注册 → transition 被忽略 → 卡菜单)。改:sceneFsm.define('menu',{...}); sceneFsm.define('play',{...}); sceneFsm.define('over',{...})`);
|
||||
}
|
||||
}
|
||||
|
||||
return { ok: errors.length === 0, errors };
|
||||
}
|
||||
|
||||
@ -359,6 +377,50 @@ export function stage(gameId) {
|
||||
return { ok: true, dir: dst };
|
||||
}
|
||||
|
||||
/**
|
||||
* ensurePlaySpec —— 据游戏 _forensicsView().state() 形态自动产 _wg1-gen/<gameId>/play-spec.json(治"纯 harness 裸跑无 driver→driven=false→E/H 降 advisory→假绿")。
|
||||
* driver 推断(照 play.cdp.cjs 的 driver 家族):
|
||||
* · state 有 targets 数组(点击类:打地鼠/经营点客/点离散目标)→ tap-targets + targetMode:'occupied'(只点 occupied===true 的目标,且 phase≠play 时自动 start 起局);
|
||||
* · 否则(按键类,无 targets)→ key-cycle(循环按方向键)。
|
||||
* gatespec:assertAfterPlay 断 score 上升(真玩应加分)+ expectLatch(限时类 latch 现有 advisory 兜底、不误杀)。
|
||||
* **已存在 play-spec(如 SAA Java 路产的)则不覆盖**——只在缺失时补。
|
||||
* @param {string} gameId stage 后的 gameId(写 _wg1-gen/<gameId>/play-spec.json)
|
||||
* @param {object|null} state smokeBoot 抓到的 _forensicsView().state() 快照(null=读不到→保守按键类)
|
||||
* @returns {{ok:boolean, wrote:boolean, reason?:string, driverType?:string, path?:string}}
|
||||
*/
|
||||
export function ensurePlaySpec(gameId, state) {
|
||||
try {
|
||||
const dst = saaGameDir(gameId);
|
||||
const specPath = join(dst, 'play-spec.json');
|
||||
if (existsSync(specPath)) {
|
||||
return { ok: true, wrote: false, reason: '已存在 play-spec(SAA Java 路或上次产)→不覆盖', path: specPath };
|
||||
}
|
||||
// 形态推断:state.targets 是非空(或存在)数组 → 点击类;否则按键类。读不到 state 保守按键类(key-cycle 对绝大多数游戏无害)。
|
||||
const hasTargets = !!(state && Array.isArray(state.targets));
|
||||
let driver, exportState;
|
||||
if (hasTargets) {
|
||||
driver = { type: 'tap-targets', targetMode: 'occupied', targetsPath: 'targets', steps: 50, stepMs: 220 };
|
||||
exportState = ['phase', 'score', 'targets'];
|
||||
} else {
|
||||
driver = { type: 'key-cycle', keys: ['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown'], steps: 60, stepMs: 180, downMs: 60 };
|
||||
exportState = ['phase', 'score'];
|
||||
}
|
||||
const spec = {
|
||||
exportState,
|
||||
driver,
|
||||
assertAfterPlay: [{ path: 'score', op: 'increased', why: '真玩应加分' }],
|
||||
expectLatch: true, // latch 现有 advisory 兜底:限时/无快速失败态类玩不到终局但有真进展→降 advisory 不致命(play.cdp.cjs)
|
||||
};
|
||||
mkdirSync(dst, { recursive: true });
|
||||
const tmp = specPath + '.tmp';
|
||||
writeFileSync(tmp, JSON.stringify(spec, null, 2), 'utf8');
|
||||
renameSync(tmp, specPath); // 原子写,防 play 读半成品
|
||||
return { ok: true, wrote: true, driverType: driver.type, path: specPath };
|
||||
} catch (e) {
|
||||
return { ok: false, wrote: false, reason: String((e && e.message) || e) };
|
||||
}
|
||||
}
|
||||
|
||||
// ───────────────────────── CLI(独立验证用)─────────────────────────
|
||||
// 用法:node tools.mjs scaffold <id> | check <id> | build <id>
|
||||
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user