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>
127 lines
6.6 KiB
JavaScript
127 lines
6.6 KiB
JavaScript
/**
|
||
* 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();
|
||
});
|