四泳道并行全流水线(资产→开发→测试→玩家迭代闭环),双双 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(压缩候选)
1129 lines
58 KiB
JavaScript
1129 lines
58 KiB
JavaScript
/**
|
||
* 《暗码》无头试玩仿真测试(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('全部断言通过。');
|