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
+173
View File
@@ -136,6 +136,10 @@ class MockAudioContext {
return { buffer: null, loop: false, connect() {}, start() {}, stop() {} };
}
createScriptProcessor() { return { connect() {}, disconnect() {}, onaudioprocess: null }; }
// 向导层 earcon 运行时合成所需(guide=standard/full 时 guide.state/nudge 会创建)
createOscillator() {
return { type: 'sine', frequency: new MockParam(440), connect() {}, start() {}, stop() {} };
}
decodeAudioData() { return Promise.resolve(fakeBuffer()); }
resume() { this.state = 'running'; return Promise.resolve(); }
}
@@ -394,6 +398,175 @@ realLog('== 看门狗路径(屏蔽 voice/n05_signal)==');
blockedAssets.clear();
}
// ================================================================ live round2 N1
// (docs/design/13-playtest-criteria.md §16:真实在播的长语音不被 30s 看门狗掐断)
realLog('== live round2 N1:长语音看门狗续约不掐(§16)==');
{
settings.set({ guide: 'off', voice: 'female', speed: 1.0, bgm: 1.0 });
const { audio, playLog, game } = makeRig();
await audio.unlock();
// 预植 40s 长语音缓存(模拟真实 intro 71.7s 超 30s 阈值的情形;虚拟时钟不真实等待)
audio._cache.set('female|voice/n00_intro', Promise.resolve({
duration: 40.0, numberOfChannels: 1, sampleRate: 44100,
getChannelData: () => new Float32Array(16),
}));
const warnBefore = consoleWarns.length;
const t0 = now;
game.start();
await flush();
assert(game.currentVoiceId === 'voice/n00_intro', 'N1:40s 长 intro 开播');
// 越过 30s 看门狗阈值:voice 真在播 → 不掐、不续链、无告警
await advance(() => now - t0 >= 30000);
assert(game.currentVoiceId === 'voice/n00_intro' && audio.isVoicePlaying()
&& game.nodeId === 'n00',
'N1:30s 看门狗触发时 voice 真在播 → 不续链(仍停在 intro)');
assert(!consoleWarns.slice(warnBefore).some((w) => w.includes('看门狗')),
'N1:在播长语音不触发看门狗告警');
// 自然播完才续链下一句(skip_hint 不再顶掉 intro 末句——冷启动末句被吃的根因)
await advance(() => game.currentVoiceId === 'voice/skip_hint');
assert(now - t0 >= 40000, `N1:intro 自然播完才续链 skip_hint(实际 ${now - t0}ms)`);
assert(voicePlays(playLog, 'voice/skip_hint') === 1, 'N1:skip_hint 在 intro 播完后播出');
await idle();
assert(!game.voicePlaying && game.nodeId === 'n00', 'N1:长链正常走完进入等待态');
}
// ================================================================ live round1 修复断言
// (docs/design/13-playtest-criteria.md §10:结局双击重听 / 结局状态播报;
// §10 分支台词口径经 §16 N2 翻案——钢索路径恢复播 n18_dog)
realLog('== live round1 修复断言(§10+§16:结局重听 / n18 两路径 / 结局状态)==');
{
settings.set({ guide: 'off', voice: 'female', speed: 1.0, bgm: 1.0 });
const { audio, playLog, game } = makeRig();
await audio.unlock();
game.start();
await idle(); // n00 链播完
// 钢索路径推进到 n18:n01→n02(right→n04)→n05→n06→n07(right→n09)→n10(right→n14)→n15
await g(game, 'tap'); await idle(); // → n01
await g(game, 'tap'); await idle(); // → n02(抉择)
await g(game, 'right'); await idle(); // → n04
await g(game, 'tap'); await idle(); // → n05
await g(game, 'tap'); await idle(); // → n06
await g(game, 'tap'); await idle(); // → n07(抉择)
await g(game, 'right'); await idle(); // → n09
await g(game, 'tap'); await idle(); // → n10(抉择)
await g(game, 'right'); await idle(); // → n14
await g(game, 'tap'); await idle(); // → n15(抉择)
await g(game, 'right'); await idle(); // 钢索 → n17(无涵洞经历)
assert(game.nodeId === 'n17' && game.viaCulvert === false,
'n15 右滑 → 钢索 n17(viaCulvert === false)');
// round2 N2(§10 P2-2 矫枉过正翻案):钢索路径恢复播 n18_dog 对峙戏 + p6
await g(game, 'tap'); await idle(); // → n18
assert(game.nodeId === 'n18' && voicePlays(playLog, 'voice/n18_dog') === 1
&& voicePlays(playLog, 'voice/p6') === 1,
'N2:钢索路径 n18 恢复播 n18_dog(对峙戏)+ 抉择提示 p6(known-issue:涵洞句轻微穿帮待 TTS 分支句)');
// 推进到结局 e2:n19→n20(left→n21)→e2
await g(game, 'left'); await idle(); // n18 交坐标 → n19
await g(game, 'tap'); await idle(); // → n20(抉择)
await g(game, 'left'); await idle(); // → n21
await g(game, 'tap'); await idle(); // → e2
assert(game.nodeId === 'e2' && game.awaitingRestart === true, '到达结局 e2 进入结算态');
// P2-1 结局双击重听:结算态双击 → 结局语音重播;重播中单击 = 跳句不重开
const e2before = voicePlays(playLog, 'voice/e2_price');
await g(game, 'doubletap');
assert(game.voicePlaying && game.currentVoiceId === 'voice/e2_price'
&& voicePlays(playLog, 'voice/e2_price') === e2before + 1,
'P2-1:结局结算态双击 = 重播结局语音');
assert(game.awaitingRestart === false, 'P2-1:结局重播期间离开结算态(单击回到跳句语义)');
await g(game, 'tap'); // 重播中单击 = 跳过本句
assert(game.nodeId === 'e2', 'P2-1:结局重播中单击跳过本句,不触发重开');
await idle();
assert(game.awaitingRestart === true && !game.voicePlaying, 'P2-1:结局重播链播完回到结算态');
// P3-2 结局后状态播报:help 后播当前结局语音(不再报 status3),播完回结算态
const e2again = voicePlays(playLog, 'voice/e2_price');
await g(game, 'up');
assert(game.currentVoiceId === 'voice/help', 'P3-2:结局后上滑先播 help');
await idle();
assert(voicePlays(playLog, 'voice/e2_price') === e2again + 1
&& voicePlays(playLog, 'voice/status3') === 0,
'P3-2:结局后状态播报 = 当前结局语音(不再报 status3)');
assert(game.awaitingRestart === true && !game.voicePlaying,
'P3-2:状态播完直接回结算态(不二次重播节点)');
}
// ================================================================ 静默兜底(standard 档)
// (docs/design/13-playtest-criteria.md §10:13s gd_continue / 30s gd_again / 手势重置)
realLog('== 静默兜底(guide=standard,§10 P2-3)==');
{
settings.set({ guide: 'standard', voice: 'female', speed: 1.0, bgm: 1.0 });
const { audio, playLog, game } = makeRig();
await audio.unlock();
game.start();
await advance(() => !game.voicePlaying); // n00 链播完(精确停链尾,不越过看门狗)
assert(game.nodeId === 'n00' && !game.voicePlaying, 'standard:n00 链播完进入等待态');
const tArm = now;
await advance(() => voicePlays(playLog, 'voice/gd_continue') >= 1);
assert(now - tArm === 13000, `standard:13s 无操作播 gd_continue(实际 ${now - tArm}ms)`);
await advance(() => voicePlays(playLog, 'voice/gd_again') >= 1);
assert(now - tArm === 30000, `standard:30s 升级播 gd_again(实际 ${now - tArm}ms)`);
await idle(); // 两级用尽彻底静默(无更多定时器)
// 玩家手势后重置:新节点播完重新挂载,13s 再次提示
await g(game, 'tap'); // → n01
await advance(() => !game.voicePlaying);
assert(game.nodeId === 'n01' && !game.voicePlaying, 'standard:手势后进入 n01 等待态');
const gdBefore = voicePlays(playLog, 'voice/gd_continue');
const tWait = now;
await advance(() => voicePlays(playLog, 'voice/gd_continue') > gdBefore);
assert(now - tWait === 13000, `standard:手势重置后新等待点 13s 再提示(实际 ${now - tWait}ms)`);
settings.set({ guide: 'off' });
}
// ================================================================ 抉择节点静默兜底
// (docs/design/13-playtest-criteria.md §16 N3:25s/50s 重播 p* 提示、手势复位)
realLog('== 抉择节点静默兜底(guide=standard,§16 N3)==');
{
settings.set({ guide: 'standard', voice: 'female', speed: 1.0, bgm: 1.0 });
const { audio, playLog, game } = makeRig();
await audio.unlock();
game.start();
await advance(() => !game.voicePlaying); // n00 播完
await g(game, 'tap'); // → n01
await advance(() => !game.voicePlaying);
await g(game, 'tap'); // → n02(抉择:n02_linwan + p1)
await advance(() => !game.voicePlaying);
assert(game.nodeId === 'n02' && game.choiceArmed, 'N3:n02 抉择链播完,等待抉择');
const p1first = voicePlays(playLog, 'voice/p1');
const tArm = now;
await advance(() => voicePlays(playLog, 'voice/p1') > p1first);
assert(now - tArm === 25000, `N3:抉择播完 25s 无操作重播 p1(实际 ${now - tArm}ms)`);
await advance(() => voicePlays(playLog, 'voice/p1') > p1first + 1);
assert(now - tArm === 50000, `N3:50s 再复述一次 p1(实际 ${now - tArm}ms)`);
await idle(); // 两级用尽彻底静默
assert(game.nodeId === 'n02' && game.choiceArmed, 'N3:复述期间剧情不推进、仍待抉择');
// 空点复位:抉择等待态单击(无进展手势)重置计时,再 25s 复述
await g(game, 'tap');
const p1again = voicePlays(playLog, 'voice/p1');
const tTap = now;
await advance(() => voicePlays(playLog, 'voice/p1') > p1again);
assert(now - tTap === 25000, `N3:空点后计时重置,25s 再复述(实际 ${now - tTap}ms)`);
// 玩家抉择后兜底终止:右滑 → n04,此后不再复述 p1
await g(game, 'right');
await advance(() => !game.voicePlaying);
assert(game.nodeId === 'n04', 'N3:右滑抉择 → n04 剧情推进');
const p1final = voicePlays(playLog, 'voice/p1');
await idle();
assert(voicePlays(playLog, 'voice/p1') === p1final, 'N3:抉择后兜底终止(不再复述)');
settings.set({ guide: 'off' });
}
// ================================================================ 资产引用校验
realLog('== 资产引用校验 ==');