Files
SoundGame/web/cipher/test/playtest.mjs
T
xsl 4b39ae2e49 feat(new8/batch3-4): cipher/teahouse/keeper/bridge 全套交付——new8 八款全部可交付收官
四泳道并行全流水线(资产→开发→测试→玩家迭代闭环),双双 R1-R2 连续达标裁定可交付:
- cipher《暗码》223 断言:摩尔斯解码嵌叙事/四结局矩阵/暴露度逐NIGHT验算/三层兜底;R2 三修(链首标记音/预警震/结局计数句+4句补录)4/4 全满分档
- teahouse《深夜茶馆》205 断言:泡茶时机窗/经济逐晚对账/多晚弧线;连续 2 轮 4/4 愿意玩 P0=0
- keeper《守塔人》257 断言:时空签名教学-兑现/插叙触发回返/trust×truth 档位/4结局;R2 F1/F2 坐实(震型豁免/首播跳句锁)4/4
- bridge《星环舰长》336 断言:巡检节奏逐毫秒/危机×系统态矩阵/仲裁7场景/忠诚差分/评级验算;R1 当场修F1-F4、R2 F5 菜单顺延保护 4/4 上行

公共:index.html 四卡片+profiles.js 四桶+check_all_assets 登记(预接线)、00-program 八款进度账收账、
gen_voice_dual 四游戏段、README/HANDOVER 阵容更新(11 款)、voice_text.json 四游戏键、mureka 台账(累计$1.26/$2.00)

验证:四游戏 playtest 223/205/257/336 全绿 + guide 122/122 + 全项目 15 游戏资产终检绿
遗留:keeper 三代人声滤波人耳试听(最高优先,ASR 对滤波句失明)、bridge 音频 32MB 超软指引 2MB(压缩候选)
2026-09-14 05:48:13 +08:00

1129 lines
58 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 《暗码》无头试玩仿真测试(docs/design/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<rec.endMs)o.push(rec.kind+':'+rec.word+'~'+e.id+'@'+(e.t-rec.atMs));}}return o;})())), false), `摩尔斯起振区间与任一 voice 播放零重叠(实际重叠 ${overlap} 处)`);
// 候选词义句 ≥ 摩尔斯结束 + 500ms(逐条校验 preview 链)
let badGap = 0;
for (const rec of game.morseLog.filter((r) => 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/<gender>/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('全部断言通过。');