zizi e591e9b705 feat(t1b-beta): A1-A4 引擎能力接线落地——受控面工厂/粒子/音频/手感经 getEngine 薄包装(easing 改判)
承 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>
2026-06-13 11:48:28 +00:00

223 lines
14 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.

/**
* engine-caps.test.mjs — A1 引擎能力门面单测node --test 直跑,零真引擎依赖)
* ownerT1b-β 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/无工厂返 nullplugin.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 接线 → 用真 createHostDevContextplugin.js零引擎注入「返回 buildEngineMath
* 门面的 mock 工厂」验 getEngine().math.easing 通;无工厂验 getEngine()==nullreal runtime 契约)。
* 集成段 real 通道「门面真调真引擎」的证据由门②mini-desktop CDP __engineCallsA1 不做,主会话事后)。
* ════════════════════════════════════════════════════════════════════════════
*/
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 内置 easingA4 的 null-engine fallbackT-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无 engineFactorystub/node 短路语义getEngine() 返 nullplugin.js 容错降级路径成立)', () => {
// host.js makeEngineCaps 在 stub 通道短路返 null ≡「createHostDevContext 不收工厂」→ getEngine()==null。
const bundle = createHostDevContext({ seed: 1 });
assert.equal(bundle.context.getEngine(), null, '无工厂时 getEngine() 必须返 null不抛错');
// 再调一次仍 nulllazy 单例 + 告警闸不影响返回值)。
assert.equal(bundle.context.getEngine(), null);
});
test('T-A1-a有 engineFactoryreal 通道注入语义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-beasing 门面 11 条曲线逐点等价引擎 Ease oracle + backInOut/elasticInOut 排除
* ════════════════════════════════════════════════════════════════════════════ */
test('T-A1-beasing 门面 11 条逐点等价引擎 Easequad 字节恒等 / 余 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-ceasing 门面越界钳制 == gamefeel 内置fallback 行为一致,防 A4 改门面后回归)
* ════════════════════════════════════════════════════════════════════════════ */
test('T-A1-ceasing 门面越界钳制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-deasing 门面端点恒等 f(0)=0 / f(1)=111 条全)
* ════════════════════════════════════════════════════════════════════════════ */
test('T-A1-deasing 门面端点恒等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-elerp/smoothStep 门面口径(含引擎 lerp 自带 percent 钳制)
* ════════════════════════════════════════════════════════════════════════════ */
test('T-A1-elerp/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);
});