修一处潜在语义错 + 补休闲玩法时间压力维度(限时/倒计时):
- 旧 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>
439 lines
20 KiB
JavaScript
439 lines
20 KiB
JavaScript
/**
|
||
* 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 个 fillRect;HUD 含 "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-targets:update 后目标漂移(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('E3:assets[] 入 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('E3:scene 类资产 → 背景消费(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('E3:scene 背景有 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('flappy:forensics 暴露 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('flappy:flap 给 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-x:forensics 暴露 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-x:move-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-x:steer 抵达目标(容差内)→ 计分(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); });
|
||
});
|