guide层v1: 全局向导交互层落地(状态签名earcon/turn cue/静默看门狗/纠错) + 6游戏接线 + 8句向导语音 + 测试全绿

- engine/guide.js 运行时合成8个签名earcon(方法论L2硬参数),off档零定时器,插播queue不顶对白
- settings: guide三档(full/standard/off默认full) + 语速2.2视障档;audio: isVoicePlaying()/setRateBoostEnabled() 清两个已知问题
- 6游戏按schemes§2接线: storyline看门狗+无效手势纠错、simon免扣重听看门狗、escape turn cue+提前卡关提示、rhythm gd_answer/gd_miss+结算看门狗+快进豁免、space首撞指令提示+结算看门狗、rpg格挡纠错+60s催促+状态签名
- 大厅设置面板+向导文档(14-guide-layer.md)+00-global-ux §6;R5台词表补4条一致性
- 试玩转写含向导事件;6 playtest(guide=off断言零改动)+guide.test(34断言)+资产终检全绿
This commit is contained in:
SoundGame Dev
2026-08-30 22:46:05 +08:00
parent 7eb9e7f025
commit 79dffac01e
69 changed files with 2466 additions and 144 deletions
+310
View File
@@ -0,0 +1,310 @@
/**
* 向导层无头测试(web/harness/guide.test.mjs)。
*
* settings.guide='full' 下,用 storyline + simon 两个代表游戏断言三 API 行为:
* ① 看门狗:storyline 进入等待态 8s → nudge earcon 事件 + gd_continue 语音
* (20s 升级 = 复用 playHelpAndResume,升级后不自动重挂);simon input
* 10s → gd_relisten、25s → 免扣机会自动重听一次;
* ② turn cue:simon 首轮序列播完 → turn earcon 事件 + confirm 震 + your_turn;
* ③ error 纠错:storyline 无效手势 → error 震 + gd_invalid(onceKey 同局只播一次);
* ④ 等级衰减: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 { 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:等待态看门狗(8s nudge+gd_continue,20s 升级 playHelpAndResume)==');
{
const { audio, playLog, game, events, vibrated } = makeRig(StorylineGame);
await audio.unlock();
game.start();
await waitChainDone(game); // 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 === 8000, `8s 后 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(() => playLog.some((e) => e.id === 'voice/help'));
assert(now - tArm === 20000, `20s 升级触发 playHelpAndResume(实际 ${now - tArm}ms)`);
await advance(() => playLog.some((e) => e.id === 'voice/status1'));
assert(voicePlays(playLog, 'voice/status1') === 1, '升级链播当前进度(status1)');
assert(game.voicePlaying, '升级后重播当前节点(帮助→进度→重播)');
// 升级帮助播完后不自动重挂(L4"之后停止,不轰炸"):链走完后无定时器 → idle
await idle();
const nudges = events.filter((e) => e.type === 'nudge').length;
await idle();
assert(events.filter((e) => e.type === 'nudge').length === nudges, '升级后不自动重挂看门狗(无后续提醒)');
// 玩家手势后恢复自动挂载:n01 链播完重新挂上,且 8s 节奏到点触发一次
await gesture(game, 'tap'); // → n01,链播(tap 同时复位 _guideHelped)
await waitChainDone(game);
await gesture(game, 'up'); // 帮助(复位 + 自带语音)→ 重播 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 === 8000, `重新挂载后仍按 8s 节奏触发(实际 ${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;25s 免扣机会自动重听
await advance(() => events.some((e) => e.type === 'nudge'));
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, '看门狗提示不消耗重听机会');
const chancesBefore = game.chances;
await advance(() => game.phase === 'playback');
assert(game._guideRelistens === 1, '25s 升级:免扣机会自动重听序列一次');
assert(game.chances === chancesBefore, '自动重听不扣机会');
await advance(() => 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');
assert(game._guideRelistens === 1, '第 2 轮不再消耗自动重听额度');
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('全部断言通过。');