diff --git a/tier2/fixtures/mini-fei-e/src/scenes/play-runtime.js b/tier2/fixtures/mini-fei-e/src/scenes/play-runtime.js new file mode 100644 index 00000000..05bcd17e --- /dev/null +++ b/tier2/fixtures/mini-fei-e/src/scenes/play-runtime.js @@ -0,0 +1,108 @@ +/** + * src/scenes/play-runtime.js —— mini-肥鹅 表现层【平台锁定运行时插座】(平台预建 · 写死不动) + * owner:tier2 引擎线(M1 · scaffold platform 预建)| 这是 agent 改不动的锁定文件 + * + * ════════════════════════════════════════════════════════════════════════════ + * 【为什么有这份 · spike feie-002/003 实证根因 3】 + * 原先「输入路由 + 逐帧 core.update tick」混在 agent 可写的 play-scene.js 里,只靠注释「别动」约束。 + * 实测 M3 重写 play-scene 时把这两件平台命脉一起改没了: + * - 输入路由没了 / 坐标错位(用 (col,row) 调 core.tapCell 而非 (i,j),且 render 坐标与 layout 不同源) + * → driver 注入的 touch 落空 / 落错格 → 没有合成 → F 门 calls=0、G/H 门连环挂。 + * - 逐帧 core.update(dt) 没了 → 订单耐心不倒计时、赢/输 latch 永不触发 + * → E_live distinct=1/N、H_progress latch=false、富[economy]/富[latch] 全挂。 + * 故把这两件平台命脉抽进本锁定文件,play-scene 只需在 create/update 各调一次。agent 写不动它, + * 无论怎么画表现层,driver-tap 路径与终态推进都焊死可达。 + * + * 【输入与逻辑分离铁律】 + * 表现层只「命中测试 → core 逻辑命令」,绝不自改 state(状态在 game-core,探针只读 readState)。 + * 命中映射坐标系 = 逻辑视口像素,与 layout.js 单一源(也是 game-core 导出 state.board[].x/y 的源、 + * 也是 harness driver tap 的源)—— 三处同源,driver tap 必落对格。 + * ════════════════════════════════════════════════════════════════════════════ + */ + +'use strict'; + +// 布局常量单一源(命中映射;与 game-core readState 导出坐标、harness driver tap 同源)。 +import { + BOARD_X, BOARD_Y, CELL, BOARD_SIZE, + PANEL_X, PANEL_Y, PANEL_W, PANEL_ROW_H, +} from '../layout.js'; + +/** + * 命中测试:屏幕逻辑坐标 → 命中目标(棋盘格 (i,j) / 订单 instId)。 + * @param {object} core game-core(读 systems.order.ordersView 映射订单行) + * @param {number} x @param {number} y + * @returns {{kind:'cell', i:number, j:number} | {kind:'order', instId:string} | null} + */ +export function hitTest(core, x, y) { + // 棋盘格命中(与渲染/导出坐标同源;注意 i=行=y 方向、j=列=x 方向,别搞反)。 + const lx = x - BOARD_X; + const ly = y - BOARD_Y; + if (lx >= 0 && ly >= 0) { + const j = Math.floor(lx / CELL); // 列(x 方向) + const i = Math.floor(ly / CELL); // 行(y 方向) + if (i >= 0 && i < BOARD_SIZE && j >= 0 && j < BOARD_SIZE) { + return { kind: 'cell', i, j }; + } + } + // 订单面板命中(driver 据 game-core 导出的 submitTargets 第 k 行点这里 → 真交单)。 + if (x >= PANEL_X && x <= PANEL_X + PANEL_W && y >= PANEL_Y) { + const k = Math.floor((y - PANEL_Y) / PANEL_ROW_H); + const orders = core.systems.order.ordersView(); + if (k >= 0 && k < orders.length) { + return { kind: 'order', instId: orders[k].instId }; + } + } + return null; +} + +/** + * 归一化输入处理:命中后调 core 逻辑命令(tap 合成格两次合成 / tap 订单交单)。 + * host.injectInput 路径与 Phaser pointerdown 路径共用本实现。 + * @param {object} core + * @param {{type:'tap'|'key'|'drag', x?:number, y?:number, key?:string}} ev + */ +export function handleInput(core, ev) { + if (!core || !ev || ev.type !== 'tap') return; + const hit = hitTest(core, ev.x, ev.y); + if (!hit) return; + if (hit.kind === 'cell') { + // 命中棋盘格 → 合成逻辑命令((i,j) 顺序;逻辑在 core/merge-system)。 + core.tapCell(hit.i, hit.j); + } else if (hit.kind === 'order') { + // 命中订单 → 交单逻辑命令。 + core.fulfillOrder(hit.instId); + } +} + +/** + * 【平台锁定 · play-scene 在 create() 里调一次】绑定驱动可达的归一化输入路由。 + * 把 Phaser 指针事件 → handleInput → core 逻辑命令。坐标系 = 逻辑视口像素(layout 同源)。 + * harness driver 注入的真 touch 事件经此路径命中,**与表现层怎么画无关**。 + * @param {object} scene 活跃的 Phaser scene(取 scene.input) + * @param {object} core game-core + */ +export function bindInput(scene, core) { + try { + // 单一集中订阅:整屏指针 → 命中映射 → core 逻辑命令(driver-tap 路径焊死)。 + scene.input.on('pointerdown', (p) => { + handleInput(core, { type: 'tap', x: p.x, y: p.y }); + }); + } catch (e) { + // headless 无 input 时吞错(逻辑核心仍可经 host.injectInput 驱动)。 + if (typeof console !== 'undefined') console.warn('[play-runtime] bindInput 异常:' + (e && e.message)); + } +} + +/** + * 【平台锁定 · play-scene 在 update(_time, delta) 里调一次】逐帧推进 core 逻辑。 + * 把帧时间换算 dt 秒喂给 core.update —— core 内部推进订单耐心倒计时、判赢/输并 latch phase。 + * **这步丢了游戏就永不到终态**(E_live/H_progress/经济/latch 门全挂),故焊进锁定文件。 + * @param {object} core + * @param {number} delta 本帧毫秒(Phaser 给) + */ +export function tick(core, delta) { + if (!core) return; + const dt = (typeof delta === 'number' ? delta : 16.67) / 1000; + core.update(dt); +} diff --git a/tier2/fixtures/mini-fei-e/src/scenes/play-scene.js b/tier2/fixtures/mini-fei-e/src/scenes/play-scene.js index 19b21bde..45d15222 100644 --- a/tier2/fixtures/mini-fei-e/src/scenes/play-scene.js +++ b/tier2/fixtures/mini-fei-e/src/scenes/play-scene.js @@ -28,6 +28,9 @@ import { BOARD_X, BOARD_Y, CELL, PANEL_X, PANEL_Y, PANEL_W, PANEL_ROW_H, } from '../layout.js'; +// 平台锁定运行时插座(写死、agent 不许动):输入路由 + 逐帧 core.update tick。 +// create 调 bindInput(driver-tap 路径焊死)、update 调 tick(终态推进焊死);命中委托 hitTest/handleInput。 +import { bindInput, tick, hitTest as platformHitTest, handleInput as platformHandleInput } from './play-runtime.js'; /** * 创建主玩法 scene(返回 Phaser.Scene 配置对象;不顶层 import phaser,经 deps.Phaser 注入)。 @@ -54,54 +57,11 @@ export function createPlayScene(deps) { let _sceneRef = null; let _hudText = null; - /** - * 命中测试:屏幕坐标 → 命中目标(棋盘格 / 订单面板;平台预建)。坐标系 = 逻辑视口像素(与 layout 同源)。 - * 定义为【闭包函数】而非对象方法——Phaser 用「配置对象」实例化 scene 时,只把 create/update/preload - * 拷成生命周期回调,不会把对象上的其它方法搬到 scene 实例上,故 create 内 `this.hitTest` 会是 undefined。 - * 抽成闭包函数后,对象方法(host.injectInput 路径)与 Phaser pointerdown 路径都调它、不依赖 `this`。 - * @param {number} x @param {number} y - * @returns {{kind:'cell', i:number, j:number} | {kind:'order', instId:string} | null} - */ - function doHitTest(x, y) { - // 棋盘格命中(平台预建:与渲染坐标同源,确定性,driver 友好)。 - const lx = x - BOARD_X; - const ly = y - BOARD_Y; - if (lx >= 0 && ly >= 0) { - const j = Math.floor(lx / CELL); - const i = Math.floor(ly / CELL); - if (i >= 0 && i < 3 && j >= 0 && j < 3) { - return { kind: 'cell', i, j }; - } - } - // 订单面板命中(平台预建·driver 交单可达):第 k 行可点区映射到 ordersView()[k] 的 instId - // (与 game-core 导出的 submitTargets 第 k 行同源),driver 据 submitTargets 点这里 → core.fulfillOrder 真交单。 - // 注:LLM 的 TODO-2/TODO-5 渲染订单面板时须用同一 layout(PANEL_*),才与本命中区 + driver tap 对齐。 - if (x >= PANEL_X && x <= PANEL_X + PANEL_W && y >= PANEL_Y) { - const k = Math.floor((y - PANEL_Y) / PANEL_ROW_H); - const orders = core.systems.order.ordersView(); - if (k >= 0 && k < orders.length) { - return { kind: 'order', instId: orders[k].instId }; - } - } - return null; - } - - /** - * 归一化输入处理(闭包函数;命中后调 core 逻辑命令)。host.injectInput 与 Phaser pointerdown 共用。 - * @param {{type:'tap'|'key'|'drag', x?:number, y?:number, key?:string}} ev - */ - function doHandleInput(ev) { - if (!core || !ev || ev.type !== 'tap') return; - const hit = doHitTest(ev.x, ev.y); - if (!hit) return; - if (hit.kind === 'cell') { - // 命中棋盘格 → 合成逻辑命令(两次点同物品合成,逻辑在 core/merge-system)。 - core.tapCell(hit.i, hit.j); - } else if (hit.kind === 'order') { - // 命中订单 → 交单逻辑命令。 - core.fulfillOrder(hit.instId); - } - } + // 命中测试 / 归一化输入处理已抽进平台锁定的 play-runtime.js(写死、agent 不动)。 + // 这里只留薄委托闭包,既供 Phaser pointerdown 路径,也供下方对象方法(host.injectInput 路径)调用, + // 两条路径同一实现、坐标 layout 同源 → driver-tap 必落对格。 + function doHitTest(x, y) { return platformHitTest(core, x, y); } + function doHandleInput(ev) { platformHandleInput(core, ev); } return { // Phaser scene 配置:key 由 main.js 注册时给('PlayScene');本对象作为 sceneConfig 传 scene.add。 @@ -131,13 +91,8 @@ export function createPlayScene(deps) { if (typeof console !== 'undefined') console.warn('[play-scene] create 占位渲染异常:' + (e && e.message)); } - // Phaser 指针输入订阅(真玩时玩家点屏;坐标交命中 → core 逻辑命令)。 - // 调闭包 doHandleInput 而非 this.handleNormalizedInput——配置对象式 scene 的方法不在实例上(见 doHitTest 注释)。 - try { - this.input.on('pointerdown', (p) => { - doHandleInput({ type: 'tap', x: p.x, y: p.y }); - }); - } catch (e) { /* headless 无 input 时吞错 */ } + // Phaser 指针输入订阅 → 平台锁定运行时插座(driver-tap 路径焊死;agent 改不动)。 + bindInput(this, core); // ════════════════════════════════════════════════════════════════════ // 【LLM 填表现层 · TODO-1 · 棋盘格真渲染】 @@ -170,9 +125,9 @@ export function createPlayScene(deps) { * @param {number} delta 本帧毫秒(Phaser 给) */ update(_time, delta) { - // 推进核心逻辑(dt 秒;core 内部判赢/输并 latch phase)。 - const dt = (typeof delta === 'number' ? delta : 16.67) / 1000; - if (core) core.update(dt); + // 推进核心逻辑 → 平台锁定运行时插座(终态推进焊死;agent 改不动)。 + // core 内部推进订单耐心倒计时、判赢/输并 latch phase。丢了这步游戏永不到终态。 + tick(core, delta); // 平台预建:刷新最小 HUD 占位(金币 + phase)。LLM 的 TODO-3 会替换成真 HUD。 if (_hudText && core) { diff --git a/tier2/gen-worker/run_engine.py b/tier2/gen-worker/run_engine.py index 95ebfdb1..2ed51c99 100644 --- a/tier2/gen-worker/run_engine.py +++ b/tier2/gen-worker/run_engine.py @@ -96,6 +96,12 @@ FIXTURE_HINT_BUSINESS_SIM = """\ **填完先调 validate_datatable**(平台预检:查 schema/DAG/可达性),过了再 build——build 也会自动跑这道预检。 ② 在 src/scenes/play-scene.js 的 TODO 处填表现层(约 56%):3×3 棋盘格真渲染、订单面板、金币/库存 HUD、合成命中反馈。 逻辑命令(core.tapCell/core.fulfillOrder)、命中映射坐标系(BOARD_X/Y/CELL)、readState 语义约定都不要改。 + **两件平台命脉绝不能删**(它俩已抽进锁定文件 src/scenes/play-runtime.js,你写不动它): + - create() 里必须保留 `bindInput(this, core)` —— 接 driver-tap 输入路由。删了它 driver 点击不进 core, + 合成永不发生,F/G 门挂、calls=0。 + - update(_time, delta) 里必须保留 `tick(core, delta)` —— 逐帧推进 core(订单耐心倒计时 + 赢/输 latch)。 + 删了它游戏永不到终态,E_live/H_progress/经济/latch 门全挂。 + 你只管画(render),别碰输入路由与 tick;它俩从 play-runtime.js import,坐标与 driver 同源、已对齐。 语义 state(readState 已导出,别改):{phase, coins, ingredients, board, orders, failStreak} —— 富游戏三门据它真玩取证。 """ diff --git a/tier2/gen-worker/worker/run.py b/tier2/gen-worker/worker/run.py index c42087e8..00d79051 100644 --- a/tier2/gen-worker/worker/run.py +++ b/tier2/gen-worker/worker/run.py @@ -182,6 +182,13 @@ def build(game_id: str, entry: str = "src/main.js", *, global_name: str = DEFAUL + "\n".join(f" - {e}" for e in dv["errors"]) + ("\n" + dv["expectedSchemaHint"] if dv.get("expectedSchemaHint") else "")) return {"ok": False, "bundlePath": None, "log": log, "durationMs": 0} + # play-scene 契约预检(根因 3 另一半):必须接平台锁定的 bindInput(输入路由)+ tick(终态推进), + # 否则游戏画得再好也过不了 F/G/H/经济/latch 门。同样是诚实早断 + 精确反馈,不放松门。 + pv = validate_play_scene(game_id) + if not pv["ok"]: + log = ("play-scene 未过平台契约预检(esbuild 未执行)。逐项问题:\n" + + "\n".join(f" - {e}" for e in pv["errors"])) + return {"ok": False, "bundlePath": None, "log": log, "durationMs": 0} if not PHASER_BUILD_SCRIPT.exists(): # 诚实失败:脚本未就位则给落地指引,不假装成功。 return {"ok": False, "bundlePath": None, "durationMs": 0, @@ -362,6 +369,42 @@ def validate_datatable(game_id: str) -> dict: return {"ok": not errs, "errors": errs, "expectedSchemaHint": schema_hint if errs else ""} +def validate_play_scene(game_id: str) -> dict: + """校验 agent 写的 play-scene.js 是否仍接平台锁定的两件命脉:输入路由 bindInput + 逐帧 tick。 + + 为什么要它(spike feie-002/003 实证根因 3 的另一半):driver-tap 路径与终态推进原先混在 agent 可写的 + play-scene 里,M3 重写时把它俩一起改没了: + - 没接 bindInput → driver 注入的 touch 不路由到 core 逻辑命令 → 没合成 → F/G 门挂、calls=0; + - 没调 tick(core.update) → 订单耐心不倒计时、赢/输 latch 永不触发 → E_live/H_progress/经济/latch 门挂。 + 这两件已抽进平台锁定文件 play-runtime.js,agent 只需在 play-scene 的 create 调 bindInput(this, core)、 + update 调 tick(core, delta)。本检查确认这两个调用在,缺了就响亮告诉 agent 补上(否则它再怎么画表现层也过不了门)。 + + 防 Goodhart:只查「这两个平台调用是否在场」(结构性,文本扫描即可判),不替 agent 设计表现层、不放松门。 + + Returns: + {ok, errors:[人读字符串]}。 + """ + wd = _workdir(game_id) + ps = wd / "src" / "scenes" / "play-scene.js" + if not ps.exists(): + return {"ok": False, "errors": ["缺 src/scenes/play-scene.js(表现层场景文件)。"]} + src = ps.read_text(encoding="utf-8") + errs: list[str] = [] + # 必须从锁定运行时插座 import 这两件(允许命名导入;名字本身就够判)。 + if "play-runtime" not in src: + errs.append("play-scene.js 没 import './play-runtime.js'——平台锁定的输入路由 + tick 在那;" + "必须 `import { bindInput, tick } from './play-runtime.js'`。") + # create 里必须接输入路由(driver-tap 路径焊死)。 + if "bindInput(" not in src: + errs.append("play-scene.js 的 create() 必须调 bindInput(this, core) 接平台输入路由——" + "否则 harness driver 注入的点击不会路由到 core 逻辑命令,合成永不发生(F/G 门挂、calls=0)。") + # update 里必须逐帧推进 core(终态推进焊死)。 + if "tick(" not in src: + errs.append("play-scene.js 的 update(_time, delta) 必须调 tick(core, delta) 逐帧推进 core——" + "否则订单耐心不倒计时、赢/输 latch 永不触发(E_live/H_progress/经济/latch 门挂)。") + return {"ok": not errs, "errors": errs} + + def _derive_richgame_from_datatable(wd: Path) -> dict | None: """从工程内 data/datatable.gold.json 派生三联动门静态校验所需的 richGame.{mergeChains,orders}。 diff --git a/tier2/gen-worker/worker/toolkit.py b/tier2/gen-worker/worker/toolkit.py index 4dc10566..e7f7b4a9 100644 --- a/tier2/gen-worker/worker/toolkit.py +++ b/tier2/gen-worker/worker/toolkit.py @@ -41,6 +41,7 @@ LOCKED_PLATFORM_FILES = frozenset({ "src/systems/merge-system.js", # 合成系统纯逻辑(tapCell 两次点同物品合成) "src/systems/order-system.js", # 订单系统纯逻辑 "src/systems/reachability.js", # 可达性纯逻辑 + "src/scenes/play-runtime.js", # 表现层平台锁定运行时插座(输入路由 + 逐帧 core.update tick) })