Files
SoundGame/web/harness/guide.test.mjs
T
xsl ee9d7efe57 feat(playbook): 26 号交互手册首批落地——storyline/escape/simon 全面重构 + 引擎六模块
按 docs/design/26-audio-interaction-playbook.md 重构三款游戏(对白重写 + 双性别语音重生成 580 文件)。

引擎层(web/engine/,其余 12 款游戏零改动):
- floor.js 发言权管理器:SPEAKING/YOUR_TURN/BUSY/ACTION 四态、交接提示音、
  等待声(220/330Hz 嘟-嘟,20s 后放缓)、跨链音乐压低(audio.js holdDuck)
- receipts.js 输入回执三件套(ok/invalid/busy,100ms 内,原则 4)
- confusion.js 迷茫检测(乱点/无效/静默信号 → 三级接管,§4.5)
- hints.js 等待点 + 三档提示(full→short→提示音,30s 台账跨点去重,原则 9)
- novice.js 手势解锁阶梯(up/down/doubletap 跨局持久,§4.3)
- pipeline.js 输入管道(回执收口、防连点 400ms、双击折算、explainOnce)

游戏层:
- storyline R3:开场 71.7s 规则念白 → 3 秒内动手;双击=重播(反模式5)废除;
  抉择点三档提示+主动重听;断点存档复述(§4.12)
- escape R3:单击=检查+使用合并(双击用法废除);长按=我在哪(四向玩法裁定);
  撞墙归因每方向一次(原则 7);迷茫 L2 缩小/L3 演示;坐标存档续玩
- simon R3:新手三音色,按实力渐进解锁(+up/down/dt);失败表替代机会账
  (归因 2s 内→免费重听→放慢→结算,原则 15);播放期手势=busy 回执

测试:三款 playtest 重写(含 §6.2 乱点机器人:无声操作/被动重复/打断失败全 0,
首操作 ~2s);guide.test 改为 Guide 层单测+rhythm/space 段。全部通过
(38+33+22+72+19+35 断言)。设计单见 docs/design/26-apply-*.md。
2026-10-01 08:11:12 +08:00

