round2: 看门狗礼仪F1(不重播/单句升级/idle-skip) + 分档归因F2 + 交接count-in/answer F3 + 教学看表现F4-F5 + 警报去叠F6 + 回绕F7 + 开场瘦身F8 + 10句新语音 + 测试全绿(6playtest零改动+guide52断言+资产终检)

This commit is contained in:
SoundGame Dev
2026-08-31 00:26:25 +08:00
parent 296c996fb9
commit 902461f0f4
65 changed files with 1094 additions and 775 deletions
+94 -29
View File
@@ -1,13 +1,16 @@
/**
* 向导层无头测试(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 → 免扣机会自动重听一次;
* 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 同局只播一次);
* ④ 等级衰减:guide='off' 时以上全部无事件、无 gd_* 播放、无定时器注册。
* ④ 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
@@ -143,6 +146,7 @@ globalThis.fetch = async (u) => {
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');
@@ -172,44 +176,50 @@ const waitChainDone = (game) => advance(() => !game.voicePlaying);
// ================================================================ ① storyline 看门狗
realLog('== ① storyline:等待态看门狗(8s nudge+gd_continue,20s 升级 playHelpAndResume)==');
realLog('== ① storyline:等待态看门狗(25s nudge+gd_continue,50s 升级只发 gd_again,无重播)==');
{
const { audio, playLog, game, events, vibrated } = makeRig(StorylineGame);
await audio.unlock();
game.start();
await waitChainDone(game); // n00_intro + skip_hint 播完 → W1 静默等待(看门狗已挂)
// 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 === 8000, `8s 后 nudge 触发(实际 ${now - tArm}ms)`);
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(() => 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, '升级后重播当前节点(帮助→进度→重播)');
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 仅开场一次)');
// 升级帮助播完后不自动重挂(L4"之后停止,不轰炸"):链走完后无定时器 → idle
// 两级用尽彻底静默:链走完后无任何向导定时器 → idle
await idle();
const nudges = events.filter((e) => e.type === 'nudge').length;
await idle();
assert(events.filter((e) => e.type === 'nudge').length === nudges, '升级后不自动重挂看门狗(无后续提醒)');
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
// 玩家手势后重新挂载: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 === 8000, `重新挂载后仍按 8s 节奏触发(实际 ${now - tWait}ms)`);
assert(now - tWait === 25000, `重新挂载后仍按 25s 节奏触发(实际 ${now - tWait}ms)`);
resetTimers();
}
@@ -251,18 +261,28 @@ realLog('== ②/①b simon:首轮 turn cue(earcon 事件+confirm 震+your_tu
assert(vibrated([20]), 'turn cue 带 confirm 轻震');
assert(voicePlays(playLog, 'voice/your_turn') === 1, '第 1 轮保留 your_turn 语音');
// ①b input 看门狗:10s nudge+gd_relisten;25s 免扣机会自动重听
// ①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, '看门狗提示不消耗重听机会');
const chancesBefore = game.chances;
await advance(() => game.phase === 'playback');
assert(game._guideRelistens === 1, '25s 升级:免扣机会自动重听序列一次');
assert(game.chances === chancesBefore, '自动重听不扣机会');
await advance(() => game.phase === 'input');
// 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;
@@ -271,13 +291,58 @@ realLog('== ②/①b simon:首轮 turn cue(earcon 事件+confirm 震+your_tu
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 全静
// ================================================================ ④ F1.4 idle-only
realLog('== ④ guide=off:全部 API 静默 no-op(无事件、无 gd_* 播放、无定时器注册)==');
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);