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 置完成。
1338 lines
66 KiB
JavaScript
1338 lines
66 KiB
JavaScript
/**
|
||
* 《暗码》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('全部断言通过。');
|