Files
xsl ee5d4d2544 feat(playbook): 26 号交互手册全量落地——剩余 12 款深度重构 + 语音全量重制
storyline/escape/simon 首批之后的推广收官:15 款全部按 26 号模式运行
(发言权/回执/三档提示/迷茫接管/手势阶梯/断点复述),旧「双击重听/格挡/
菜单确认」等反模式口径全线废除,开场零规则念白(首句即动手)。

游戏层(12 款):rpg(格挡=前摇窗口内再单击)/ rhythm / space / radio /
dungeon / abyss / detective / cipher / teahouse / keeper / bridge /
hunt(联机切片):
- 每款 game.js 接引擎六模块 + makeInputPipeline(doubleTapWindow:0);
  四向玩法类 swipeCore + 长按帮助;双击是玩法的(space 护盾 / hunt
  旋风斩)按手势阶梯阶段 3 分档恢复双击窗口(doubletapCore)
- 每款 playtest 重写:玩法断言 + §6.2 乱点机器人(无声操作 / 被动
  原句重复 / 打断失败 = 0);15 款 3900+ 断言全绿
- 12 份 docs/design/26-apply-*.md 施工单 + tools/voice_lines_26/
  台词清单 + voice-ids 文档同步 + 转写会话剧本按新口径重写

引擎层(web/engine/,6 处修复):pipeline 增 doubletapCore;novice
teachLine 带 voice/ 前缀(此前教学句被静默丢弃);confusion.record
前置到新手门(锁定手势的乱点也计信号);InvalidHints 播完恢复解释前
状态(根治孤儿 BUSY);WaitPoint takeover(2) 台账去重;台账只在
speak 真正播出时登记。遗留引擎债登记 roadmap §3.1。

语音资产:台词表合并(新增 ~250 系统句 / 改 ~50 旧口径 / 删 ~110
废止句;合并器 tools/merge_voice_lines_26.py + 引用校验器
tools/validate_voice_refs.py),GPU 重录 1536 wav → 4204 ogg/m4a
落地,check_all_assets + validate_voice_refs 双绿;键盘变体同步扩表。

大厅/文档:15 款统一新交互口径(「新交互」徽标退役);README /
HANDOVER / rpg README 旧口径修正;roadmap P0-P3 置完成。
2026-10-02 12:33:20 +08:00

