lili 33d9cd62ad feat(adapter): 时间压力维度——新增 elapsed 相对秒,支撑 time-based win/lose 规则
修一处潜在语义错 + 补休闲玩法时间压力维度(限时/倒计时):
- 旧 state.t = nowMs/1000 是【绝对单调时】(受控面明禁跨设备比绝对值),用作 `t >= timeLimit` 规则会因时钟原点(performance.now/Date.now)立判或漂移——错
- 新增 state.elapsed = (now - startMs)/1000(init 记 startMs,相对秒),forensics 暴露 elapsed;规则 `elapsed >= config.timeLimit → win/lose` 正确(零新增 op,正交叠加任一族)
- 单测经注入时钟(createHostDevContext({clock}))验:init@1s 起点,clockMs=3000 时 elapsed=2(相对,非绝对 3)仍 playing,clockMs=4500 时 elapsed=3.5 触发 lose——证相对非绝对

验证:src 全量 197/197 绿(+1 时间压力);additive 改动经回归九门(tap-targets 重建探针 A–I 全绿,H score:0→3+latch)确认不破坏既有 6 族,探针已清场。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 15:10:52 -07:00

439 lines
20 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.

/**
* adapter.test.mjs —— E1 2D 适配器零依赖 node 单测node --test 直跑)
* owner前端+引擎线Mac计划 U1最小贯穿实验运行node --test src/adapter/adapter.test.mjs
*
* 验「声明式 gameDefinition → GameInstance」端到端可行接缝形态验证
* 1. init → phase=playing、目标建好、score=0
* 2. render 画到入参 g满幅底 + 目标 + HUD
* 3. tap 命中目标 → score++ + occupied输入因果对应九门 G/H
* 4. tap 落空 → 不计分;
* 5. 命中全部 → rule 评估 → phase=gameover + result=win + latch终态驻留对应九门 H latch
* 6. destroy 幂等。
* 测试基建createHostDevContext 真受控面getInput/time+ inputBridge._emit 注入合成 pointerdown + 录制版 2D 上下文。
*/
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import { adaptGameDefinition } from './create-game-from-definition.js';
import { createHostDevContext } from '../core/plugin.js';
const HERE = dirname(fileURLToPath(import.meta.url));
const SOURCE = JSON.parse(readFileSync(join(HERE, 'fixtures', 'tap-target.source.json'), 'utf8'));
const DRIFT_SOURCE = JSON.parse(readFileSync(join(HERE, 'fixtures', 'moving-targets.source.json'), 'utf8'));
const CATCH_SOURCE = JSON.parse(readFileSync(join(HERE, 'fixtures', 'catch.source.json'), 'utf8'));
const FLAPPY_SOURCE = JSON.parse(readFileSync(join(HERE, 'fixtures', 'flappy.source.json'), 'utf8'));
const SEEK_SOURCE = JSON.parse(readFileSync(join(HERE, 'fixtures', 'seek.source.json'), 'utf8'));
const AVOID_SOURCE = JSON.parse(readFileSync(join(HERE, 'fixtures', 'avoid.source.json'), 'utf8'));
/** 录制版 2D 上下文:记 fillRect/fillText 调用setter 容纳 fillStyle/font/strokeStyle 赋值。 */
function recordingCtx2d() {
const rec = { fillRects: [], texts: [] };
return {
set fillStyle(_v) {}, get fillStyle() { return ''; },
set strokeStyle(_v) {}, get strokeStyle() { return ''; },
set font(_v) {}, get font() { return ''; },
set lineWidth(_v) {}, get lineWidth() { return 1; },
fillRect(x, y, w, h) { rec.fillRects.push({ x, y, w, h }); },
fillText(s) { rec.texts.push(String(s)); },
strokeRect() {}, beginPath() {}, arc() {}, stroke() {}, moveTo() {}, lineTo() {},
_rec: rec,
};
}
/** 造 boot 上下文(真受控面 + 录制 mainContext) + 暴露 inputBridge 注入合成输入。 */
function makeBoot() {
const { context, inputBridge } = createHostDevContext();
const g = recordingCtx2d();
return { boot: { ctx: context, mainContext: g, canvas: {}, seed: 1 }, g, inputBridge };
}
/** fixture 目标中心点(pos + w/2;w=h=64 → +32)。 */
function center(idx) {
const e = SOURCE.gameDefinition.entities[idx];
return { x: e.transform.position.x + 32, y: e.transform.position.y + 32 };
}
test('init → playing, 目标建好, score=0', () => {
const game = adaptGameDefinition(SOURCE)();
const { boot } = makeBoot();
game.init(boot);
const v = game._forensicsView().state();
assert.equal(v.phase, 'playing');
assert.equal(v.score, 0);
assert.equal(v.targets.length, 3);
assert.equal(v.targets[0].occupied, false);
assert.equal(v.tickModel, 'event');
});
test('remaining随 score 上升而下降, win 时归 0(对齐 HUD 进展契约)', () => {
const game = adaptGameDefinition(SOURCE)();
const { boot, inputBridge } = makeBoot();
game.init(boot);
assert.equal(game._forensicsView().state().remaining, 3); // winScore 3, score 0
inputBridge._emit('pointerdown', center(0));
assert.equal(game._forensicsView().state().remaining, 2);
inputBridge._emit('pointerdown', center(1));
inputBridge._emit('pointerdown', center(2));
assert.equal(game._forensicsView().state().remaining, 0);
});
test('render 画满幅底 + 目标 + HUD', () => {
const game = adaptGameDefinition(SOURCE)();
const { boot, g } = makeBoot();
game.init(boot);
game.render(g);
// 满幅底(1) + 脉冲条(1) + 3 目标 = ≥5 个 fillRectHUD 含 "Score:" 文本
assert.ok(g._rec.fillRects.length >= 5, 'fillRect 调用应 ≥5');
assert.ok(g._rec.texts.some((s) => s.startsWith('Score:')), 'HUD 应有 Score 文本');
});
test('tap 命中目标 → score++ + occupied(输入因果 G/H)', () => {
const game = adaptGameDefinition(SOURCE)();
const { boot, inputBridge } = makeBoot();
game.init(boot);
const c0 = center(0);
inputBridge._emit('pointerdown', { x: c0.x, y: c0.y });
const v = game._forensicsView().state();
assert.equal(v.score, 1);
assert.equal(v.targets[0].occupied, true);
});
test('tap 落空(远离所有目标) → 不计分', () => {
const game = adaptGameDefinition(SOURCE)();
const { boot, inputBridge } = makeBoot();
game.init(boot);
inputBridge._emit('pointerdown', { x: 5, y: 820 });
assert.equal(game._forensicsView().state().score, 0);
});
test('命中全部 → rule win → phase=gameover + latch 终态驻留', () => {
const game = adaptGameDefinition(SOURCE)();
const { boot, inputBridge } = makeBoot();
game.init(boot);
for (let i = 0; i < 3; i++) { const c = center(i); inputBridge._emit('pointerdown', { x: c.x, y: c.y }); }
game.update(0.016); // 规则在 update 评估
let v = game._forensicsView().state();
assert.equal(v.score, 3);
assert.equal(v.phase, 'gameover');
assert.equal(v.result, 'win');
// latch终态后再点 + update 不改终态
const c0 = center(0);
inputBridge._emit('pointerdown', { x: c0.x, y: c0.y });
game.update(0.016);
v = game._forensicsView().state();
assert.equal(v.phase, 'gameover');
assert.equal(v.score, 3);
});
test('destroy 幂等', () => {
const game = adaptGameDefinition(SOURCE)();
const { boot } = makeBoot();
game.init(boot);
game.destroy();
game.destroy();
assert.equal(game._forensicsView().state().targets.length, 0);
});
test('drift-targetsupdate 后目标漂移(realtime/update 行为泛化)', () => {
const game = adaptGameDefinition(DRIFT_SOURCE)();
const { boot } = makeBoot();
game.init(boot);
const before = game._forensicsView().state().targets.map((t) => t.x);
game.update(0.1);
game.update(0.1);
const after = game._forensicsView().state().targets.map((t) => t.x);
assert.ok(after.some((x, i) => x !== before[i]), '至少一个目标 x 应漂移(update 行为生效)');
});
test('drift 下 tap 当前(漂移后)位仍命中计分', () => {
const game = adaptGameDefinition(DRIFT_SOURCE)();
const { boot, inputBridge } = makeBoot();
game.init(boot);
game.update(0.1);
const t0 = game._forensicsView().state().targets[0]; // 当前漂移后中心
inputBridge._emit('pointerdown', { x: t0.x, y: t0.y });
assert.equal(game._forensicsView().state().score, 1);
});
test('lose 规则:连点空达 maxMisses → phase=gameover + result=lose', () => {
const game = adaptGameDefinition(SOURCE)();
const { boot, inputBridge } = makeBoot();
game.init(boot);
for (let i = 0; i < 3; i++) inputBridge._emit('pointerdown', { x: 5, y: 820 }); // 3 次点空(远离所有目标)
game.update(0.016); // 规则评估
const v = game._forensicsView().state();
assert.equal(v.misses, 3);
assert.equal(v.phase, 'gameover');
assert.equal(v.result, 'lose');
});
test('win 优先于 lose先达 winScore 即 win(规则序)', () => {
const game = adaptGameDefinition(SOURCE)();
const { boot, inputBridge } = makeBoot();
game.init(boot);
const c = (i) => { const e = SOURCE.gameDefinition.entities[i]; return { x: e.transform.position.x + 32, y: e.transform.position.y + 32 }; };
for (let i = 0; i < 3; i++) { const p = c(i); inputBridge._emit('pointerdown', { x: p.x, y: p.y }); }
game.update(0.016);
assert.equal(game._forensicsView().state().result, 'win');
});
test('时间压力族elapsed >= config.timeLimit 规则 + 注入时钟 → 到时 lose(零新增 op,复用 rule 机制)', () => {
// 注入可控时钟,验 time-based 规则:elapsed 是【相对秒】(init 起点起算),非 state.t 的绝对单调值。
let clockMs = 0;
const { context } = createHostDevContext({ clock: () => clockMs });
const src = {
gameDefinition: {
entities: [{ id: 't', transform: { position: { x: 100, y: 200 } }, components: ['r'] }],
components: [{ id: 'r', kind: 'render', shape: 'rect', w: 50, h: 50, color: '#3cf' }],
behaviors: [{ id: 'b', trigger: 'input', op: 'hit-nearest-target' }],
scenes: [{ id: 's', entityRefs: ['t'] }],
rules: [{ id: 'timeout', condition: 'elapsed >= config.timeLimit', outcome: 'lose' }],
},
config: { timeLimit: 3 },
};
const game = adaptGameDefinition(src)();
clockMs = 1000; // init 在绝对 t=1s 记起点 → 验 elapsed 用相对差(否则绝对 1>... 立判,本例必假阴/假阳)
game.init({ ctx: context, mainContext: recordingCtx2d(), canvas: {}, seed: 1 });
clockMs = 3000; game.update(0.1); // elapsed=(3000-1000)/1000=2 < 3 → 未到时(若误用绝对 t=3 则会错误触发)
assert.equal(game._forensicsView().state().phase, 'playing', '未到时限应仍 playing');
assert.ok(Math.abs(game._forensicsView().state().elapsed - 2) < 1e-6, 'elapsed 应=相对 2 秒(非绝对 3)');
clockMs = 4500; game.update(0.1); // elapsed=3.5 >= 3 → 到时
const v = game._forensicsView().state();
assert.equal(v.phase, 'gameover');
assert.equal(v.result, 'lose');
});
test('E3assets[] 入 forensics(通道可观测)+ spec-only 几何兜底不崩', () => {
const game = adaptGameDefinition(SOURCE)();
const { boot, g } = makeBoot();
game.init(boot);
const v = game._forensicsView().state();
assert.ok(Array.isArray(v.assets) && v.assets.length >= 1, 'forensics 应暴露 assets[]');
assert.equal(v.assets[0].category, 'sprite');
// spec-only(无 url)+ node 无 Image → 几何兜底:render 不崩、仍画目标矩形。
game.render(g);
assert.ok(g._rec.fillRects.length >= 5, '几何兜底仍画目标');
});
test('E3scene 类资产 → 背景消费(node/无 url 默认底兜底不崩)+ assets[] 可观测 scene 类', () => {
const src = {
gameDefinition: {
entities: [{ id: 't', transform: { position: { x: 100, y: 200 } }, components: ['r'] }],
components: [{ id: 'r', kind: 'render', shape: 'rect', w: 50, h: 50, color: '#3cf' }],
behaviors: [{ id: 'b', trigger: 'input', op: 'hit-nearest-target' }],
scenes: [{ id: 's', entityRefs: ['t'] }], rules: [],
},
assets: [{ id: 'bg1', category: 'scene', ref: 'asset://bg1', url: 'https://example.com/bg.png' }],
config: {},
};
const game = adaptGameDefinition(src)();
const { boot, g } = makeBoot();
game.init(boot);
// node 无 Image → bgImg null → 默认底色兜底:render 不崩、仍画满幅底(D 门)。
assert.doesNotThrow(() => game.render(g));
assert.ok(g._rec.fillRects.length >= 1, '默认底色兜底仍画满幅');
assert.ok(game._forensicsView().state().assets.some((a) => a.category === 'scene'), 'forensics assets[] 应含 scene 类');
});
test('E3scene 背景有 url + 已加载 → render 走 drawImage 满幅(浏览器路;Image stub 验分支)', () => {
// node 无 Image → 以最小 Image stub 模拟浏览器侧已加载图,验 render 走 drawImage(0,0,满幅) 分支(非兜底)。
const prevImage = globalThis.Image;
globalThis.Image = class { constructor() { this.complete = true; this.naturalWidth = 64; this.naturalHeight = 64; } set src(_v) {} };
try {
const src = {
gameDefinition: { entities: [], components: [], behaviors: [], scenes: [], rules: [] },
assets: [{ id: 'bg1', category: 'scene', ref: 'asset://bg1', url: 'data:image/gif;base64,R0lGODlhAQABAAAAACw=' }],
config: {},
};
const game = adaptGameDefinition(src)();
const { boot } = makeBoot();
const g = recordingCtx2d();
const drawn = [];
g.drawImage = (_img, x, y, w, h) => drawn.push({ x, y, w, h });
game.init({ ctx: boot.ctx, mainContext: g, canvas: {}, seed: 1 });
game.render(g);
assert.equal(drawn.length, 1, '背景应 drawImage 一次');
assert.deepEqual(drawn[0], { x: 0, y: 0, w: 390, h: 844 }, '背景应画满幅 390×844');
} finally {
globalThis.Image = prevImage; // 还原全局,勿污染其它用例
}
});
// ── 控制类 archetype(paddle-intercept 族:连续控制 + 下落接住,exercises I 门) ──
test('控制类forensics 暴露 paddle/ball(供 paddle-intercept driver)', () => {
const game = adaptGameDefinition(CATCH_SOURCE)();
const { boot } = makeBoot();
game.init(boot);
const v = game._forensicsView().state();
assert.ok(v.paddle && typeof v.paddle.x === 'number', '应暴露 paddle.x');
assert.ok(v.ball && typeof v.ball.x === 'number' && typeof v.ball.y === 'number', '应暴露 ball.x/y');
});
test('control-x输入移动 paddle.x 中心对齐触点', () => {
const game = adaptGameDefinition(CATCH_SOURCE)();
const { boot, inputBridge } = makeBoot();
game.init(boot);
inputBridge._emit('pointerdown', { x: 300, y: 790 });
assert.ok(Math.abs(game._forensicsView().state().paddle.x - 300) < 5, 'paddle 中心应≈触点 x');
});
test('fall-and-catch球随 update 下落', () => {
const game = adaptGameDefinition(CATCH_SOURCE)();
const { boot } = makeBoot();
game.init(boot);
const y0 = game._forensicsView().state().ball.y;
game.update(0.1);
assert.ok(game._forensicsView().state().ball.y > y0, '球应下落');
});
test('控制类paddle 对齐下落球 → 接住计分', () => {
const game = adaptGameDefinition(CATCH_SOURCE)();
const { boot, inputBridge } = makeBoot();
game.init(boot);
const ball0x = game._forensicsView().state().ball.x;
inputBridge._emit('pointerdown', { x: ball0x, y: 790 }); // paddle 对齐首球 x
for (let i = 0; i < 30; i++) game.update(0.1); // 推进至球落到 paddle 层
assert.ok(game._forensicsView().state().score >= 1, '对齐后应接住至少 1 次');
});
// ── flappy(flap-to-gap 族:重力 + 振翅 + 穿缝,第 4 游戏族) ──
test('flappyforensics 暴露 bird{y,vy} + nextGap{centerY}(供 flap-to-gap driver)', () => {
const game = adaptGameDefinition(FLAPPY_SOURCE)();
const { boot } = makeBoot();
game.init(boot);
const v = game._forensicsView().state();
assert.ok(v.bird && typeof v.bird.y === 'number' && typeof v.bird.vy === 'number', '应暴露 bird.y/vy');
assert.ok(v.nextGap && typeof v.nextGap.centerY === 'number', '应暴露 nextGap.centerY');
});
test('flappyflap 给 bird 向上冲量(vy 变负)', () => {
const game = adaptGameDefinition(FLAPPY_SOURCE)();
const { boot, inputBridge } = makeBoot();
game.init(boot);
game.update(0.05); // 先因重力 vy>0
inputBridge._emit('pointerdown', { x: 195, y: 400 });
assert.ok(game._forensicsView().state().bird.vy < 0, 'flap 后 vy 应为负(上冲)');
});
test('flappy重力下落 + 缝隙滚过 → 穿缝计分或撞漏(机制生效)', () => {
const game = adaptGameDefinition(FLAPPY_SOURCE)();
const { boot } = makeBoot();
game.init(boot);
for (let i = 0; i < 60; i++) game.update(0.05); // 推进:bird 落 + 缝隙滚过 bird.x
const v = game._forensicsView().state();
assert.ok(v.score + v.misses >= 1, '缝隙至少越过一次(穿缝计分或撞=漏接)');
});
// ── seek-x(导航/收集 族:方向式左右 steer + 抵达目标计分,第 5 游戏族) ──
test('seek-xforensics 暴露 mover{x} + nextGoal{x}(供 seek-x driver)', () => {
const game = adaptGameDefinition(SEEK_SOURCE)();
const { boot } = makeBoot();
game.init(boot);
const v = game._forensicsView().state();
assert.ok(v.mover && typeof v.mover.x === 'number', '应暴露 mover.x');
assert.ok(v.nextGoal && typeof v.nextGoal.x === 'number', '应暴露 nextGoal.x');
});
test('seek-xmove-lr 方向式(点左半左移 / 点右半右移)', () => {
const game = adaptGameDefinition(SEEK_SOURCE)();
const { boot, inputBridge } = makeBoot();
game.init(boot);
const x0 = game._forensicsView().state().mover.x;
inputBridge._emit('pointerdown', { x: 30, y: 760 }); // 左半 → 左移
const x1 = game._forensicsView().state().mover.x;
assert.ok(x1 < x0, '点左半应使 mover 左移');
inputBridge._emit('pointerdown', { x: 360, y: 760 }); // 右半 → 右移
assert.ok(game._forensicsView().state().mover.x > x1, '点右半应使 mover 右移');
});
test('seek-xsteer 抵达目标(容差内)→ 计分(update 评估)', () => {
const game = adaptGameDefinition(SEEK_SOURCE)();
const { boot, inputBridge } = makeBoot();
game.init(boot);
// mover 中心 195、goal 中心 85、step 36、tol 34:连点左半 steer 向左,update 评估抵达。
for (let i = 0; i < 4; i++) { inputBridge._emit('pointerdown', { x: 30, y: 760 }); game.update(0.05); }
assert.ok(game._forensicsView().state().score >= 1, '抵达目标应计分');
});
// ── 规避/辨别(tap-safe 族:点 safe 计分、点 hazard 即负,第 6 游戏族;exercises safeOnly driver) ──
test('规避forensics 每个 target 暴露 safe(3 safe + 2 hazard)', () => {
const game = adaptGameDefinition(AVOID_SOURCE)();
const { boot } = makeBoot();
game.init(boot);
const ts = game._forensicsView().state().targets;
assert.equal(ts.filter((t) => t.safe === true).length, 3, '应 3 个 safe');
assert.equal(ts.filter((t) => t.safe === false).length, 2, '应 2 个 hazard');
});
test('tap-safe点 safe → 计分;点 hazard → 记 miss 不计分', () => {
const game = adaptGameDefinition(AVOID_SOURCE)();
const { boot, inputBridge } = makeBoot();
game.init(boot);
inputBridge._emit('pointerdown', { x: 80, y: 180 }); // s1 中心(50+30,150+30)=safe
let v = game._forensicsView().state();
assert.equal(v.score, 1, '点 safe 应计分');
assert.equal(v.misses, 0);
inputBridge._emit('pointerdown', { x: 80, y: 460 }); // h1 中心(50+30,430+30)=hazard
v = game._forensicsView().state();
assert.equal(v.score, 1, '点 hazard 不计分');
assert.equal(v.misses, 1, '点 hazard 记一次 miss');
});
test('规避:点 hazard → lose 规则(misses>=maxMisses)→ gameover lose', () => {
const game = adaptGameDefinition(AVOID_SOURCE)();
const { boot, inputBridge } = makeBoot();
game.init(boot);
inputBridge._emit('pointerdown', { x: 360, y: 460 }); // h2 中心 = hazard
game.update(0.016); // 规则评估
const v = game._forensicsView().state();
assert.equal(v.phase, 'gameover');
assert.equal(v.result, 'lose');
});
// ── 鲁棒性:便宜模型产的 gameDefinition 常残缺/畸形,生产路适配器须优雅降级不崩 ──
test('鲁棒:空 gameDefinition → 不崩、phase playing、可渲染', () => {
const game = adaptGameDefinition({})();
const { boot, g } = makeBoot();
game.init(boot);
assert.equal(game._forensicsView().state().phase, 'playing');
assert.doesNotThrow(() => { game.update(0.016); game.render(g); });
assert.ok(g._rec.fillRects.length >= 1, '至少画背景');
});
test('鲁棒:实体缺 transform / 组件引用不存在 → 默认值不崩', () => {
const src = { gameDefinition: { entities: [{ id: 'x', components: ['nope'] }], scenes: [{ id: 's', entityRefs: ['x'] }], rules: [] } };
const game = adaptGameDefinition(src)();
const { boot, g } = makeBoot();
game.init(boot);
assert.equal(typeof game._forensicsView().state().targets[0].x, 'number');
assert.doesNotThrow(() => game.render(g));
});
test('鲁棒:畸形 rule condition → 不触发不崩', () => {
const src = { gameDefinition: { entities: [], scenes: [], rules: [{ id: 'r', condition: '乱七八糟', outcome: 'win' }] }, config: {} };
const game = adaptGameDefinition(src)();
const { boot } = makeBoot();
game.init(boot);
game.update(0.016);
assert.equal(game._forensicsView().state().phase, 'playing'); // 畸形条件不误触发 win
});
test('鲁棒:不支持的 behavior op → 忽略不崩', () => {
const src = { gameDefinition: { entities: [], scenes: [], rules: [], behaviors: [{ id: 'b', trigger: 'input', op: 'unknown-op' }] }, config: {} };
const game = adaptGameDefinition(src)();
const { boot, inputBridge } = makeBoot();
game.init(boot);
assert.doesNotThrow(() => inputBridge._emit('pointerdown', { x: 100, y: 100 }));
assert.equal(game._forensicsView().state().score, 0);
});
test('鲁棒:render/update 在 init 前调 → 不崩(防御阶段错)', () => {
const game = adaptGameDefinition(SOURCE)();
const { g } = makeBoot();
assert.doesNotThrow(() => { game.update(0.016); game.render(g); });
});