fix(tier2): 修真接线三处——pointerdown 闭包/逐帧补桌/patience 缩到有界可达

CDP 探针实证 driver tap 抛「this.handleNormalizedInput is not a function」→
state0/state1 全 null、E_live=1、F_wiring=0、九门连锁红。三处根因+修:

① Phaser 用「配置对象」实例化 scene 时只搬 create/update/preload,不搬对象上其它
   方法 → create 内 `this.handleNormalizedInput`/`this.hitTest` undefined,玩家点屏即抛。
   修:把 hitTest/handleNormalizedInput 抽成闭包 doHitTest/doHandleInput,pointerdown
   与 host.injectInput 都调闭包、不依赖 this(对象方法保留为薄委托)。

② 合成清空棋盘格、产物只进库存不回落棋盘 → 棋盘很快只剩单件、driver 无对可合成、
   赢路卡在中途(offline sim:coins 停 54)。修:core.update 逐帧 merge.refillFromInventory()
   (幂等,把库存里没上桌的基础料补进空格)→ offline sim 赢路达 coins116/win。

③ gold patience 440~600s 在有界 driver run(~30s)里订单永不流失 → 经济门破产路
   不可达。修:patience 缩到 16~20s(赢路~6s 完成远早于;破产路~20s 内 3 单流失=lose)。
   忠实 offline sim 验:patience≤30s 两路都达终态,≥60s 破产路 stuck=play。

验证:headless-smoke + logic-smoke 全绿(赢/输双路可达 + latch 不回弹)。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
zizi 2026-06-23 20:43:10 +00:00
parent 712547d212
commit 44d1b45583
3 changed files with 66 additions and 44 deletions

View File

