- 程序章程 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(风暴根治/回声真实性/密度下限+呼吸层/导航最短路/菜单关闭反馈/金币档同账本)+ 实现核对登记
1207 lines
59 KiB
JavaScript
1207 lines
59 KiB
JavaScript
/**
|
||
* 《不眠频率》无头试玩仿真测试(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('全部断言通过。');
|