376 lines
18 KiB
JavaScript
376 lines
18 KiB
JavaScript
/**
|
||
* 向导层无头测试(web/harness/guide.test.mjs)。
|
||
*
|
||
* settings.guide='full' 下,用 storyline + simon 两个代表游戏 + Guide 直连断言:
|
||
* ① 看门狗礼仪(round2 F1):storyline 进入等待态 25s → nudge + gd_continue,
|
||
* 50s 升级只重申手势(gd_again),**不再重播 help/进度/当前节点**,两级用尽
|
||
* 彻底静默;simon input 10s → gd_relisten,25s 升级 = confirm 震 + turn
|
||
* earcon 纯标记(**取消自动重播序列**);
|
||
* ② turn cue:simon 首轮序列播完 → turn earcon 事件 + confirm 震 + your_turn;
|
||
* ③ error 纠错:storyline 无效手势 → error 震 + gd_invalid(onceKey 同局只播一次);
|
||
* ④ F1.4 idle-only:对白在播时 error/watchdog 语音跳过(事件带 skipped:true、
|
||
* 不入队),空闲时正常播;turn 语音保持 queue 不受影响;
|
||
* ⑤ 等级衰减:guide='off' 时以上全部无事件、无 gd_* 播放、无定时器注册。
|
||
* 事件经 guide.setEventHook 收集;mock 层与各 playtest 同款(虚拟时钟)。
|
||
*
|
||
* 运行:tools/node/bin/node web/harness/guide.test.mjs
|
||
*/
|
||
import fs from 'node:fs';
|
||
import path from 'node:path';
|
||
import url from 'node:url';
|
||
|
||
const ROOT = path.resolve(path.dirname(url.fileURLToPath(import.meta.url)), '..', '..');
|
||
const GAME_DIRS = {
|
||
StorylineGame: path.join(ROOT, 'web', 'storyline'),
|
||
SimonGame: path.join(ROOT, 'web', 'simon'),
|
||
};
|
||
let currentGameDir = GAME_DIRS.StorylineGame;
|
||
|
||
// ---------------------------------------------------------------- 断言收集
|
||
|
||
const realLog = console.log.bind(console);
|
||
let passCount = 0;
|
||
const failures = [];
|
||
function assert(cond, msg) {
|
||
if (cond) { passCount++; realLog(` ✓ ${msg}`); }
|
||
else { failures.push(msg); realLog(` ✗ ${msg}`); }
|
||
}
|
||
|
||
console.error = () => {};
|
||
console.warn = () => {};
|
||
console.log = () => {};
|
||
|
||
// ---------------------------------------------------------------- 虚拟时钟
|
||
|
||
let now = 0; // 虚拟毫秒
|
||
let timerSeq = 1;
|
||
const timers = new Map(); // id -> { fireAt, cb }
|
||
const realSetImmediate = setImmediate;
|
||
|
||
globalThis.setTimeout = (cb, ms = 0) => {
|
||
const id = timerSeq++;
|
||
timers.set(id, { fireAt: now + Math.max(0, Number(ms) || 0), cb });
|
||
return id;
|
||
};
|
||
globalThis.clearTimeout = (id) => { timers.delete(id); };
|
||
Object.defineProperty(globalThis, 'performance', {
|
||
value: { now: () => now }, configurable: true,
|
||
});
|
||
|
||
async function flush(rounds = 8) {
|
||
for (let i = 0; i < rounds; i++) await new Promise((r) => realSetImmediate(r));
|
||
}
|
||
|
||
async function advance(cond = null) {
|
||
for (let n = 0; n < 50000; n++) {
|
||
await flush(2);
|
||
if (cond && cond()) return 'cond';
|
||
let nextId = -1; let nextAt = Infinity;
|
||
for (const [id, t] of timers) if (t.fireAt < nextAt) { nextAt = t.fireAt; nextId = id; }
|
||
if (nextId < 0) return 'idle';
|
||
now = nextAt;
|
||
const t = timers.get(nextId);
|
||
timers.delete(nextId);
|
||
t.cb();
|
||
}
|
||
throw new Error('虚拟时钟定时器超上限(疑似死循环)');
|
||
}
|
||
const idle = () => advance(null);
|
||
/** 清空全局定时器(用例边界卫生:上一局的看门狗不串场)。 */
|
||
const resetTimers = () => timers.clear();
|
||
|
||
// ---------------------------------------------------------------- 浏览器 mock
|
||
|
||
globalThis.window = { addEventListener: () => {}, AudioContext: null };
|
||
|
||
const storageData = new Map();
|
||
globalThis.localStorage = {
|
||
getItem: (k) => (storageData.has(k) ? storageData.get(k) : null),
|
||
setItem: (k, v) => { storageData.set(k, String(v)); },
|
||
removeItem: (k) => { storageData.delete(k); },
|
||
};
|
||
|
||
const vibrateLog = [];
|
||
Object.defineProperty(globalThis, 'navigator', {
|
||
value: { vibrate: (...a) => { vibrateLog.push(a[0]); return true; } }, configurable: true,
|
||
});
|
||
|
||
class MockParam {
|
||
constructor(v = 1) { this.value = v; }
|
||
setValueAtTime() {}
|
||
linearRampToValueAtTime() {}
|
||
cancelScheduledValues() {}
|
||
}
|
||
class MockGainNode {
|
||
constructor() { this.gain = new MockParam(); }
|
||
connect() {}
|
||
disconnect() {}
|
||
}
|
||
class MockOscillator {
|
||
constructor() {
|
||
this.type = 'sine'; this.frequency = new MockParam();
|
||
this.connect = () => {}; this.start = () => {}; this.stop = () => {};
|
||
}
|
||
}
|
||
class MockAudioContext {
|
||
constructor() { this.state = 'running'; this.currentTime = 0; this.destination = {}; }
|
||
createGain() { return new MockGainNode(); }
|
||
createOscillator() { return new MockOscillator(); }
|
||
createDynamicsCompressor() {
|
||
const n = new MockGainNode();
|
||
n.threshold = new MockParam(); n.ratio = new MockParam();
|
||
n.attack = new MockParam(); n.release = new MockParam();
|
||
return n;
|
||
}
|
||
createBufferSource() {
|
||
return { buffer: null, loop: false, connect() {}, start() {}, stop() {} };
|
||
}
|
||
createScriptProcessor() { return { connect() {}, disconnect() {}, onaudioprocess: null }; }
|
||
decodeAudioData() { return Promise.resolve({ duration: 2.0, numberOfChannels: 1, sampleRate: 44100, getChannelData: () => new Float32Array(16) }); }
|
||
resume() { this.state = 'running'; return Promise.resolve(); }
|
||
}
|
||
globalThis.window.AudioContext = MockAudioContext;
|
||
|
||
// fetch 读"当前测试游戏"的真实资产目录(语音链靠引擎 endTimer 推进,需要真文件)
|
||
globalThis.fetch = async (u) => {
|
||
const p = path.join(currentGameDir, decodeURIComponent(String(u)));
|
||
if (!fs.existsSync(p)) return { ok: false, status: 404, arrayBuffer: async () => new ArrayBuffer(0) };
|
||
const data = fs.readFileSync(p);
|
||
return {
|
||
ok: true, status: 200,
|
||
arrayBuffer: async () => data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength),
|
||
};
|
||
};
|
||
|
||
// ---------------------------------------------------------------- 装载引擎与游戏
|
||
|
||
const { WebAudioEngine } = await import('../engine/audio.js');
|
||
const { settings } = await import('../engine/settings.js');
|
||
const { Guide } = await import('../engine/guide.js');
|
||
const { StorylineGame } = await import('../storyline/game.js');
|
||
const { SimonGame } = await import('../simon/game.js');
|
||
|
||
// guide='full':本文件验证向导层行为(既有 playtest 以 guide='off' 验证零干扰)
|
||
settings.set({ guide: 'full', voice: 'female', speed: 1.0, bgm: 1.0 });
|
||
|
||
/** 组装一局:playLog + 震动/事件捕获(每个用例全新 audio+game)。 */
|
||
function makeRig(GameClass) {
|
||
currentGameDir = GAME_DIRS[GameClass.name];
|
||
const audio = new WebAudioEngine('audio/');
|
||
const playLog = [];
|
||
const origPlay = audio.play.bind(audio);
|
||
audio.play = (id, opts) => { playLog.push({ id, t: now }); return origPlay(id, opts); };
|
||
const game = new GameClass(audio, {});
|
||
const events = [];
|
||
game.guide.setEventHook((evt) => events.push({ ...evt, t: now }));
|
||
const vibBase = vibrateLog.length;
|
||
return {
|
||
audio, playLog, game, events, vibBase,
|
||
vibrated: (pattern) => vibrateLog.slice(vibBase).some((p) => JSON.stringify(p) === JSON.stringify(pattern)),
|
||
};
|
||
}
|
||
const voicePlays = (playLog, id) => playLog.filter((e) => e.id === id).length;
|
||
async function gesture(game, g) { game.onGesture(g); await flush(); }
|
||
/** 等到当前语音链播完(精确停在链尾,不越过其后的看门狗定时器)。 */
|
||
const waitChainDone = (game) => advance(() => !game.voicePlaying);
|
||
|
||
// ================================================================ ① storyline 看门狗
|
||
|
||
realLog('== ① storyline:等待态看门狗(25s nudge+gd_continue,50s 升级只发 gd_again,无重播)==');
|
||
{
|
||
const { audio, playLog, game, events, vibrated } = makeRig(StorylineGame);
|
||
await audio.unlock();
|
||
game.start();
|
||
// F8(round2):开场链最前插 gd_brief(brief→intro)
|
||
assert(voicePlays(playLog, 'voice/gd_brief') === 1, 'F8:开场链最前播 gd_brief');
|
||
assert(playLog.find((e) => e.id.startsWith('voice/'))?.id === 'voice/gd_brief',
|
||
'F8:gd_brief 是本局第一句语音');
|
||
await waitChainDone(game); // gd_brief + n00_intro + skip_hint 播完 → W1 静默等待(看门狗已挂)
|
||
assert(!game.voicePlaying && game.nodeId === 'n00', 'n00 链播完,进入等待态');
|
||
assert(events.some((e) => e.type === 'state' && e.name === 'explore'), 'start 播 explore 状态签名事件');
|
||
|
||
const tArm = now;
|
||
await advance(() => events.some((e) => e.type === 'nudge'));
|
||
assert(now - tArm === 25000, `25s 后 nudge 触发(实际 ${now - tArm}ms)`);
|
||
assert(events.some((e) => e.type === 'watchdog' && e.voiceId === 'gd_continue'),
|
||
'看门狗第 1 级发 gd_continue 语音事件');
|
||
assert(voicePlays(playLog, 'voice/gd_continue') === 1, 'gd_continue 语音进入播放');
|
||
assert(!vibrated([8]), 'nudge 级不额外震动(earcon-only 轻提醒)');
|
||
|
||
await advance(() => events.filter((e) => e.type === 'watchdog').length >= 2);
|
||
assert(now - tArm === 50000, `50s 升级触发(实际 ${now - tArm}ms)`);
|
||
assert(events.some((e) => e.type === 'watchdog' && e.voiceId === 'gd_again'),
|
||
'升级只发 gd_again(重申手势)语音事件');
|
||
assert(voicePlays(playLog, 'voice/gd_again') === 1, 'gd_again 语音播放');
|
||
// F1.1:升级不再重播 help + 进度 + 当前节点
|
||
assert(!playLog.some((e) => e.id === 'voice/help' || e.id.startsWith('voice/status')),
|
||
'F1:升级不重播 help/进度');
|
||
assert(voicePlays(playLog, 'voice/n00_intro') === 1, 'F1:升级不重播当前节点(n00_intro 仅开场一次)');
|
||
|
||
// 两级用尽彻底静默:链走完后无任何向导定时器 → idle
|
||
await idle();
|
||
const nudges = events.filter((e) => e.type === 'nudge').length;
|
||
await idle();
|
||
assert(events.filter((e) => e.type === 'nudge').length === nudges, '两级用尽彻底静默(无后续提醒)');
|
||
|
||
// 玩家手势后重新挂载:n01 链播完重新挂上,且 25s 节奏到点触发一次
|
||
await gesture(game, 'tap'); // → n01,链播
|
||
await waitChainDone(game);
|
||
const nudgeBefore = events.filter((e) => e.type === 'nudge').length;
|
||
const tWait = now;
|
||
await advance(() => events.filter((e) => e.type === 'nudge').length > nudgeBefore);
|
||
assert(now - tWait === 25000, `重新挂载后仍按 25s 节奏触发(实际 ${now - tWait}ms)`);
|
||
resetTimers();
|
||
}
|
||
|
||
// ================================================================ ③ storyline error onceKey
|
||
|
||
realLog('== ③ storyline:无效手势纠错 gd_invalid(onceKey 同局只播一次)==');
|
||
{
|
||
const { audio, playLog, game, events, vibrated } = makeRig(StorylineGame);
|
||
await audio.unlock();
|
||
game.start();
|
||
await waitChainDone(game); // W1 等待态(n00 非选择节点)
|
||
await gesture(game, 'left'); // 选择节点外左右滑 = 无效手势
|
||
assert(vibrated([60, 40, 60]), '无效手势给 error 震');
|
||
assert(events.some((e) => e.type === 'error' && e.voiceId === 'gd_invalid'), '发 gd_invalid 语音事件');
|
||
assert(voicePlays(playLog, 'voice/gd_invalid') === 1, 'gd_invalid 播放一次');
|
||
await gesture(game, 'right');
|
||
assert(voicePlays(playLog, 'voice/gd_invalid') === 1, 'onceKey:同局第二次无效手势不再播语音');
|
||
assert(game.nodeId === 'n00' && !game.voicePlaying, '无效手势不推进剧情(原静默语义保持)');
|
||
resetTimers();
|
||
}
|
||
|
||
// ================================================================ ② simon turn cue + 看门狗
|
||
|
||
realLog('== ②/①b simon:首轮 turn cue(earcon 事件+confirm 震+your_turn)与 input 看门狗 ==');
|
||
{
|
||
const { audio, playLog, game, events, vibrated } = makeRig(SimonGame);
|
||
await audio.unlock();
|
||
game.start();
|
||
await advance(() => game.phase === 'tutorial' && game.teachAwait); // T0 六音教学第 1 步
|
||
for (const g of ['up', 'down', 'left', 'right', 'tap', 'doubletap']) {
|
||
await advance(() => game.teachAwait && game.phase === 'tutorial');
|
||
await gesture(game, g);
|
||
}
|
||
await advance(() => game.phase === 'input');
|
||
assert(game.round === 1, '教学走完进入第 1 轮 input');
|
||
|
||
// ② 首轮 turn cue:序列播完 400ms → confirm 震 + turn earcon 事件 + your_turn
|
||
assert(events.some((e) => e.type === 'turn' && e.name === 'turn'), 'turn earcon 事件发出');
|
||
assert(vibrated([20]), 'turn cue 带 confirm 轻震');
|
||
assert(voicePlays(playLog, 'voice/your_turn') === 1, '第 1 轮保留 your_turn 语音');
|
||
|
||
// ①b input 看门狗:10s nudge+gd_relisten
|
||
const tInput = now;
|
||
await advance(() => events.some((e) => e.type === 'nudge'));
|
||
assert(now - tInput === 10000, `input 10s 后 nudge 触发(实际 ${now - tInput}ms)`);
|
||
assert(events.some((e) => e.type === 'watchdog' && e.voiceId === 'gd_relisten'),
|
||
'input 10s 无输入发 gd_relisten 事件');
|
||
assert(voicePlays(playLog, 'voice/gd_relisten') === 1, 'gd_relisten 语音播放');
|
||
assert(game.chances === 1, '看门狗提示不消耗重听机会');
|
||
|
||
// 25s 升级(round2 F1.2):取消自动重播序列 → 只发 confirm 震 + turn earcon(纯标记)
|
||
const nudgesBefore = events.filter((e) => e.type === 'nudge').length;
|
||
const turnsBefore = events.filter((e) => e.type === 'turn').length;
|
||
const vibBefore = vibrateLog.length;
|
||
await advance(() => events.filter((e) => e.type === 'nudge').length > nudgesBefore);
|
||
assert(now - tInput === 25000, `25s 升级触发(实际 ${now - tInput}ms)`);
|
||
assert(game.phase === 'input' && game.round === 1, '升级不再自动重播序列(仍停在 input 等玩家)');
|
||
assert(events.filter((e) => e.type === 'turn').length === turnsBefore + 1, '升级发 turn earcon 事件(纯标记)');
|
||
assert(vibrateLog.slice(vibBefore).some((p) => JSON.stringify(p) === JSON.stringify([20])),
|
||
'升级发 confirm 轻震');
|
||
assert(voicePlays(playLog, 'voice/round_01') === 1, '序列未重播(round_01 仍只有第 1 轮一次)');
|
||
await idle(); // 级数用尽:看门狗自动停止
|
||
assert(game.phase === 'input', '升级后仍等待输入(免费泄题已废除)');
|
||
|
||
// 正常答题清第 1 轮:答题手势逐个 disarm,round1→round2 过渡期看门狗不触发
|
||
const nudgeBefore = events.filter((e) => e.type === 'nudge').length;
|
||
for (const g of game.sequence) await gesture(game, g);
|
||
assert(game.phase === 'roundend', '按序答题进入 roundend');
|
||
await advance(() => game.phase === 'input' && game.round === 2);
|
||
assert(events.filter((e) => e.type === 'nudge').length === nudgeBefore,
|
||
'答题手势复位看门狗:第 2 轮开启前无 nudge');
|
||
resetTimers();
|
||
}
|
||
|
||
// ================================================================ ④ F1.4 idle-only
|
||
|
||
realLog('== ④ F1.4 idle-only:对白在播时 error/watchdog 语音跳过(skipped 事件、不入队),turn 保持 queue ==');
|
||
{
|
||
currentGameDir = GAME_DIRS.StorylineGame;
|
||
const audio = new WebAudioEngine('audio/');
|
||
const playLog = [];
|
||
const origPlay = audio.play.bind(audio);
|
||
audio.play = (id, opts) => { playLog.push({ id, t: now }); return origPlay(id, opts); };
|
||
await audio.unlock();
|
||
const guide = new Guide(audio);
|
||
const events = [];
|
||
guide.setEventHook((evt) => events.push({ ...evt, t: now }));
|
||
|
||
// watchdog:对白在播时到点 → 只发 skipped 事件,语音不入队
|
||
guide.watchdog({ arm: true, at: [1000], say: ['gd_continue'] });
|
||
audio.play('voice/n00_intro'); // 长语音占住通道(mock 句长 2s)
|
||
await flush();
|
||
assert(audio.isVoicePlaying(), '前提:对白正在播放');
|
||
await advance(() => events.some((e) => e.type === 'watchdog'));
|
||
const wd = events.find((e) => e.type === 'watchdog');
|
||
assert(wd && wd.voiceId === 'gd_continue' && wd.skipped === true,
|
||
'watchdog 语音被 idle-skip(事件带 skipped:true)');
|
||
assert(voicePlays(playLog, 'voice/gd_continue') === 0, 'watchdog 语音未入队播放');
|
||
|
||
// error:对白在播 → 同规则只发事件;空闲 → 正常播放
|
||
guide.error({ voice: 'gd_invalid' });
|
||
assert(events.some((e) => e.type === 'error' && e.skipped === true),
|
||
'error 语音对白在播时跳过(skipped 事件)');
|
||
assert(voicePlays(playLog, 'voice/gd_invalid') === 0, 'error 语音未入队播放');
|
||
await advance(() => !audio.isVoicePlaying()); // 等在播对白自然结束
|
||
guide.error({ voice: 'gd_invalid' });
|
||
assert(voicePlays(playLog, 'voice/gd_invalid') === 1, '空闲时 error 语音正常播放');
|
||
assert(events.filter((e) => e.type === 'error' && !e.skipped).length === 1,
|
||
'空闲播放的 error 事件不带 skipped 标记');
|
||
|
||
// turn:保持 queue,不受 idle-only 影响(对白在播时照常排队)
|
||
audio.play('voice/n01_wake');
|
||
await flush();
|
||
assert(audio.isVoicePlaying(), '前提:对白正在播放');
|
||
guide.turn({ voice: 'gd_answer' });
|
||
assert(voicePlays(playLog, 'voice/gd_answer') === 1, 'turn 语音不受 idle-only 影响(照常入队)');
|
||
assert(events.some((e) => e.type === 'turn'), 'turn 事件照常发出');
|
||
resetTimers();
|
||
}
|
||
|
||
// ================================================================ ⑤ guide=off 全静
|
||
|
||
realLog('== ⑤ guide=off:全部 API 静默 no-op(无事件、无 gd_* 播放、无定时器注册)==');
|
||
{
|
||
settings.set({ guide: 'off' });
|
||
const { audio, playLog, game, events, vibrated } = makeRig(StorylineGame);
|
||
await audio.unlock();
|
||
game.start();
|
||
await waitChainDone(game);
|
||
assert(!game.voicePlaying, 'n00 链播完进入等待态');
|
||
assert(events.length === 0, 'off 档零向导事件(state/turn/watchdog/nudge 均无)');
|
||
|
||
// 静置 30s:无任何向导定时器 → 虚拟时钟完全不走(挂了看门狗定时器就会被推进)
|
||
const t0 = now;
|
||
await idle();
|
||
assert(now === t0, `off 档等待态无任何定时器注册(时钟纹丝不动,t0=${t0} now=${now})`);
|
||
|
||
await gesture(game, 'left'); // 无效手势
|
||
assert(voicePlays(playLog, 'voice/gd_invalid') === 0, 'off 档不播 gd_invalid');
|
||
assert(!vibrated([60, 40, 60]), 'off 档无向导 error 震');
|
||
assert(playLog.every((e) => !e.id.includes('gd_')), 'off 档全程无 gd_* 资产加载');
|
||
settings.set({ guide: 'full' });
|
||
}
|
||
|
||
// ---------------------------------------------------------------- 汇总
|
||
|
||
realLog(`\n通过 ${passCount} 条断言,失败 ${failures.length} 条`);
|
||
if (failures.length > 0) {
|
||
realLog('失败清单:');
|
||
for (const f of failures) realLog(` - ${f}`);
|
||
process.exit(1);
|
||
}
|
||
realLog('全部断言通过。');
|