round2: 看门狗礼仪F1(不重播/单句升级/idle-skip) + 分档归因F2 + 交接count-in/answer F3 + 教学看表现F4-F5 + 警报去叠F6 + 回绕F7 + 开场瘦身F8 + 10句新语音 + 测试全绿(6playtest零改动+guide52断言+资产终检)
This commit is contained in:
+94
-29
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user