games-development-ai/tier2/harness/play-phaser.cdp.cjs
zizi 5b35e61f89 feat(tier2): B门-P3 装载与品类——落库seam+versionId+projectType分流 + 品类可插拔机制
【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>
2026-06-24 04:49:59 +00:00

1033 lines
65 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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,
};