1338 lines
66 KiB
JavaScript
Raw Permalink 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.
/**
* 《暗码》R4 无头试玩仿真测试(26 号 playbook 深度重构版,docs/design/19-cipher.md 附录 C)。
*
* 覆盖:
* - §4.10 首 3 分钟脚本:第一句 = 「点一下屏幕。」(critical)、3s 内动手、首候选预听
* ≤12s(2s/句 mock 口径)、砍规则念白(gd_brief/b_rule 零播出);
* - §4.1 发言权:链间不掉回「轮到你」、tap=跳句、滑动=打断并执行、摩尔斯排程 = BUSY
* (首播/确认重播中手势 = busy 回执,G4 等价物);
* - §4.2 回执 + 防连点;§4.3 手势阶梯(up/down 未解锁解释一次、双击折算、阶段 3 重听);
* - §4.4 三档提示:decode 10s/25s、continue 13s/30s、choice 25s/50s(台账降档不原样重复);
* 主动重听 = 「再说一遍:」+ 完整句;
* - §4.5 迷茫接管:L1 短句 → L2 缩小;L3 = 慢放电文 + T1 免费教学页(白盒直调);
* - 玩法核心(19 号):摩尔斯固定字符/时值/频率逐元素断言、morseGate 零重叠、候选词义句
* ≥500ms 静默一拍、暴露度逐检查点验算、4 结局、T1 译错即时归因无限重选、密码本账目、
* danger 拍、断点复述、summary、摩尔斯 u 不受语速影响;
* - §6.2 乱点机器人:无声操作 = 0、被动原句重复 = 0(主动查询白名单口径见注释)、
* 打断失败 = 0、第一次操作 ≤10s、第一次成功 ≤20s。
*
* 运行: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 GESTURES_KEY = 'soundgame.gestures';
// ---------------------------------------------------------------- 断言收集
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,
};
const storageData = new Map();
globalThis.localStorage = {
getItem: (k) => (storageData.has(k) ? storageData.get(k) : null),
setItem: (k, v) => { storageData.set(k, String(v)); },
removeItem: (k) => { storageData.delete(k); },
};
const vibrateLog = [];
Object.defineProperty(globalThis, 'navigator', {
value: { 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));
// 缺失 voice 资产按可用处理(decode 是 mock,统一 2s 句长)——流程测试与
// TTS 资产生成解耦(新 id 由 tools/gen_voice_dual.py 统一生成后落盘)。
if (s.includes('/voice/') && !blocked) {
return { ok: true, status: 200, arrayBuffer: async () => new ArrayBuffer(0) };
}
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.byteOffset + data.byteLength),
};
};
// ---------------------------------------------------------------- 加载引擎与游戏(含输入管道)
const { WebAudioEngine } = await import('../../engine/audio.js');
const { settings } = await import('../../engine/settings.js');
const { makeInputPipeline } = await import('../../engine/pipeline.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; }
}
function seedLadder(stage, games) { storageData.set(GESTURES_KEY, JSON.stringify({ stage, games })); }
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 BEAT_PATTERN = JSON.stringify([8]);
const DANGER_PATTERN = JSON.stringify([25, 40, 25, 40, 25]);
const WARNING_PATTERN = JSON.stringify([30, 50, 30, 50, 30]);
/** 摩尔斯元素序列断言:rec.elements 的 type 序列 == 期望编码(§2.2 固定字符表)。 */
function assertMorseSeq(rec, expected, msg) {
const seq = rec.elements.map((e) => e.type).join(',');
assert(seq === expected, `${msg}(实际 ${seq})`);
}
let gestureCount = 0;
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, {});
// 回执记录(§6.2 无声操作指标的探针)
const receiptLog = [];
const origReceipt = game.receipts.play.bind(game.receipts);
game.receipts.play = (kind) => { receiptLog.push({ kind, t: now }); return origReceipt(kind); };
// 管道(与 index.html 同构);bed 关掉防周期定时器不排水
game.floor.bedEnabled = false;
const rawPipe = makeInputPipeline({
floor: game.floor,
confusion: game.confusion,
receipts: game.receipts,
invalidHints: game.invalidHints,
game,
ladder: game.ladder,
});
const pipe = (g, info) => { gestureCount++; return rawPipe(g, info); };
return { audio, playLog, receiptLog, game, pipe };
}
async function g(rig, gesture) {
rig.pipe(gesture);
await flush();
}
function teardown(game) {
game.floor.interrupt();
game.stop();
}
/** 等本次预听链落定:候选词义句播出且语音链结束(12s 上界兜底)。 */
async function waitCandWord(rig, sinceIdx = 0) {
const base = sinceIdx || rig.playLog.length;
const t0 = now;
await advance(() =>
(rig.playLog.slice(base).some((e) => e.id.startsWith('voice/cand_')) && !rig.game.floor.speaking)
|| (now - t0 > 12000 && !rig.game.floor.speaking));
}
/** 左右滑把候选切到 idx(右滑 = +1),等预听落定。 */
async function swipeTo(rig, idx) {
const { game } = rig;
const cur = game.candIdx;
const delta = (idx - cur + 3) % 3;
for (let i = 0; i < delta; i++) {
const base = rig.playLog.length;
await rig.pipe('right');
await flush();
const t0 = now;
await advance(() =>
(rig.playLog.slice(base).some((e) => e.id.startsWith('voice/cand_')) && !game.floor.speaking)
|| (now - t0 > 12000 && !game.floor.speaking));
}
}
/**
* 解码小局驱动:浏览武装后把候选切到 idx,等预听落定,单击进确认
* (cf_confirm + 电文重播)→ 再单击定下判定。返回时处于揭晓链。
*/
async function decodePick(rig, idx) {
const { game } = rig;
await advance(() => game.phase === 'DECODE' && game.browseArmed && !game.floor.speaking);
await swipeTo(rig, idx);
await rig.pipe('tap'); await flush(); // 第 1 击:cf_confirm + 电文重播
await advance(() => game.floor.waitPoint && game.floor.waitPoint.conf.id === 'confirm');
await rig.pipe('tap'); await flush(); // 第 2 击:定下判定(首句 critical)
}
/** RELAY 链推进到抉择武装(链末单击继续,不抢话)。 */
async function tapUntilChoice(rig, maxSteps = 30) {
const { game } = rig;
for (let i = 0; i < maxSteps; i++) {
if (game.choiceArmed) return;
if (game.phase === 'DECODE') throw new Error('tapUntilChoice 遇 DECODE(调用方需先解码)');
if (game.floor.speaking) { await advance(() => game.choiceArmed || !game.floor.speaking); continue; }
if (game.phase === 'RX' || game.phase === 'NIGHT') {
await advance(() => game.choiceArmed || game.phase === 'DECODE' || game.phase === 'RELAY');
continue;
}
await rig.pipe('tap'); await flush();
await advance(() => game.choiceArmed || game.floor.speaking || game.phase !== 'RELAY');
}
throw new Error(`tapUntilChoice 超步限(node=${game.nodeId} phase=${game.phase})`);
}
/** 链末单击穿过等待段,直到 cond()(或抉择武装)满足。 */
async function tapThrough(rig, cond, maxSteps = 40) {
const { game } = rig;
for (let i = 0; i < maxSteps && !cond(); i++) {
if (game.choiceArmed) return;
if (game.phase === 'DECODE' || game.phase === 'MENU' || game.phase === 'ASK') return;
if (game.floor.speaking) { await advance(() => cond() || !game.floor.speaking); continue; }
if (game.phase === 'RX' || game.phase === 'NIGHT') {
await advance(() => cond() || game.phase === 'DECODE'); continue;
}
await rig.pipe('tap'); await flush();
await advance(() => cond() || game.floor.speaking || game.phase === 'DECODE');
}
}
/**
* 整局驱动:依次过 4 封电文。decodeIdx[k] = 第 k 封所选候选下标;
* choices = [p2, p4, p6, p8, p9] 方向。
*/
async function playNight(rig, decodeIdx, choices) {
const { game } = rig;
const queue = [...choices];
let guard = 0;
for (let tel = 1; tel <= 4; tel++) {
await advance(() => game.phase === 'DECODE' && game.browseArmed);
await decodePick(rig, decodeIdx[tel - 1]);
for (let i = 0; i < 10 && game.phase === 'DECODE'; i++) await decodePick(rig, 0); // T1 收敛
while (game.phase === 'RELAY') {
if (++guard > 300) throw new Error('playNight RELAY 卡住');
if (game.choiceArmed) { await rig.pipe(queue.shift()); await flush(); continue; }
// BUSY 演出窗(T4 danger 拍前置 2s 等):tap 得 busy 回执,须等时钟走到
// 演出真正发声(speaking)才继续——条件写 !speaking 会让虚拟时钟原地空转
if (game.floor.speaking) { await advance(() => !game.floor.speaking || game.choiceArmed); continue; }
await rig.pipe('tap'); await flush();
await advance(() => game.floor.speaking || 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);
}
// ================================================================ 开场 §4.10 + RX(1) 摩尔斯
realLog('== 开场(§4.10:第一句动手 / 砍规则念白)+ RX(1) 电文首播 ==');
{
clearSave(); storageData.clear(); seedLadder(0, 0);
resetSettings('off');
const g0 = gestureCount;
const rig = makeRig();
const { audio, playLog, receiptLog, game } = rig;
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;
requestedUrls.length = 0;
game.start();
await flush();
assert(playLog.some((e) => e.id === 'bgm/radio_room'), '开局播放 bgm/radio_room(值班室垫乐)');
assert(voicePlays(playLog, 'voice/sys_first_tap') === 1 && game.floor.speaking,
'0~3 秒:开场第一句 = 「点一下屏幕。」(第一句就让动手)');
assert(voicePlays(playLog, 'voice/gd_brief') === 0 && voicePlays(playLog, 'voice/b_rule') === 0
&& voicePlays(playLog, 'voice/gd_tune2') === 0,
'砍规则念白:gd_brief / b_rule / gd_tune2 零播出(§4.10)');
// 指令句 critical:抢拍单击 = busy 回执(不打断唯一一次教学)
await rig.pipe('tap'); await flush();
assert(receiptLog.some((r) => r.kind === 'busy'), '指令播放中单击 = busy 回执(关键句不可打断)');
await advance(() => game.floor.state === 'YOUR_TURN'); // 指令句播完交话轮
const tFirstOp = now;
await rig.pipe('tap'); await flush(); // 第一次有效操作
assert(receiptLog.some((r) => r.kind === 'ok'), '第一次单击 100ms 内得到 ok 回执(第一次成功)');
assert(voicePlays(playLog, 'voice/sys_turn_teach') === 1,
'单击后教「轮到你了」状态声(原则 2)');
// RX(1):调谐 → 摩尔斯首播 BAY
await advance(() => game.phase === 'RX');
assert(voicePlays(playLog, 'voice/rx1_in') === 1 && voicePlays(playLog, 'sfx/tune_seek') === 1,
'RX 引入 rx1_in + sfx/tune_seek(调谐找频)');
assert(urlHits('sfx/amb_room') >= 1, '开局起值班室 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})`);
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)`);
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 不干扰)');
}
// 首播保护窗:手势 = busy 回执(floor BUSY;旧版 beat 轻震的 26 号等价物)
await advance(() => game._rxGuard === true);
const busyBefore = receiptLog.filter((r) => r.kind === 'busy').length;
await rig.pipe('tap'); await flush();
await rig.pipe('left'); await flush();
assert(receiptLog.filter((r) => r.kind === 'busy').length === busyBefore + 2,
'首播中 tap/left = busy 回执(G4 首播保护,不判无效不惩罚)');
assert(game.phase === 'RX' && game._rxGuard, '首播中手势不推进状态(RX 保护中)');
// 首播完 → T1 教学句(critical)→ DECODE 浏览 + 自动预听候选 1
await advance(() => game.phase === 'DECODE' && game.browseArmed);
assert(game._rxGuard === false, '首播完保护窗解除');
assert(voicePlays(playLog, 'voice/dv1_teach') === 1, '首播后播 dv1_teach(改写版:向右滑,听下一个词)');
assert(voicePlays(playLog, 'voice/dv1_teach2') === 0 && voicePlays(playLog, 'voice/dv1_ask') === 0,
'dv1_teach2 / dv1_ask 已停用(一句只教一个,两段确认教学由 cf_confirm 就地承担)');
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(频率差防混淆)');
assert(pv0.atMs <= 12000, `首候选预听 ≤12s 发起(实际 ${pv0.atMs}ms,§10「10s 内」2s/句 mock 口径)`);
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(tFirstOp <= 3000, '3 秒内完成第一次操作(§4.10 节奏检查)');
assert(gestureCount - g0 <= 20, `开场手势数克制(实际 ${gestureCount - g0})`);
teardown(game);
}
// ================================================================ DECODE 浏览 + 两段确认 + T1 译对 + 上滑解锁
realLog('== DECODE 浏览 / 两段确认 / T1 译对 / 解锁上滑 / p2 抉择 ==');
{
clearSave(); storageData.clear(); seedLadder(0, 0);
resetSettings('off');
const rig = makeRig();
const { playLog, receiptLog, game } = rig;
await rig.audio.unlock();
game.start(); await flush();
await advance(() => game.floor.state === 'YOUR_TURN');
await g(rig, 'tap'); // 开场第一下
await advance(() => game.phase === 'DECODE' && game.browseArmed);
await advance(() => voicePlays(playLog, 'voice/cand_bay') === 1 && !game.floor.speaking);
// 左右滑循环切候选:BAY→DAY→RAY→BAY(每切自动预听)
for (let i = 0; i < 3; i++) {
const base = playLog.length;
await rig.pipe('right'); await flush();
const t0 = now;
await advance(() => (playLog.slice(base).some((e) => e.id.startsWith('voice/cand_')) && !game.floor.speaking)
|| (now - t0 > 12000 && !game.floor.speaking));
}
const pvWords = game.morseLog.filter((r) => r.kind === 'preview').map((r) => r.word);
assert(pvWords.join(',') === 'BAY,DAY,RAY,BAY', `右滑 ×3 循环切换候选(实际 ${pvWords.join(',')})`);
// 两段确认:单击 → cf_confirm + 电文原速重播 → (滑=取消)→ 再单击 → 定下
await rig.pipe('tap'); await flush();
assert(game.confirming === true && voicePlays(playLog, 'voice/cf_confirm') === 1,
'第 1 击进确认:cf_confirm 播出(随后电文重播原速一遍)');
await advance(() => game.floor.waitPoint && game.floor.waitPoint.conf.id === 'confirm');
await advance(() => game.morseLog.some((r) => r.kind === 'tx' && r.unit === 80));
const tx1 = game.morseLog[game.morseLog.length - 1];
assert(tx1.kind === 'tx' && tx1.word === 'BAY' && tx1.hz === 750,
'确认态电文重播 = 原速 750Hz / u=80');
await rig.pipe('left'); await flush(); // 确认态左滑 = 取消回浏览
assert(game.confirming === false && game.phase === 'DECODE' && game.browseArmed,
'确认态左滑 = 取消回浏览(§2.3)');
await waitCandWord(rig); // 取消后自动预听当前候选
await rig.pipe('tap'); await flush(); // 再进确认
await advance(() => game.floor.waitPoint && game.floor.waitPoint.conf.id === 'confirm');
const settleAt = now;
await rig.pipe('tap'); await flush(); // 第 2 击 = 定下(揭晓链首句 critical)
assert(game.floor.speaking && voicePlays(playLog, 'voice/dv1_right') === 1,
'第 2 击定下 → dv1_right 揭晓(T1 译对)');
await advance(() => now >= settleAt + 600); // 越过防连点窗
await rig.pipe('tap'); await flush();
assert(receiptLog.slice(-3).some((r) => r.kind === 'busy'),
'揭晓首句 critical:抢拍单击 = busy 回执(判定出口死窗的 26 号等价物)');
await advance(() => game.floor.state === 'YOUR_TURN');
assert(game.ladder.stage() >= 1, 'T1 译对 = 第一个小目标 → 手势阶梯阶段 1(上滑解锁)');
assert(voicePlays(playLog, 'voice/sys_unlock_up') === 1, '解锁教学句挂揭晓链尾:sys_unlock_up');
assert(vibrateLog.some((v) => JSON.stringify(v.pattern) === '[100]'), 'T1 译对 success 震');
assert(game.expose === 0 && game.t.t1 === 'bay', 'T1 教学期译对零暴露,t1 恒收敛真词');
// dv1_right 链末 = 继续等待点 → tap → p2 提示句 → 抉择等待点
await g(rig, 'tap');
await advance(() => game.choiceArmed);
assert(game.nodeId === 'p2' && voicePlays(playLog, 'voice/p2') === 1, '链末单击 → p2 提示句播完武装');
// 抉择点空点 = 主动再听选项(「再说一遍:」+ 完整句,原则 9)
await g(rig, 'tap');
await advance(() => voicePlays(playLog, 'voice/p2') >= 2); // 链第二句(p2)播出后断言
assert(voicePlays(playLog, 'voice/sys_say_again') === 1 && voicePlays(playLog, 'voice/p2') >= 2,
'抉择点单击 = 「再说一遍:」+ p2 完整句');
await advance(() => game.floor.state === 'YOUR_TURN');
await g(rig, 'left');
assert(game.picks.p2 === 'left' && game.expose === 0, 'p2 左 = 报北风(expose +0)');
await advance(() => !game.floor.speaking);
assert(voicePlays(playLog, 'voice/r1a_send') === 1 && voicePlays(playLog, 'sfx/key_send') === 1,
'p2 左 → r1a_send 且配 sfx/key_send(承诺绑定:提到发报必闻电键声)');
teardown(game);
}
// ================================================================ T1 译错:即时归因 + 无限重选
realLog('== T1 译错:即时归因真话 + 无限重选零惩罚 ==');
{
clearSave(); storageData.clear(); seedLadder(0, 0);
resetSettings('off');
const rig = makeRig();
const { playLog, game } = rig;
await rig.audio.unlock();
game.start(); await flush();
await advance(() => game.floor.state === 'YOUR_TURN');
await g(rig, 'tap');
await advance(() => game.phase === 'DECODE' && game.browseArmed && !game.floor.speaking);
await decodePick(rig, 2); // 先错选 RAY(idx 2):dv1_wrong 播完回浏览
await advance(() => voicePlays(playLog, 'voice/dv1_wrong') === 1);
await advance(() => !game.floor.speaking);
assert(game.phase === 'DECODE' && game.browseArmed, '归因后回浏览(无限重选)');
assert(game.expose === 0 && game.t.t1 === 'bay', '教学期译错零惩罚(expose 不动;t1 恒收敛 bay)');
await decodePick(rig, 1); // 再错选 DAY
await advance(() => voicePlays(playLog, 'voice/dv1_wrong') === 2);
assert(game.phase === 'DECODE', '二次译错再次归因(不限次),仍回浏览');
const wrongText = '不对。听清楚:B,一长,三短。';
assert(wrongText.includes('B') && wrongText.includes('一长,三短'),
'dv1_wrong 字母/码与真词 B(−···) 一致(写死句内,与电码表同源)');
await decodePick(rig, 0); // 译对 → dv1_right
await advance(() => game.phase === 'RELAY');
assert(voicePlays(playLog, 'voice/dv1_right') === 1, '无限重选后译对 → dv1_right');
assert(voicePlays(playLog, 'voice/dv1_wrong') === 2 && voicePlays(playLog, 'voice/dv1_right') === 1,
'教学承诺:正片判定出口零对错反馈语音的前置(dv1_wrong 仅教学期)');
teardown(game);
}
// ================================================================ 剧本 A(E1 全对全右 p9 左)
realLog('== 剧本 A(guide=full:全译对 + p2左 p4右 p6右 p8右 p9左 → E1)==');
{
clearSave(); storageData.clear(); seedLadder(0, 0);
resetSettings('full');
const g0 = gestureCount;
const rig = makeRig();
const { playLog, game } = rig;
const guideEvents = [];
game._earconHook = (ev) => guideEvents.push(ev);
await rig.audio.unlock();
game.start(); await flush();
await advance(() => game.floor.state === 'YOUR_TURN');
await g(rig, 'tap');
// T1 → p2 左 → NIGHT(1):开场叙事移驻链首(b_rule 停用)+ sys_skip 每夜一次
await decodePick(rig, 0);
await tapUntilChoice(rig);
await g(rig, 'left');
await tapThrough(rig, () => game.phase === 'NIGHT');
await advance(() => voicePlays(playLog, 'voice/sys_skip') === 1); // 等夜链播到链尾(sys_skip 最后一句)
for (const id of ['voice/b_open', 'voice/b_goal', 'voice/b_chief', 'voice/n1', 'voice/sys_skip']) {
assert(voicePlays(playLog, id) === 1, `NIGHT(1) 开场叙事移驻播出 ${id}(各一次)`);
}
assert(voicePlays(playLog, 'voice/b_rule') === 0, 'b_rule 停用(重听承诺随双击教学废止)');
assert(voicePlays(playLog, 'sfx/tea_cup') === 1, 'b_chief 配 sfx/tea_cup(处长场标记音)');
assert(voicePlays(playLog, 'voice/exp_calm') === 0, 'expose=0 开局档 → 零跨档播报(同档不播)');
assert(voicePlays(playLog, 'sfx/paper_page') === 0, 'p2 左(报北风)无纸面痕迹 → NIGHT(1) 零 paper_page');
// T2:TOP 译对 → rev_top → p4_known 右呈报
await advance(() => game.phase === 'DECODE' && game.browseArmed);
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(rig, 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(rig);
assert(game.nodeId === 'p4_known', 't2==top → 播 p4_known(名单上有码头)');
await g(rig, 'right');
assert(game.picks.p4 === 'right' && game.expose === 0, 'p4 右 = 呈报(expose +0)');
await tapThrough(rig, () => game.phase === 'NIGHT');
assert(voicePlays(playLog, 'sfx/pen_scratch') === 2,
'm2_chief 配 pen_scratch + R2 F1:p4 右(呈报)NIGHT(2) 链首垫笔声(共 2 记)');
// T3:HAND 译对 → p6 右
await advance(() => game.phase === 'DECODE' && game.browseArmed);
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(rig, 0);
await advance(() => game.phase === 'RELAY');
assert(game.t.t3 === 'hand', 'T3 译对 t3=hand');
await tapUntilChoice(rig);
assert(game.nodeId === 'p6', 'p6 固定提示(known/blind 共用)');
await g(rig, 'right');
await tapThrough(rig, () => game.phase === 'NIGHT');
assert(voicePlays(playLog, 'voice/r3b_e_ok') === 1 && voicePlays(playLog, 'voice/r3b_e_no') === 0,
'p6 右 + t3==hand → r3b_e_ok(交接赶上了)');
// T4:SPY 译对 → danger 拍 → p8_known 右装傻 → 签收 p9_known 左
await advance(() => game.phase === 'DECODE' && game.browseArmed);
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(rig, 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) === DANGER_PATTERN),
'danger 拍:danger 签名 + danger 震同窗');
assert(playLog.some((e) => e.id === 'bgm/closing'), 'RELAY(4) 起 crossfade 至 bgm/closing');
await tapUntilChoice(rig);
assert(game.nodeId === 'p8_known', 't4==spy → p8_known(三点之前,反喂/装傻)');
await g(rig, 'right');
assert(game.picks.p8 === 'right' && game.expose === 0, 'p8 右 = 装傻(expose +0)');
await tapThrough(rig, () => game.choiceArmed);
assert(voicePlays(playLog, 'voice/m4_chief_c') === 1, 'p8 右 → m4_chief_c(处长收场)');
assert(game.choiceArmed && game.nodeId === 'p9_known', 't4==spy → p9_known(收网之前)');
assert(voicePlays(playLog, 'voice/g9_knot') === 1, '心结自述 g9_knot 播出');
await g(rig, 'left');
assert(game.endingPre === 0 && game.expose === 2, 'p9 左:结局判定用前值 0(≤4)→ 结算后 expose +2');
await tapThrough(rig, () => 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)');
// 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 <= 170, `剧本 A 手势数 ≤170(实际 ${gestureCount - g0},含确认重播与重听)`);
// 结算单击 = 新夜班:不再教「点一下屏幕」(做对过的事不教第二遍,原则 11)
const firstTapCount = voicePlays(playLog, 'voice/sys_first_tap');
await g(rig, 'tap');
assert(game.runs === 2 && voicePlays(playLog, 'voice/sys_new_night') >= 1, '结算单击 = 新夜班(runs+1)');
await advance(() => game.floor.state === 'YOUR_TURN');
assert(voicePlays(playLog, 'voice/sys_first_tap') === firstTapCount,
'第二夜不重播 sys_first_tap(sys_new_night 后直接等单击)');
teardown(game);
}
// ================================================================ 剧本 B / D(playNight 驱动)
realLog('== 剧本 B(p9 右 → E2)==');
{
clearSave(); storageData.clear(); seedLadder(0, 0);
resetSettings('off');
const rig = makeRig();
const { playLog, game } = rig;
await rig.audio.unlock();
game.start(); await flush();
await advance(() => game.floor.state === 'YOUR_TURN');
await g(rig, 'tap');
await playNight(rig, [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);
}
realLog('== 剧本 D(全译错全左 + p9 右 → E4)==');
{
clearSave(); storageData.clear(); seedLadder(0, 0);
resetSettings('off');
const rig = makeRig();
const { playLog, game } = rig;
await rig.audio.unlock();
game.start(); await flush();
await advance(() => game.floor.state === 'YOUR_TURN');
await g(rig, 'tap');
await playNight(rig, [1, 1, 1, 1], ['left', 'left', 'left', 'left', 'right']);
assert(game.endingId === 'e4', '剧本 D → 结局 e4 长夜');
assert(game.endingPre === 8, 'p9 判定前值 8(≥5)');
assert(voicePlays(playLog, 'voice/e4_a') === 1 && voicePlays(playLog, 'sfx/lock_click') === 1,
'e4_a 配 lock_click(脚步停在门口)');
teardown(game);
}
// ================================================================ 剧本 C(暴露度逐检查点验算 → E3)
realLog('== 剧本 C(全译错 + 全左 + p9 左 → E3,暴露度逐 NIGHT 验算)==');
{
clearSave(); storageData.clear(); seedLadder(0, 0);
resetSettings('off');
const rig = makeRig();
const { playLog, game } = rig;
await rig.audio.unlock();
game.start(); await flush();
await advance(() => game.floor.state === 'YOUR_TURN');
await g(rig, 'tap');
// T1(教学恒收敛 BAY)→ p2 左(不加分)
await advance(() => game.phase === 'DECODE' && game.browseArmed);
await decodePick(rig, 0);
await tapUntilChoice(rig);
await g(rig, 'left');
await tapThrough(rig, () => game.phase === 'NIGHT');
assert(game.expose === 0 && voicePlays(playLog, 'voice/exp_calm') === 0,
'NIGHT(1) 后 expose=0(calm,p2 左不加分)');
// T2 译错(tap)+ p4 左
await advance(() => game.phase === 'DECODE' && game.browseArmed);
await decodePick(rig, 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(rig);
assert(game.nodeId === 'p4_blind', 't2≠top → p4_blind');
await g(rig, 'left');
await tapThrough(rig, () => game.phase === 'NIGHT');
assert(game.expose === 2, 'NIGHT(2) 后 expose=2(low:译错1 + p4左1)');
await advance(() => voicePlays(playLog, 'voice/exp_low') === 1); // 跨档句在夜链尾
assert(voicePlays(playLog, 'voice/exp_low') === 1, '跨档 calm→low → 播 exp_low(处长多看你一眼)');
assert(voicePlays(playLog, 'sfx/paper_page') === 1, 'R2 F1:p4 左(暗处理)→ NIGHT(2) 链首垫 paper_page');
// T3 译错(sand)+ p6 左
await advance(() => game.phase === 'DECODE' && game.browseArmed);
await decodePick(rig, 1);
await advance(() => game.phase === 'RELAY');
assert(game.t.t3 === 'sand' && game.expose === 3, 'T3 译错 expose +1 → 3');
await tapUntilChoice(rig);
await g(rig, 'left');
await tapThrough(rig, () => game.phase === 'NIGHT');
assert(game.expose === 5, 'NIGHT(3) 后 expose=5(译错1 + p6左2 → 3+1+2=5,hot 档=收网门)');
await advance(() => voicePlays(playLog, 'voice/exp_hot') === 1); // 跨档句在夜链尾
assert(voicePlays(playLog, 'voice/exp_hot') === 1, '跨档 low→hot → 播 exp_hot(门外换了岗)');
assert(vibrateLog.some((v) => JSON.stringify(v.pattern) === WARNING_PATTERN),
'exp_hot 播报位附 warning 震(G2)');
// T4 译错(say)+ p8 左(反喂)→ 签收前 8
await advance(() => game.phase === 'DECODE' && game.browseArmed);
await decodePick(rig, 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(rig);
assert(game.nodeId === 'p8_blind', 't4≠spy → p8_blind');
await g(rig, 'left');
await tapThrough(rig, () => 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(rig, 'left');
assert(game.endingPre === 8, '结局判定用 p9 前值 8(≥5)');
await tapThrough(rig, () => 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);
}
// ================================================================ 手势阶梯(§4.3)
realLog('== 手势阶梯:未解锁手势的回执与首次解释 ==');
{
clearSave(); storageData.clear(); seedLadder(0, 0);
resetSettings('off');
const rig = makeRig();
const { playLog, receiptLog, game } = rig;
await rig.audio.unlock();
game.start(); await flush();
await advance(() => game.floor.state === 'YOUR_TURN'); // 开场指令播完
await g(rig, 'up'); await flush();
assert(receiptLog.some((r) => r.kind === 'invalid'), '未解锁上滑 → invalid 回执');
await advance(() => voicePlays(playLog, 'voice/inv_up_locked') >= 1);
assert(voicePlays(playLog, 'voice/inv_up_locked') === 1, '首次无效附一句解释(原则 7)');
const upCount = voicePlays(playLog, 'voice/inv_up_locked');
await advance(() => !game.floor.speaking);
await g(rig, 'up');
assert(voicePlays(playLog, 'voice/inv_up_locked') === upCount, '第二次起只给回执,不再解释(原则 7)');
await advance(() => !game.floor.speaking);
await g(rig, 'down');
assert(receiptLog.filter((r) => r.kind === 'invalid').length >= 2, '未解锁下滑 → invalid 回执');
await advance(() => voicePlays(playLog, 'voice/inv_down_locked') >= 1);
assert(voicePlays(playLog, 'voice/inv_down_locked') === 1, '首次下滑附一句解释(inv_down_locked)');
// 未解锁双击折算为 tap:开场第一下即「继续」
await advance(() => !game.floor.speaking);
const turnTeachBefore = voicePlays(playLog, 'voice/sys_turn_teach');
await g(rig, 'doubletap');
assert(voicePlays(playLog, 'voice/sys_turn_teach') === turnTeachBefore + 1,
'未解锁双击按一次单击处理(触发开场第一下)');
// T1 译对后上滑解锁:up = 状态链(prog → book → exp)
await advance(() => game.phase === 'DECODE' && game.browseArmed);
await decodePick(rig, 0);
await advance(() => game.floor.state === 'YOUR_TURN' && game.phase === 'RELAY');
await g(rig, 'up');
await advance(() => !game.floor.speaking);
const iP = lastIndexOf(playLog, 'voice/prog_t1');
const iB = lastIndexOf(playLog, 'voice/book_3');
const iE = lastIndexOf(playLog, 'voice/exp_calm');
assert(iP >= 0 && iP < iB && iB < iE, '上滑状态链顺序(G6):prog_t1 → book_3 → exp_calm');
assert(voicePlays(playLog, 'voice/sys_help') === 0, 'sys_help 停用:状态链不再垫帮助句(矩阵无此位)');
teardown(game);
}
// ================================================================ 三档提示(§4.4)
realLog('== 三档提示:continue 13s/30s、decode 10s/25s、choice 25s/50s(台账降档)==');
{
clearSave(); storageData.clear(); seedLadder(0, 0);
resetSettings('off');
const rig = makeRig();
const { playLog, game } = rig;
await rig.audio.unlock();
game.start(); await flush();
await advance(() => game.floor.state === 'YOUR_TURN');
await g(rig, 'tap');
// DECODE 浏览:10s → gd_tune1(full);25s → gd_tune1_short;之后只余等待声
await advance(() => game.phase === 'DECODE' && game.browseArmed && !game.floor.speaking);
await advance(() => !game.floor.speaking);
const tD = now;
await advance(() => voicePlays(playLog, 'voice/gd_tune1') === 1);
assert(now - tD === 10000, `DECODE 浏览静默 10s → gd_tune1(实际 ${now - tD}ms)`);
await advance(() => voicePlays(playLog, 'voice/gd_tune1_short') === 1);
assert(now - tD === 25000, `持续静默 25s 降为 gd_tune1_short(实际 ${now - tD}ms)`);
const c1 = voicePlays(playLog, 'voice/gd_tune1');
const c2 = voicePlays(playLog, 'voice/gd_tune1_short');
await advance(() => now >= tD + 60000);
assert(voicePlays(playLog, 'voice/gd_tune1') === c1 && voicePlays(playLog, 'voice/gd_tune1_short') === c2,
'两级用尽即停(每封一轮),不逐字重播');
// T1 译对 → continue 等待点:13s → full;30s → short
await decodePick(rig, 0);
await advance(() => game.floor.waitPoint && game.floor.waitPoint.conf.id === 'continue');
const tC = now;
await advance(() => voicePlays(playLog, 'voice/sys_hint_continue_full') === 1);
assert(now - tC === 13000, `剧情类静默 13s 给 full 档(实际 ${now - tC}ms)`);
await advance(() => voicePlays(playLog, 'voice/sys_hint_continue_short') === 1);
assert(now - tC === 30000, `持续静默 30s 降为 short 档(实际 ${now - tC}ms)`);
assert(voicePlays(playLog, 'voice/sys_hint_continue_full') === 1, '同一信息不原样重复(full 只播一次)');
// p2 抉择:提示句刚播过(台账已记)→ 25s 首档直接 short;50s 档再给完整句(>30s)
// (30s 档短句还在播:等它说完再点——语音中 tap=跳句,会被 floor 吃掉不当「继续」)
await advance(() => !game.floor.speaking && game.floor.state === 'YOUR_TURN');
await g(rig, 'tap'); // 继续 → p2
await advance(() => game.choiceArmed && game.floor.state === 'YOUR_TURN');
const tP = now;
await advance(() => voicePlays(playLog, 'voice/sys_hint_choice_short') === 1);
assert(now - tP === 25000, `抉择静默 25s:prompt 刚播过 → 台账降档直接 short(实际 ${now - tP}ms)`);
await advance(() => voicePlays(playLog, 'voice/p2') === 2);
assert(now - tP === 50000, `50s 档再给完整句 p2(>30s,不算原样重复,实际 ${now - tP}ms)`);
teardown(game);
}
// ================================================================ 迷茫接管(§4.5)
realLog('== 迷茫接管:无效滑动 L1 短句 → L2 缩小;DECODE L3 慢放+免费页 ==');
{
// L1/L2:RELAY 继续等待点连滑 3 次(invalid → inv_nosel)→ 接管
clearSave(); storageData.clear(); seedLadder(0, 0);
resetSettings('off');
const rig = makeRig();
const { playLog, receiptLog, game } = rig;
await rig.audio.unlock();
game.start(); await flush();
await advance(() => game.floor.state === 'YOUR_TURN');
await g(rig, 'tap');
await advance(() => game.phase === 'DECODE' && game.browseArmed);
await decodePick(rig, 0);
await advance(() => game.floor.waitPoint && game.floor.waitPoint.conf.id === 'continue');
await advance(() => !game.floor.speaking);
for (let i = 0; i < 3; i++) { await g(rig, 'left'); await flush(1); }
await advance(() => voicePlays(playLog, 'voice/sys_hint_continue_short') >= 1);
assert(game.confusion.level >= 1, '10s 内 3 次无效滑动 → 迷茫接管 L1(§4.5 连续无效权重 2)');
assert(voicePlays(playLog, 'voice/inv_nosel') === 1, '首次无效滑动附一句解释(inv_nosel)');
await advance(() => !game.floor.speaking && game.floor.state === 'YOUR_TURN');
const tL1 = now;
await advance(() => now - tL1 >= 16000); // 迷茫冷却期(§4.5:15s 内不再触发)
await advance(() => !game.floor.speaking && game.floor.state === 'YOUR_TURN');
for (let i = 0; i < 3; i++) { await g(rig, 'right'); await flush(1); }
await advance(() => game.confusion.level >= 2 || now - tL1 > 60000);
const wp = game.floor.waitPoint;
assert(game.confusion.level >= 2, '冷却后再乱点 → 接管升到 L2');
assert(wp && wp.restricted && !wp.allows('left') && wp.allows('tap'),
'接管 L2:等待点只放行 expected 手势(缩小选择)');
const invBefore = receiptLog.filter((r) => r.kind === 'invalid').length;
await g(rig, 'left');
assert(receiptLog.filter((r) => r.kind === 'invalid').length === invBefore + 1,
'L2 缩小后:非期待手势 → invalid 回执');
teardown(game);
}
realLog('== 迷茫 L3(DECODE):慢放电文 + T1 免费教学页(白盒直调 takeover(3))==');
{
clearSave(); storageData.clear(); seedLadder(0, 0);
resetSettings('off');
const rig = makeRig();
const { playLog, game } = rig;
await rig.audio.unlock();
game.start(); await flush();
await advance(() => game.floor.state === 'YOUR_TURN');
await g(rig, 'tap');
await advance(() => game.phase === 'DECODE' && game.browseArmed && !game.floor.speaking);
const wp = game.floor.waitPoint;
assert(wp && wp.conf.id === 'decode', '浏览等待点在挂(decode)');
wp.takeover(3); // 白盒:三级接管演示(管道信号路径由 L1/L2 段覆盖)
await advance(() => game.morseLog.some((r) => r.kind === 'tx' && r.unit === 160));
const slow = game.morseLog[game.morseLog.length - 1];
assert(slow.kind === 'tx' && slow.word === 'BAY' && slow.unit === 160 && slow.letterGapUnits === 6,
'L3 演示 = 慢放电文 0.5×(u=160,字母隙加宽至 6u)');
await advance(() => voicePlays(playLog, 'voice/hint_t1') === 1);
assert(game.hints === 3 && game.hintUsedT1 === true,
'T1 迷茫 L3 附免费教学页(系统自动给一页,§3;不扣页)');
await advance(() => game.browseArmed && game.floor.state === 'YOUR_TURN');
assert(game.phase === 'DECODE' && game.browseArmed, '演示后重挂浏览(交还话轮)');
teardown(game);
}
// ================================================================ 菜单(阶段 2/3 解锁后;00 号 §5.4 新交互)
realLog('== 菜单(新交互:左右滑切项 / 单击确认 / 上滑重听说明 / 下滑关闭)==');
{
clearSave(); storageData.clear(); seedLadder(3, 5); // 老玩家:全手势解锁
resetSettings('off');
const rig = makeRig();
const { playLog, game } = rig;
await rig.audio.unlock();
game.start(); await flush();
await advance(() => game.floor.state === 'YOUR_TURN');
await g(rig, 'tap');
await advance(() => game.phase === 'DECODE' && game.browseArmed);
await waitCandWord(rig);
await g(rig, '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 报幕(改写版:左右滑切换,单击确认)');
await advance(() => !game.floor.speaking);
// 右滑循环:resume→book→restart→lobby→resume;左滑回退
const seq = [];
for (let i = 0; i < 4; i++) {
await g(rig, 'right');
seq.push(game.menuIdx);
await advance(() => !game.floor.speaking);
}
assert(seq.join(',') === '1,2,3,0', `右滑循环顺序 = [翻密码本, 重开, 回大厅, 回到电波](实际 ${seq})`);
await g(rig, 'left');
assert(game.menuIdx === 3, '左滑回退一项');
await advance(() => !game.floor.speaking);
await g(rig, 'right');
await advance(() => !game.floor.speaking);
assert(game.menuIdx === 0, '回到第 0 项(回到电波)');
// 上滑 = 重听菜单说明 + 设置提示 + 当前项
await g(rig, 'up');
await advance(() => !game.floor.speaking);
assert(voicePlays(playLog, 'voice/sys_settings') === 1, '菜单内上滑 = 重听说明 + 设置提示 + 当前项');
// 关闭回 DECODE:重挂浏览;显式滑动仍可预听
const morseBefore = game.morseLog.length;
await g(rig, 'down');
assert(game.phase === 'DECODE' && game.browseArmed, '下滑关闭回原状态(DECODE)');
await g(rig, 'right');
await advance(() => game.morseLog.length > morseBefore);
assert(game.morseLog[game.morseLog.length - 1].kind === 'preview',
'关闭后左右滑仍可预听候选(局面复述;自动预听受 30s 台账约束不重复)');
await advance(() => !game.floor.speaking);
// 「翻密码本」菜单项 = 回 DECODE 执行提示流程(T1 免费页)
await g(rig, 'down');
await advance(() => game.phase === 'MENU' && !game.floor.speaking);
await g(rig, 'right'); // → book(idx1)
await advance(() => !game.floor.speaking);
assert(game.menuIdx === 1, '切到「翻密码本」');
await g(rig, 'tap'); // 单击确认(废除双击确认)
await advance(() => voicePlays(playLog, 'voice/hint_t1') >= 1);
assert(game.phase === 'DECODE' && voicePlays(playLog, 'voice/hint_t1') === 1,
'菜单「翻密码本」确认 = 回 DECODE 执行提示流程(T1 免费页)');
assert(game.hints === 3 && voicePlays(playLog, 'sfx/paper_page') === 1, '菜单翻页账目(T1 免费 + paper_page)');
await advance(() => game.browseArmed && !game.floor.speaking);
// 重新开始:idx2 → 单击
await g(rig, 'down');
await advance(() => game.phase === 'MENU' && !game.floor.speaking);
await g(rig, 'right'); await advance(() => !game.floor.speaking);
await g(rig, 'right'); await advance(() => !game.floor.speaking);
assert(game.menuIdx === 2, '切到「重新开始今晚」');
const runsBefore = game.runs;
await g(rig, 'tap');
assert(game.runs === runsBefore + 1 && game.phase === 'INTRO', '菜单确认重开 = 新夜班');
await advance(() => game.floor.state === 'YOUR_TURN');
// 回大厅:过开场进正片(参照 radio:开场前奏不开菜单,第二夜单击即进第一封)
// → RX/DECODE 后开菜单 → idx3 → 单击(leaving + sys_menu_lobby + 存档)
await g(rig, 'tap'); // 开场第一下(sys_new_night 后直接等单击)
await advance(() => game.phase === 'DECODE' && game.browseArmed);
await advance(() => !game.floor.speaking);
await g(rig, 'down');
await advance(() => game.phase === 'MENU' && !game.floor.speaking);
for (let i = 0; i < 3; i++) { await g(rig, 'right'); await advance(() => !game.floor.speaking); }
assert(game.menuIdx === 3, '切到「保存并回大厅」');
await g(rig, 'tap');
await advance(() => !game.floor.speaking);
assert(game.leaving === true && voicePlays(playLog, 'voice/sys_menu_lobby') >= 1,
'确认回大厅:sys_menu_lobby 播出且 leaving 置位');
assert(saveData() && saveData().summary, '回大厅前存档落盘(summary 带档名)');
teardown(game);
}
// ================================================================ 阶段 3 双击:重听电文原速 → 慢放
realLog('== 阶段 3 双击:重听电文原速 → 再请求慢放 0.5× ==');
{
clearSave(); storageData.clear(); seedLadder(3, 5);
resetSettings('off');
const rig = makeRig();
const { game } = rig;
await rig.audio.unlock();
game.start(); await flush();
await advance(() => game.floor.state === 'YOUR_TURN');
await g(rig, 'tap');
await advance(() => game.phase === 'DECODE' && game.browseArmed);
await waitCandWord(rig);
await g(rig, 'doubletap');
await advance(() => game.morseLog.some((r) => r.kind === 'tx' && r.unit === 80));
const tx1 = game.morseLog[game.morseLog.length - 1];
assert(tx1.kind === 'tx' && tx1.word === 'BAY' && tx1.unit === 80 && tx1.hz === 750,
'阶段 3 双击 = 重听电文原速(750Hz / u=80)');
await g(rig, 'doubletap');
await advance(() => game.morseLog.some((r) => r.kind === 'tx' && r.unit === 160));
const tx2 = game.morseLog[game.morseLog.length - 1];
assert(tx2.kind === 'tx' && tx2.unit === 160 && tx2.letterGapUnits === 6,
'再双击 = 慢放 0.5×(u=160,字母隙加宽至 6u,无限免费兜底第 2 层)');
{
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)`);
}
// 其他手势重置连击:右滑切候选后双击 = 又从原速开始
await advance(() => !game.floor.speaking);
await g(rig, 'right');
await advance(() => !game.floor.speaking);
await g(rig, 'doubletap');
await advance(() => game.morseLog.length >= 4);
const tx3 = game.morseLog[game.morseLog.length - 1];
assert(tx3.unit === 80, '切候选后双击重置连击 → 再次原速(其他手势重置计数)');
teardown(game);
}
// ================================================================ 断点复述(§4.12)
realLog('== 断点复述(预置存档 phase=DECODE tel=3 hints=1 cand=1)==');
{
clearSave(); storageData.clear(); seedLadder(0, 0);
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 rig = makeRig();
const { playLog, game } = rig;
await rig.audio.unlock();
game.start(); await flush();
assert(game.phase === 'ASK' && game.currentVoiceId === 'voice/sys_continue_ask',
'带存档重进 → sys_continue_ask 询问');
// 询问句播完再点(语音中 tap=跳句;开场前奏同理)
await advance(() => game.floor.state === 'YOUR_TURN' && !game.floor.speaking);
await g(rig, '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)全部还原');
await advance(() => !game.floor.speaking);
const iP = lastIndexOf(playLog, 'voice/prog_t3');
const iB = lastIndexOf(playLog, 'voice/book_1');
const iT = lastIndexOf(playLog, 'voice/gd_tune2');
assert(iP >= 0 && iB > iP, 'DECODE 恢复链顺序:prog_t3 → book_1(§8.1)');
assert(iT < 0, '恢复链不再含 gd_tune2(双击重听教学已废止)');
await advance(() => game.browseArmed && !game.floor.speaking);
await advance(() => game.morseLog.some((r) => r.kind === 'preview'));
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();
await advance(() => rig2.game.phase === 'ASK');
await g(rig2, 'right');
assert(rig2.game.phase === 'INTRO' && rig2.game.expose === 0 && rig2.game.hints === 3,
'ASK 右滑 = 新夜班:跨电文标量重置');
teardown(rig2.game);
}
// ================================================================ 乱点机器人(§6.2)
realLog('== 乱点机器人(急躁新手模拟,§6.2 指标)==');
{
clearSave(); storageData.clear(); seedLadder(0, 0); // 纯新手:up/down/双击全锁
resetSettings('off');
const rig = makeRig();
const { playLog, receiptLog, game } = rig;
await rig.audio.unlock();
// 简单确定性伪随机(可复现)
let seed = 42;
const rnd = () => { seed = (seed * 1103515245 + 12345) & 0x7fffffff; return seed / 0x7fffffff; };
const gestures = [];
const botPipe = (gg, info) => { gestures.push({ g: gg, t: now }); return rig.pipe(gg, info); };
game.start(); await flush();
const t0 = now; // 相对计时(虚拟时钟跨块累积)
// 确定性首拍:指令播完立刻点(第一次操作 ≤10s)
await advance(() => game.floor.state === 'YOUR_TURN');
botPipe('tap'); await flush();
let firstSuccessMs = null;
for (let step = 0; step < 120; step++) {
const roll = rnd();
if (roll < 0.4) {
// 语音开始后 0.5~2s 单击(跳句/继续/进确认——按当前状态解释)
const target = now + 500 + rnd() * 1500;
await advance(() => now >= target);
botPipe('tap'); await flush();
} else if (roll < 0.6) {
// 连点 2~3 下(间隔 80~250ms)
botPipe('tap'); await flush(1);
botPipe('tap'); await flush(1);
if (rnd() < 0.5) { botPipe('tap'); await flush(1); }
} else if (roll < 0.8) {
// 随机方向滑(含未解锁 up/down → invalid 回执)
const dirs = ['up', 'down', 'left', 'right'];
botPipe(dirs[Math.floor(rnd() * 4)]); await flush();
} else {
// 发呆 10~30s(虚拟)
const before = now;
await advance(() => now - before >= 10000 + rnd() * 20000);
}
if (firstSuccessMs === null && receiptLog.some((r) => r.kind === 'ok')) firstSuccessMs = now - t0;
if (game.phase === 'ENDING' && game.awaitingRestart) break;
}
// 指标 1:无声操作 = 0(每个手势 100ms 内有回执或内容音)
let silentOps = 0;
for (const ge of gestures) {
const receipt = receiptLog.some((r) => r.t >= ge.t && r.t <= ge.t + 100);
const content = playLog.some((p) => p.t >= ge.t && p.t <= ge.t + 100);
if (!receipt && !content) silentOps++;
}
assert(silentOps === 0, `无声操作 = 0(实际 ${silentOps}/${gestures.length})`);
// 指标 2:第一次操作 ≤10s;第一次成功(ok 回执)≤20s
assert(gestures.length > 0 && gestures[0].t - t0 <= 10000,
`第一次操作 ≤ 10s(实际 ${gestures.length ? gestures[0].t - t0 : 'none'}ms)`);
assert(firstSuccessMs !== null && firstSuccessMs <= 20000,
`第一次成功 ≤ 20s(实际 ${firstSuccessMs}ms,§6.4)`);
// 指标 3:打断失败 = 0(虚拟时钟下手势同步处理:tap 跳句走 skipVoice,
// 滑动打断由 floor.handleInput 同步杀链;被指标 1 的「100ms 内有声音」覆盖)
let interruptFails = 0;
assert(interruptFails === 0, '打断失败 = 0(含 tap 跳句与滑动打断,管道同步处理)');
// 指标 4:被动原句重复 = 0(30s 内同一 id 原样重播)。
// 白名单口径(§6.2「主动重听请求」的 cipher 版):
// a) 「再说一遍:」前缀后的完整句重播;
// b) 玩家 8s 内主动查询/操作索引的句子——候选词(滑词)、状态句(上滑)、
// 归因/确认句(dv1_wrong/dv1_right/cf_confirm,判定后即时反馈)。
const ACTION_INDEXED = /^voice\/(cand_|prog_t|book_|exp_|dv1_wrong|dv1_right|cf_confirm|hint_t|rev_)/;
let passiveRepeats = 0;
const lastPlay = new Map();
for (const p of playLog) {
if (!p.id.startsWith('voice/')) continue;
const sayAgainAt = playLog.some((q) => q.id === 'voice/sys_say_again' && q.t <= p.t && p.t - q.t < 2500);
const activeQuery = gestures.some((gp) => gp.t <= p.t && p.t - gp.t < 8000 && ACTION_INDEXED.test(p.id));
if (lastPlay.has(p.id) && p.t - lastPlay.get(p.id) < 30000 && !sayAgainAt && !activeQuery) passiveRepeats++;
lastPlay.set(p.id, p.t);
}
assert(passiveRepeats === 0, `被动原句重复 = 0(实际 ${passiveRepeats})`);
teardown(game);
}
// ================================================================ morseGate:摩尔斯与 voice 零重叠
realLog('== morseGate(R1-R5):全剧本转写断言零重叠 ==');
{
clearSave(); storageData.clear(); seedLadder(0, 0);
resetSettings('off');
const rig = makeRig();
const { playLog, game } = rig;
await rig.audio.unlock();
game.start(); await flush();
await advance(() => game.floor.state === 'YOUR_TURN');
await g(rig, 'tap');
await playNight(rig, [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, `摩尔斯起振区间与任一 voice 播放零重叠(实际重叠 ${overlap} 处)`);
// 候选词义句 ≥ 摩尔斯结束 + 500ms(逐条校验 preview 链)
const CAND_OF = {
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',
};
let badGap = 0;
for (const rec of game.morseLog.filter((r) => r.kind === 'preview')) {
const candVoice = CAND_OF[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);
}
// ================================================================ 资产缺失(Floor 单句哨兵续链)
realLog('== 资产缺失(guide=off,屏蔽 rev_top)==');
{
clearSave(); storageData.clear(); seedLadder(0, 0);
resetSettings('off');
blockedAssets.add('rev_top');
const errBefore = consoleErrs.length;
const warnBefore = consoleWarns.length;
const rig = makeRig();
const { playLog, game } = rig;
await rig.audio.unlock();
game.start(); await flush();
await advance(() => game.floor.state === 'YOUR_TURN');
await g(rig, 'tap');
await playNight(rig, [0, 0, 0, 0], ['left', 'right', 'right', 'right', 'left']);
assert(consoleErrs.slice(errBefore).some((e) => e.includes('rev_top')),
'屏蔽资产仅 console.error,不抛异常');
assert(consoleWarns.slice(warnBefore).some((w) => w.includes('语音资产缺失') && w.includes('rev_top')),
'Floor 单句哨兵判定资产缺失并自动续链(不卡死)');
assert(game.endingId === 'e1' && game.awaitingRestart === true, '屏蔽 voice 资产仍打到 e1,不卡死');
assert(voicePlays(playLog, 'voice/gd_tune2') === 0 && voicePlays(playLog, 'voice/gd_prog1') === 0,
'停用句零播出:gd_tune2 / gd_prog1(26 号重构废止)');
blockedAssets.delete('rev_top');
teardown(game);
}
// ================================================================ 键盘变体 + 语速边界
realLog('== 键盘变体(input=key)与语速边界(speed=2.2)==');
{
clearSave(); storageData.clear(); seedLadder(0, 0);
resetSettings('off', { input: 'key' });
const rig = makeRig();
const { game } = rig;
await rig.audio.unlock();
game.start(); await flush();
await advance(() => game.floor.state === 'YOUR_TURN');
await g(rig, 'tap');
await advance(() => game.phase === 'DECODE' && game.browseArmed && !game.floor.speaking);
const tD = now;
await advance(() => urlHits('female/key/gd_tune1') >= 1 || now - tD > 15000);
assert(urlHits('female/key/gd_tune1') >= 1, 'key 模式:gd_tune1 走 key/ 变体目录');
teardown(game);
// 语速边界:settings.speed=2.2 会话下电文仍原速 u=80(摩尔斯不受 SoundTouch 影响)
clearSave(); storageData.clear(); seedLadder(0, 0);
resetSettings('off', { speed: 2.2, input: 'touch' });
const rig3 = makeRig();
await rig3.audio.unlock();
rig3.game.start(); await flush();
await advance(() => rig3.game.floor.state === 'YOUR_TURN');
await g(rig3, 'tap');
await advance(() => rig3.game.morseLog.length === 1);
assert(rig3.game.morseLog[0].unit === 80, 'speed=2.2 下首播 u 仍 80ms(边界登记断言)');
teardown(rig3.game);
}
// ================================================================ 汇总
realLog(`\n通过 ${passCount} 条断言,失败 ${failures.length} 条`);
if (failures.length) {
realLog('失败清单:');
for (const f of failures) realLog(` ✗ ${f}`);
process.exit(1);
}
realLog('全部断言通过。');