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:
+114
-16
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* playtest.mjs — 《音序回响》无头试玩仿真测试(docs/design/13-playtest-criteria.md §3 + §7)。
|
||||
* playtest.mjs — 《音序回响》无头试玩仿真测试(docs/design/13-playtest-criteria.md §3 + §7 + §9 round1-live 变更)。
|
||||
*
|
||||
* 思路:mock 浏览器环境(window/document/navigator/localStorage/fetch/AudioContext),
|
||||
* 加载**真实** engine/audio.js(拿到真实 skipVoice/onComplete/设置语义)与 game.js,
|
||||
@@ -232,6 +232,8 @@ audio.stopBgm = (ms) => { stopBgmLog.push(ms); return origStopBgm(ms); };
|
||||
|
||||
const played = (id) => playLog.some((e) => e.id === id);
|
||||
const playCount = (id) => playLog.filter((e) => e.id === id).length;
|
||||
const OCC_IDS = ['voice/gd_occ_up', 'voice/gd_occ_dt', 'voice/gd_occ_both'];
|
||||
const occTotal = () => OCC_IDS.reduce((n, id) => n + playCount(id), 0);
|
||||
const lastVoice = () => {
|
||||
for (let i = playLog.length - 1; i >= 0; i--) {
|
||||
if (playLog[i].id.startsWith('voice/')) return playLog[i].id;
|
||||
@@ -331,8 +333,10 @@ async function toInputViaSkip(round) {
|
||||
const seqSnapshot = game.sequence.join(',');
|
||||
await gesture('left'); // playback 阶段任意手势静默忽略(含 voice 也不跳)
|
||||
assert(game.phase === 'playback' && game.sequence.join(',') === seqSnapshot, 'playback 阶段手势静默忽略、序列不受影响');
|
||||
const pl0 = playLog.length;
|
||||
await advanceUntil(() => game.phase === 'input', { desc: `第 ${round} 轮进入输入` });
|
||||
assert(game.inputIdx === 0, `第 ${round} 轮 inputIdx 归零`);
|
||||
checkOccAnnouncedOnce(pl0);
|
||||
checkSeqConstraints();
|
||||
}
|
||||
|
||||
@@ -341,12 +345,25 @@ async function toInputViaWatchdog(round) {
|
||||
await advanceUntil(() => game.phase === 'announce', { desc: `第 ${round} 轮 announce` });
|
||||
await advance(300);
|
||||
assert(!audio._voice, '被屏蔽的语音确实没有播起来');
|
||||
const pl0 = playLog.length;
|
||||
await advanceUntil(() => game.phase === 'playback', { desc: '看门狗兜底进 playback', max: 10000 });
|
||||
assert(errorLog.some((e) => e.includes('资产缺失')), '资产缺失仅 console.error');
|
||||
const seqSnapshot = game.sequence.join(',');
|
||||
await gesture('left');
|
||||
assert(game.phase === 'playback' && game.sequence.join(',') === seqSnapshot, '看门狗路径 playback 手势同样忽略');
|
||||
await advanceUntil(() => game.phase === 'input', { desc: `第 ${round} 轮进入输入` });
|
||||
checkOccAnnouncedOnce(pl0);
|
||||
}
|
||||
|
||||
// P2(round1 live,13 号文档 §9):组合占用提示 gd_occ_* 每局每种只在首次出现
|
||||
// 轮播一次——在轮次开场窗口(playback→input)跨轮跟踪,重复播报即失败。
|
||||
const occAtRoundStart = new Set();
|
||||
function checkOccAnnouncedOnce(fromIdx) {
|
||||
for (const e of playLog.slice(fromIdx)) {
|
||||
if (!e.id.startsWith('voice/gd_occ_')) continue;
|
||||
assert(!occAtRoundStart.has(e.id), `P2:${e.id} 只在首次出现轮播(第 ${game.round} 轮开场重复播报)`);
|
||||
occAtRoundStart.add(e.id);
|
||||
}
|
||||
}
|
||||
|
||||
let dtAnswerTested = false; // 裁定①:期望恰为 doubletap 时双击=答题
|
||||
@@ -388,25 +405,30 @@ assert(game._interval() === 700, '第 1 轮播放间隔 700ms');
|
||||
await toInputViaSkip(1);
|
||||
await answerAll();
|
||||
await advanceUntil(() => game.phase === 'announce' && game.round === 2, { desc: '进第 2 轮' });
|
||||
assert(played('voice/correct'), 'P1:每轮答对都播表扬(第 1 轮 correct,池轮播)');
|
||||
|
||||
await toInputViaSkip(2);
|
||||
await answerAll();
|
||||
assert(played('voice/gd_clear'), 'P1:每轮答对都播表扬(第 2 轮 gd_clear,池轮播)');
|
||||
blockedAssets.add('round_03'); // 看门狗被测项:在第 3 轮播报发起前屏蔽该语音资产
|
||||
await advanceUntil(() => game.phase === 'announce' && game.round === 3, { desc: '进第 3 轮' });
|
||||
|
||||
await toInputViaWatchdog(3);
|
||||
await answerAll();
|
||||
assert(playCount('voice/correct') === 2, 'P1:每轮答对都播表扬(第 3 轮 correct 轮播)');
|
||||
await advanceUntil(() => game.phase === 'announce' && game.round === 4, { desc: '进第 4 轮' });
|
||||
|
||||
// ---------------------------------------------------------------- 第 4 轮:答错触发复活
|
||||
section('第 4 轮:答错消耗复活、序列不变重播');
|
||||
await toInputViaSkip(4);
|
||||
assert(game.chances === 1, '初始 1 次重听机会');
|
||||
assert(game.freeRelistens === 2, 'P1(round2 live):免费重听每局 2 次开局即给足(教学承诺「免费机会有两次」)');
|
||||
{
|
||||
const prevSeq = game.sequence;
|
||||
const exp = game.sequence[0];
|
||||
// round3 G5:up=重听键,注入错手势时避开(否则触发重听而非判错)
|
||||
const wrong = GESTURES.find((g) => g !== exp && g !== 'doubletap' && g !== 'up');
|
||||
const occBefore = occTotal();
|
||||
await gesture(wrong);
|
||||
assert(game.chances === 0, '答错自动消耗复活:chances 1→0');
|
||||
assert(game.phase === 'roundend' && played('voice/retry_use'), '播 retry_use');
|
||||
@@ -416,40 +438,84 @@ assert(game.chances === 1, '初始 1 次重听机会');
|
||||
await advanceUntil(() => game.phase === 'input', { desc: '重播后回输入' });
|
||||
assert(game.sequence === prevSeq, '复活重播序列不变(引用相等)');
|
||||
assert(game.inputIdx === 0, '复活后输入进度清零');
|
||||
// P2(round1 live,13 号文档 §9):答错重听链尾重播本轮组合占用提示
|
||||
const seqHasOcc = game.sequence.includes('up') || game.sequence.includes('doubletap');
|
||||
assert(occTotal() === occBefore + (seqHasOcc ? 1 : 0), 'P2:答错重听时重播本轮组合提示(唯一重播途径)');
|
||||
}
|
||||
await answerAll();
|
||||
await advanceUntil(() => game.phase === 'announce' && game.round === 5, { desc: '进第 5 轮' });
|
||||
assert(game.chances === 1, '每完成 4 轮 chances +1(回到 1)');
|
||||
assert(played('voice/chance_plus'), '播 chance_plus');
|
||||
assert(playCount('voice/gd_clear') === 2, 'P1:每轮答对都播表扬(第 4 轮 gd_clear 轮播)');
|
||||
|
||||
// ---------------------------------------------------------------- 第 5 轮:主动重听 + 边界
|
||||
section('第 5 轮:主动重听(上滑)/ chances=0 上滑 / 双击=作答(round3 G5,13 号文档 §8 变更记录)');
|
||||
section('第 5 轮:主动重听(上滑)两层账 / 重听拒绝 / 双击=作答(round3 G5 §8 + round2 live P1 §15)');
|
||||
await toInputViaSkip(5);
|
||||
{
|
||||
// 未支持手势兜底(round1 live P1:答题阶段长按 9 秒零反馈,玩家以为卡死)
|
||||
const idx = game.inputIdx;
|
||||
const ch = game.chances;
|
||||
const vib0 = 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(同一动作收尾)静默不重复反馈');
|
||||
}
|
||||
{
|
||||
// round3 G5:input 阶段双击永远=作答,主动重听改绑上滑(期望恰为 up 时上滑=作答)
|
||||
// round2 live P1(13 号文档 §15):重听分两层账——免费 2 次/局先用(不碰真机会),
|
||||
// 第 3 次起才扣 chances。教学 gd_economy 承诺「免费机会有两次」,承诺几次给几次。
|
||||
await answerPastDoubletaps();
|
||||
await answerPastUps(); // 先把期望恰为 up 的音答掉
|
||||
const relistenCount = playCount('voice/relisten');
|
||||
await gesture('up'); // 期望非 up → 主动重听
|
||||
assert(game.chances === 0, '主动重听耗机会:chances 1→0');
|
||||
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(played('voice/gd_chances_left1'), 'P1:第 1 次后如实报余量(gd_chances_left1「还剩一次」,链尾句)');
|
||||
assert(game.inputIdx === 0, '重听后输入进度清零');
|
||||
}
|
||||
{
|
||||
// chances=0 时上滑非期望手势:不判错、播 chances_out
|
||||
// 第 2 次主动重听:仍走免费额度(兑现教学承诺的第二次)
|
||||
await answerPastDoubletaps();
|
||||
await answerPastUps();
|
||||
await gesture('up');
|
||||
assert(game.freeRelistens === 0 && game.chances === 1, 'P1:第 2 次重听仍免费(1→0),真机会仍不动');
|
||||
await advanceUntil(() => game.phase === 'input', { desc: '第 2 次重听重播后回输入' });
|
||||
assert(played('voice/gd_chances_left0'), 'P1:第 2 次后播 gd_chances_left0(免费用完、之后扣机会,链尾句)');
|
||||
assert(game.inputIdx === 0, '第 2 次重听后输入进度清零');
|
||||
}
|
||||
{
|
||||
// 第 3 次主动重听:免费已尽,扣真机会(chances 1→0);真机会扣空如实报 chances_out
|
||||
await answerPastDoubletaps();
|
||||
await answerPastUps();
|
||||
const coCount = playCount('voice/chances_out');
|
||||
await gesture('up');
|
||||
assert(game.freeRelistens === 0 && game.chances === 0, 'P1:第 3 次重听才扣真机会(chances 1→0)');
|
||||
await advanceUntil(() => game.phase === 'input', { desc: '第 3 次重听重播后回输入' });
|
||||
assert(playCount('voice/chances_out') === coCount + 1, 'P1:真机会扣空追加播 chances_out(链尾句)');
|
||||
assert(game.inputIdx === 0, '第 3 次重听后输入进度清零');
|
||||
}
|
||||
{
|
||||
// 免费+真机会皆空时上滑非期望手势:拒绝重听——不判错、不耗账、播 chances_out
|
||||
await answerPastDoubletaps();
|
||||
await answerPastUps();
|
||||
const round = game.round;
|
||||
const idx = game.inputIdx;
|
||||
const coCount = playCount('voice/chances_out');
|
||||
await gesture('up');
|
||||
assert(game.phase === 'input' && game.round === round && game.inputIdx === idx, 'chances=0 上滑非期望:不判错(round/inputIdx 不变)');
|
||||
assert(playCount('voice/chances_out') === coCount + 1, 'chances=0 上滑播 chances_out');
|
||||
assert(game.chances === 0, 'chances 不变(不为负)');
|
||||
assert(game.phase === 'input' && game.round === round && game.inputIdx === idx, '两账皆空上滑非期望:拒绝重听不判错(round/inputIdx 不变)');
|
||||
assert(playCount('voice/chances_out') === coCount + 1, '拒绝重听播 chances_out');
|
||||
assert(game.chances === 0 && game.freeRelistens === 0, '两账不变(不为负)');
|
||||
}
|
||||
// 答完第 5 轮(途中验证双击=答题);若本轮没遇到 doubletap 则继续往下轮找
|
||||
await answerAll();
|
||||
assert(playCount('voice/correct') === 3, 'P1:每轮答对都播表扬(第 5 轮 correct 轮播)');
|
||||
while (!dtAnswerTested && game.round < 10) {
|
||||
await advanceUntil(() => game.phase === 'announce', { desc: '继续找 doubletap 回合' });
|
||||
const r = game.round;
|
||||
@@ -481,14 +547,9 @@ const expectedBand = score <= 0 ? 'voice/score_band1' : score <= 3 ? 'voice/scor
|
||||
: score <= 6 ? 'voice/score_band3' : score <= 9 ? 'voice/score_band4'
|
||||
: score <= 14 ? 'voice/score_band5' : 'voice/score_band6';
|
||||
assert(!played('bgm/menu'), 'gameover 瞬间尚无 BGM');
|
||||
// 结算语音链播放中单击 = 跳过本句(不是重开)
|
||||
// 结算语音链自然播完,不在此插手——播放中单击=直接重开(P0 言行一致),
|
||||
// 该断言需要一次专门的 gameover,见后文「结算屏言行一致」专项场景。
|
||||
await advanceUntil(() => !!audio._voice, { desc: '结算语音链开播' });
|
||||
for (let i = 0; i < 8 && audio._voice; i++) {
|
||||
const ph = game.phase;
|
||||
await gesture('tap');
|
||||
assert(game.phase === ph && game.phase === 'gameover', '结算语音播放中单击=跳过本句(不重开)');
|
||||
await advance(50);
|
||||
}
|
||||
await advanceUntil(() => !audio._voice, { desc: '结算语音链播完' });
|
||||
assert(played('voice/gameover') && played(expectedBand), `score_band 语音与 round-1=${score} 匹配(${expectedBand})`);
|
||||
assert(played('voice/new_record'), '破纪录播 new_record');
|
||||
@@ -521,6 +582,16 @@ section('菜单(下滑呼出)与单击重开');
|
||||
await gesture('down');
|
||||
assert(game.phase === 'menu' && played('voice/menu_open'), '下滑呼出菜单');
|
||||
assert(playCount('bgm/menu') >= 1, '菜单状态挂 bgm/menu(与结算同一首)');
|
||||
// P1(round1 live,13 号文档 §9):菜单项切换逐项播报——右滑=下一项、左滑=上一项
|
||||
await gesture('right');
|
||||
assert(game._menuIdx === 1 && played('voice/menu_home'), 'P1:菜单右滑=下一项并播报("回大厅")');
|
||||
const mr = playCount('voice/menu_restart'); // _openMenu 链上的 menu_restart 可能已被右滑顶掉,用增量断言
|
||||
await gesture('left');
|
||||
assert(game._menuIdx === 0 && playCount('voice/menu_restart') === mr + 1, 'P1:菜单左滑=上一项并播报("重新开始")');
|
||||
await gesture('left');
|
||||
assert(game._menuIdx === 2 && played('voice/menu_back'), 'P1:菜单首项再左滑回绕末项并播报("返回游戏")');
|
||||
await gesture('right');
|
||||
assert(game._menuIdx === 0, 'P1:菜单右滑回到首项');
|
||||
await gesture('tap');
|
||||
assert(game._menuIdx === 1 && played('voice/menu_home'), '菜单单击循环到"回大厅"');
|
||||
await gesture('tap');
|
||||
@@ -530,8 +601,13 @@ assert(played('voice/rules'), '菜单上滑=帮助(规则播报)');
|
||||
await gesture('doubletap'); // 选中"返回"
|
||||
assert(game.phase === 'gameover', '菜单双击确认"返回"→ 回结算页');
|
||||
await advanceUntil(() => !audio._voice, { desc: 'rules 链播完' });
|
||||
// 下滑=关闭菜单(13 号文档 §7.5 菜单约定的 simon 落地补全)
|
||||
await gesture('down');
|
||||
assert(game.phase === 'menu', '再次下滑呼出菜单');
|
||||
await gesture('down');
|
||||
assert(game.phase === 'gameover', '菜单内下滑=关闭菜单回结算页');
|
||||
const stopsBefore = stopBgmLog.length;
|
||||
await gesture('tap'); // 结算页无语音时单击 = 重开
|
||||
await gesture('tap'); // 结算页单击 = 直接重开(P0 言行一致,无论语音是否在播)
|
||||
assert(stopBgmLog.length === stopsBefore + 1 && stopBgmLog[stopBgmLog.length - 1] === 300, '单击重开 stopBgm(300) 回静默');
|
||||
await advanceUntil(() => game.phase === 'announce' && game.round === 1, { desc: '重开进第 1 轮' });
|
||||
assert(game.phase !== 'tutorial' && playCount('voice/skip_hint') === 1, '单击重开跳过教学直达第 1 轮(skip_hint 不重播)');
|
||||
@@ -539,6 +615,28 @@ await advanceUntil(() => game.phase === 'input', { desc: '重开后第 1 轮输
|
||||
await answerAll();
|
||||
assert(game.round === 1 && (game.phase === 'roundend' || game.phase === 'announce'), '重开后对局功能正常');
|
||||
|
||||
// ---------------------------------------------------------------- 结算屏言行一致(round1 live P0)
|
||||
section('结算屏言行一致:结算语音链播放中单击=直接重开');
|
||||
// 新局第 2 轮故意连错两次耗尽复活,再造一次 gameover
|
||||
await advanceUntil(() => game.phase === 'input' && game.round === 2, { desc: '新局第 2 轮输入' });
|
||||
{
|
||||
const wrong = GESTURES.find((g) => g !== game.sequence[0] && g !== 'doubletap' && g !== 'up');
|
||||
await gesture(wrong);
|
||||
assert(game.phase === 'roundend' && game.chances === 0, '新局答错消耗唯一复活');
|
||||
await advanceUntil(() => game.phase === 'input', { desc: '新局重播后回输入' });
|
||||
const wrong2 = GESTURES.find((g) => g !== game.sequence[0] && g !== 'doubletap' && g !== 'up');
|
||||
await gesture(wrong2);
|
||||
assert(game.phase === 'gameover', '复活耗尽再答错 → 第二次 gameover');
|
||||
}
|
||||
await advanceUntil(() => !!audio._voice, { desc: '第二次结算语音链开播' });
|
||||
{
|
||||
const r1 = playCount('voice/round_01');
|
||||
await gesture('tap'); // 语音链播放中单击:不再跳句,直接开新局(言行一致是铁律)
|
||||
assert(game.phase === 'announce' && game.round === 1, 'P0:结算语音播放中单击=直接开新局(不再跳句)');
|
||||
assert(playCount('voice/round_01') === r1 + 1, 'P0:新局立即开播 round_01(结算语音链被打断)');
|
||||
}
|
||||
await advanceUntil(() => game.phase === 'input', { desc: '第三次开局进入第 1 轮输入' });
|
||||
|
||||
// ---------------------------------------------------------------- settings.bgm 边界(全局断言 3)
|
||||
section('settings.bgm 边界');
|
||||
settings.set({ bgm: 0 });
|
||||
|
||||
Reference in New Issue
Block a user