配对码与认领(B): - POST /api/pair/start|join:6 位码 10 分钟单次有效、仅存内存零泄露面、5 连错 per-IP 封锁 10 分钟;join=新设备档案按桶 LWW 并入老档案+token 改绑(复用 claim-merge);日志脱敏 - 大厅:生成配对码(speechSynthesis 逐位朗读+aria-live+重听)/从其他设备转入(one-time-code 输入框)/从本机移除/401 过期自愈引导/aria 补全/玩家名可听化 - api-smoke 12/12(配对全流程/频控/封锁/过期自愈/移除/digitsToCn)+ CDP 真浏览器 E2E 22/22 键盘语音双套资产(A,路线 a): - 164 变体句(8 游戏 grep 实测,超出调研预估 153:含轻触/点两下/按住变体)×2 性别=328 生成 656 文件 36.9 分钟;触屏主资产零字节改动(PCM 逐样本比对后还原) - 引擎 _candidates 三跳路由 key→gender→平铺(inputMode 跟随);缓存键 mode|gender|id;engine-input 新增 K9 路由断言(72 条) - check_all_assets 增 key 列(两性别一致且 ⊆ 主集)8 游戏全绿;voice_text.json 增 __key__ 表;live control.mjs key 会话对照键盘措辞验收(集成) - voice-ids 文档:8 游戏键盘变体小节由 KEY_LINES 程序化生成(与台词表逐字断言一致);补齐 storyline/escape/simon 三个历史缺失文档;design README 增 new8 索引 - mock 适配:playtest persona 加 maxTouchPoints=1 保持触屏链断言语义零改动 回归:12 套件全绿(8 playtest + guide 122 + engine-voice 19 + engine-input 72 + api-smoke 12/12) 同批入库:批次 2/3 已完稿设计文档(17-abyss/18-detective/19-cipher 全套,20-22 在途)
1394 lines
71 KiB
JavaScript
1394 lines
71 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', {
|
||
// maxTouchPoints: 1 —— 手机 persona:inputMode() 判 touch,语音走触屏措辞主资产(键盘路由由 engine-input K9 专测)
|
||
value: { maxTouchPoints: 1, 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}`);
|
||
}
|
||
assert(voicePlays(playLog, 'sfx/onair_sting') === 1 && voicePlays(playLog, 'sfx/line_drop') >= 1
|
||
&& voicePlays(playLog, 'sfx/tape_static') === 1,
|
||
'SA3 音效接线:onair_sting(开场台标)/ line_drop(幕间挂断)/ tape_static(p9 直面)各就位');
|
||
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(iP2 >= 0 && iP2 < iR2 && iR2 < iH,
|
||
'BRIDGE 上滑状态链顺序(G6):prog_c2(下一通)→ rep_mid → sys_help(状态在前、帮助垫后)');
|
||
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(iP >= 0 && iP < iR && iR < iH, '上滑状态链顺序(G6):prog_c3 → rep_mid → sys_help');
|
||
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);
|
||
}
|
||
}
|
||
|
||
// ================================================================ R3 修复回归
|
||
// (research/new8/radio/round2/synthesis.md G1-G6 打磨轮;断言增量登记
|
||
// docs/design/15-radio.md 文末「R3 修复回归」小节)
|
||
|
||
realLog('== R3 修复回归(G1 帮助链去重 / G2 rep_low warning 震 / G3 male 回退确认 / G4 空点轻震 / G5 首播复播显式规则 / G6 状态链重排)==');
|
||
{
|
||
const WARN_PATTERN = JSON.stringify([30, 50, 30, 50, 30]); // warning 震型(G2)
|
||
const BEAT_PATTERN = JSON.stringify([8]); // beat 震型(G1 标记 / G4 轻震)
|
||
|
||
// ---- G6+G2+G1(ONAIR):上滑状态链「prog → rep → sys_help」+ rep_low warning 震
|
||
// + 同一等待点第 2 次起口令换纯音频标记(震 + nudge earcon),新等待点仍全量
|
||
clearSave(); resetSettings('full');
|
||
{
|
||
const { audio, playLog, game } = makeRig();
|
||
const earcons = [];
|
||
const origEarcon = game.guide._playEarcon.bind(game.guide);
|
||
game.guide._playEarcon = (name) => { earcons.push(name); return origEarcon(name); };
|
||
const vib0 = vibrateLog.length;
|
||
await audio.unlock();
|
||
game.start(); await flush();
|
||
await advance(() => game.phase === 'RING');
|
||
await g(game, 'tap'); // 接听 → z1_ring_in
|
||
await advance(() => !game.voicePlaying); // 链末等待(等待点 = S:z1_ring_in)
|
||
await g(game, 'up'); // 该等待点第 1 次:全量口令
|
||
await advance(() => !game.voicePlaying);
|
||
const iP1 = lastIndexOf(playLog, 'voice/prog_c1');
|
||
const iR1 = lastIndexOf(playLog, 'voice/rep_low');
|
||
const iH1 = lastIndexOf(playLog, 'voice/sys_help');
|
||
assert(iP1 >= 0 && iP1 < iR1 && iR1 < iH1,
|
||
'G6:ONAIR 上滑状态链顺序 prog_c1 → rep_low → sys_help(状态在前、帮助垫后)');
|
||
assert(vibrateLog.slice(vib0).some((v) => JSON.stringify(v.pattern) === WARN_PATTERN),
|
||
'G2:ONAIR 上滑复听 rep_low(rep=0)附 warning 震');
|
||
await advance(() => !game.voicePlaying); // 局面复述播完,回到同一链末等待
|
||
await g(game, 'up'); // 同一等待点第 2 次:纯音频标记
|
||
await advance(() => !game.voicePlaying);
|
||
assert(voicePlays(playLog, 'voice/sys_help') === 1,
|
||
'G1:同一等待点第 2 次上滑不重播 sys_help 口令(提示永不逐字重复)');
|
||
assert(voicePlays(playLog, 'voice/prog_c1') === 2 && voicePlays(playLog, 'voice/rep_low') === 2,
|
||
'G1:状态句照播(复述状态=合法帮助,去重只针对口令)');
|
||
assert(vibrateLog.slice(vib0).some((v) => JSON.stringify(v.pattern) === BEAT_PATTERN),
|
||
'G1:第 2 次帮助播 beat 轻震标记(不加新台词的纯音频方案)');
|
||
assert(earcons.includes('nudge'),
|
||
'G1:第 2 次帮助播 nudge earcon(复用统一 8 音词表,零新增)');
|
||
await g(game, 'tap'); // 链末单击继续 → z1_a(新等待点)
|
||
await advance(() => !game.voicePlaying);
|
||
await g(game, 'up'); // 新等待点第 1 次
|
||
await advance(() => !game.voicePlaying);
|
||
assert(voicePlays(playLog, 'voice/sys_help') === 2,
|
||
'G1:去重按等待点——新等待点(z1_a)首滑仍全量口令,非全局静默');
|
||
teardown(game);
|
||
}
|
||
|
||
// ---- G2(RING):上滑复听 rep_low 附 warning 震(live 局 B t=198.5/497.5 同款证据位,
|
||
// R1 P3-3 遗留——原实现只在 BRIDGE 跨档播报时震且该分支实际不可达)
|
||
clearSave(); resetSettings('off'); // off 档:warning 震来自游戏层,与向导档位无关
|
||
{
|
||
const { audio, playLog, game } = makeRig();
|
||
const vib0 = vibrateLog.length;
|
||
await audio.unlock();
|
||
game.start(); await flush();
|
||
await advance(() => game.phase === 'RING');
|
||
await g(game, 'up'); // 状态播报:prog_c1 → rep_low(rep=0)
|
||
await advance(() => !game.voicePlaying);
|
||
const iP1 = lastIndexOf(playLog, 'voice/prog_c1');
|
||
const iR1 = lastIndexOf(playLog, 'voice/rep_low');
|
||
assert(iP1 >= 0 && iP1 < iR1, 'G2 前置:RING 上滑播 prog_c1 → rep_low(rep=0 → low 档)');
|
||
assert(vibrateLog.slice(vib0).some((v) => JSON.stringify(v.pattern) === WARN_PATTERN),
|
||
'G2:RING 上滑复听 rep_low 附 warning 震(R1 P3-3 遗留补齐)');
|
||
teardown(game);
|
||
}
|
||
|
||
// ---- G6+G2+G1(ENDING):上滑状态链「结局主句 → 口碑 → sys_help」+ 去重
|
||
clearSave(); resetSettings('full');
|
||
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();
|
||
const vib0 = vibrateLog.length;
|
||
await audio.unlock();
|
||
game.start(); await flush();
|
||
await advance(() => game.phase === 'ASK' && !game.voicePlaying);
|
||
await g(game, 'tap'); // 续玩 → 结局链重播(F4 锁只锁首句)
|
||
await advance(() => game.awaitingRestart); // 播完回结算态
|
||
await g(game, 'up'); // 第 1 次:e4_a → rep_low → sys_help
|
||
await advance(() => !game.voicePlaying);
|
||
const iE = lastIndexOf(playLog, 'voice/e4_a');
|
||
const iR = lastIndexOf(playLog, 'voice/rep_low');
|
||
const iH = lastIndexOf(playLog, 'voice/sys_help');
|
||
assert(iE >= 0 && iE < iR && iR < iH,
|
||
'G6:ENDING 上滑状态链顺序 e4_a(结局主句)→ rep_low → sys_help');
|
||
assert(vibrateLog.slice(vib0).some((v) => JSON.stringify(v.pattern) === WARN_PATTERN),
|
||
'G2:ENDING 上滑复听 rep_low 附 warning 震');
|
||
const helpN = voicePlays(playLog, 'voice/sys_help');
|
||
await g(game, 'up'); // 同一等待点(E:e4)第 2 次:纯音频标记
|
||
await advance(() => !game.voicePlaying);
|
||
assert(voicePlays(playLog, 'voice/sys_help') === helpN,
|
||
'G1:ENDING 同一等待点第 2 次上滑不重播 sys_help 口令');
|
||
teardown(game);
|
||
}
|
||
|
||
// ---- G4+G5:不可跳句空点 beat 轻震(p* / F5 尾句 / F4 锁三处)+
|
||
// 「首播必完整、复播可即掐」——F4 锁只锁首播一句,复播(局面复述)不重新武装
|
||
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();
|
||
const vib0 = vibrateLog.length;
|
||
await audio.unlock();
|
||
game.start(); await flush();
|
||
await advance(() => game.phase === 'ASK' && !game.voicePlaying);
|
||
await g(game, 'tap'); // 恢复 w1_say 组(首句受 F4 锁)
|
||
assert(game.currentVoiceId === 'voice/w1_say', 'G4 前置:断点续玩恢复,首句 w1_say 在播');
|
||
await g(game, 'tap'); // 锁内单击:不跳(G5 ASK 恢复首句例外)
|
||
assert(game.currentVoiceId === 'voice/w1_say',
|
||
'G5:ASK 恢复链首句例外——锁内单击不跳(首播必完整,F4)');
|
||
assert(vibrateLog.slice(vib0).filter((v) => JSON.stringify(v.pattern) === BEAT_PATTERN).length === 1,
|
||
'G4:F4 锁内空点单击播 beat 轻震(可区分于真确认的 [20])');
|
||
await advance(() => game.currentVoiceId === 'voice/w1_sayo'); // 首句播完,锁释放
|
||
await g(game, 'down'); // 呼出菜单
|
||
await advance(() => !game.voicePlaying);
|
||
await g(game, 'down'); // 关菜单 → 局面复述(重播 w1_say 组 = 复播)
|
||
assert(game.currentVoiceId === 'voice/w1_say', 'G5 前置:关菜单局面复述回放首句 w1_say');
|
||
await g(game, 'tap'); // 复播的首句单击 → 可跳(锁不随复播重新武装)
|
||
await flush();
|
||
assert(game.currentVoiceId === 'voice/w1_sayo',
|
||
'G5:复播可即掐——F4 锁只锁首播一句,复播(局面复述)首句单击照常跳过');
|
||
await advance(() => game.currentVoiceId === 'voice/w1_saye');
|
||
await g(game, 'tap'); // F5 链末句(交接 p7 前)单击不可跳
|
||
assert(game.currentVoiceId === 'voice/w1_saye' && game.voicePlaying,
|
||
'G5:首播必完整——链末关键句(交接抉择提示前)单击不可跳(F5)');
|
||
assert(vibrateLog.slice(vib0).filter((v) => JSON.stringify(v.pattern) === BEAT_PATTERN).length === 2,
|
||
'G4:F5 尾句空点单击播 beat 轻震(R2 P3-4「p9 后空点」证据位)');
|
||
await advance(() => !game.voicePlaying); // 尾句完整播完 → 链末等待
|
||
await g(game, 'tap'); // 链末单击继续 → p7 提示句起播
|
||
const vibP = vibrateLog.length;
|
||
await g(game, 'tap'); // p* 播放中单击不可跳(PROMPT_RE 门)
|
||
assert(game.currentVoiceId === 'voice/p7' && game.promptPlaying,
|
||
'G5:首播必完整——p* 首播单击不跳(PROMPT_RE 门)');
|
||
assert(vibrateLog.slice(vibP).some((v) => JSON.stringify(v.pattern) === BEAT_PATTERN),
|
||
'G4:p* 不可跳句空点单击播 beat 轻震(R1 P3-1 证据位)');
|
||
teardown(game);
|
||
}
|
||
|
||
// ---- G3:male 设置下 k1_a/k1_b(糖糖 NPC 平铺组)回退行为——性别目录 404 后
|
||
// 平铺版照常播出(非静默),回退链工作正常 → 确认 + 断言 + 文档注记
|
||
clearSave(); resetSettings('off');
|
||
settings.set({ voice: 'male' });
|
||
storageData.set(SAVE_KEY, JSON.stringify({
|
||
schema: 1, updatedAt: new Date().toISOString(), summary: '第3通·失眠的孩子 · 口碑+2 · 结局0/4',
|
||
phase: 'ONAIR', node: 'k1_in', call: 3, ending: null,
|
||
rep: 2, knot: false, heardArgument: false,
|
||
bond: { zhou: 1, deer: 1, kid: 0, wife: 0, han: 0 }, endings: {}, nights: 3, lastCloser: null,
|
||
}));
|
||
{
|
||
const { audio, playLog, game } = makeRig();
|
||
const errBefore = consoleErrs.length;
|
||
await audio.unlock();
|
||
game.start(); await flush();
|
||
await advance(() => game.phase === 'ASK' && !game.voicePlaying);
|
||
await g(game, 'tap'); // 恢复 → k1_in(链末等待)→ 单击续进 k1_a → k1_b
|
||
await tapUntil(game, () => game.currentVoiceId === 'voice/k1_b', 6);
|
||
await advance(() => !game.voicePlaying);
|
||
assert(urlHits('audio/voice/male/k1_a') >= 1 && urlHits('audio/voice/male/k1_b') >= 1,
|
||
'G3 前置:male 设置先尝试性别目录 male/k1_a、male/k1_b(无资产属设计——NPC 平铺组不做双性别)');
|
||
assert(voicePlays(playLog, 'voice/k1_a') === 1 && voicePlays(playLog, 'voice/k1_b') === 1,
|
||
'G3:male/k1_a、k1_b 404 后回退平铺版播出(非静默,引擎「性别目录→平铺」回退链正常)');
|
||
assert(!consoleErrs.slice(errBefore).some((e) => e.includes('k1_a') || e.includes('k1_b')),
|
||
'G3:回退命中平铺版 → 零「资产缺失」console.error(若真静默则修,实测确认无需修)');
|
||
settings.set({ voice: 'female' });
|
||
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 批次已全部补产落地(2026-09-02,research/new8/batch1-assets-report.md
|
||
// §8:含 R2 F3 phone_ring,共 12 件);保留清单作为守卫——若未来出现新缺失必须仍属该组。
|
||
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('全部断言通过。');
|