round4: H1 escape目标带实时方位 + H2 simon轮次占用预告(根治手势两义) + H3 重听账目真实化(诊断=初始发放1次vs承诺2次) + H4 input看门狗5s + H5 归因真话(gd_miss_none) + 20语音 + guide120断言全绿

This commit is contained in:
SoundGame Dev
2026-08-31 02:51:41 +08:00
parent 6be3d29b78
commit 3d37d4656d
55 changed files with 822 additions and 215 deletions
+190 -3
View File
@@ -25,6 +25,7 @@ const GAME_DIRS = {
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;
@@ -169,6 +170,7 @@ 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 });
@@ -275,6 +277,12 @@ realLog('== ②/①b simon:首轮 turn cue(earcon 事件+confirm 震+your_tu
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');
@@ -283,12 +291,12 @@ 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
// ①b input 看门狗:round4 H4 首级 10s→5s,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(now - tInput === 5000, `H4:input 首级 5s 后 nudge 触发(实际 ${now - tInput}ms)`);
assert(events.some((e) => e.type === 'watchdog' && e.voiceId === 'gd_relisten'),
'input 10s 无输入发 gd_relisten 事件');
'input 5s 无输入发 gd_relisten 事件');
assert(voicePlays(playLog, 'voice/gd_relisten') === 1, 'gd_relisten 语音播放');
assert(game.chances === 1, '看门狗提示不消耗重听机会');
@@ -599,6 +607,185 @@ realLog('== ⑨ G7 space:loud 警报档(撞击前 500ms)同窗 dir_* 超
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:余量句与真实余量一致——第 1 次免费重听用掉(2→1)播 left1,第 2 次(1→0)播 left0
fixSequence(game, []); // 无占用填充轮
await advance(() => game.phase === 'input');
game.chances = 2; // 测试钩子:满额免费重听(初始 1 + 第 4 轮完成 +1,上限 2)
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.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.chances === 0 && voicePlays(playLog, 'voice/gd_chances_left0') === l0_0 + 1,
'H3:第 2 次免费重听用掉播 gd_chances_left0(「用完了」只在真实余量为 0 时出现)');
await advance(() => game.phase === 'input');
const co0 = voicePlays(playLog, 'voice/chances_out');
await gesture(game, 'up'); // 余 0 再求重听:拒绝路径(机会账)行为不变
assert(game.phase === 'input' && voicePlays(playLog, 'voice/chances_out') === co0 + 1,
'H3:余 0 再求重听仍拒绝并播 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 escape:目标句后按房间图播 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:目标在同一房间不播');
g0.stop();
resetTimers();
// 三处目标句接线点:found_key → cabinet(2,1);gd_next_fuse → box(0,0);fix_box → gate(2,2)
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)
await advance(() => game.phase === 'PLAY' && !audio.isVoicePlaying());
assert(game.x === 1 && game.y === 1 && !game.hasKey, '前提:二周目 PLAY 起点、身上无钥匙');
// ① found_key:储物间拾钥匙 → 铁柜房在东(右面的房间)
await gesture(game, 'left'); // (0,1)
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_room_r'));
const ifk = playLog.findIndex((e) => e.id === 'voice/found_key');
assert(ifk > -1 && playLog.findIndex((e) => e.id === 'voice/gd_room_r') > ifk,
'H1:found_key 后接播 gd_room_r(目标铁柜房按图在东)');
// ② gd_next_fuse:开柜得保险丝 → 电箱房在西(左面的房间)
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_room_l'));
const inf = playLog.findIndex((e) => e.id === 'voice/gd_next_fuse');
assert(inf > -1 && playLog.findIndex((e) => e.id === 'voice/gd_room_l') > inf,
'H1:gd_next_fuse 后接播 gd_room_l(目标电箱房按图在西)');
// ③ fix_box:修电箱通电 → 大门在东(右面的房间)
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.filter((e) => e.id === 'voice/gd_room_r').length >= 2);
const ifx = playLog.findIndex((e) => e.id === 'voice/fix_box');
assert(ifx > -1 && playLog.map((e) => e.id).lastIndexOf('voice/gd_room_r') > ifx,
'H1:fix_box 后接播 gd_room_r(目标大门按图在东)');
assert(game.powerOn, '前提:电箱已通电(接线点均在真实解谜链上)');
game.stop();
resetTimers();
}
// ---------------------------------------------------------------- 汇总
realLog(`\n通过 ${passCount} 条断言,失败 ${failures.length} 条`);