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
+2 -1
View File
@@ -24,7 +24,8 @@
3. 正式对局:报「第 N 轮」→ 播放序列(此阶段手势忽略)→ 「轮到你了」
(仅第 1 轮)→ 玩家复现;每个手势即时回声放对应音色。
4. 全部复现正确:答对反馈,进入下一轮(序列尾部 +1,速度加快,第 12 轮起 420ms 下限)。
5. 复现错误:有机会则自动消耗重听(输入阶段双击=主动重听)→ 无机会则
5. 复现错误:有机会则自动消耗重听(输入阶段上滑=主动重听——免费重听每局 2 次
先用,不耗真机会;免费用尽后重听才扣机会)→ 无机会则
播报成绩与新纪录 → 单击重开(重开跳过教学)。
6. 旁白播放中单击=跳过当前句(skipVoice,链播继续);无语音时单击=各阶段原语义。
+82 -40
View File
@@ -8,14 +8,18 @@
* - 单击跳过旁白(12 号文档):voice 播放中单击 = audio.skipVoice()(截断当前句、
* 语音链继续跳下一句);无语音时单击 = 原语义。例外:教学"教一个练一个"的
* 等待输入阶段(teachAwait)单击仍是答题;playback 阶段手势静默忽略、菜单
* 单击循环菜单项(均维持原裁定)。开场 t0 附近插播一次 voice/skip_hint。
* 单击循环菜单项(均维持原裁定);**结算(gameover)单击不可跳句**——播报
* 承诺「单击屏幕,再来一局。」,单击必须言行一致直接重开(round1 live P0)。
* 开场 t0 附近插播一次 voice/skip_hint。
* - 旁白全部整句化:轮次 round_01..round_20 + round_more 兜底;成绩 score_band1..6
* 分段整句;删除 di/lun/num_0–9/score_pre/best_pre 拼接体系与旧教学连播。
* - 交互式教学(T0-T7):教一个音色练一次该手势,做对才教下一个;教学期下滑跳过。
* - 播放序列每音配 BEAT 轻震(节拍锚点);播完 400ms 给 CONFIRM 震动表示"轮到你了"
* (第 1 轮同时播 your_turn 语音)。
* - 复活机制:每局初始 1 次重听机会,每完成 4 轮 +1(上限 2)。输入阶段双击=主动
* 重听(耗机会);答错有机会则自动消耗重播同一序列(序列不重新随机)。
* - 复活机制:重听分两层账(round2 live P1 改口)——免费重听每局 2 次(教学
* gd_economy 承诺「免费机会有两次」,先说先给、不碰真机会);免费用完后
* 主动重听(input 阶段上滑)才扣 chances。chances 每局初始 1、每完成 4 轮
* +1(上限 2),答错有机会则自动消耗重播同一序列(序列不重新随机)。
* - 反馈全部改震动:对/错 SUCCESS/ERROR,sfx/correct、wrong、newrecord 已删除;
* sfx/gameover 保留为内容性结束音。
*
@@ -27,9 +31,11 @@
* 帮助)。同一手势在 input 阶段不再有第二种含义(P1 round2 P0 根治)。
*
* round4(docs/design/14-guide-layer.md「round4 v1.3」,synthesis H2~H4):
* - H2 占用手势预告:每轮序列播放完成后、「轮到你」提示前,序列含 up/doubletap
* 则播 gd_occ_up/dt/both(游戏反馈层全档位,插在轮次链中);被占用轮次 input
* 看门狗只发 nudge earcon 不播重听建议句(say 动态置空)。
* - H2 占用手势预告(round1 live P2 改口):序列含 up/doubletap 则播
* gd_occ_up/dt/both(游戏反馈层全档位,插在轮次链中),但**每种组合每局只在
* 首次出现轮播一次**(原"每轮必念"12 轮连念 12 遍被点名为 P2 不适);之后仅
* 在答错消耗机会重听时随 retry_use 重播本轮组合提示。被占用轮次 input 看门狗
* 只发 nudge earcon 不播重听建议句(say 动态置空)不变。
* - H3 重听账目真实:主动重听使用时如实报余量(2→1 播 gd_chances_left1、
* 1→0 播 gd_chances_left0,替换该路径旧「用完了」绝对句,全档位)。
* - H4 input 看门狗首级 10s→5s(升级级 25s 与次数不变;非 input 阶段不动)。
@@ -74,6 +80,10 @@ const TEACH_BLUNT = {
const BEST_KEY = 'soundgame.simon.best';
const AUTH_KEY = 'soundgame.auth'; // 大厅登录写入(见 web/index.html)
// 表扬句池(既有台词,轮播;round1 live P1:每轮答对必播一句——原 G6 口径
//「correct 每 4 轮一次 + gd_clear 每局限 1 次」12 轮仅 3 轮有表扬,被推翻)
const PRAISE = ['voice/correct', 'voice/gd_clear'];
// 向导层(14 号文档 + round2 synthesis F1.2 + round4 H4):input 阶段静默看门狗
// ——首级 5s 轻提醒 +gd_relisten(H4 round4:10s→5s,P4「10 秒干等」;升级级
// 不变、次数不变);25s 升级取消自动重播序列(P1:泄题且与扣机会规则矛盾)→
@@ -89,6 +99,9 @@ export class SimonGame extends Game {
this.round = 0; // 当前轮次(1 起),序列长度 = round + 1
this.inputIdx = 0;
this.chances = 1; // 重听机会(初始 1,每 4 轮 +1,上限 2)
// 免费重听(round2 live P1):每局 2 次,教学 gd_economy 承诺「免费机会有两次」
// ——承诺几次给几次,先用免费额度不碰 chances,用完主动重听才扣真机会
this.freeRelistens = 2;
this.best = this._loadBest(); // 历史最高(完成轮数),localStorage 持久
// 教学状态
this.teachIdx = 0;
@@ -98,9 +111,10 @@ export class SimonGame extends Game {
this._menuIdx = 0;
this._session = 0; // 会话令牌:重开后使旧计时器/语音链回调失效
this._timers = [];
// G6(round3):轮结算链尾轻量反馈每局各限 1 次
this._clearSaid = false;
// 轮结算反馈:gd_record 每局限 1 次(表扬每轮必播,见 _roundClear)
this._recordSaid = false;
// P2(round1 live):占用组合预告每局每种只播首次(答错时重播,见 _wrongAnswer)
this._occAnnounced = new Set();
}
start() {
@@ -145,7 +159,10 @@ export class SimonGame extends Game {
/**
* 单击是否应判定为"跳过当前旁白句"。规则:有 voice 在播 + 当前阶段允许跳过。
* - tutorial:仅讲解/反馈语音播放中可跳;teachAwait(等玩家练手势)单击=答题;
* - announce/roundend/gameover:轮次/成绩/结算播报可跳(高轮次玩家刚需);
* - announce/roundend:轮次/成绩播报可跳(高轮次玩家刚需);
* - gameover:**不可跳**——结算播报承诺「单击屏幕,再来一局。」,单击必须
* 言行一致直接重开(round1 live P0:玩家照播报单击却只跳过一句,随后进
* 菜单迷路流失);
* - playback:维持"序列播放期手势静默忽略"裁定(含第 1 轮 your_turn);
* - input:答题阶段无语音;menu:单击循环菜单项是原语义,不拦截。
* 读引擎的 _voice 字段(只读)判断是否有对白在播;无公开 API,属既定约定。
@@ -155,8 +172,7 @@ export class SimonGame extends Game {
switch (this.phase) {
case 'tutorial': return !this.teachAwait;
case 'announce':
case 'roundend':
case 'gameover': return true;
case 'roundend': return true;
default: return false;
}
}
@@ -249,8 +265,9 @@ export class SimonGame extends Game {
this.sequence = [];
this.round = 0;
this.chances = 1;
this._clearSaid = false; // G6:每局重新封顶
this.freeRelistens = 2; // 免费重听每局重置(round2 live P1)
this._recordSaid = false;
this._occAnnounced.clear(); // P2:新一局重新封顶(每种组合首次再播)
this._nextRound();
}
@@ -330,10 +347,14 @@ export class SimonGame extends Game {
if (s !== this._session || this.phase !== 'playback') return;
vibrate('confirm'); // "轮到你了"
this.guide.turn({}); // turn earcon 每轮泛化(第 1 轮另有 your_turn 语音,下行)
// H2(round4):序列播放完成后、「轮到你」提示前,按本轮序列播占用预告
//(gd_occ_up/dt/both,游戏反馈层全档位,插在轮次链中)——重听键与答案音
// 共用词表的结构性冲突,换手势无解,改为轮次开场讲清判据(P1#1+P2)。
const ids = this._occupancyVoices();
// H2(round4)→ round1 live P2 改口:占用预告每种组合每局只播首次
//(_occAnnounced 记录;之后仅答错重听时随 retry_use 重播,见 _wrongAnswer)。
// 重听键与答案音共用词表的结构性冲突,换手势无解,改为轮次开场讲清判据。
let ids = this._occupancyVoices();
if (ids.length) {
if (this._occAnnounced.has(ids[0])) ids = [];
else this._occAnnounced.add(ids[0]);
}
if (this.round === 1) ids.push('voice/your_turn');
if (ids.length) {
this._speakGuarded(ids, () => {
@@ -388,6 +409,14 @@ export class SimonGame extends Game {
this._relisten();
return;
}
// 未支持手势兜底(round1 live P1:答题阶段长按 9 秒零反馈,玩家以为卡死)——
// 即时 error 震,不判错、不耗机会、不进重听。此前 TONE['longpress'] 为
// undefined,audio.play(undefined) 直接抛 TypeError(事件回调内静默崩溃,
// 表象正是"手势被吞")。longpressend 是同一动作的收尾,静默不重复反馈。
if (!TONE[g]) {
if (g !== 'longpressend') vibrate('error');
return;
}
// 立即回声放玩家手势的音色(sfx 低延迟;识别震动引擎内置,不重复震)
this.audio.play(TONE[g]);
if (g !== this.sequence[this.inputIdx]) {
@@ -399,28 +428,35 @@ export class SimonGame extends Game {
}
/**
* 主动重听:消耗一次机会,重播当前序列(进度清零重输,序列不变)。
* H3(round4):使用时如实报余量——免费重听第 1 次用掉(余 1)播
* gd_chances_left1「免费重听还剩一次。」、第 2 次用掉(余 0)播
* gd_chances_left0「免费重听用完了。之后重听会花一次机会,别怕,慢慢来。」
* (游戏档全级别,替换本路径旧「用完了」绝对句;P1:教学说两次、用一次即报
* 用完了——账目不可知)。账目诊断:教学/演练/看门狗均无隐性扣减(watchdog
* 提示不消耗机会,教学期不触 chances),缺口在「免费两次」中的第二次要打完
* 第 4 轮才发放(每 4 轮 +1、上限 2)——「用完了」因此只在真实余量为 0 时出现。
* 答错自动消耗路径(retry_use + chances_out)与 0 次时再求重听的拒绝路径
* (chances_out)是「答错可以消耗机会重听」的机会账,语义如实、维持不变。
* 主动重听:重播当前序列(进度清零重输,序列不变)。两层账(round2 live P1):
* - 免费重听每局 2 次,先用免费额度、**不碰 chances**——教学 gd_economy 承诺
* 「免费机会有两次」,承诺几次给几次(旧账免费第二次要打完第 4 轮才随
* chances +1 发放,玩家第 1 次重听就听报「用完了」、答错一题即死,规则级
* 冤死)。第 1 次用掉(余 1)播 gd_chances_left1「免费重听还剩一次。」、
* 第 2 次用掉(余 0)播 gd_chances_left0「免费重听用完了。之后重听会花一次
* 机会,别怕,慢慢来。」(H3 round4 如实报余量,游戏档全级别)。
* - 免费用完后重听扣 chances(真机会);扣空(chances 1→0)追加 chances_out
* 如实报「重听机会用完了,小心。」;免费与真机会皆空时再求重听 → 拒绝
* (error 震 + chances_out,不判错、不耗账)。
* 答错自动消耗路径(retry_use + chances_out)是「答错可以消耗机会重听」的
* 机会账,与主动重听分账,语义如实、维持不变。
*/
_relisten() {
if (this.chances <= 0) {
if (this.freeRelistens <= 0 && this.chances <= 0) {
vibrate('error');
this.audio.play('voice/chances_out');
return;
}
this.chances--;
vibrate('pickup');
const ids = ['voice/relisten'];
if (this.chances === 1) ids.push('voice/gd_chances_left1'); // 余 1:还剩一次
else if (this.chances === 0) ids.push('voice/gd_chances_left0'); // 余 0:用完了
if (this.freeRelistens > 0) {
this.freeRelistens--;
if (this.freeRelistens === 1) ids.push('voice/gd_chances_left1'); // 余 1:还剩一次
else ids.push('voice/gd_chances_left0'); // 余 0:免费用完,之后扣机会
} else {
this.chances--; // 第 3 次起扣真机会
if (this.chances === 0) ids.push('voice/chances_out'); // 真机会也空了,如实报
}
const s = this._session;
this.phase = 'roundend'; // 重听播报期间暂停接收输入
this._speakGuarded(ids, () => { if (s === this._session) this._playSequence(); });
@@ -430,22 +466,18 @@ export class SimonGame extends Game {
this.phase = 'roundend';
const s = this._session;
vibrate('success');
const ids = [];
if (this.round % 4 === 0) ids.push('voice/correct'); // 每 4 轮播一次,其余仅震动
// 每轮答对都播一句表扬(PRAISE 池轮播)——round1 live P1:小孩每轮都要听到
// 一句夸奖,"过关没声音夸我,我都怀疑是不是其实没答对"。
const ids = [PRAISE[(this.round - 1) % PRAISE.length]];
// 每完成 4 轮机会 +1(上限 2)并播报
if (this.round % 4 === 0 && this.chances < 2) {
this.chances++;
vibrate('pickup');
ids.push('voice/chance_plus');
}
// G6(round3):轮结算链尾轻量反馈——gd_clear「漂亮,过了。」每局 1 次;
// 本轮完成数超过历史最佳(且确有旧纪录可破)时追加 gd_record「破纪录了。」
// 每局 1 次(结算另有 new_record,此处是局中破纪录的即时一刻)。游戏规则句
// gd_record「破纪录了。」每局限 1 次:本轮完成数超过历史最佳(且确有旧纪录
// 可破)时追加——局中破纪录的即时一刻(结算另有 new_record)。游戏规则句
// 随链播(全档位,gd_economy 先例),不走向导通道。
if (!this._clearSaid) {
this._clearSaid = true;
ids.push('voice/gd_clear');
}
if (!this._recordSaid && this.best > 0 && this.round > this.best) {
this._recordSaid = true;
ids.push('voice/gd_record');
@@ -464,6 +496,9 @@ export class SimonGame extends Game {
this._timer(() => vibrate('warning'), 300);
const ids = ['voice/retry_use'];
if (this.chances === 0) ids.push('voice/chances_out');
// P2(round1 live):答错重听时重播本轮组合占用提示(首次播报之后的唯一
// 重播途径——"能不能只在我答错的时候才提醒我")
ids.push(...this._occupancyVoices());
const s = this._session;
this.phase = 'roundend';
this._speakGuarded(ids, () => { if (s === this._session) this._playSequence(); });
@@ -532,11 +567,18 @@ export class SimonGame extends Game {
_onMenuGesture(g) {
const items = ['voice/menu_restart', 'voice/menu_home', 'voice/menu_back'];
if (g === 'tap') {
// 单击/右滑=下一项、左滑=上一项,切换即播报当前项(round1 live P1:原来
// 左右滑只有震动没有语音,"不知道有几项、指在哪项")
if (g === 'tap' || g === 'right') {
this._menuIdx = (this._menuIdx + 1) % items.length;
this.audio.play(items[this._menuIdx]);
} else if (g === 'left') {
this._menuIdx = (this._menuIdx + items.length - 1) % items.length;
this.audio.play(items[this._menuIdx]);
} else if (g === 'up') {
this._speakRules();
} else if (g === 'down') {
this.phase = 'gameover'; // 下滑=关闭菜单回结算页(13 号文档 §7.5 菜单约定)
} else if (g === 'doubletap') {
const sel = this._menuIdx;
if (sel === 0) this._restart();
+114 -16
View File
@@ -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 });