fix(escape): 暗室状态引导方案落地(25号策划文档 B1-B8 → F1-F9 实施)

策划轮:25-escape-map-flow.md as-built地图/玩家流程/信息账本+盲区清单(S1-S7)+方案B1-B8
实施(玩法零改动,只加状态引导层):
- F1 撞墙方位句「左边是墙」(wall_dir_*×4,同向10s限频)
- F2 目标句并入方位「铁柜,在右面的房间」(gd_*_dir×3)
- F3 无进展150s活跃计时器+gd_prog_1..3进度重述(只重述不泄题,专治活跃迷路玩家——修卡关计时从「无进展」退化为「无操作」的既定偏差)
- F4 双击兜底直通L2降级L1(不再白送答案)
- F5 空房首访描述(4空房可区分)
- F7 busy窗口手势缓存补判(原静默吞手势)
- F8 结算链单击守卫(time_band/again完整到达)
- F9 跳过引导补播目标句
- F6 空房方位特征句按25号裁定留档后续轮
新增语音10句×2性别(flock串行QC无离群,ASR逐字正确)
验证:escape playtest 235/235(+85新断言)+ guide 132/132 + 终检全绿 + 14游戏回归零回退
live复跑10.4分钟通关:零截断,S1-S7盲区逐条核销
档案 research/escape-state/round1-2;01号文档追加修订记录R1
This commit is contained in:
xsl
2026-09-15 00:37:41 +08:00
parent 258ffd5ba3
commit 963dfa1cc8
60 changed files with 4207 additions and 80 deletions
+480 -5
View File
@@ -418,13 +418,16 @@ section('A. 关键路径:引导 → 解谜 → 逃脱 → 结算 → 菜单
assert(rec.count('voice/wall') >= 1, '撞墙播 wall 语音');
await finishVoices(1);
// ---- 边界:双击无目标兜底 = 背包/use_fail + L2 明示,不崩 ----
// ---- 边界:双击无目标兜底 = 背包/use_fail + L1 意象暗示,不崩(escape-state
// round2 B4 变更登记:原直通 L2 明示 stuck_*_2 把答案白送给没卡关的玩家,
// 降级为 L1 意象句 stuck_*_1——不点明房间与动作;答案仍锁在计时器 L2/L3) ----
const errCnt2 = vibrateCount(ERR_PAT);
await gs(game, 'doubletap'); // 电箱房无保险丝
assert(vibrateCount(ERR_PAT) === errCnt2 + 1, '双击无目标发 error 震动');
assert(rec.count('voice/use_fail') === 1, '兜底播 use_fail(关键房间)');
await finishVoices(1);
assert(rec.count('voice/stuck_key_2') === 1, '兜底追加 L2 明示 stuck_key_2');
assert(rec.count('voice/stuck_key_1') === 1, 'B4:兜底追加 L1 意象暗示 stuck_key_1(原 L2 已降级)');
assert(rec.count('voice/stuck_key_2') === 0, 'B4:兜底不再直通 L2 明示 stuck_key_2');
await finishVoices(1);
game.stop();
@@ -501,7 +504,16 @@ section('A2. 冷启动引导链:intro 后 g1_move 自动播出,零单击走
await gs(g2b.game, 'down'); // G3 答案 = 左滑,下滑 = 跳过
assert(g2b.rec.count('voice/guide_skip') === 1, 'G3 下滑仍跳过引导');
await tick(VOICE_END_SLACK);
assert(g2b.game.phase === 'PLAY', 'G3 下滑跳过直达 PLAY');
// B8(escape-state round2 变更登记):跳过确认句后、进 PLAY 前补播目标句
// g7_goal——跳过链从 1 句变 2 句,第一句播完时仍在 GUIDE
assert(g2b.rec.count('voice/g7_goal') === 1, 'B8:guide_skip 后补播 g7_goal');
assert(g2b.game.phase === 'GUIDE', 'B8:g7_goal 补播期间尚未进 PLAY');
await tick(VOICE_END_SLACK);
assert(g2b.game.phase === 'PLAY', 'G3 下滑跳过(补播 g7_goal 后)直达 PLAY');
assert(g2b.rec.count('voice/start_room') === 1, 'B8:跳过路径 g7_goal 后播 start_room');
const goalT = g2b.rec.last('voice/g7_goal').t;
const startT = g2b.rec.last('voice/start_room').t;
assert(goalT < startT, 'B8:跳过路径播报顺序 guide_skip → g7_goal → start_room');
g2b.game.stop();
}
@@ -538,7 +550,7 @@ section('B. 引导纠错与跳过');
assert(game.guideStep === 3 && game._guideBusy, '左滑后正常推进(G4 语音中)');
game.stop();
// 下滑跳过引导
// 下滑跳过引导(B8:确认句后补播 g7_goal 再进 PLAY)
store.clear();
const g2 = await newGame();
g2.game.start();
@@ -546,7 +558,9 @@ section('B. 引导纠错与跳过');
await gs(g2.game, 'down');
assert(g2.rec.count('voice/guide_skip') === 1, '引导期下滑播 guide_skip');
await tick(VOICE_END_SLACK);
assert(g2.game.phase === 'PLAY', '下滑跳过引导直达 PLAY');
assert(g2.rec.count('voice/g7_goal') === 1, 'B8:guide_skip 后补播 g7_goal');
await tick(VOICE_END_SLACK);
assert(g2.game.phase === 'PLAY', '下滑跳过引导(补播 g7_goal 后)直达 PLAY');
assert(store.get('soundgame.escape.guideDone') === '1', '跳过引导也写 guideDone');
assert(g2.rec.count('voice/start_room') === 1, '跳过引导播 start_room');
g2.game.stop();
@@ -807,6 +821,466 @@ section('E. 设置边界');
game.stop();
}
// ================================================================
// 场景 G:B1 撞墙方位句(wall_dir_* 接播 + 同向 10s 限频 + 打断作废;引导期不播)
// ================================================================
section('G. B1 撞墙方位句(escape-state round2 F1)');
{
// 引导期 G2 伪撞墙不播方位句(B1 仅 PLAY 阶段)
store.clear();
const gd = await newGame();
gd.game.start();
await flush();
await tick(VOICE_END_SLACK);
await gs(gd.game, 'tap'); // G0 → G1
await finishVoices(2);
await gs(gd.game, 'up'); // G1
await finishVoices(2);
await gs(gd.game, 'up'); // G2 ×2(伪撞墙)
await gs(gd.game, 'left');
await finishVoices(2);
assert(gd.rec.count('voice/wall') >= 2, '前提:G2 撞墙播过 wall 语音');
for (const d of ['l', 'r', 'u', 'd']) {
assert(gd.rec.count(`voice/wall_dir_${d}`) === 0, `B1:引导期 G2 撞墙不播 wall_dir_${d}(仅 PLAY)`);
}
gd.game.stop();
store.clear();
store.set('soundgame.escape.guideDone', '1');
const { game, rec } = await newGame();
game.start();
await flush();
await gs(game, 'tap'); // REOPEN → PLAY(start_room → g7_goal)
await finishVoices(2);
await gs(game, 'left'); // (0,1) B5a 首访 empty_2
await gs(game, 'up'); // (0,0) room_power
await finishVoices(2);
// ① 撞墙后 wall 播完接播方位句(顺序:wall → wall_dir_u)
await gs(game, 'up'); // 撞北墙
assert(rec.count('voice/wall_dir_u') === 0, 'B1:wall 播放中方位句未播(接播不抢话)');
await finishVoices(1);
assert(rec.count('voice/wall_dir_u') === 1, 'B1:wall 播完接播 wall_dir_u「上边是墙。」');
assert(rec.last('voice/wall_dir_u').t >= rec.last('voice/wall').t + 1000, 'B1:方位句在 wall 自然播完之后');
await finishVoices(1);
// ② 打断作废:撞左墙 wall 未播完再撞上墙——左墙方位句作废;
// 上墙 10s 限频内(① 刚播过)不再重复
await gs(game, 'left'); // wall(左)
await gs(game, 'up'); // wall(上)顶掉上一句
await finishVoices(2);
assert(rec.count('voice/wall_dir_l') === 0, 'B1:wall 被打断则方位句自然作废(wall_dir_l 未播)');
assert(rec.count('voice/wall_dir_u') === 1, 'B1:同向 10s 限频——上墙方位句不重复(仍 1 次)');
// ③ 同向限频与恢复:左墙首播 → 10s 内再撞不重复 → 窗口过后恢复
await gs(game, 'left'); // 左墙(② 的接播被作废,未播过 → 本次接播)
await finishVoices(2);
assert(rec.count('voice/wall_dir_l') === 1, 'B1:被作废的方向补撞后方位句正常接播');
await gs(game, 'left'); // 10s 窗口内再撞左墙
await finishVoices(2);
assert(rec.count('voice/wall_dir_l') === 1, 'B1:同向 10s 内限频 1 次(wall_dir_l 不重复)');
await tick(10000);
await gs(game, 'left');
await finishVoices(2);
assert(rec.count('voice/wall_dir_l') === 2, 'B1:限频窗口过后同向方位句恢复');
game.stop();
}
// ================================================================
// 场景 H:B2 目标名+方位一体句(gd_*_dir 替换三处接播,异常路径回落 gd_room_*)
// ================================================================
section('H. B2 目标名+方位一体句(escape-state round2 F2)');
{
store.clear();
store.set('soundgame.escape.guideDone', '1');
const { game, rec } = await newGame();
game.start();
await flush();
await gs(game, 'tap'); // REOPEN → PLAY (1,1)
await finishVoices(2); // start_room + g7_goal
// ① found_key 链尾 → gd_cabinet_dir(储物间 (0,2) → 铁柜房 (2,1),固定为右)
game.x = 0; game.y = 2; // 测试钩子:瞬移储物间
await gs(game, 'tap'); // 检查 → 拾钥匙
await tick(800); // found_key 起播
await finishVoices(2); // found_key → gd_cabinet_dir
assert(rec.count('voice/found_key') === 1, '前提:拾钥匙播 found_key');
assert(rec.count('voice/gd_cabinet_dir') === 1, 'B2:found_key 链尾播 gd_cabinet_dir「铁柜,在右面的房间。」');
assert(rec.last('voice/gd_cabinet_dir').t > rec.last('voice/found_key').t, 'B2:一体句在 found_key 之后');
assert(rec.count('voice/gd_room_r') === 0, 'B2:正常路径不再播无目标名的 gd_room_r');
// ② unlock_cabinet/gd_next_fuse 链尾 → gd_box_dir(铁柜房 → 电箱房 (0,0),固定为左)
game.x = 2; game.y = 1; // 钩子:瞬移铁柜房
await gs(game, 'doubletap');
await finishVoices(3); // unlock_cabinet → gd_next_fuse → gd_box_dir
assert(rec.count('voice/gd_box_dir') === 1, 'B2:gd_next_fuse 链尾播 gd_box_dir「电箱,在左面的房间。」');
assert(rec.last('voice/gd_box_dir').t > rec.last('voice/gd_next_fuse').t, 'B2:一体句在 gd_next_fuse 之后');
assert(rec.count('voice/gd_room_l') === 0, 'B2:正常路径不再播 gd_room_l');
// ③ fix_box 链尾 → gd_gate_dir(电箱房 → 大门 (2,2),固定为右)
game.x = 0; game.y = 0; // 钩子:瞬移电箱房
await gs(game, 'doubletap');
await finishVoices(2); // fix_box → gd_gate_dir
assert(rec.count('voice/gd_gate_dir') === 1, 'B2:fix_box 链尾播 gd_gate_dir「大门,在右面的房间。」');
assert(rec.last('voice/gd_gate_dir').t > rec.last('voice/fix_box').t, 'B2:一体句在 fix_box 之后');
// ④ 异常路径回落:计算方位与枚举不符 → 回落原 gd_room_*(归因真话,原句保留接线)
game.x = 1; game.y = 1; // 钩子:异常位置(正常链上不存在)
game._playGoalDir([0, 1], 'voice/gd_cabinet_dir', 'voice/gd_room_r'); // 目标在西 ≠ 枚举右
await flush();
assert(rec.count('voice/gd_room_l') === 1, 'B2:方位与枚举不符时回落 gd_room_*(原句保留接线)');
await finishVoices(1);
game._playGoalDir([1, 1], 'voice/gd_cabinet_dir', 'voice/gd_room_r'); // 同房间不播(H1 原语义)
await flush();
assert(rec.count('voice/gd_room_l') === 1 && rec.count('voice/gd_cabinet_dir') === 1,
'B2:目标在同一房间不播(H1 原语义不变)');
game.stop();
}
// ================================================================
// 场景 I:B3 无进展 150s 活跃计时器(gd_prog 进度重述)
// 边界:key 阶段不走 / ≥5 手势闸门(挂机不触)/ 每段限 1 次 /
// 有进展时复位 / 待播期间操作作废且计入已播
// ================================================================
section('I. B3 无进展活跃计时器(escape-state round2 F3)');
{
store.clear();
store.set('soundgame.escape.guideDone', '1');
const { audio, game, rec } = await newGame();
game.start();
await flush();
await gs(game, 'tap'); // REOPEN → PLAY
await finishVoices(2);
// 往返手势一律 (1,1)↔(0,1)(空房,避开关键房间的首到进展事件)
const wander = async (n) => { for (let i = 0; i < n; i++) await gs(game, i % 2 ? 'right' : 'left'); };
// ① key 阶段不走本通道:无进展 200s + 活跃 5 手势 → 无 gd_prog
await wander(5);
game._lastProgressAt = now - 200000; // 钩子:模拟 200s 无进展
game._checkStuck();
await flush();
assert(rec.count('voice/gd_prog_1') === 0, 'B3:key 阶段不走本通道(L1/L2/L3 已覆盖)');
await tick(1600); // 消化首访 empty_2
// ② 拾钥匙进 cabinet 阶段(进展事件复位计时)
game.x = 0; game.y = 2; // 钩子:瞬移储物间
await gs(game, 'tap');
await tick(800);
await finishVoices(2); // found_key + gd_cabinet_dir
assert(game._stage() === 'cabinet' && game._gesturesSinceProgress === 0,
'B3 前提:cabinet 阶段,进展事件已复位计数');
// ③ 闸门:无进展 151s 但活跃手势 <5(挂机/低活跃)→ 不触
game._lastProgressAt = now - 151000;
game._checkStuck();
await flush();
assert(rec.count('voice/gd_prog_1') === 0, 'B3:活跃手势 <5 不触(挂机交给 60s L1 体系)');
// ④ 活跃触发:5 手势后无进展 151s → gd_prog_1 到达一次
game.x = 1; game.y = 1;
await wander(5);
await tick(1600);
game._checkStuck(); // _lastProgressAt 仍为 ② 的钩子值(移动不复位无进展计时)
await flush();
assert(rec.count('voice/gd_prog_1') === 1, 'B3:活跃(≥5 手势)无进展 150s 播 gd_prog_1');
// ⑤ 每进展段限 1 次:再到点不重复
await finishVoices(1);
game._lastProgressAt = now - 200000;
game._checkStuck();
await flush();
assert(rec.count('voice/gd_prog_1') === 1, 'B3:gd_prog_1 每进展段限 1 次');
// ⑥ 有进展时复位:开柜(进展事件)→ 计数清零、计时重起,不立即触下一段
game.x = 2; game.y = 1;
await gs(game, 'doubletap'); // 开柜
assert(game._gesturesSinceProgress === 0, 'B3:进展事件后活跃手势计数清零(复位)');
game._checkStuck(); // _lastProgressAt 刚复位,远不足 150s
await flush();
assert(rec.count('voice/gd_prog_2') === 0, 'B3:有进展时计时器复位(不立即触 gd_prog_2)');
await finishVoices(3); // unlock 链
// ⑦ box 段可再触一次
game.x = 1; game.y = 1;
await wander(5);
await tick(1600);
game._lastProgressAt = now - 151000;
game._checkStuck();
await flush();
assert(rec.count('voice/gd_prog_2') === 1, 'B3:box 阶段活跃无进展 150s 播 gd_prog_2');
await finishVoices(1);
// ⑧ 待播期间操作作废且计入已播(每段限 1 次不重发)
game.x = 0; game.y = 0;
await gs(game, 'doubletap'); // 修电 → gate 阶段(进展复位)
await finishVoices(2); // fix_box + gd_gate_dir
game.x = 1; game.y = 1;
await wander(5);
await tick(1600);
await gs(game, 'tap'); // 单击检查
await tick(800); // 检查描述播放中
assert(audio.isVoicePlaying(), 'B3 前提:检查描述播放中');
game._lastProgressAt = now - 151000;
game._checkStuck(); // 到点 → gd_prog_3 只入队
assert(rec.count('voice/gd_prog_3') === 0, 'B3:语音播放中 gd_prog 只入队不开播(不顶句)');
await gs(game, 'left'); // 玩家操作 → 待播 gd_prog_3 作废
await finishVoices(2);
assert(rec.count('voice/gd_prog_3') === 0, 'B3:待播期间操作即作废(不播出)');
assert(game._progShown.has('gate'), 'B3:作废仍计入已播(每进展段限 1 次)');
game._lastProgressAt = now - 200000;
game._checkStuck();
await flush();
assert(rec.count('voice/gd_prog_3') === 0, 'B3:作废的 gd_prog 不重发');
game.stop();
}
// ================================================================
// 场景 J:B4 双击兜底降级(背包 + L1 意象;去重;L2 仍由计时器到达)
// ================================================================
section('J. B4 双击兜底降级(escape-state round2 F4)');
{
store.clear();
store.set('soundgame.escape.guideDone', '1');
const { game, rec } = await newGame();
game.start();
await flush();
await gs(game, 'tap'); // REOPEN → PLAY (1,1) 非关键房
await finishVoices(2);
// 非关键房兜底 = 背包 + L1 意象暗示(不再直通 L2);播了即记账
await gs(game, 'doubletap');
assert(rec.count('voice/inv_empty') === 1, 'B4:非关键房兜底先播背包 inv_empty');
await finishVoices(2);
assert(rec.count('voice/stuck_key_1') === 1, 'B4:兜底接 L1 意象暗示 stuck_key_1');
assert(rec.count('voice/stuck_key_2') === 0, 'B4:兜底不直通 L2 明示 stuck_key_2');
assert(game._stuckShown.has('L1'), 'B4:兜底播 L1 即记账(沿用 _stuckShown 去重)');
// 同进展段再次兜底不重复 L1 意象句(既有去重)
await gs(game, 'doubletap');
await finishVoices(2);
assert(rec.count('voice/inv_empty') === 2, 'B4:再次兜底仍播背包');
assert(rec.count('voice/stuck_key_1') === 1, 'B4:同进展段再次兜底不重复 L1 意象句');
// 降级后 L2 仍可达:L1 已记账 → 60s 不再重复(整级跳过,stuck_ask 亦不播);
// 120s 无操作 L2 明示照常到达(难度不降)
await tick(65000);
assert(rec.count('voice/stuck_ask') === 0, 'B4:L1 已记账,60s 无操作不再重复 L1 级');
await tick(60000);
assert(rec.count('voice/stuck_key_2') === 1, 'B4:降级后 L2 仍由计时器到达(120s 无操作)');
game.stop();
}
// ================================================================
// 场景 K:B5a 空房首访描述(首访到达 / 重访不重复 / 单击检查语义 / 非进展事件)
// ================================================================
section('K. B5a 空房首访描述(escape-state round2 F5)');
{
store.clear();
store.set('soundgame.escape.guideDone', '1');
const { game, rec } = await newGame();
game.start();
await flush();
await gs(game, 'tap'); // REOPEN → PLAY (1,1)
await finishVoices(2);
// 首访空房不算进展事件:不清卡关已播级别
game._stuckShown.add('L1'); // 钩子:模拟本段已播 L1
await gs(game, 'up'); // (1,0) 首访
assert(game._stuckShown.has('L1'), 'B5a:空房首访不算进展事件(不清 _stuckShown)');
game._stuckShown.clear(); // 复位钩子
// 首访 (1,0) → empty_1;本房间内单击不再重复首访句;重访零语音;重访后单击照常
assert(rec.count('voice/empty_1') === 1, 'B5a:空房首访补播 empty_1(一次性听觉锚)');
await finishVoices(1);
await gs(game, 'tap'); // 首访句刚播过的房间内单击
await tick(900);
assert(rec.count('voice/empty_1') === 1, 'B5a:首访句刚播过的房间内单击不重复同一句');
await gs(game, 'down'); // (1,1)
await gs(game, 'up'); // (1,0) 重访
await tick(900);
assert(rec.count('voice/empty_1') === 1, 'B5a:空房重访零语音(不重复首访句)');
await gs(game, 'tap'); // 重访后单击检查
await tick(900);
assert(rec.count('voice/empty_1') === 2, 'B5a:重访后单击检查照常播 empty_1(语义不变)');
await finishVoices(1);
// 四房映射:(0,1)→empty_2、(1,2)→empty_3、(2,0)→empty_3(对称水滴共用合法记忆点)
await gs(game, 'down'); // (1,1)
await gs(game, 'left'); // (0,1) 首访
assert(rec.count('voice/empty_2') === 1, 'B5a:(0,1) 首访播 empty_2');
await finishVoices(1);
await gs(game, 'right'); // (1,1)
await gs(game, 'down'); // (1,2) 首访
assert(rec.count('voice/empty_3') === 1, 'B5a:(1,2) 首访播 empty_3');
await finishVoices(1);
game.x = 2; game.y = 1; // 钩子:瞬移铁柜房(已 visited 与否不影响下行)
game.visited.add('2,1');
await gs(game, 'up'); // (2,0) 首访
assert(rec.count('voice/empty_3') === 2, 'B5a:(2,0) 首访复用 empty_3(两间"远处水滴"对称分布)');
await finishVoices(1);
game.stop();
}
// ================================================================
// 场景 L:B6 引导 busy 窗口手势缓存(补判 / 答错路径 / 不触发跳过 / 仅 1 个)
// ================================================================
section('L. B6 引导 busy 手势缓存(escape-state round2 F7)');
{
store.clear();
const { game, rec } = await newGame();
game.start();
await flush();
await tick(VOICE_END_SLACK); // intro 播完(自动推进 1.2s 未到点)
await gs(game, 'tap'); // G0 → _advance(1) busy
// ① busy 窗口手势缓存:不当场判定,解锁后按新步骤补判(合法 = 作答)
await gs(game, 'up'); // busy 窗口注入滑动 → 缓存
assert(game.guideStep === 0 && game._guideBusy, 'B6 前提:busy 窗口内步骤未解锁');
const stepsAtBusy = rec.count('sfx/step');
assert(rec.count('sfx/step') === stepsAtBusy, 'B6:busy 窗口手势不当场判定(无脚步声)');
await finishVoices(2); // g1_move + skip_hint 播完 → 解锁 G1 → 缓存 up 补判
assert(rec.count('sfx/step') === stepsAtBusy + 1, 'B6:解锁后缓存手势补判(G1 滑动作答,脚步声)');
assert(game.guideStep === 1 && game._guideBusy, 'B6:补判作答后正常推进(G2 语音中)');
await finishVoices(2); // g1_done + g2_wall
assert(game.guideStep === 2, 'B6:进入 G2');
// ② 缓存手势对新步骤合法:G2→G3 busy 缓存左滑(G3 的答案)→ 解锁直接作答
await gs(game, 'up'); // G2 第一滑
await tick(VOICE_END_SLACK); // wall 播完
await gs(game, 'right'); // G2 第二滑 → _advance(3) busy
await gs(game, 'left'); // busy 缓存左滑
assert(game.guideStep === 2, 'B6:busy 窗口仍停 G2');
await finishVoices(2); // wall + g3_left 播完 → 解锁 G3 → 缓存 left 补判
assert(game.x === 0 && game.y === 1, 'B6:缓存左滑按 G3 补判作答(移到 (0,1))');
assert(game.guideStep === 3 && game._guideBusy, 'B6:补判作答后推进 G4');
assert(rec.count('voice/guide_skip') === 0, 'B6:缓存手势不触发下滑跳过(S6 事故修复)');
await finishVoices(1); // g4_down → G4 解锁
// ③ 缓存手势对新步骤不合法:按答错路径处理(有纠正语音,可归因)
await gs(game, 'down'); // G4 作答 → _advance(5) busy
await gs(game, 'up'); // busy 缓存 up
await finishVoices(1); // 解锁 G5 → 补判:G5 期望单击,up = 答错
assert(game.guideStep === 5, 'B6:进入 G5');
assert(rec.count('voice/guide_wrong_gesture') === 1, 'B6:缓存手势不合法按答错路径处理(纠正语音可归因)');
assert(game.guideErrs === 1, 'B6:答错计数照常');
assert(rec.count('voice/guide_skip') === 0, 'B6:答错路径同样不触发跳过');
// ④ 仅缓存 1 个:busy 连注两个手势,解锁后只补判首个
await gs(game, 'tap'); // G5 作答 → _advance(6) busy
await gs(game, 'doubletap'); // 缓存(恰为 G6 答案)
await gs(game, 'left'); // 不再缓存(仅 1 个)
await finishVoices(2); // g5_key + g6_doubletap → 解锁 G6 → 补判 doubletap
assert(game.guideStep === 6 && game._guideBusy, 'B6:仅首个缓存手势补判(doubletap 作答 G6)');
await finishVoices(3); // inv_key + g6_done + g7_goal → PLAY
assert(game.phase === 'PLAY', 'B6:引导链完整走完(缓存不破坏既有推进)');
game.stop();
// ⑤ 跳过分手势不缓存:busy 窗口下滑(可跳过步骤)仍立即跳过
store.clear();
const g2b = await newGame();
g2b.game.start();
await flush();
await gs(g2b.game, 'tap'); // G0 → _advance(1) busy
assert(g2b.game._guideBusy, '前提:G0→G1 推进语音播放中');
await gs(g2b.game, 'down'); // busy 窗口下滑 = 跳过分手势 → 立即跳过,不缓存
assert(g2b.rec.count('voice/guide_skip') === 1, 'B6:busy 窗口下滑跳过语义不变(不缓存)');
assert(g2b.game._guidePending === null, 'B6:跳过分手势不进缓存');
g2b.game.stop();
}
// ================================================================
// 场景 M:B7 结算链单击守卫(播完前不重开 / 链完整到达 / 30s 兜底解除)
// ================================================================
section('M. B7 结算链单击守卫(escape-state round2 F8)');
{
store.clear();
store.set('soundgame.escape.guideDone', '1');
const { game, rec } = await newGame();
game.start();
await flush();
await gs(game, 'tap'); // REOPEN → PLAY
await finishVoices(2);
// 钩子速通:开柜 → 修电 → 大门
game.hasKey = true; game.x = 2; game.y = 1;
await gs(game, 'doubletap'); // 开柜
await finishVoices(3);
game.x = 0; game.y = 0;
await gs(game, 'doubletap'); // 修电
await finishVoices(2);
game.x = 2; game.y = 2;
await gs(game, 'doubletap'); // 逃脱
assert(game.phase === 'ENDING' && game._settleBusy, 'B7 前提:ENDING + 结算守卫挂载');
// 结算链播放中单击 = 忽略(链不被截断,time_band/again 完整到达)
await gs(game, 'tap');
assert(game.phase === 'ENDING', 'B7:结算链播完前单击不重开(仍 ENDING)');
assert(rec.count('voice/replay') === 0, 'B7:守卫期单击不触发重开(无 replay)');
await finishVoices(1);
assert(rec.count('voice/time_band1') === 1, 'B7:结算链完整——time_band 到达(不再被吞)');
await finishVoices(1);
assert(rec.count('voice/again') === 1, 'B7:结算链完整——again 到达');
await finishVoices(1); // 链完 → 守卫解除
assert(!game._settleBusy, 'B7:链播完守卫解除');
await gs(game, 'tap');
assert(game.phase === 'PLAY' && rec.count('voice/replay') === 1, 'B7:守卫解除后单击重开原语义不变');
game.stop();
// 30s 资产缺失兜底:again 缺失 → 链停摆 → 守卫靠兜底解除(防静默死锁)
blockedAssets = ['again'];
store.clear();
store.set('soundgame.escape.guideDone', '1');
const g2 = await newGame();
g2.game.start();
await flush();
await gs(g2.game, 'tap');
await finishVoices(2);
g2.game.hasKey = true; g2.game.x = 2; g2.game.y = 1;
await gs(g2.game, 'doubletap');
await finishVoices(3);
g2.game.x = 0; g2.game.y = 0;
await gs(g2.game, 'doubletap');
await finishVoices(2);
g2.game.x = 2; g2.game.y = 2;
await gs(g2.game, 'doubletap');
assert(g2.game.phase === 'ENDING', '前提:again 缺失下逃脱进 ENDING');
await finishVoices(2); // escape + time_band 播完,again 缺失链停摆
await gs(g2.game, 'tap');
assert(g2.game.phase === 'ENDING', 'B7:again 缺失链停摆时守卫仍生效(单击不重开)');
await tick(30000); // 兜底
assert(!g2.game._settleBusy, 'B7:30s 资产缺失兜底解除守卫(防静默死锁)');
await gs(g2.game, 'tap');
assert(g2.game.phase === 'PLAY', 'B7:兜底解除后可重开');
blockedAssets = [];
g2.game.stop();
}
// ================================================================
// 场景 N:B8 REOPEN 直进路径补播目标句(start_room → g7_goal,可单击跳过)
// ================================================================
section('N. B8 REOPEN 直进补播 g7_goal(escape-state round2 F9)');
{
store.clear();
store.set('soundgame.escape.guideDone', '1');
const { game, rec } = await newGame();
game.start();
await flush();
assert(game.phase === 'REOPEN', '前提:二周目 REOPEN');
await gs(game, 'tap'); // 直接开始
assert(game.phase === 'PLAY', 'REOPEN 单击直进 PLAY');
assert(rec.count('voice/start_room') === 1, 'B8:直进播 start_room');
await finishVoices(1);
assert(rec.count('voice/g7_goal') === 1, 'B8:start_room 后接播 g7_goal');
assert(rec.last('voice/g7_goal').t > rec.last('voice/start_room').t, 'B8:直进路径顺序 start_room → g7_goal');
// g7_goal 可单击跳过(skippable 默认 true,12 号文档——教学义务句已随首局完成,
// 此处是状态告知不是教学)
const skips0 = rec.skips();
await gs(game, 'tap'); // g7_goal 播放中单击 = 跳过 + 补发检查
assert(rec.skips() === skips0 + 1, 'B8:二周目 g7_goal 可单击跳过');
assert(game.phase === 'PLAY', 'B8:跳过后仍在 PLAY');
await finishVoices(2); // 补发检查描述消化
game.stop();
}
// ================================================================
// 资产引用校验:game.js 引用的 id 在音频目录有 ogg + m4a(voice 双性别),零缺失
// ================================================================
@@ -822,6 +1296,7 @@ section('F. 资产引用校验');
ids.add(`voice/guide_wrong_${d}`);
ids.add(`voice/guide_blunt_${d}`);
}
for (const d of ['l', 'r', 'u', 'd']) ids.add(`voice/wall_dir_${d}`); // B1 撞墙方位句族
for (const st of ['key', 'cabinet', 'box', 'gate']) {
for (const lv of [1, 2, 3]) ids.add(`voice/stuck_${st}_${lv}`);
}