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
+46 -18
View File
@@ -423,6 +423,9 @@ realLog('== ⑥ G1/G3 rhythm:gd_answer 落在首个应答窗口前整整 1 小
await advance(() => tSec() >= game._bgmT0 + 1.5); // BGM 已起、首窗口未到
await gesture(game, 'down');
assert(game.phase === 'menu', 'G3:曲中下滑呼出菜单(无悬置 down 音符)');
// live round2 P1:菜单 1s 去抖独占期(防瞬穿)——关菜单手势须在独占期后到达
const menuT0 = tSec();
await advance(() => tSec() >= menuT0 + 1.05);
const kicksBefore = game._sched.filter((e) => e.sfx === 'sfx/inst_kick').length;
await gesture(game, 'down');
assert(voicePlays(playLog, 'voice/gd_count') === 1, 'G3:恢复先播 gd_count(full 档)');
@@ -448,6 +451,8 @@ realLog('== ⑥ G1/G3 rhythm:gd_answer 落在首个应答窗口前整整 1 小
// G3:gd_count 每局恢复限 1 次(第二次恢复直接 count-in)
await gesture(game, 'down');
const menuT1 = tSec();
await advance(() => tSec() >= menuT1 + 1.05); // live round2 P1:菜单 1s 去抖独占期后再操作
const cntBefore = voicePlays(playLog, 'voice/gd_count');
await gesture(game, 'down');
assert(voicePlays(playLog, 'voice/gd_count') === cntBefore, 'G3:gd_count 每局恢复限 1 次');
@@ -536,28 +541,33 @@ function driveSimonToInput(rig) {
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:第 1 轮答完 → 轮结算链尾 gd_clear(每局 1 次)
// 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/gd_clear') === 1, 'G6:轮成功链尾播 gd_clear(漂亮,过了)');
assert(voicePlays(playLog, 'voice/correct') === 1, 'G6:每轮答对必播表扬(第 1 轮 correct「答对了!」)');
await advance(() => game.phase === 'input' && game.round === 2);
// G6:第 2 轮答完且超过预设历史纪录(钩子 best=1)→ 追加 gd_record(每局 1 次)
// 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(破纪录了)');
assert(voicePlays(playLog, 'voice/gd_clear') === 1, 'G6:gd_clear 每局仅第 1 次轮成功播');
await advance(() => game.phase === 'input' && game.round === 3);
// 双击永远=作答的判错面:chances=0(重听已耗)时双击期望非双击 → 判错到底(gameover),不是 chances_out
@@ -576,6 +586,7 @@ function driveSimonToInput(rig) {
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');
@@ -589,8 +600,14 @@ function driveSimonToInput(rig) {
// ================================================================ ⑨ G7 space 方向呼喊
realLog('== ⑨ G7 space:loud 警报档(撞击前 500ms)同窗 dir_* 超短方向呼喊 ==');
realLog('== ⑨ G7 space:dir_* 方向呼喊与呼啸同窗(spawn 当刻,round4 R1 改口) ==');
{
// 设计依据(research/04-playtests/live/round1/space-xiaozhao.md P1-1 + 13 号文档 §11):
// G7 原裁定为「loud 警报档(撞击前 500ms)同窗呼喊」,但第 1 轮全拟真实测每颗陨石的
// 呼喊都落在撞击判定前 ~0.4s——听完再滑必死,与教学培养的"听语音滑"直接矛盾。
// round4 R1 把呼喊提前到**陨石呼啸响起瞬间**(spawn 当刻):呼喊=反应窗口起点的指引,
// 与 whoosh 同窗;loud 警报保留在撞击前 500ms 承担"临近警示",二者**明确错峰**、
// 不再绑定(撞击点落在保护期内的陨石不呼喊,protect_<dir> 长句教学优先)。
const { audio, playLog, game } = makeRig(SpaceGame);
await audio.unlock();
game.start();
@@ -600,9 +617,17 @@ realLog('== ⑨ G7 space:loud 警报档(撞击前 500ms)同窗 dir_* 超
assert(game.phase === 'playing', '前提:跳过演练进对局');
await advance(() => playLog.some((e) => e.id.startsWith('voice/dir_')));
const shout = playLog.find((e) => e.id.startsWith('voice/dir_'));
const louds = playLog.filter((e) => e.id === 'sfx/alarm' && e.opts.volume === 1).map((e) => e.t);
assert(['left', 'right', 'up', 'down'].includes(shout.id.slice('voice/dir_'.length)), 'G7:呼喊方向合法');
assert(louds.some((t) => Math.abs(t - shout.t) < 60), `G7:dir_* 与 loud 警报同窗(≤50ms,实际最小 ${Math.min(...louds.map((t) => Math.abs(t - shout.t)))}ms)`);
const dir = shout.id.slice('voice/dir_'.length);
assert(['left', 'right', 'up', 'down'].includes(dir), 'G7:呼喊方向合法');
const whooshes = playLog.filter((e) => e.id === `sfx/whoosh_${dir}`).map((e) => e.t);
assert(whooshes.some((t) => Math.abs(t - shout.t) < 60),
`G7:dir_* 与同向呼啸同窗(≤60ms,实际最小 ${Math.min(...whooshes.map((t) => Math.abs(t - shout.t)))}ms)`);
// 错峰验证:呼喊后第一声 loud 警报(同颗陨石的撞击前 500ms 档)应 ≥2s 才到
// (逼近时长 ≥2.8s → 呼喊到 loud 档间隔 ≥2.3s,取 2s 保守下界)
await advance(() => playLog.some((e) => e.id === 'sfx/alarm' && e.opts.volume === 1 && e.t > shout.t));
const nextLoud = playLog.find((e) => e.id === 'sfx/alarm' && e.opts.volume === 1 && e.t > shout.t);
assert(nextLoud.t - shout.t >= 2000,
`G7:呼喊先于其 loud 警报 ≥2s(错峰:呼喊在窗口起点,实际 ${nextLoud.t - shout.t}ms)`);
game.stop();
resetTimers();
}
@@ -680,29 +705,32 @@ function fixSequence(game, recipe) {
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
// 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');
game.chances = 2; // 测试钩子:满额免费重听(初始 1 + 第 4 轮完成 +1,上限 2)
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 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(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 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 时出现)');
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'); // 余 0 再求重听:拒绝路径(机会账)行为不变
await gesture(game, 'up'); // 两账皆空再求重听:拒绝路径(机会账)行为不变
assert(game.phase === 'input' && voicePlays(playLog, 'voice/chances_out') === co0 + 1,
'H3:余 0 再求重听仍拒绝并播 chances_out(答错救援的机会账路径不变)');
'H3:两账皆空再求重听仍拒绝并播 chances_out(答错救援的机会账路径不变)');
while (game.phase === 'input') await gesture(game, game.sequence[game.inputIdx]);
game.stop();
resetTimers();