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 置完成。
1191 lines
57 KiB
JavaScript
1191 lines
57 KiB
JavaScript
/**
|
||
* 《不眠频率》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('全部断言通过。');
|