@ -1,6 +1,6 @@
{
"schemaVersion": "mini-fei-e-1.0",
"_note": "金标样例(人工预建)——供 driver / 三层校验门 / 富游戏三门做确定性对照测试,不依赖 LLM。本份满足所有 spike 门约束:① mergeChains 是 6 条不相交边(item-base→item-product)、DAG 无环;② 5 个订单的 requires 全是合成产出物(tier2),requiresReachable 通过;③ 开局 20→赢线 100、连续 3 流失=输线;④ unlockCoins 体现金币门控解锁(耦合点5)。主题=mini-肥鹅小卖部,照 wanglanmei-ref 砍小。",
"_note": "金标样例(人工预建)——供 driver / 三层校验门 / 富游戏三门做确定性对照测试,不依赖 LLM。本份满足所有 spike 门约束:① mergeChains 是 6 条不相交边(item-base→item-product)、DAG 无环;② 5 个订单的 requires 全是合成产出物(tier2),requiresReachable 通过;③ 开局 20→赢线 100、连续 3 流失=输线;④ unlockCoins 体现金币门控解锁(耦合点5)。主题=mini-肥鹅小卖部,照 wanglanmei-ref 砍小。patience 单位=秒(game-core remainMs=patience*1000),取 16~20s 是为两路都能在有界 driver run 里跑到终态:赢路约 6s 完成(远早于 patience),破产路只合成不交单 → 3 单约 16~20s 内耗尽流失=lose(原 440~600s 在有界 run 里无法流失,经济门破产路不可达,本份修正)。",
"currencies": {
"coins": { "id": "coins", "initial": 20, "label": "金币" },
"ingredients": {
@ -32,11 +32,11 @@
{ "from": "egg", "to": "cake", "cost": 2, "unlockCoins": 80 }
],
"orders": [
{ "id": "order-1", "requires": { "bread": 1 }, "reward": 18, "patience": 600, "customerAssetId": "ph-customer-kid" },
{ "id": "order-2", "requires": { "jam": 1 }, "reward": 22, "patience": 560, "customerAssetId": "ph-customer-lady" },
{ "id": "order-3", "requires": { "soda": 1, "bread": 1 }, "reward": 30, "patience": 520, "customerAssetId": "ph-customer-grandpa" },
{ "id": "order-4", "requires": { "cream": 1, "chocolate": 1 }, "reward": 40, "patience": 480, "customerAssetId": "ph-customer-auntie" },
{ "id": "order-5", "requires": { "cake": 1 }, "reward": 48, "patience": 440, "customerAssetId": "ph-customer-kid" }
{ "id": "order-1", "requires": { "bread": 1 }, "reward": 18, "patience": 20, "customerAssetId": "ph-customer-kid" },
{ "id": "order-2", "requires": { "jam": 1 }, "reward": 22, "patience": 19, "customerAssetId": "ph-customer-lady" },
{ "id": "order-3", "requires": { "soda": 1, "bread": 1 }, "reward": 30, "patience": 18, "customerAssetId": "ph-customer-grandpa" },
{ "id": "order-4", "requires": { "cream": 1, "chocolate": 1 }, "reward": 40, "patience": 17, "customerAssetId": "ph-customer-auntie" },
{ "id": "order-5", "requires": { "cake": 1 }, "reward": 48, "patience": 16, "customerAssetId": "ph-customer-kid" }
],
"winCondition": { "coinsTarget": 100 },
"loseCondition": { "consecutiveOrderFails": 3 }

View File

@ -157,6 +157,10 @@ export function createGameCore(deps) {
update(dtSeconds) {
if (state.phase !== 'play') return; // title / 终态不推进逻辑
order.update(dtSeconds);
// 逐帧补桌:合成会清空棋盘格(产物只进库存、不回落棋盘),不补桌则棋盘很快只剩单件、driver 无对可合成、
// 赢路径被卡(coins 停在中途)。refillFromInventory 幂等——只把库存里还没上桌的基础料补进空格,
// 棋盘满 / 库存耗尽即停。这是「棋盘=合成工作台、库存=料仓」设计下保正循环可持续的必需步(logic-smoke 同源)。
merge.refillFromInventory();
state.coins = resource.state.coins; // 同步渲染缓存
_settle(); // 赢 / 输判定 + latch
},

View File

@ -54,6 +54,55 @@ 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);
}
}
return {
// Phaser scene 配置:key 由 main.js 注册时给('PlayScene');本对象作为 sceneConfig 传 scene.add。
// 注:用对象式 scene(Phaser 支持 {create, update} 配置对象),避免顶层 class extends Phaser.Scene 依赖。
@ -82,10 +131,11 @@ export function createPlayScene(deps) {
if (typeof console !== 'undefined') console.warn('[play-scene] create 占位渲染异常:' + (e && e.message));
}
// Phaser 指针输入订阅(真玩时玩家点屏;坐标交 hitTest 命中 → core 逻辑命令)。
// Phaser 指针输入订阅(真玩时玩家点屏;坐标交命中 → core 逻辑命令)。
// 调闭包 doHandleInput 而非 this.handleNormalizedInput——配置对象式 scene 的方法不在实例上(见 doHitTest 注释)。
try {
this.input.on('pointerdown', (p) => {
this.handleNormalizedInput({ type: 'tap', x: p.x, y: p.y });
doHandleInput({ type: 'tap', x: p.x, y: p.y });
});
} catch (e) { /* headless 无 input 时吞错 */ }
@ -135,52 +185,20 @@ export function createPlayScene(deps) {
/**
* 归一化输入入口(main.js 的 PhaserGameInstance.injectInput 路由到这里;harness 驱动 + 真玩共用)。
* 做命中测试:屏幕坐标 → 棋盘格 (i,j) / 订单 → 调 core 逻辑命令。
* 委托给闭包 doHandleInput(不依赖 this,与 Phaser pointerdown 路径同一实现)。
* @param {{type:'tap'|'key'|'drag', x?:number, y?:number, key?:string}} ev
*/
handleNormalizedInput(ev) {
if (!core || !ev || ev.type !== 'tap') return;
const hit = this.hitTest(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);
}
doHandleInput(ev);
},
/**
* 命中测试:屏幕坐标 → 命中目标(平台预建棋盘格映射;订单映射留 TODO 给 LLM 定面板布局后补)。
* 坐标系 = 逻辑视口像素(与 BOARD_X/BOARD_Y/CELL 同源)。
* 命中测试(外部可调;委托闭包 doHitTest)。坐标系 = 逻辑视口像素(与 layout 同源)。
* @param {number} x @param {number} y
* @returns {{kind:'cell', i:number, j:number} | {kind:'order', instId:string} | null}
*/
hitTest(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 行的可点区 = [PANEL_X, PANEL_X+PANEL_W] × [PANEL_Y+k*ROW_H, +ROW_H]。
// 把命中行 k 映射到 ordersView()[k] 的 instId(与 game-core 导出的 submitTargets 第 k 行同源),
// driver 据 submitTargets 点这里 → core.fulfillOrder(instId) 真交单。
// 注: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;
return doHitTest(x, y);
},
};
}