zizi 99b6c82dec feat(runner-v2): P1 王蓝莓上引擎=游戏宿主装载契约 spike 实(mini-desktop real 引擎 5 门全过)
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>
2026-06-13 18:32:50 +00:00

199 lines
8.5 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.

/**
* 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=1serve 一发起冷却 → 推进到冷却尾段(剩余<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 即双钟分裂回归)`);
});