Files
SoundGame/web/radio/test/playtest.mjs
T
xsl d870a75a26 feat(new8/batch1): 两款新 RPG《不眠频率》radio +《回声地窟》dungeon 全套(多agent流水线:策划→资产→开发→测试→玩家R1→修复→R2)
- 程序章程 research/new8/00-program.md(8款概念/回合协议/验收门/进度账)
- 设计:docs/design/15-radio.md+voice-ids(132句,6状态9抉择4结局,电话滤波资产侧带通)/16-dungeon.md+voice-ids(125句,脉冲回声/7层seed生成/三档战斗/元进度遗产)
- 资产:TTS 970文件(GPU flock串行)+ Mureka BGM 8首 $0.24(台账 mureka_log.md)+ 程序合成 sfx(含预pan侧墙/开口、phone_ring铃声、amb_cave呼吸层、turn_tick)+ 终检 check_all_assets 8游戏全绿
- radio:game.js+playtest 221断言(4结局矩阵/pendingChoice盲玩公平/RING两级看门狗/菜单循环/存档schema)
- dungeon:game.js+playtest 1610断言(seed全网格回声一致性/学期零死/格挡80%/死亡归因真话/元进度/菜单冻结/上滑5段状态链)
- 引擎修复:audio.js _playVoice 加载竞态补杀 token(R1真机71分钟语音风暴根因)+ 同id 3s窗口≥5次熔断保险丝 + gesture.js 长按boost事件门(+66行,9套件回归全绿)
- 大厅:新增 radio/dungeon 卡片 + GAMES 数组
- harness:sessions/radio.mjs+dungeon.mjs(主线+压力双剧本)+ transcribe 注册 + voice_text.json 刷新(8游戏704句)+ 13号文档 §20 登记
- R1 存档:4人设评审×2游戏 + 真机试玩报告×2(radio 5/5愿意玩;dungeon 0/4→确诊风暴)+ synthesis×2 + 转写8份
- R2 修复:radio F1-F5(看门狗补挂/rep_low改词重录/真铃声/ASK续玩语义/链末跳过保护)+ dungeon D0-1..D1-6(风暴根治/回声真实性/密度下限+呼吸层/导航最短路/菜单关闭反馈/金币档同账本)+ 实现核对登记
2026-09-02 18:52:37 +08:00

