【U1 第二装载落库(F族)】store.py + run.persist + studio收口 + index.template分流:
SourceProjectStore 接口 + LocalFsStore 真实现(GEN_DIR/_store 内容哈希寻址/版本归档/可重建,9 round-trip 测试过)
+ BackendStore seam(MySQL+OSS 交 Java 后端,forbidden-import 守着不实连)。versionId=v<秒>-<hash前12>,
改源得新版本/保留旧版/fetch 按 version 取回。projectType 分流闸:index.template 从"工厂存在性隐式判断"
升为显式分流(tier2-phaser 现行路 / gamedef-legacy / amodel-2.0 留 seam 报错不静默)。result 新增 store_addressing。
【U2 品类可插拔机制(G/E族)】archetypes.py 注册表 + run/harness 泛化:
品类注册表(key→{数据表schema/脚手架/driver/门/fixture}),business-sim 唯一注册、五件套全指现有资产。
scaffold_init/gate_spec/prompt_hint 改读注册表;business-sim 铺出脚手架/spec/hint 与旧逐字节一致(回归验证)。
富游戏门按品类选(business-sim→现三门;未注册品类→"门待建"占位 passed:false、decision 不会 accept)。
"加新品类=补 5 样内容(非引擎码)"配方写进 archetypes.py。
全 tier2/ 内、零碰 Tier0/1、py_compile+node --check+forbidden-import 通过、金标装配冒烟不受影响。token 真跑验证待 mini-desktop。
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1033 lines
65 KiB
JavaScript
1033 lines
65 KiB
JavaScript
/**
|
||
* play-phaser.cdp.cjs —— tier2 富游戏线「CDP 真玩取证驱动」(Phaser 重写版,单写 agent 产出的 Phaser 工程共用一份)
|
||
* owner:tier2 引擎线(M2 探针+门) | cwd 必须 = game-runtime | 复用 test/harness/browser-evidence.cjs
|
||
*
|
||
* ════════════════════════════════════════════════════════════════════════════
|
||
* 【这份是什么】fork 自 LittleJS 廉价线 game-runtime/games/_wg1-gen/_shared/play.cdp.cjs,
|
||
* 把【四条 per-engine 探针钩子】(canvas 选择器 / 帧源 / boot 信号 / 输入注入+state 读取)按 Phaser 重写,
|
||
* 保留【引擎无关的判定骨架】(会话/截图/像素回读/FNV hash/checkAssertion/九门组装/A-B 对照)。
|
||
* 钩子重写依据 = tier2/contracts/engine-probe-hooks.md(A6)的四钩子 LittleJS→Phaser 映射表。
|
||
* 换引擎 = 重写这套钩子实现(不是参数化一键切,见 A6/D5);本版把钩子集中进 PHASER_PROBE 抽象层,
|
||
* 降低重写面、并给第二引擎(Pixi)做依据。
|
||
*
|
||
* 【与 LittleJS 版的硬差异(不是改参数,是 per-engine 重写)】
|
||
* ① canvas 选择器:LittleJS=`#game-engine`(引擎自建叠加 canvas)→ Phaser 自挂 canvas,选择器走 PHASER_PROBE.canvasSelector
|
||
* (候选:Phaser 默认无 id 的首个 canvas 或 game 容器内 canvas;spike 实测定,本版默认 `#game-engine, canvas`)。
|
||
* ② 帧源:LittleJS=`window.__engine.snapshot().frame` → Phaser 无 __engine,改读 `game.loop.frame`(单调递增帧计数,A6 候选),
|
||
* 经页面侧 `window.__tier2Frame()` 归一化导出(host 注入,见 boot-phaser-host.d.ts)。
|
||
* ③ boot 信号:LittleJS=`__genBooted && __gameHostEngineInitFired` → Phaser scene ready 后置 `window.__tier2Booted`(A6 候选)。
|
||
* ④ 输入注入+state:tap/key/drag 经 CDP 注入(路径同 LittleJS,坐标按 Phaser 视口 rect 映射);
|
||
* 富游戏语义 state 走 `window.__gameState()`(游戏经 _forensicsView 范式导出 → host 透传,A6 语义 state 导出约定)。
|
||
*
|
||
* 【九门(沿用 LittleJS 哲学,探针 Phaser 化)】A_boot/B_uncaught/C_frame/D_render/E_live/F_wiring/G_input/H_progress/I_control。
|
||
* F_wiring 真接线:LittleJS 用 `window.__engineCalls` 含期望 call-ID → Phaser 等价走 `window.__engineCalls`(Phaser 能力调用 trace,
|
||
* 由游戏/host 记录,A6 F 门接线量);spike 期富游戏品类更看富游戏三门的真调资源(callsAddCoins/callsConsumeIngredient)。
|
||
* driven 感知 advisory 分级(A-model 已落待合并对账,D4):无 driver/inputs 时 E_live/H_progress 降 advisory(不计 pass)。
|
||
*
|
||
* 【富游戏三门(tier2 专属,九门之上的接线地板,D3/A5)】产 tier2-verdict.schema.json 的 layerResults.L1.richGameGates:
|
||
* · 三联动门 tripleLink:订单 requires 可被合成产出满足(跨表可达,静态 mergeChains→orders) ∧ 合成链 DAG 无环(拓扑) ∧
|
||
* 完单真调 addCoins ∧ 合成消耗真调 consumeIngredient。
|
||
* · 经济门 economy:盈利路(金币 20→100 判 win) ∧ 破产路(连续 3 订单流失判 lose),两路都被真输入驱动到终态(reachedTerminal)。
|
||
* · latch 门 latch:终态落定不回弹(多帧轮询 phase 恒定) ∧ 宿主可读(readState 读到终局 phase)。
|
||
*
|
||
* 【用法】node tier2/harness/play-phaser.cdp.cjs <gameId> [--base=http://localhost:4320] [--cdp=http://localhost:9222] [--spec=<spec.json>] [--out=<evidenceDir>]
|
||
* spec.json(缺省读 games/<gameId>/play-spec.json):{ driver:{type:'business-sim',...}, assertAfterPlay:[...], expectLatch:true, richGame:{...}, expectedEngineCallPrefixes:[...] }
|
||
* 产物:<evidenceDir>/{verdict.json(tier2-verdict 形状), first-paint.png, after-play.png};退出码 0=accept / 1=非 accept。
|
||
* 真跑必须在 mini-desktop(6c6g 禁 chrome)。本机只 node --check 静态校验。
|
||
* ════════════════════════════════════════════════════════════════════════════
|
||
*/
|
||
|
||
'use strict';
|
||
|
||
const path = require('node:path');
|
||
const http = require('node:http');
|
||
const fs = require('node:fs');
|
||
const crypto = require('node:crypto');
|
||
// 富游戏三门里「三联动门」的静态部分(跨表可达 + DAG 无环)由纯逻辑模块算,与真跑解耦、可单测。
|
||
// (纯逻辑,无浏览器依赖;放顶层 require,便于本机单测。)
|
||
const richGates = require('./rich-game-gates.cjs');
|
||
|
||
// ── 浏览器依赖(browser-evidence + ws)懒加载 ──
|
||
// 理由:6c6g 禁 chrome 且未装 ws;judge 判定函数(checkAssertion/evalXxxGate/assembleVerdict)要能在本机被 require 单测,
|
||
// 故把需要 ws / CDP 的依赖延迟到真跑(connect)时才加载,不在模块顶层 import(否则本机 require 即 MODULE_NOT_FOUND)。
|
||
let H = null, CdpSession = null, captureImageData = null, saveScreenshot = null, fnv1a32 = null, WebSocket = null;
|
||
function loadBrowserDeps() {
|
||
if (H) return;
|
||
// 复用纯计算口径 + CDP 会话类 + 像素抓取/截图(test/harness/browser-evidence.cjs;引擎无关,照搬)。
|
||
H = require(path.resolve(__dirname, '../../game-runtime/test/harness/browser-evidence.cjs'));
|
||
({ CdpSession, captureImageData, saveScreenshot, fnv1a32 } = H);
|
||
// CDP 需 ws。ws 装在 game-runtime/node_modules,而本 harness 在 tier2/ 外、play 步 cwd=仓根,
|
||
// 裸 require('ws') 按模块位置(tier2/harness)解析不到 → 从 game-runtime 显式解析(与上方 browser-evidence 同口径)。
|
||
const grRequire = require('node:module').createRequire(path.resolve(__dirname, '../../game-runtime/package.json'));
|
||
WebSocket = grRequire('ws');
|
||
}
|
||
|
||
/** 纯 Promise 延时(不走被拦的 sleep 命令)。 */
|
||
const delay = (ms) => new Promise((r) => setTimeout(r, ms));
|
||
|
||
/* ════════════════════════════════════════════════════════════════════════════
|
||
* 【人可玩 advisory 阈值常量(单元 A)】
|
||
* ★ DIRECTIONAL —— 候选值,待主会话用金标 fixture(mini-fei-e)实测校准后再定,并据此决定是否提 fatal。
|
||
* 背景:九门用 driver 按布局坐标点(不看屏幕)且点得飞快,抓不到「反应窗口只为机器人调、人来不及」这类缺陷
|
||
* (feie-005 即此症:订单只有 8 秒耐心)。本常量给「人类最小反应窗口」一个候选下限。
|
||
* 金标实测值(datatable.gold.json):5 个订单 patience = 20/19/18/17/16 秒 → ms = 20000/19000/18000/17000/16000,
|
||
* min=16000ms。注:金标这组 patience 已是「为有界 driver run 砍小后」的值(原 440~600s),仍 ≥ 16s。
|
||
* 候选阈值取 12000ms(12s):低于金标 min(16s)留一档余量、让金标全过;feie-005 的 8s(=8000ms)< 12000ms 被挂。
|
||
* 【纪律】本阈值仅产 advisory violation,绝不参与 decision。提 fatal/调阈值由主会话实测金标后做(见文件末 promotionPlan)。
|
||
* ════════════════════════════════════════════════════════════════════════════ */
|
||
const HUMAN_MIN_PATIENCE_MS = 12000;
|
||
|
||
/* ════════════════════════════════════════════════════════════════════════════
|
||
* 【PHASER_PROBE —— per-engine 探针钩子抽象层(A6 EngineProbeHooks 的 Phaser 实现)】
|
||
* 把四条钩子集中在这里,换引擎只重写这一段(不散落全文件);directional v1,spike 实证后收窄。
|
||
* ════════════════════════════════════════════════════════════════════════════ */
|
||
const PHASER_PROBE = {
|
||
// ① canvas 选择器:像素回读 / 坐标映射目标。Phaser 自挂 canvas,id 视宿主而定 →
|
||
// 先试约定的 #game-engine(若 host 沿用旧约定),回落到 wrap 内首个 <canvas>(Phaser 默认无 id)。spike 实测定。
|
||
canvasSelector: '#game-engine, #game-canvas, #wrap canvas, canvas',
|
||
|
||
// ② 帧源(boot 信号、帧计数):Phaser 无 LittleJS 的 __engine.snapshot().frame。
|
||
// host 须把单调帧计数归一化导出为 window.__tier2Frame()(候选实现 = game.loop.frame)。读不到返 null。
|
||
frameReadExpr:
|
||
'(function(){ try {' +
|
||
' if (typeof window.__tier2Frame === "function") { var f = window.__tier2Frame(); return (typeof f === "number") ? f : null; }' +
|
||
' if (window.__phaserGame && window.__phaserGame.loop && typeof window.__phaserGame.loop.frame === "number") return window.__phaserGame.loop.frame;' +
|
||
' return null;' +
|
||
'} catch (e) { return null; } })()',
|
||
|
||
// ③ boot 就绪信号:Phaser scene create 完成后 host 置 window.__tier2Booted=true;boot 失败置 window.__tier2BootError。
|
||
// (承袭 LittleJS 双信号 booted+引擎接管的语义,Phaser 合并为单标志 __tier2Booted=「引擎已起、可真玩」。)
|
||
bootReadExpr:
|
||
'({ booted: !!window.__tier2Booted, err: (window.__tier2BootError || window.__phaserBootError || null) })',
|
||
|
||
// ④ 输入注入:tap/key/drag 经 CDP 注入(路径引擎无关);坐标映射用本 selector 的 canvas rect。
|
||
// Phaser 输入投递路径与 LittleJS 一致(浏览器 → CDP dispatch → Phaser input 插件订阅),故注入实现可照搬,
|
||
// 只把坐标映射的目标 canvas 换成 PHASER_PROBE.canvasSelector。
|
||
|
||
// ④ 语义 state 读取:游戏经 _forensicsView 范式导出 → host 透传到页面别名 window.__gameState()(A6 语义 state 导出约定)。
|
||
stateReadExpr:
|
||
'(function(){ try {' +
|
||
' var s = (typeof window.__gameState === "function") ? window.__gameState()' +
|
||
' : (window.__tier2Host && typeof window.__tier2Host.state === "function") ? window.__tier2Host.state() : null;' +
|
||
' return s; } catch (e) { return null; } })()',
|
||
|
||
// F 门真接线量:Phaser 能力调用 trace(host/游戏记 window.__engineCalls,含调用 call-ID 前缀)。
|
||
engineCallsReadExpr: '(window.__engineCalls || [])',
|
||
// 未捕获错误:host 收集到 window.__tier2Internals.uncaught[](承袭 LittleJS __genInternals.uncaught)。
|
||
uncaughtReadExpr:
|
||
'(function(){ var n = window.__tier2Internals || window.__genInternals; var u = (n && n.uncaught) ? n.uncaught : []; return u.slice(0, 10); })()',
|
||
};
|
||
|
||
/** 极简 HTTP(PUT 建 target / GET 列表)。复刻自 LittleJS 版(browser-evidence 的 httpJson 未导出)。 */
|
||
function httpJson(method, urlStr) {
|
||
return new Promise((resolve, reject) => {
|
||
const u = new URL(urlStr);
|
||
const req = http.request(
|
||
{ hostname: u.hostname, port: u.port, path: u.pathname + u.search, method, timeout: 15000 },
|
||
(res) => { let b = ''; res.on('data', (c) => (b += c)); res.on('end', () => {
|
||
try { resolve(b ? JSON.parse(b) : {}); } catch (e) { reject(new Error('CDP HTTP 非 JSON:' + b.slice(0, 200))); } }); }
|
||
);
|
||
req.on('error', reject);
|
||
req.on('timeout', () => req.destroy(new Error('CDP HTTP 超时:' + urlStr)));
|
||
req.end();
|
||
});
|
||
}
|
||
|
||
/** 建 target → 连 WS → 启用域 → 设固定取证视口(390×844/DPR2/touch)→ 导航。返回 CdpSession。(引擎无关,照搬。) */
|
||
async function connect(url, cdpHttp) {
|
||
loadBrowserDeps(); // 真跑入口:此刻才加载 ws / browser-evidence(懒加载,见模块顶部说明)
|
||
const created = await httpJson('PUT', `${cdpHttp}/json/new?${encodeURI('about:blank')}`);
|
||
const wsUrl = created.webSocketDebuggerUrl;
|
||
if (!wsUrl) throw new Error('建 target 失败(无 webSocketDebuggerUrl):' + JSON.stringify(created).slice(0, 200));
|
||
const ws = await new Promise((resolve, reject) => {
|
||
const s = new WebSocket(wsUrl, { maxPayload: 256 * 1024 * 1024 });
|
||
s.on('open', () => resolve(s)); s.on('error', reject);
|
||
});
|
||
const cdp = new CdpSession(ws, created.id);
|
||
await cdp.send('Page.enable');
|
||
await cdp.send('Runtime.enable');
|
||
await cdp.send('Emulation.setDeviceMetricsOverride', { width: 390, height: 844, deviceScaleFactor: 2, mobile: true });
|
||
try { await cdp.send('Emulation.setTouchEmulationEnabled', { enabled: true, maxTouchPoints: 1 }); } catch (_) {}
|
||
await cdp.send('Page.navigate', { url });
|
||
return cdp;
|
||
}
|
||
|
||
/** 守卫A(Phaser 钩子③):轮询等 Phaser boot 就绪(__tier2Booted);拿到 bootError 即抛。 */
|
||
async function waitBoot(cdp, timeoutMs) {
|
||
const deadline = Date.now() + (timeoutMs || 30000);
|
||
await delay(800); // 固定 settle,让 Phaser scene preload 起步
|
||
while (Date.now() < deadline) {
|
||
const st = await cdp.evaluate(PHASER_PROBE.bootReadExpr);
|
||
if (st && st.err) throw new Error('Phaser 装载失败(守卫A):' + st.err);
|
||
if (st && st.booted) return;
|
||
await delay(300);
|
||
}
|
||
const last = await cdp.evaluate(PHASER_PROBE.bootReadExpr).catch(() => null);
|
||
throw new Error('Phaser 装载超时(守卫A):' + JSON.stringify(last));
|
||
}
|
||
|
||
/** 读 Phaser 单调帧号(钩子②);读不到返 null。 */
|
||
async function readEngineFrame(cdp) {
|
||
const f = await cdp.evaluate(PHASER_PROBE.frameReadExpr);
|
||
return (typeof f === 'number') ? f : null;
|
||
}
|
||
|
||
/** 守卫C:测 ms 内 Phaser 帧增量(钩子②帧源)。 */
|
||
async function frameDelta(cdp, ms) {
|
||
const f0 = await readEngineFrame(cdp);
|
||
await delay(ms);
|
||
const f1 = await readEngineFrame(cdp);
|
||
return { f0, f1, delta: (f0 == null || f1 == null) ? null : (f1 - f0) };
|
||
}
|
||
|
||
/** 推进对照实例到目标帧号(Phaser 自走,轮询帧源)。返回到达的帧号(-1=超时)。 */
|
||
async function advanceToFrame(cdp, targetFrame, timeoutMs, pollMs) {
|
||
const deadline = Date.now() + (timeoutMs || 15000);
|
||
while (Date.now() < deadline) {
|
||
const f = await readEngineFrame(cdp);
|
||
if (f != null && f >= targetFrame) return f;
|
||
await delay(pollMs || 100);
|
||
}
|
||
return -1;
|
||
}
|
||
|
||
/** 守卫D:canvas readback(钩子①选择器),统计有色像素(maxCh>80)数 + 全屏最亮通道值 + FNV hash。(引擎无关。) */
|
||
async function brightPixels(cdp, selector) {
|
||
const img = await captureImageData(cdp, selector || PHASER_PROBE.canvasSelector);
|
||
const d = img.data; let bright = 0, maxCh = 0;
|
||
for (let i = 0; i < d.length; i += 4) {
|
||
if (d[i + 3] === 0) continue;
|
||
const m = Math.max(d[i], d[i + 1], d[i + 2]);
|
||
if (m > maxCh) maxCh = m;
|
||
if (m > 80) bright++;
|
||
}
|
||
return { bright, maxCh, hash: fnv1a32(d), width: img.width, height: img.height };
|
||
}
|
||
|
||
/** 轻量采样:只取 canvas 的 FNV 哈希(守卫E 多采样用)。 */
|
||
async function sampleHash(cdp, selector) {
|
||
const img = await captureImageData(cdp, selector || PHASER_PROBE.canvasSelector);
|
||
return fnv1a32(img.data);
|
||
}
|
||
|
||
/** 逻辑坐标 → 页面 client 坐标(钩子④坐标映射;用 Phaser canvas 自身 backing-store 尺寸算缩放,不硬编码 390/844)。 */
|
||
async function mapToClient(cdp, xLogical, yLogical) {
|
||
const m = await cdp.evaluate(
|
||
'(function(){ var c = document.querySelector("' + PHASER_PROBE.canvasSelector.replace(/"/g, '\\"') + '");' +
|
||
' if (!c) return null; var r = c.getBoundingClientRect();' +
|
||
// 缩放 = CSS 显示尺寸(rect) / canvas backing-store 尺寸(c.width/height)——这是 client↔canvas 内部坐标的标准换算,
|
||
// 与 DPR 无关:游戏逻辑像素 = canvas backing-store 像素(Phaser Scale.NONE,backing=逻辑 390×844、不含 DPR 乘子),
|
||
// 故 sx=r.width/c.width。早先误按「c.width 含 DPR、逻辑=c.width/dpr」算,在 Scale.NONE 下把坐标整体放大 dpr 倍、
|
||
// 点击全落到错格(实测 tap 逻辑(85,175)→错点格(2,1)、合成永不成、coins 卡 20)。
|
||
' return { l: r.left, t: r.top, sx: r.width / (c.width || 1), sy: r.height / (c.height || 1) }; })()'
|
||
);
|
||
if (!m) throw new Error('mapToClient:找不到 Phaser canvas(' + PHASER_PROBE.canvasSelector + ')');
|
||
return { x: m.l + xLogical * m.sx, y: m.t + yLogical * m.sy };
|
||
}
|
||
|
||
/** 真触摸点按(touchStart→touchEnd 背靠背 + 裸 {x,y} 点)。钩子④输入注入(引擎无关,坐标走 Phaser 映射)。 */
|
||
async function tap(cdp, xLogical, yLogical) {
|
||
const c = await mapToClient(cdp, xLogical, yLogical);
|
||
await cdp.send('Input.dispatchTouchEvent', { type: 'touchStart', touchPoints: [{ x: c.x, y: c.y }] });
|
||
await cdp.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] });
|
||
await delay(120); // 等真事件经浏览器 → Phaser input 插件下一拍消化
|
||
}
|
||
|
||
/** 真键按(keyDown→downMs→keyUp)。code=DOM code(ArrowLeft/Space/KeyA…)。钩子④输入注入。 */
|
||
async function key(cdp, code, downMs) {
|
||
const map = { ArrowLeft: 37, ArrowRight: 39, ArrowUp: 38, ArrowDown: 40, Space: 32, Enter: 13 };
|
||
const keyCode = map[code] || 0;
|
||
const k = code === 'Space' ? ' ' : code;
|
||
await cdp.send('Input.dispatchKeyEvent', { type: 'keyDown', code, key: k, windowsVirtualKeyCode: keyCode, nativeVirtualKeyCode: keyCode });
|
||
await delay(downMs || 200);
|
||
await cdp.send('Input.dispatchKeyEvent', { type: 'keyUp', code, key: k, windowsVirtualKeyCode: keyCode, nativeVirtualKeyCode: keyCode });
|
||
}
|
||
|
||
/** 触屏拖拽:touchStart(from) → 多帧 touchMove 插值到 to → touchEnd。钩子④输入注入。 */
|
||
async function drag(cdp, from, to, ms) {
|
||
const f = from || { x: 195, y: 600 }, t = to || { x: 195, y: 700 };
|
||
const n = 8, dur = ms || 400;
|
||
const cf = await mapToClient(cdp, f.x, f.y), ct = await mapToClient(cdp, t.x, t.y);
|
||
await cdp.send('Input.dispatchTouchEvent', { type: 'touchStart', touchPoints: [{ x: cf.x, y: cf.y }] });
|
||
for (let i = 1; i <= n; i++) {
|
||
const kk = i / n, x = cf.x + (ct.x - cf.x) * kk, y = cf.y + (ct.y - cf.y) * kk;
|
||
await cdp.send('Input.dispatchTouchEvent', { type: 'touchMove', touchPoints: [{ x, y }] });
|
||
await delay(Math.max(8, Math.floor(dur / n)));
|
||
}
|
||
await cdp.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] });
|
||
await delay(120);
|
||
}
|
||
|
||
/** 读游戏可观测语义 state(钩子④;可测性红线:只读游戏自己导出的 _forensicsView 视图,禁 evaluate 篡改)。读不到返 null。 */
|
||
async function readGameState(cdp) {
|
||
return await cdp.evaluate(PHASER_PROBE.stateReadExpr);
|
||
}
|
||
|
||
/** 读 Phaser 能力调用 trace(F 门真接线量)。 */
|
||
async function readEngineCalls(cdp) {
|
||
return await cdp.evaluate(PHASER_PROBE.engineCallsReadExpr);
|
||
}
|
||
|
||
/** 读未捕获错误(B 门)。 */
|
||
async function readUncaught(cdp) {
|
||
return await cdp.evaluate(PHASER_PROBE.uncaughtReadExpr);
|
||
}
|
||
|
||
/* ──────────────────────────────────────────────────────────────────────────
|
||
* 【判定骨架(引擎无关,照搬 LittleJS 版;A6「判定骨架可保范式」)】
|
||
* ────────────────────────────────────────────────────────────────────────── */
|
||
|
||
/** 读嵌套路径(如 'orders' / 'resources.coins');任一层缺失返回 undefined。 */
|
||
function getPath(obj, p) {
|
||
if (!obj || !p) return undefined;
|
||
return String(p).split('.').reduce((o, k) => (o == null ? undefined : o[k]), obj);
|
||
}
|
||
|
||
/** 判一条机制断言(s0=玩前态 / s1=玩后态)。op:increased/decreased/changed/>/>=/</<=/==/in。返回逐条明细。 */
|
||
function checkAssertion(a, s0, s1) {
|
||
const p = a.path;
|
||
const v0 = getPath(s0, p);
|
||
const v1 = getPath(s1, p);
|
||
const num = (x) => (typeof x === 'number' ? x : Number(x));
|
||
let pass = false;
|
||
switch (a.op) {
|
||
case 'increased': pass = num(v1) > num(v0); break;
|
||
case 'decreased': pass = num(v1) < num(v0); break;
|
||
case 'changed': pass = JSON.stringify(v1) !== JSON.stringify(v0); break;
|
||
case '>': pass = num(v1) > num(a.value); break;
|
||
case '>=': pass = num(v1) >= num(a.value); break;
|
||
case '<': pass = num(v1) < num(a.value); break;
|
||
case '<=': pass = num(v1) <= num(a.value); break;
|
||
case '==': pass = v1 === a.value; break;
|
||
case 'in': case 'reached': pass = Array.isArray(a.value) && a.value.indexOf(v1) >= 0; break;
|
||
default: pass = false;
|
||
}
|
||
return { path: p, op: a.op, before: v0, after: v1, value: a.value, pass, why: a.why || '' };
|
||
}
|
||
|
||
/** 状态进展判(E_live 状态活性兜底,富游戏看 coins/orders 进展)。 */
|
||
function stateProgressed(s0, s1) {
|
||
if (!s0 || !s1) return false;
|
||
for (const k of ['coins', 'score', 'completedOrders', 'merges']) {
|
||
if (typeof s0[k] === 'number' && typeof s1[k] === 'number' && s0[k] !== s1[k]) return true;
|
||
}
|
||
// 终态推进也算活性(title→play→win/lose)。
|
||
if (s0.phase !== s1.phase) return true;
|
||
return false;
|
||
}
|
||
|
||
/* ──────────────────────────────────────────────────────────────────────────
|
||
* 【品类 → driver / 富游戏门 分发(U2 可插拔机制 · harness 侧)】
|
||
* 九门(A_boot…I_control)是引擎级、所有品类共用,与品类无关。
|
||
* driver 与「富游戏门」(九门之上的接线地板)才是品类专属:
|
||
* - driver:每品类一份确定性序列(business-sim.driver.js…),runDriver 按 driver.type 分发;
|
||
* - 富游戏门:business-sim 的 三联动/经济/latch 是**经营品类专属语义**(订单↔合成可达、双路经济),
|
||
* 别的品类(塔防/跑酷)不适用,要各自的门。spike 期只 business-sim 一个品类接了富游戏门;
|
||
* 其余品类在 assembleRichGameGates 走「门待建」清晰占位,**不静默套用经营门、也不静默放过**。
|
||
* 纪律(对齐 E2 克制警示):不为一个实现先建满"门集注册"抽象——先用一张最小品类表立住分发口径,
|
||
* 待第二品类落地、有两套富游戏门做依据,再抽通用门集接口(见 followups)。
|
||
* ────────────────────────────────────────────────────────────────────────── */
|
||
// 已接「富游戏三门」的品类集合(spike 期唯一:business-sim)。其余品类 → 门待建占位。
|
||
const RICHGAME_GATED_ARCHETYPES = new Set(['business-sim']);
|
||
|
||
/** 驱动器分发(tier2 富游戏品类:按 driver.type 选对应 .driver.js)。缺省 type=business-sim。 */
|
||
async function runDriver(cdp, driver, hashes, deps) {
|
||
const type = (driver && driver.type) || 'business-sim';
|
||
if (type === 'business-sim') {
|
||
const businessSim = require('./business-sim.driver.js');
|
||
return businessSim.run(cdp, driver, hashes, deps);
|
||
}
|
||
// 未注册 driver.type → 空转 + 响亮记 note(H/G 门判红线;不静默假装驱动过)。
|
||
// 加新品类:在 tier2/harness/<品类>.driver.js 写 driver,并在此加一条 `type===<品类>` 分发(见 archetypes.py 配方)。
|
||
for (let i = 0; i < (driver.steps || 10); i++) {
|
||
try { hashes.push(await sampleHash(cdp)); } catch (_) {}
|
||
await delay(driver.stepMs || 300);
|
||
}
|
||
return { drove: 0, steps: driver.steps || 10, note: '未注册 driver.type=' + type + '(在 runDriver 加分发 + 写 <品类>.driver.js)' };
|
||
}
|
||
|
||
/* ──────────────────────────────────────────────────────────────────────────
|
||
* 【富游戏三门真玩部分(D3/A5 richGameGates)】
|
||
* tripleLink 静态部分(requiresReachable/dagAcyclic)走 rich-game-gates.cjs 纯逻辑;
|
||
* 真调资源部分(callsAddCoins/callsConsumeIngredient)+ economy + latch 走真玩观测。
|
||
* ────────────────────────────────────────────────────────────────────────── */
|
||
|
||
/** 三联动门:静态(跨表可达 + DAG 无环) + 真玩(完单真调 addCoins / 合成真调 consumeIngredient)。
|
||
* @param tableSpec {mergeChains, orders}(spec.richGame,喂静态检查)
|
||
* @param callTrace window.__engineCalls 快照(含 resource.addCoins / resource.consumeIngredient 调用 call-ID)
|
||
* @param state0/state1 玩前/玩后语义 state(佐证 coins↑ / ingredients changed) */
|
||
function evalTripleLinkGate(tableSpec, callTrace, state0, state1) {
|
||
const checks = [];
|
||
// 静态①:跨表可达性(每个 order.requires 物品 ∈ mergeChains 可产出集合)。
|
||
const reach = richGates.requiresReachable(tableSpec || {});
|
||
checks.push({ name: 'requiresReachable', ok: reach.ok, detail: reach.detail });
|
||
// 静态②:合成链 DAG 无环。
|
||
const dag = richGates.dagAcyclic((tableSpec && tableSpec.mergeChains) || []);
|
||
checks.push({ name: 'dagAcyclic', ok: dag.ok, detail: dag.detail });
|
||
// 真玩③:完单真调 addCoins(call-ID 前缀命中 + coins 玩后 increased 佐证)。
|
||
const calls = Array.isArray(callTrace) ? callTrace : [];
|
||
const hitAddCoins = calls.some((x) => String(x).indexOf('resource.addCoins') === 0 || String(x).indexOf('addCoins') === 0);
|
||
const coinsUp = !!(state0 && state1 && typeof state0.coins === 'number' && typeof state1.coins === 'number' && state1.coins > state0.coins);
|
||
checks.push({ name: 'callsAddCoins', ok: hitAddCoins && coinsUp, detail: `call-ID命中=${hitAddCoins} coins ${state0 && state0.coins}→${state1 && state1.coins}` });
|
||
// 真玩④:合成消耗真调 consumeIngredient(call-ID 命中 + 某食材 changed 佐证)。
|
||
const hitConsume = calls.some((x) => String(x).indexOf('resource.consumeIngredient') === 0 || String(x).indexOf('consumeIngredient') === 0);
|
||
const ingChanged = richGates.ingredientsChanged(state0 && state0.ingredients, state1 && state1.ingredients);
|
||
checks.push({ name: 'callsConsumeIngredient', ok: hitConsume && ingChanged, detail: `call-ID命中=${hitConsume} ingredients变=${ingChanged}` });
|
||
return { passed: checks.every((c) => c.ok), checks };
|
||
}
|
||
|
||
/** 经济门:盈利路(coins 推到 ≥winThreshold 且 phase=='win')+ 破产路(连续 streakLose 流失,phase=='lose'),两路真输入驱动到终态。
|
||
* @param winRes 盈利路真玩观测 {coins, phase, reachedTerminal}
|
||
* @param loseRes 破产路真玩观测 {phase, reachedTerminal, lostStreak} */
|
||
function evalEconomyGate(winRes, loseRes, winThreshold) {
|
||
const checks = [];
|
||
const wt = winThreshold != null ? winThreshold : 100;
|
||
// 盈利路:coins ≥ 赢线 ∧ phase=win ∧ 真到终态。
|
||
const winOk = !!(winRes && typeof winRes.coins === 'number' && winRes.coins >= wt && winRes.phase === 'win' && winRes.reachedTerminal === true);
|
||
checks.push({ name: 'profitPathWin', ok: winOk, detail: `coins=${winRes && winRes.coins}(≥${wt}?) phase=${winRes && winRes.phase} terminal=${winRes && winRes.reachedTerminal}` });
|
||
// 破产路:phase=lose ∧ 真到终态。
|
||
const loseOk = !!(loseRes && loseRes.phase === 'lose' && loseRes.reachedTerminal === true);
|
||
checks.push({ name: 'bankruptPathLose', ok: loseOk, detail: `phase=${loseRes && loseRes.phase} terminal=${loseRes && loseRes.reachedTerminal} lostStreak=${loseRes && loseRes.lostStreak}` });
|
||
// 两路都真输入驱动到终态(能算 ≠ 能玩到)。
|
||
const bothTerminal = !!(winRes && winRes.reachedTerminal === true && loseRes && loseRes.reachedTerminal === true);
|
||
checks.push({ name: 'bothReachedTerminal', ok: bothTerminal, detail: `win.terminal=${winRes && winRes.reachedTerminal} lose.terminal=${loseRes && loseRes.reachedTerminal}` });
|
||
return { passed: checks.every((c) => c.ok), checks };
|
||
}
|
||
|
||
/** latch 门:终态落定不回弹(多帧轮询 phase 恒定 win/lose/gameover)+ 宿主可读(readState 读到终局 phase)。
|
||
* 在赢路/破产路驱动后调用:停输入,轮询等终态,再驻留校验。返回 {passed, checks, terminalPhase}。 */
|
||
async function evalLatchGate(cdp, opts) {
|
||
const o = opts || {};
|
||
const terminals = o.terminals || ['win', 'lose', 'gameover'];
|
||
const checks = [];
|
||
// ① 轮询等进入终态(最多 ~4.2s)。
|
||
let phaseNow = null;
|
||
for (let i = 0; i < 14; i++) {
|
||
const s = await readGameState(cdp);
|
||
phaseNow = s ? s.phase : null;
|
||
if (terminals.indexOf(phaseNow) >= 0) break;
|
||
await delay(300);
|
||
}
|
||
const reachedTerminal = terminals.indexOf(phaseNow) >= 0;
|
||
checks.push({ name: 'hostReadsTerminal', ok: reachedTerminal, detail: `宿主轮询 readState 读到 phase=${phaseNow}` });
|
||
// ② 驻留校验:再等 600ms,phase 必须仍是同一终态(瞬时终态/自动重开=latch 违约,宿主 500ms 轮询会漏读)。
|
||
let dwellOk = false, after = null;
|
||
if (reachedTerminal) {
|
||
await delay(600);
|
||
const s2 = await readGameState(cdp);
|
||
after = s2 ? s2.phase : null;
|
||
dwellOk = after === phaseNow; // 落定不回弹:仍是同一终态
|
||
}
|
||
checks.push({ name: 'terminalDwell', ok: dwellOk, detail: `驻留 600ms 后 phase=${after}(须恒=${phaseNow})` });
|
||
return { passed: checks.every((c) => c.ok), checks, terminalPhase: reachedTerminal ? phaseNow : null };
|
||
}
|
||
|
||
/* ──────────────────────────────────────────────────────────────────────────
|
||
* 【人可玩 advisory 检查(单元 A · 修「gate 通过≠人能玩」盲区)】
|
||
* 三项全部 advisory:只写进 verdict.humanPlayability,绝不参与 decision/runnableOk/L1.passed。
|
||
* 坐标系前提:host 用 Phaser Scale.NONE + width/height=逻辑视口(390×844)、resolution 默认 1,
|
||
* 故 canvas backing-store 像素 = 逻辑像素(无 DPR 乘子;与 mapToClient 注释、boot-phaser-host 配置一致)。
|
||
* 因此 state.board 的格中心逻辑坐标 (x,y) 直接就是 captureImageData 缓冲里的像素坐标 (x,y)。
|
||
* ────────────────────────────────────────────────────────────────────────── */
|
||
|
||
/** 从 RGBA 缓冲取一个矩形 patch 的统计(均值 RGB + 各通道方差和 + 有效采样数)。
|
||
* 越界/全透明像素跳过(透明=alpha 0)。patch 用「格内中心区」避开格边框 stroke,降低误差。
|
||
* @param {Uint8Array} data RGBA 字节缓冲
|
||
* @param {number} W 缓冲宽(像素)
|
||
* @param {number} H 缓冲高(像素)
|
||
* @param {number} cx patch 中心 x(像素=逻辑)
|
||
* @param {number} cy patch 中心 y
|
||
* @param {number} half patch 半边长(像素)
|
||
* @returns {{ mean:[number,number,number], variance:number, samples:number }}
|
||
*/
|
||
function patchStats(data, W, H, cx, cy, half) {
|
||
let sr = 0, sg = 0, sb = 0, srr = 0, sgg = 0, sbb = 0, n = 0;
|
||
const x0 = Math.max(0, Math.round(cx - half)), x1 = Math.min(W - 1, Math.round(cx + half));
|
||
const y0 = Math.max(0, Math.round(cy - half)), y1 = Math.min(H - 1, Math.round(cy + half));
|
||
for (let yy = y0; yy <= y1; yy++) {
|
||
for (let xx = x0; xx <= x1; xx++) {
|
||
const i = (yy * W + xx) * 4;
|
||
if (data[i + 3] === 0) continue; // 透明像素不计(Phaser 不透明黑底,正常无透明)
|
||
const r = data[i], g = data[i + 1], b = data[i + 2];
|
||
sr += r; sg += g; sb += b; srr += r * r; sgg += g * g; sbb += b * b; n++;
|
||
}
|
||
}
|
||
if (n === 0) return { mean: [0, 0, 0], variance: 0, samples: 0 };
|
||
const mr = sr / n, mg = sg / n, mb = sb / n;
|
||
// 三通道方差之和(衡量 patch 内部「花不花」——精灵/文本会让方差升高,纯色占位方差近 0)。
|
||
const variance = Math.max(0, (srr / n - mr * mr)) + Math.max(0, (sgg / n - mg * mg)) + Math.max(0, (sbb / n - mb * mb));
|
||
return { mean: [mr, mg, mb], variance, samples: n };
|
||
}
|
||
|
||
/** 两 RGB 均值的欧氏色差。 */
|
||
function colorDist(a, b) {
|
||
const dr = a[0] - b[0], dg = a[1] - b[1], db = a[2] - b[2];
|
||
return Math.sqrt(dr * dr + dg * dg + db * db);
|
||
}
|
||
|
||
/**
|
||
* render-reflects-state(advisory):对 state.board 里每个有 item 的格子,取其 canvas 像素区域,
|
||
* 与「空格子基线区域」比对;若有 item 的格子区域和空格子无法区分(基本等于背景/空格子)→记 violation。
|
||
* 判据:item 格相对空格基线,要么色差够大(画了有色占位/精灵)、要么内部方差明显更高(画了精灵/文本/图标);
|
||
* 两者都不满足 = 渲染没反映「这格有东西」(feie-005 即此症:state 有原料但画空格)。
|
||
* @param {Uint8Array} data RGBA 缓冲
|
||
* @param {number} W @param {number} H 缓冲尺寸(像素=逻辑)
|
||
* @param {object} state 语义 state(含 board=[{i,j,x,y,item}])
|
||
* @returns {{ pass:boolean, checked:number, emptyButShouldHaveItem:Array, note?:string }}
|
||
*/
|
||
function evalRenderReflectsState(data, W, H, state) {
|
||
const board = (state && Array.isArray(state.board)) ? state.board : null;
|
||
if (!board || board.length === 0) {
|
||
return { pass: true, checked: 0, emptyButShouldHaveItem: [], note: 'state 无 board(非格子类游戏或未导出)→ advisory 跳过' };
|
||
}
|
||
// patch 半边长:CELL≈110,取格内中心 ~64×64 区(half=32),避开 4px stroke 与格间隙。
|
||
const HALF = 32;
|
||
// 收集每格 patch 统计。
|
||
const itemCells = [], emptyCells = [];
|
||
for (const c of board) {
|
||
if (!c || typeof c.x !== 'number' || typeof c.y !== 'number') continue;
|
||
const ps = patchStats(data, W, H, c.x, c.y, HALF);
|
||
if (ps.samples === 0) continue; // 该格落在缓冲外/全透明,跳过
|
||
const rec = { i: c.i, j: c.j, item: c.item, mean: ps.mean, variance: ps.variance };
|
||
if (c.item) itemCells.push(rec); else emptyCells.push(rec);
|
||
}
|
||
if (itemCells.length === 0) {
|
||
return { pass: true, checked: 0, emptyButShouldHaveItem: [], note: '当前帧无有 item 的格子(棋盘空/已合成清空)→ advisory 跳过' };
|
||
}
|
||
// 空格基线:优先用本帧空格 patch 的均值/方差;无空格时回落「整盘最暗 patch」近似空底(经营档空格=底色)。
|
||
let baseMean, baseVar;
|
||
if (emptyCells.length > 0) {
|
||
const n = emptyCells.length;
|
||
baseMean = [0, 0, 0]; baseVar = 0;
|
||
for (const e of emptyCells) { baseMean[0] += e.mean[0]; baseMean[1] += e.mean[1]; baseMean[2] += e.mean[2]; baseVar += e.variance; }
|
||
baseMean = [baseMean[0] / n, baseMean[1] / n, baseMean[2] / n]; baseVar = baseVar / n;
|
||
} else {
|
||
// 无空格基线:取所有 item 格里方差最低者当「最接近空」的近似底(保守,宁可漏判不可误挂)。
|
||
const lowest = itemCells.reduce((a, b) => (b.variance < a.variance ? b : a), itemCells[0]);
|
||
baseMean = lowest.mean; baseVar = lowest.variance;
|
||
}
|
||
// 判据阈值(advisory;directional):色差 > 18(8bit 通道感知可辨)或方差增量 > 120(画了精灵/文本)即「反映了状态」。
|
||
const COLOR_DIST_MIN = 18, VAR_DELTA_MIN = 120;
|
||
const emptyButShouldHaveItem = [];
|
||
for (const c of itemCells) {
|
||
const dist = colorDist(c.mean, baseMean);
|
||
const varDelta = c.variance - baseVar;
|
||
const reflects = dist > COLOR_DIST_MIN || varDelta > VAR_DELTA_MIN;
|
||
if (!reflects) {
|
||
emptyButShouldHaveItem.push({ i: c.i, j: c.j, item: c.item, colorDist: Math.round(dist), varDelta: Math.round(varDelta) });
|
||
}
|
||
}
|
||
const pass = emptyButShouldHaveItem.length === 0;
|
||
return {
|
||
pass, checked: itemCells.length, emptyButShouldHaveItem,
|
||
note: `比对 ${itemCells.length} 个有 item 格 vs ${emptyCells.length} 个空格基线(色差>${COLOR_DIST_MIN} 或 方差增量>${VAR_DELTA_MIN} 判为反映状态)`,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* render-sanity / no-[object Object](advisory):扫描渲染出的文本(页面 DOM textContent + host 暴露的 canvas 文本若有)
|
||
* 是否含 [object Object]/undefined/NaN 这类漏底实现细节 → 记 violation。
|
||
* 说明:Phaser canvas 文本不在 DOM 里,无法直接 textContent 抓;若 host/游戏把当前渲染文本暴露到
|
||
* window.__renderedText(string[]) 则一并扫(可选,缺则只扫 DOM)。这是 advisory:抓到漏底=强信号、抓不到≠保证干净。
|
||
* @param {object} cdp CDP 会话
|
||
* @returns {Promise<{ pass:boolean, violations:Array, note?:string }>}
|
||
*/
|
||
async function evalRenderSanity(cdp) {
|
||
// 在页面侧收集文本来源:① document.body.innerText(DOM 文本,如 #status、HTML overlay);
|
||
// ② window.__renderedText(host/游戏可选暴露的「当前 canvas 渲染文本」数组,A6 可测性扩展,缺省 [])。
|
||
const expr =
|
||
'(function(){ try {' +
|
||
' var out = [];' +
|
||
' try { if (document && document.body) out.push(String(document.body.innerText || "")); } catch (e1) {}' +
|
||
' try { var rt = window.__renderedText; if (Array.isArray(rt)) out = out.concat(rt.map(String)); else if (typeof rt === "string") out.push(rt); } catch (e2) {}' +
|
||
' return out.join("\\n").slice(0, 20000);' +
|
||
'} catch (e) { return ""; } })()';
|
||
let text = '';
|
||
try { text = String(await cdp.evaluate(expr) || ''); } catch (e) { return { pass: true, violations: [], note: '取页面文本异常(advisory 跳过):' + String((e && e.message) || e) }; }
|
||
// 漏底标记词表(用边界匹配避免误伤正常词,如把数据里的 "undefined" 文案?——这里只抓裸标记,故配词边界)。
|
||
const markers = [
|
||
{ marker: '[object Object]', re: /\[object Object\]/g },
|
||
{ marker: 'undefined', re: /(^|[^A-Za-z0-9_])undefined([^A-Za-z0-9_]|$)/g },
|
||
{ marker: 'NaN', re: /(^|[^A-Za-z0-9_])NaN([^A-Za-z0-9_]|$)/g },
|
||
];
|
||
const violations = [];
|
||
for (const m of markers) {
|
||
const idx = text.search(m.re);
|
||
if (idx >= 0) {
|
||
// 截一段上下文(去掉换行,便于落证)。
|
||
const sample = text.slice(Math.max(0, idx - 20), idx + 40).replace(/\s+/g, ' ').trim();
|
||
violations.push({ marker: m.marker, sample });
|
||
}
|
||
}
|
||
return { pass: violations.length === 0, violations, note: `扫描渲染文本 ${text.length} 字符(DOM innerText + window.__renderedText 若有)` };
|
||
}
|
||
|
||
/**
|
||
* human-timing(advisory):读数据表(经 state.orders 或 dataTable.orders)订单 patience(及反应窗口),
|
||
* 记录 min/max/各单 patience;低于候选人类最小阈值 HUMAN_MIN_PATIENCE_MS 的订单记 advisory violation。
|
||
* 单位归一:数据表 patience 单位=秒(game-core remainMs=patience*1000),故 *1000 转 ms 比对。
|
||
* @param {object|null} state 语义 state(state.orders[].patience 单位秒)
|
||
* @param {object|null} dataTable 数据表(datatable.gold.json;dataTable.orders[].patience 单位秒)
|
||
* @returns {{ pass:boolean, minPatienceMs:number|null, maxPatienceMs:number|null, thresholdMs:number, patiencesMs:Array, violations:Array, note?:string }}
|
||
*/
|
||
function evalHumanTiming(state, dataTable) {
|
||
// patience 来源优先级:数据表(权威·静态全量) > state.orders(运行时,可能已倒计时/只在场子集)。
|
||
// 取「原始 patience」而非 remainMs(remainMs 会随时间衰减,不能当反应窗口设计值)。
|
||
let orders = null, src = '';
|
||
if (dataTable && Array.isArray(dataTable.orders) && dataTable.orders.length) { orders = dataTable.orders; src = 'datatable'; }
|
||
else if (state && Array.isArray(state.orders) && state.orders.length) { orders = state.orders; src = 'state.orders'; }
|
||
if (!orders) {
|
||
return { pass: true, minPatienceMs: null, maxPatienceMs: null, thresholdMs: HUMAN_MIN_PATIENCE_MS, patiencesMs: [], violations: [], note: '无订单 patience 可读(非订单类游戏或未导出)→ advisory 跳过' };
|
||
}
|
||
const patiencesMs = [];
|
||
for (const o of orders) {
|
||
if (!o || typeof o.patience !== 'number') continue;
|
||
const ms = o.patience * 1000; // 秒 → 毫秒
|
||
patiencesMs.push({ orderId: (o.id != null ? String(o.id) : (o.instId != null ? String(o.instId) : '(无id)')), patienceMs: ms, belowThreshold: ms < HUMAN_MIN_PATIENCE_MS });
|
||
}
|
||
if (patiencesMs.length === 0) {
|
||
return { pass: true, minPatienceMs: null, maxPatienceMs: null, thresholdMs: HUMAN_MIN_PATIENCE_MS, patiencesMs: [], violations: [], note: `订单无 patience 字段(来源=${src})→ advisory 跳过` };
|
||
}
|
||
const vals = patiencesMs.map((p) => p.patienceMs);
|
||
const minMs = Math.min(...vals), maxMs = Math.max(...vals);
|
||
const violations = patiencesMs.filter((p) => p.belowThreshold).map((p) => ({ orderId: p.orderId, patienceMs: p.patienceMs }));
|
||
return {
|
||
pass: violations.length === 0, minPatienceMs: minMs, maxPatienceMs: maxMs, thresholdMs: HUMAN_MIN_PATIENCE_MS,
|
||
patiencesMs, violations,
|
||
note: `patience 来源=${src};min=${minMs}ms max=${maxMs}ms 阈值=${HUMAN_MIN_PATIENCE_MS}ms(★ directional,待金标校准)`,
|
||
};
|
||
}
|
||
|
||
/** 组装 tier2-verdict.schema.json 形状(A5)。judge 纯代码产出,零 LLM、零 agent 自评。
|
||
* @param ctx 收集到的全部判定材料 */
|
||
function assembleVerdict(ctx) {
|
||
const {
|
||
designId, taskId, versionId, round, traceId, evidenceDir,
|
||
structureOk, guards, richGameGates, playReport, drivenInputs,
|
||
} = ctx;
|
||
|
||
// 九门 → gateResults(driven 感知:无 driver/inputs 时 E_live/H_progress 降 advisory→fatal=false 不计 pass)。
|
||
const driven = !!ctx.driven;
|
||
const gateOrder = ['A_boot', 'B_uncaught', 'C_frame', 'D_render', 'E_live', 'F_wiring', 'G_input', 'H_progress', 'I_control'];
|
||
const advisoryWhenUndriven = { E_live: true, H_progress: true };
|
||
const gateResults = gateOrder.map((name) => {
|
||
const g = guards[name] || { pass: false, detail: '门未执行' };
|
||
const isAdvisory = !driven && advisoryWhenUndriven[name] === true;
|
||
return {
|
||
gate: name,
|
||
passed: !!g.pass,
|
||
fatal: !isAdvisory, // advisory 门此次不致命(不计 pass)
|
||
detail: typeof g.detail === 'string' ? g.detail : JSON.stringify(g).slice(0, 300),
|
||
};
|
||
});
|
||
// runnableOk = 所有【致命】门通过(advisory 门不计入)。
|
||
const runnableOk = gateResults.every((r) => !r.fatal || r.passed);
|
||
|
||
// L1.passed = 九门致命门全绿 ∧ 富游戏三门全绿。
|
||
const richAllPass = !!(richGameGates && richGameGates.tripleLink && richGameGates.tripleLink.passed
|
||
&& richGameGates.economy && richGameGates.economy.passed
|
||
&& richGameGates.latch && richGameGates.latch.passed);
|
||
const l1Passed = runnableOk && structureOk && richAllPass;
|
||
|
||
// decision(spike 初期只由 L1 裁):accept=结构OK∧可运行∧L1全绿;否则 fix(可回循环改);kill 由调用方在构建多次不过/熔断时显式置。
|
||
const decision = ctx.forceKill ? 'kill' : (structureOk && runnableOk && l1Passed ? 'accept' : 'fix');
|
||
|
||
// reasons(受控词表)。
|
||
const reasons = [];
|
||
if (decision === 'accept') reasons.push('accept');
|
||
else {
|
||
if (!structureOk) reasons.push('structure_invalid');
|
||
if (!runnableOk) reasons.push('runnable_fail');
|
||
// 富游戏门逐门失败码(gate_<门名>)。
|
||
if (richGameGates) {
|
||
for (const gk of ['tripleLink', 'economy', 'latch']) {
|
||
if (richGameGates[gk] && richGameGates[gk].passed === false) reasons.push('gate_' + gk);
|
||
}
|
||
}
|
||
if (ctx.forceKill) reasons.push('circuit_break');
|
||
if (decision === 'fix' && reasons.length === 0) reasons.push('p1_fix_round');
|
||
}
|
||
if (reasons.length === 0) reasons.push(decision === 'accept' ? 'accept' : 'runnable_fail');
|
||
|
||
const verdict = {
|
||
designId: designId || crypto.createHash('sha256').update(String(taskId || 'tier2-unknown')).digest('hex'),
|
||
taskId: taskId != null ? taskId : 'tier2-task',
|
||
decision,
|
||
structureOk: !!structureOk,
|
||
runnableOk: !!runnableOk,
|
||
round: typeof round === 'number' ? round : 0,
|
||
layerResults: {
|
||
// L1 硬约束层(机器判,零 LLM)。
|
||
L1: {
|
||
passed: l1Passed,
|
||
gateResults,
|
||
richGameGates: richGameGates || undefined,
|
||
},
|
||
// L2 设计符合层(spike 初期 observe-only,不计 decision)。
|
||
L2: { enforced: false, passed: null, signals: ctx.l2Signals || [] },
|
||
// L3 效果层(永不当门;scoreOnly 焊死)。
|
||
L3: { scoreOnly: true, score: null, notes: ctx.l3Notes || [] },
|
||
},
|
||
findings: ctx.findings || [],
|
||
reasons,
|
||
evidence: {
|
||
traceId: traceId || ('tier2-' + (taskId != null ? taskId : Date.now())),
|
||
evidenceDir: evidenceDir || undefined,
|
||
},
|
||
};
|
||
// decision=accept 强制 versionId+playReport(顶层 allOf)。
|
||
if (decision === 'accept') {
|
||
verdict.versionId = versionId != null ? versionId : 'pending-callback';
|
||
verdict.playReport = playReport || { loaded: true, drivenInputs: drivenInputs || 0, reachedTerminal: true };
|
||
} else if (playReport) {
|
||
verdict.playReport = playReport;
|
||
}
|
||
if (ctx.breakerKind) verdict.breakerKind = ctx.breakerKind;
|
||
|
||
// ── 人可玩 advisory 段(单元 A):纯附加,绝不参与上面已算定的 decision/reasons/runnableOk/L1.passed ──
|
||
// decision 在本函数上方早已算完;此处只把 advisory 观测结果挂上去供人审,一个字节不回改 decision。
|
||
if (ctx.humanPlayability) {
|
||
const hp = ctx.humanPlayability;
|
||
verdict.humanPlayability = {
|
||
advisory: true, // 不变量:本段全 advisory,只报告不阻塞(防 Goodhart)
|
||
renderReflectsState: hp.renderReflectsState || null,
|
||
renderSanity: hp.renderSanity || null,
|
||
humanTiming: hp.humanTiming || null,
|
||
};
|
||
}
|
||
return verdict;
|
||
}
|
||
|
||
/* ──────────────────────────────────────────────────────────────────────────
|
||
* 【主流程】
|
||
* ────────────────────────────────────────────────────────────────────────── */
|
||
|
||
async function main() {
|
||
const argv = process.argv.slice(2);
|
||
const gameId = argv[0];
|
||
if (!gameId) { console.error('用法:node play-phaser.cdp.cjs <gameId> [--base=] [--cdp=] [--spec=] [--out=]'); process.exit(2); }
|
||
const getArg = (k, def) => { const a = argv.find((x) => x.startsWith(k + '=')); return a ? a.split('=').slice(1).join('=') : def; };
|
||
const base = getArg('--base', 'http://localhost:4320');
|
||
const cdpHttp = getArg('--cdp', 'http://localhost:9222');
|
||
// tier2 落点独立(别碰 _wg1-gen):默认 games/<gameId>/,证据 evidence/。
|
||
const gameDir = path.resolve('games', gameId);
|
||
const evDir = getArg('--out', path.join(gameDir, 'evidence'));
|
||
fs.mkdirSync(evDir, { recursive: true });
|
||
|
||
const specPath = getArg('--spec', path.join(gameDir, 'play-spec.json'));
|
||
let spec = { driver: null, assertAfterPlay: [], expectLatch: false, richGame: {}, expectedEngineCallPrefixes: [] };
|
||
try { spec = Object.assign(spec, JSON.parse(fs.readFileSync(specPath, 'utf8'))); } catch (_) { /* 用默认 */ }
|
||
|
||
// 人可玩 human-timing 用的数据表(patience 权威·静态全量)。来源优先级:
|
||
// ① spec.dataTablePath 显式指定 → ② spec 所在目录下 data/datatable.gold.json(真跑约定:--spec 指向 <GAME_DIR>/play-spec.json,
|
||
// 故数据表在 <GAME_DIR>/data/;本机 fixture 同构)→ ③ gameDir 兜底(本机直跑、无 --spec 时)→ ④ spec.richGame(含 orders.patience)。
|
||
// 注:真跑 gameId='_tier2-gen/<id>',gameDir=path.resolve('games',gameId) 不指向真游戏目录(真目录在 game-runtime/games/);
|
||
// 故以 --spec 所在目录(specDir)为准取数据表,才在真跑/本机两环境都对。读不到不报错(advisory):evalHumanTiming 回落 state.orders。
|
||
const specDir = path.dirname(specPath);
|
||
let dataTable = null;
|
||
const dtCandidates = [
|
||
spec.dataTablePath ? path.resolve(spec.dataTablePath) : null,
|
||
path.join(specDir, 'data', 'datatable.gold.json'),
|
||
path.join(specDir, 'data', 'datatable.json'),
|
||
path.join(gameDir, 'data', 'datatable.gold.json'),
|
||
].filter(Boolean);
|
||
for (const p of dtCandidates) {
|
||
try { dataTable = JSON.parse(fs.readFileSync(p, 'utf8')); break; } catch (_) { /* 试下一个 */ }
|
||
}
|
||
// 兜底:spec.richGame 自带 orders.patience(play-spec.example 即如此;worker 也会从数据表 derive 进 spec.richGame)。
|
||
if (!dataTable && spec.richGame && Array.isArray(spec.richGame.orders)) dataTable = spec.richGame;
|
||
|
||
const url = `${base}/games/${gameId}/index.html`;
|
||
const round = spec.round != null ? spec.round : 0;
|
||
// driven 感知:有 driver 或非空 inputs → driven=true,E_live/H_progress 恢复致命。
|
||
const driven = !!spec.driver || ((spec.inputs || []).some((e) => e && (e.t === 'tap' || e.t === 'key' || e.t === 'drag')));
|
||
|
||
const guards = {};
|
||
const richGameGates = { tripleLink: null, economy: null, latch: null };
|
||
let structureOk = !!spec.structureOk; // 结构层(源工程校验+构建)由上游 build 段产出,经 spec 透传(本 harness 只判可运行层);缺省 false。
|
||
let playReport = null;
|
||
let totalDriven = 0;
|
||
let forceKill = false, breakerKind = null;
|
||
const l3Notes = [];
|
||
// 人可玩 advisory 段累加器(单元 A;三项全 advisory,不参与 decision)。
|
||
const humanPlayability = { renderReflectsState: null, renderSanity: null, humanTiming: null };
|
||
|
||
let cdp = null;
|
||
try {
|
||
cdp = await connect(url, cdpHttp);
|
||
// 守卫A:Phaser boot 就绪。
|
||
await waitBoot(cdp, 30000);
|
||
guards.A_boot = { pass: true, detail: 'Phaser __tier2Booted=true' };
|
||
|
||
// 首帧截图 + 渲染快照(守卫D 初值 + 守卫E t0 + 守卫H 玩前态)。
|
||
await saveScreenshot(cdp, path.join(evDir, 'first-paint.png'));
|
||
const px0 = await brightPixels(cdp);
|
||
const state0 = await readGameState(cdp);
|
||
|
||
// ── 人可玩 advisory ① render-reflects-state(首帧取证)──
|
||
// 首帧棋盘已由 boot 的 refillFromInventory 铺满料(state0.board 多数格有 item),且 feie-005 的「画空格」
|
||
// 缺陷首帧即可见 → 此刻抓 canvas 像素与 state0.board 比对最稳、最早(driver 还没扰动棋盘)。全 advisory。
|
||
try {
|
||
const img0 = await captureImageData(cdp, PHASER_PROBE.canvasSelector);
|
||
humanPlayability.renderReflectsState = evalRenderReflectsState(img0.data, img0.width, img0.height, state0);
|
||
} catch (eRR) {
|
||
humanPlayability.renderReflectsState = { pass: true, checked: 0, emptyButShouldHaveItem: [], note: '取 canvas 像素异常(advisory 跳过):' + String((eRR && eRR.message) || eRR) };
|
||
}
|
||
|
||
// 守卫C:Phaser 掌帧增量(@60fps 理论 ~30/500ms;区间 [20,45])。
|
||
const fd = await frameDelta(cdp, 500);
|
||
guards.C_frame = { pass: fd.delta != null && fd.delta >= 20 && fd.delta <= 45, detail: `Δframe=${fd.delta}`, ...fd };
|
||
|
||
// 真玩主路径(赢路径):business-sim driver「点合成→凑齐订单物品→交单→看金币涨」。每步采帧哈希供守卫E。
|
||
const hashes = [px0.hash];
|
||
let driveRes = { drove: 0, steps: 0 };
|
||
if (spec.driver) {
|
||
driveRes = await runDriver(cdp, spec.driver, hashes, { tap, key, drag, readGameState, sampleHash, getPath, delay });
|
||
totalDriven += driveRes.drove || 0;
|
||
} else {
|
||
for (const ev of (spec.inputs || [])) {
|
||
if (ev.t === 'tap') await tap(cdp, ev.x, ev.y);
|
||
else if (ev.t === 'key') await key(cdp, ev.code, ev.downMs);
|
||
else if (ev.t === 'drag') await drag(cdp, ev.from, ev.to, ev.ms);
|
||
else if (ev.t === 'wait') await delay(ev.ms || 200);
|
||
totalDriven++;
|
||
try { hashes.push(await sampleHash(cdp)); } catch (_) {}
|
||
await delay(50);
|
||
}
|
||
}
|
||
await delay(250);
|
||
|
||
// 守卫D:真渲染(输入后再测,取较优)。
|
||
const px1 = await brightPixels(cdp);
|
||
hashes.push(px1.hash);
|
||
const state1 = await readGameState(cdp);
|
||
const bright = Math.max(px0.bright, px1.bright), maxCh = Math.max(px0.maxCh, px1.maxCh);
|
||
guards.D_render = { pass: bright > 50 && maxCh > 80, detail: `bright=${bright} maxCh=${maxCh}`, bright, maxCh };
|
||
|
||
// 守卫E:活性(≥2 个不同画面态 OR 状态进展;driven=false 时降 advisory)。
|
||
const distinct = new Set(hashes);
|
||
const liveByState = stateProgressed(state0, state1);
|
||
guards.E_live = { pass: distinct.size >= 2 || liveByState, detail: `distinct=${distinct.size}/${hashes.length} liveByState=${liveByState}`, distinctStates: distinct.size, samples: hashes.length, liveByState };
|
||
|
||
// 守卫B:未捕获错误。
|
||
const unc = await readUncaught(cdp);
|
||
guards.B_uncaught = { pass: Array.isArray(unc) && unc.length === 0, detail: `uncaught=${(unc || []).length}`, uncaught: unc };
|
||
|
||
// 守卫F:真接线(扫 window.__engineCalls 命中期望前缀;缺省=非空即可)。
|
||
const callTrace = await readEngineCalls(cdp);
|
||
const prefixes = spec.expectedEngineCallPrefixes || [];
|
||
const calls = Array.isArray(callTrace) ? callTrace : [];
|
||
const fHit = prefixes.length
|
||
? calls.some((x) => prefixes.some((p) => String(x).indexOf(p) === 0))
|
||
: calls.length > 0;
|
||
guards.F_wiring = { pass: fHit, detail: `calls=${calls.length} expected=${JSON.stringify(prefixes)}`, callCount: calls.length, sample: calls.slice(0, 12), expected: prefixes };
|
||
|
||
await saveScreenshot(cdp, path.join(evDir, 'after-play.png'));
|
||
|
||
// 守卫G:输入有效性(同 seed 无输入对照实例推进到同帧号,比对整帧哈希)。driven=false 时 SKIP。
|
||
if (driven) {
|
||
let ctrl = null;
|
||
try {
|
||
const endFrame = await readEngineFrame(cdp);
|
||
ctrl = await connect(url, cdpHttp);
|
||
await waitBoot(ctrl, 30000);
|
||
const reached = await advanceToFrame(ctrl, endFrame != null ? endFrame : 0, 20000);
|
||
const ch = await brightPixels(ctrl);
|
||
guards.G_input = { pass: px1.hash !== ch.hash, detail: `输入态${px1.hash}≠对照${ch.hash}@f${endFrame}`, inputHash: px1.hash, controlHash: ch.hash, atFrame: endFrame, ctrlFrame: reached };
|
||
} catch (e2) {
|
||
guards.G_input = { pass: false, detail: 'G 门对照异常:' + String((e2 && e2.message) || e2) };
|
||
} finally {
|
||
if (ctrl) ctrl.close();
|
||
}
|
||
} else {
|
||
guards.G_input = { pass: true, detail: '无 driver/inputs(driven=false,SKIP)', skipped: true };
|
||
}
|
||
|
||
// 守卫H:机制进展 + latch 终态(driven=false 时降 advisory)。
|
||
const asserts = spec.assertAfterPlay || [];
|
||
const expectLatch = spec.expectLatch === true;
|
||
if (asserts.length === 0 && !expectLatch) {
|
||
guards.H_progress = { pass: true, detail: '未声明 assertAfterPlay/expectLatch(向后兼容 SKIP)', skipped: true };
|
||
} else if (!state1 || state1.phase === 'booting') {
|
||
guards.H_progress = { pass: false, detail: '声明机制门但游戏未经 _forensicsView 导出语义 state(可测性红线未满足)', state0, state1 };
|
||
} else {
|
||
const checks = asserts.map((a) => checkAssertion(a, state0, state1));
|
||
let latch = null;
|
||
if (expectLatch) {
|
||
latch = await evalLatchGate(cdp, { terminals: spec.latchTerminals || ['win', 'lose', 'gameover'] });
|
||
}
|
||
const progressPass = checks.every((c) => c.pass);
|
||
const latchPass = !expectLatch || !!(latch && latch.passed);
|
||
guards.H_progress = { pass: progressPass && latchPass, detail: `progress=${progressPass} latch=${latchPass}`, checks, latch, state0, state1 };
|
||
}
|
||
|
||
// 守卫I:控制跟手(声明 controlCheck 才启用;富游戏经营档多为离散点击,缺省 SKIP)。
|
||
if (spec.controlCheck) {
|
||
// 富游戏控制体多为「点目标→选中态变化」,沿用 LittleJS I 门思路但读语义 state 字段(spec.controlCheck.statePath)。
|
||
const cc = spec.controlCheck;
|
||
const before = getPath(await readGameState(cdp), cc.statePath || 'selected');
|
||
if (Array.isArray(cc.tapXs)) for (const tx of cc.tapXs) { await tap(cdp, tx, cc.tapY != null ? cc.tapY : 600); await delay(120); }
|
||
const after = getPath(await readGameState(cdp), cc.statePath || 'selected');
|
||
const changed = JSON.stringify(before) !== JSON.stringify(after);
|
||
guards.I_control = { pass: changed, detail: `控制体 ${cc.statePath || 'selected'} ${changed ? '响应' : '无响应'}`, before, after };
|
||
} else {
|
||
guards.I_control = { pass: true, detail: '未声明 controlCheck(向后兼容 SKIP)', skipped: true };
|
||
}
|
||
|
||
/* ── 富游戏门(D3/A5)· 按品类选(U2)──
|
||
* 富游戏三门(三联动/经济/latch)是 business-sim 经营品类专属语义。品类取自 spec.driver.type(缺省 business-sim)。
|
||
* 仅当品类 ∈ RICHGAME_GATED_ARCHETYPES 才跑这三门;未注册品类 → 三门置「门待建」清晰占位
|
||
* (passed:false + reason),既不静默套用经营门、也不静默放过(richAllPass=false → decision 不会是 accept)。 */
|
||
const archetype = (spec.driver && spec.driver.type) || 'business-sim';
|
||
if (RICHGAME_GATED_ARCHETYPES.has(archetype)) {
|
||
// ① 三联动门:静态(可达+DAG) + 真玩(完单真调 addCoins / 合成真调 consumeIngredient)。
|
||
richGameGates.tripleLink = evalTripleLinkGate(spec.richGame, callTrace, state0, state1);
|
||
|
||
// ② 经济门:盈利路 + 破产路,两路真输入驱动到终态。
|
||
// 赢路 = 上面主驱动的结果(business-sim driver 默认跑赢路);读其终态。
|
||
let winRes = { coins: state1 && state1.coins, phase: state1 && state1.phase, reachedTerminal: false };
|
||
{
|
||
const lg = await evalLatchGate(cdp, { terminals: spec.latchTerminals || ['win', 'lose', 'gameover'] });
|
||
winRes.reachedTerminal = !!(lg.terminalPhase != null);
|
||
winRes.phase = lg.terminalPhase || winRes.phase;
|
||
const sEnd = await readGameState(cdp);
|
||
if (sEnd && typeof sEnd.coins === 'number') winRes.coins = sEnd.coins;
|
||
}
|
||
// 破产路 = fresh 实例跑 driver 的 bankrupt 变体(只合成不交单 / 放任订单流失)。
|
||
let loseRes = { phase: null, reachedTerminal: false, lostStreak: null };
|
||
if (spec.driver && spec.driver.type === 'business-sim' && spec.economy && spec.economy.bankruptPath !== false) {
|
||
let bcdp = null;
|
||
try {
|
||
bcdp = await connect(url, cdpHttp);
|
||
await waitBoot(bcdp, 30000);
|
||
const businessSim = require('./business-sim.driver.js');
|
||
await businessSim.runBankrupt(bcdp, spec.driver, [], { tap, key, drag, readGameState, sampleHash, getPath, delay });
|
||
const lg2 = await evalLatchGate(bcdp, { terminals: ['lose', 'gameover'] });
|
||
const sLose = await readGameState(bcdp);
|
||
loseRes.phase = (sLose && sLose.phase) || lg2.terminalPhase;
|
||
loseRes.reachedTerminal = !!(lg2.terminalPhase != null);
|
||
loseRes.lostStreak = sLose && sLose.lostStreak;
|
||
} catch (eL) {
|
||
loseRes.error = String((eL && eL.message) || eL);
|
||
} finally {
|
||
if (bcdp) bcdp.close();
|
||
}
|
||
} else {
|
||
loseRes.skipped = '未启用破产路(spec.economy.bankruptPath=false 或非 business-sim driver)';
|
||
}
|
||
richGameGates.economy = evalEconomyGate(winRes, loseRes, spec.economy && spec.economy.winThreshold);
|
||
|
||
// ③ latch 门:用赢路的 latch 观测(终态落定不回弹 + 宿主可读)。
|
||
{
|
||
const lg = await evalLatchGate(cdp, { terminals: spec.latchTerminals || ['win', 'lose', 'gameover'] });
|
||
richGameGates.latch = { passed: lg.passed, checks: lg.checks };
|
||
}
|
||
} else {
|
||
// 未注册富游戏门的品类:清晰占位,绝不静默放过(套经营门会误判,放过会假绿)。
|
||
// 加新品类的富游戏门:落进 rich-game-gates.cjs(纯逻辑)+ 本文件真玩部分,并把品类加进
|
||
// RICHGAME_GATED_ARCHETYPES(见 worker/archetypes.py 配方④ + followups「富游戏门是否需按品类抽象」)。
|
||
const pending = (name) => ({
|
||
passed: false,
|
||
checks: [{ name: 'archetypeGatesPending', ok: false,
|
||
detail: `品类「${archetype}」的富游戏门(${name})未接线 —— 门待建,不静默放过(见 archetypes.py 配方④)` }],
|
||
});
|
||
richGameGates.tripleLink = pending('tripleLink');
|
||
richGameGates.economy = pending('economy');
|
||
richGameGates.latch = pending('latch');
|
||
console.log(` ⚠ 品类「${archetype}」无注册富游戏门 → 三门置「门待建」占位(decision 不会 accept)`);
|
||
}
|
||
|
||
// ── 人可玩 advisory ② render-sanity / ③ human-timing(真玩末尾·session 仍活时取)──
|
||
// ② 扫渲染文本漏底([object Object]/undefined/NaN);③ 读数据表 patience 与候选人类阈值比对。全 advisory。
|
||
humanPlayability.renderSanity = await evalRenderSanity(cdp);
|
||
humanPlayability.humanTiming = evalHumanTiming(state1, dataTable);
|
||
|
||
// 玩家取证报告(playReport,编排器对 CDP 捕获数据计算;非 agent 自评)。
|
||
const sFinal = await readGameState(cdp);
|
||
playReport = {
|
||
loaded: true,
|
||
drivenInputs: totalDriven,
|
||
reachedTerminal: !!winRes.reachedTerminal,
|
||
terminalPhase: winRes.phase || (sFinal && sFinal.phase) || null,
|
||
consoleErrors: Array.isArray(unc) ? unc.map(String) : [],
|
||
stateSnapshot: sFinal && typeof sFinal === 'object' ? {
|
||
coins: sFinal.coins, ingredients: sFinal.ingredients, orders: sFinal.orders, phase: sFinal.phase,
|
||
} : null,
|
||
};
|
||
} catch (e) {
|
||
// 真跑异常:A_boot 失败兜底;多次构建/装载不过由上游置 forceKill,本层异常归 runnable_fail。
|
||
guards.A_boot = guards.A_boot || { pass: false, detail: 'boot 异常:' + String((e && e.message) || e) };
|
||
l3Notes.push('harness 主流程异常:' + String((e && e.message) || e));
|
||
if (/超时|timeout/i.test(String((e && e.message) || e))) { /* 超时归 runnable,不强 kill;熔断由编排器判 */ }
|
||
} finally {
|
||
if (cdp) cdp.close();
|
||
}
|
||
|
||
// 组装 tier2-verdict(A5 形状)。
|
||
const verdict = assembleVerdict({
|
||
designId: spec.designId, taskId: spec.taskId, versionId: spec.versionId,
|
||
round, traceId: spec.traceId, evidenceDir: path.relative(process.cwd(), evDir),
|
||
structureOk, guards, richGameGates, playReport, drivenInputs: totalDriven,
|
||
driven, findings: spec.findings, l2Signals: spec.l2Signals, l3Notes,
|
||
forceKill, breakerKind,
|
||
humanPlayability, // 人可玩 advisory 段(单元 A;assembleVerdict 纯附加,不碰 decision)
|
||
});
|
||
|
||
fs.writeFileSync(path.join(evDir, 'verdict.json'), JSON.stringify(verdict, null, 2));
|
||
|
||
// 控制台四件套摘要。
|
||
const g = verdict.layerResults.L1.gateResults;
|
||
console.log(`\n=== tier2 Phaser 真玩判定 [${gameId}] decision=${verdict.decision.toUpperCase()} (L1 ${verdict.layerResults.L1.passed ? '✅' : '❌'}) ===`);
|
||
for (const r of g) console.log(` ${r.passed ? '✅' : (r.fatal ? '❌' : '➖adv')} ${r.gate} ${r.detail || ''}`);
|
||
const rg = verdict.layerResults.L1.richGameGates || {};
|
||
const rgLine = (name, gate) => {
|
||
if (!gate) { console.log(` ◻ 富[${name}] 未执行`); return; }
|
||
console.log(` ${gate.passed ? '✅' : '❌'} 富[${name}] ${(gate.checks || []).map((c) => `${c.name}${c.ok ? '✓' : '✗'}`).join(' ')}`);
|
||
};
|
||
rgLine('三联动', rg.tripleLink); rgLine('经济', rg.economy); rgLine('latch', rg.latch);
|
||
// 人可玩 advisory 摘要(不影响 decision/退出码;➖adv 标识=只报告)。
|
||
const hp = verdict.humanPlayability;
|
||
if (hp) {
|
||
const hpLine = (name, r) => {
|
||
if (!r) { console.log(` ◻ 人玩[${name}] 未执行`); return; }
|
||
console.log(` ${r.pass ? '✅' : '➖adv✗'} 人玩[${name}] ${r.note || ''}`);
|
||
};
|
||
hpLine('渲染反映状态', hp.renderReflectsState);
|
||
hpLine('渲染漏底', hp.renderSanity);
|
||
hpLine('人类反应窗口', hp.humanTiming);
|
||
// 把违规项摘要打出来(advisory,只为人审)。
|
||
const rrs = hp.renderReflectsState;
|
||
if (rrs && Array.isArray(rrs.emptyButShouldHaveItem) && rrs.emptyButShouldHaveItem.length) {
|
||
console.log(` ↳ 有 item 却画空的格:${rrs.emptyButShouldHaveItem.map((e) => `(${e.i},${e.j})=${e.item}`).join(' ')}`);
|
||
}
|
||
const rsn = hp.renderSanity;
|
||
if (rsn && Array.isArray(rsn.violations) && rsn.violations.length) {
|
||
console.log(` ↳ 漏底文本:${rsn.violations.map((v) => v.marker).join(' ')}`);
|
||
}
|
||
const ht = hp.humanTiming;
|
||
if (ht && Array.isArray(ht.violations) && ht.violations.length) {
|
||
console.log(` ↳ 反应窗口过短(<${ht.thresholdMs}ms):${ht.violations.map((v) => `${v.orderId}=${v.patienceMs}ms`).join(' ')}`);
|
||
}
|
||
}
|
||
console.log(` reasons=${JSON.stringify(verdict.reasons)} round=${verdict.round} driven=${driven}`);
|
||
console.log(` evidence → ${path.relative(process.cwd(), evDir)}/`);
|
||
// 退出码只看 decision(人可玩段 advisory,绝不改退出码)。
|
||
process.exit(verdict.decision === 'accept' ? 0 : 1);
|
||
}
|
||
|
||
// 仅作为脚本直接运行时跑 main;被 require 时只导出(便于单测判定函数)。
|
||
if (require.main === module) {
|
||
main();
|
||
}
|
||
|
||
module.exports = {
|
||
PHASER_PROBE,
|
||
HUMAN_MIN_PATIENCE_MS,
|
||
RICHGAME_GATED_ARCHETYPES, // U2:已接富游戏门的品类集合(spike 期唯一 business-sim;其余走「门待建」占位)
|
||
runDriver, // U2:品类 → driver 分发(按 driver.type)
|
||
checkAssertion, getPath, stateProgressed,
|
||
evalTripleLinkGate, evalEconomyGate, evalLatchGate, assembleVerdict,
|
||
// 人可玩 advisory 检查(单元 A;纯函数部分可本机单测)。
|
||
patchStats, colorDist, evalRenderReflectsState, evalRenderSanity, evalHumanTiming,
|
||
};
|