Runner v2 全弧第一相(HJ-RUNNER-ARC-001 / P1):用王蓝莓把"上层游戏宿主装载契约"
从零 spike 到"引擎真掌帧 + 游戏美术与引擎粒子同屏可玩",解锁 P2 泛化 / P3 生成。
契约与共享件
- 新增装载契约 game-runtime/src/core/game-host.d.ts(第9类 additive·暂名):
GameHostBootContext / GameInstance / GameHostFactory;游戏=纯模块工厂,零引擎 import,
能力经下层 ctx.getEngine()(受控面 6 项零改),绘制面 render(g) 的 g=引擎 mainContext
- makeEngineCaps 闭包提升为可 import 模块 host-dev/engine-caps.js(createEngineCaps):
host-dev/ref/未来生成游戏共用;engineMode 短路 / __engineCalls 探针 / null-engine 语义逐行不变
王蓝莓 real 通道(games/wanglanmei-ref)
- 增 ?engine=real:主循环从浏览器 RAF 改挂 engineInit 五回调(掌帧唯一源=引擎);
游戏美术绘制面从 #game 改引擎 mainContext(同屏合并);bundle 延后到 setupReal 内以
mainContext 建(getContext2d 闭包快照修);保 ?engine=stub 逐像素确定性回滚
- game-shop.js 抽为独立 GameHostFactory(零引擎 import,P3 生成 agent 可复制范本)
- entry.js 动态 import('littlejsengine')=node-import-safe ∧ esbuild iife 入包两全
- 驱动 e2e-input.cdp.cjs real 改造:禁 stepFrames 手摇(防双掌帧),真触摸+轮询;
门②同屏像素判据(暖色 warmHits ∧ 引擎粒子 brightHits 同张 #game-engine)
真机验收(mini-desktop real 引擎,5 门)
引擎掌帧 dFrame=31∈[20,45] / 同屏 warmHits=682∧brightHits=13028 同张 #game-engine /
particles.spawnEmitter+synthSfx call-ID 运行时真调引擎 / CDP 517 真触摸跑通整局
(第1天接待18·营收¥79.9·连击9) / console 零错;本机 88 单测全绿(含修复 4 红+补回归)
证据:games/wanglanmei-ref/evidence/p1-real/(e2e-evidence.json pass=true + 5 截图肉眼复核)
诚实 caveat:门② brightHits 在结算屏混入亮 UI,同屏合并由 warmHits(美术落引擎画布)+
call-ID(粒子真调引擎)+ 引擎架构(粒子渲同一 mainContext)三证成立,非阻断。
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
199 lines
8.5 KiB
JavaScript
199 lines
8.5 KiB
JavaScript
/**
|
||
* headless-boot.test.mjs — 无浏览器全链路冒烟(本机最强可跑验证)
|
||
*
|
||
* 用「Proxy 伪 2D 上下文 + window 桩」真跑 bootRef:
|
||
* boot(evidence) → 标题点「开店」→ prep 点「+」进货/点「开门营业」→ 营业期连点货架接待
|
||
* → 跑满一天到日结 → 点「继续」→ 第 2 天 prep 点升级卡 → 断言升级到手。
|
||
* 整条链路走的就是真实 onTap 命中路由 + 真实渲染调用面(每帧 drawFrame 全画),
|
||
* 任何宿主层运行时错误都会在此抛出。像素正确性留集成段浏览器证据(两层测试纪律)。
|
||
*/
|
||
|
||
'use strict';
|
||
|
||
import { test } from 'node:test';
|
||
import assert from 'node:assert/strict';
|
||
|
||
/** 建一个「接受一切调用」的伪 2D 上下文(特定方法给出形状正确的返回值)。 */
|
||
function makeFake2d() {
|
||
const grad = { addColorStop() {} };
|
||
const base = {
|
||
canvas: { width: 780, height: 1688 },
|
||
createLinearGradient: () => grad,
|
||
createRadialGradient: () => grad,
|
||
getImageData: () => ({ data: new Uint8ClampedArray(4), width: 1, height: 1 }),
|
||
putImageData() {},
|
||
measureText: () => ({ width: 0 }),
|
||
};
|
||
const fnCache = new Map();
|
||
return new Proxy(base, {
|
||
get(target, prop) {
|
||
if (prop in target) return target[prop];
|
||
// 其余一律给可调用空函数(fillRect/arc/fillText/setTransform/clip/…)。
|
||
if (!fnCache.has(prop)) fnCache.set(prop, () => {});
|
||
return fnCache.get(prop);
|
||
},
|
||
set(target, prop, v) {
|
||
target[prop] = v; // fillStyle/font/globalAlpha… 任意赋值放行
|
||
return true;
|
||
},
|
||
});
|
||
}
|
||
|
||
/** 装好 window/canvas 桩并 boot。@param {string} search URL 参数 */
|
||
async function bootHeadless(search) {
|
||
globalThis.window = {
|
||
location: { search },
|
||
addEventListener() {},
|
||
requestAnimationFrame() {
|
||
return 0;
|
||
},
|
||
cancelAnimationFrame() {},
|
||
};
|
||
const fake2d = makeFake2d();
|
||
const canvas = {
|
||
width: 0,
|
||
height: 0,
|
||
style: {},
|
||
getContext: () => fake2d,
|
||
addEventListener() {},
|
||
getBoundingClientRect: () => ({ left: 0, top: 0, width: 390, height: 844 }),
|
||
};
|
||
const { bootRef } = await import('../src/main.js');
|
||
const host = await bootRef({ canvas });
|
||
return host;
|
||
}
|
||
|
||
test('无浏览器全链路:boot→开店→进货定价→营业接待→日结→次日升级(真 tap 路由)', async () => {
|
||
const host = await bootHeadless('?mode=evidence&seed=99');
|
||
const F = globalThis.window.__refForensics;
|
||
const C = globalThis.window.__refChecklist;
|
||
assert.equal(host.mode, 'evidence');
|
||
assert.equal(F.probe().verify.ok, true, '六锚链 verifyChain 应通过');
|
||
assert.deepEqual(
|
||
F.probe().anchors.slice(0, 6),
|
||
['t_boot', 't_plugins_ready', 't_game_init', 't_first_paint', 't_input_bound', 't_game_start'],
|
||
'六锚 canonical 顺序'
|
||
);
|
||
assert.equal(F.state().phase, 'title');
|
||
|
||
/** 按 zone id 点一下(取证几何=命中真源)。@param {string} id */
|
||
function tapZone(id) {
|
||
const z = F.geometry().find((zz) => zz.id === id);
|
||
assert.ok(z, `命中区缺失:${id}(当前=${F.geometry().map((x) => x.id).join(',')})`);
|
||
host.tap(z.x, z.y);
|
||
host.stepFrames(2); // 让事件/渲染落帧
|
||
}
|
||
|
||
// ① 标题 → 开店。
|
||
tapZone('newGame');
|
||
assert.equal(F.state().phase, 'prep');
|
||
|
||
// ② 进货:每件商品点「+」若干次(走真实按钮路由)。
|
||
for (const pid of ['soda', 'snack', 'bread']) {
|
||
for (let i = 0; i < 7; i++) tapZone('inc-' + pid);
|
||
}
|
||
assert.ok(F.state().stock.soda >= 6, '进货应到账');
|
||
|
||
// ③ 开门营业。
|
||
tapZone('openShop');
|
||
assert.equal(F.state().phase, 'open');
|
||
|
||
// ④ 营业:每 30 帧把三个货架槽各点一遍(匹配则接待;冷却点击走输入缓冲)。
|
||
let guard = 0;
|
||
while (F.state().phase === 'open' && guard++ < 240) {
|
||
for (const pid of F.state().unlocked) {
|
||
const z = F.geometry().find((zz) => zz.id === 'serve-' + pid);
|
||
if (z) host.tap(z.x, z.y);
|
||
host.stepFrames(6);
|
||
}
|
||
host.stepFrames(12);
|
||
}
|
||
assert.equal(F.state().phase, 'settle', `应到日结(guard=${guard})`);
|
||
const settled = F.state();
|
||
assert.ok(settled.today.served > 0, '当天应有成交');
|
||
|
||
// ⑤ 日结 → 第 2 天 prep。
|
||
tapZone('confirmSettle');
|
||
assert.equal(F.state().phase, 'prep');
|
||
assert.equal(F.state().day, 2);
|
||
|
||
// ⑥ 升级:点「收银加速 I」卡(¥55,首日利润后必买得起;不够就先验余额再断言)。
|
||
const cashBefore = F.state().cash;
|
||
assert.ok(cashBefore >= 550, `第 2 天开市前应买得起最便宜升级(cash=${cashBefore})`);
|
||
tapZone('up-speed1');
|
||
assert.equal(F.state().upgrades.speed1, true, '升级应到手');
|
||
|
||
// ⑦ checklist 自动子项抽查(结构深度/角色壳/手感子项在 node 侧即可断言)。
|
||
assert.equal(C.shendu.loopReachable, true);
|
||
assert.equal(C.shendu.failureReachable, true);
|
||
assert.equal(C.shendu.priceTwoSidedLoss, true);
|
||
assert.ok(C.shougan.tapFeedbackMaxFrames <= 3, `点击反馈帧延迟 ${C.shougan.tapFeedbackMaxFrames} 应 ≤3`);
|
||
assert.ok(C.juese.lineTotal >= 20);
|
||
assert.ok(C.juese.linesShownLive > 0, '过程中应展示过台词');
|
||
// 评估环境无音频:音效种类为 0 属预期(发声留集成段真浏览器)。
|
||
assert.equal(C.yinyue.audioUnlocked, true, 'tap 应触发解锁流程(无 AudioContext 时降级静默)');
|
||
|
||
// ⑧ 取证几何与状态导出形状健全。
|
||
for (const z of F.geometry()) {
|
||
assert.ok(Number.isFinite(z.x) && Number.isFinite(z.hw));
|
||
}
|
||
assert.ok(F.recentEvents().length > 0);
|
||
assert.ok(F.measures().frames > 100);
|
||
});
|
||
|
||
test('无浏览器确定性:同 seed 同驱动脚本 → 状态终点一致', async () => {
|
||
/** 跑固定脚本到营业 300 帧,返回状态快照。 */
|
||
async function run() {
|
||
// 每次重置模块图无必要:bootRef 每次新建实例,互不共享状态(window 桩重建)。
|
||
const host = await bootHeadless('?mode=evidence&seed=777');
|
||
const F = globalThis.window.__refForensics;
|
||
const tap = (id) => {
|
||
const z = F.geometry().find((zz) => zz.id === id);
|
||
if (z) {
|
||
host.tap(z.x, z.y);
|
||
host.stepFrames(2);
|
||
}
|
||
};
|
||
tap('newGame');
|
||
for (const pid of ['soda', 'snack']) for (let i = 0; i < 4; i++) tap('inc-' + pid);
|
||
tap('openShop');
|
||
host.stepFrames(300);
|
||
const s = F.state();
|
||
return JSON.stringify({ cash: s.cash, customers: s.customers, stock: s.stock, rep: s.reputation });
|
||
}
|
||
const a = await run();
|
||
const b = await run();
|
||
assert.equal(a, b, '同 seed 同脚本应逐字段一致(受控时间/随机生效)');
|
||
});
|
||
|
||
test('P4 输入缓冲补打恢复:冷却期点击在窗口内自动补打(mustFix #2 回归守门)', async () => {
|
||
// 【守门意图】本测专钉「双 mockNowMs 致 bundle.clock 冻死=0 → 补打窗口判恒假」缺陷的回归:
|
||
// 修前 host 喂 bundle.clock 的钟恒 0 → 输入事件 tMs=0 → InputBuffer 记 0 → game-shop maybeReplayBufferedServe
|
||
// 用推进的游戏钟减 0 判窗口恒假 → measures.bufferedServeReplays 恒 0。
|
||
// 修后 host 唯一推进时钟既喂 bundle.clock 又(经 ctx.time)供游戏逻辑读 → 同钟同刻 → 窗口判真实生效。
|
||
// 驱动构造(确定性,seed=1 实测 replays=1):serve 一发起冷却 → 推进到冷却尾段(剩余<260ms 进窗)补一发同
|
||
// product(冷却中→buffer)→ 静候冷却清(不点别的)→ 该帧 maybeReplayBufferedServe 用同钟补打成功。
|
||
const host = await bootHeadless('?mode=evidence&seed=1');
|
||
const F = globalThis.window.__refForensics;
|
||
const z = (id) => F.geometry().find((zz) => zz.id === id);
|
||
const tap = (id) => { const g = z(id); if (g) host.tap(g.x, g.y); return !!g; };
|
||
|
||
tap('newGame'); host.stepFrames(2);
|
||
for (const pid of ['soda', 'snack', 'bread']) for (let i = 0; i < 9; i++) { if (tap('inc-' + pid)) host.stepFrames(1); }
|
||
tap('openShop'); host.stepFrames(2);
|
||
assert.equal(F.state().phase, 'open', '应入营业');
|
||
|
||
let guard = 0;
|
||
while (F.state().phase === 'open' && guard++ < 300) {
|
||
for (const pid of F.state().unlocked) {
|
||
const g = z('serve-' + pid);
|
||
if (!g) continue;
|
||
host.tap(g.x, g.y); host.stepFrames(20); // 第一发 serve→冷却;推进到冷却尾段
|
||
host.tap(g.x, g.y); host.stepFrames(18); // 第二发冷却中→buffer;静候冷却清→补打
|
||
}
|
||
host.stepFrames(4);
|
||
}
|
||
const replays = F.measures().bufferedServeReplays;
|
||
assert.ok(replays > 0, `接待缓冲补打应触发(bufferedServeReplays=${replays};恒 0 即双钟分裂回归)`);
|
||
});
|