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。
This commit is contained in:
+27
-489
@@ -199,199 +199,45 @@ async function gesture(game, g) { game.onGesture(g); await flush(); }
|
||||
/** 等到当前语音链播完(精确停在链尾,不越过其后的看门狗定时器)。 */
|
||||
const waitChainDone = (game) => advance(() => !game.voicePlaying);
|
||||
|
||||
// ================================================================ ① storyline 看门狗
|
||||
// ================================================================ ⓪ Guide 层单测(stub 游戏夹具)
|
||||
// 26 号重构后 storyline/escape/simon 不再使用 Guide(发言权/等待点接管),
|
||||
// Guide 仍服务 rhythm/space/rpg 等未改造游戏;这里用最小夹具锁 Guide 自身语义。
|
||||
|
||||
realLog('== ① storyline:等待态看门狗(25s nudge+gd_continue,50s 升级只发 gd_again,无重播)==');
|
||||
realLog('== ⓪ Guide 层:状态签名 / turn 排队 / error idle-only / off 全静 ==');
|
||||
{
|
||||
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);
|
||||
}
|
||||
// round4 H2/H4 前提锚定:announce 时点固定第 1 轮序列为无 up/doubletap 的两音
|
||||
//(guide.test 的 Math.random 是真随机——不固定则 gd_relisten 断言会随占用预告
|
||||
// 动态置空而偶发跳过;固定后本块行为与 round3 基线一致)
|
||||
await advance(() => game.phase === 'announce');
|
||||
game.sequence[0] = 'down';
|
||||
game.sequence[1] = 'tap';
|
||||
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 看门狗:round4 H4 首级 10s→5s,nudge+gd_relisten
|
||||
const tInput = now;
|
||||
await advance(() => events.some((e) => e.type === 'nudge'));
|
||||
assert(now - tInput === 5000, `H4:input 首级 5s 后 nudge 触发(实际 ${now - tInput}ms)`);
|
||||
assert(events.some((e) => e.type === 'watchdog' && e.voiceId === 'gd_relisten'),
|
||||
'input 5s 无输入发 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})`);
|
||||
guide.state('explore');
|
||||
assert(events.some((e) => e.type === 'state' && e.name === 'explore'), 'state() 发状态签名事件');
|
||||
|
||||
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_* 资产加载');
|
||||
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' });
|
||||
}
|
||||
|
||||
@@ -512,93 +358,6 @@ realLog('== ⑦ G2 rhythm:教学同一步连 2 失败 → 领奏降速 25% 重
|
||||
resetTimers();
|
||||
}
|
||||
|
||||
// ================================================================ ⑧ G5 simon 双击/上滑
|
||||
|
||||
realLog('== ⑧ G5 simon:input 阶段双击永远=作答、上滑=主动重听(full 与 off 双档)==');
|
||||
function driveSimonToInput(rig) {
|
||||
const { audio, game } = rig;
|
||||
return (async () => {
|
||||
await audio.unlock();
|
||||
rig.game.start();
|
||||
await advance(() => game.phase === 'tutorial' && game.teachAwait);
|
||||
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');
|
||||
return game;
|
||||
})();
|
||||
}
|
||||
{
|
||||
const rig = makeRig(SimonGame);
|
||||
const { playLog, game } = rig;
|
||||
await driveSimonToInput(rig);
|
||||
assert(game.round === 1 && game.phase === 'input', '前提:教学完进第 1 轮 input');
|
||||
assert(voicePlays(playLog, 'voice/gd_economy') === 1, 'G5:教学收尾播 gd_economy(重录新文案)');
|
||||
// 固定期望序列(测试钩子):idx0=doubletap、idx1=left
|
||||
game.sequence[0] = 'doubletap';
|
||||
game.sequence[1] = 'left';
|
||||
await gesture(game, 'doubletap');
|
||||
assert(game.inputIdx === 1 && game.phase === 'input', 'G5:期望恰为 doubletap 时双击=作答(inputIdx 前进)');
|
||||
const ch = game.chances;
|
||||
const relistenN = voicePlays(playLog, 'voice/relisten');
|
||||
game.freeRelistens = 0; // 钩子:免费层置空,本段专测扣真机会路径(round2 live P1 两层账,免费层断言见 simon playtest 与 13 号文档 §15)
|
||||
await gesture(game, 'up'); // 期望非 up → 主动重听
|
||||
assert(game.chances === ch - 1 && voicePlays(playLog, 'voice/relisten') === relistenN + 1,
|
||||
'G5:input 上滑=主动重听(耗机会、播 relisten)');
|
||||
await advance(() => game.phase === 'input');
|
||||
assert(game.inputIdx === 0, 'G5:重听后输入进度清零');
|
||||
|
||||
// G6(round1 live P1 改口,13 号文档 §9):轮结算**每轮必播表扬**,既有台词池
|
||||
// [correct, gd_clear] 按轮轮播——第 1 轮 = correct「答对了!」(原"gd_clear 每局
|
||||
// 限 1 次"口径已被 round1 live 推翻:12 轮仅 3 轮有表扬)
|
||||
await gesture(game, 'doubletap'); // 期望 doubletap → 答对
|
||||
await gesture(game, 'left');
|
||||
await advance(() => game.phase === 'roundend');
|
||||
assert(voicePlays(playLog, 'voice/correct') === 1, 'G6:每轮答对必播表扬(第 1 轮 correct「答对了!」)');
|
||||
await advance(() => game.phase === 'input' && game.round === 2);
|
||||
|
||||
// G6:第 2 轮答完 → 表扬轮播到 gd_clear;且超过预设历史纪录(钩子 best=1)→
|
||||
// 同链追加 gd_record(每局 1 次)。链有两句(gd_clear 在前),须等链尾播完再计数。
|
||||
game.best = 1; // 模拟上局留下纪录(测试钩子)
|
||||
game.sequence[0] = 'up'; // 顺带验证期望恰为 up 时上滑=作答(00 §5.3 豁免同款)
|
||||
await gesture(game, 'up');
|
||||
assert(game.inputIdx === 1 && game.phase === 'input', 'G5:期望恰为 up 时上滑=作答');
|
||||
for (let i = 0; i < 8 && game.phase === 'input'; i++) await gesture(game, game.sequence[game.inputIdx]);
|
||||
await advance(() => game.phase === 'roundend' && game.round === 2);
|
||||
await advance(() => voicePlays(playLog, 'voice/gd_record') === 1); // 等表扬+纪录链播完再断言
|
||||
assert(voicePlays(playLog, 'voice/gd_clear') === 1, 'G6:表扬池轮播(第 2 轮 gd_clear「漂亮,过了。」)');
|
||||
assert(voicePlays(playLog, 'voice/gd_record') === 1, 'G6:局中破个人纪录播 gd_record(破纪录了)');
|
||||
await advance(() => game.phase === 'input' && game.round === 3);
|
||||
|
||||
// 双击永远=作答的判错面:chances=0(重听已耗)时双击期望非双击 → 判错到底(gameover),不是 chances_out
|
||||
game.sequence[0] = 'left'; // 钩子固定期望,注入 doubletap 必判错
|
||||
await gesture(game, 'doubletap');
|
||||
assert(game.phase === 'gameover', 'G5:input 双击永远=作答(期望非双击时判错到底,不再触发重听)');
|
||||
game.stop();
|
||||
resetTimers();
|
||||
}
|
||||
{
|
||||
settings.set({ guide: 'off' });
|
||||
const rig = makeRig(SimonGame);
|
||||
const { game } = rig;
|
||||
await driveSimonToInput(rig);
|
||||
game.sequence[0] = 'doubletap';
|
||||
game.sequence[1] = 'left';
|
||||
await gesture(game, 'doubletap');
|
||||
assert(game.inputIdx === 1, 'off:双击=作答同样成立(玩法映射与向导档无关)');
|
||||
game.freeRelistens = 0; // 钩子:免费层置空,上滑直接走扣真机会路径(round2 live P1)
|
||||
await gesture(game, 'up');
|
||||
assert(game.chances === 0, 'off:上滑=主动重听同样成立');
|
||||
await advance(() => game.phase === 'input');
|
||||
await gesture(game, 'doubletap');
|
||||
await gesture(game, 'doubletap');
|
||||
assert(game.phase === 'gameover', 'off:双击期望非双击时判错(无重听兜底)');
|
||||
settings.set({ guide: 'full' });
|
||||
game.stop();
|
||||
resetTimers();
|
||||
}
|
||||
|
||||
// ================================================================ ⑨ G7 space 方向呼喊
|
||||
|
||||
realLog('== ⑨ G7 space:dir_* 方向呼喊与呼啸同窗(spawn 当刻,round4 R1 改口) ==');
|
||||
@@ -633,227 +392,6 @@ realLog('== ⑨ G7 space:dir_* 方向呼喊与呼啸同窗(spawn 当刻,ro
|
||||
resetTimers();
|
||||
}
|
||||
|
||||
// ================================================================ ⑩ H1~H5(round4)
|
||||
|
||||
realLog('== ⑩a H2/H3/H4 simon:占用预告三句、被占用轮无重听建议、5s 看门狗、余量句 ==');
|
||||
async function driveSimonToAnnounce(rig) {
|
||||
const { audio, game } = rig;
|
||||
await audio.unlock();
|
||||
game.start();
|
||||
await advance(() => game.phase === 'tutorial' && game.teachAwait);
|
||||
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 === 'announce');
|
||||
return game;
|
||||
}
|
||||
/** 测试钩子(13 号文档允许直改实例字段):固定当前轮序列;未指定位用无 up/dt 填充。 */
|
||||
function fixSequence(game, recipe) {
|
||||
const fill = ['down', 'right', 'tap', 'left'];
|
||||
for (let i = 0; i < game.sequence.length; i++) {
|
||||
game.sequence[i] = i < recipe.length ? recipe[i] : fill[i % fill.length];
|
||||
}
|
||||
}
|
||||
{
|
||||
const rig = makeRig(SimonGame);
|
||||
const { playLog, game, events } = rig;
|
||||
await driveSimonToAnnounce(rig);
|
||||
|
||||
// R1(含 up):序列播放完成后、「轮到你」提示前播 gd_occ_up
|
||||
fixSequence(game, ['up', 'tap']);
|
||||
await advance(() => game.phase === 'input');
|
||||
assert(voicePlays(playLog, 'voice/gd_occ_up') === 1, 'H2:含 up 轮次播 gd_occ_up(序列播放完成后)');
|
||||
const iOcc = playLog.findIndex((e) => e.id === 'voice/gd_occ_up');
|
||||
const iTurn = playLog.findIndex((e) => e.id === 'voice/your_turn');
|
||||
assert(iOcc > -1 && iTurn > iOcc, 'H2:占用预告在「轮到你」提示之前(插在轮次链中)');
|
||||
|
||||
// 被占用轮 input 看门狗:H4 首级 5s 只发 nudge,不播重听建议句(say 动态置空)
|
||||
const tInput = now;
|
||||
const rl0 = voicePlays(playLog, 'voice/gd_relisten');
|
||||
const nb0 = events.filter((e) => e.type === 'nudge').length;
|
||||
await advance(() => events.filter((e) => e.type === 'nudge').length > nb0);
|
||||
assert(now - tInput === 5000, `H4:input 看门狗首级 5s(实际 ${now - tInput}ms)`);
|
||||
assert(voicePlays(playLog, 'voice/gd_relisten') === rl0,
|
||||
'H2:up 被占用轮看门狗只发 nudge earcon,不播重听建议句');
|
||||
|
||||
// 期望恰为 up 时上滑=作答(00 §5.3 豁免同款)→ 清第 1 轮
|
||||
for (const g of game.sequence) await gesture(game, g);
|
||||
await advance(() => game.phase === 'announce' && game.round === 2);
|
||||
|
||||
// R2(仅含 doubletap):gd_occ_dt;重听建议不受影响(建议只关联 up 占用)
|
||||
fixSequence(game, ['down', 'doubletap', 'left']);
|
||||
await advance(() => game.phase === 'input');
|
||||
assert(voicePlays(playLog, 'voice/gd_occ_dt') === 1, 'H2:含 doubletap 轮次播 gd_occ_dt');
|
||||
const t2 = now;
|
||||
const rl1 = voicePlays(playLog, 'voice/gd_relisten');
|
||||
const nb1 = events.filter((e) => e.type === 'nudge').length;
|
||||
await advance(() => events.filter((e) => e.type === 'nudge').length > nb1);
|
||||
assert(now - t2 === 5000, 'H4:看门狗首级恒 5s(升级级 25s 与级数不变)');
|
||||
assert(voicePlays(playLog, 'voice/gd_relisten') === rl1 + 1,
|
||||
'H2:仅 doubletap 占用不影响上滑重听建议');
|
||||
for (const g of game.sequence) await gesture(game, g);
|
||||
await advance(() => game.phase === 'announce' && game.round === 3);
|
||||
|
||||
// R3(up+doubletap 双占用):gd_occ_both;看门狗同样静默只 nudge
|
||||
fixSequence(game, ['up', 'doubletap', 'left', 'right']);
|
||||
await advance(() => game.phase === 'input');
|
||||
assert(voicePlays(playLog, 'voice/gd_occ_both') === 1, 'H2:up+doubletap 双占用播 gd_occ_both');
|
||||
const rl2 = voicePlays(playLog, 'voice/gd_relisten');
|
||||
const nb2 = events.filter((e) => e.type === 'nudge').length;
|
||||
await advance(() => events.filter((e) => e.type === 'nudge').length > nb2);
|
||||
assert(voicePlays(playLog, 'voice/gd_relisten') === rl2, 'H2:双占用轮看门狗同样不播重听建议');
|
||||
for (const g of game.sequence) await gesture(game, g);
|
||||
await advance(() => game.phase === 'announce' && game.round === 4);
|
||||
|
||||
// H3(round2 live P1 改口,13 号文档 §15):重听两层账——免费 2 次/局开局即给足、
|
||||
// 先用不碰 chances;余量句与免费余量一致:第 1 次(2→1)播 left1、第 2 次(1→0)
|
||||
// 播 left0;免费与真机会皆空时再求重听 → 拒绝并播 chances_out(机会账路径不变)。
|
||||
fixSequence(game, []); // 无占用填充轮
|
||||
await advance(() => game.phase === 'input');
|
||||
assert(game.freeRelistens === 2, 'H3:免费重听每局 2 次开局即给足(教学 gd_economy 承诺两次)');
|
||||
const rel0 = voicePlays(playLog, 'voice/relisten');
|
||||
const l1_0 = voicePlays(playLog, 'voice/gd_chances_left1');
|
||||
await gesture(game, 'up'); // 期望非 up → 主动重听①:免费 2→1
|
||||
await advance(() => voicePlays(playLog, 'voice/gd_chances_left1') === l1_0 + 1); // 余量句在 relisten 链尾
|
||||
assert(game.freeRelistens === 1 && game.chances === 1 && voicePlays(playLog, 'voice/gd_chances_left1') === l1_0 + 1,
|
||||
'H3:第 1 次免费重听用掉播 gd_chances_left1(余量句与真实余量一致,真机会不动)');
|
||||
assert(voicePlays(playLog, 'voice/relisten') === rel0 + 1, 'H3:重听照常播 relisten 并重播序列');
|
||||
await advance(() => game.phase === 'input');
|
||||
assert(game.inputIdx === 0, 'H3:重听后输入进度清零');
|
||||
const l0_0 = voicePlays(playLog, 'voice/gd_chances_left0');
|
||||
await gesture(game, 'up'); // 重听②:免费 1→0
|
||||
await advance(() => voicePlays(playLog, 'voice/gd_chances_left0') === l0_0 + 1);
|
||||
assert(game.freeRelistens === 0 && game.chances === 1 && voicePlays(playLog, 'voice/gd_chances_left0') === l0_0 + 1,
|
||||
'H3:第 2 次免费重听用掉播 gd_chances_left0(「用完了」只在免费余量为 0 时出现)');
|
||||
await advance(() => game.phase === 'input');
|
||||
game.chances = 0; // 测试钩子:真机会一并置空,下一求重听走拒绝路径
|
||||
const co0 = voicePlays(playLog, 'voice/chances_out');
|
||||
await gesture(game, 'up'); // 两账皆空再求重听:拒绝路径(机会账)行为不变
|
||||
assert(game.phase === 'input' && voicePlays(playLog, 'voice/chances_out') === co0 + 1,
|
||||
'H3:两账皆空再求重听仍拒绝并播 chances_out(答错救援的机会账路径不变)');
|
||||
while (game.phase === 'input') await gesture(game, game.sequence[game.inputIdx]);
|
||||
game.stop();
|
||||
resetTimers();
|
||||
}
|
||||
|
||||
realLog('== ⑩b H5 rhythm:教学应答窗口无任何尝试 → gd_miss_none(none 优先级最高)==');
|
||||
{
|
||||
const rig = makeRig(RhythmGame);
|
||||
const { audio, playLog, game } = rig;
|
||||
await audio.unlock();
|
||||
game.start();
|
||||
await advance(() => audio.isVoicePlaying()); // welcome 开播
|
||||
await gesture(game, 'tap'); // 播放中单击 = 跳过 → 教学 T1
|
||||
await advance(() => game.phase === 'tut' && game._cues.length > 0);
|
||||
// T1 整句不出手(窗口关闭时该音未判定)→ 首次失败归因 gd_miss_none
|
||||
await advance(() => voicePlays(playLog, 'voice/gd_miss_none') === 1);
|
||||
assert(game._tutFails === 1, '前提:T1 首次失败(整句不出手)');
|
||||
assert(voicePlays(playLog, 'voice/gd_miss_none') === 1, 'H5:无尝试播 gd_miss_none(这拍没接)');
|
||||
assert(voicePlays(playLog, 'voice/gd_miss_late') === 0 && voicePlays(playLog, 'voice/gd_miss_wrong') === 0,
|
||||
'H5:没出手不再被归因为「慢了半拍」(归因真话律:宁可粗不可假)');
|
||||
game.stop();
|
||||
resetTimers();
|
||||
}
|
||||
|
||||
realLog('== ⑩c H1/B2 escape:目标句后按房间图播方位句(round2 B2:三处固定接线点升级为"目标名+方位"一体句,异常路径回落 gd_room_*)==');
|
||||
{
|
||||
// 方向映射按图校验(直连单元断言,gd_room_* 映射表保留不动)
|
||||
const rig0 = makeRig(EscapeGame);
|
||||
const { game: g0 } = rig0;
|
||||
await rig0.audio.unlock();
|
||||
g0.x = 1; g0.y = 1;
|
||||
assert(g0._roomDirVoice([0, 1]) === 'voice/gd_room_l', 'H1:目标在西 → gd_room_l');
|
||||
assert(g0._roomDirVoice([2, 1]) === 'voice/gd_room_r', 'H1:目标在东 → gd_room_r');
|
||||
assert(g0._roomDirVoice([1, 0]) === 'voice/gd_room_u', 'H1:目标在北(上面的房间)→ gd_room_u');
|
||||
assert(g0._roomDirVoice([1, 2]) === 'voice/gd_room_d', 'H1:目标在南(下面的房间)→ gd_room_d');
|
||||
assert(g0._roomDirVoice([1, 1]) === null, 'H1:目标在同一房间不播');
|
||||
// B2 异常路径回落:计算方位与枚举不符 → 原 gd_room_*(原句保留接线,归因真话)
|
||||
g0._playGoalDir([0, 1], 'voice/gd_cabinet_dir', 'voice/gd_room_r'); // 目标在西 ≠ 枚举右
|
||||
await flush();
|
||||
assert(rig0.playLog.some((e) => e.id === 'voice/gd_room_l'), 'B2:方位与枚举不符时回落 gd_room_*');
|
||||
g0.stop();
|
||||
resetTimers();
|
||||
|
||||
// 三处目标句接线点:found_key → cabinet(2,1);gd_next_fuse → box(0,0);fix_box → gate(2,2)
|
||||
// (B2 换线:gd_room_r/l → gd_cabinet_dir/gd_box_dir/gd_gate_dir,断言同步登记变更)
|
||||
const rig = makeRig(EscapeGame);
|
||||
const { audio, playLog, game } = rig;
|
||||
storageData.set('soundgame.escape.guideDone', '1'); // 二周目直达 REOPEN→PLAY
|
||||
await audio.unlock();
|
||||
game.start();
|
||||
await advance(() => game.phase === 'REOPEN');
|
||||
await advance(() => !audio.isVoicePlaying()); // reopen_prompt 播完
|
||||
await gesture(game, 'tap'); // 直接开始 → PLAY (1,1)(B8:start_room → g7_goal 链)
|
||||
await advance(() => game.phase === 'PLAY' && !audio.isVoicePlaying());
|
||||
assert(game.x === 1 && game.y === 1 && !game.hasKey, '前提:二周目 PLAY 起点、身上无钥匙');
|
||||
|
||||
// ① found_key:储物间拾钥匙 → 铁柜房在东 → B2 一体句 gd_cabinet_dir
|
||||
await gesture(game, 'left'); // (0,1)(B5a:首访播 empty_2)
|
||||
await gesture(game, 'down'); // (0,2) 储物间(首到播 room_storage)
|
||||
await advance(() => !audio.isVoicePlaying()); // 房间描述播完再单击(否则单击=跳句)
|
||||
await gesture(game, 'tap'); // 检查 → found_key(800ms 后)→ 一体句
|
||||
await advance(() => playLog.some((e) => e.id === 'voice/gd_cabinet_dir'));
|
||||
const ifk = playLog.findIndex((e) => e.id === 'voice/found_key');
|
||||
assert(ifk > -1 && playLog.findIndex((e) => e.id === 'voice/gd_cabinet_dir') > ifk,
|
||||
'B2:found_key 后接播 gd_cabinet_dir(铁柜,在右面的房间)');
|
||||
assert(!playLog.some((e) => e.id === 'voice/gd_room_r'), 'B2:正常路径不再播 gd_room_r');
|
||||
|
||||
// ② gd_next_fuse:开柜得保险丝 → 电箱房在西 → B2 一体句 gd_box_dir
|
||||
await gesture(game, 'right'); await gesture(game, 'right'); // (1,2) (2,2)
|
||||
await gesture(game, 'up'); // (2,1) 铁柜房
|
||||
await gesture(game, 'doubletap'); // 开柜
|
||||
await advance(() => playLog.some((e) => e.id === 'voice/gd_box_dir'));
|
||||
const inf = playLog.findIndex((e) => e.id === 'voice/gd_next_fuse');
|
||||
assert(inf > -1 && playLog.findIndex((e) => e.id === 'voice/gd_box_dir') > inf,
|
||||
'B2:gd_next_fuse 后接播 gd_box_dir(电箱,在左面的房间)');
|
||||
assert(!playLog.some((e) => e.id === 'voice/gd_room_l'), 'B2:正常路径不再播 gd_room_l');
|
||||
|
||||
// ③ fix_box:修电箱通电 → 大门在东 → B2 一体句 gd_gate_dir
|
||||
await advance(() => !audio.isVoicePlaying()); // gd_box_dir 播完再走(首访空房句不顶链)
|
||||
await gesture(game, 'left'); await gesture(game, 'left'); // (1,1) (0,1)
|
||||
await gesture(game, 'up'); // (0,0) 电箱房
|
||||
await gesture(game, 'doubletap'); // 修电箱
|
||||
await advance(() => playLog.some((e) => e.id === 'voice/gd_gate_dir'));
|
||||
const ifx = playLog.findIndex((e) => e.id === 'voice/fix_box');
|
||||
assert(ifx > -1 && playLog.findIndex((e) => e.id === 'voice/gd_gate_dir') > ifx,
|
||||
'B2:fix_box 后接播 gd_gate_dir(大门,在右面的房间)');
|
||||
assert(game.powerOn, '前提:电箱已通电(接线点均在真实解谜链上)');
|
||||
game.stop();
|
||||
resetTimers();
|
||||
}
|
||||
|
||||
realLog('== ⑩d B6 escape:引导 busy 窗口手势缓存(full 档)——解锁后补判、答错路径、不触发跳过 ==');
|
||||
{
|
||||
const rig = makeRig(EscapeGame);
|
||||
const { audio, playLog, game } = rig;
|
||||
await audio.unlock();
|
||||
game.start();
|
||||
await advance(() => game.phase === 'GUIDE' && !game._guideBusy && !audio.isVoicePlaying()); // G0 待输入
|
||||
await gesture(game, 'tap'); // G0 → _advance(1) busy(g1_move + skip_hint)
|
||||
assert(game._guideBusy, '前提:G0→G1 推进语音播放中(busy 窗口)');
|
||||
await gesture(game, 'up'); // busy 窗口注入滑动 → 缓存(不当场判定)
|
||||
assert(game.guideStep === 0 && game.guideSwipes === 0, 'B6:busy 窗口手势不当场判定');
|
||||
// 解锁 G1 → 缓存 up 按 G1 补判 = 作答(sfx/step + 推进 G2)
|
||||
await advance(() => game.guideStep === 2 && !game._guideBusy);
|
||||
assert(playLog.some((e) => e.id === 'sfx/step'), 'B6:full 档下缓存手势解锁后补判作答(脚步声)');
|
||||
|
||||
// G2 busy 缓存非答案手势 → 解锁 G3 后按答错路径(纠正语音,不触发下滑跳过)
|
||||
await gesture(game, 'up'); // G2 第一滑
|
||||
await advance(() => !audio.isVoicePlaying());
|
||||
await gesture(game, 'right'); // G2 第二滑 → _advance(3) busy
|
||||
await gesture(game, 'down'); // busy 缓存下滑(非 G3 答案)
|
||||
assert(game.guideStep === 2, 'B6:busy 窗口仍停 G2');
|
||||
await advance(() => game.guideStep === 3 && !game._guideBusy);
|
||||
assert(voicePlays(playLog, 'voice/guide_wrong_left') === 1, 'B6:缓存手势不合法按答错路径处理(guide_wrong_left)');
|
||||
assert(voicePlays(playLog, 'voice/guide_skip') === 0, 'B6:缓存的下滑不触发跳过引导(live round1 S6 事故修复)');
|
||||
assert(game.phase === 'GUIDE', 'B6:仍在引导中(未被保送 PLAY)');
|
||||
game.stop();
|
||||
resetTimers();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 汇总
|
||||
|
||||
realLog(`\n通过 ${passCount} 条断言,失败 ${failures.length} 条`);
|
||||
if (failures.length > 0) {
|
||||
realLog('失败清单:');
|
||||
|
||||
Reference in New Issue
Block a user