1207 lines
59 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 《不眠频率》无头试玩仿真测试(docs/design/15-radio.md §9 playtest 断言清单)。
*
* 方案:
* - mock 浏览器环境:window/localStorage/navigator/fetch/AudioContext
* (decodeAudioData 返回固定 2.0s 时长的假 buffer,语音链靠引擎 endTimer +
* onComplete 推进,虚拟时钟驱动,不真实等待);
* - 使用**真实** engine/audio.js + settings.js + guide.js;
* - 手势直接注入 game.onGesture(与 attachGestures 回调同路径);
* - 虚拟时钟:patch setTimeout/clearTimeout/performance.now;
* - 铃声为无缝循环垫(R2 F3:游戏侧持循环句柄,无重触发定时器),全程可复现;
* - 通话侧 amb 循环关闭(ambLoopEnabled=false,重触发定时器会让虚拟时钟永不空闲)。
*
* 运行:tools/node/bin/node web/radio/test/playtest.mjs
*/
import fs from 'node:fs';
import path from 'node:path';
import url from 'node:url';
const GAME_DIR = path.resolve(path.dirname(url.fileURLToPath(import.meta.url)), '..');
const AUDIO_DIR = path.join(GAME_DIR, 'audio');
const SAVE_KEY = 'soundgame.progress.radio';
// ---------------------------------------------------------------- 断言收集
const realLog = console.log.bind(console);
let passCount = 0;
const failures = [];
function assert(cond, msg) {
if (cond) { passCount++; realLog(` ✓ ${msg}`); }
else { failures.push(msg); realLog(` ✗ ${msg}`); }
}
// console 捕获(引擎/游戏的 warn/error 进日志,测试输出用 realLog)
const consoleErrs = [];
const consoleWarns = [];
console.error = (...a) => { consoleErrs.push(a.join(' ')); };
console.warn = (...a) => { consoleWarns.push(a.join(' ')); };
console.log = () => {}; // 游戏逐手势 console.log,静默
// ---------------------------------------------------------------- 虚拟时钟
let now = 0; // 虚拟毫秒
let timerSeq = 1;
const timers = new Map(); // id -> { fireAt, cb, args }
const realSetImmediate = setImmediate;
globalThis.setTimeout = (cb, ms = 0, ...args) => {
const id = timerSeq++;
timers.set(id, { fireAt: now + Math.max(0, Number(ms) || 0), cb, args });
return id;
};
globalThis.clearTimeout = (id) => { timers.delete(id); };
globalThis.setInterval = (cb, ms = 0, ...args) => {
const id = timerSeq++;
const step = (...a) => {
if (!intervals.has(id)) return;
cb(...a);
if (intervals.has(id)) timers.set(id, { fireAt: now + Math.max(0, Number(ms) || 0), cb: step, args });
};
timers.set(id, { fireAt: now + Math.max(0, Number(ms) || 0), cb: step, args });
return id;
};
const intervals = new Set();
globalThis.clearInterval = (id) => { intervals.delete(id); timers.delete(id); };
Object.defineProperty(globalThis, 'performance', {
value: { now: () => now }, configurable: true,
});
/** 排空微任务(fetch/decode 的 promise 链)。 */
async function flush(rounds = 8) {
for (let i = 0; i < rounds; i++) await new Promise((r) => realSetImmediate(r));
}
/**
* 推进虚拟时钟:逐个触发到期定时器,直到没有待触发定时器(= 游戏在等手势)
* 或 cond() 满足。返回 'cond' | 'idle'。
*/
async function advance(cond = null) {
for (let n = 0; n < 50000; n++) {
await flush(2);
if (cond && cond()) return 'cond';
let nextId = -1; let nextAt = Infinity;
for (const [id, t] of timers) if (t.fireAt < nextAt) { nextAt = t.fireAt; nextId = id; }
if (nextId < 0) return 'idle';
now = nextAt;
const t = timers.get(nextId);
timers.delete(nextId);
t.cb(...t.args);
}
throw new Error('虚拟时钟定时器超上限(疑似死循环)');
}
// ---------------------------------------------------------------- 浏览器 mock
const winListeners = new Map();
globalThis.window = {
addEventListener: (type, fn) => {
if (!winListeners.has(type)) winListeners.set(type, []);
winListeners.get(type).push(fn);
},
AudioContext: null, // 下方赋值
};
function fireWindowEvent(type) {
for (const fn of winListeners.get(type) || []) fn();
}
const storageData = new Map();
globalThis.localStorage = {
getItem: (k) => (storageData.has(k) ? storageData.get(k) : null),
setItem: (k, v) => { storageData.set(k, String(v)); },
removeItem: (k) => { storageData.delete(k); },
};
const vibrateLog = [];
Object.defineProperty(globalThis, 'navigator', {
value: { vibrate: (...a) => { vibrateLog.push({ pattern: a[0], t: now }); return true; } },
configurable: true,
});
class MockParam {
constructor(v = 1) { this.value = v; }
setValueAtTime() {}
linearRampToValueAtTime() {}
cancelScheduledValues() {}
}
class MockGainNode {
constructor() { this.gain = new MockParam(); }
connect() {}
disconnect() {}
}
function fakeBuffer() {
return {
duration: 2.0, // 固定句长 2s(虚拟时钟推进语音链)
numberOfChannels: 1,
sampleRate: 44100,
getChannelData: () => new Float32Array(16),
};
}
class MockAudioContext {
constructor() { this.state = 'running'; this.currentTime = 0; this.destination = {}; }
createGain() { return new MockGainNode(); }
createDynamicsCompressor() {
const n = new MockGainNode();
n.threshold = new MockParam(); n.ratio = new MockParam();
n.attack = new MockParam(); n.release = new MockParam();
return n;
}
createBufferSource() {
return { buffer: null, loop: false, connect() {}, start() {}, stop() {} };
}
createScriptProcessor() { return { connect() {}, disconnect() {}, onaudioprocess: null }; }
// 向导层 earcon 运行时合成所需
createOscillator() {
return { type: 'sine', frequency: new MockParam(440), connect() {}, start() {}, stop() {} };
}
decodeAudioData() { return Promise.resolve(fakeBuffer()); }
resume() { this.state = 'running'; return Promise.resolve(); }
}
globalThis.window.AudioContext = MockAudioContext;
// fetch:读真实资产目录;blockedAssets 中的片段强制 404(资产缺失测试用)
const requestedUrls = [];
const blockedAssets = new Set();
globalThis.fetch = async (u, opts = {}) => {
const s = String(u);
requestedUrls.push(s);
if (s.startsWith('/api/')) {
// 服务器 API:模拟 401(无有效会话)/200
if ((opts.method || 'GET') === 'PUT') {
return { ok: true, status: 200, json: async () => ({ ok: true, game: 'radio' }) };
}
return { ok: false, status: 401, json: async () => ({ error: 'unauthorized' }) };
}
const blocked = [...blockedAssets].some((b) => s.includes(b));
const p = path.join(GAME_DIR, decodeURIComponent(s));
if (blocked || !fs.existsSync(p)) {
return { ok: false, status: 404, arrayBuffer: async () => new ArrayBuffer(0) };
}
const data = fs.readFileSync(p);
return {
ok: true, status: 200,
arrayBuffer: async () => data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength),
};
};
// ---------------------------------------------------------------- 加载引擎与游戏
const { WebAudioEngine } = await import('../../engine/audio.js');
const { settings } = await import('../../engine/settings.js');
const { RadioGame } = await import('../game.js');
function resetSettings(guide) {
settings.set({ guide, voice: 'female', speed: 1.0, bgm: 1.0 });
}
resetSettings('off');
function clearSave() { storageData.delete(SAVE_KEY); }
function saveData() {
const raw = storageData.get(SAVE_KEY);
if (!raw) return null;
try { return JSON.parse(raw); } catch (_) { return null; }
}
const voicePlays = (playLog, id) => playLog.filter((e) => e.id === id).length;
const urlHits = (frag) => requestedUrls.filter((u) => u.includes(frag)).length;
const lastIndexOf = (playLog, id) => playLog.map((e) => e.id).lastIndexOf(id);
const ERR_PATTERN = JSON.stringify([60, 40, 60]); // error 震型
function makeRig() {
const audio = new WebAudioEngine('audio/');
const playLog = [];
const origPlay = audio.play.bind(audio);
audio.play = (id, opts) => { playLog.push({ id, t: now, opts }); return origPlay(id, opts); };
const game = new RadioGame(audio, {});
game.ambLoopEnabled = false; // 无头确定性:通话 amb 只起播一次,不重触发
return { audio, playLog, game };
}
let gestureCount = 0;
async function g(game, gesture) {
gestureCount++;
game.onGesture(gesture);
await flush();
}
/** 结束一个测试块:清看门狗/铃声/幕间定时器,避免污染后续块的虚拟时钟。 */
function teardown(game) {
game.guide.disarm();
game.stop(); // 内部 seqToken++:被中断链不得经 30s 语音看门狗复活
}
/**
* 叙事链推进器:语音在播时等它自然播完(不跳句),链末等待态单击继续
* (§2.3「链末=继续」),直到 cond() 满足(抉择武装 / RING / 提示句在播等)。
* 途经 RING(幕间自动流转到下一通)时单击接听;幕间喘息等自动段推进定时器。
*/
async function tapUntil(game, cond, maxSteps = 80) {
for (let i = 0; i < maxSteps; i++) {
if (cond()) return;
if (game.phase === 'RING') { await g(game, 'tap'); continue; } // 接听
if (game.voicePlaying) {
await advance(() => cond() || !game.voicePlaying); // 等当前句播完(自动流照走)
continue;
}
if (game.choiceArmed) {
throw new Error(`tapUntil 遇到未应答的抉择(node=${game.nodeId})——调用方需先做选择`);
}
await g(game, 'tap'); // 链末单击 = 继续
if (!cond() && !game.voicePlaying && game.phase !== 'RING') {
await advance(() => cond() || game.voicePlaying || game.phase === 'RING');
}
}
throw new Error(`tapUntil 超步限(node=${game.nodeId} phase=${game.phase})`);
}
// 抉择脚本(按 p1..p9 遭遇顺序;p5 仅 p4=右、p7 仅 p6=左 可达)
const SCRIPT_A = ['left', 'left', 'left', 'right', 'left', 'left', 'left', 'left', 'left'];
const SCRIPT_B = [...SCRIPT_A.slice(0, 8), 'right'];
// §9 剧本 C:p1右 p2左 p3右 p4右 p5右 p6右 p8右 p9左(p6 右 → 无 p7),rep=3 → e3
const SCRIPT_C = ['right', 'left', 'right', 'right', 'right', 'right', 'right', 'left'];
// §9 剧本 D:p1右 p2右 p3右 p4左 p6右 p8右 p9右(p4 左 → 无 p5;p6 右 → 无 p7),rep=0 → e4
const SCRIPT_D = ['right', 'right', 'right', 'left', 'right', 'right', 'right'];
// 剧本 E:全左但 p4 左(错过线索)→ heardArgument=false,C4 播 w1_say2 组;rep=9 → e1
const SCRIPT_E = ['left', 'left', 'left', 'left', 'left', 'left', 'left', 'left'];
/** 从头开一局并按脚本打完到 ENDING 结算态(链末单击继续、RING 单击接听)。 */
async function playNight(game, choices) {
let i = 0; let guard = 0;
while (!(game.phase === 'ENDING' && game.awaitingRestart)) {
if (++guard > 400) throw new Error(`playNight 卡住(node=${game.nodeId} phase=${game.phase})`);
if (game.phase === 'RING') { await g(game, 'tap'); continue; }
if (game.phase === 'BRIDGE') { await advance(() => game.phase !== 'BRIDGE'); continue; }
if (game.choiceArmed) {
const c = choices[i++];
if (!c) throw new Error(`playNight 抉择脚本耗尽(node=${game.nodeId})`);
await g(game, c);
continue;
}
if (!game.voicePlaying) { await g(game, 'tap'); continue; } // 链末=继续
await advance(() => !game.voicePlaying);
}
}
// ================================================================ 主路径试玩
// (§9 关键路径:剧本 A 全程 E1,rep 逐点断言与 §4.2 一致,≤60 手势)
realLog('== 主路径 · 剧本 A(E1,guide=off)==');
{
clearSave();
resetSettings('off');
const g0 = gestureCount;
const { audio, playLog, game } = makeRig();
// §7 全局:解锁前 play 为 no-op
audio.play('voice/b_open');
await flush();
assert(audio._voice === null && consoleWarns.some((w) => w.includes('尚未解锁')),
'启动遮罩点按前无音频播放(play 为 no-op)');
await audio.unlock();
playLog.length = 0; // 清掉解锁前 no-op 尝试的计数(无播放,仅探测记录)
game.start();
await flush();
assert(playLog.some((e) => e.id === 'bgm/onair'), '开局播放 bgm/onair(直播间垫乐,全夜班不切)');
assert(voicePlays(playLog, 'voice/sys_new_night') === 1, '新夜班 sys_new_night 先行');
assert(game.phase === 'INTRO', '进入宏状态 INTRO');
// 1. INTRO 链完整(自动流)→ RING(c1):turn 事件与铃声断言
await advance(() => game.currentVoiceId === 'voice/b_open');
assert(game.nodeId === 'b_open' && game.voicePlaying && game.currentVoiceId === 'voice/b_open',
'sys_new_night 播完接 b_open(voice 层有播放记录)');
assert(voicePlays(playLog, 'voice/gd_brief') === 0, 'guide=off:开场不插 gd_brief');
await advance(() => game.phase === 'RING');
for (const id of ['voice/b_open', 'voice/b_rain', 'voice/b_rule', 'voice/b_goal']) {
assert(voicePlays(playLog, id) === 1, `INTRO 链播出 ${id}`);
}
assert(voicePlays(playLog, 'voice/sys_skip') === 1, '开场 sys_skip 提示播一次(每局一次)');
assert(game.callN === 1 && game.phase === 'RING', 'INTRO 链完自动进 RING(c1)');
await flush();
assert(game._ringSrc !== null, 'F3:RING 铃声循环垫起播(sfx/phone_ring 无缝 loop,游戏侧持句柄)');
assert(saveData() && saveData().phase === 'RING' && saveData().call === 1,
'RING 进入即存档(写入时机),phase=RING / call=1');
// off 档 RING 挂起:零向导提示、零超时惩罚;循环垫无重触发定时器(虚拟时钟可空闲)
assert((await advance()) === 'idle' && game._ringSrc !== null,
'F3:RING 铃声循环垫持续(挂起态虚拟时钟可空闲 = 零重触发定时器,可复现)');
assert(voicePlays(playLog, 'voice/gd_ring1') === 0 && voicePlays(playLog, 'voice/gd_ring2') === 0,
'guide=off:RING 静默挂起零提示(看门狗不挂、零超时惩罚)');
// 2. 单击接听 → ONAIR(c1);叙事链末等待单击推进到 p1
await g(game, 'tap');
assert(game.phase === 'ONAIR' && game.nodeId === 'z1_ring_in' && game.voicePlaying,
'单击接听 → ONAIR(c1) z1_ring_in(线路引入)');
assert(game._ringSrc === null, 'F3:单击接听 → 铃声循环垫即停(不拖尾盖住通话)');
assert(game.rep === 0, '开局 rep=0');
await tapUntil(game, () => game.promptPlaying, 10);
assert(game.nodeId === 'p1' && game.promptPlaying, 'C1 链末单击推进至 p1 提示句播放中');
assert(voicePlays(playLog, 'voice/z1_ring_in') === 1 && voicePlays(playLog, 'voice/z1_a') === 1
&& voicePlays(playLog, 'voice/z1_b') === 1, '接听链 z1_ring_in→z1_a→z1_b 各播一次');
assert(game.choiceArmed === false, '提示播放中 choiceArmed=false(W2:播完即真,零插播)');
// p* 不可跳 + pendingChoice 左滑缓存(盲玩公平)
await g(game, 'tap');
assert(game.currentVoiceId === 'voice/p1' && game.promptPlaying, 'p* 播放中单击不跳句(PROMPT_RE 门)');
await g(game, 'left');
assert(game.pendingChoice === 'left', 'p* 播放中左滑缓存 pendingChoice(盲玩公平)');
await advance(() => game.nodeId === 'z1_song');
assert(game.pendingChoice === null, '播完 pendingChoice 立即生效并清空');
assert(game.rep === 1, 'p1 左 rep +1 → 1(与 §4.2 一致)');
assert(voicePlays(playLog, 'voice/z1_song') === 1, 'z1_song 恰播一次(不丢、不重复)');
// p2 左 → rep 3,选择结算后存档
await tapUntil(game, () => game.choiceArmed, 8); // z1_song→z1_songo→z1_sdone→p2
await g(game, 'left');
assert(game.rep === 3, 'p2 左 rep +2 → 3(+1+2=3,与 §9 一致)');
const sd1 = saveData();
assert(sd1 && sd1.rep === 3 && sd1.node === 'p2' && sd1.phase === 'ONAIR' && sd1.call === 1,
'抉择结算后存档:node=p2 / rep=3 / phase=ONAIR');
const keysNeeded = ['schema', 'updatedAt', 'summary', 'phase', 'node', 'rep', 'knot',
'heardArgument', 'bond', 'endings', 'nights'];
assert(keysNeeded.every((k) => k in sd1), '存档 data 覆盖 §8.1 全部字段(含 summary 字符串)');
assert(sd1.summary === '第1通·走丢的老人 · 口碑+3 · 结局0/4', `summary 人话格式(实际: ${sd1.summary})`);
// C1 收敛 z1_ok1 → BRIDGE(rep 跨 low→mid 播报)→ RING(c2)
await tapUntil(game, () => game.phase === 'BRIDGE', 10);
assert(voicePlays(playLog, 'voice/rep_mid') === 1, '口碑跨档(low→mid)→ BRIDGE 被动播报 rep_mid');
await advance(() => game.phase === 'RING');
assert(game.callN === 2, 'C1 收敛 z1_ok1 → BRIDGE → RING(c2)');
// C2:接听后先测双击重听(节点播完态)
await g(game, 'tap');
assert(game.phase === 'ONAIR' && game.nodeId === 'l1_in', '接听第二通 → l1_in(C2)');
await tapUntil(game, () => game.choiceArmed, 8);
const p3Before = voicePlays(playLog, 'voice/p3');
await g(game, 'doubletap');
assert(game.nodeId === 'p3' && voicePlays(playLog, 'voice/p3') === p3Before + 1,
'ONAIR 播完态双击 → 当前节点链同 id 序列重播(p3)');
await g(game, 'tap');
assert(game.currentVoiceId === 'voice/p3' && !game.choiceArmed,
'重播期间单击不跳 p* 提示句、nodeId 不变');
await advance(() => game.choiceArmed);
assert(!game.voicePlaying, '重播完回原等待态(choiceArmed)');
// C2 左 → rep 5
await g(game, 'left');
assert(game.rep === 5, 'p3 左 rep +2 → 5');
await tapUntil(game, () => game.phase === 'RING', 10);
assert(game.callN === 3, 'C2 收敛 → BRIDGE → RING(c3)');
// C3:arg_muffled 线索音 2 次、p4 右 → heardArgument、p5 左
await g(game, 'tap');
assert(game.nodeId === 'k1_in', '接听第三通 → k1_in(C3)');
await tapUntil(game, () => game.nodeId === 'k1_b' && !game.voicePlaying, 6);
// k1_b 起播 arg_muffled(1 次)+ 6.5s 后第二次(节点内 sfxEcho;链末等待态 nodeId 不变)
await advance(() => voicePlays(playLog, 'sfx/arg_muffled') >= 2);
assert(voicePlays(playLog, 'sfx/arg_muffled') === 2, 'k1_b 起播 arg_muffled 线索音 2 次(信息层)');
await tapUntil(game, () => game.choiceArmed, 4); // → p4
await g(game, 'right'); // p4 右 = 问她
assert(game.heardArgument === true, 'p4 右后 heardArgument === true');
assert(game.rep === 6, 'p4 右 rep +1 → 6');
await tapUntil(game, () => game.choiceArmed, 6); // k1_ask→k1_asko→p5
await g(game, 'left'); // p5 左 = 真话
assert(game.rep === 7, 'p5 左 rep +1 → 7');
assert(game.bond.kid === 1, 'p5 左 → bond.kid=1');
await tapUntil(game, () => game.phase === 'RING', 12);
assert(game.callN === 4, 'C3 收敛 → BRIDGE → RING(c4)');
// C4:w1_say 组(heardArgument 消费)
await g(game, 'tap');
assert(game.nodeId === 'w1_in', '接听第四通 → w1_in(C4)');
await tapUntil(game, () => game.choiceArmed, 8);
await g(game, 'left'); // p6 左
assert(game.rep === 9, 'p6 左(heardArgument)rep +2 → 9');
await tapUntil(game, () => game.choiceArmed, 6); // w1_say 组 → p7
assert(voicePlays(playLog, 'voice/w1_say') >= 1 && voicePlays(playLog, 'voice/w1_say2') === 0,
'C4 播 w1_say 组(heardArgument 消费),绝不播 w1_say2');
await g(game, 'left'); // p7 左
assert(game.rep === 11, 'p7 左 rep +2 → 11');
await tapUntil(game, () => game.phase === 'RING', 10);
assert(game.callN === 5, 'C4 收敛 → BRIDGE → RING(c5)');
// C5:危机拍 + bond_zhou 回响
await g(game, 'tap');
assert(game.nodeId === 'h1_in', '接听第五通 → h1_in(C5)');
await tapUntil(game, () => game.choiceArmed, 8);
await g(game, 'left'); // p8 左
assert(game.rep === 12, 'p8 左 rep +1 → 12(理论上限)');
await tapUntil(game, () => game.choiceArmed, 8); // h1_talk → 危机拍自动流 → 签收 p9
assert(voicePlays(playLog, 'sfx/brake_screech') >= 1, 'C5 危机拍:brake_screech 前置 2s 预警');
assert(vibrateLog.some((v) => JSON.stringify(v.pattern) === JSON.stringify([25, 40, 25, 40, 25])),
'危机拍 danger 震型');
assert(voicePlays(playLog, 'voice/h1_echo') === 1, 'bond_zhou=1 → C5 插播 h1_echo 回响句(C1→C5)');
assert(voicePlays(playLog, 'voice/h1_talko') === 1 && voicePlays(playLog, 'voice/h1_danger') === 1,
'危机拍整段自动流不等待(h1_talko→h1_danger)');
// p9 左 → knot → ENDING(e1) + 尾声回响
await g(game, 'left');
assert(game.knot === true, 'p9 左 → knot=true(心结解开)');
assert(game.rep === 12, 'p9 不动 rep');
await advance(() => game.phase === 'ENDING' && game.awaitingRestart);
assert(game.endingId === 'e1', 'rep≥5 且直面 → ENDING(e1 天亮之前)');
for (const id of ['voice/e1_a', 'voice/e1_b', 'voice/ep_lead', 'voice/ep_zhou_ok',
'voice/ep_deer_ok', 'voice/ep_kid_ok', 'voice/ep_wife_ok', 'voice/ep_han_ok']) {
assert(voicePlays(playLog, id) === 1, `结局链播出 ${id}`);
}
const sd2 = saveData();
assert(sd2 && sd2.phase === 'ENDING' && sd2.ending === 'e1' && sd2.endings.e1 === true,
'ENDING 进入即存档,endings 收集 e1');
assert(sd2.summary === '已收播·天亮之前 · 结局1/4', `已收播 summary(实际: ${sd2.summary})`);
assert(['zhou', 'deer', 'kid', 'wife', 'han'].every((k) => game.bond[k] === 1),
'剧本 A 五位来电者好感全 1');
// 归因真话律:剧本 A 全程零归因/补救句(成功不教学)
assert(['voice/z1_bade', 'voice/k1_lulle', 'voice/k1_liee', 'voice/e4_b']
.every((id) => voicePlays(playLog, id) === 0), '剧本 A 全程无归因/补救句(成功不教学)');
// ENDING 双击重听结局链
const e1aBefore = voicePlays(playLog, 'voice/e1_a');
await g(game, 'doubletap');
assert(game.awaitingRestart === false && game.nodeId === 'e1',
'结局链播完双击 → 重听结局链(清结算态、nodeId 不变)');
await g(game, 'tap');
assert(game.nodeId === 'e1' && !game.awaitingRestart, '重播期间单击 = 跳本句,不触发重开');
await advance(() => game.awaitingRestart);
assert(voicePlays(playLog, 'voice/e1_a') === e1aBefore + 1, '结局链同 id 序列重播完成,回结算态');
// 结算态单击 → 新夜班
await g(game, 'tap');
assert(game.phase === 'INTRO' && game.nights === 2 && game.rep === 0 && !game.knot
&& !game.heardArgument, '结算单击 = 新夜班:回 INTRO,rep/knot/heardArgument 归零');
assert(game.endings.e1 === true, 'endings 结局收集跨夜保留');
assert(voicePlays(playLog, 'voice/sys_new_night') === 2, 'sys_new_night 随新夜班再播');
assert(voicePlays(playLog, 'voice/sys_skip') === 1, 'sys_skip 每局只播一次,重开不重播');
assert(gestureCount - g0 <= 60, `关键路径手势预算 ≤60(实际 ${gestureCount - g0})`);
teardown(game);
}
// ================================================================ 四结局矩阵
// (§9 边界:剧本 B → e2;剧本 C → e3;剧本 D → e4;剧本 E 补 w1_say2 差分;
// 各局 rep 终值与 §4.2 一致)
realLog('== 四结局矩阵(剧本 B / C / D / E)==');
{
// ---- 剧本 B:与 A 同路但 p9 右 → e2
clearSave(); resetSettings('off');
{
const { audio, playLog, game } = makeRig();
await audio.unlock();
game.start(); await flush();
await playNight(game, SCRIPT_B);
assert(game.endingId === 'e2' && game.rep === 12 && game.knot === false,
'剧本 B(p9 右)→ e2 热线常亮,rep=12,knot=false');
assert(voicePlays(playLog, 'voice/e2_a') === 1 && voicePlays(playLog, 'voice/e2_b') === 1,
'e2 专属结局主句 + 钩子播出');
teardown(game);
}
// ---- 剧本 C:p1右 p2左 p3右 p4右 p5右 p6右 p8右 p9左 → e3, rep=3
clearSave(); resetSettings('off');
{
const { audio, playLog, game } = makeRig();
await audio.unlock();
game.start(); await flush();
await playNight(game, SCRIPT_C);
assert(game.endingId === 'e3' && game.rep === 3 && game.knot === true,
'剧本 C → e3 一个人的频道,rep=3,knot=true');
assert(voicePlays(playLog, 'voice/e3_a') === 1 && voicePlays(playLog, 'voice/e3_b') === 1,
'e3 专属结局主句 + 钩子播出');
assert(voicePlays(playLog, 'voice/z1_ask') === 1 && voicePlays(playLog, 'voice/z1_adone') === 1,
'剧本 C:p1 右 → 问路牌路径推进(z1_ask→z1_asko→z1_adone)');
assert(game.heardArgument === true && voicePlays(playLog, 'voice/w1_say') === 0
&& voicePlays(playLog, 'voice/w1_say2') === 0,
'剧本 C:p4 右埋线索但 p6 右本通结束 → 两组带话差分都不播');
assert(voicePlays(playLog, 'voice/ep_zhou_no') === 0, '剧本 C:老周被救到 → 不播 ep_zhou_no');
assert(voicePlays(playLog, 'voice/ep_deer_no') === 1, '剧本 C:p3 右错过小鹿 → 尾声播 ep_deer_no');
teardown(game);
}
// ---- 剧本 D:p1右 p2右 p3右 p4左 p6右 p8右 p9右 → e4, rep=0
clearSave(); resetSettings('off');
{
const { audio, playLog, game } = makeRig();
await audio.unlock();
game.start(); await flush();
await playNight(game, SCRIPT_D);
assert(game.endingId === 'e4' && game.rep === 0 && game.knot === false,
'剧本 D → e4 静音,rep=0,knot=false');
assert(voicePlays(playLog, 'voice/z1_bad') === 1 && voicePlays(playLog, 'voice/z1_bade') === 1,
'剧本 D:p1右+p2右 砸单组合 → 播 z1_bad/z1_bade 归因句(真话律)');
assert(voicePlays(playLog, 'voice/k1_lulle') === 1, '剧本 D:p4 左错过线索 → k1_lulle 诚实复盘');
assert(voicePlays(playLog, 'voice/w1_say') === 0 && voicePlays(playLog, 'voice/w1_say2') === 0,
'剧本 D:p6 右本通结束 → 无带话差分(heardArgument=false 也不播)');
assert(voicePlays(playLog, 'voice/e4_a') === 1 && voicePlays(playLog, 'voice/e4_b') === 1,
'e4 专属结局主句 + 钩子播出');
for (const id of ['voice/ep_zhou_no', 'voice/ep_deer_no', 'voice/ep_kid_no',
'voice/ep_wife_no', 'voice/ep_han_no']) {
assert(voicePlays(playLog, id) === 1, `剧本 D 全错过 → 尾声播出 ${id}`);
}
teardown(game);
}
// ---- 剧本 E:全左但 p4 左(错过线索)→ heardArgument=false → C4 播 w1_say2 组
clearSave(); resetSettings('off');
{
const { audio, playLog, game } = makeRig();
await audio.unlock();
game.start(); await flush();
await playNight(game, SCRIPT_E);
assert(game.endingId === 'e1' && game.rep === 9,
'剧本 E(p4 左错线索,其余全左)→ e1,rep=1+2+2+0+1+2+1=9');
assert(game.heardArgument === false, '剧本 E:p4 左 → heardArgument=false');
assert(voicePlays(playLog, 'voice/w1_say2') === 1 && voicePlays(playLog, 'voice/w1_say2o') === 1
&& voicePlays(playLog, 'voice/w1_say2e') === 1 && voicePlays(playLog, 'voice/w1_say') === 0,
'剧本 E:C4 左路消费差分 → 播 w1_say2 组整组,绝不播 w1_say 组');
assert(voicePlays(playLog, 'voice/k1_lulle') === 1, '剧本 E:p4 左 → k1_lulle 复盘句(诚实归因,非惩罚)');
teardown(game);
}
}
// ================================================================ 状态播报与无效手势
// (§2.3 上滑状态链 + 无效手势零静默 + idle-only F1.4)
realLog('== 状态播报与无效手势(guide=full)==');
{
// ---- RIG A:RING 上滑状态播报(第几通 + 口碑档,留在 RING)
clearSave(); resetSettings('full');
{
const { audio, playLog, game } = makeRig();
await audio.unlock();
game.start(); await flush();
await advance(() => game.phase === 'RING');
await g(game, 'up');
assert(game.currentVoiceId === 'voice/prog_c1', 'RING 上滑先播 prog_c1(第几通)');
await advance(() => !game.voicePlaying);
const iP1 = lastIndexOf(playLog, 'voice/prog_c1');
const iR1 = lastIndexOf(playLog, 'voice/rep_low');
assert(iP1 >= 0 && iP1 < iR1, 'RING 上滑状态链顺序:prog_c1 → rep_low(rep=0 → low 档整句)');
assert(game.phase === 'RING', '状态播报后留在 RING');
teardown(game);
}
// ---- RIG B:RING 无效手势(左滑 / 首通前双击)零静默 + onceKey 同局限次
clearSave(); resetSettings('full');
{
const { audio, playLog, game } = makeRig();
const vib0 = vibrateLog.length;
await audio.unlock();
game.start(); await flush();
await advance(() => game.phase === 'RING');
await g(game, 'left');
assert(vibrateLog.slice(vib0).some((v) => JSON.stringify(v.pattern) === ERR_PATTERN),
'RING 左滑 → error 震(无效手势零静默)');
assert(voicePlays(playLog, 'voice/gd_invalid') === 1, 'RING 左滑 → gd_invalid(full 档播出)');
await g(game, 'doubletap'); // 首通前无上一通收尾句 → 无效
assert(voicePlays(playLog, 'voice/gd_invalid') === 1,
'再次无效手势 gd_invalid 不重播(onceKey 同局限次)');
const errN = vibrateLog.slice(vib0).filter((v) => JSON.stringify(v.pattern) === ERR_PATTERN).length;
assert(errN === 2, `无效手势震反馈每次都有(零静默,实际 ${errN} 次)`);
teardown(game);
}
// ---- RIG C:idle-only(F1.4,对白在播时纠错跳过且不占 onceKey)+ BRIDGE 上滑报状态
clearSave(); resetSettings('full');
{
const { audio, playLog, game } = makeRig();
const guideEvents = [];
game.guide.setEventHook((ev) => guideEvents.push(ev));
const vib0 = vibrateLog.length;
await audio.unlock();
game.start(); await flush();
await advance(() => game.phase === 'RING');
await g(game, 'tap'); // 接听 → z1_ring_in 播放中
await g(game, 'left');
assert(guideEvents.some((e) => e.type === 'error' && e.skipped === true),
'对白在播时无效手势 → 纠错句 idle-only 跳过(事件带 skipped)');
assert(voicePlays(playLog, 'voice/gd_invalid') === 0 && game.voicePlaying,
'在播对白不被纠错句顶掉,gd_invalid 未播出');
await advance(() => !game.voicePlaying); // z1_ring_in 链末
await g(game, 'left');
assert(voicePlays(playLog, 'voice/gd_invalid') === 1,
'语音空闲后再次无效 → gd_invalid 播出(idle 跳过不占 onceKey)');
assert(vibrateLog.slice(vib0).filter((v) => JSON.stringify(v.pattern) === ERR_PATTERN).length === 2,
'两次无效手势 error 震都发出(零静默)');
// C1 → BRIDGE:上滑报状态(帮助 + 下一通 + 口碑档),自动链继续
await tapUntil(game, () => game.choiceArmed, 10); // → p1
await g(game, 'left'); // p1 左
await tapUntil(game, () => game.choiceArmed, 10); // → p2
await g(game, 'left'); // p2 左
await tapUntil(game, () => game.phase === 'BRIDGE', 10); // z1_stay→…→z1_ok1→@bridge
await advance(() => !game.voicePlaying); // rep_mid 跨档播报播完
await g(game, 'up');
await advance(() => !game.voicePlaying);
const iH = lastIndexOf(playLog, 'voice/sys_help');
const iP2 = lastIndexOf(playLog, 'voice/prog_c2');
const iR2 = lastIndexOf(playLog, 'voice/rep_mid');
assert(iH < iP2 && iP2 < iR2, 'BRIDGE 上滑状态链顺序:sys_help → prog_c2(下一通)→ rep_mid');
await advance(() => game.phase === 'RING');
assert(game.callN === 2, 'BRIDGE 上滑后自动链继续进 RING(c2)');
teardown(game);
}
}
// ================================================================ 菜单交互
// (§9 边界:菜单返回不丢节点、循环顺序、帮助、保存并回大厅;queue 不顶句)
realLog('== 菜单交互(guide=off,C1 期间呼出)==');
{
clearSave(); resetSettings('off');
const { audio, playLog, game } = makeRig();
await audio.unlock();
game.start(); await flush();
await advance(() => game.phase === 'RING');
await g(game, 'tap'); // 接听 C1
await tapUntil(game, () => game.promptPlaying, 10); // p1 提示播放中呼出菜单
await g(game, 'down');
assert(game.phase === 'MENU', '下滑呼出模态菜单');
await flush();
assert(audio._bgm && audio._bgm.id === 'bgm/menu', '进菜单 BGM 切 bgm/menu');
await g(game, 'tap'); // 播放中单击循环 → queue 排队,不截断当前报名
await advance(() => !game.voicePlaying);
await g(game, 'tap');
await advance(() => !game.voicePlaying);
await g(game, 'tap');
await advance(() => !game.voicePlaying);
const cnt = (id) => voicePlays(playLog, id);
const iMenu = playLog.findIndex((e) => e.id === 'voice/sys_menu');
const iRes = playLog.findIndex((e) => e.id === 'voice/sys_menu_resume');
const iRst = playLog.findIndex((e) => e.id === 'voice/sys_menu_restart');
const iLob = playLog.findIndex((e) => e.id === 'voice/sys_menu_lobby');
assert(iMenu >= 0 && iMenu < iRes && iRes < iRst && iRst < iLob,
'菜单项 queue 依序播出(sys_menu → 回到直播 → 重新开始今晚 → 保存并回大厅),在播句不被截断');
assert(cnt('voice/sys_menu_restart') === 1 && cnt('voice/sys_menu_lobby') === 1
&& cnt('voice/sys_menu_resume') === 2,
'单击循环顺序 = [回到直播, 重新开始今晚, 保存并回大厅],末项单击回绕首项再报名');
await g(game, 'up');
await advance(() => !game.voicePlaying);
assert(cnt('voice/sys_help') >= 1, '菜单内上滑播 sys_help 帮助');
await g(game, 'doubletap'); // 当前项 = 回到直播
assert(game.phase === 'ONAIR' && game.nodeId === 'p1',
'双击确认「回到直播」→ phase/node 不变');
await advance(() => game.choiceArmed);
assert(cnt('voice/p1') >= 2, '菜单关闭后重播当前节点(局面复述,含抉择提示)');
await g(game, 'down'); // 再开
await advance(() => !game.voicePlaying);
await g(game, 'down'); // 下滑关闭
assert(game.phase === 'ONAIR' && game.nodeId === 'p1', '下滑关闭菜单 → 回原状态');
await advance(() => game.choiceArmed);
// 保存并回大厅
await g(game, 'down');
await advance(() => !game.voicePlaying);
await g(game, 'tap');
await g(game, 'tap'); // 第二项 = 保存并回大厅
await g(game, 'doubletap');
assert(cnt('voice/sys_menu_lobby') >= 2, '确认回大厅 → sys_menu_lobby 播出');
assert(game.leaving === true, 'leaving 置位(跳转大厅前先播确认句)');
const sd = saveData();
assert(sd && sd.schema === 1 && typeof sd.summary === 'string' && sd.summary.length > 0,
'回大厅确认后 localStorage 有最新 data(含 summary)');
teardown(game);
}
// ================================================================ 中断复述
// (§9 边界:杀进程重进 → sys_continue_ask → 单击恢复 + 上滑状态链顺序断言;
// §8.1 断点续玩:单击=继续上次 / 右滑=新夜班)
realLog('== 中断复述(guide=off,预置存档 node=k1_asko)==');
{
clearSave(); resetSettings('off');
storageData.set(SAVE_KEY, JSON.stringify({
schema: 1,
updatedAt: new Date().toISOString(),
summary: '第3通·失眠的孩子 · 口碑+2 · 结局0/4',
phase: 'ONAIR',
node: 'k1_asko',
call: 3,
ending: null,
rep: 2,
knot: false,
heardArgument: true,
bond: { zhou: 1, deer: 1, kid: 0, wife: 0, han: 0 },
endings: {},
nights: 3,
lastCloser: null,
}));
const { audio, playLog, game } = makeRig();
await audio.unlock();
game.start(); await flush();
assert(game.phase === 'ASK' && game.currentVoiceId === 'voice/sys_continue_ask',
'带存档重进 → sys_continue_ask 询问');
await g(game, 'tap');
assert(game.phase === 'ONAIR' && game.nodeId === 'k1_asko' && game.callN === 3,
'单击继续 → 恢复 phase+node+call');
assert(game.rep === 2 && game.heardArgument === true && game.nights === 3,
'rep / heardArgument / nights 全部还原');
await advance(() => !game.voicePlaying);
assert(voicePlays(playLog, 'voice/k1_asko') === 1, '重播当前节点链(局面复述)');
await g(game, 'up');
await advance(() => !game.voicePlaying);
const iH = lastIndexOf(playLog, 'voice/sys_help');
const iP = lastIndexOf(playLog, 'voice/prog_c3');
const iR = lastIndexOf(playLog, 'voice/rep_mid');
assert(iH >= 0 && iH < iP && iP < iR, '上滑状态链顺序:sys_help → prog_c3 → rep_mid');
assert(voicePlays(playLog, 'voice/prog_c3') === 1, 'prog_c3 整句(第三通)');
await advance(() => !game.voicePlaying);
assert(voicePlays(playLog, 'voice/k1_asko') === 2, '状态播完重播当前节点(局面复述)');
await g(game, 'tap'); // k1_asko 链末 → p5
await advance(() => game.choiceArmed);
await g(game, 'left'); // p5 左
assert(game.rep === 3, '恢复局内抉择正常结算:p5 左 rep 2+1=3');
teardown(game);
// ASK 右滑 = 新夜班(§8.1):复用上面会话的最新存档再进一次(storageData 保留 p5 抉择后的存档)
{
const { audio: audio2, game: game2 } = makeRig();
await audio2.unlock();
game2.start(); await flush();
assert(game2.phase === 'ASK', '再次带存档重进 → ASK 询问态');
await g(game2, 'right');
assert(game2.phase === 'INTRO' && game2.rep === 0 && !game2.knot && !game2.heardArgument
&& game2.saved === null, 'ASK 右滑 = 新夜班:跨通话变量重置、存档询问态清除');
teardown(game2);
}
}
// ================================================================ 看门狗
// (§7.2/§9:RING 两级不同句 10s/25s;ONAIR 链末 25s/50s gd_continue/gd_again;
// p* 播完 25s/50s 复述两级;ENDING 25s/50s;guide 计时、手势复位、用尽静默)
realLog('== 看门狗(guide=full,RING 两级 / ONAIR 链末 / p* 两级 / ENDING 两级)==');
{
clearSave(); resetSettings('full');
const { audio, playLog, game } = makeRig();
const guideEvents = [];
game.guide.setEventHook((ev) => guideEvents.push(ev));
await audio.unlock();
game.start(); await flush();
await advance(() => game.currentVoiceId === 'voice/b_open'); // sys_new_night 播完接 b_open 链
assert(voicePlays(playLog, 'voice/gd_brief') === 1, 'full 档开场链最前插 gd_brief');
assert(guideEvents.some((e) => e.type === 'state' && e.name === 'explore'), 'INTRO 进入播 explore 签名');
await advance(() => game.phase === 'RING');
const tR = now;
assert(guideEvents.some((e) => e.type === 'turn'), 'RING 进入 turn 事件(铃声 + turn cue)');
await advance(() => voicePlays(playLog, 'voice/gd_ring1') === 1);
assert(now - tR === 10000, 'RING 看门狗第一级 10s → gd_ring1');
await advance(() => voicePlays(playLog, 'voice/gd_ring2') === 1);
assert(now - tR === 25000, 'RING 看门狗第二级 25s → gd_ring2(与第一级不同句)');
const r1 = voicePlays(playLog, 'voice/gd_ring1');
const r2 = voicePlays(playLog, 'voice/gd_ring2');
assert(r1 === 1 && r2 === 1, '两级提示各播一次(绝不逐字重播)');
await advance(() => now >= tR + 40000);
assert(voicePlays(playLog, 'voice/gd_ring1') === r1 && voicePlays(playLog, 'voice/gd_ring2') === r2
&& game._ringSrc !== null, '两级用尽后只剩铃声循环垫,永不惩罚');
// ONAIR 链末等待看门狗(§7.2 叙事链末行 / §9:25s gd_continue、50s gd_again)
await g(game, 'tap'); // 接听 → z1_ring_in
await advance(() => !game.voicePlaying); // 链末等待,看门狗挂载
const tW = now;
await advance(() => voicePlays(playLog, 'voice/gd_continue') === 1);
assert(now - tW === 25000, 'ONAIR 链末 25s 静默 → gd_continue');
await advance(() => voicePlays(playLog, 'voice/gd_again') === 1);
assert(now - tW === 50000, '50s → gd_again(第二级新句)');
await advance(() => now >= tW + 70000);
assert(voicePlays(playLog, 'voice/gd_again') === 1, 'ONAIR 两级用尽彻底静默');
await g(game, 'tap'); // 手势复位看门狗,继续 → z1_a
await tapUntil(game, () => game.choiceArmed, 8); // z1_a→z1_b→p1
const tP = now;
await advance(() => voicePlays(playLog, 'voice/p1') === 2);
assert(now - tP === 25000, 'p* 播完 25s 无操作 → 重播当前 p* 提示句(第一级)');
await advance(() => voicePlays(playLog, 'voice/p1') === 3);
assert(now - tP === 50000, '50s → 再复述一次(第二级)');
await advance(() => now >= tP + 70000);
assert(voicePlays(playLog, 'voice/p1') === 3, 'p* 复述两级后彻底静默');
await g(game, 'left');
assert(game.rep === 1 && game.nodeId === 'z1_song', '复述期间剧情不推进,玩家抉择后正常生效');
await tapUntil(game, () => game.choiceArmed, 8); // z1_song→z1_songo→z1_sdone→p2
await g(game, 'left'); // p2 左(rep 3)
await tapUntil(game, () => game.phase === 'BRIDGE', 10);
assert(voicePlays(playLog, 'voice/rep_mid') === 1, '口碑跨档(low→mid)→ BRIDGE 被动播报 rep_mid');
await advance(() => game.phase === 'RING');
await g(game, 'tap'); // 接听 c2
await tapUntil(game, () => game.choiceArmed, 8); // l1_in→l1_a→l1_b→p3
await g(game, 'left'); // p3 左(rep 5)
await tapUntil(game, () => game.phase === 'BRIDGE', 10);
assert(voicePlays(playLog, 'voice/rep_high') === 1, '口碑跨入 ≥5(mid→high)→ BRIDGE 播报 rep_high');
await advance(() => game.phase === 'RING');
// C3 起按剧本 A 剩余抉择打到 ENDING(playNight 内含接听/链末推进)
await playNight(game, ['right', 'left', 'left', 'left', 'left', 'left']);
assert(game.knot === true, 'p9 左 → knot=true');
await advance(() => game.phase === 'ENDING' && game.awaitingRestart);
const tE = now;
const gc0 = voicePlays(playLog, 'voice/gd_continue');
const ga0 = voicePlays(playLog, 'voice/gd_again');
await advance(() => voicePlays(playLog, 'voice/gd_continue') === gc0 + 1);
assert(now - tE === 25000, 'ENDING 结算 25s 静默 → gd_continue');
await advance(() => voicePlays(playLog, 'voice/gd_again') === ga0 + 1);
assert(now - tE === 50000, '50s → gd_again(第二级新句)');
await advance(() => now >= tE + 70000);
assert(voicePlays(playLog, 'voice/gd_again') === ga0 + 1, '两级用尽彻底静默');
await g(game, 'tap');
assert(game.phase === 'INTRO' && game.nights === 2, '结算单击重开新夜班');
const choiceEvents = guideEvents.filter((e) => e.type === 'state' && e.name === 'choice').length;
assert(choiceEvents === 9, `每个 p* 起播发一次 choice 签名(实际 ${choiceEvents})`);
assert(guideEvents.some((e) => e.type === 'state' && e.name === 'settle'), 'BRIDGE/ENDING 进入 settle 签名');
assert(guideEvents.some((e) => e.type === 'state' && e.name === 'danger'), 'C5 危机拍 danger 签名(全局唯一使用点)');
teardown(game);
}
// ================================================================ 资产缺失
// (§9 边界:屏蔽任意 voice 资产 → 单句看门狗自动续链到结局,不卡死)
realLog('== 资产缺失(guide=off,屏蔽 k1_asko)==');
{
clearSave(); resetSettings('off');
blockedAssets.add('k1_asko');
const errBefore = consoleErrs.length;
const { audio, playLog, game } = makeRig();
await audio.unlock();
game.start(); await flush();
await playNight(game, SCRIPT_A);
assert(consoleErrs.slice(errBefore).some((e) => e.includes('k1_asko')),
'屏蔽资产仅 console.error,不抛异常');
assert(consoleWarns.some((w) => w.includes('看门狗') && w.includes('k1_asko')),
'单句 30s 看门狗判定资产缺失并自动续链');
assert(game.endingId === 'e1' && game.awaitingRestart === true, '屏蔽 voice 资产仍打到 e1 结局,不卡死');
assert(voicePlays(playLog, 'voice/gd_ring1') === 0 && voicePlays(playLog, 'voice/gd_ring2') === 0
&& voicePlays(playLog, 'voice/gd_continue') === 0 && voicePlays(playLog, 'voice/gd_again') === 0,
'guide=off 全程零向导语音(gd_*)');
blockedAssets.delete('k1_asko');
teardown(game);
}
// ================================================================ 设置联动
// (§7 全局:voice 性别目录切换、speed 从下一句生效、bgm=0 停播与自动恢复、
// 长按 1.7x 引擎接管)
realLog('== 设置联动与引擎行为(guide=off)==');
{
clearSave(); resetSettings('off');
const { audio, playLog, game } = makeRig();
await audio.unlock();
game.start(); await flush();
await advance(() => game.phase === 'RING');
await g(game, 'tap');
await tapUntil(game, () => game.promptPlaying, 10); // p1
settings.set({ voice: 'male', speed: 1.7 });
await g(game, 'left'); // 提示播放中 → pendingChoice 缓存
await advance(() => game.nodeId === 'z1_song'); // 提示播完缓存生效,z1_song 以新设置起播
assert(urlHits('audio/voice/male/z1_song') > 0, 'settings.voice=male → 下一句起走 male 目录');
assert(audio._voice && audio._voice.st.tempo === 1.7, 'settings.speed=1.7 下一句起生效(SoundTouch tempo)');
settings.set({ speed: 1.0, voice: 'female' });
fireWindowEvent('soundgame:longpress');
assert(audio._voice && audio._voice.st.tempo === 1.7, '长按 → 旁白临时 1.7 倍速(不变调)');
fireWindowEvent('soundgame:longpressend');
assert(audio._voice && audio._voice.st.tempo === 1.0, '松开 → 恢复 settings.speed');
await tapUntil(game, () => game.choiceArmed, 8); // z1_song→z1_songo→z1_sdone→p2
assert(audio._bgm && audio._bgm.id === 'bgm/onair', 'ONAIR/RING 垫乐 bgm/onair');
settings.set({ bgm: 0 });
await flush();
assert(audio._bgm === null, 'settings.bgm=0 立即停播');
settings.set({ bgm: 1 });
await flush();
assert(audio._bgm && audio._bgm.id === 'bgm/onair', 'bgm 恢复后自动补播最后状态垫乐');
teardown(game);
}
// ================================================================ R2 修复回归
// (research/new8/radio/round1/synthesis.md F1-F5 + V1/V2;断言增量登记
// docs/design/15-radio.md 文末「R2 修复回归」小节)
realLog('== R2 修复回归(F1 看门狗重挂 / F3 铃声垫 / F4 续玩跳句锁 / F5 链末句保护 / V1 抉择时序 / V2 菜单循环)==');
{
// ---- F1a:ONAIR 链末等待态发无效手势——看门狗被 disarm 拆掉后必须重挂(阈值内触发)
clearSave(); resetSettings('full');
{
const { audio, playLog, game } = makeRig();
await audio.unlock();
game.start(); await flush();
await advance(() => game.phase === 'RING');
await g(game, 'tap'); // 接听 → z1_ring_in
await advance(() => !game.voicePlaying); // 链末等待(看门狗挂载)
const t0 = now;
await g(game, 'left'); // 无效手势(live 局 B t=445.4 同款:旧实现拆看门狗不补挂 → 34.2s 静默)
assert(voicePlays(playLog, 'voice/gd_invalid') === 1, 'F1 前置:链末无效手势照常零静默(gd_invalid)');
await advance(() => voicePlays(playLog, 'voice/gd_continue') === 1);
assert(now - t0 === 25000, `F1:链末等待中发无效手势,看门狗仍在阈值内触发(25s 整,实际 ${now - t0}ms)`);
await advance(() => voicePlays(playLog, 'voice/gd_again') === 1);
assert(now - t0 === 50000, `F1:重挂后第二级同样有效(50s 整,实际 ${now - t0}ms)`);
teardown(game);
}
// ---- F1b:RING 无效手势不拆铃看门狗(gd_ring1 仍准点)且铃声垫不受影响
clearSave(); resetSettings('full');
{
const { audio, playLog, game } = makeRig();
await audio.unlock();
game.start(); await flush();
await advance(() => game.phase === 'RING');
await flush();
const t0 = now;
await g(game, 'left'); // RING 无效手势
await advance(() => voicePlays(playLog, 'voice/gd_ring1') === 1);
assert(now - t0 === 10000, `F1:RING 无效手势后铃看门狗仍 10s 准点(实际 ${now - t0}ms)`);
assert(game._ringSrc !== null, 'F3:RING 无效手势不拆铃声循环垫');
teardown(game);
}
// ---- F1c:ASK 询问态无效手势看门狗重挂
clearSave(); resetSettings('full');
storageData.set(SAVE_KEY, JSON.stringify({
schema: 1, updatedAt: new Date().toISOString(), summary: '第1通·走丢的老人 · 口碑+0 · 结局0/4',
phase: 'ONAIR', node: 'z1_a', call: 1, ending: null,
rep: 0, knot: false, heardArgument: false,
bond: { zhou: 0, deer: 0, kid: 0, wife: 0, han: 0 }, endings: {}, nights: 1, lastCloser: null,
}));
{
const { audio, playLog, game } = makeRig();
await audio.unlock();
game.start(); await flush();
await advance(() => game.phase === 'ASK' && !game.voicePlaying); // sys_continue_ask 播完
const t0 = now;
await g(game, 'up'); // ASK 内无效手势(上滑不在 ASK 语义内)
await advance(() => voicePlays(playLog, 'voice/gd_continue') === 1);
assert(now - t0 === 25000, `F1:ASK 无效手势后看门狗仍 25s 准点(实际 ${now - t0}ms)`);
teardown(game);
}
// ---- F4/F5:续玩恢复首句跳句锁 + 链末句保护(w1_say 组一段链三连断言:
// 首句受锁不可跳 → 锁解除后中段句可跳(对照) → 末句受 F5 保护不可跳)
clearSave(); resetSettings('off');
storageData.set(SAVE_KEY, JSON.stringify({
schema: 1, updatedAt: new Date().toISOString(), summary: '第4通·争吵的夫妻 · 口碑+9 · 结局0/4',
phase: 'ONAIR', node: 'w1_say', call: 4, ending: null,
rep: 9, knot: false, heardArgument: true,
bond: { zhou: 1, deer: 1, kid: 1, wife: 0, han: 0 }, endings: {}, nights: 1, lastCloser: null,
}));
{
const { audio, playLog, game } = makeRig();
await audio.unlock();
game.start(); await flush();
await advance(() => game.phase === 'ASK' && !game.voicePlaying);
await g(game, 'tap'); // 单击继续上次 → 恢复 w1_say 组
assert(game.phase === 'ONAIR' && game.currentVoiceId === 'voice/w1_say',
'F4 前置:断点续玩恢复,首句 w1_say 在播');
await g(game, 'tap');
assert(game.currentVoiceId === 'voice/w1_say', 'F4:恢复后首句播完前单击不跳句(先听完再武装跳句)');
await advance(() => game.currentVoiceId === 'voice/w1_sayo');
await g(game, 'tap');
await flush();
assert(game.currentVoiceId === 'voice/w1_saye', 'F4 对照:首句播完后跳句重新武装(w1_sayo 被正常跳过)');
await g(game, 'tap'); // w1_saye = 本节点末句(播完交接 p7 抉择提示)
assert(game.currentVoiceId === 'voice/w1_saye' && game.voicePlaying,
'F5:链末句(交接抉择提示前)单击不可跳');
await advance(() => !game.voicePlaying);
assert(voicePlays(playLog, 'voice/w1_saye') === 1, 'F5 对照:链末句完整播出不被吃尾');
await g(game, 'tap'); // 链末单击继续
await advance(() => game.choiceArmed);
assert(game.nodeId === 'p7', 'F5 对照:末句播完后照常交接 p7 抉择提示');
teardown(game);
}
// ---- F4b:续玩恢复到结算态——结局链首句播完才武装跳句(live 局 B 被扔进结局链的陷阱场景)
clearSave(); resetSettings('off');
storageData.set(SAVE_KEY, JSON.stringify({
schema: 1, updatedAt: new Date().toISOString(), summary: '已收播·静音 · 结局1/4',
phase: 'ENDING', node: 'e4', call: 5, ending: 'e4',
rep: 0, knot: false, heardArgument: false,
bond: { zhou: 0, deer: 0, kid: 0, wife: 0, han: 0 }, endings: { e4: true }, nights: 2,
lastCloser: null,
}));
{
const { audio, playLog, game } = makeRig();
await audio.unlock();
game.start(); await flush();
await advance(() => game.phase === 'ASK' && !game.voicePlaying);
await g(game, 'tap'); // 单击继续上次 → 结局链重播
assert(game.phase === 'ENDING' && game.currentVoiceId === 'voice/e4_a',
'F4b 前置:续玩恢复到 ENDING(e4) 结局链首句');
await g(game, 'tap');
assert(game.currentVoiceId === 'voice/e4_a', 'F4b:恢复后首句(e4_a)播完前单击不跳句');
await advance(() => game.currentVoiceId === 'voice/e4_b');
await g(game, 'tap');
await flush();
assert(game.currentVoiceId === 'voice/ep_lead', 'F4b:首句播完后跳句武装(e4_b 被跳过,链照常走)');
teardown(game);
}
// ---- F5b:@ending 过场转移前的链末句保护(d9_facee 点题句,live 局 A 被吃掉的尾句)
clearSave(); resetSettings('off');
storageData.set(SAVE_KEY, JSON.stringify({
schema: 1, updatedAt: new Date().toISOString(), summary: '第5通·雨夜司机 · 口碑+12 · 结局0/4',
phase: 'ONAIR', node: 'd9_facee', call: 5, ending: null,
rep: 12, knot: true, heardArgument: true,
bond: { zhou: 1, deer: 1, kid: 1, wife: 1, han: 1 }, endings: {}, nights: 1, lastCloser: null,
}));
{
const { audio, playLog, game } = makeRig();
await audio.unlock();
game.start(); await flush();
await advance(() => game.phase === 'ASK' && !game.voicePlaying);
await g(game, 'tap');
assert(game.currentVoiceId === 'voice/d9_facee', 'F5b 前置:恢复至签收链末句 d9_facee');
await g(game, 'tap');
assert(game.currentVoiceId === 'voice/d9_facee', 'F5b:@ending 过场前的链末句单击不可跳');
await advance(() => game.phase === 'ENDING' && game.awaitingRestart);
assert(game.endingId === 'e1' && voicePlays(playLog, 'voice/d9_facee') === 1,
'F5b 对照:末句完整播完后自动过场进 ENDING(e1)');
teardown(game);
}
// ---- V1:pendingChoice 生效时序——抉择语音播完才生效;播中连点不丢、不改、不提前
clearSave(); resetSettings('off');
{
const { audio, playLog, game } = makeRig();
await audio.unlock();
game.start(); await flush();
await advance(() => game.phase === 'RING');
await g(game, 'tap');
await tapUntil(game, () => game.promptPlaying, 10); // → p1 提示句播放中
await g(game, 'left');
await g(game, 'tap'); // 压力局连点:p* 不可跳……
await g(game, 'tap');
assert(game.currentVoiceId === 'voice/p1' && game.promptPlaying, 'V1 前置:p* 播放中连点不跳句');
assert(game.pendingChoice === 'left' && game.rep === 0 && game.nodeId === 'p1',
'V1:连点不清缓存、不提前生效(rep 未动、节点未进)');
await g(game, 'right');
assert(game.pendingChoice === 'right', 'V1:播中再滑覆盖缓存(最后一次为准)');
await g(game, 'left');
const iP1Last = lastIndexOf(playLog, 'voice/p1');
await advance(() => game.nodeId === 'z1_song');
const ids = playLog.map((e) => e.id);
const iConfirm = ids.indexOf('sfx/ui_confirm');
assert(iConfirm > iP1Last && ids.indexOf('voice/z1_song') > iConfirm,
'V1:生效时序 = p1 播完 → ui_confirm 结算音 → 结果句(播完才生效,零插播)');
assert(game.rep === 1 && game.pendingChoice === null && game.nodeId === 'z1_song',
'V1:缓存恰好生效一次(p1 左 rep=1,缓存清空,不丢不重复)');
teardown(game);
}
// ---- V2:菜单全项循环——三项(含第三项「保存并回大厅」)逐项可达、循环无漂移、确认可执行
clearSave(); resetSettings('off');
{
const { audio, playLog, game } = makeRig();
await audio.unlock();
game.start(); await flush();
await advance(() => game.phase === 'RING');
await g(game, 'down'); // 呼出模态菜单
await advance(() => !game.voicePlaying); // sys_menu + 首项报名播完
for (let i = 0; i < 6; i++) { // 两个整循环(stress 局 38 次循环听不全第三项的回归)
await g(game, 'tap');
await advance(() => !game.voicePlaying);
}
const ids = playLog.map((e) => e.id);
const iHdr = ids.indexOf('voice/sys_menu');
const cycle = ids.slice(iHdr + 1, iHdr + 7);
assert(JSON.stringify(cycle) === JSON.stringify([
'voice/sys_menu_resume', 'voice/sys_menu_restart', 'voice/sys_menu_lobby',
'voice/sys_menu_resume', 'voice/sys_menu_restart', 'voice/sys_menu_lobby',
]), `V2:单击循环两个整周期逐项可达(实际: ${cycle.join(' → ')})`);
assert(game.menuIdx === 0, 'V2:循环 6 次后回绕首项(模 3 无漂移)');
await g(game, 'tap');
await advance(() => !game.voicePlaying);
await g(game, 'tap');
await advance(() => !game.voicePlaying); // 停在第二项 = 保存并回大厅
const lobBefore = voicePlays(playLog, 'voice/sys_menu_lobby');
await g(game, 'doubletap'); // 确认执行
await flush();
assert(game.leaving === true && voicePlays(playLog, 'voice/sys_menu_lobby') === lobBefore + 1,
'V2:双击确认「保存并回大厅」→ 确认句播出且 leaving 置位');
assert(saveData() && saveData().call === 1 && typeof saveData().summary === 'string',
'V2:确认执行落盘最新存档(含 summary,第1通)');
teardown(game);
}
}
// ================================================================ 静态审计
// (数字规范:分档整句字面量、无运行时 id 拼接;存档 summary 格式;资产盘点)
realLog('== 静态审计 ==');
{
const src = fs.readFileSync(path.join(GAME_DIR, 'game.js'), 'utf8');
for (const id of ['voice/prog_c1', 'voice/prog_c2', 'voice/prog_c3', 'voice/prog_c4',
'voice/prog_c5', 'voice/rep_high', 'voice/rep_mid', 'voice/rep_low',
'voice/gd_ring1', 'voice/gd_ring2']) {
assert(src.includes(`'${id}'`), `分档整句字面量存在:${id}`);
}
assert(!/voice\/\$\{/.test(src), '无模板字符串拼接 voice id');
assert(!/['"]voice\/[A-Za-z0-9_]+['"]\s*\+/.test(src), '无字符串相加拼接 voice id');
assert(!/\+\s*['"]voice\//.test(src), '无前置相加拼接 voice id');
// 资产盘点:voice/bgm 全量齐备(ogg+m4a,含性别目录/平铺回退)
const ids = [...new Set([...src.matchAll(/'((?:voice|sfx|bgm)\/[A-Za-z0-9_.-]+)'/g)].map((m) => m[1]))];
assert(ids.length > 0, `game.js 提取到 ${ids.length} 个资产 id`);
const both = (rel) => ['.ogg', '.m4a'].every((ext) => fs.existsSync(path.join(AUDIO_DIR, rel + ext)));
// Stable Audio 3 批次缺口(gen/gen_sfx.py 头注:HF token 未配置本轮跳过,
// R2 F3 已移除 phone_ring(程序合成铃声循环垫落盘);剩 11 件待补;
// 缺音效走引擎 console.error 静默降级,不阻塞玩法(§6.1 降级方案)。
const STABLE_AUDIO_PENDING = new Set(['sfx/onair_sting', 'sfx/line_connect',
'sfx/line_drop', 'sfx/amb_rain_city', 'sfx/amb_station', 'sfx/amb_kid_room', 'sfx/arg_muffled',
'sfx/amb_street_rain', 'sfx/amb_cab', 'sfx/brake_screech', 'sfx/tape_static']);
const missingVoiceBgm = [];
const missingSfx = [];
for (const id of ids) {
if (id.startsWith('voice/')) {
const name = id.slice(6);
if (!(both(`voice/male/${name}`) || both(`voice/female/${name}`) || both(`voice/${name}`))) {
missingVoiceBgm.push(id);
}
} else if (id.startsWith('bgm/')) {
if (!both(id)) missingVoiceBgm.push(id);
} else if (!both(id)) {
missingSfx.push(id);
}
}
assert(missingVoiceBgm.length === 0,
`voice/bgm 资产零缺失(${ids.length} 个引用 id,ogg+m4a 双格式,含性别目录/平铺回退)`);
assert(both('sfx/ui_confirm') && both('sfx/ui_menu') && both('sfx/phone_ring'),
'程序合成 sfx ui_confirm/ui_menu/phone_ring 齐备(R2 F3 铃声循环垫落盘,ogg+m4a)');
assert(missingSfx.every((id) => STABLE_AUDIO_PENDING.has(id)),
`sfx 缺失仅限 Stable Audio 未产出批次(实际: ${missingSfx.join(', ') || '无'})`);
// 双性别 / NPC 平铺抽查(§5.3)
assert(both('voice/male/p1') && both('voice/female/p1'), '抉择提示 p1 双性别齐备');
assert(both('voice/z1_a') && !fs.existsSync(path.join(AUDIO_DIR, 'voice/male/z1_a.ogg')),
'NPC 老周 z1_a 仅平铺目录(不做双性别,电话滤波质感)');
assert(both('voice/male/gd_ring1') && both('voice/female/gd_ring2'),
'RING 两级提示 gd_ring1/gd_ring2 双性别齐备');
// 存档 summary 人话格式(RING 进入时机)
clearSave(); resetSettings('off');
const { audio, game } = makeRig();
await audio.unlock();
game.start(); await flush();
await advance(() => game.phase === 'RING');
const sd = saveData();
assert(sd && sd.schema === 1, '存档 schema=1');
assert(sd && sd.summary === '第1通·走丢的老人 · 口碑+0 · 结局0/4',
`summary 格式「第N通·来电者 · 口碑±X · 结局K/4」(实际: ${sd && sd.summary})`);
assert(sd && Number.isFinite(Date.parse(sd.updatedAt)), 'updatedAt 为 ISO 时间戳');
teardown(game);
}
// ================================================================ 汇总
realLog(`\n通过 ${passCount} 条断言,失败 ${failures.length} 条`);
if (failures.length > 0) {
realLog('失败清单:');
for (const f of failures) realLog(` - ${f}`);
process.exit(1);
}
realLog('全部断言通过。');