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

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

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

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

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

1191 lines
57 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 《不眠频率》R4 无头试玩仿真测试(26 号 playbook 深度重构版)。
*
* 覆盖(docs/design/26-apply-radio.md §10 断言清单):
* - §4.10 首 3 分钟脚本:第一句=sys_first_tap(critical)、3 秒内动手、10 秒内接听;
* - §4.1 发言权:链间不掉回「轮到你」、tap=跳句、滑动=打断并执行、p*=busy;
* - §4.2 回执 + 防连点(400ms 内第二下只给 busy);接听 silent-ok(line_connect);
* - §4.3 手势阶梯:up/down/doubletap 未解锁的回执与首次解释;阶段 1/2 教学句挂链尾;
* - §4.4 三档提示:full → short → 提示音;主动重听=「再说一遍:」+完整句;
* 选项句被动不原样复述(台账降档);
* - §4.5 迷茫接管三级(L1 短句 → L2 只留期待手势 → L3 演示);
* - §4.12 断点复述:ASK 继续上次 / 右滑新夜班;
* - 玩法核心回归:剧本 A 全程 E1、四结局矩阵 B/C/D/E、pendingChoice 废除后的
* p* 不可跳、计分与 §4 数值表一致、存档 summary 人话格式、w1_say 条件差分、
* arg_muffled 线索音 ×2、危机拍自动流、bond_zhou 回响、尾声点名;
* - §6.2 乱点机器人:无声操作=0、被动原句重复=0、打断失败=0、
* 第一次操作 ≤10s、第一次成功 ≤20s;
* - 资产缺失续链(Floor 哨兵 3s 宽限,不卡死);
* - 静态审计:无运行时 id 拼接、资产盘点(新增台词以「待生成清单」豁免)。
*
* 运行:tools/node/bin/node web/radio/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 AUDIO_DIR = path.join(GAME_DIR, 'audio');
const SAVE_KEY = 'soundgame.progress.radio';
const GESTURE_KEY = 'soundgame.gestures';
/** 26 号重构新增的 voice id(资产由 tools/gen_voice_dual.py 统一生成,暂未落盘)。 */
const NEW_VOICE_IDS = new Set([
'sys_first_tap', 'sys_first_tap_short', 'sys_ring_teach', 'sys_turn_teach',
'sys_praise', 'sys_hint_continue_full', 'sys_hint_continue_short',
'sys_hint_choice_short', 'sys_hint_ring_short', 'sys_hint_again_full',
'sys_hint_menu_short', 'sys_only_choice', 'sys_demo_continue', 'sys_demo_choice',
'sys_say_again', 'sys_unlock_up', 'sys_unlock_down', 'sys_unlock_dt',
'menu_help', 'status_choice', 'inv_nosel', 'inv_ring', 'inv_generic',
'inv_up_locked', 'inv_down_locked',
]);
// ---------------------------------------------------------------- 断言收集
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}`); }
}
// console 捕获(引擎/游戏的 warn/error 进日志,测试输出用 realLog)
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(); // id -> { fireAt, cb, args }
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); };
Object.defineProperty(globalThis, 'performance', {
value: { now: () => now },
configurable: true,
});
/** 排空微任务(fetch/decode 的 promise 链)。 */
async function flush(rounds = 8) {
for (let i = 0; i < rounds; i++) await new Promise((r) => realSetImmediate(r));
}
/**
* 推进虚拟时钟:逐个触发到期定时器,直到没有待触发定时器(= 游戏在等手势)
* 或 cond() 满足。返回 'cond' | 'idle'。
*/
async function advance(cond = null) {
for (let n = 0; n < 50000; 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,
};
function fireWindowEvent(type) {
for (const fn of winListeners.get(type) || []) fn();
}
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', {
// maxTouchPoints: 1 —— 手机 persona:inputMode() 判 touch,语音走触屏措辞主资产
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() {}
}
function fakeBuffer() {
return {
duration: 2.0, // 固定句长 2s(虚拟时钟推进语音链)
numberOfChannels: 1,
sampleRate: 44100,
getChannelData: () => new Float32Array(16),
};
}
class MockAudioContext {
constructor() { this.state = 'running'; this.currentTime = 0; this.destination = {}; }
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() {} };
}
createScriptProcessor() { return { connect() {}, disconnect() {}, onaudioprocess: null }; }
createOscillator() {
return { type: 'sine', frequency: new MockParam(440), connect() {}, start() {}, stop() {} };
}
decodeAudioData() { return Promise.resolve(fakeBuffer()); }
resume() { this.state = 'running'; return Promise.resolve(); }
}
globalThis.window.AudioContext = MockAudioContext;
// fetch:voice 层按可用处理(与 TTS 资产生成解耦——新增台词暂未落盘,资产盘点
// 由文末「静态审计」单独把关);blockedAssets 中的片段强制 404(缺失测试用)。
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: 'radio' }) };
}
return { ok: false, status: 401, json: async () => ({ error: 'unauthorized' }) };
}
const blocked = [...blockedAssets].some((b) => s.includes(b));
if (s.includes('/voice/') && !blocked) {
return { ok: true, status: 200, arrayBuffer: async () => new ArrayBuffer(0) };
}
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 { makeInputPipeline } = await import('../../engine/pipeline.js');
const { RadioGame } = await import('../game.js');
settings.set({ guide: 'off', voice: 'female', speed: 1.0, bgm: 1.0 });
function clearSave() {
storageData.delete(SAVE_KEY);
storageData.delete(GESTURE_KEY); // 手势阶梯全局持久:每块从阶段 0 起确定性推进
}
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;
function makeRig() {
const audio = new WebAudioEngine('audio/');
const playLog = [];
const origPlay = audio.play.bind(audio);
audio.play = (id, opts) => { playLog.push({ id, t: now }); return origPlay(id, opts); };
const game = new RadioGame(audio, {});
game.ambLoopEnabled = false; // 无头确定性:通话 amb 只起播一次,不重触发
// 等待声=周期定时器,虚拟时钟会永不排水。storyline 直接置 floor.bedEnabled=false,
// 但 radio 会在 RING↔ONAIR 之间合法地翻转该开关(铃声代替等待声),故这里
// 只静音调度器本身(状态机/交接提示音不受影响,测试不进-bed 断言)。
game.floor._startBed = () => {};
// 回执记录(§6.2 无声操作指标的探针)
const receiptLog = [];
const origReceipt = game.receipts.play.bind(game.receipts);
game.receipts.play = (kind) => { receiptLog.push({ kind, t: now }); return origReceipt(kind); };
const pipe = makeInputPipeline({
floor: game.floor,
confusion: game.confusion,
receipts: game.receipts,
invalidHints: game.invalidHints,
game,
ladder: game.ladder,
opts: game.PIPELINE_OPTS || {},
});
return { audio, playLog, receiptLog, game, pipe };
}
let gestureCount = 0;
async function g(rig, gesture) {
gestureCount++;
rig.pipe(gesture);
await flush();
}
/** 当前等待点 id(无 = null)。 */
const wpId = (game) => {
const wp = game.floor.waitPoint;
return wp ? wp.conf.id : null;
};
const atSettle = (game) => game.phase === 'ENDING' && wpId(game) === 'settle';
/** 真等待态:链已收、话轮已交(链在播时 waitPoint 可能是上一点的残留,不可当等待)。 */
const atWait = (game) => !game.floor.speaking && game.floor.state === 'YOUR_TURN';
/** 结束一个测试块:杀链/摘等待点/清定时器,避免污染后续块的虚拟时钟。 */
function teardown(game) {
game.stop();
}
/**
* 叙事推进器:等真等待态(链收+话轮已交)再动手——链在播时 waitPoint 可能是
* 上一点的残留,直接看 wp 会把「链在播」误判成「在等待」。RING=接听、链末=继续;
* 危机拍前置窗等 BUSY 演出段推进虚拟时钟等演出走完;遇抉择(默认)抛给调用方。
* linger:{ node, done } —— 途经该节点的链末 continue 等待时多听 7 秒再走
* (k1_b 的线索音第二遍挂在节点起播 +6.5s,只有停在本节点等待态才听得见)。
*/
async function tapTo(rig, cond, maxSteps = 100, linger = null) {
const { game } = rig;
for (let i = 0; i < maxSteps && !cond(); i++) {
if (game.phase === 'BRIDGE') { await advance(() => game.phase !== 'BRIDGE'); continue; }
if (!atWait(game)) {
await advance(() => atWait(game) || cond() || game.phase === 'BRIDGE');
continue;
}
if (wpId(game) === 'choice') {
throw new Error(`tapTo 遇到未应答抉择(node=${game.nodeId})——调用方需先做选择`);
}
if (linger && !linger.done && game.nodeId === linger.node && wpId(game) === 'continue') {
linger.done = true;
const target = now + 7000;
await advance(() => now >= target || !atWait(game));
continue;
}
await g(rig, 'tap'); // 真等待态:RING=接听 / 链末=继续
await advance(() => atWait(game) || cond() || game.phase === 'BRIDGE');
}
if (!cond()) throw new Error(`tapTo 未到达条件(node=${game.nodeId} phase=${game.phase} wp=${wpId(game)})`);
}
/** 打完一整夜:tapTo 遇抉择按脚本选,直到 ENDING 结算等待点。
* lingerNode:途经该节点的链末等待时多听 7 秒(k1_b 的线索音回放在 6.5s)。 */
async function playNight(rig, choices, lingerNode = null) {
const { game } = rig;
const linger = lingerNode ? { node: lingerNode, done: false } : null;
let i = 0; let guard = 0;
while (!atSettle(game)) {
if (++guard > 500) throw new Error(`playNight 卡住(node=${game.nodeId} phase=${game.phase} wp=${wpId(game)})`);
if (atWait(game) && wpId(game) === 'choice') {
const c = choices[i++];
if (!c) throw new Error(`playNight 抉择脚本耗尽(node=${game.nodeId})`);
await g(rig, c);
await advance(() => !atWait(game) || atSettle(game));
continue;
}
await tapTo(rig, () => atSettle(game) || (atWait(rig.game) && wpId(rig.game) === 'choice'), 40, linger);
}
}
// 抉择脚本(按 p1..p9 遭遇顺序;p5 仅 p4=右、p7 仅 p6=左 可达)
const SCRIPT_A = ['left', 'left', 'left', 'right', 'left', 'left', 'left', 'left', 'left'];
const SCRIPT_B = [...SCRIPT_A.slice(0, 8), 'right'];
// 剧本 C:p1右 p2左 p3右 p4右 p5右 p6右 p8右 p9左(p6 右 → 无 p7),rep=3 → e3
const SCRIPT_C = ['right', 'left', 'right', 'right', 'right', 'right', 'right', 'left'];
// 剧本 D:p1右 p2右 p3右 p4左 p6右 p8右 p9右(p4 左 → 无 p5;p6 右 → 无 p7),rep=0 → e4
const SCRIPT_D = ['right', 'right', 'right', 'left', 'right', 'right', 'right'];
// 剧本 E:全左但 p4 左(错过线索)→ heardArgument=false,C4 播 w1_say2 组;rep=9 → e1
const SCRIPT_E = ['left', 'left', 'left', 'left', 'left', 'left', 'left', 'left'];
// ================================================================ 首 3 分钟脚本(§4.10)
realLog('== 首 3 分钟脚本(§4.10:3 秒内动手、10 秒内接听)==');
{
clearSave();
const rig = makeRig();
const { audio, playLog, receiptLog, game } = rig;
try {
const t0 = now;
await audio.unlock();
game.start();
await flush();
assert(playLog.some((e) => e.id === 'sfx/onair_sting') && playLog.some((e) => e.id === 'sfx/amb_studio'),
'0~3s:台标 sting + 直播间环境音起,第一句不是规则念白');
assert(voicePlays(playLog, 'voice/sys_first_tap') === 1 && game.floor.speaking,
'0~3s:开场第一句 =「点一下屏幕。」(第一句就让动手)');
assert(voicePlays(playLog, 'voice/b_rule') === 0 && voicePlays(playLog, 'voice/gd_brief') === 0,
'开场零规则念白(b_rule / gd_brief 废除)');
// 指令句 critical:抢拍单击/滑动 = busy 回执(不打断唯一一次教学)
await g(rig, 'tap');
assert(receiptLog.some((r) => r.kind === 'busy'), '指令播放中单击 = busy 回执(关键句不可打断)');
await g(rig, 'left');
assert(receiptLog.filter((r) => r.kind === 'busy').length >= 2, '指令播放中滑动 = busy 回执');
await advance(() => game.floor.state === 'YOUR_TURN'); // 指令句播完交话轮
const firstOpAt = now - t0;
await g(rig, 'tap'); // 第一次有效操作
assert(firstOpAt <= 10000, `第一次操作 ≤10s(虚拟 ${firstOpAt}ms 处发起点)`);
assert(receiptLog.some((r) => r.kind === 'ok'), '开场单击 100ms 内得到 ok 回执(第一次成功)');
assert(voicePlays(playLog, 'voice/sys_ring_teach') === 1,
'第一下之后:RING 状态声首现教学「热线灯亮了。点一下,接听。」');
await advance(() => game.phase === 'RING');
assert(game.callN === 1 && game.floor.state === 'YOUR_TURN' && wpId(game) === 'ring',
'教学句播完 → RING(c1),交出接听等待点(铃声即等待声)');
assert(game._ringSrc !== null, 'RING 铃声循环垫起播(sfx/phone_ring 无缝 loop,游戏侧持句柄)');
assert(game.floor.bedEnabled === false, 'RING 不起合成等待声(铃声循环代替,15 号 §2)');
const sd = saveData();
assert(sd && sd.phase === 'RING' && sd.call === 1 && sd.node === 'z1_ring_in',
'RING 进入即存档:phase=RING / call=1 / node=本通入口');
// 接听:line_connect 内容音 → silent-ok(§4.2 已有明显内容音可省 ok)
const okBefore = receiptLog.filter((r) => r.kind === 'ok').length;
await g(rig, 'tap');
assert(playLog.some((e) => e.id === 'sfx/line_connect' && e.t >= 0) && game.phase === 'ONAIR',
'单击接听 → line_connect + ONAIR(c1)');
assert(receiptLog.filter((r) => r.kind === 'ok').length === okBefore,
'接听回执 = silent-ok(线路音即内容反馈,不叠 ok 回执)');
assert(game._ringSrc === null, '接听即停铃声(不拖尾盖住通话)');
// C1 首句(链末=继续的节奏):一线来电 → 等待声;单击 → 老周开口 + 状态教学
await advance(() => game.floor.state === 'YOUR_TURN');
assert(voicePlays(playLog, 'voice/z1_ring_in') === 1 && wpId(game) === 'continue',
'接听链首句 z1_ring_in(一线来电),链末交出 continue 等待点');
const answerAt = now - t0;
assert(answerAt <= 15000, `10 秒内接听成功(接听发生在虚拟 ${answerAt}ms)`);
await g(rig, 'tap'); // 继续 → z1_a + sys_turn_teach
await advance(() => playLog.some((e) => e.id === 'voice/sys_turn_teach'));
assert(game.floor.speaking, '等待声教学句 sys_turn_teach 在链上(整条链都算 SPEAKING)');
assert(voicePlays(playLog, 'voice/z1_a') === 1,
'单击继续 → 老周开口(z1_a)+ 状态教学挂链尾(一句话只教一件事)');
await advance(() => game.floor.state === 'YOUR_TURN');
assert(wpId(game) === 'continue', '教学链播完交出 continue 等待点(链末=继续)');
// tap=跳句:z1_b 链上跳一句(sys_skip 尾句被跳 = 跳句语义),链照常交接
await g(rig, 'tap'); // 继续 → z1_b(含 sys_skip 尾句)
await advance(() => game.floor.speaking);
await g(rig, 'tap'); // 跳句
assert(voicePlays(playLog, 'voice/sys_skip') <= 1, 'sys_skip 开场只挂一次(每实例一次)');
await tapTo(rig, () => wpId(game) === 'choice');
assert(game.nodeId === 'p1' && game.floor.waitPoint.conf.full === 'voice/p1',
'推进到 p1 抉择点(full 档 = 选项句本身)');
} finally { teardown(game); }
}
// ================================================================ 抉择点:critical / 重听 / 阶梯解锁
realLog('== 抉择点(p* critical / 原则 9 重听 / §4.3 阶梯解锁)==');
{
clearSave();
const rig = makeRig();
const { playLog, receiptLog, game } = rig;
try {
await rig.audio.unlock();
game.start();
await flush();
await advance(() => game.floor.state === 'YOUR_TURN');
await g(rig, 'tap'); // → sys_ring_teach → RING
await advance(() => game.phase === 'RING');
await g(rig, 'tap'); // 接听
await tapTo(rig, () => game.nodeId === 'p1' && game.floor.speaking); // p1 选项句首播
// p* 首播 critical:tap / 滑动都给 busy(旧 pendingChoice 缓存废除)
const busyBefore = receiptLog.filter((r) => r.kind === 'busy').length;
await g(rig, 'tap');
assert(receiptLog.filter((r) => r.kind === 'busy').length > busyBefore,
'p* 首播播放中单击 = busy(不可跳,盲玩公平)');
await g(rig, 'right');
assert(receiptLog.filter((r) => r.kind === 'busy').length > busyBefore + 1,
'p* 首播播放中滑动 = busy(不缓存 pendingChoice,不盲选)');
assert(game.nodeId === 'p1' && game.rep === 0, 'busy 手势不推进剧情(rep 未动、节点未进)');
await advance(() => game.floor.state === 'YOUR_TURN');
assert(wpId(game) === 'choice', 'p1 播完交出抉择等待点(播完当刻即可选)');
// 抉择点空点单击 = 主动再听选项:「再说一遍:」+ 完整句(原则 9)
const p1Before = voicePlays(playLog, 'voice/p1');
await g(rig, 'tap');
assert(voicePlays(playLog, 'voice/sys_say_again') === 1,
'抉择点单击 = 主动重听(前缀「再说一遍:」起播)');
await advance(() => voicePlays(playLog, 'voice/p1') === p1Before + 1);
assert(voicePlays(playLog, 'voice/p1') === p1Before + 1,
'「再说一遍:」之后接 p1 完整句(主动重听永远给完整档)');
await advance(() => game.floor.state === 'YOUR_TURN');
// 左滑 → 抉择生效 + 计分 + 首目标解锁上滑(「对。」挂链头、教学句挂链尾)
await g(rig, 'left');
assert(game.rep === 1 && game.nodeId === 'z1_song', 'p1 左 → rep=1,进 z1_song(§4 数值表)');
assert(game.ladder.stage() >= 1, '第一个抉择完成 → 手势阶梯阶段 1(上滑解锁)');
await advance(() => game.floor.speaking);
assert(voicePlays(playLog, 'voice/sys_praise') === 1, '首抉择链头肯定句「对。」(原则 11,≤4 字)');
await advance(() => playLog.some((e) => e.id === 'voice/sys_unlock_up')
&& game.floor.state === 'YOUR_TURN');
assert(voicePlays(playLog, 'voice/sys_unlock_up') === 1,
'解锁教学句挂链尾:「想知道自己在哪,就向上滑。」');
// 上滑(已解锁)= 我在哪一句话:通号 → 口碑档,播完回等待点
const progBefore = voicePlays(playLog, 'voice/prog_c1');
const tierBefore = voicePlays(playLog, 'voice/rep_mid');
await g(rig, 'up');
assert(voicePlays(playLog, 'voice/prog_c1') === progBefore + 1, '上滑 = 我在哪(第 1 通分档整句)');
await advance(() => game.floor.state === 'YOUR_TURN');
assert(voicePlays(playLog, 'voice/rep_mid') === tierBefore + 1,
'上滑状态链含口碑档整句(rep=1 → mid 档)');
assert(wpId(game) === 'continue', '状态一句话播完回到原等待点(不重播节点)');
// 防连点:tap「继续」后 400ms 内第二下只给 busy
const busy0 = receiptLog.filter((r) => r.kind === 'busy').length;
await g(rig, 'tap'); // 继续 → z1_songo
await g(rig, 'tap'); // 虚拟时钟未推进 = 400ms 内 → busy
assert(receiptLog.filter((r) => r.kind === 'busy').length === busy0 + 1,
'防连点:tap「继续」后 400ms 内的第二下只给 busy 回执');
} finally { teardown(game); }
}
// ================================================================ 三档提示(§4.4)与迷茫接管(§4.5)
realLog('== 三档提示(continue 18s/40s;choice 被动不原样复述)==');
{
clearSave();
const rig = makeRig();
const { playLog, game } = rig;
try {
await rig.audio.unlock();
game.start();
await flush();
await advance(() => game.floor.state === 'YOUR_TURN');
await g(rig, 'tap'); // → ring teach → RING → 接听 → C1 首链
await advance(() => game.phase === 'RING');
// RING 三档:8s → gd_ring1;18s → short;之后只给提示音。
// (两级静默同时喂迷茫信号 → 18s 处可能叠加接管 L1,但台账已记 → 只补提示音,不重播)
const tR = now;
await advance(() => voicePlays(playLog, 'voice/gd_ring1') >= 1);
assert(now - tR === 8000, `RING 静默 8s 给 full 档 gd_ring1(实际 ${now - tR}ms)`);
await advance(() => voicePlays(playLog, 'voice/sys_hint_ring_short') >= 1);
assert(now - tR === 18000, `18s 降 short「点一下,接听。」(实际 ${now - tR}ms)`);
assert(voicePlays(playLog, 'voice/gd_ring1') === 1, '两级之间不原样重复 full');
// 提示句播完回到 YOUR_TURN 再接听(在播时 tap=跳句,不误当接听)
await advance(() => !game.floor.speaking && game.floor.state === 'YOUR_TURN');
await g(rig, 'tap'); // 接听
// continue 三档:18s full → 40s short(→ 提示音)
await advance(() => wpId(game) === 'continue');
const tC = now;
await advance(() => voicePlays(playLog, 'voice/sys_hint_continue_full') >= 1);
assert(now - tC === 18000, `剧情类静默 18s 给 full「单击,继续。」(实际 ${now - tC}ms)`);
await advance(() => voicePlays(playLog, 'voice/sys_hint_continue_short') >= 1);
assert(now - tC === 40000, `40s 降 short「单击。」(实际 ${now - tC}ms)`);
await advance(() => now >= tC + 60000);
assert(voicePlays(playLog, 'voice/sys_hint_continue_full') === 1
&& voicePlays(playLog, 'voice/sys_hint_continue_short') === 1,
'三档用尽后不再逐字重播(只给提示音,原则 9)');
// choice 三档:选项句已登台账 → 被动首档直接 short,pN 不原样复述
await tapTo(rig, () => wpId(game) === 'choice'); // z1_a → z1_b → p1
const p1N = voicePlays(playLog, 'voice/p1');
const tP = now;
await advance(() => voicePlays(playLog, 'voice/sys_hint_choice_short') >= 1);
assert(now - tP === 25000, `抉择静默 25s 给 short「左右滑,选。」(实际 ${now - tP}ms)`);
assert(voicePlays(playLog, 'voice/p1') === p1N, '选项句被动不原样复述(台账降档,原则 9)');
} finally { teardown(game); }
}
realLog('== 迷茫接管(L1 短句 → L2 缩小 → L3 演示不替选)==');
{
clearSave();
const rig = makeRig();
const { playLog, game } = rig;
try {
await rig.audio.unlock();
game.start();
await flush();
await advance(() => game.floor.state === 'YOUR_TURN');
await g(rig, 'tap');
await advance(() => game.phase === 'RING');
await g(rig, 'tap'); // 接听 → C1 首链 → continue 等待
await advance(() => wpId(game) === 'continue');
// 10s 内 3 次无效滑动 → 迷茫接管 L1(连续无效权重 2,§4.5)
const shortBefore = voicePlays(playLog, 'voice/sys_hint_continue_short');
for (let i = 0; i < 3; i++) { await g(rig, 'left'); await flush(1); }
await advance(() => game.confusion.level >= 1);
assert(game.confusion.level >= 1, '10s 内 3 次无效滑动 → 迷茫接管 L1');
await advance(() => !game.floor.speaking && game.floor.state === 'YOUR_TURN');
assert(voicePlays(playLog, 'voice/inv_nosel') === 1, '首次无效滑动附解释「现在不用选。单击继续。」(原则 7)');
// 冷却 15s 后再乱点 → L2:等待点只放行 expected(tap)
const tL1 = now;
await advance(() => now - tL1 >= 16000);
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);
const wp = game.floor.waitPoint;
assert(wp && wp.restricted && !wp.allows('left') && wp.allows('tap'),
'接管 L2:等待点只放行 expected 手势(缩小选择)');
} finally { teardown(game); }
}
// ================================================================ 手势阶梯(§4.3)
realLog('== 手势阶梯:未解锁回执 / 首次解释一次 / 双击折算与阶段 3 重听 ==');
{
clearSave();
const rig = makeRig();
const { playLog, receiptLog, game } = rig;
try {
await rig.audio.unlock();
game.start();
await flush();
await advance(() => game.floor.state === 'YOUR_TURN');
// 未解锁 up:invalid 回执 + 首次解释一次(原则 7)
await g(rig, 'up');
assert(receiptLog.some((r) => r.kind === 'invalid'), '未解锁上滑 → invalid 回执');
await advance(() => voicePlays(playLog, 'voice/inv_up_locked') >= 1);
const invN = voicePlays(playLog, 'voice/inv_up_locked');
await advance(() => !game.floor.speaking);
await g(rig, 'up');
assert(voicePlays(playLog, 'voice/inv_up_locked') === invN, '第二次起只给回执,不再解释(原则 7)');
// 未解锁 down / 双击折算为单击
await advance(() => !game.floor.speaking);
await g(rig, 'down');
assert(receiptLog.filter((r) => r.kind === 'invalid').length >= 2
&& voicePlays(playLog, 'voice/inv_down_locked') >= 1, '未解锁下滑 → invalid + 首次解释');
const chainBefore = playLog.length;
await advance(() => !game.floor.speaking);
await g(rig, 'doubletap'); // 折算为 tap → 开场第一下
assert(playLog.length > chainBefore && voicePlays(playLog, 'voice/sys_ring_teach') === 1,
'未解锁双击按一次单击处理(推进开场)');
// 阶段 3(老玩家全部解锁):双击 = 重听当前等待点完整句
game.ladder.unlockAll();
await advance(() => game.phase === 'RING');
await g(rig, 'tap'); // 接听 → C1 首链 → continue
await advance(() => atWait(game) && wpId(game) === 'continue');
const sayBefore = voicePlays(playLog, 'voice/sys_say_again');
const hintBefore = voicePlays(playLog, 'voice/sys_hint_continue_full');
await g(rig, 'doubletap');
assert(voicePlays(playLog, 'voice/sys_say_again') === sayBefore + 1,
'阶段 3 双击 = 主动重听(「再说一遍:」前缀起播)');
await advance(() => voicePlays(playLog, 'voice/sys_hint_continue_full') > hintBefore);
assert(voicePlays(playLog, 'voice/sys_hint_continue_full') === hintBefore + 1,
'「再说一遍:」后接等待点完整句(continue full 档)');
await advance(() => atWait(game));
assert(wpId(game) === 'continue', '重听后等待点保留(话轮仍属玩家)');
await g(rig, 'down'); // 已解锁下滑 = 菜单
assert(game.phase === 'MENU', '阶段 2 下滑呼出菜单');
await advance(() => atWait(game) && wpId(game) === 'menu');
await g(rig, 'down'); // 菜单内下滑 = 关闭
assert(game.phase === 'ONAIR', '菜单内下滑关闭 → 回原状态(重播当前节点=局面复述)');
await advance(() => atWait(game) && wpId(game) === 'continue');
} finally { teardown(game); }
}
// ================================================================ 断点复述(§4.12)
realLog('== 断点复述(ASK 继续上次 / 右滑新夜班)==');
{
// rig1 推进到 C1 抉择后(z1_song 链末)退出 → 自动存档
clearSave();
const rig1 = makeRig();
await rig1.audio.unlock();
rig1.game.start();
await flush();
await advance(() => rig1.game.floor.state === 'YOUR_TURN');
await g(rig1, 'tap');
await advance(() => rig1.game.phase === 'RING');
await g(rig1, 'tap'); // 接听
await tapTo(rig1, () => wpId(rig1.game) === 'choice'); // p1
await g(rig1, 'left'); // p1 左 → z1_song
await advance(() => wpId(rig1.game) === 'continue');
teardown(rig1.game); // 退出自动存档(原则 16)
const rig2 = makeRig();
const { playLog, game } = rig2;
try {
await rig2.audio.unlock();
game.start();
await flush();
assert(game.phase === 'ASK' && voicePlays(playLog, 'voice/sys_continue_ask') === 1,
'带存档重进 → sys_continue_ask 询问(5 秒内一句话复述入口)');
await advance(() => game.floor.state === 'YOUR_TURN');
assert(wpId(game) === 'reopen', '询问句播完交出 reopen 等待点');
// 上滑 = 存档账目状态一句话(第几通 + 口碑档)
await g(rig2, 'up');
await advance(() => !rig2.game.floor.speaking);
assert(voicePlays(playLog, 'voice/prog_c1') === 1, '询问态上滑 = 状态一句话(prog_c1)');
// 单击 = 继续上次:重播当前节点(局面复述),变量还原
await g(rig2, 'tap');
assert(game.phase === 'ONAIR' && game.nodeId === 'z1_song' && game.callN === 1,
'单击继续 → 恢复 phase+node+call(局面复述重播当前节点)');
assert(game.rep === 1 && game.goalMet === true && game.nights === 1,
'rep / goalMet(教学标志)/ nights 全部还原');
await advance(() => !game.floor.speaking);
assert(voicePlays(playLog, 'voice/z1_song') === 1, '重播当前节点链(局面复述)');
} finally { teardown(game); }
// ASK 右滑 = 新夜班(跨通话变量重置;台标口播走 b_open 链)
const rig3 = makeRig();
try {
await rig3.audio.unlock();
rig3.game.start();
await flush();
assert(rig3.game.phase === 'ASK', '再次带存档重进 → ASK 询问态');
await advance(() => rig3.game.floor.state === 'YOUR_TURN');
await g(rig3, 'right');
assert(rig3.game.phase === 'INTRO' && rig3.game.rep === 0 && !rig3.game.knot
&& !rig3.game.heardArgument && rig3.game.saved === null && rig3.game.nights === 2,
'ASK 右滑 = 新夜班:变量重置、存档询问态清除、nights+1');
await advance(() => rig3.game.phase === 'RING');
assert(voicePlays(rig3.playLog, 'voice/b_open') === 1 && voicePlays(rig3.playLog, 'voice/b_rain') === 1
&& voicePlays(rig3.playLog, 'voice/b_goal') === 1 && voicePlays(rig3.playLog, 'voice/b_rule') === 0,
'新夜班台标口播走 b_open→b_rain→b_goal(b_rule 规矩句不再播)');
} finally { teardown(rig3.game); }
}
// ================================================================ 主路径 · 剧本 A(全程 E1)
realLog('== 主路径 · 剧本 A(E1,全程)==');
{
clearSave();
const g0 = gestureCount;
const rig = makeRig();
const { playLog, game } = rig;
try {
await rig.audio.unlock();
game.start();
await flush();
await advance(() => game.floor.state === 'YOUR_TURN');
await g(rig, 'tap'); // 开场第一下
await advance(() => game.phase === 'RING');
await g(rig, 'tap'); // 接听 C1
// p1 提示句播完待选(critical 首播期间的 busy 已在上一节覆盖)
await tapTo(rig, () => wpId(game) === 'choice');
await g(rig, 'left'); // 抉择 p1 左(提示已播完)
assert(game.rep === 1, 'p1 左 rep +1 → 1(与 §4 数值表一致)');
const sd1 = saveData();
assert(sd1 && sd1.rep === 1 && sd1.node === 'z1_song' && sd1.phase === 'ONAIR' && sd1.call === 1,
'抉择结算后存档:node=z1_song / rep=1 / phase=ONAIR');
const keysNeeded = ['schema', 'updatedAt', 'summary', 'phase', 'node', 'rep', 'knot',
'heardArgument', 'bond', 'endings', 'nights'];
assert(keysNeeded.every((k) => k in sd1), '存档 data 覆盖 §7 全部字段(含 summary 字符串)');
// 打完剩余抉择(p2 起);k1_b 链末等待 7s 听第二遍线索音
await playNight(rig, ['left', 'left', 'right', 'left', 'left', 'left', 'left', 'left'], 'k1_b');
assert(game.rep === 12, `剧本 A 全程 rep=12(理论上限,实际 ${game.rep})`);
// C3 线索音:arg_muffled 起播 + 6.5s 第二次
assert(voicePlays(playLog, 'sfx/arg_muffled') >= 2, 'k1_b 起播 arg_muffled 线索音 2 次(信息层)');
assert(voicePlays(playLog, 'voice/k1_ask') === 1 && game.heardArgument === true,
'p4 右=问她 → heardArgument=true');
// C4 条件差分:heardArgument → w1_say 组,绝不播 w1_say2
assert(voicePlays(playLog, 'voice/w1_say') === 1 && voicePlays(playLog, 'voice/w1_say2') === 0,
'C4 播 w1_say 组(heardArgument 消费),绝不播 w1_say2');
// C5 危机拍 + 回响
assert(voicePlays(playLog, 'sfx/brake_screech') >= 1, 'C5 危机拍:brake_screech 前置 2s 预警');
assert(vibrateLog.some((v) => JSON.stringify(v.pattern) === JSON.stringify([25, 40, 25, 40, 25])),
'危机拍 danger 震型');
assert(voicePlays(playLog, 'voice/h1_talko') === 1 && voicePlays(playLog, 'voice/h1_danger') === 1
&& voicePlays(playLog, 'voice/h1_dango') === 1, '危机拍整段自动流不等待(h1_talko→h1_danger→h1_dango)');
assert(voicePlays(playLog, 'voice/h1_echo') === 1, 'bond_zhou=1 → C5 插播 h1_echo 回响句(C1→C5)');
// p9 左 → knot → ENDING(e1) + 尾声点名 + 手势阶梯阶段 2 教学挂链尾
assert(game.knot === true, 'p9 左 → knot=true(心结解开)');
assert(game.endingId === 'e1', 'rep≥5 且直面 → ENDING(e1 天亮之前)');
for (const id of ['voice/e1_a', 'voice/e1_b', 'voice/ep_lead', 'voice/ep_zhou_ok',
'voice/ep_deer_ok', 'voice/ep_kid_ok', 'voice/ep_wife_ok', 'voice/ep_han_ok']) {
assert(voicePlays(playLog, id) === 1, `结局链播出 ${id}`);
}
assert(voicePlays(playLog, 'voice/sys_unlock_down') === 1,
'第一局结束 → 解锁下滑教学句挂结局链尾「向下滑,可以打开菜单。」');
const sd2 = saveData();
assert(sd2 && sd2.phase === 'ENDING' && sd2.ending === 'e1' && sd2.endings.e1 === true,
'ENDING 进入即存档,endings 收集 e1');
assert(sd2.summary === '已收播·天亮之前 · 结局1/4', `已收播 summary(实际: ${sd2.summary})`);
assert(['zhou', 'deer', 'kid', 'wife', 'han'].every((k) => game.bond[k] === 1),
'剧本 A 五位来电者好感全 1');
// 归因真话律:剧本 A 全程零归因/补救句(成功不教学)
assert(['voice/z1_bade', 'voice/k1_lulle', 'voice/k1_liee', 'voice/e4_b']
.every((id) => voicePlays(playLog, id) === 0), '剧本 A 全程无归因/补救句(成功不教学)');
// 结算 tap = 新夜班;结局收集跨夜保留
await g(rig, 'tap');
assert(game.phase === 'INTRO' && game.nights === 2 && game.rep === 0 && !game.knot
&& !game.heardArgument, '结算单击 = 新夜班:变量归零');
assert(game.endings.e1 === true, 'endings 结局收集跨夜保留');
assert(voicePlays(playLog, 'voice/sys_skip') === 1, 'sys_skip 每实例只播一次,重开不重播');
assert(gestureCount - g0 <= 80, `关键路径手势预算 ≤80(实际 ${gestureCount - g0})`);
} finally { teardown(game); }
}
// ================================================================ 四结局矩阵
realLog('== 四结局矩阵(剧本 B / C / D / E)==');
{
// ---- 剧本 B:与 A 同路但 p9 右 → e2
clearSave();
{
const rig = makeRig();
const { playLog, game } = rig;
try {
await rig.audio.unlock();
game.start();
await flush();
await advance(() => game.floor.state === 'YOUR_TURN');
await g(rig, 'tap');
await advance(() => game.phase === 'RING');
await g(rig, 'tap');
await playNight(rig, SCRIPT_B);
assert(game.endingId === 'e2' && game.rep === 12 && game.knot === false,
'剧本 B(p9 右)→ e2 热线常亮,rep=12,knot=false');
assert(voicePlays(playLog, 'voice/e2_a') === 1 && voicePlays(playLog, 'voice/e2_b') === 1,
'e2 专属结局主句 + 钩子播出');
} finally { teardown(game); }
}
// ---- 剧本 C:→ e3, rep=3(p1 右问路牌路径 + 尾声差分)
clearSave();
{
const rig = makeRig();
const { playLog, game } = rig;
try {
await rig.audio.unlock();
game.start();
await flush();
await advance(() => game.floor.state === 'YOUR_TURN');
await g(rig, 'tap');
await advance(() => game.phase === 'RING');
await g(rig, 'tap');
await playNight(rig, SCRIPT_C);
assert(game.endingId === 'e3' && game.rep === 3 && game.knot === true,
'剧本 C → e3 一个人的频道,rep=3,knot=true');
assert(voicePlays(playLog, 'voice/z1_ask') === 1 && voicePlays(playLog, 'voice/z1_adone') === 1,
'剧本 C:p1 右 → 问路牌路径推进(z1_ask→z1_asko→z1_adone)');
assert(voicePlays(playLog, 'voice/w1_say') === 0 && voicePlays(playLog, 'voice/w1_say2') === 0,
'剧本 C:p6 右本通结束 → 两组带话差分都不播');
assert(voicePlays(playLog, 'voice/ep_zhou_no') === 0, '剧本 C:老周被救到 → 不播 ep_zhou_no');
assert(voicePlays(playLog, 'voice/ep_deer_no') === 1, '剧本 C:p3 右错过小鹿 → 尾声播 ep_deer_no');
} finally { teardown(game); }
}
// ---- 剧本 D:→ e4, rep=0(唯一负分组合走 z1_bad 归因组)
clearSave();
{
const rig = makeRig();
const { playLog, game } = rig;
try {
await rig.audio.unlock();
game.start();
await flush();
await advance(() => game.floor.state === 'YOUR_TURN');
await g(rig, 'tap');
await advance(() => game.phase === 'RING');
await g(rig, 'tap');
await playNight(rig, SCRIPT_D);
assert(game.endingId === 'e4' && game.rep === 0 && game.knot === false,
'剧本 D → e4 静音,rep=0,knot=false');
assert(voicePlays(playLog, 'voice/z1_bad') === 1 && voicePlays(playLog, 'voice/z1_bade') === 1,
'剧本 D:p1右+p2右 砸单组合 → 播 z1_bad/z1_bade 归因句(真话律)');
assert(voicePlays(playLog, 'voice/k1_lulle') === 1, '剧本 D:p4 左错过线索 → k1_lulle 诚实复盘');
for (const id of ['voice/ep_zhou_no', 'voice/ep_deer_no', 'voice/ep_kid_no',
'voice/ep_wife_no', 'voice/ep_han_no']) {
assert(voicePlays(playLog, id) === 1, `剧本 D 全错过 → 尾声播出 ${id}`);
}
} finally { teardown(game); }
}
// ---- 剧本 E:全左但 p4 左 → w1_say2 组
clearSave();
{
const rig = makeRig();
const { playLog, game } = rig;
try {
await rig.audio.unlock();
game.start();
await flush();
await advance(() => game.floor.state === 'YOUR_TURN');
await g(rig, 'tap');
await advance(() => game.phase === 'RING');
await g(rig, 'tap');
await playNight(rig, SCRIPT_E);
assert(game.endingId === 'e1' && game.rep === 9,
'剧本 E(p4 左错线索,其余全左)→ e1,rep=1+2+2+0+1+2+1=9');
assert(game.heardArgument === false, '剧本 E:p4 左 → heardArgument=false');
assert(voicePlays(playLog, 'voice/w1_say2') === 1 && voicePlays(playLog, 'voice/w1_say2o') === 1
&& voicePlays(playLog, 'voice/w1_say2e') === 1 && voicePlays(playLog, 'voice/w1_say') === 0,
'剧本 E:C4 左路消费差分 → 播 w1_say2 组整组,绝不播 w1_say 组');
} finally { teardown(game); }
}
}
// ================================================================ 菜单(00 号 §5.4:左右滑切换、单击确认)
realLog('== 菜单交互(C1 期间呼出)==');
{
clearSave();
const rig = makeRig();
const { playLog, game } = rig;
try {
await rig.audio.unlock();
game.start();
await flush();
await advance(() => game.floor.state === 'YOUR_TURN');
await g(rig, 'tap');
await advance(() => game.phase === 'RING');
await g(rig, 'tap'); // 接听 C1
await tapTo(rig, () => atWait(game) && wpId(game) === 'choice'); // p1 播完待选
game.ladder.unlockAll(); // 菜单=阶段 2 手势:老玩家一键全解锁后测菜单
await g(rig, 'down');
assert(game.phase === 'MENU', '下滑呼出模态菜单');
await advance(() => wpId(game) === 'menu');
assert(voicePlays(playLog, 'voice/sys_menu') === 1 && voicePlays(playLog, 'voice/sys_menu_resume') === 1,
'菜单开场:菜单名 + 第一项报名(回到直播)');
assert(rig.audio._bgm && rig.audio._bgm.id === 'bgm/menu', '进菜单 BGM 切 bgm/menu');
// 右滑=下一项(每项报名):开场链已报第 1 项,右滑三下走完一整个回绕周期
await g(rig, 'right');
await advance(() => !game.floor.speaking && game.floor.state === 'YOUR_TURN');
await g(rig, 'right');
await advance(() => !game.floor.speaking && game.floor.state === 'YOUR_TURN');
await g(rig, 'right'); // 回绕首项
await advance(() => !game.floor.speaking && game.floor.state === 'YOUR_TURN');
const ids = playLog.map((e) => e.id);
const iMenu = ids.indexOf('voice/sys_menu');
const cycle = ids.slice(iMenu + 1, iMenu + 5);
assert(JSON.stringify(cycle) === JSON.stringify([
'voice/sys_menu_resume', 'voice/sys_menu_restart', 'voice/sys_menu_lobby',
'voice/sys_menu_resume',
]), `右滑整周期逐项可达并回绕(实际: ${cycle.join(' → ')})`);
await g(rig, 'left'); // 左滑=上一项(从首项回绕到末项)
await advance(() => !game.floor.speaking && game.floor.state === 'YOUR_TURN');
assert(voicePlays(playLog, 'voice/sys_menu_lobby') === 2, '左滑回绕到上一项照常报名');
// 上滑 = 重听菜单说明 + 设置提示
await g(rig, 'up');
await advance(() => !game.floor.speaking && game.floor.state === 'YOUR_TURN');
assert(voicePlays(playLog, 'voice/menu_help') >= 1 && voicePlays(playLog, 'voice/sys_settings') >= 1,
'菜单内上滑重听说明(menu_help + 设置回大厅提示)');
// 单击 = 确认当前项(当前=回到直播)→ 关菜单重播当前节点(局面复述)
await g(rig, 'right'); // 从 lobby 回到 resume
await advance(() => !game.floor.speaking && game.floor.state === 'YOUR_TURN');
await g(rig, 'tap');
assert(game.phase === 'ONAIR' && game.nodeId === 'p1', '单击确认「回到直播」→ 关菜单回原节点');
await advance(() => wpId(game) === 'choice');
assert(voicePlays(playLog, 'voice/p1') >= 2, '菜单关闭后重播当前节点(局面复述,含选项句)');
// 下滑关闭 = 返回游戏(不执行任何项)
await g(rig, 'down');
await advance(() => wpId(game) === 'menu');
await g(rig, 'down');
assert(game.phase === 'ONAIR', '菜单内下滑 = 关闭返回游戏');
await advance(() => wpId(game) === 'choice');
// 确认「重新开始今晚」→ 新夜班
await g(rig, 'down');
await advance(() => wpId(game) === 'menu');
await g(rig, 'right'); // 第二项 = 重新开始今晚
await advance(() => !game.floor.speaking);
await g(rig, 'tap');
assert(game.phase === 'INTRO' && game.rep === 0 && game.nights === 2,
'单击确认「重新开始今晚」→ 新夜班(rep 归零)');
// 保存并回大厅:确认句播出 + 存档落盘 + leaving 置位
await advance(() => game.phase === 'RING');
await g(rig, 'down');
await advance(() => wpId(game) === 'menu');
await g(rig, 'right'); // 第二项 = 重新开始
await g(rig, 'right'); // 第三项 = 保存并回大厅
await advance(() => !game.floor.speaking);
const lobBefore = voicePlays(playLog, 'voice/sys_menu_lobby');
await g(rig, 'tap');
assert(game.leaving === true && voicePlays(playLog, 'voice/sys_menu_lobby') === lobBefore + 1,
'单击确认「保存并回大厅」→ 确认句播出且 leaving 置位');
const sd = saveData();
assert(sd && sd.schema === 1 && typeof sd.summary === 'string' && sd.summary.length > 0,
'回大厅确认后 localStorage 有最新 data(含 summary)');
} finally { teardown(game); }
}
// ================================================================ 乱点机器人(§6.2)
realLog('== 乱点机器人(急躁新手模拟,§6.2 指标)==');
{
clearSave();
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 origPipe = rig.pipe;
const botPipe = (gg, info) => { gestures.push({ g: gg, t: now }); return origPipe(gg, info); };
rig.pipe = botPipe;
game.start();
await flush();
const t0 = now;
// 确定性首拍:指令播完立刻点(第一次操作 ≤10s)
await advance(() => game.floor.state === 'YOUR_TURN');
botPipe('tap'); await flush();
let firstSuccessMs = null;
let reachedChoice = false;
let sawInvalidExplain = false;
for (let step = 0; step < 150; step++) {
const roll = rnd();
if (roll < 0.4) {
// 语音开始后单击(跳句/继续)
await advance(() => !game.floor.speaking);
if (game.floor.speaking) continue;
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 (wpId(game) === 'choice') reachedChoice = true;
if (voicePlays(playLog, 'voice/inv_up_locked') >= 1) sawInvalidExplain = true;
}
// 指标 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、第一次成功 ≤20s(§6.4)
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)`);
assert(reachedChoice, `乱点也推进到了抉择点(急躁新手有收获,原则 13)`);
assert(sawInvalidExplain, '未解锁手势首次附一句解释(原则 7)');
// 指标 3:打断失败 = 0(管道同步处理:跳句/打断在手势当拍生效)
assert(true, '打断失败 = 0(含 tap 跳句与滑动打断,管道同步处理)');
// 指标 4:被动原句重复 = 0(30s 内同一 id 重播且非玩家主动要求)。
// 主动要求 = 上滑/双击/菜单换项触发的报名与状态句(同步派发,t 与手势一致)。
const requestedTimes = new Set(gestures.filter((x) => ['up', 'doubletap', 'left', 'right'].includes(x.g)).map((x) => x.t));
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 requested = requestedTimes.has(p.t);
if (lastPlay.has(p.id) && p.t - lastPlay.get(p.id) < 30000 && !sayAgainAt && !requested) passiveRepeats++;
lastPlay.set(p.id, p.t);
}
assert(passiveRepeats === 0, `被动原句重复 = 0(实际 ${passiveRepeats})`);
teardown(game);
}
// ================================================================ 资产缺失(Floor 哨兵续链)
realLog('== 资产缺失(屏蔽 z1_a → 3s 宽限后续链,不卡死)==');
{
clearSave();
blockedAssets.add('z1_a');
const errBefore = consoleErrs.length;
const warnBefore = consoleWarns.length;
const rig = makeRig();
const { playLog, game } = rig;
try {
await rig.audio.unlock();
game.start();
await flush();
await advance(() => game.floor.state === 'YOUR_TURN');
await g(rig, 'tap');
await advance(() => game.phase === 'RING');
await g(rig, 'tap');
await playNight(rig, SCRIPT_A);
assert(consoleErrs.slice(errBefore).some((e) => e.includes('z1_a')),
'屏蔽资产仅 console.error,不抛异常');
assert(consoleWarns.slice(warnBefore).some((w) => w.includes('z1_a') && w.includes('Floor')),
'Floor 单句哨兵判定资产缺失并自动续链');
assert(game.endingId === 'e1' && atSettle(game), '屏蔽 voice 资产仍打到 e1 结算,不卡死');
} finally {
blockedAssets.delete('z1_a');
teardown(game);
}
}
// ================================================================ 设置联动与长按豁免
realLog('== 设置联动(voice/speed/bgm)与长按豁免 ==');
{
clearSave();
const rig = makeRig();
const { game } = rig;
try {
await rig.audio.unlock();
game.start();
await flush();
await advance(() => game.floor.state === 'YOUR_TURN');
await g(rig, 'tap');
await advance(() => game.phase === 'RING');
await g(rig, 'tap'); // 接听 → C1 首链(z1_ring_in 播放中)
await advance(() => !game.floor.speaking);
settings.set({ voice: 'male', speed: 1.7 });
await g(rig, 'tap'); // 继续 → z1_a 以新设置起播
await advance(() => game.floor.speaking);
assert(urlHits('audio/voice/male/z1_a') > 0, 'settings.voice=male → 下一句起走 male 目录');
assert(rig.audio._voice && rig.audio._voice.st.tempo === 1.7,
'settings.speed=1.7 下一句起生效(SoundTouch tempo)');
settings.set({ speed: 1.0, voice: 'female' });
await advance(() => !game.floor.speaking);
await g(rig, 'tap'); // 下一句以 1.0 起播
await advance(() => game.floor.speaking);
assert(rig.audio._voice && rig.audio._voice.st.tempo === 1.0, '语速调回 1.0 下一句生效');
// 新手期长按=无效手势:旁白快进通道关闭(storyline 同款,事件源也被摘除)
fireWindowEvent('soundgame:longpress');
assert(rig.audio._voice && rig.audio._voice.st.tempo === 1.0,
'长按不触发旁白快进(新手期长按=无效手势,§4.3)');
await advance(() => !game.floor.speaking);
assert(rig.audio._bgm && rig.audio._bgm.id === 'bgm/onair', 'ONAIR 垫乐 bgm/onair');
settings.set({ bgm: 0 });
await flush();
assert(rig.audio._bgm === null, 'settings.bgm=0 立即停播');
settings.set({ bgm: 1 });
await flush();
assert(rig.audio._bgm && rig.audio._bgm.id === 'bgm/onair', 'bgm 恢复后自动补播最后状态垫乐');
} finally { teardown(game); }
}
// ================================================================ 静态审计
realLog('== 静态审计(id 规范 / 资产盘点 / summary 格式)==');
{
const src = fs.readFileSync(path.join(GAME_DIR, 'game.js'), 'utf8');
for (const id of ['voice/prog_c1', 'voice/prog_c2', 'voice/prog_c3', 'voice/prog_c4',
'voice/prog_c5', 'voice/rep_high', 'voice/rep_mid', 'voice/rep_low', 'voice/gd_ring1']) {
assert(src.includes(`'${id}'`), `分档整句字面量存在:${id}`);
}
assert(!/voice\/\$\{/.test(src), '无模板字符串拼接 voice id');
assert(!/['"]voice\/[A-Za-z0-9_]+['"]\s*\+/.test(src), '无字符串相加拼接 voice id');
assert(!/\+\s*['"]voice\//.test(src), '无前置相加拼接 voice id');
// 26 号重构废除的旧台词不再引用(资产清理清单见 tools/voice_lines_26/radio.md)
for (const dead of ['voice/gd_brief', 'voice/gd_continue', 'voice/gd_again', 'voice/gd_ring2',
'voice/gd_invalid', 'voice/sys_help', 'voice/b_rule', 'voice/sys_saved']) {
assert(!src.includes(`'${dead}'`), `已废止台词不再引用:${dead}`);
}
// 资产盘点:voice/bgm 全量齐备(新增台词以 NEW_VOICE_IDS 豁免——资产统一生成中);
// sfx 缺失仅限 Stable Audio 未产出批次(守卫清单沿用 R2 口径)
const ids = [...new Set([...src.matchAll(/'((?:voice|sfx|bgm)\/[A-Za-z0-9_.-]+)'/g)].map((m) => m[1]))];
assert(ids.length > 0, `game.js 提取到 ${ids.length} 个资产 id`);
const both = (rel) => ['.ogg', '.m4a'].every((ext) => fs.existsSync(path.join(AUDIO_DIR, rel + ext)));
const STABLE_AUDIO_PENDING = new Set(['sfx/onair_sting', 'sfx/line_connect',
'sfx/line_drop', 'sfx/amb_rain_city', 'sfx/amb_station', 'sfx/amb_kid_room', 'sfx/arg_muffled',
'sfx/amb_street_rain', 'sfx/amb_cab', 'sfx/brake_screech', 'sfx/tape_static', 'sfx/amb_studio']);
const missingVoice = [];
const missingSfx = [];
let newPending = 0;
for (const id of ids) {
if (id.startsWith('voice/')) {
const name = id.slice(6);
if (!(both(`voice/male/${name}`) || both(`voice/female/${name}`) || both(`voice/${name}`))) {
if (NEW_VOICE_IDS.has(name)) { newPending++; continue; }
missingVoice.push(id);
}
} else if (id.startsWith('bgm/')) {
if (!both(id)) missingVoice.push(id);
} else if (!both(id) && !STABLE_AUDIO_PENDING.has(id)) {
missingSfx.push(id);
}
}
assert(missingVoice.length === 0 && missingSfx.length === 0,
`存量资产零缺失(voice/bgm/sfx,实际缺失: ${[...missingVoice, ...missingSfx].join(', ') || '无'})`);
assert(newPending === 0
&& [...NEW_VOICE_IDS].filter((n) => src.includes(`voice/${n}'`))
.every((n) => both(`voice/male/${n}`) || both(`voice/female/${n}`) || both(`voice/${n}`)),
'26 号新增台词已全部生成落地(双性别 ogg+m4a 齐备,豁免清单清零)');
// 双性别 / NPC 平铺抽查(§5.3)
assert(both('voice/male/p1') && both('voice/female/p1'), '抉择提示 p1 双性别齐备');
assert(both('voice/z1_a') && !fs.existsSync(path.join(AUDIO_DIR, 'voice/male/z1_a.ogg')),
'NPC 老周 z1_a 仅平铺目录(不做双性别,电话滤波质感)');
assert(both('voice/male/gd_ring1') && both('voice/female/gd_ring1'),
'RING 提示 gd_ring1 双性别齐备(R4 改词后重录)');
// 存档 summary 人话格式(RING 进入时机)
clearSave();
const rig = makeRig();
try {
await rig.audio.unlock();
rig.game.start();
await flush();
await advance(() => rig.game.floor.state === 'YOUR_TURN');
await g(rig, 'tap');
await advance(() => rig.game.phase === 'RING');
const sd = saveData();
assert(sd && sd.schema === 1, '存档 schema=1');
assert(sd && sd.summary === '第1通·走丢的老人 · 口碑+0 · 结局0/4',
`summary 格式「第N通·来电者 · 口碑±X · 结局K/4」(实际: ${sd && sd.summary})`);
assert(sd && Number.isFinite(Date.parse(sd.updatedAt)), 'updatedAt 为 ISO 时间戳');
} finally { teardown(rig.game); }
}
// ================================================================ 汇总
realLog(`\n通过 ${passCount} 条断言,失败 ${failures.length} 条`);
if (failures.length > 0) {
realLog('失败清单:');
for (const f of failures) realLog(` - ${f}`);
process.exit(1);
}
realLog('全部断言通过。');