fix(simon): 音序回响状态透明打磨(玩家代理取证迭代,玩法零改动)

- S2:新增input_open双音柔铃标记「能答了」边界(playback/input边界此前吞手势无标记)
- S1/S3/S4:input阶段长按=状态查询(13句status_*分档整句:长度4档×进度4档×账目5档,两本账口径一致,不耗账不闭窗)
- S5:announce/roundend非单击手势补error震(00 §5.4三秒反馈律)
- S6 P0:gameover下滑进菜单→结算链死锁8.4s修复(结算看守+挂起补播)
- S7:your_turn每局一次(原每轮重念)
- 取证:live两会话+4人设评审,S清单见research/simon-polish/round1/findings.md
- 验证:playtest 156/156 + guide 122 + 终检绿 + 14游戏回归零回退 + live复跑核对局14/14压力局8/8;状态感知2/5→4/5(四席同升),愿意玩改口为是
- 02号文档追加§9修订记录;新增input_open+13句status_*走完整TTS管线(flock串行QC无离群)
This commit is contained in:
xsl
2026-09-14 20:17:28 +08:00
parent ae850a4ce0
commit 258ffd5ba3
89 changed files with 7455 additions and 21 deletions
+79 -9
View File
@@ -328,6 +328,12 @@ function checkSeqConstraints() {
/** announce →(单击跳过轮次播报)→ playback(手势静默忽略)→ input。 */
async function toInputViaSkip(round) {
await advanceUntil(() => game.phase === 'announce' && !!audio._voice, { desc: `第 ${round} 轮播报` });
// round5 S5:announce 阶段非单击手势给 error 震(00 §5.4 三秒反馈律;旧行为静默吞)
const v0 = vibLog.length;
await gesture('left');
assert(game.phase === 'announce', `S5:第 ${round} 轮 announce 误触不推进相位`);
assert(vibLog.slice(v0).some((p) => Array.isArray(p) && p[0] === 60),
`S5:第 ${round} 轮 announce 误触给 error 震(不再静默吞)`);
const skips = skipLog.length;
await gesture('tap');
assert(skipLog.length === skips + 1, `第 ${round} 轮 round_* 播报单击可跳`);
@@ -406,6 +412,15 @@ section('第 1-3 轮(含单击跳播报、playback 忽略、看门狗)');
assert(game._interval() === 700, '第 1 轮播放间隔 700ms');
await toInputViaSkip(1);
await answerAll();
assert(game.phase === 'roundend', '第 1 轮答完进 roundend 表扬链');
{
// round5 S5:roundend 播报期手势同样不静默吞(error 震 + 相位不推进)
const v0 = vibLog.length;
await gesture('left');
assert(game.phase === 'roundend', 'S5:roundend 播报期误触不推进相位');
assert(vibLog.slice(v0).some((p) => Array.isArray(p) && p[0] === 60), 'S5:roundend 误触给 error 震');
}
assert(played('sfx/input_open'), 'S2:input 开放标记音 input_open 已播(第 1 轮 your_turn 链尾)');
await advanceUntil(() => game.phase === 'announce' && game.round === 2, { desc: '进第 2 轮' });
assert(played('voice/correct'), 'P1:每轮答对都播表扬(第 1 轮 correct,池轮播)');
@@ -454,18 +469,30 @@ assert(playCount('voice/gd_clear') === 2, 'P1:每轮答对都播表扬(第 4
section('第 5 轮:主动重听(上滑)两层账 / 重听拒绝 / 双击=作答(round3 G5 §8 + round2 live P1 §15)');
await toInputViaSkip(5);
{
// 未支持手势兜底(round1 live P1:答题阶段长按 9 秒零反馈,玩家以为卡死)
// round5 S1/S3/S4:input 阶段长按=状态查询(长度/进度/重听两账,分档整句)。
// 第 5 轮:len=6 → status_len_mid;idx=0 → status_prog_start;免费 2 次 → status_free2。
const idx = game.inputIdx;
const ch = game.chances;
const vib0 = vibLog.length;
const v0 = vibLog.length;
await gesture('longpress');
assert(game.phase === 'input' && game.inputIdx === idx && game.chances === ch,
'P1:input 阶段长按不判错、不耗机会、不进重听');
assert(vibLog.slice(vib0).some((p) => Array.isArray(p) && p[0] === 60),
'P1:input 阶段长按发 error 震动兜底(不再零反馈,也不再抛 TypeError)');
const vib1 = vibLog.length;
await gesture('longpressend');
assert(vibLog.length === vib1 && game.inputIdx === idx, 'P1:longpressend(同一动作收尾)静默不重复反馈');
assert(game.phase === 'input' && game.inputIdx === idx && game.chances === ch && game.freeRelistens === 2,
'S4:长按查询不判错、不耗账、不清进度');
assert(!vibLog.slice(v0).some((p) => Array.isArray(p) && p[0] === 60),
'S4:查询不再走 error 震兜底(长按已升为查询键)');
await advance(3400); // 查询三句链播完(mock 语音 1s/句,onComplete 串行)
assert(playCount('voice/status_len_mid') === 1 && playCount('voice/status_prog_start') === 1
&& playCount('voice/status_free2') === 1, 'S1/S3/S4:查询三档整句(长度 mid/进度 start/账目 free2)');
await gesture('longpressend'); // 收尾静默,不重复查询
assert(playCount('voice/status_len_mid') === 1, 'S4:longpressend 静默不重复查询');
// 答两音再查 → 进度档 early(idx=2、len=6:2*3 ≤ 6)
await answerPastDoubletaps();
await answerPastUps();
await gesture(game.sequence[game.inputIdx]);
await gesture(game.sequence[game.inputIdx]);
assert(game.inputIdx === 2, 'S3:查询后作答窗口原样可用(已答 2 音)');
await gesture('longpress');
await advance(3400);
assert(playCount('voice/status_prog_early') === 1, 'S3:进度档随 inputIdx 分档(early)');
}
{
// round3 G5:input 阶段双击永远=作答,主动重听改绑上滑(期望恰为 up 时上滑=作答)
@@ -474,11 +501,13 @@ await toInputViaSkip(5);
await answerPastDoubletaps();
await answerPastUps(); // 先把期望恰为 up 的音答掉
const relistenCount = playCount('voice/relisten');
const ioBefore = playCount('sfx/input_open');
assert(game.freeRelistens === 2 && game.chances === 1, 'P1:第 5 轮账目起点 = 免费 2 次 + 真机会 1 次');
await gesture('up'); // 期望非 up → 第 1 次主动重听(免费)
assert(game.freeRelistens === 1 && game.chances === 1, 'P1:第 1 次重听用免费额度(2→1),真机会不动');
assert(playCount('voice/relisten') === relistenCount + 1 && game.phase === 'roundend', '播 relisten 并重播');
await advanceUntil(() => game.phase === 'input', { desc: '重听重播后回输入' });
assert(playCount('sfx/input_open') === ioBefore + 1, 'S2:重听重播后回 input 再发 input_open 标记');
assert(played('voice/gd_chances_left1'), 'P1:第 1 次后如实报余量(gd_chances_left1「还剩一次」,链尾句)');
assert(game.inputIdx === 0, '重听后输入进度清零');
}
@@ -676,6 +705,47 @@ await advance(500);
await advance(500);
}
// ---------------------------------------------------------------- 场景三:round5 S6/S7
// S6 结算链阶段看守:gameover 瞬间进菜单 → 链挂起不播 → 回结算页补播(承诺与相位一致)。
// S7 your_turn 每局一次:第 1 轮复活重播不再重念。
section('场景三:结算链阶段看守(S6)与 your_turn 每局一次(S7)');
{
const game3 = new SimonGame(audio, fakeEl());
gameRef = game3;
const ytBase = playCount('voice/your_turn'); // 主局第 1 轮已播过,用增量断言
game3.start();
await advanceUntil(() => game3.phase === 'tutorial' && game3.teachAwait, { desc: '场景三教学' });
await gesture('down'); // 跳教学
await advanceUntil(() => game3.phase === 'input', { desc: '场景三第 1 轮输入' });
assert(playCount('voice/your_turn') === ytBase + 1, 'S7:第 1 轮首次开放播一次 your_turn');
// 答错一次(chances=1)→ 复活重播 → your_turn 不重念
const wrong = GESTURES.find((g) => g !== game3.sequence[0] && g !== 'doubletap' && g !== 'up');
await gesture(wrong);
assert(game3.phase === 'roundend' && game3.chances === 0, '场景三:答错消耗唯一复活');
await advanceUntil(() => game3.phase === 'input', { desc: '场景三复活重播后回输入' });
assert(playCount('voice/your_turn') === ytBase + 1, 'S7:复活重播不重念 your_turn(每局一次)');
// 再答错 → gameover;死亡瞬间下滑进菜单(压力线手型,live-B 行 141)
const wrong2 = GESTURES.find((g) => g !== game3.sequence[0] && g !== 'doubletap' && g !== 'up');
await gesture(wrong2);
assert(game3.phase === 'gameover', '场景三:复活耗尽再答错 → gameover');
const go0 = playCount('voice/gameover');
const sb0 = playCount('voice/score_band1');
await gesture('down');
assert(game3.phase === 'menu', 'S6:死亡瞬间下滑进菜单(压力线可复现路径)');
await advance(2000); // 越过结算链原定 +1400ms 点火点
assert(playCount('voice/gameover') === go0 && playCount('voice/score_band1') === sb0,
'S6:菜单内不交付结算链(「单击再来一局」不在错误的相位播)');
assert(game3._settleChain && game3._settleChain.length === 4,
'S6:结算链挂起待补播(gameover+score_band+replay+rules_prompt)');
await gesture('down'); // 关菜单回结算页
assert(game3.phase === 'gameover', 'S6:关菜单回结算页');
await advanceUntil(() => playCount('voice/gameover') === go0 + 1, { desc: 'S6:回结算页补播结算链', max: 4000 });
await advanceUntil(() => playCount('voice/score_band1') === sb0 + 1, { desc: 'S6:补播链续播 score_band1', max: 4000 });
game3.stop();
gameRef = game;
await advance(500);
}
// ---------------------------------------------------------------- 资产引用校验
section('资产引用校验(ogg+m4a 双格式,voice 双性别)');
{