zizi 3108009500 feat(runner-v2): P3 L0 通用装载壳——L1 生成 worker 的产出靶载体(node 5 门验过)
P3 第一块基石:把 wanglanmei-ref/entry-bundle.js 的「studio-consumable 引擎包入口」泛化为「任意生成游戏」通用壳——L1 worker 只写 generated-factory.js(一个 GameHostFactory),套本壳 esbuild 打 __GameBundle bundle 即得 studio 可消费引擎包,避免 20 个 prompt 各造一遍 7 插件 wiring。

新增(零改既有契约):① src/host/generic-host-config.js=buildGenericHostConfig(标准 7 能力插件集+六锚偏序+390×844 视口+wiring,零wanglanmei专属);② games/_generic/entry-generic.js=通用入口壳(动态 import 引擎 Option C node-safe 与 entry-bundle 同款;调用形 {canvas,seed} 逐字对齐 inject.ts:248;暴露 window.__GameBundle.{GameHostFactory,bootGameHost,default});③ games/_generic/generated-factory.js=占位 stub(L1 出码替换靶)+ P0 latch 终态范本(到 TARGET 置 gameover 驻留不可逆,_forensicsView().state().phase 暴露给 watchGameEnd);④ test/generic-shell.test.mjs=node 侧守门零依赖。

5 门验过(6c6g node,主笔亲跑复核):①esbuild iife __GameBundle.bootGameHost=function ②import-safe(node-load window 零触)③回归 src 161/161+games 57/57 ④B1 raw 203.8KB/gz 69KB ✔ ⑤占位工厂经受控面 init→tap→latch gameover 驻留。dist/ 已 gitignore 不入库。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 10:40:35 +00:00

127 lines
6.6 KiB
JavaScript
Raw Permalink 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.

