live试玩round1+round2修复: 引擎gesture新增tapcancel(immediateTap误判定回滚,默认关闭零行为变化)+ rhythm判定账簿/超时Miss复活/菜单1s去抖/正片下滑永不弹菜单 + space判定冷静期800ms/暂停恢复3颗缓冲/回大厅leaving收尾/hit_last在播标记 + storyline intro满播不掐/抉择25s兜底 + simon免费重听两层账 + escape教学链自动接播/90s高频零提示 + rpg擦伤零误报/tap不丢 + guide.test适配去抖期(语义不变)+ 文档同步(13号§9-§19/web-engine-api/README)。回归:storyline82/escape140/simon132/rhythm141/space155/rpg188/guide122共921断言全绿

This commit is contained in:
xsl
2026-09-01 22:00:00 +08:00
parent 624fec8a23
commit 4e9970e227
22 changed files with 3161 additions and 401 deletions
+287 -4
View File
@@ -428,10 +428,11 @@ async function scenarioA() {
assert(t2 - t1 >= 400, 'A8: turn_player 与指令名之间留间隙不叠放(≥400ms)');
}
// T0:左右滑 2 次 → attack 步
// T0:左右滑 2 次 → attack 步(round1-live P2-3:T1 改播 tut_battle_2,不再播 tut_b3)
g(game, 'right'); g(game, 'left');
await tick(1500);
assert(bt.teachStage === 'attack' && plays(audio, mark).has('voice/tut_b3'), 'A8: T0 切换 2 次 → T1 attack(tut_b3)');
assert(bt.teachStage === 'attack' && plays(audio, mark).count('voice/tut_battle_2') >= 2,
'A8: T0 切换 2 次 → T1 attack(重播 tut_battle_2,不再播 tut_b3)');
// T1:选中攻击单击命中 → parry 步
await tick(1200);
const wolfHpBefore = bt.enemy.hp;
@@ -448,8 +449,9 @@ async function scenarioA() {
assert(game.player.hp < hpBeforeHit && game.player.hp >= 1, 'A8: T2 受击掉血且 ≥1');
assert(bt.teachStage === 'defend', 'A8: T2 受击后 → T3 defend');
// T3:选防御单击 → tut_b5 收尾;防御减伤 50% 演示(狼 base 2 → 1)
await tick(2500); // tut_b4+tut_b6 播完进 PLAYER_SELECT
assert(plays(audio, mark).orderOk('voice/tut_b4', 'voice/tut_b6'), 'A8: 格挡失败反馈 tut_b4 + 防御教学 tut_b6');
await tick(4200); // tut_b4+tut_battle_4+tut_b6 三句播完进 PLAYER_SELECT(round1-live P1 链扩为 3 句)
assert(plays(audio, mark).orderOk('voice/tut_b4', 'voice/tut_battle_4', 'voice/tut_b6'),
'A8: 格挡失败反馈 tut_b4 + 减伤教学 tut_battle_4 + 防御教学 tut_b6(round1-live P1 明说减伤经济)');
selectCmd(game, 'defend');
await tick(1200);
g(game, 'tap');
@@ -469,6 +471,8 @@ async function scenarioA() {
assert(p.orderOk('sfx/enemy_die', 'voice/btl_enemy_die', 'sfx/victory', 'voice/btl_win', 'voice/btl_loot_small'),
'A8: 胜利序列 enemy_die → victory sfx → btl_win → loot 有序');
assert(game.flags.tutorial_battle_done === true, 'A8: tutorial_battle_done 置位');
assert(plays(audio, mark).count('voice/tut_b3') === 0,
'A8: 教学全程不播 tut_b3(「挡住了!」与未格挡实况矛盾,round1-live P2-3 退出教学链)');
assert(game.stats.battlesWon === 1, 'A8: battlesWon 0→1(难度档位依据)');
{
const stop = audio.log.filter((e) => e.id === '(stop)').at(-1);
@@ -842,6 +846,278 @@ async function scenarioB6() {
assert(!plays(audio, mark).has('voice/boss1_warn'), 'B6: Lv3 到寨门不播 boss1_warn');
}
async function scenarioB7() {
origConsoleLog('\n=== 场景 B7:Boss 战全格挡擦伤上限(round1-live P1) ===');
localStorageMock.clear();
const { game, audio } = newGame();
game.start();
await tick(2000);
// 设计标准出装(11 文档 §3 数值表):Lv3 + 铁剑 + 皮甲,HP36
game.player.lv = 3;
game.player.hp = game.maxHp;
game.player.weapon = 'iron_sword';
game.player.armor = 'leather';
game._startBattle('enc_boss1'); // 钩子:直接进 Boss 战
await battlePump(game, audio);
const mark = audio.log.length;
const logsBefore = consoleLogs.length;
await fightSmart(game, audio, { maxRounds: 30 }); // 全格挡打法(每次前摇内双击,不喝药)
assert(game.flags.boss1_done === true, 'B7: 全格挡可击杀 Boss(章节 flags 置位)');
const dmgs = consoleLogs.slice(logsBefore)
.map((l) => l.match(/敌方攻击 dmg=(\d+) 格挡=(true|false)/))
.filter(Boolean);
assert(dmgs.length >= 3, `B7: Boss 战发生多次敌方攻击(${dmgs.length} 次)`);
assert(dmgs.every((m) => m[2] === 'true'), 'B7: 全部敌方攻击均被格挡(fightSmart 全双击)');
assert(dmgs.every((m) => Number(m[1]) >= 1 && Number(m[1]) <= 3),
`B7: 格挡擦伤全部 ∈[1,3](11 文档 §3「格挡成功 2-3 伤」+ 钳顶,实测 ${dmgs.map((m) => m[1]).join(',')})`);
assert(!plays(audio, mark).has('voice/st_hp_low'),
'B7: 全格挡 Boss 战不播 st_hp_low(血量播报与实际战况匹配)');
assert(game.player.hp / game.maxHp >= 0.3,
`B7: 全格挡战后血量不掉 low 档(${game.player.hp}/${game.maxHp})`);
// 确定性探针:base 12 的一刀被格挡 → 擦伤恒为 3(原实现 round(12×0.3)=4 超设计上限)
await battlePump(game, audio); // 章结算链走完回村
game._dmg = () => 12; // 钩子:伤害确定化
game._startBattle('enc_boss1');
await battlePump(game, audio, 5000);
g(game, 'tap'); // 攻击(敌 45→33)
g(game, 'doubletap'); // 前摇内双击格挡
await tick(500);
assert(lastEnemyDmg() === 3, `B7: 擦伤钳顶确定性探针(base 12 格挡 → ${lastEnemyDmg()},期望 3)`);
}
async function scenarioB8() {
origConsoleLog('\n=== 场景 B8:闲置提示与结算语音不互掐 + 返回项末位提示(round1-live P2/P3,guide=full) ===');
localStorageMock.clear();
settings.set({ guide: 'full' });
try {
const { game, audio } = newGame();
const guideEvents = [];
game.guide.setEventHook((e) => guideEvents.push(e));
await setupWolfBattle(game);
await battlePump(game, audio, 5000);
const b = game._battle;
assert(b && b.state === 'PLAYER_SELECT', 'B8: 野狼战进玩家回合');
// 对照组:无闲置提示在播时击杀 → btl_enemy_die 不排队(默认顶句语义不变)
b.enemy.hp = 1; // 钩子:一刀死
g(game, 'tap');
await tick(500);
const diePlain = audio.log.filter((e) => e.id === 'voice/btl_enemy_die').at(-1);
assert(diePlain && !diePlain.opts.queue, 'B8: 无闲置提示在播时击杀语音不排队(对照)');
await battlePump(game, audio); // 胜利链走完回探索
assert(game.phase === 'explore', 'B8: 首狼击杀回探索态');
// 第二只狼:PLAYER_SELECT 闲置 30s → gd_stall 开播 → 立即出手
game._wolfDone = false; // 钩子:允许再次遇狼
await reenterStream(game);
await battlePump(game, audio, 5000);
const b2 = game._battle;
assert(b2 && b2.state === 'PLAYER_SELECT', 'B8: 第二只狼进玩家回合');
guideEvents.length = 0;
let waited = 0;
while (!guideEvents.some((e) => e.type === 'watchdog' && e.voiceId === 'gd_stall' && !e.skipped)
&& waited < 45000) { await tick(500); waited += 500; }
assert(waited < 45000, 'B8: 闲置 30s 看门狗播 gd_stall(未跳过)');
assert(audio._voice && audio._voice.id === 'voice/gd_stall', 'B8: gd_stall 正在播');
b2.enemy.hp = 1; // 钩子:一刀死
g(game, 'tap'); // 结算链首句应排到 gd_stall 之后播完,不拦腰顶断
await tick(300);
const dieQueued = audio.log.filter((e) => e.id === 'voice/btl_enemy_die').at(-1);
assert(dieQueued && dieQueued.opts.queue === true,
'B8: gd_stall 开播中结算语音排队等播完(queue:true,不顶断)');
await battlePump(game, audio); // 胜利回探索
// P3:探索态右滑落到"返回"项 → 报名后接末位提示(每场景一次)
g(game, 'right'); await tick(1200); // go_hunter
g(game, 'tap'); await tick(3000); // 猎人小屋
const m3 = audio.log.length;
g(game, 'right'); await tick(1200); // go_deepwood
g(game, 'right'); await tick(1200); // back(末项)
assert(plays(audio, m3).orderOk('voice/opt_back', 'voice/gd_wrap_last'),
'B8: 落到"返回"项 → 报名 opt_back 后接末位提示 gd_wrap_last(每场景一次)');
g(game, 'left'); await tick(1200);
g(game, 'right'); await tick(1200); // 再次落到 back
assert(plays(audio, m3).count('voice/gd_wrap_last') === 1,
'B8: 同一场景内末位提示不重复(_wrapShown 去重)');
} finally {
settings.set({ guide: 'off' }); // 复位:其余场景契约是 guide=off
}
}
async function scenarioB9() {
origConsoleLog('\n=== 场景 B9:指令名去重防口吃(round1-live P2「攻攻击。」) ===');
localStorageMock.clear();
const { game, audio } = newGame();
await setupWolfBattle(game);
await battlePump(game, audio, 5000);
const b = game._battle;
assert(b && b.state === 'PLAYER_SELECT', 'B9: 野狼战进玩家回合');
// 折返滑 + 滞留 650ms 定时器:指令名不双播
game._playerSelect(); // 钩子:回合重进(650ms 指令名定时器挂载中)
const m1 = audio.log.length;
g(game, 'right'); g(game, 'left'); // 攻击→破空斩→攻击(<650ms 折返)
await tick(900);
assert(plays(audio, m1).count('voice/cmd_attack') === 1,
'B9: 折返滑回攻击+滞留定时器 → cmd_attack 不双播(1.2s 去重跳过本次)');
// 相邻两回合连进:旧回合定时器作废
await tick(1500); // 让过去重窗口,隔离下一段
game._playerSelect();
const m2 = audio.log.length;
game._playerSelect(); // 钩子:相邻两回合连进(旧回合 650ms 定时器滞留)
await tick(900);
assert(plays(audio, m2).count('voice/cmd_attack') === 1,
'B9: 连进两回合 → 旧回合指令名定时器作废(cmd_attack 只播一次)');
}
async function scenarioB10() {
origConsoleLog('\n=== 场景 B10:受伤播报只挂实质受击 + 档位一致 + 同档节流(round2-live P1) ===');
localStorageMock.clear();
const { game, audio } = newGame();
await setupWolfBattle(game);
game.stats.battlesWon = 5; // 钩子:标准档(前摇 1.0s、×1.0)
game._battle.tier = { telSec: 1.0, dmgMul: 1.0 };
game._dmg = (a, d) => Math.max(1, a - d); // 伤害确定化:狼 atk4 → 实击 4、擦伤 1
await battlePump(game, audio, 5000);
const b = game._battle;
assert(b && b.state === 'PLAYER_SELECT', 'B10: 野狼战进玩家回合');
game.player.hp = 15; // 钩子:15/20=0.75 高档,一刀擦伤即跨 0.7 边界
b.enemy.hp = 99; // 钩子:全程打不死,专测受击播报
assert(b.hpBandVoice === 'high', 'B10: 开战"最后被告知档"=当前实际档(high)');
const mark = audio.log.length;
const attackRound = async (parry) => {
selectCmd(game, 'attack');
await tick(1200);
g(game, 'tap');
if (parry) g(game, 'doubletap'); // 前摇内即时双击格挡
await tick(2200); // 前摇 1.0s 结算 + 可能的降档句(1s)+ 回合交接
};
const itemRound = async () => {
selectCmd(game, 'item');
await tick(1200);
g(game, 'tap');
await tick(2200); // 喝药 + 敌方一个前摇回合(硬吃)
};
// ① 格挡擦伤跨档(15→14,0.70 跌进 mid):静默走血,不播任何受伤播报
await attackRound(true);
assert(game.player.hp === 14, `B10: 格挡擦伤 15→14(实际 ${game.player.hp})`);
assert(plays(audio, mark).count('voice/st_hp_mid') === 0 && plays(audio, mark).count('voice/st_hp_low') === 0,
'B10: 擦伤跨档不触发"受伤"播报(addendum §1:st_hp_* 是受击附属,非血量全局钩子)');
assert(b.hpBandVoice === 'high', 'B10: 擦伤静默走血不改写"最后被告知档"');
// ② 实质受击降档(14→10,mid):播当前实际档 st_hp_mid(不多不少)
await attackRound(false);
assert(game.player.hp === 10 && plays(audio, mark).count('voice/st_hp_mid') === 1,
'B10: 实质受击降档 → 播 st_hp_mid(与实际档位一致)');
assert(plays(audio, mark).count('voice/st_hp_low') === 0 && b.hpBandVoice === 'mid',
'B10: 播报档不高估(mid 不喊 low),"最后被告知档"同步为 mid');
// ③ 同档再受击(10→6,0.30 仍 mid):不重复喊
await attackRound(false);
assert(game.player.hp === 6 && plays(audio, mark).count('voice/st_hp_mid') === 1,
'B10: 同档再受击不重复播(节流:mid 仍 1 次)');
// ④ 再受击跌 low(6→2,0.10):播 st_hp_low
await attackRound(false);
assert(game.player.hp === 2 && plays(audio, mark).count('voice/st_hp_low') === 1 && b.hpBandVoice === 'low',
'B10: 实质受击跌 low → 播 st_hp_low(命悬一线与实际档位一致)');
// ⑤ 同档再受击(钩子回血到 5 → 1,仍 low):不重复喊
game.player.hp = 5;
await attackRound(false);
assert(game.player.hp === 1 && plays(audio, mark).count('voice/st_hp_low') === 1,
'B10: low 档内再受击不重复播(节流:low 仍 1 次)');
// ⑥ 喝药回血(1→9)后硬吃一刀(9→5,回 low):与"最后被告知档"同档 → 不重播
game.player.potions = 3; // 钩子:药管够
await itemRound();
assert(game.player.hp === 5 && plays(audio, mark).count('voice/st_hp_low') === 1,
'B10: 回血后一刀直接跌回 low(无中间档):同档不重播(low 仍 1 次)');
// ⑦ 再喝药(5→13)硬吃一刀(13→9,mid):升档静默同步;再受击(9→5)跌 low = 新降档 → 重播一次
await itemRound();
assert(game.player.hp === 9 && plays(audio, mark).count('voice/st_hp_mid') === 1 && b.hpBandVoice === 'mid',
'B10: 回血升档静默(mid 不重播),"最后被告知档"经实击同步回 mid');
await attackRound(false);
assert(game.player.hp === 5 && plays(audio, mark).count('voice/st_hp_low') === 2 && b.hpBandVoice === 'low',
'B10: 经中间档再次跌破 low = 新降档,重播一次 st_hp_low(不属"狼来了")');
}
async function scenarioB11() {
origConsoleLog('\n=== 场景 B11:探索菜单语音播放中单击=跳句+补发确认(round2-live P1) ===');
localStorageMock.clear();
const { game, audio } = newGame();
game.start();
await tick(60000); // 序章+进村,选项报名已播完
// ① 选项报名在播中单击 → 跳句 + 执行当前选项(live 1264.6s 证据场景)
g(game, 'right'); // 切到 shop:opt_shop 开播(mock 1s/句)
assert(audio._voice && audio._voice.id === 'voice/opt_shop', 'B11: 选项报名在播(opt_shop)');
const skips0 = audio.skipCount;
g(game, 'tap');
assert(audio.skipCount === skips0 + 1, 'B11: 报名在播中单击触发跳句(skipVoice)');
assert(game.phase === 'shop', 'B11: 跳句同时补发确认语义:进商店(动作不石沉大海)');
// ② desc 链在播中单击 → 只跳句不确认(12 文档 §1 跳句惯例不回退)
g(game, 'down'); await tick(2000); // 离店回探索(选项报名播完)
g(game, 'left'); await tick(1200); // 切回 go_path
g(game, 'tap'); // 确认 go_path → desc_path 开播
assert(game.scene === 'path' && audio._voice, 'B11: 确认 go_path 进 path(desc 在播)');
const skips1 = audio.skipCount;
g(game, 'tap');
assert(audio.skipCount === skips1 + 1 && game.scene === 'path',
'B11: desc 在播中单击=只跳句不确认(场景不变,A5 契约不回退)');
await tick(3000); // 链走完 → 选项报名
// ③ 边界提示在播中单击"返回" → 跳句+执行返回(live 1370.1s 证据场景,guide=full)
settings.set({ guide: 'full' });
try {
g(game, 'left'); await tick(1200); // path 首项左滑反绕到末项 back → opt_back 播完接 gd_wrap_last
assert(audio._voice && audio._voice.id === 'voice/gd_wrap_last', 'B11: 边界提示 gd_wrap_last 在播');
const skips2 = audio.skipCount;
g(game, 'tap');
assert(audio.skipCount === skips2 + 1 && game.scene === 'village',
'B11: 边界提示在播中单击"返回"=跳句+执行返回(回 village,动作不丢)');
} finally {
settings.set({ guide: 'off' }); // 复位:其余场景契约是 guide=off
}
}
async function scenarioB12() {
origConsoleLog('\n=== 场景 B12:教学自动轮播"播完再上"不互掐(round2-live P2) ===');
localStorageMock.clear();
const { game, audio } = newGame();
await walkToTeachBattle(game);
const b = game._battle;
// 等教学链播完进 PLAYER_SELECT(T0 menu,看门狗挂载点)
let waited = 0;
while (!(b.state === 'PLAYER_SELECT' && b.teachStage === 'menu') && waited < 30000) { await tick(500); waited += 500; }
assert(b.state === 'PLAYER_SELECT' && b.teachStage === 'menu', 'B12: 教学 T0 进玩家回合(看门狗已挂载)');
await tick(2500); // 让 650ms 指令名定时器播完(隔离声道,避免顶掉占用钩子)
// 声道占用钩子:blocked 的 voice 永不触发 onComplete(模拟"上一句仍在播")
audio.blocked.add('voice/tut_b5');
audio.play('voice/tut_b5');
await tick(9000); // 越过 8s 重复提示触发点
assert(plays(audio).count('voice/tut_b0') === 1,
'B12: 语音在播时 8s 重复提示跳过(自动轮播不顶句,tut_b0 仍 1 次)');
await tick(8000); // 越过 16s 兜底推进点
assert(b.teachStage === 'menu' && plays(audio).count('voice/tut_battle_2') === 1,
'B12: 语音在播时 16s 兜底延后推进(不拦腰斩句,live 333.09s 互掐场景)');
const tFree = vnow;
audio.stopVoice(); // 模拟在播句播完(声道空出)
await tick(2500); // 1.5s 重试间隔内应推进
assert(b.teachStage === 'attack', 'B12: 声道空出后兜底推进 menu→attack(延后不死锁)');
const tAdv = audio.log.filter((e) => e.id === 'voice/tut_battle_2').at(-1).t;
assert(tAdv >= tFree, `B12: 推进语音等声道空出才开播("播完再上",${tAdv} ≥ ${tFree})`);
}
async function scenarioB13() {
origConsoleLog('\n=== 场景 B13:杀敌结算语音不腰斩刚开播的指令名(round2-live P2) ===');
localStorageMock.clear();
const { game, audio } = newGame();
await setupWolfBattle(game);
await battlePump(game, audio, 5000);
const b = game._battle;
assert(b && b.state === 'PLAYER_SELECT', 'B13: 野狼战进玩家回合');
game._playCmdVoice(); // 钩子:指令名刚开播(复现 live 843.9s:「攻击。」播到一半出手)
assert(audio._voice && audio._voice.id === 'voice/cmd_attack', 'B13: 指令名在播');
b.enemy.hp = 1; // 钩子:一刀死
g(game, 'tap');
await tick(300);
const die = audio.log.filter((e) => e.id === 'voice/btl_enemy_die').at(-1);
assert(die && die.opts.queue === true,
'B13: 指令名开播 1.6s 内击杀语音 queue 排队等播完(不拦腰斩句)');
await battlePump(game, audio);
assert(game.phase === 'explore', 'B13: 排队后胜利链正常走完回探索(不死锁)');
}
// ================================ 资产引用校验 ================================
function assetAudit() {
@@ -896,6 +1172,13 @@ try {
await scenarioB4();
await scenarioB5();
await scenarioB6();
await scenarioB7();
await scenarioB8();
await scenarioB9();
await scenarioB10();
await scenarioB11();
await scenarioB12();
await scenarioB13();
} catch (e) {
failures.push(`未捕获异常: ${e.stack}`);
}