402 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.
/**
* 向导层无头测试(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'),
RhythmGame: path.join(ROOT, 'web', 'rhythm'),
SpaceGame: path.join(ROOT, 'web', 'space'),
EscapeGame: path.join(ROOT, 'web', 'escape'),
};
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); };
// rhythm 的 40ms 调度泵 / space 的局内定时链依赖 interval(round3 G1/G7 用例需要)
const intervals = new Set();
globalThis.setInterval = (cb, ms = 0) => {
const id = timerSeq++;
const step = () => {
if (!intervals.has(id)) return; // 已被 clearInterval
cb();
if (intervals.has(id)) timers.set(id, { fireAt: now + Math.max(0, Number(ms) || 0), cb: step });
};
intervals.add(id);
timers.set(id, { fireAt: now + Math.max(0, Number(ms) || 0), cb: step });
return id;
};
globalThis.clearInterval = (id) => { intervals.delete(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', {
// maxTouchPoints: 1 —— 手机 persona:inputMode() 判 touch,语音走触屏措辞主资产(键盘路由由 engine-input K9 专测)
value: { maxTouchPoints: 1, 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.destination = {}; this.sampleRate = 44100; }
// 跟随虚拟时钟:rhythm 用 ctx.currentTime 绝对排程(round3 G1 时间窗断言的前提)
get currentTime() { return now / 1000; }
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, playbackRate: new MockParam(), 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');
const { RhythmGame } = await import('../rhythm/game.js');
const { SpaceGame } = await import('../space/game.js');
const { EscapeGame } = await import('../escape/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];
storageData.clear(); // 用例间进度隔离(rhythm tutorialDone / simon best 等不串场)
const audio = new WebAudioEngine('audio/');
const playLog = [];
const origPlay = audio.play.bind(audio);
audio.play = (id, opts) => { playLog.push({ id, t: now, opts: opts || {} }); 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)),
vibratedSince: (mark, pattern) => vibrateLog.slice(mark).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);
// ================================================================ ⓪ Guide 层单测(stub 游戏夹具)
// 26 号重构后 storyline/escape/simon 不再使用 Guide(发言权/等待点接管),
// Guide 仍服务 rhythm/space/rpg 等未改造游戏;这里用最小夹具锁 Guide 自身语义。
realLog('== ⓪ Guide 层:状态签名 / turn 排队 / error idle-only / off 全静 ==');
{
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 guide = new Guide(audio);
const events = [];
guide.setEventHook((evt) => events.push({ ...evt, t: now }));
await audio.unlock();
guide.state('explore');
assert(events.some((e) => e.type === 'state' && e.name === 'explore'), 'state() 发状态签名事件');
guide.turn({ voice: 'gd_continue' });
assert(events.some((e) => e.type === 'turn'), 'turn() 发交接事件');
// F1.4 idle-only:对白在播时 error 语音跳过(skipped 事件、不入队)
audio.play('voice/menu', { queue: false });
await flush();
assert(audio.isVoicePlaying(), '夹具对白在播');
guide.error({ voice: 'gd_invalid' });
assert(events.some((e) => e.type === 'error' && e.skipped), '对白在播时 error 语音跳过(skipped)');
await advance(() => !audio.isVoicePlaying());
assert(!playLog.some((e) => e.id === 'voice/gd_invalid'), '被跳过的 error 不入队迟到');
// off:全部 API 静默 no-op(无事件、无定时器)
settings.set({ guide: 'off' });
const evBefore = events.length;
guide.state('battle');
guide.turn({ voice: 'gd_continue' });
guide.error({ voice: 'gd_invalid' });
guide.watchdog({ arm: true, at: [1000], onEscalate: () => assert(false, 'off 档不应触发升级') });
assert(events.length === evBefore, 'off 档全部 API 静默(零事件)');
await advance(() => false, );
settings.set({ guide: 'full' });
}
// ================================================================ ⑥ G1/G3 rhythm
realLog('== ⑥ G1/G3 rhythm:gd_answer 落在首个应答窗口前整整 1 小节;菜单恢复 count-in 可感知 ==');
{
const { audio, playLog, game, events } = makeRig(RhythmGame);
const tSec = () => now / 1000;
const SPB = 60 / 90; // 曲目一 90BPM
await audio.unlock();
game.start();
await advance(() => audio.isVoicePlaying()); // welcome 开播
await gesture(game, 'tap'); // 播放中单击 = 跳过 → 教学链
await advance(() => game.phase === 'tut');
await gesture(game, 'down'); // 跳过教学 → 曲目一 main
await advance(() => game.phase === 'main' && game._cues.length > 0);
assert(game._song && game._song.id === 'song1', '前提:进入曲目一 main(90BPM)');
// G1:gd_answer(曲谱定时队列直达)在首个应答窗口起点之前整整 1 小节
await advance(() => playLog.some((e) => e.id === 'voice/gd_answer'));
const ans = playLog.find((e) => e.id === 'voice/gd_answer');
const win0 = game._cues[0].expected; // 首个应答窗口起点(秒)
assert(ans.t / 1000 < win0, `G1:gd_answer(${(ans.t / 1000).toFixed(2)}s) < 首窗口起点(${win0.toFixed(2)}s)`);
assert(Math.abs(win0 - ans.t / 1000 - 4 * SPB) < 0.06,
`G1:窗口前整整 1 小节(提前 ${(win0 - ans.t / 1000).toFixed(2)}s ≈ ${(4 * SPB).toFixed(2)}s)`);
assert(events.some((e) => e.type === 'turn'), 'G1:turn earcon 同点发出(standard 档也发,off 静默)');
// G3:曲中开菜单 → 关菜单恢复:full 档先播 gd_count,链播完才开始 4 拍 count-in
await advance(() => tSec() >= game._bgmT0 + 1.5); // BGM 已起、首窗口未到
await gesture(game, 'down');
assert(game.phase === 'menu', 'G3:曲中下滑呼出菜单(无悬置 down 音符)');
// live round2 P1:菜单 1s 去抖独占期(防瞬穿)——关菜单手势须在独占期后到达
const menuT0 = tSec();
await advance(() => tSec() >= menuT0 + 1.05);
const kicksBefore = game._sched.filter((e) => e.sfx === 'sfx/inst_kick').length;
await gesture(game, 'down');
assert(voicePlays(playLog, 'voice/gd_count') === 1, 'G3:恢复先播 gd_count(full 档)');
assert(audio.isVoicePlaying(), 'G3:count-in 在 gd_count 链播完之后才开始(语音串行)');
await advance(() => !audio.isVoicePlaying() && game._timer !== null);
const allKicks = game._sched.filter((e) => e.sfx === 'sfx/inst_kick').map((e) => e.time).sort((a, b) => a - b);
assert(allKicks.length === kicksBefore + 4, 'G3:count-in 4 记底鼓排入 _sched(sfx 层音量 1.0)');
const schedTimes = game._sched.map((e) => e.time);
assert(schedTimes.every((t, i) => i === 0 || t >= schedTimes[i - 1]), 'G3:_sched 已按时间重排(count-in 位于队头,round2 版未重排被队头阻塞的根因)');
const ciKicks = allKicks.slice(0, 4);
assert(ciKicks.every((t, i) => i === 0 || Math.abs(t - ciKicks[i - 1] - SPB) < 0.05), 'G3:底鼓按一拍间距排布');
const vibMark = vibrateLog.length;
await advance(() => tSec() >= ciKicks[3] + 0.3);
assert(vibrateLog.slice(vibMark).filter((p) => JSON.stringify(p) === '[8]').length >= 4,
'G3:4 记 count-in 底鼓各带 beat 震(按拍点准时落)');
assert(game.phase === 'main', 'G3:恢复后回 main');
// G1:恢复 = 一次「进 main」:暂停前旧标记已消费 → 对恢复后的首个未判定窗口重排
const gdCountBefore = voicePlays(playLog, 'voice/gd_answer');
await advance(() => voicePlays(playLog, 'voice/gd_answer') > gdCountBefore);
const ans2 = playLog.filter((e) => e.id === 'voice/gd_answer').pop();
assert(ans2.t / 1000 < game._cues[0].expected, 'G1:恢复后的 gd_answer 仍在首个未判定窗口起点之前');
// G3:gd_count 每局恢复限 1 次(第二次恢复直接 count-in)
await gesture(game, 'down');
const menuT1 = tSec();
await advance(() => tSec() >= menuT1 + 1.05); // live round2 P1:菜单 1s 去抖独占期后再操作
const cntBefore = voicePlays(playLog, 'voice/gd_count');
await gesture(game, 'down');
assert(voicePlays(playLog, 'voice/gd_count') === cntBefore, 'G3:gd_count 每局恢复限 1 次');
assert(game.phase === 'main', 'G3:第二次恢复不经语音直接 count-in');
game.stop();
resetTimers();
}
// ================================================================ ⑦ G2 rhythm 教学 DDA
realLog('== ⑦ G2 rhythm:教学同一步连 2 失败 → 领奏降速 25% 重演 + gd_slow;做对恢复、每局封顶 ==');
{
const { audio, playLog, game } = makeRig(RhythmGame);
const tSec = () => now / 1000;
const SPB = 60 / 90;
await audio.unlock();
game.start();
await advance(() => audio.isVoicePlaying());
await gesture(game, 'tap'); // 跳过 welcome
await advance(() => game.phase === 'tut' && game._cues.length > 0);
let lead = Math.min(...game._sched.filter((e) => e.sfx === 'sfx/inst_kick').map((e) => e.time));
assert(Math.abs(game._cues[0].expected - lead - 4 * SPB) < 0.05, '基准:T1 应答窗 = 领奏后 4 拍(正常速率)');
// 失败①:整句不出手(F2.1 归因 + 纠正重教)。用持久里程碑停表:
//(_tutEvaluate 会先把 _phaseEnd 置 Infinity 防重入,等「阶段末」类条件会跑飞)
await advance(() => game._tutFails === 1);
assert(game._tutFails === 1, '失败 1 次:纠正重教(原路径不变)');
await advance(() => game._phaseEnd < 1e9 && tSec() < game._phaseEnd - 0.5); // 纠正链播完、重新领奏
// 失败②:再整句不出手 → G2 降速重演
await advance(() => game._tutSlowUsed === true);
assert(game._tutSlowUsed === true, '同一步连续 2 次失败 → 进入降速路径');
await advance(() => voicePlays(playLog, 'voice/gd_slow') === 1);
assert(voicePlays(playLog, 'voice/gd_slow') === 1, 'G2:连 2 失败播 gd_slow(full 档)');
assert(game._tutRate === 0.75, 'G2:该步领奏降速 25%(_tutRate=0.75)');
await advance(() => game._cues.length > 0 && game._sched.some((e) => e.sfx === 'sfx/inst_kick' && e.time > tSec()));
lead = Math.min(...game._sched.filter((e) => e.sfx === 'sfx/inst_kick').map((e) => e.time));
assert(Math.abs(game._cues[0].expected - lead - 4 * SPB / 0.75) < 0.06,
`G2:降速后应答窗 = 领奏后 4 拍×1/0.75(实际 ${(game._cues[0].expected - lead).toFixed(2)}s)`);
// 降速步做对 → 恢复正常速度
await advance(() => tSec() >= game._cues[0].expected + 0.29);
await gesture(game, 'tap'); // tap 补偿 -0.30s 后 dt≈0,判对
await advance(() => game._tutIdx === 1);
assert(game._tutRate === 1.0, 'G2:该步做对恢复正常速度');
// 封顶:T2 再连 2 失败 → 不再降速,维持 retry_skip 跳步
await advance(() => game._tutIdx === 1 && game._tutFails === 1); // T2 失败 1
await advance(() => game._phaseEnd < 1e9 && tSec() < game._phaseEnd - 0.5 && game._tutIdx === 1);
await advance(() => voicePlays(playLog, 'voice/retry_skip') === 1); // T2 失败 2 → 跳步
assert(voicePlays(playLog, 'voice/gd_slow') === 1, 'G2:gd_slow 每教学局 1 次封顶');
assert(voicePlays(playLog, 'voice/retry_skip') === 1, 'G2:封顶后维持 retry_skip 跳步不卡流程');
await advance(() => game._tutIdx === 2);
assert(game._tutIdx === 2, 'G2:跳步进 T3');
game.stop();
resetTimers();
}
// ================================================================ ⑨ G7 space 方向呼喊
realLog('== ⑨ G7 space:dir_* 方向呼喊与呼啸同窗(spawn 当刻,round4 R1 改口) ==');
{
// 设计依据(research/04-playtests/live/round1/space-xiaozhao.md P1-1 + 13 号文档 §11):
// G7 原裁定为「loud 警报档(撞击前 500ms)同窗呼喊」,但第 1 轮全拟真实测每颗陨石的
// 呼喊都落在撞击判定前 ~0.4s——听完再滑必死,与教学培养的"听语音滑"直接矛盾。
// round4 R1 把呼喊提前到**陨石呼啸响起瞬间**(spawn 当刻):呼喊=反应窗口起点的指引,
// 与 whoosh 同窗;loud 警报保留在撞击前 500ms 承担"临近警示",二者**明确错峰**、
// 不再绑定(撞击点落在保护期内的陨石不呼喊,protect_<dir> 长句教学优先)。
const { audio, playLog, game } = makeRig(SpaceGame);
await audio.unlock();
game.start();
await advance(() => game.phase === 'drill');
await gesture(game, 'down'); // 无悬置演练陨石:下滑跳过演练
await advance(() => game.phase === 'playing');
assert(game.phase === 'playing', '前提:跳过演练进对局');
await advance(() => playLog.some((e) => e.id.startsWith('voice/dir_')));
const shout = playLog.find((e) => e.id.startsWith('voice/dir_'));
const dir = shout.id.slice('voice/dir_'.length);
assert(['left', 'right', 'up', 'down'].includes(dir), 'G7:呼喊方向合法');
const whooshes = playLog.filter((e) => e.id === `sfx/whoosh_${dir}`).map((e) => e.t);
assert(whooshes.some((t) => Math.abs(t - shout.t) < 60),
`G7:dir_* 与同向呼啸同窗(≤60ms,实际最小 ${Math.min(...whooshes.map((t) => Math.abs(t - shout.t)))}ms)`);
// 错峰验证:呼喊后第一声 loud 警报(同颗陨石的撞击前 500ms 档)应 ≥2s 才到
// (逼近时长 ≥2.8s → 呼喊到 loud 档间隔 ≥2.3s,取 2s 保守下界)
await advance(() => playLog.some((e) => e.id === 'sfx/alarm' && e.opts.volume === 1 && e.t > shout.t));
const nextLoud = playLog.find((e) => e.id === 'sfx/alarm' && e.opts.volume === 1 && e.t > shout.t);
assert(nextLoud.t - shout.t >= 2000,
`G7:呼喊先于其 loud 警报 ≥2s(错峰:呼喊在窗口起点,实际 ${nextLoud.t - shout.t}ms)`);
game.stop();
resetTimers();
}
realLog(`\n通过 ${passCount} 条断言,失败 ${failures.length} 条`);
if (failures.length > 0) {
realLog('失败清单:');
for (const f of failures) realLog(` - ${f}`);
process.exit(1);
}
realLog('全部断言通过。');