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
+109 -19
View File
@@ -5,7 +5,8 @@
* - tap :voice 播放中 = 跳过本句(audio.skipVoice,链继续走下一句;
* 抉择提示 p* 播放中不可跳,盲玩公平性);无语音时 = 继续剧情;
* 结局后 = 重新开始
* - doubletap :重听当前节点的全部台词
* - doubletap :重听当前节点的全部台词(含结局节点——重播期间单击=跳句,
* 播完回到结算态;live round1 P2-1)
* - left / right :在选择节点做抉择(提示音播完后生效;播放中会缓存 pendingChoice)
* - up :听操作说明 + 当前进度,随后自动重播当前剧情
* - down :呼出菜单(左滑重新开始,右滑/单击返回,上滑听帮助)
@@ -15,8 +16,8 @@
*
* 机制保留:语音链 onComplete 串行 + seqToken 打断作废、pendingChoice 缓存、
* 剧情标记(companion/knowTrap/betrayed)、虚拟节点 resolve、e1 彩蛋、4 结局、
* 单句看门狗(VOICE_WATCHDOG_MS 超时自动续链,voice 资产缺失不卡死——
* 13 号文档 §1 边界断言)。
* 单句看门狗(VOICE_WATCHDOG_MS 超时且引擎确认 voice 未在播 = 资产缺失,
* 才自动续链不卡死;真实在播的长语音续约不掐——13 号文档 §1 + §16 N1)。
*
* 与 Android 差异:sfx 预热用 prewarm(提前解码)代替音量 0 假播(Web 引擎
* 按需解码 + AudioBuffer 缓存,见 docs/web-engine-api.md)。
@@ -76,17 +77,37 @@ const ALL_SFX = [
const MODE = { STORY: 'STORY', MENU: 'MENU' };
/** 单句语音看门狗:超时未 onComplete 视为资产缺失,自动推进链(不卡死)。 */
/** 单句语音看门狗:超时未 onComplete 且引擎确认 voice 未在播才视为资产缺失,
* 自动推进链(不卡死);voice 真在播(长句)则续约等待,不得掐断(N1)。 */
const VOICE_WATCHDOG_MS = 30000;
/** 抉择提示音 id(voice/p1~p8):播放中单击不可跳过(12 号文档 §1.2)。 */
const PROMPT_RE = /^voice\/p\d/;
// 向导层(14 号文档 + round2 synthesis F1.1):剧情类等待默认低频——25s 轻提醒
// +gd_continue,50s 升级只重申手势(gd_again),**永不重播 help/进度/当前节点**;
// 同一等待点最多 2 次提示后彻底静默(guide.js 升级链用尽自动停止)
// 向导层(14 号文档 + round2 synthesis F1.1):full 档 25s 轻提醒+gd_continue,
// 50s 升级只重申手势(gd_again),**永不重播 help/进度/当前节点**;
// 同一等待点最多 2 次提示后彻底静默(guide.js 升级链用尽自动停止)。
// full 档节奏由 web/harness/guide.test.mjs 锁定,不可改。
const GUIDE_WATCHDOG_AT = [25000, 50000];
// live round1 P2-3(静默无兜底):standard 档 guide 看门狗只发 nudge earcon、
// 无语音(14 号文档 #2),玩家等不到可听提示(老陈开局 45s 空白)。复用同一
// 看门狗(计时/复位/升级/取消都在向导层,不另造定时器):standard 档提前到
// 13s/30s,由 onEscalate 补播同 id 提示句(游戏反馈层,gd_economy 全档位先例);
// off 档看门狗整体不挂(既定全静契约 + 6 playtest 零干扰红线)。
const IDLE_HINT_AT = [13000, 30000];
// live round2 N3(抉择节点无静默兜底):p6/p8 实测 35~39s 无操作零提醒——
// 抉择点恰恰最容易漏听选项、最需要复述。14 号文档原裁定「抉择态(p* 播完)
// 不插语音不挂看门狗」的本义是"提示播完立即可选、不插语音延迟抉择"(W2
// 契约,保留:播完当刻 choiceArmed 即真、零插播);本兜底与之不冲突——只在
// 播完 25s 完全无操作后重播当前 p* 提示句本身(复用既有资产,不新增语音
// id),50s 再复述一次后彻底静默(与继续节点同一两级上限礼仪);玩家任何
// 手势经 guide.disarm 复位。新裁定依据 = round2 玩家复测证据(13 号文档 §16)。
// full/standard 档提示句都由游戏层播(p* 是剧情内容不是向导句,standard 档
// 玩家同样必须能复述选项);off 档整体不挂(既定全静契约)。
const CHOICE_HINT_AT = [25000, 50000];
export class StorylineGame extends Game {
constructor(audio, root) {
super(audio, root);
@@ -106,6 +127,7 @@ export class StorylineGame extends Game {
this.companion = false; // 带上了小满
this.knowTrap = false; // 装死时听到北区陷阱的情报
this.betrayed = false; // 向猎狗交出过坐标
this.viaCulvert = false; // n15 左滑走了涵洞(分支经历标记,保留给 TTS 分支句启用)
}
// ---------------------------------------------------------------- Game
@@ -137,6 +159,8 @@ export class StorylineGame extends Game {
// 旁白播放中单击 = 跳过本句(语音链继续);抉择提示 p* 不可跳
if (!this.promptPlaying) this.audio.skipVoice();
} else if (node.next != null) this.playNode(this.resolve(node.next));
// 抉择等待态空点 = 无进展手势:重新挂载兜底(25s 计时重置,N3「操作即复位」)
else if (node.prompt != null && this.choiceArmed) this.armChoiceHint(node);
break;
case 'doubletap':
this.replayNode();
@@ -197,7 +221,7 @@ export class StorylineGame extends Game {
this.pendingChoice = null;
this.awaitingRestart = false;
if (node.sfx) this.audio.play(node.sfx);
const seq = [...node.voices];
const seq = this.nodeVoices(id);
// F8 开场瘦身(round2):操作口令 gd_brief(≤8s)前置到开场链最前,
// 「brief→intro」;原 intro 长句里的操作教学资产保留不动。
// 向导句只在 full 档插播(off/standard 保持纯剧情链)。
@@ -214,7 +238,9 @@ export class StorylineGame extends Game {
}
replayNode() {
if (this.awaitingRestart) return;
// 结局节点同样兑现"双击:重听刚才的台词"(live round1 P2-1:结局播完双击
// 只有震动不重播)。重播期间清结算态——单击回到"跳过本句"而非重开;
// 播完由 onNodeSeqDone 重新进入结算态。
this.seqToken++;
this.audio.stopVoice();
// 重播当前节点(不重复进入音效之外的状态重置)
@@ -222,12 +248,28 @@ export class StorylineGame extends Game {
if (!node) return;
this.choiceArmed = false;
this.pendingChoice = null;
const seq = [...node.voices];
this.awaitingRestart = false;
const seq = this.nodeVoices(this.nodeId);
if (this.nodeId === 'e1' && this.companion) seq.push('voice/e1_child');
if (node.prompt) seq.push(node.prompt);
this.playSeq(seq, () => this.onNodeSeqDone(node));
}
/**
* 节点基础语音链。
* known-issue(live round2 N2,13 号文档 §16):round1 修 P2-2「涵洞也拦不住
* 你」穿帮时按 !viaCulvert 整段滤掉 n18_dog,结果钢索路径猎狗对峙戏蒸发、
* p6 抉择(交出坐标还是拒绝)失去对手戏上下文——玩家裁定这比穿帮更糟。
* 现恢复涵洞/钢索两条路径都播 n18_dog:钢索路径接受该句的轻微穿帮,待
* TTS 补录分支句后按 viaCulvert 选播(voice_text.json 无替代资产,台词
* 文本不可改、不新增语音 id)。
*/
nodeVoices(id) {
const node = NODES[id];
if (!node) return [];
return [...node.voices];
}
/** playNode / replayNode 的语音链自然播完后的公共收尾。 */
onNodeSeqDone(node) {
this.voicePlaying = false;
@@ -237,24 +279,53 @@ export class StorylineGame extends Game {
const g = this.pendingChoice;
this.pendingChoice = null;
this.choose(g);
} else {
// 抉择提示播完即可选(W2 契约:播完当刻 choiceArmed 即真、零插播)。
// 在此之上挂静默兜底:25s 无操作重播 p* 提示(N3,见 CHOICE_HINT_AT 注释)
this.armChoiceHint(node);
}
// 抉择提示播完即可选(W2 契约):此处不可插任何语音,也不挂看门狗
} else if (node.ending) {
this.awaitingRestart = true;
}
// W1/W3 静默等待看门狗(向导层,round2 F1.1 礼仪重构):25s nudge+gd_continue;
// 50s 升级只重申手势(gd_again)——**不再重播 help+进度+当前节点**。两级用尽
// 彻底静默,玩家下一次手势(新节点播完)重新挂载。抉择态与语音播放中不挂。
// W1/W3 静默等待看门狗(向导层,round2 F1.1 礼仪重构):full 档 25s nudge+
// gd_continue、50s 升级 gd_again——**不重播 help+进度+当前节点**,两级用尽
// 彻底静默。live round1 P2-3:standard 档 guide 只发 earcon 无语音,同一
// 看门狗换用 IDLE_HINT_AT(13s/30s)并由 onEscalate 补播同 id 提示句
// (游戏反馈层,off 档整体不挂)。玩家手势后由 guide.disarm 复位,下一
// 节点播完重新挂载。语音播放中不挂;抉择态改挂 armChoiceHint(N3 兜底)。
if (node.prompt == null) {
this.guide.watchdog({
arm: true,
at: GUIDE_WATCHDOG_AT,
at: this.guide.level() === 'full' ? GUIDE_WATCHDOG_AT : IDLE_HINT_AT,
say: ['gd_continue', 'gd_again'],
onEscalate: (n) => {
// full 档提示句由 guide 的 say 播(guide.test 锁定),不重复播
if (this.guide.level() === 'full') return;
this.audio.play(`voice/${n === 1 ? 'gd_continue' : 'gd_again'}`, { queue: true });
},
cond: () => this.mode === MODE.STORY && !this.voicePlaying,
});
}
}
/**
* N3 抉择节点静默兜底:25s/50s 无操作重播当前 p* 提示句(复用既有资产),
* 两级用尽彻底静默;玩家任何手势经 guide.disarm 复位(抉择态空点由
* onStoryGesture 重新挂载 = 计时重置;已缓存 pendingChoice 直接推进不挂)。
* guide=off 时 guide.watchdog 整体 no-op(不挂定时器,全静契约)。
*/
armChoiceHint(node) {
this.guide.watchdog({
arm: true,
at: CHOICE_HINT_AT,
onEscalate: () => {
if (node.prompt) this.audio.play(node.prompt, { queue: true });
},
// 已抉择(choiceArmed=false)/离开剧情态/新链在播 → 到点静默取消
cond: () => this.mode === MODE.STORY && this.choiceArmed && !this.voicePlaying,
});
}
/** 顺序播放一组对白,全部自然播完后回调。被打断时回调作废(seqToken 机制)。 */
playSeq(ids, onDone) {
const token = ++this.seqToken;
@@ -270,13 +341,22 @@ export class StorylineGame extends Game {
this.currentVoiceId = ids[i];
this.promptPlaying = PROMPT_RE.test(ids[i]);
let advanced = false;
// 看门狗:资产缺失时引擎不触发 onComplete,超时自动续链(不卡死)
const wd = setTimeout(() => {
// 看门狗:资产缺失时引擎不触发 onComplete,超时自动续链(不卡死)。
// live round2 N1:固定 30s 阈值会把真实在播的长语音误判为卡死——冷启动
// n00_intro(71.7s,2.2x 也要 32.6s)在 30s 处被续链的下一句 skip_hint
// 顶掉,末句「现在,单击屏幕,睁开眼睛」被吃(菜单重开时链上无下一句
// 可顶,故只在冷启动咬人);常速下 e1/e2/e4(48~51s)同样越线。判定
// 对齐真实播放状态:引擎 isVoicePlaying() 为真 = 资产在播(自然播完
// 必触发 onComplete),续约再等一轮,不得掐;为假才是资产缺失(引擎
// 静默跳过、永不回调),照旧告警续链(13 号文档 §1 不卡死断言不变)。
const armWd = () => setTimeout(() => {
if (advanced || token !== this.seqToken) return;
if (this.audio.isVoicePlaying()) { wd = armWd(); return; }
advanced = true;
console.warn('[StorylineGame] 语音看门狗超时,自动续链:', ids[i]);
console.warn('[StorylineGame] 语音看门狗超时(voice 未在播,判定资产缺失),自动续链:', ids[i]);
step(i + 1);
}, VOICE_WATCHDOG_MS);
let wd = armWd();
this.audio.play(ids[i], {
onComplete: () => {
if (advanced || token !== this.seqToken) return;
@@ -297,6 +377,7 @@ export class StorylineGame extends Game {
// 选择带来的剧情标记(注意此时 nodeId 仍是抉择点,n09 是 n07 右滑的目标)
if (this.nodeId === 'n07' && g === 'right') this.knowTrap = true; // 装死听情报
if (this.nodeId === 'n11' && g === 'left') this.companion = true;
if (this.nodeId === 'n15') this.viaCulvert = g === 'left'; // 涵洞/钢索(分支经历标记)
if (this.nodeId === 'n18' && g === 'left') this.betrayed = true;
this.playNode(this.resolve(g === 'left' ? node.left : node.right));
}
@@ -313,12 +394,20 @@ export class StorylineGame extends Game {
playHelpAndResume(backToMenu = false) {
this.seqToken++;
this.audio.stopVoice();
const node = NODES[this.nodeId];
const ending = !!(node && node.ending);
const act = this.actOf(this.nodeId);
const status = act === 1 ? 'voice/status1' : act === 2 ? 'voice/status2' : 'voice/status3';
// live round1 P3-2:结局后状态播报 = 当前结局语音(句尾自带"单击重新开始"),
// 不再报"这是最后一段路了"(status3)。结局句本身已是节点重播,播完直接
// 回结算态,不二次重播节点。
const status = ending ? node.voices[0]
: act === 1 ? 'voice/status1' : act === 2 ? 'voice/status2' : 'voice/status3';
this.playSeq(['voice/help', status], () => {
this.voicePlaying = false;
if (backToMenu) {
this.playSeq(['voice/menu'], () => { this.voicePlaying = false; });
} else if (ending) {
this.onNodeSeqDone(node); // 回结算态(awaitingRestart + 静默兜底重挂)
} else {
this.playNode(this.nodeId); // 重播当前剧情节点
}
@@ -353,6 +442,7 @@ export class StorylineGame extends Game {
this.companion = false;
this.knowTrap = false;
this.betrayed = false;
this.viaCulvert = false;
this.mode = MODE.STORY;
this.audio.play('sfx/ui_confirm');
this.audio.play('bgm/dark_drone'); // 从菜单重开时切回主题(结局重开为同首不重启)
+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('== 资产引用校验 ==');