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:
lili 2026-06-25 23:15:42 -07:00
parent 8b491d1330
commit 9fe50a4fee
5 changed files with 81 additions and 2 deletions

View File

@ -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(), ... })` 写。

View File

@ -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) {

View File

@ -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 失败时日志最有诊断价值)。让"读日志诊断运行时问题"成立。

View File

@ -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 快照=永远 menutargets 永远空 自动验收看不到顾客永判死菜单;实测 wanglanmei 翻车点)

View File

@ -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 driverdriven=falseE/H advisory绿")
* driver 推断( play.cdp.cjs driver 家族):
* · state targets 数组(点击类:打地鼠/经营点客/点离散目标) tap-targets + targetMode:'occupied'(只点 occupied===true 的目标, phaseplay 时自动 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)) {