/**
* generic-shell.test.mjs — P3 generic 通用装载壳 node 侧守门(import-safe + 占位工厂 latch 终态)
* owner:P3/W-G1 生成主线 L0 | 运行:node --test(零依赖,本机 6c6g 可跑;浏览器真渲门留 mini-desktop)
*
* 【本测覆盖三件(本机 node 可验的最强证据;像素级真渲证据留集成段 mini-desktop)】
* ① import-safe:entry-generic.js / generic-host-config.js / generated-factory.js 在 node import 即安全
* (顶层零 window/AudioContext 调用,引擎经动态 import 延后求值,Option C)。导出齐备。
* ② generic-host-config:buildGenericHostConfig 返回标准 7 件插件集 + 偏序 + 390×844 视口 + wiring,且 node 侧可装配。
* ③ 占位工厂 latch 终态:经「真实受控面 bundle(createHostDevContext,node 侧可建,不需 window/引擎)」驱动占位工厂
* init→getInput().on→pointerdown 计分→到 TARGET 进入 gameover 并**驻留**(再点不复活),
* 验 _forensicsView().state().phase 终态 latch(= host.state() 透传给 watchGameEnd 的判据,inject.ts:217)。
*
* 【为何不直接跑 bootGameHost】bootGameHost 真启动需 window/document/RAF/引擎(浏览器面),node 侧不可跑——
* 故 node 侧用 setupCommon 同款手法(建 bundle→factory(opts)→game.init→inputBridge._emit 驱动)验「占位工厂经受控面通道」
* 到 latch 终态;bootGameHost 整壳真启动(含引擎掌帧)的真渲门留 mini-desktop(见 staging-ops.md)。
*/
'use strict';
import { test } from 'node:test';
import assert from 'node:assert/strict';
/* ── ① import-safe + 导出齐备 ───────────────────────────────────────────── */
const MODULES = [
['../../../src/host/generic-host-config.js', ['buildGenericHostConfig', 'GENERIC_VIEWPORT']],
['../generated-factory.js', ['default']],
['../entry-generic.js', ['GameHostFactory', 'bootGameHost', 'default']],
];
for (const [path, expectedExports] of MODULES) {
test(`import-safe:${path} 可在 node 导入且导出齐备(顶层不触 window/引擎)`, async () => {
const mod = await import(path);
for (const name of expectedExports) {
assert.ok(name in mod, `${path} 缺导出 ${name}`);
}
});
}
/* ── ② generic-host-config:标准插件集 + 偏序 + 视口 + wiring ──────────────── */
test('generic-host-config:返回标准 7 件插件集 + 六锚偏序 + 390×844 视口 + wiring(node 侧可装配)', async () => {
const { buildGenericHostConfig, GENERIC_VIEWPORT } = await import('../../../src/host/generic-host-config.js');
const cfg = buildGenericHostConfig({ seed: 42 });
// 标准 7 件插件实例齐备。
for (const k of ['collision', 'physics', 'gamefeel', 'juice', 'palettePost', 'audioMusic', 'save']) {
assert.ok(cfg.plugins[k], `缺标准插件 ${k}`);
}
// 注册偏序 = 7 件(保六锚 canonical 顺序)。
assert.equal(cfg.registerOrder.length, 7, '注册偏序应为 7 件');
// 视口 = P3 钉死 390×844。
assert.deepEqual(cfg.viewport, { w: 390, h: 844 });
assert.deepEqual(GENERIC_VIEWPORT, { w: 390, h: 844 });
// wiring:buildFactoryOpts 经 runtime 暴露 plugins/bundle/getRealAudioCtx。
const fo = cfg.buildFactoryOpts(null, { bundle: { tag: 'b' }, plugins: cfg.plugins, getRealAudioCtx: () => null });
assert.ok(fo.runtime && fo.runtime.plugins && fo.runtime.bundle, 'buildFactoryOpts 应返回 runtime.{plugins,bundle}');
});
/* ── ③ 占位工厂 latch 终态(经真实受控面 bundle 驱动,node 侧可跑)──────────── */
test('占位工厂:经受控面通道 init→tap 计分→到 TARGET 进入 gameover 并驻留(P0 latch 终态)', async () => {
const createGame = (await import('../generated-factory.js')).default;
const { createHostDevContext } = await import('../../../src/core/plugin.js');
// 建真实受控面 bundle(node 侧可建:不需 window/引擎;context2d 给最小桩,绘制非本测验收项)。
// 这是 setupCommon 同款手法(boot-game-host.js:165):bundle.context 即 boot.ctx,inputBridge._emit 即 host.tap 的底层。
const fillRectCalls = [];
const ctx2dStub = {
fillStyle: '',
font: '',
fillRect: (...a) => fillRectCalls.push(a),
fillText: () => {},
save: () => {},
restore: () => {},
setTransform: () => {},
};
let nowMs = 0;
const bundle = createHostDevContext({
context2d: ctx2dStub,
seed: 42,
audioFactory: () => null, // 受控音频:node 侧无 AudioContext,返 null(占位工厂不依赖)
clock: () => nowMs,
// 无 engineFactory:getEngine() 返 null(占位工厂零引擎能力依赖,符合纯净工厂路径)
});
// factory(opts) → game.init(boot)(boot=装载契约脊柱;ctx=受控面,触发占位工厂的 getInput().on 订阅)。
const game = createGame(undefined); // 占位工厂演示纯净工厂:不传 runtime
await game.init({
ctx: bundle.context,
mainContext: ctx2dStub,
canvas: null,
seed: 42,
});
// 初始:init 后 phase=playing,score=0。
let st = game._forensicsView().state();
assert.equal(st.phase, 'playing', 'init 后应为 playing');
assert.equal(st.score, 0);
const TARGET = st.target;
assert.ok(TARGET >= 1, 'target 应 ≥1');
// 经受控输入桥 _emit('pointerdown')(= host.tap 的底层路径)逐次计分,到 TARGET 进终态。
for (let i = 0; i < TARGET; i++) {
nowMs += 16;
bundle.inputBridge._emit('pointerdown', { x: 10, y: 10 });
}
st = game._forensicsView().state();
assert.equal(st.phase, 'gameover', '到 TARGET 分应进入 gameover 终态');
assert.equal(st.score, TARGET, 'gameover 时分数应=TARGET');
assert.equal(st.gameoverReason, 'reached_target_score');
// 【latch 驻留】终态后再点多次:phase 恒为 gameover,分数不再涨(watchGameEnd 任一时刻轮询读到的都是 gameover)。
for (let i = 0; i < 5; i++) {
nowMs += 16;
bundle.inputBridge._emit('pointerdown', { x: 10, y: 10 });
}
st = game._forensicsView().state();
assert.equal(st.phase, 'gameover', 'latch:终态后再点应恒为 gameover(不复活)');
assert.equal(st.score, TARGET, 'latch:终态后分数不再变');
// update/render 可调不抛(绘制面桩可写);render 走到 fillRect(绘制面真被写)。
game.update(1 / 60);
game.render(ctx2dStub);
assert.ok(fillRectCalls.length > 0, 'render 应写绘制面(fillRect 被调)');
// destroy 幂等可调。
game.destroy();
game.destroy();
});