承 A0 引擎掌帧地基,按创始人「引擎↔插件边界模型」(引擎有→薄包装·引擎无→自研补层·
同一职不留两条并行路)接线 A1-A4。host.js 注入真 engineFactory,getEngine() 在 host-dev 真活。
A1 受控面+引擎工厂地基+easing 门面:
- host.js makeEngineCaps 一处构造 EngineCapabilities{particles,audio.synth,math};
- 新 host-dev/engine-math.js 包装引擎 lerp/smoothStep + Ease 曲线(11 条等价曲线门面);
- api.d.ts additive 扩 EngineEasing + EngineMath.easing?(不破已冻 lerp/smoothStep)+ 块级降级措辞校准为 null no-op;
- 受控面 random/time 保确定性(引擎粒子用全局 rand 不可种子对齐→random 留插件自管,真缺件)。
A2 particles 薄包装+删 sim:
- EngineEmitterSpec additive 扩 10 可选字段(count/coneAngle/speed/gravityScale/sizeStart-End/colorStart-End/additive);
- spawnEmitter 改走 getEngine().particles 映射引擎 ParticleEmitter,删自研逐粒子积分 sim;null-engine→no-op 句柄;
- juice 全段(hitStop/屏震/闪白)补层保留;测试转向映射纯单测+真渲出现(逐像素确定性随 sim 退役,§1.4 记账)。
A3 audio 包装+删 vendored+补 ×0.3:
- 删 vendor/zzfx.js+zzfxm.js;audio.synth 包装引擎 zzfxG/zzfxM;
- 播放层补 ×0.3 主音量(引擎样本不烤 0.3,gain 层施)+ SAMPLE_RATE=44100 本地常量;
- impl.js 走 getEngine().audio.synth,null-engine→合成 no-op(不回退 vendored)。
A4 gamefeel easing 改判=门面包装(对抗门源码证伪纠偏):
- easing 改走 getEngine().math.easing 门面(quadIn→Ease.POWER(2) 等),内置降名 builtinEasing 作 null-engine fallback(纯数学族特例);
- backInOut/elasticInOut 引擎 IN_OUT 拼接差 6.6%/17%(另一条曲线)→门面刻意不暴,这二者恒走内置=真缺件补层;
- collision/physics-lite/palette-post 加边界裁定注释(真缺件补层,源码精校:引擎仅 isOverlapping/isIntersecting 返 boolean,无 Manifold/任意几何/RayHit)。
对抗门两逮(均前置于实现·零返工):
- A4 easing 误判 fatal:前轮按小写 quadIn grep 假阴性误判「引擎无 easing」;引擎实有 Ease(esm.js:15118)→创始人拍本波包装;
- collision 注释 under-claim:已精校为源码准措辞。新红线 §1.5:引擎能力普查禁按预期名 grep,须读 export 块/.d.ts。
本机门全绿(亲验):
- node --test 168/168 绿;all-plugins+host-bundle 双 esbuild rc=0(35594B/209516B);
- 真接线门①引擎真入产物 littlejsengine 163641B;门③负扫=0 插件直 import+vendor 已删+0 自研 sim+0 假注释;Q4 引擎 import 仅 entry.js+host.js。
A6(mini-desktop real 像素门 + 真接线门② runtime probe call-ID)待主会话驱动。
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
223 lines
14 KiB
JavaScript
223 lines
14 KiB
JavaScript
/**
|
||
* engine-caps.test.mjs — A1 引擎能力门面单测(node --test 直跑,零真引擎依赖)
|
||
* owner:T1b-β A1 | 运行:node --test host-dev/test/engine-caps.test.mjs
|
||
*
|
||
* ════════════════════════════════════════════════════════════════════════════
|
||
* 【测什么 / 为什么这样测】
|
||
* A1 落地:① host-dev/engine-math.js 的 buildEngineMath(包装引擎 lerp/smoothStep/Ease 成 math 门面,
|
||
* 含 easing 11 条等价曲线);② host.js 工厂 makeEngineCaps 注入 createHostDevContext.engineFactory
|
||
* → getEngine() 在 real 通道返三面、stub/无工厂返 null(plugin.js getEngineShared 已就绪,A1 只接线)。
|
||
*
|
||
* 【F7 铁律】`import * as LJS from 'littlejsengine'` 在 node 顶层抛 `window is not defined`(已实测)
|
||
* → 本测**不 import 真引擎、不 import host.js**(host.js 顶层 import 引擎,node 同样 import 不了)。
|
||
* 故:
|
||
* · 测门面映射正确性 → 注入**内联引擎 Ease 曲线的 mock ljs**(逐字节复制 littlejs.esm.js:15118,
|
||
* 下方 makeEngineOracle 标注源行号)作 oracle,比对 buildEngineMath 产出的门面逐点等价。
|
||
* 这不是掏空——是把「门面映射对不对 + 字节恒等/ULP 边界 + backInOut/elasticInOut 排除」钉死的正面锚。
|
||
* · 测工厂注入/getEngine 接线 → 用真 createHostDevContext(plugin.js,零引擎),注入「返回 buildEngineMath
|
||
* 门面的 mock 工厂」验 getEngine().math.easing 通;无工厂验 getEngine()==null(real runtime 契约)。
|
||
* 集成段 real 通道「门面真调真引擎」的证据由门②(mini-desktop CDP __engineCalls)兜(A1 不做,主会话事后)。
|
||
* ════════════════════════════════════════════════════════════════════════════
|
||
*/
|
||
|
||
import { test } from 'node:test';
|
||
import assert from 'node:assert/strict';
|
||
|
||
import { buildEngineMath, clampUnit } from '../engine-math.js';
|
||
import { createHostDevContext } from '../../src/core/plugin.js';
|
||
// gamefeel 内置 easing(A4 的 null-engine fallback):T-A1-c 用它比对门面越界钳制一致。
|
||
import { easing as gamefeelEasing } from '../../src/plugins/gamefeel/impl.js';
|
||
|
||
/* ──────────────────────────────────────────────────────────────────────────
|
||
* 引擎 Ease oracle(逐字节复制 littlejs.esm.js:15118 `const Ease`,不 import 真引擎,规避 F7)。
|
||
* lerp/smoothStep 同复制 esm.js:1444/:1500。每处标源行号,复审可逐字节复验。
|
||
* ────────────────────────────────────────────────────────────────────────── */
|
||
function makeEngineOracle() {
|
||
// esm.js:1444 lerp(自带 percent 钳制:clamp(percent))
|
||
const clamp = (v, min = 0, max = 1) => (v < min ? min : v > max ? max : v);
|
||
const lerp = (valueA, valueB, percent) => valueA + clamp(percent) * (valueB - valueA);
|
||
// esm.js:1500 smoothStep
|
||
const smoothStep = (percent) => percent * percent * (3 - 2 * percent);
|
||
// esm.js:15118 const Ease(仅复制 A1 门面用到的曲线 + 修饰器)
|
||
const PI = Math.PI;
|
||
const sin = Math.sin;
|
||
const Ease = {
|
||
LINEAR: (x) => x,
|
||
POWER: (n) => (x) => x ** n,
|
||
BACK: (x) => x * x * (2.70158 * x - 1.70158),
|
||
ELASTIC: (x) =>
|
||
x === 0 ? 0 :
|
||
x === 1 ? 1 :
|
||
-(2 ** (10 * x - 10)) * sin(((37 - 40 * x) * PI) / 6),
|
||
IN: (f) => f,
|
||
OUT: (f) => (x) => 1 - f(1 - x),
|
||
IN_OUT: (f) => Ease.PIECEWISE(f, Ease.OUT(f)),
|
||
PIECEWISE: (...fns) => {
|
||
const n = fns.length;
|
||
return (x) => {
|
||
const i = (x * n - 1e-9) >> 0;
|
||
return (fns[i]((x - i / n) * n) + i) / n;
|
||
};
|
||
},
|
||
};
|
||
return { lerp, smoothStep, Ease };
|
||
}
|
||
|
||
/** 门面 easing 键 → oracle 引擎曲线(同 A1 映射;用于逐点比对)。 */
|
||
function oracleEasingMap(oracle) {
|
||
const E = oracle.Ease;
|
||
return {
|
||
linear: E.LINEAR,
|
||
quadIn: E.POWER(2),
|
||
quadOut: E.OUT(E.POWER(2)),
|
||
quadInOut: E.IN_OUT(E.POWER(2)),
|
||
cubicIn: E.POWER(3),
|
||
cubicOut: E.OUT(E.POWER(3)),
|
||
cubicInOut: E.IN_OUT(E.POWER(3)),
|
||
backIn: E.BACK,
|
||
backOut: E.OUT(E.BACK),
|
||
elasticIn: E.ELASTIC,
|
||
elasticOut: E.OUT(E.ELASTIC),
|
||
};
|
||
}
|
||
|
||
const EASING_11 = [
|
||
'linear', 'quadIn', 'quadOut', 'quadInOut',
|
||
'cubicIn', 'cubicOut', 'cubicInOut',
|
||
'backIn', 'backOut', 'elasticIn', 'elasticOut',
|
||
];
|
||
|
||
/* ════════════════════════════════════════════════════════════════════════════
|
||
* T-A1-a:工厂注入 / getEngine 接线(真 createHostDevContext,零引擎)
|
||
* ════════════════════════════════════════════════════════════════════════════ */
|
||
|
||
test('T-A1-a|无 engineFactory(stub/node 短路语义):getEngine() 返 null(plugin.js 容错降级路径成立)', () => {
|
||
// host.js makeEngineCaps 在 stub 通道短路返 null ≡「createHostDevContext 不收工厂」→ getEngine()==null。
|
||
const bundle = createHostDevContext({ seed: 1 });
|
||
assert.equal(bundle.context.getEngine(), null, '无工厂时 getEngine() 必须返 null(不抛错)');
|
||
// 再调一次仍 null(lazy 单例 + 告警闸不影响返回值)。
|
||
assert.equal(bundle.context.getEngine(), null);
|
||
});
|
||
|
||
test('T-A1-a|有 engineFactory(real 通道注入语义):getEngine() 返三面、math.easing 门面在', () => {
|
||
const oracle = makeEngineOracle();
|
||
// 模拟 host.js makeEngineCaps real 分支:math=buildEngineMath(LJS),particles/audio.synth 空骨架占位。
|
||
const fakeFactory = () => ({
|
||
math: buildEngineMath(oracle),
|
||
particles: { spawnEmitter: () => ({ isActive: () => false, stop: () => {} }) },
|
||
audio: { synth: { synthSfx: () => null, synthSong: () => null } },
|
||
});
|
||
const bundle = createHostDevContext({ seed: 1, engineFactory: fakeFactory });
|
||
const eng = bundle.context.getEngine();
|
||
assert.ok(eng, 'getEngine() 应返真能力面');
|
||
assert.ok(eng.math && typeof eng.math.lerp === 'function', 'math.lerp 在');
|
||
assert.ok(eng.math.easing && typeof eng.math.easing.quadIn === 'function', 'math.easing.quadIn 在');
|
||
// 工厂只调一次(lazy 单例):第二次 getEngine 返同一对象。
|
||
assert.equal(bundle.context.getEngine(), eng, 'getEngine 应 lazy 单例(同一实例)');
|
||
});
|
||
|
||
/* ════════════════════════════════════════════════════════════════════════════
|
||
* T-A1-b:easing 门面 11 条曲线逐点等价引擎 Ease oracle + backInOut/elasticInOut 排除
|
||
* ════════════════════════════════════════════════════════════════════════════ */
|
||
|
||
test('T-A1-b|easing 门面 11 条逐点等价引擎 Ease(quad 字节恒等 / 余 ULP)+ 二条 inOut 不在门面', () => {
|
||
const oracle = makeEngineOracle();
|
||
const math = buildEngineMath(oracle);
|
||
const oracleMap = oracleEasingMap(oracle);
|
||
|
||
// 逐点扫 x∈[0,1] 共 201 点。门面内部钳 t,与 oracle 在 [0,1] 内同曲线 → 比内点。
|
||
for (const name of EASING_11) {
|
||
const fac = math.easing[name];
|
||
const orc = oracleMap[name];
|
||
assert.equal(typeof fac, 'function', `门面应有 ${name}`);
|
||
let maxDiff = 0;
|
||
let byteEqualAll = true;
|
||
for (let i = 0; i <= 200; i++) {
|
||
const x = i / 200;
|
||
const a = fac(x);
|
||
const b = orc(x);
|
||
const d = Math.abs(a - b);
|
||
if (d > maxDiff) maxDiff = d;
|
||
if (!Object.is(a, b)) byteEqualAll = false;
|
||
}
|
||
// 全 11 条至少 ULP 等价(<1e-9,§0.1 实测 maxAbsDiff ≤ 2.1e-15)。
|
||
assert.ok(maxDiff < 1e-9, `${name} 门面与引擎 oracle 应 ULP 等价,实测 maxDiff=${maxDiff}`);
|
||
// quadIn/quadOut 应逐字节恒等(§0.1 实测 2001/2001 字节恒等)。
|
||
if (name === 'quadIn' || name === 'quadOut') {
|
||
assert.ok(byteEqualAll, `${name} 应与引擎 oracle 逐字节恒等`);
|
||
}
|
||
}
|
||
|
||
// backInOut/elasticInOut 刻意不入门面(引擎 IN_OUT 拼接是另一条曲线,差 6.6%/17%,非 ULP)。
|
||
assert.equal(math.easing.backInOut, undefined, 'backInOut 不应在门面(引擎无此等价曲线)');
|
||
assert.equal(math.easing.elasticInOut, undefined, 'elasticInOut 不应在门面(引擎无此等价曲线)');
|
||
});
|
||
|
||
/* ════════════════════════════════════════════════════════════════════════════
|
||
* T-A1-c:easing 门面越界钳制 == gamefeel 内置(fallback 行为一致,防 A4 改门面后回归)
|
||
* ════════════════════════════════════════════════════════════════════════════ */
|
||
|
||
test('T-A1-c|easing 门面越界钳制:fn(-0.5)===fn(0)、fn(1.5)===fn(1),且与 gamefeel 内置越界逐值一致', () => {
|
||
const oracle = makeEngineOracle();
|
||
const math = buildEngineMath(oracle);
|
||
for (const name of EASING_11) {
|
||
const fn = math.easing[name];
|
||
// 门面自身越界钳制:越下界 ≡ f(0),越上界 ≡ f(1)。
|
||
assert.ok(Object.is(fn(-0.5), fn(0)), `${name}(-0.5) 应 === ${name}(0)`);
|
||
assert.ok(Object.is(fn(1.5), fn(1)), `${name}(1.5) 应 === ${name}(1)`);
|
||
// 与 gamefeel 内置 fallback 的越界行为逐值一致(gamefeel 同名曲线对越界亦 clamp01)。
|
||
const gf = gamefeelEasing[name];
|
||
assert.equal(typeof gf, 'function', `gamefeel 应有同名 ${name}`);
|
||
// 越界点门面与内置应同值(二者都钳到端点,端点值 ULP 等价)。
|
||
assert.ok(Math.abs(fn(-0.5) - gf(-0.5)) < 1e-9, `${name} 门面与内置在 t=-0.5 应等价`);
|
||
assert.ok(Math.abs(fn(1.5) - gf(1.5)) < 1e-9, `${name} 门面与内置在 t=1.5 应等价`);
|
||
}
|
||
});
|
||
|
||
/* ════════════════════════════════════════════════════════════════════════════
|
||
* T-A1-d:easing 门面端点恒等 f(0)=0 / f(1)=1(11 条全)
|
||
* ════════════════════════════════════════════════════════════════════════════ */
|
||
|
||
test('T-A1-d|easing 门面端点恒等:11 条全 f(0)=0、f(1)=1(与 gamefeel 测同口径 <1e-9)', () => {
|
||
const oracle = makeEngineOracle();
|
||
const math = buildEngineMath(oracle);
|
||
for (const name of EASING_11) {
|
||
const fn = math.easing[name];
|
||
assert.ok(Math.abs(fn(0) - 0) < 1e-9, `${name}(0) 应 ≈ 0,实测 ${fn(0)}`);
|
||
assert.ok(Math.abs(fn(1) - 1) < 1e-9, `${name}(1) 应 ≈ 1,实测 ${fn(1)}`);
|
||
}
|
||
});
|
||
|
||
/* ════════════════════════════════════════════════════════════════════════════
|
||
* T-A1-e:lerp/smoothStep 门面口径(含引擎 lerp 自带 percent 钳制)
|
||
* ════════════════════════════════════════════════════════════════════════════ */
|
||
|
||
test('T-A1-e|lerp/smoothStep 门面逐值等价引擎 + lerp 自钳 percent', () => {
|
||
const oracle = makeEngineOracle();
|
||
const math = buildEngineMath(oracle);
|
||
// smoothStep 逐点比 oracle。
|
||
for (let i = 0; i <= 100; i++) {
|
||
const p = i / 100;
|
||
assert.ok(Math.abs(math.smoothStep(p) - oracle.smoothStep(p)) < 1e-12, `smoothStep(${p}) 应等价`);
|
||
}
|
||
// lerp 内点逐值比。
|
||
assert.equal(math.lerp(0, 10, 0.5), 5, 'lerp(0,10,0.5)=5');
|
||
assert.equal(math.lerp(2, 4, 0), 2, 'lerp 起点');
|
||
assert.equal(math.lerp(2, 4, 1), 4, 'lerp 终点');
|
||
// 引擎 lerp 自带 percent 钳制(clamp(percent)):越界 percent 被钳到 [0,1]。
|
||
assert.equal(math.lerp(0, 10, -1), 0, 'lerp percent<0 钳到 0 → 起点');
|
||
assert.equal(math.lerp(0, 10, 2), 10, 'lerp percent>1 钳到 1 → 终点');
|
||
});
|
||
|
||
/* ════════════════════════════════════════════════════════════════════════════
|
||
* 辅助:clampUnit 自身(门面钳制基元)
|
||
* ════════════════════════════════════════════════════════════════════════════ */
|
||
|
||
test('辅助|clampUnit 钳到 [0,1]', () => {
|
||
assert.equal(clampUnit(-0.5), 0);
|
||
assert.equal(clampUnit(1.5), 1);
|
||
assert.equal(clampUnit(0.3), 0.3);
|
||
assert.equal(clampUnit(0), 0);
|
||
assert.equal(clampUnit(1), 1);
|
||
});
|