fix(tier2): 抽平台命脉进锁定 play-runtime.js + play-scene 契约门(根因3 另一半)

spike feie-003 实证(lock+schema 都修对后):F_wiring 仍 calls=0、E_live distinct=1/N、
latch=false。diff 出真因——M3 重写 play-scene 时把两件平台命脉一起改没了:
① 输入路由(doHitTest/doHandleInput/this.input.on pointerdown):没了或坐标错位
   (用 (col,row) 调 core.tapCell 而非 (i,j))→ driver 注入的 touch 落空/落错格 → 没合成 → F/G 门挂。
② 逐帧 core.update(dt):没了 → 订单耐心不倒计时、赢/输 latch 永不触发 → E_live/H/经济/latch 全挂。

这俩原先混在 agent 可写的 play-scene 里、只靠注释「别动」——无机器门=必被 M3 改掉。
现抽进平台锁定文件 src/scenes/play-runtime.js(bindInput 输入路由 + tick 逐帧推进),
加进 LOCKED_PLATFORM_FILES;fixture play-scene 重构成 create 调 bindInput(this,core)、
update 调 tick(core,delta),薄委托命中给锁定实现(坐标 layout 同源、driver-tap 焊死)。
加 validate_play_scene 契约门(build 段跑):play-scene 必须 import play-runtime 且调 bindInput+tick,
缺了响亮回喂精确指引。prompt/hint 同步:你只管画 render,别碰输入路由与 tick。

门未放松(judge 纯代码判);fixture 重构后须重跑金标 smoke 验仍 ACCEPT(下一步)。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
zizi 2026-06-23 21:53:53 +00:00
parent f8ec30c875
commit 0fd0d4fb10
5 changed files with 171 additions and 58 deletions

View File

@ -0,0 +1,108 @@
/**
* src/scenes/play-runtime.js mini-肥鹅 表现层平台锁定运行时插座(平台预建 · 写死不动)
* ownertier2 引擎线(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=0G/H 门连环挂
* - 逐帧 core.update(dt) 没了 订单耐心不倒计时/ latch 永不触发
* E_live distinct=1/NH_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);
}

View File

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

View File

@ -96,6 +96,12 @@ FIXTURE_HINT_BUSINESS_SIM = """\
**填完先调 validate_datatable**(平台预检: schema/DAG/可达性),过了再 buildbuild 也会自动跑这道预检
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} 富游戏三门据它真玩取证
"""

View File

@ -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}。

View File

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