From 9fe50a4fee850d5cb3ea43de7c4ff34bf902406a Mon Sep 17 00:00:00 2001 From: lili Date: Thu, 25 Jun 2026 23:15:42 -0700 Subject: [PATCH] =?UTF-8?q?fix(amodel-gen):=20=E6=B2=BB=E4=BE=BF=E5=AE=9C?= =?UTF-8?q?=E6=A1=A3=E7=94=9F=E6=88=90=E5=B9=BB=E8=A7=89=20+=20=E8=A1=A5?= =?UTF-8?q?=20play-spec=20=E9=93=BE=E8=B7=AF(=E4=B9=9D=E9=97=A8=E5=9F=BA?= =?UTF-8?q?=E7=BA=BF=E5=89=A9=E4=B8=A4=E5=B1=82=E6=A0=B9=E5=9B=A0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 便宜档九门基线诊断后,治创始人选的剩两层: - 层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) --- .agents/skills/littlejs-game-dev.md | 2 + game-runtime/tools/amodel-gen/gen.mjs | 11 ++++- game-runtime/tools/amodel-gen/play.mjs | 6 +++ game-runtime/tools/amodel-gen/prompt.mjs | 2 + game-runtime/tools/amodel-gen/tools.mjs | 62 ++++++++++++++++++++++++ 5 files changed, 81 insertions(+), 2 deletions(-) diff --git a/.agents/skills/littlejs-game-dev.md b/.agents/skills/littlejs-game-dev.md index e8f13da6..990fd851 100644 --- a/.agents/skills/littlejs-game-dev.md +++ b/.agents/skills/littlejs-game-dev.md @@ -133,6 +133,8 @@ export function createGame({ plugins, bundle, viewport }) { > **铁律:插件方法名一律以 `src/plugins//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//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(), ... })` 写。 diff --git a/game-runtime/tools/amodel-gen/gen.mjs b/game-runtime/tools/amodel-gen/gen.mjs index f4877fe5..fcb3a781 100644 --- a/game-runtime/tools/amodel-gen/gen.mjs +++ b/game-runtime/tools/amodel-gen/gen.mjs @@ -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) { diff --git a/game-runtime/tools/amodel-gen/play.mjs b/game-runtime/tools/amodel-gen/play.mjs index b9785bc8..42103738 100644 --- a/game-runtime/tools/amodel-gen/play.mjs +++ b/game-runtime/tools/amodel-gen/play.mjs @@ -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 失败时日志最有诊断价值)。让"读日志诊断运行时问题"成立。 diff --git a/game-runtime/tools/amodel-gen/prompt.mjs b/game-runtime/tools/amodel-gen/prompt.mjs index a9f4c5d9..81de0bea 100644 --- a/game-runtime/tools/amodel-gen/prompt.mjs +++ b/game-runtime/tools/amodel-gen/prompt.mjs @@ -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 翻车点)。 diff --git a/game-runtime/tools/amodel-gen/tools.mjs b/game-runtime/tools/amodel-gen/tools.mjs index f196a976..e80b7840 100644 --- a/game-runtime/tools/amodel-gen/tools.mjs +++ b/game-runtime/tools/amodel-gen/tools.mjs @@ -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//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//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 | check | build if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {