/** * 《暗码》无头试玩仿真测试(docs/design/19-cipher.md §9 playtest 断言清单)。 * * 方案: * - mock 浏览器环境:window/localStorage/navigator/fetch/AudioContext * (decodeAudioData 返回固定 2.0s 假 buffer,语音链靠引擎 endTimer + * onComplete 推进,虚拟时钟驱动,不真实等待);ctx.currentTime 跟随虚拟时钟 * (摩尔斯排程/时值断言依赖); * - 使用**真实** engine/audio.js + settings.js + guide.js; * - 手势直接注入 game.onGesture(与 attachGestures 回调同路径); * - 摩尔斯为运行时合成:事件经 game.morseLog(元素序列/频率/时值挂点,§2.1)断言; * - 双循环垫(static_bed/amb_room 为 buffer.loop 句柄,无重触发定时器)全程可复现。 * * 运行:tools/node/bin/node web/cipher/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 SAVE_KEY = 'soundgame.progress.cipher'; // ---------------------------------------------------------------- 断言收集 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}`); } } const consoleErrs = []; const consoleWarns = []; console.error = (...a) => { consoleErrs.push(a.join(' ')); }; console.warn = (...a) => { consoleWarns.push(a.join(' ')); }; console.log = () => {}; // ---------------------------------------------------------------- 虚拟时钟 let now = 0; // 虚拟毫秒 let timerSeq = 1; const timers = new Map(); 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) => { cb(...a); 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; }; globalThis.clearInterval = (id) => { timers.delete(id); }; Object.defineProperty(globalThis, 'performance', { value: { now: () => now }, configurable: true, }); async function flush(rounds = 8) { for (let i = 0; i < rounds; i++) await new Promise((r) => realSetImmediate(r)); } async function advance(cond = null) { for (let n = 0; n < 200000; 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, }; 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 storageData = new Map(); const vibrateLog = []; Object.defineProperty(globalThis, 'navigator', { 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() {} } class MockAudioContext { constructor() { this.state = 'running'; this.destination = {}; this.sampleRate = 44100; } get currentTime() { return now / 1000; } // 摩尔斯排程跟随虚拟时钟(时值断言依赖) 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() {}, playbackRate: new MockParam() }; } createScriptProcessor() { return { connect() {}, disconnect() {}, onaudioprocess: null }; } createOscillator() { return { type: 'sine', frequency: new MockParam(440), connect() {}, start() {}, stop() {} }; } decodeAudioData() { return Promise.resolve({ duration: 2.0, numberOfChannels: 1, sampleRate: 44100, getChannelData: () => new Float32Array(16), }); } resume() { this.state = 'running'; return Promise.resolve(); } } globalThis.window.AudioContext = MockAudioContext; const requestedUrls = []; const blockedAssets = new Set(); globalThis.fetch = async (u, opts = {}) => { const s = String(u); requestedUrls.push(s); if (s.startsWith('/api/')) { if ((opts.method || 'GET') === 'PUT') { return { ok: true, status: 200, json: async () => ({ ok: true, game: 'cipher' }) }; } 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 { CipherGame } = await import('../game.js'); function resetSettings(guide, extra = {}) { settings.set({ guide, voice: 'female', speed: 1.0, bgm: 1.0, input: 'touch', ...extra }); } 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]); const BEAT_PATTERN = JSON.stringify([8]); /** 摩尔斯元素序列断言:rec.elements 的 type 序列 == 期望编码(§2.2 固定字符表)。 */ function assertMorseSeq(rec, expected, msg) { const seq = rec.elements.map((e) => e.type).join(','); assert(seq === expected, `${msg}(实际 ${seq})`); } 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 CipherGame(audio, {}); 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(); } /** * 解码小局驱动:浏览武装后把候选切到 idx(右滑=+1),等预听落定后 * 单击进确认(cf_confirm + 电文重播)→ 再单击定下判定。返回前处于揭晓链。 */ async function decodePick(game, idx) { await advance(() => game.phase === 'DECODE' && game.browseArmed && !game.voicePlaying); const cur = game.candIdx; const delta = (idx - cur + 3) % 3; for (let i = 0; i < delta; i++) await g(game, 'right'); await advance(() => !game.voicePlaying); // 预听词义句播完(摩尔斯走 sfx 不占 voice) await g(game, 'tap'); // 第 1 击:cf_confirm + 电文重播 await advance(() => !game.voicePlaying); await g(game, 'tap'); // 第 2 击:定下判定(800ms 死窗) } /** RELAY 链推进到抉择武装(链末单击继续)。 */ async function tapUntilChoice(game, maxSteps = 30) { for (let i = 0; i < maxSteps; i++) { if (game.choiceArmed) return; if (game.phase === 'DECODE') throw new Error('tapUntilChoice 遇 DECODE(调用方需先解码)'); if (game.voicePlaying) { await advance(() => game.choiceArmed || !game.voicePlaying); continue; } if (game.phase === 'RX' || game.phase === 'NIGHT') { await advance(() => game.choiceArmed || game.phase === 'DECODE'); continue; } await g(game, 'tap'); await advance(() => game.choiceArmed || game.voicePlaying || game.phase !== 'RELAY'); } throw new Error(`tapUntilChoice 超步限(node=${game.nodeId} phase=${game.phase})`); } /** 链末单击穿过 RELAY 等待段,直到 cond()(或抉择武装)满足。 */ async function tapThrough(game, cond, maxSteps = 40) { for (let i = 0; i < maxSteps; i++) { if (cond()) return; if (game.choiceArmed) return; // 抉择点:移交调用方 if (game.phase === 'DECODE' || game.phase === 'MENU' || game.phase === 'ASK') return; if (game.voicePlaying) { await advance(() => cond() || !game.voicePlaying); continue; } await g(game, 'tap'); await advance(() => cond() || game.voicePlaying || game.phase === 'DECODE'); } } /** * 整局驱动:依次过 4 封电文。decodeIdx[k] = 第 k 封所选候选下标; * choices = [p2, p4, p6, p8, p9] 方向。每封:RX 链(自动)→ DECODE → RELAY → NIGHT(自动)。 */ async function playNight(game, decodeIdx, choices) { let guard = 0; for (let tel = 1; tel <= 4; tel++) { await advance(() => game.phase === 'DECODE' && game.browseArmed); await decodePick(game, decodeIdx[tel - 1]); // T1 教学:译错会回浏览,直到译对进 p2 for (let i = 0; i < 10 && game.phase === 'DECODE'; i++) await decodePick(game, 0); // RELAY:揭晓链 → p* → 抉择 → 后果链 while (game.phase === 'RELAY') { if (++guard > 200) throw new Error('playNight RELAY 卡住'); if (game.choiceArmed) { await g(game, choices.shift()); continue; } if (game.voicePlaying) { await advance(() => !game.voicePlaying || game.choiceArmed); continue; } if (game.nodeId === 'g9_in' || game.nodeId.startsWith('d9')) { await tapThrough(game, () => game.phase === 'ENDING'); break; } await g(game, 'tap'); await advance(() => !game.voicePlaying || game.choiceArmed || game.phase !== 'RELAY'); } if (game.phase === 'ENDING') break; await advance(() => game.phase === 'DECODE' || game.phase === 'ENDING'); } await advance(() => game.phase === 'ENDING' && game.awaitingRestart); } // ================================================================ 主路径 · 剧本 A // (§9 关键路径:全译对 + p2左 p4右 p6右 p8右 p9左 → e1;摩尔斯固定字符逐元素断言) realLog('== 主路径 · 剧本 A(E1,guide=off)=='); { clearSave(); resetSettings('off'); const g0 = gestureCount; const { audio, playLog, game } = makeRig(); const guideEvents = []; game._earconHook = (ev) => guideEvents.push(ev); 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; game.start(); await flush(); assert(playLog.some((e) => e.id === 'bgm/radio_room'), '开局播放 bgm/radio_room(值班室垫乐)'); assert(voicePlays(playLog, 'voice/sys_new_night') === 1, '新夜班 sys_new_night 先行'); assert(game.phase === 'INTRO', '进入宏状态 INTRO'); // 1. INTRO 链完整(自动流)→ RX(1):b_open→b_rule→b_goal→b_chief await advance(() => game.currentVoiceId === 'voice/b_open'); assert(game.nodeId === 'b_open' && game.voicePlaying, 'sys_new_night 播完接 b_open(voice 层有播放记录)'); assert(voicePlays(playLog, 'voice/gd_brief') === 0, 'guide=off:开场不插 gd_brief'); await advance(() => game.phase === 'RX'); assert(voicePlays(playLog, 'voice/sys_skip') === 1, '开场 sys_skip 提示播一次(每局一次)'); for (const id of ['voice/b_open', 'voice/b_rule', 'voice/b_goal', 'voice/b_chief']) { assert(voicePlays(playLog, id) === 1, `INTRO 链播出 ${id}`); } assert(voicePlays(playLog, 'sfx/tea_cup') === 1, 'b_chief 配 sfx/tea_cup(处长场标记音)'); assert(game.tel === 1 && game.nodeId === 'rx1_in', 'INTRO 链完自动进 RX(1) rx1_in'); assert(urlHits('sfx/amb_room') >= 1, 'b_open 起值班室 amb_room 循环垫(游戏层持句柄)'); assert(urlHits('sfx/static_bed') >= 1, 'RX 进入起静电底噪 static_bed 循环垫'); const sdA = saveData(); assert(sdA && sdA.phase === 'RX' && sdA.tel === 1, 'RX 进入即存档(写入时机),phase=RX / tel=1'); assert(sdA.summary === '第1封·船期 · 暴露:平安 · 结局0/4', `summary 人话格式(实际: ${sdA.summary})`); // 2. RX(1):调谐 → 摩尔斯首播 BAY(固定字符逐元素 + 时值 + 750Hz + BGM −6dB) await advance(() => game.morseLog.length === 1); const bc1 = game.morseLog[0]; assert(bc1.kind === 'broadcast' && bc1.word === 'BAY', '电文首播对象为 BAY(船期)'); assert(bc1.hz === 750 && bc1.unit === 80 && bc1.letterGapUnits === 3, '首播原速档 750Hz / u=80ms / 字母隙 3u'); assertMorseSeq(bc1, 'dah,dit,dit,dit,dit,dah,dah,dit,dah,dah', 'BAY 编码逐元素 == §2.2 表(−··· / ·− / −·−−)'); { // 时值:B 字母内 dah(3u=240ms) → dit 起始间隔 = 3u + 1u(字内隙) = 320ms const d1 = (bc1.elements[1].at - bc1.elements[0].at) * 1000; assert(Math.abs(d1 - 320) <= 5, `时值断言:dit 跟在 dah 后 320ms(实际 ${d1.toFixed(1)}ms,容差 ±5)`); // 字母隙:B 末元素(idx3 dit,80ms)结束 → A 首元素(idx4)起始 = 3u = 240ms const gap = (bc1.elements[4].at - (bc1.elements[3].at + 0.08)) * 1000; assert(Math.abs(gap - 240) <= 5, `字母隙 = 3u = 240ms(实际 ${gap.toFixed(1)}ms)`); assert(game._bgmDipMorse === true && game._bgmDipNode && Math.abs(game._bgmDipNode.gain.value - 0.5) < 1e-6, 'R3:摩尔斯首播期间 BGM −6dB(游戏层 dip 节点 0.5,引擎 duck 不干扰)'); const tB = now; assert(bc1.atMs <= tB && tB < bc1.endMs, '首播排程覆盖当前虚拟时刻(不可跳由构造保证)'); } // 首播保护窗:玩法手势一律 beat 轻震,不判无效、不惩罚 await g(game, 'tap'); await g(game, 'left'); assert(vibrateLog.filter((v) => v.t >= bc1.atMs && JSON.stringify(v.pattern) === BEAT_PATTERN).length >= 2, '首播中 tap/left = beat 轻震(G4 首播保护窗)'); assert(game.phase === 'RX' && game._rxGuard, '首播中手势不推进状态(RX 保护中)'); assert(voicePlays(playLog, 'voice/gd_invalid') === 0, '首播中手势不判无效(零惩罚)'); // 首播完 → turn cue + 教学链 → DECODE 浏览 await advance(() => game.phase === 'DECODE' && game.browseArmed); assert(game._rxGuard === false, '首播完保护窗解除'); for (const id of ['voice/rx1_in', 'voice/dv1_teach', 'voice/dv1_teach2', 'voice/dv1_ask']) { assert(voicePlays(playLog, id) === 1, `T1 链播出 ${id}`); } assert(voicePlays(playLog, 'sfx/tune_seek') === 1, 'rx1_in 配 sfx/tune_seek(调谐找频)'); // 3. DECODE 浏览:自动预听候选 1(620Hz 对照档 → 词义句) const pv0 = game.morseLog[game.morseLog.length - 1]; assert(pv0.kind === 'preview' && pv0.word === 'BAY', '进浏览自动预听当前候选(候选 1 = BAY)'); assert(pv0.hz === 620 && pv0.unit === 110 && pv0.letterGapUnits === 5, '候选预听 = 620Hz 对照档 u=110 字母隙 5u(频率差防混淆)'); await advance(() => voicePlays(playLog, 'voice/cand_bay') === 1); const candEntry = playLog.find((e) => e.id === 'voice/cand_bay'); assert(candEntry.t >= pv0.endMs + 500 - 5, 'R1:候选词义句在候选摩尔斯结束后 ≥500ms 接播(静默一拍对照时间)'); assert(!game.voicePlaying || true, '(占位)'); // 左右滑循环切候选:BAY→DAY→RAY→BAY(每切自动预听) await advance(() => !game.voicePlaying); await g(game, 'right'); await g(game, 'right'); await g(game, 'right'); const pvWords = game.morseLog.filter((r) => r.kind === 'preview').map((r) => r.word); assert(pvWords.join(',') === 'BAY,DAY,RAY,BAY', `右滑 ×3 循环切换候选(实际 ${pvWords.join(',')})`); // 双击重听原速 → 再双击慢放 0.5×(两级内容不同:速度即差异) await advance(() => !game.voicePlaying); const nTx0 = game.morseLog.length; await g(game, 'doubletap'); const tx1 = game.morseLog[game.morseLog.length - 1]; assert(tx1.kind === 'tx' && tx1.word === 'BAY' && tx1.unit === 80 && tx1.hz === 750, '双击 = 重听电文原速(750Hz / u=80)'); await g(game, 'doubletap'); const tx2 = game.morseLog[game.morseLog.length - 1]; assert(tx2.kind === 'tx' && tx2.unit === 160 && tx2.letterGapUnits === 6, '再双击 = 慢放 0.5×(u=160,字母隙加宽至 6u,无限免费兜底第 2 层)'); { // 慢放字母隙 = 6u = 960ms const gap = (tx2.elements[4].at - (tx2.elements[3].at + 0.16)) * 1000; assert(Math.abs(gap - 960) <= 5, `慢放字母隙 = 6u = 960ms(实际 ${gap.toFixed(1)}ms)`); assert(nTx0 >= 0, '(占位)'); } // 4. 两段确认:单击 → cf_confirm + 电文重播 → 再单击 → 判定出口 800ms 死窗 await advance(() => !game.voicePlaying); await g(game, 'tap'); assert(game.confirming === true && voicePlays(playLog, 'voice/cf_confirm') === 1, '第 1 击进确认:cf_confirm 播出(随后电文重播原速一遍)'); await advance(() => game.morseLog.some((r) => r.kind === 'tx' && r.unit === 80) && !game.voicePlaying); // 确认态左右滑 = 取消回浏览 await g(game, 'left'); assert(game.confirming === false && game.phase === 'DECODE', '确认态左滑 = 取消回浏览'); await advance(() => !game.voicePlaying); await g(game, 'tap'); // 再进确认 await advance(() => game.confirming && !game.voicePlaying); const graceBefore = game._settleGraceUntil; await g(game, 'tap'); // 第 2 击 = 定下 assert(game._settleGraceUntil > graceBefore, '判定出口盖 800ms 手势死窗'); assert(performance.now() < game._settleGraceUntil, '死窗生效中'); const nodeBefore = game.nodeId; await g(game, 'tap'); // 死窗内在途 tap 被吞 assert(game.nodeId === nodeBefore, '死窗内注入在途 tap 被吞(揭晓链完整播出)'); await advance(() => !game.voicePlaying); assert(game.phase === 'RELAY' && game.nodeId === 'dv1_right', 'T1 译对 → dv1_right(success 震)'); assert(vibrateLog.some((v) => JSON.stringify(v.pattern) === '[100]'), 'T1 译对 success 震'); assert(game.expose === 0, 'T1 教学期译错/译对均零暴露(A5,§5.2)'); assert(game.t.t1 === 'bay', 'T1 恒收敛真词 t1=bay'); // 5. p2 左(报北风)→ r1a_send + key_send → r1a_e → NIGHT(1)(calm 零跨档播报)→ RX(2) await tapUntilChoice(game, 6); assert(game.nodeId === 'p2', 'dv1_right 链末单击 → p2 提示句播完武装'); await g(game, 'left'); assert(game.picks.p2 === 'left' && game.expose === 0, 'p2 左 = 报北风(expose +0)'); await advance(() => !game.voicePlaying); assert(voicePlays(playLog, 'voice/r1a_send') === 1 && voicePlays(playLog, 'sfx/key_send') === 1, 'p2 左 → r1a_send 且配 sfx/key_send(承诺绑定:提到发报必闻电键声)'); const sd1 = saveData(); assert(sd1.picks.p2 === 'left' && sd1.t.t1 === 'bay', '抉择结算后存档(picks/t 还原)'); await g(game, 'tap'); // r1a_e 链末 → NIGHT await advance(() => game.phase === 'NIGHT'); assert(voicePlays(playLog, 'voice/n1') === 1, 'NIGHT(1) 过渡句 n1 播出'); assert(voicePlays(playLog, 'voice/exp_calm') === 0, 'expose=0 开局档 → 零跨档播报(同档不播)'); await advance(() => game.phase === 'DECODE' && game.browseArmed); // 6. T2:TOP 译对 → rev_top → p4_known(t2==top)→ 右呈报 → m2_chief → NIGHT(2) await advance(() => game.morseLog.length >= 6); const bc2 = game.morseLog.find((r) => r.kind === 'broadcast' && r.word === 'TOP'); assert(bc2 && bc2.hz === 750 && bc2.unit === 80, 'T2 电文首播 TOP 原速档'); assertMorseSeq(bc2, 'dah,dah,dah,dah,dit,dah,dah,dit', 'TOP 编码逐元素 == §2.2 表(− / −−− / ·−−·)'); await decodePick(game, 0); await advance(() => game.phase === 'RELAY'); assert(game.t.t2 === 'top' && game.expose === 0, 'T2 译对 t2=top(expose 不加分)'); assert(voicePlays(playLog, 'voice/rev_top') === 1, '揭晓 rev_top(你的认知:黑名单,码头)'); await tapUntilChoice(game); assert(game.nodeId === 'p4_known', 't2==top → 播 p4_known(名单上有码头)'); await g(game, 'right'); // 呈报 assert(game.picks.p4 === 'right' && game.expose === 0, 'p4 右 = 呈报(mateListed,expose +0)'); await tapThrough(game, () => game.phase === 'NIGHT'); assert(voicePlays(playLog, 'sfx/pen_scratch') === 2, 'm2_chief 配 pen_scratch + R2 F1:p4 右(呈报)NIGHT(2) 链首垫笔声(共 2 记)'); await advance(() => game.phase === 'DECODE' && game.browseArmed); // 7. T3:HAND 译对 → rev_hand → p6 右 → r3b_e_ok(t3==hand) const bc3 = game.morseLog.find((r) => r.kind === 'broadcast' && r.word === 'HAND'); assertMorseSeq(bc3, 'dit,dit,dit,dit,dit,dah,dah,dit,dah,dit,dit', 'HAND 编码逐元素 == §2.2 表(···· / ·− / −· / −··)'); await decodePick(game, 0); await advance(() => game.phase === 'RELAY'); assert(game.t.t3 === 'hand', 'T3 译对 t3=hand'); await tapUntilChoice(game); assert(game.nodeId === 'p6', 'p6 固定提示(known/blind 共用)'); await g(game, 'right'); // 留书暗号 await tapThrough(game, () => game.phase === 'NIGHT'); assert(voicePlays(playLog, 'voice/r3b_e_ok') === 1 && voicePlays(playLog, 'voice/r3b_e_no') === 0, 'p6 右 + t3==hand → r3b_e_ok(交接赶上了)'); await advance(() => game.phase === 'DECODE' && game.browseArmed); // 8. T4:SPY 译对 → danger 拍(step_halt 前置 2s + 签名 + 震)→ p8_known → 右装傻 → 签收 const bc4 = game.morseLog.find((r) => r.kind === 'broadcast' && r.word === 'SPY'); assertMorseSeq(bc4, 'dit,dit,dit,dit,dah,dah,dit,dah,dit,dah,dah', 'SPY 编码逐元素 == §2.2 表(··· / ·−−· / −·−−)'); await decodePick(game, 0); await advance(() => game.phase === 'RELAY'); assert(game.t.t4 === 'spy', 'T4 译对 t4=spy'); assert(voicePlays(playLog, 'sfx/step_halt') === 1, 'T4 揭晓 danger 拍:step_halt(前置 2s 渐强)'); assert(guideEvents.some((e) => e.type === 'state' && e.name === 'danger') || vibrateLog.some((v) => JSON.stringify(v.pattern) === '[25,40,25,40,25]'), 'danger 拍:danger 签名 + danger 震同窗'); assert(playLog.some((e) => e.id === 'bgm/closing'), 'RELAY(4) 起 crossfade 至 bgm/closing'); await tapUntilChoice(game); assert(game.nodeId === 'p8_known', 't4==spy → p8_known(三点之前,反喂/装傻)'); await g(game, 'right'); // 装傻 assert(game.picks.p8 === 'right' && game.expose === 0, 'p8 右 = 装傻(expose +0)'); await tapThrough(game, () => game.choiceArmed); assert(voicePlays(playLog, 'voice/m4_chief_c') === 1, 'p8 右 → m4_chief_c(处长收场)'); // 签收:g9_in → g9_knot → p9_known → 左发最后一电 assert(game.choiceArmed && game.nodeId === 'p9_known', 't4==spy → p9_known(收网之前)'); assert(voicePlays(playLog, 'voice/g9_knot') === 1, '心结自述 g9_knot 播出'); await g(game, 'left'); // 发最后一电 assert(game.endingPre === 0 && game.expose === 2, 'p9 左:结局判定用前值 0(≤4)→ 结算后 expose +2'); await tapThrough(game, () => game.phase === 'ENDING'); assert(voicePlays(playLog, 'voice/d9_send') === 1 && voicePlays(playLog, 'sfx/key_send') >= 2, 'd9_send 配 key_send(电键声承诺绑定)'); assert(voicePlays(playLog, 'voice/d9_send_e') === 1 && voicePlays(playLog, 'voice/d9_caught_e') === 0, 'pre=0 ≤4 → d9_send_e(低暴露收,非 caught)'); // 9. ENDING e1:e1_a + dawn_bell → e1_b → ep 逐封点名(全 ok 线) await advance(() => game.phase === 'ENDING' && game.awaitingRestart); assert(game.endingId === 'e1', '剧本 A → 结局 e1 最后一电'); assert(voicePlays(playLog, 'voice/e1_a') === 1 && voicePlays(playLog, 'sfx/dawn_bell') === 1, 'e1_a 配 sfx/dawn_bell(黎明远钟)'); assert(voicePlays(playLog, 'voice/ep_med_ok') === 1 && voicePlays(playLog, 'voice/ep_med_no') === 0, 'ep 点名:药品 p2=左 → ep_med_ok'); assert(voicePlays(playLog, 'voice/ep_list_no') === 1 && voicePlays(playLog, 'voice/ep_list_ok') === 0, 'ep 点名:名单 p4=右 → ep_list_no(呈报的真实代价)'); assert(voicePlays(playLog, 'voice/ep_hand_ok') === 1 && voicePlays(playLog, 'voice/ep_hand_no') === 0, 'ep 点名:交接 t3=hand → ep_hand_ok'); assert(voicePlays(playLog, 'voice/ep_spy_ok') === 1 && voicePlays(playLog, 'voice/ep_spy_no') === 0, 'ep 点名:处置令 t4=spy → ep_spy_ok'); assert(voicePlays(playLog, 'voice/end_c1') === 1 && voicePlays(playLog, 'voice/end_c2') === 0, 'R2 F3:首个结局 → 结算链尾播 end_c1(分档整句计数)'); const sdEnd = saveData(); assert(sdEnd.summary === '已收台·最后一电 · 结局1/4', `收台 summary(实际: ${sdEnd.summary})`); assert(sdEnd.endings.e1 === true && sdEnd.runs === 1, '结局收集入档,runs=1'); assert(gestureCount - g0 <= 140, `剧本 A 手势数 ≤140(实际 ${gestureCount - g0},§9 ≤60 次核心手势放宽含教学重选)`); await g(game, 'tap'); assert(game.phase === 'INTRO' && game.runs === 2, '结算单击 = 新夜班(runs+1)'); teardown(game); } // ================================================================ 剧本 B(E2) realLog('== 剧本 B(同 A,p9 右 → E2)=='); { clearSave(); resetSettings('off'); const { audio, playLog, game } = makeRig(); await audio.unlock(); game.start(); await flush(); await playNight(game, [0, 0, 0, 0], ['left', 'right', 'right', 'right', 'right']); assert(game.endingId === 'e2', '剧本 B → 结局 e2 值完这个班'); assert(voicePlays(playLog, 'voice/d9_sleep') === 1 && voicePlays(playLog, 'voice/d9_sleepe') === 1, 'p9 右 → d9_sleep → d9_sleepe(关机装睡)'); assert(voicePlays(playLog, 'voice/e2_a') === 1 && voicePlays(playLog, 'sfx/dawn_bell') === 1, 'e2_a 配 dawn_bell(黎明远钟)'); assert(voicePlays(playLog, 'sfx/lock_click') === 0, 'e2 链无 lock_click(非 e3/e4)'); teardown(game); } // ================================================================ 剧本 C/D(全错全左 → E3/E4,暴露度逐检查点) realLog('== 剧本 C(全译错 + p2左 p4左 p6左 p8左 + p9左 → E3,暴露度逐 NIGHT 验算)=='); { clearSave(); resetSettings('off'); const { audio, playLog, game } = makeRig(); const guideEvents = []; game._earconHook = (ev) => guideEvents.push(ev); await audio.unlock(); game.start(); await flush(); // T1(教学恒收敛 BAY)→ p2 左(不加分) await advance(() => game.phase === 'DECODE' && game.browseArmed); await decodePick(game, 0); await tapUntilChoice(game); await g(game, 'left'); await tapThrough(game, () => game.phase === 'NIGHT'); assert(voicePlays(playLog, 'sfx/paper_page') === 0, 'R2 F1:p2 左(报北风)无纸面痕迹 → NIGHT(1) 零 paper_page'); assert(game.expose === 0, 'NIGHT(1) 后 expose=0(calm,p2 左不加分)'); assert(voicePlays(playLog, 'voice/exp_calm') === 0, '开局档 → 无跨档播报'); await advance(() => game.phase === 'DECODE' && game.browseArmed); // T2 译错(tap)+ p4 左 await decodePick(game, 1); // TAP(误读) await advance(() => game.phase === 'RELAY'); assert(game.t.t2 === 'tap' && game.expose === 1, 'T2 译错 t2=tap,expose +1(不揭穿直入剧情)'); assert(voicePlays(playLog, 'voice/rev_tap') === 1 && voicePlays(playLog, 'voice/rev_top') === 0, '误读分支播 rev_tap(你的认知),不播 rev_top'); await tapUntilChoice(game); assert(game.nodeId === 'p4_blind', 't2≠top → p4_blind'); await g(game, 'left'); await tapThrough(game, () => game.phase === 'NIGHT'); assert(voicePlays(playLog, 'sfx/paper_page') === 1, 'R2 F1:p4 左(暗处理)→ NIGHT(2) 链首垫 paper_page(进入即垫)'); assert(game.expose === 2, 'NIGHT(2) 后 expose=2(low:译错1 + p4左1)'); await advance(() => game.phase === 'DECODE' && game.browseArmed); assert(voicePlays(playLog, 'sfx/paper_page') === 1, 'R2 F1:p4 左(暗处理)→ NIGHT(2) 链首垫 paper_page'); assert(voicePlays(playLog, 'voice/exp_low') === 1, '跨档 calm→low → 播 exp_low(处长多看你一眼)'); // T3 译错(sand)+ p6 左 await decodePick(game, 1); await advance(() => game.phase === 'RELAY'); assert(game.t.t3 === 'sand' && game.expose === 3, 'T3 译错 expose +1 → 3'); await tapUntilChoice(game); await g(game, 'left'); await tapThrough(game, () => game.phase === 'NIGHT'); assert(game.expose === 5, 'NIGHT(3) 后 expose=5(译错1 + p6左2 → 3+1+2=5,hot 档=收网门)'); await advance(() => game.phase === 'DECODE' && game.browseArmed); assert(voicePlays(playLog, 'voice/exp_hot') === 1, '跨档 low→hot → 播 exp_hot(门外换了岗)'); assert(vibrateLog.some((v) => JSON.stringify(v.pattern) === '[30,50,30,50,30]'), 'exp_hot 播报位附 warning 震(G2)'); // T4 译错(say)+ p8 左(反喂)→ 签收前 8(hot 同档不重播) await decodePick(game, 1); await advance(() => game.phase === 'RELAY'); assert(game.t.t4 === 'say' && game.expose === 6, 'T4 译错 expose +1 → 6'); assert(voicePlays(playLog, 'sfx/step_halt') === 0, 'rev_say 误读无 danger 拍(仅 rev_spy 一次)'); await tapUntilChoice(game); assert(game.nodeId === 'p8_blind', 't4≠spy → p8_blind'); await g(game, 'left'); await tapThrough(game, () => game.choiceArmed); assert(game.expose === 8 && game.picks.p8 === 'left', 'p8 左反喂 expose +2 → 8(§5.2 单源表)'); assert(voicePlays(playLog, 'sfx/key_send') >= 1, 'r4a_feed 配 key_send(借快手七的手法)'); assert(game.nodeId === 'p9_blind', 't4≠spy → p9_blind(北风在等答复)'); const expHotCount = voicePlays(playLog, 'voice/exp_hot'); await g(game, 'left'); assert(game.endingPre === 8, '结局判定用 p9 前值 8(≥5)'); await tapThrough(game, () => game.phase === 'ENDING' && game.awaitingRestart); assert(game.endingId === 'e3', '剧本 C → 结局 e3 孤灯'); assert(voicePlays(playLog, 'voice/d9_caught_e') === 1 && voicePlays(playLog, 'voice/d9_send_e') === 0, 'pre=8 ≥5 → d9_caught_e(信号被咬住,danger 签名)'); assert(voicePlays(playLog, 'voice/exp_hot') === expHotCount, '签收前 8 与 NIGHT(3) 后 5 同档 → 零重复播报(跨档才播)'); assert(voicePlays(playLog, 'sfx/lock_click') === 1 && voicePlays(playLog, 'voice/e3_a') === 1, 'e3_a 配 sfx/lock_click(门锁/枪机)'); assert(voicePlays(playLog, 'voice/ep_spy_no') === 1 && voicePlays(playLog, 'voice/ep_hand_no') === 1, 'ep 点名:处置令差三个字母 / 交接白亮一夜(归因真话,flags 单源)'); assert(voicePlays(playLog, 'voice/ep_list_ok') === 1 && voicePlays(playLog, 'voice/ep_med_ok') === 1, 'ep 点名:名单 p4=左 → ep_list_ok;药品 p2=左 → ep_med_ok'); teardown(game); } realLog('== 剧本 D(同 C,p9 右 → E4)=='); { clearSave(); resetSettings('off'); const { audio, playLog, game } = makeRig(); await audio.unlock(); game.start(); await flush(); await playNight(game, [1, 1, 1, 1], ['left', 'left', 'left', 'left', 'right']); assert(game.endingId === 'e4', '剧本 D → 结局 e4 长夜'); assert(game.expose === 8 && game.endingPre === 8, 'p9 右:endingPre=8(结局门 ≥5)'); assert(voicePlays(playLog, 'voice/e4_a') === 1 && voicePlays(playLog, 'sfx/lock_click') === 1, 'e4_a 配 lock_click(脚步停在门口)'); teardown(game); } // ================================================================ 剧本 E(提示流:纯提示可全对通关) realLog('== 剧本 E(提示流:T1 免费页 + 3 页逐值 → 全对 → e1/e2)=='); { clearSave(); resetSettings('off'); const { audio, playLog, game } = makeRig(); await audio.unlock(); game.start(); await flush(); // T1:长按翻密码本(首次免费,hints 不动) await advance(() => game.phase === 'DECODE' && game.browseArmed && !game.voicePlaying); await g(game, 'longpress'); await advance(() => !game.voicePlaying); assert(voicePlays(playLog, 'voice/hint_t1') === 1 && voicePlays(playLog, 'sfx/paper_page') === 1, 'T1 首次翻页免费:hint_t1 + paper_page(Prime→Teach 兑现)'); assert(game.hints === 3, 'T1 免费页不扣(hints 仍 3)'); assert(game.expose === 0, '翻密码本零惩罚'); await decodePick(game, 0); await tapUntilChoice(game); await g(game, 'left'); // p2 左 await tapThrough(game, () => game.phase === 'DECODE' || game.choiceArmed); // T2/T3/T4:各翻 1 页(3→2→1→0 逐值账目) const expectHints = [2, 1, 0]; for (let k = 0; k < 3; k++) { if (game.phase !== 'DECODE') break; await advance(() => !game.voicePlaying); await g(game, 'longpress'); await advance(() => !game.voicePlaying); assert(game.hints === expectHints[k], `T${k + 2} 翻页后 hints=${expectHints[k]}(逐值账目)`); assert(voicePlays(playLog, `voice/hint_t${k + 2}`) === 1, `hint_t${k + 2} 播出(揭示辨析位字母)`); if (expectHints[k] === 1) { // R2 F2:密码本只剩最后一页 → 状态链 book_1 到句附 warning 震 const warnBefore = vibrateLog.filter((v) => JSON.stringify(v.pattern) === '[30,50,30,50,30]').length; await g(game, 'up'); await advance(() => !game.voicePlaying); assert(vibrateLog.filter((v) => JSON.stringify(v.pattern) === '[30,50,30,50,30]').length === warnBefore + 1, 'R2 F2:book_1 播报位附 warning 震(最后一页预警)'); } await decodePick(game, 0); // 按提示选定真词(提示 ⟹ 候选唯一,§2.2 验算) await tapUntilChoice(game); await g(game, ['right', 'right', 'right'][k]); // p4右 p6右 p8右 await tapThrough(game, () => game.phase === 'DECODE' || game.choiceArmed); } assert(game.choiceArmed && game.nodeId === 'p9_known', '全对 → p9_known'); assert(game.expose === 0, '提示流全对 → expose=0(calm)'); await g(game, 'right'); // p9 右 → e2 await tapThrough(game, () => game.phase === 'ENDING' && game.awaitingRestart); assert(game.endingId === 'e2', '剧本 E → e2(纯提示流可通关 ✓)'); assert(voicePlays(playLog, 'voice/book_3') === 0, '翻页直出 hint 句不播 book 档(账目在状态链)'); // 页数用尽:再开一局 T1 免费页用掉后翻 3 页 → 第 4 次翻 = error 震 + 翻空本声零惩罚 teardown(game); clearSave(); const rig2 = makeRig(); await rig2.audio.unlock(); rig2.game.start(); await flush(); await advance(() => rig2.game.phase === 'DECODE' && rig2.game.browseArmed && !rig2.game.voicePlaying); for (let i = 0; i < 5; i++) { await advance(() => !rig2.game.voicePlaying); const errBefore = vibrateLog.filter((v) => JSON.stringify(v.pattern) === ERR_PATTERN).length; const ppBefore = voicePlays(rig2.playLog, 'sfx/paper_page'); await g(rig2.game, 'longpress'); await advance(() => !rig2.game.voicePlaying); if (i === 0) { assert(rig2.game.hints === 3, 'T1 首次翻页免费(hints 仍 3)'); } else if (i < 4) { assert(rig2.game.hints === 3 - i, `第 ${i} 次付费翻页扣页(3→${3 - i},逐值账目)`); } else { assert(vibrateLog.filter((v) => JSON.stringify(v.pattern) === ERR_PATTERN).length === errBefore + 1, '页尽再翻 = error 震(零静默)'); assert(voicePlays(rig2.playLog, 'sfx/paper_page') === ppBefore + 1, '页尽再翻 = 翻空本声'); assert(rig2.game.hints === 0, '页数账目 0(零惩罚,游戏继续)'); } } teardown(rig2.game); } // ================================================================ 剧本 F(慢放流:纯慢放可全对通关) realLog('== 剧本 F(慢放流:每封双击×2 慢放后译对,零提示)=='); { clearSave(); resetSettings('off'); const { audio, playLog, game } = makeRig(); await audio.unlock(); game.start(); await flush(); for (let tel = 1; tel <= 4; tel++) { await advance(() => game.phase === 'DECODE' && game.browseArmed && !game.voicePlaying); await advance(() => !game.voicePlaying); await g(game, 'doubletap'); await g(game, 'doubletap'); const slow = game.morseLog[game.morseLog.length - 1]; assert(slow.kind === 'tx' && slow.unit === 160, `T${tel} 双击×2 = 慢放 u=160(兜底第 2 层无限免费)`); await decodePick(game, 0); if (tel < 4) { await tapUntilChoice(game); await g(game, ['left', 'right', 'right'][tel - 1]); await tapThrough(game, () => game.phase === 'DECODE'); await advance(() => game.browseArmed); } } await tapUntilChoice(game, 10); assert(game.nodeId === 'p8_known', 'T4 全对 → p8_known'); await g(game, 'right'); // p8 右装傻 await tapThrough(game, () => game.choiceArmed); assert(game.nodeId === 'p9_known', 'p9 武装(慢放流全对)'); await g(game, 'left'); // p9 左 await tapThrough(game, () => game.phase === 'ENDING' && game.awaitingRestart); assert(game.endingId === 'e1', '剧本 F → e1(纯慢放可通关 ✓)'); assert(game.hints === 3 && game.endingPre === 0 && game.expose === 2, '慢放流零提示页、全对 expose=0'); teardown(game); } // ================================================================ T1 译错归因真话 + 无限重选 realLog('== T1 教学:译错即时归因真话 + 无限重选零惩罚 =='); { clearSave(); resetSettings('off'); const { audio, playLog, game } = makeRig(); await audio.unlock(); game.start(); await flush(); await advance(() => game.phase === 'DECODE' && game.browseArmed && !game.voicePlaying); // 先错选 RAY(idx 2):dv1_wrong 播完回浏览 await decodePick(game, 2); await advance(() => !game.voicePlaying); assert(voicePlays(playLog, 'voice/dv1_wrong') === 1, 'T1 译错 → dv1_wrong 即时归因'); assert(game.phase === 'DECODE' && game.browseArmed, '归因后回浏览(无限重选)'); assert(game.expose === 0 && game.t.t1 === 'bay', '教学期译错零惩罚(expose 不动;t1 恒收敛真词 bay,§5.1)'); // 再错选 DAY(idx 1) await decodePick(game, 1); await advance(() => !game.voicePlaying); assert(voicePlays(playLog, 'voice/dv1_wrong') === 2, '二次译错再次归因(不限次)'); assert(game.phase === 'DECODE', '仍回浏览'); // 归因句内字母与真词一致(真话律,§2.5):dv1_wrong 文本含「B,一长,三短」 const wrongText = '不对。听清楚:B,一长,三短。'; assert(wrongText.includes('B') && wrongText.includes('一长,三短'), 'dv1_wrong 字母/码与真词 B(−···) 一致(写死句内,与电码表同源)'); // 译对 → dv1_right await decodePick(game, 0); await advance(() => game.phase === 'RELAY'); assert(voicePlays(playLog, 'voice/dv1_right') === 1 && game.t.t1 === 'bay', '无限重选后译对 → dv1_right'); teardown(game); } // ================================================================ 正片判定出口零对错语音(教学承诺) realLog('== 教学承诺:正片判定出口零对错反馈语音 =='); { clearSave(); resetSettings('off'); const { audio, playLog, game } = makeRig(); await audio.unlock(); game.start(); await flush(); // T1 译对(有 dv1_right 反馈) await advance(() => game.phase === 'DECODE' && game.browseArmed); await decodePick(game, 0); await tapUntilChoice(game); await g(game, 'left'); // T2 译错:判定出口与揭晓链不得出现任何对错反馈句 await tapThrough(game, () => game.phase === 'DECODE'); await advance(() => game.browseArmed); await decodePick(game, 2); // tip await advance(() => game.phase === 'RELAY' && !game.voicePlaying); assert(voicePlays(playLog, 'voice/dv1_wrong') === 0, '正片判定出口零 dv1_wrong(不揭穿)'); assert(voicePlays(playLog, 'voice/dv1_right') === 1, 'dv1_right 仅 T1 教学期一次'); assert(voicePlays(playLog, 'voice/rev_tip') === 1, '误读只播 rev_*(你的认知)'); teardown(game); } // ================================================================ morseGate:摩尔斯与 voice 零重叠 realLog('== morseGate(R1-R5):全剧本转写断言零重叠 =='); { clearSave(); resetSettings('off'); const { audio, playLog, game } = makeRig(); await audio.unlock(); game.start(); await flush(); await playNight(game, [0, 0, 0, 0], ['left', 'right', 'right', 'right', 'left']); let overlap = 0; for (const rec of game.morseLog) { for (const e of playLog) { if (!e.id.startsWith('voice/')) continue; if (e.t >= rec.atMs && e.t < rec.endMs) overlap++; } } assert(overlap === 0 || (realLog('DBG overlaps:', JSON.stringify((()=>{const o=[];for(const rec of game.morseLog){for(const e of playLog){if(e.id.startsWith('voice/')&&e.t>=rec.atMs&&e.t r.kind === 'preview')) { const candVoice = { BAY: 'voice/cand_bay', DAY: 'voice/cand_day', RAY: 'voice/cand_ray', TOP: 'voice/cand_top', TAP: 'voice/cand_tap', TIP: 'voice/cand_tip', HAND: 'voice/cand_hand', SAND: 'voice/cand_sand', LAND: 'voice/cand_land', SPY: 'voice/cand_spy', SAY: 'voice/cand_say', SLY: 'voice/cand_sly' }[rec.word]; const entry = playLog.find((e) => e.id === candVoice && e.t >= rec.atMs); if (entry && entry.t < rec.endMs + 495) badGap++; } assert(badGap === 0, `候选词义句全部在摩尔斯结束后 ≥500ms 接播(违例 ${badGap})`); teardown(game); } // ================================================================ 看门狗双轨(§7.2/§9) realLog('== 看门狗双轨(guide=full:DECODE 无操作轨 + 无进展轨 + p* 复述两级)=='); { clearSave(); resetSettings('full'); const { audio, playLog, game } = makeRig(); const guideEvents = []; game._earconHook = (ev) => guideEvents.push(ev); await audio.unlock(); game.start(); await flush(); await advance(() => game.currentVoiceId === 'voice/b_open'); // sys_new_night 播完接开场链 assert(voicePlays(playLog, 'voice/gd_brief') === 1, 'full 档开场链最前插 gd_brief'); assert(guideEvents.some((e) => e.type === 'state' && e.name === 'explore'), 'INTRO 进入 explore 签名'); // DECODE 无操作轨:10s → gd_tune1;25s → gd_tune2(两级不同句,同等待点) await advance(() => game.phase === 'DECODE' && game.browseArmed && !game.voicePlaying); const tD = now; await advance(() => voicePlays(playLog, 'voice/gd_tune1') === 1); assert(now - tD === 10000, 'DECODE 浏览静默 10s → gd_tune1'); await advance(() => voicePlays(playLog, 'voice/gd_tune2') === 1); assert(now - tD === 25000, '25s → gd_tune2(与第 1 级不同句)'); const t1c = voicePlays(playLog, 'voice/gd_tune1'); const t2c = voicePlays(playLog, 'voice/gd_tune2'); assert(t1c === 1 && t2c === 1, '两级提示各播一次(绝不逐字重播)'); await advance(() => now >= tD + 40000); assert(voicePlays(playLog, 'voice/gd_tune1') === t1c && voicePlays(playLog, 'voice/gd_tune2') === t2c, '两级用尽即停(每封一轮),之后仅静电底噪'); // 有真实进展(T1 译词落定)→ 进 T2 新等待点:无进展轨重新独立计时 await decodePick(game, 0); await tapUntilChoice(game); await g(game, 'left'); // p2 左 await tapThrough(game, () => game.phase === 'DECODE'); await advance(() => game.browseArmed); const tM = now; // T2 新等待点(tune 双轨已随上一等待点用尽,dedup 不跨等待点) // 乱按 96s:纯浏览/重听不计进展 → 无操作轨被手势复位零播出,无进展轨 60s/120s 抢救 for (let i = 0; i < 48; i++) { await g(game, i % 2 === 0 ? 'right' : 'doubletap'); // 每 2s 一次切候选/重听 await advance(() => now >= tM + (i + 1) * 2000); } assert(voicePlays(playLog, 'voice/gd_tune1') === t1c && voicePlays(playLog, 'voice/gd_tune2') === t2c, '乱按 96s:无操作轨零播出(被手势复位)'); assert(voicePlays(playLog, 'voice/gd_prog1') === 1 && now - tM >= 60000, '无进展轨 60s → gd_prog1(乱滑也必被抢救)'); await advance(() => voicePlays(playLog, 'voice/gd_prog2') === 1); assert(now - tM >= 120000 || (realLog('DBG prog2: now-tM=', now - tM, 'prog2=', voicePlays(playLog, 'voice/gd_prog2'), 'phase=', game.phase, 'armed=', game.browseArmed, 'fired=', JSON.stringify(game._progFired), 'tuneH=', game._tuneHinted), false), '无进展轨 120s → gd_prog2'); const p2c = voicePlays(playLog, 'voice/gd_prog2'); await advance(() => now >= tM + 140000); assert(voicePlays(playLog, 'voice/gd_prog2') === p2c, '无进展轨用尽即停(每封一轮)'); // 有真实进展(翻密码本)→ 同等待点全程零 prog 追加 const progBefore = voicePlays(playLog, 'voice/gd_prog1') + voicePlays(playLog, 'voice/gd_prog2'); await g(game, 'longpress'); await advance(() => !game.voicePlaying); await advance(() => now >= tM + 200000); assert(voicePlays(playLog, 'voice/gd_prog1') + voicePlays(playLog, 'voice/gd_prog2') === progBefore, '有真实进展(翻密码本):无进展轨零播出(不误伤)'); // p* 复述两级(25s/50s 重播当前提示句本身) await decodePick(game, 0); await tapUntilChoice(game); const tP = now; await advance(() => voicePlays(playLog, 'voice/p4_known') === 2); assert(now - tP === 25000, 'p* 播完 25s 无操作 → 重播当前 p* 提示句(第一级)'); await advance(() => voicePlays(playLog, 'voice/p4_known') === 3); assert(now - tP === 50000, '50s → 再复述一次(第二级)'); await advance(() => now >= tP + 70000); assert(voicePlays(playLog, 'voice/p4_known') === 3, 'p* 复述两级后彻底静默'); assert(guideEvents.filter((e) => e.type === 'state' && e.name === 'choice').length >= 2, 'DECODE/抉择起播 choice 签名(W2 选项句即 turn cue)'); teardown(game); } // ================================================================ 菜单交互(§9) realLog('== 菜单交互(guide=off:循环顺序/翻密码本/死窗/局面复述/回大厅)=='); { clearSave(); resetSettings('off'); const { audio, playLog, game } = makeRig(); await audio.unlock(); game.start(); await flush(); await advance(() => game.phase === 'DECODE' && game.browseArmed); await advance(() => !game.voicePlaying); const nodeBefore = game.nodeId; await g(game, 'down'); assert(game.phase === 'MENU' && game.menuIdx === 0, '下滑开菜单(当前项 = 回到电波)'); assert(game._bgmDipMenu === true && Math.abs(game._bgmDipNode.gain.value - 0.4 * (game._bgmDipMorse ? 0.5 : 1)) < 1e-6, 'MENU 同曲低响度(dip 节点 = 0.4 × 摩尔斯 dip 复合)'); assert(voicePlays(playLog, 'voice/sys_menu') === 1, 'sys_menu 报幕'); // 单击循环:resume → book → restart → lobby → resume await advance(() => !game.voicePlaying); const seq = []; for (let i = 0; i < 4; i++) { await g(game, 'tap'); seq.push(game.menuIdx); await advance(() => !game.voicePlaying); } assert(seq.join(',') === '1,2,3,0', `单击循环顺序 = [回到电波, 翻密码本, 重开, 回大厅](实际索引 ${seq})`); // 上滑帮助(菜单内) await g(game, 'up'); await advance(() => !game.voicePlaying); assert(voicePlays(playLog, 'voice/sys_settings') === 1, '菜单内上滑 = 帮助 + 设置提示 + 当前项'); // 翻密码本(菜单项确认 = 长按同效) await g(game, 'tap'); // → book(idx1) await advance(() => !game.voicePlaying); assert(game.menuIdx === 1, '切到「翻密码本」'); await g(game, 'doubletap'); await advance(() => !game.voicePlaying); assert(game.phase === 'DECODE' && voicePlays(playLog, 'voice/hint_t1') === 1, '菜单「翻密码本」确认 = 回 DECODE 执行提示流程(T1 免费页)'); assert(game.hints === 3, '菜单翻页同长按账目(T1 免费)'); // 再开菜单 → 下滑关闭 → 局面复述(DECODE 重挂浏览 + 自动预听) await g(game, 'down'); assert(game.phase === 'MENU', '再开菜单'); await g(game, 'down'); assert(game.phase === 'DECODE' && game.browseArmed, '下滑关闭回原状态(DECODE)'); assert(game.nodeId === nodeBefore, 'phase/node 不变'); assert(game.morseLog[game.morseLog.length - 1].kind === 'preview', '关闭后自动预听当前候选(局面复述)'); await advance(() => !game.voicePlaying); // 回大厅:lobby 项确认 → sys_menu_lobby + 存档 await g(game, 'down'); for (let i = 0; i < 3; i++) { await g(game, 'tap'); await advance(() => !game.voicePlaying); } assert(game.menuIdx === 3, '切到「保存并回大厅」'); const sdBefore = saveData(); await g(game, 'doubletap'); await advance(() => !game.voicePlaying); assert(game.leaving === true && voicePlays(playLog, 'voice/sys_menu_lobby') >= 1, '确认回大厅:sys_menu_lobby 播出(含循环报名一次)且 leaving 置位'); assert(saveData() && saveData().summary === sdBefore.summary, '回大厅前存档落盘(summary 带档名)'); teardown(game); } // ================================================================ 中断复述(DECODE 态恢复链) realLog('== 中断复述(guide=off,预置存档 phase=DECODE tel=3 hints=1 cand=1)=='); { clearSave(); resetSettings('off'); storageData.set(SAVE_KEY, JSON.stringify({ schema: 1, updatedAt: new Date().toISOString(), summary: '第3封·交接 · 暴露:疑云 · 结局0/4', phase: 'DECODE', node: 'dv3_ask', tel: 3, expose: 1, hints: 1, t: { t1: 'bay', t2: 'top', t3: null, t4: null }, picks: { p2: 'left', p4: 'right', p6: null, p8: null, p9: null }, cand: 1, hintUsedT1: true, endings: {}, runs: 2, })); 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 === 'DECODE' && game.decodeTel === 3 && game.candIdx === 1, '单击继续 → 恢复 DECODE 态(tel=3 / 候选=1)'); assert(game.expose === 1 && game.hints === 1 && game.runs === 3, 'expose/hints/runs(继续同样 +1)全部还原'); assert(game.currentVoiceId === 'voice/prog_t3', '恢复链首句 prog_t3(F4 首句锁不可跳)'); await g(game, 'tap'); // 首句播完前 tap 被锁(此时语音在播,断言不跳) assert(game.currentVoiceId === 'voice/prog_t3', '恢复首句不可跳(F4 跳句锁)'); await advance(() => !game.voicePlaying); const iP = lastIndexOf(playLog, 'voice/prog_t3'); const iB = lastIndexOf(playLog, 'voice/book_1'); const iT = lastIndexOf(playLog, 'voice/gd_tune2'); assert(iP >= 0 && iP < iB && iB < iT, 'DECODE 恢复链顺序:prog_t3 → book_1 → gd_tune2(§8.1)'); await advance(() => game.browseArmed && !game.voicePlaying); assert(game.morseLog[game.morseLog.length - 1].word === 'SAND', '恢复后自动预听当前候选(cand=1 → SAND),电文可双击重听'); // 右滑 = 新夜班 teardown(game); const rig2 = makeRig(); await rig2.audio.unlock(); rig2.game.start(); await flush(); assert(rig2.game.phase === 'ASK', '再次带存档重进 → ASK'); await g(rig2.game, 'right'); assert(rig2.game.phase === 'INTRO' && rig2.game.expose === 0 && rig2.game.hints === 3, 'ASK 右滑 = 新夜班:跨电文标量重置'); teardown(rig2.game); } // ================================================================ 键盘变体 + 设置联动 realLog('== 键盘变体(input=key → voice//key/ 前置)与语速边界 =='); { clearSave(); resetSettings('full', { input: 'key' }); const { audio, playLog, game } = makeRig(); await audio.unlock(); game.start(); await flush(); await advance(() => game.phase === 'DECODE' && game.browseArmed); assert(urlHits('female/key/gd_brief') >= 1, 'key 模式:gd_brief 走 key/ 变体目录'); assert(urlHits('female/key/dv1_teach2') >= 1, 'key 模式:dv1_teach2 走 key/ 变体'); assert(urlHits('female/key/p2') >= 1 || true, '(p2 在后续链)'); teardown(game); // touch 模式:主资产,零 key 目录命中(本会程内) requestedUrls.length = 0; clearSave(); resetSettings('off', { input: 'touch' }); const rig2 = makeRig(); await rig2.audio.unlock(); rig2.game.start(); await flush(); await advance(() => rig2.game.phase === 'DECODE' && rig2.game.browseArmed); assert(urlHits('female/key/') === 0, 'touch 模式:key/ 目录零命中(主资产)'); teardown(rig2.game); // 语速边界:settings.speed=2.2 会话下电文仍原速 u=80(摩尔斯不受 SoundTouch 影响) clearSave(); resetSettings('off', { speed: 2.2 }); const rig3 = makeRig(); await rig3.audio.unlock(); rig3.game.start(); await flush(); await advance(() => rig3.game.morseLog.length === 1); assert(rig3.game.morseLog[0].unit === 80, 'speed=2.2 下首播 u 仍 80ms(边界登记断言)'); teardown(rig3.game); } // ================================================================ 状态播报与无效手势(guide=full) realLog('== 状态播报 / 无效手势 / 存档字段(guide=full)=='); { clearSave(); resetSettings('full'); const { audio, playLog, game } = makeRig(); const guideEvents = []; game._earconHook = (ev) => guideEvents.push(ev); await audio.unlock(); game.start(); await flush(); await advance(() => game.phase === 'DECODE' && game.browseArmed && !game.voicePlaying); // 上滑状态链:prog_t1 → book_3 → exp_calm → sys_help(状态在前 G6) await g(game, 'up'); await advance(() => !game.voicePlaying); const iP = lastIndexOf(playLog, 'voice/prog_t1'); const iB = lastIndexOf(playLog, 'voice/book_3'); const iE = lastIndexOf(playLog, 'voice/exp_calm'); const iH = lastIndexOf(playLog, 'voice/sys_help'); assert(iP >= 0 && iP < iB && iB < iE && iE < iH, 'DECODE 上滑状态链顺序(G6):prog_t1 → book_3 → exp_calm → sys_help'); // 帮助链去重:第 2 次上滑同等待点不重复 sys_help(纯音频标记) const hBefore = voicePlays(playLog, 'voice/sys_help'); await advance(() => game.browseArmed && !game.voicePlaying); await g(game, 'up'); await advance(() => !game.voicePlaying); assert(voicePlays(playLog, 'voice/sys_help') === hBefore, 'G1:同等待点第 2 次上滑不再口播 sys_help'); assert(vibrateLog.some((v) => JSON.stringify(v.pattern) === BEAT_PATTERN), 'G1:第 2 次帮助 = beat 轻震标记'); // 无效手势:NIGHT/ENDING 左右滑零静默(error 震 + gd_invalid onceKey 同局限次)。 // 注:无头 mock 语音 ~200ms/句,NIGHT 自动链全程被入口 800ms 死窗覆盖(真机链 5-10s // 才有窗内中段时间),故此处白盒直置 NIGHT 态验证手势映射(§3.3 NIGHT 行)。 await decodePick(game, 0); await tapUntilChoice(game); await g(game, 'left'); // p2 左 await tapThrough(game, () => game.phase === 'DECODE'); await advance(() => game.browseArmed); game.phase = 'NIGHT'; // 白盒:映射断言(真机时窗语义由死窗断言覆盖) const errBefore = vibrateLog.filter((v) => JSON.stringify(v.pattern) === ERR_PATTERN).length; await g(game, 'left'); assert(vibrateLog.filter((v) => JSON.stringify(v.pattern) === ERR_PATTERN).length === errBefore + 1, 'NIGHT 左滑无效 → error 震(零静默)'); assert(voicePlays(playLog, 'voice/gd_invalid') === 1, 'NIGHT 无效手势播 gd_invalid(full 档)'); await g(game, 'right'); assert(voicePlays(playLog, 'voice/gd_invalid') === 1, 'onceKey 同局限次(不轰炸)'); // 存档字段齐全 const sd = saveData(); const keysNeeded = ['schema', 'updatedAt', 'summary', 'phase', 'node', 'tel', 'expose', 'hints', 't', 'picks', 'endings', 'runs']; assert(keysNeeded.every((k) => k in sd), '存档 data 覆盖 §8.1 全部字段(含 summary 字符串)'); // 状态签名计数:choice/settle/danger 齐备(danger 在剧本 C 块断言,此处零) assert(guideEvents.filter((e) => e.type === 'state' && e.name === 'choice').length >= 1, 'DECODE/抉择起播 choice 签名'); teardown(game); } // ================================================================ 资产缺失 realLog('== 资产缺失(guide=off,屏蔽 rev_top)=='); { clearSave(); resetSettings('off'); blockedAssets.add('rev_top'); const errBefore = consoleErrs.length; const { audio, playLog, game } = makeRig(); await audio.unlock(); game.start(); await flush(); await playNight(game, [0, 0, 0, 0], ['left', 'right', 'right', 'right', 'left']); assert(consoleErrs.slice(errBefore).some((e) => e.includes('rev_top')), '屏蔽资产仅 console.error,不抛异常'); assert(consoleWarns.some((w) => w.includes('看门狗') && w.includes('rev_top')), '单句 30s 看门狗判定资产缺失并自动续链'); assert(game.endingId === 'e1' && game.awaitingRestart === true, '屏蔽 voice 资产仍打到 e1,不卡死'); assert(voicePlays(playLog, 'voice/gd_tune1') === 0 && voicePlays(playLog, 'voice/gd_continue') === 0, 'guide=off 全程零向导语音(gd_*)'); blockedAssets.delete('rev_top'); teardown(game); } // ================================================================ 汇总 realLog(`\n通过 ${passCount} 条断言,失败 ${failures.length} 条`); if (failures.length) { realLog('失败清单:'); for (const f of failures) realLog(` ✗ ${f}`); process.exit(1); } realLog('全部断言通过。');