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 置完成。
1479 lines
75 KiB
JavaScript
1479 lines
75 KiB
JavaScript
/**
|
||
* 《深海回音》R3 无头试玩仿真测试(26 号 playbook 深度重构版)。
|
||
*
|
||
* 方案:
|
||
* - mock 浏览器环境:window/localStorage/navigator/fetch/AudioContext
|
||
* (decodeAudioData 返回固定 2.0s 时长的假 buffer,语音链靠引擎 endTimer +
|
||
* onComplete / Floor 轮询推进,虚拟时钟驱动,不真实等待);缺失 voice 资产
|
||
* 按可用处理(流程测试与 TTS 资产生成解耦,资产品质由文末「资产引用校验」
|
||
* 单独把关,新台词 id 允许待生成);强制 404(blockedAssets)走 Floor 单句
|
||
* 看门狗自动续链(引擎 §4.1 缺失资产兜底)。
|
||
* - 使用**真实** engine/audio.js + settings.js + pipeline.js + floor/hints/
|
||
* confusion/novice/receipts 全六模块;手势走 makeInputPipeline(与
|
||
* index.html 同构,doubleTapWindow=0 语义由管道内折算等价覆盖)。
|
||
* - 虚拟时钟:patch setTimeout/clearTimeout/performance.now;环境音循环为
|
||
* buffer.loop 句柄(无重触发定时器),全程可空闲、可复现。
|
||
*
|
||
* 覆盖(17 号附录 C §10 + 26 号 §6):
|
||
* - §4.10 首 3 分钟:3 秒内第一次操作、第一次成功(ok 回执)、开场规则念白废除;
|
||
* - 玩法核心:剧本 A~F 六条路径到 4 结局(氧气逐检查点验算 §4)、氧气门三形态、
|
||
* DIVER/DECK/NPC 台词差分、尾声回响选播、存档 schema/summary;
|
||
* - pendingChoice(V1 时序:旁白滑动缓存 → 选项句播完才生效,零插播);
|
||
* - sonarGate 仲裁(危机拍前置 / 空闲门 / 零重叠)、结算死窗(critical 首句)、
|
||
* 搜索等待(单击=脉冲 / amb+余音替代合成等待声);
|
||
* - §4.4 三档提示(ping 8/18、continue 13/30、choice 25→short 台账降档);
|
||
* §4.5 迷茫接管三级(L1 短句 → L2 缩小 → L3 替发脉冲);
|
||
* §4.3 手势阶梯(首脉冲→上滑、首结局→菜单、教学句挂链尾);
|
||
* §4.12 断点复述(F4 恢复首句锁);
|
||
* - §6.2 乱点机器人:无声操作 0 / 被动原句重复 0 / 打断失败 0 / 首操作 ≤10s /
|
||
* 首成功 ≤20s;菜单(左右滑换项 + 单击确认 + 二次确认重开)。
|
||
*
|
||
* 运行:tools/node/bin/node web/abyss/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.abyss';
|
||
const LADDER_KEY = 'soundgame.gestures';
|
||
|
||
// ---------------------------------------------------------------- 断言收集
|
||
|
||
const realLog = console.log.bind(console);
|
||
let passCount = 0;
|
||
const failures = [];
|
||
function assert(cond, msg) {
|
||
if (cond) { passCount++; realLog(` ✓ ${msg}`); }
|
||
else { failures.push(msg); realLog(` ✗ ${msg}`); }
|
||
}
|
||
|
||
// 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 < 80000; 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('虚拟时钟定时器超上限(疑似死循环)');
|
||
}
|
||
const tick = (ms) => { now += ms; };
|
||
|
||
// ---------------------------------------------------------------- 浏览器 mock
|
||
|
||
const winListeners = new Map();
|
||
globalThis.window = {
|
||
addEventListener: (type, fn) => {
|
||
if (!winListeners.has(type)) winListeners.set(type, []);
|
||
winListeners.get(type).push(fn);
|
||
},
|
||
AudioContext: null, // 下方赋值
|
||
};
|
||
|
||
const storageData = new Map();
|
||
globalThis.localStorage = {
|
||
getItem: (k) => (storageData.has(k) ? storageData.get(k) : null),
|
||
setItem: (k, v) => { storageData.set(k, String(v)); },
|
||
removeItem: (k) => { storageData.delete(k); },
|
||
};
|
||
|
||
const vibrateLog = [];
|
||
Object.defineProperty(globalThis, 'navigator', {
|
||
// 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:读真实资产目录;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: 'abyss' }) };
|
||
}
|
||
return { ok: false, status: 401, json: async () => ({ error: 'unauthorized' }) };
|
||
}
|
||
const blocked = [...blockedAssets].some((b) => s.includes(b));
|
||
if (s.includes('/voice/') && !blocked) {
|
||
// 缺失 voice 资产按可用处理(统一 2s 句长)——流程测试与资产生成解耦
|
||
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 { AbyssGame } = await import('../game.js');
|
||
|
||
function resetSettings(guide) {
|
||
settings.set({ guide, voice: 'female', speed: 1.0, bgm: 1.0, input: 'touch' });
|
||
}
|
||
resetSettings('off');
|
||
|
||
function clearSave() { storageData.delete(SAVE_KEY); }
|
||
function saveData() {
|
||
const raw = storageData.get(SAVE_KEY);
|
||
if (!raw) return null;
|
||
try { return JSON.parse(raw); } catch (_) { return null; }
|
||
}
|
||
|
||
const voicePlays = (playLog, id) => playLog.filter((e) => e.id === id).length;
|
||
const lastIndexOf = (playLog, id) => playLog.map((e) => e.id).lastIndexOf(id);
|
||
const urlHits = (frag) => requestedUrls.filter((u) => u.includes(frag)).length;
|
||
const WARN_PATTERN = JSON.stringify([30, 50, 30, 50, 30]); // warning 震型
|
||
const DANGER_PATTERN = JSON.stringify([25, 40, 25, 40, 25]); // danger 震型
|
||
|
||
/** 声呐信息音与语音零重叠校验(§5 sonarGate 排队语义)。 */
|
||
function sonarVoiceOverlaps(game) {
|
||
const bad = [];
|
||
for (const sp of game._sonarPlays) {
|
||
for (const rec of game._voiceIntervals) {
|
||
if (rec.start <= sp.t && sp.t < rec.end) bad.push(`${sp.id}@${sp.t}ms 撞 ${rec.id}`);
|
||
}
|
||
}
|
||
return bad;
|
||
}
|
||
|
||
function makeRig() {
|
||
const audio = new WebAudioEngine('audio/');
|
||
const playLog = [];
|
||
const origPlay = audio.play.bind(audio);
|
||
audio.play = (id, opts) => { playLog.push({ id, t: now, opts }); return origPlay(id, opts); };
|
||
const game = new AbyssGame(audio, {});
|
||
// 回执记录(§6.2 无声操作指标的探针)
|
||
const receiptLog = [];
|
||
const origReceipt = game.receipts.play.bind(game.receipts);
|
||
game.receipts.play = (kind) => { receiptLog.push({ kind, t: now }); return origReceipt(kind); };
|
||
// 管道(与 index.html 同构);bed 关掉防周期定时器不排水——游戏层 _yieldTurn
|
||
// 会按等待点翻转 bedEnabled,测试侧统一压为 false(真实 bed 听感由真实页验证)
|
||
game.floor.bedEnabled = false;
|
||
const origYield = game._yieldTurn.bind(game);
|
||
game._yieldTurn = (wp) => { game.floor.bedEnabled = false; game.floor.yieldTurn(wp); };
|
||
void origYield;
|
||
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 };
|
||
}
|
||
|
||
async function act(rig, gesture) {
|
||
rig.pipe(gesture);
|
||
await flush();
|
||
}
|
||
|
||
/** 结束一个测试块:作废在途链,避免污染后续块的虚拟时钟。 */
|
||
function teardown(rig) {
|
||
rig.game.stop();
|
||
}
|
||
|
||
// ---- 抉择脚本(p1..p9 遭遇顺序;剧本 A-E + 补剧本 F,17 号 §3 抉择树)
|
||
// A:全右 + p9 左 → e1(saved=5)
|
||
const SCRIPT_A = ['right', 'right', 'right', 'right', 'right', 'right', 'right', 'right', 'left'];
|
||
// B:同 A 但 p9 右 → e2
|
||
const SCRIPT_B = [...SCRIPT_A.slice(0, 8), 'right'];
|
||
// C:p1左 p2左 p4左,余右 → 氧气门走终局取消链 → p9 左(oxy=2≥1)→ e3(saved=3)
|
||
const SCRIPT_C = ['left', 'left', 'right', 'left', 'right', 'right', 'left'];
|
||
// D:p1左 p4左,余右 → 单载(stayer=chen → p7_chen)→ p7 左=带船长 → chen_last
|
||
// → p8 右 → oxy_end=0 → p9 左封锁(g9_dry 自动右)→ e4(saved=4)
|
||
const SCRIPT_D = ['left', 'right', 'right', 'left', 'right', 'right', 'left', 'right', 'left'];
|
||
// E:p2左 p3[无knowsPipe]左 p4左 p6左 → mid-dive floor 至 0 干涸链 → e4(saved=3)
|
||
const SCRIPT_E = ['right', 'left', 'left', 'left', 'right', 'left', 'left'];
|
||
// F:p1左 p4左 p5左,余右 → 单载(stayer=zheng → p7_zheng)→ p7 右=带小郑
|
||
// → gao_last、p8 跳过 → p9 左封锁 → e4(saved=4)
|
||
const SCRIPT_F = ['left', 'right', 'right', 'left', 'left', 'right', 'right', 'left'];
|
||
|
||
/**
|
||
* 救援推进器:语音在播时等它自然播完(不跳句);搜索等待单击发脉冲;抉择武装
|
||
* 即选;链末等待态单击继续——直到 ENDING 结算态(或 stop() 条件命中,返回
|
||
* 已消耗的抉择数,供续跑)。
|
||
*/
|
||
async function playRun(rig, choices, opts = {}) {
|
||
const { game } = rig;
|
||
let i = opts.from || 0;
|
||
const stop = opts.stop || (() => false);
|
||
let guard = 0;
|
||
while (!(game.phase === 'ENDING' && game.awaitingRestart)) {
|
||
if (++guard > 900) throw new Error(`playRun 卡住(node=${game.nodeId} phase=${game.phase} vp=${game.voicePlaying} search=${game.searchArmed})`);
|
||
if (stop()) return i;
|
||
if (game.searchArmed) { await act(rig, 'tap'); continue; } // 单击=发脉冲
|
||
if (game.choiceArmed) {
|
||
const c = choices[i++];
|
||
if (!c) throw new Error(`playRun 抉择脚本耗尽(node=${game.nodeId})`);
|
||
await act(rig, c);
|
||
continue;
|
||
}
|
||
if (!game.voicePlaying) { await act(rig, 'tap'); continue; } // 链末=继续
|
||
await advance(() => stop() || !game.voicePlaying || game.choiceArmed || game.searchArmed);
|
||
}
|
||
return i;
|
||
}
|
||
|
||
/**
|
||
* 手动推进到(第 n 潜)搜索等待:链末逐节点单击、语音自然播完——纯 advance
|
||
* 不出手(虚拟时钟会停在链末等待态 idle,测试会走偏)。
|
||
*/
|
||
async function reachSearch(rig, n = 1) {
|
||
const { game } = rig;
|
||
let guard = 0;
|
||
while (!(game.searchArmed && game.diveN === n)) {
|
||
if (++guard > 300) throw new Error(`reachSearch 卡住(node=${game.nodeId} phase=${game.phase})`);
|
||
if (game.searchArmed) return; // 已在(更早的)搜索等待
|
||
if (game.choiceArmed) throw new Error('reachSearch 意外遇到抉择(node=' + game.nodeId + ')');
|
||
if (!game.voicePlaying) { await act(rig, 'tap'); continue; }
|
||
await advance(() => game.searchArmed || !game.voicePlaying);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 叙事链推进器:语音在播时等它自然播完(不跳句),链末等待态单击继续,
|
||
* 搜索等待单击发脉冲——直到 cond() 满足。遇到未应答抉择抛错(调用方先选)。
|
||
*/
|
||
async function tapTo(rig, cond, maxSteps = 90) {
|
||
const { game } = rig;
|
||
for (let i = 0; i < maxSteps; i++) {
|
||
if (cond()) return;
|
||
if (game.searchArmed) { await act(rig, 'tap'); continue; }
|
||
if (game.choiceArmed) throw new Error(`tapTo 遇到未应答的抉择(node=${game.nodeId})——调用方需先做选择`);
|
||
if (game.voicePlaying) {
|
||
await advance(() => cond() || !game.voicePlaying);
|
||
continue;
|
||
}
|
||
await act(rig, 'tap'); // 链末单击 = 继续
|
||
}
|
||
throw new Error(`tapTo 超步限(node=${game.nodeId} phase=${game.phase})`);
|
||
}
|
||
|
||
// ================================================================ 主路径 · 首 3 分钟 + 剧本 A
|
||
// (§4.10:3 秒内动手、第一次成功 ok 回执;§9 关键路径 e1,oxy 逐点与 §4 公式一致)
|
||
|
||
realLog('== 首 3 分钟 + 主路径 · 剧本 A(e1)==');
|
||
{
|
||
clearSave();
|
||
storageData.delete(LADDER_KEY); // 新手 persona(阶段 0:up/down/doubletap 未解锁)
|
||
resetSettings('off');
|
||
const g0 = { gestures: 0 };
|
||
const rig = makeRig();
|
||
const { audio, playLog, receiptLog, game, pipe } = rig;
|
||
const gestureCount = () => g0.gestures;
|
||
|
||
await audio.unlock();
|
||
game.start();
|
||
await flush();
|
||
assert(playLog.some((e) => e.id === 'bgm/deck'), '开局播放 bgm/deck(甲板,开局淡入)');
|
||
assert(game.phase === 'INTRO' && game.floor.speaking
|
||
&& game.currentVoiceId === 'voice/sys_first_tap',
|
||
'0~3 秒:开场第一句 = 「点一下屏幕。」(critical 指令句,第一句就让动手)');
|
||
assert(voicePlays(playLog, 'voice/b_rule') === 0 && voicePlays(playLog, 'voice/gd_brief') === 0,
|
||
'开场规则念白废除(b_rule / gd_brief 零播出,§4.10 砍规则念白)');
|
||
|
||
// 指令句 critical:抢拍单击 = busy 回执(不打断唯一一次教学)
|
||
await act(rig, 'tap');
|
||
assert(receiptLog.some((r) => r.kind === 'busy'), '指令播放中单击 = busy 回执(关键句不可打断)');
|
||
await advance(() => game.floor.state === 'YOUR_TURN');
|
||
const tOp = now;
|
||
await act(rig, 'tap'); // 第一次有效操作
|
||
g0.gestures += 2;
|
||
assert(receiptLog.some((r) => r.kind === 'ok'),
|
||
'第一次单击 100ms 内得到 ok 回执(第一次成功 ≤20s,§6.4)');
|
||
assert(tOp <= 10000, `第一次操作 ≤10s(虚拟 ${tOp}ms,§6.4)`);
|
||
assert(game.floor.speaking && voicePlays(playLog, 'voice/b_goal') === 1,
|
||
'单击后播极短目标句 b_goal(海燕号五人/双瓶四潜)');
|
||
|
||
// 链间不掉回「轮到你」听感:b_goal → sys_turn_teach → sys_skip 是一条链
|
||
await advance(() => playLog.some((e) => e.id === 'voice/sys_turn_teach'));
|
||
assert(game.floor.speaking && voicePlays(playLog, 'voice/sys_turn_teach') === 1,
|
||
'「听到这个声音,就是轮到你了。」状态教学在链上(原则 2)');
|
||
assert(audio._duckHolds > 0, '语音链期间持有音乐压低(holdDuck,§4.1)');
|
||
await advance(() => voicePlays(playLog, 'voice/sys_skip') === 1 && !game.voicePlaying);
|
||
assert(voicePlays(playLog, 'voice/sys_skip') === 1, 'sys_skip 开场每实例一次');
|
||
await advance(() => game.floor.state === 'YOUR_TURN');
|
||
|
||
// 防连点:tap「继续」后 400ms 内的第二下只给 busy(§4.2)
|
||
const busyBefore = receiptLog.filter((r) => r.kind === 'busy').length;
|
||
await act(rig, 'tap'); // → b_go(一次「继续」)
|
||
await act(rig, 'tap'); // 虚拟时钟未推进 = 400ms 内 → busy
|
||
g0.gestures += 2;
|
||
assert(receiptLog.filter((r) => r.kind === 'busy').length === busyBefore + 1,
|
||
'防连点:tap「继续」后 400ms 内的第二下只给 busy 回执');
|
||
await advance(() => game.floor.state === 'YOUR_TURN');
|
||
|
||
// 手势阶梯(阶段 0):未解锁 up / down → invalid 回执 + 首次解释(原则 7)
|
||
await act(rig, 'up');
|
||
g0.gestures += 1;
|
||
assert(receiptLog.some((r) => r.kind === 'invalid'), '未解锁上滑 → invalid 回执');
|
||
await advance(() => voicePlays(playLog, 'voice/inv_up_locked') === 1);
|
||
assert(voicePlays(playLog, 'voice/inv_up_locked') === 1, '首次无效附一句解释(inv_up_locked)');
|
||
const invCount = voicePlays(playLog, 'voice/inv_up_locked');
|
||
await advance(() => !game.voicePlaying);
|
||
await act(rig, 'up');
|
||
g0.gestures += 1;
|
||
await advance(() => !game.voicePlaying);
|
||
assert(voicePlays(playLog, 'voice/inv_up_locked') === invCount,
|
||
'第二次起只给回执,不再解释(原则 7)');
|
||
|
||
// b_go 链末单击 → DIVE(1):下潜起播扣 3(账目断言)+ 下潜链 auto → 搜索武装
|
||
await advance(() => game.floor.state === 'YOUR_TURN');
|
||
await act(rig, 'tap');
|
||
g0.gestures += 1;
|
||
assert(game.phase === 'DIVE' && game.diveN === 1 && game.oxy === 9,
|
||
'单击开始下潜 → DIVE(1),oxy 12-3=9(每次下潜开始扣 3,承诺)');
|
||
assert(playLog.some((e) => e.id === 'bgm/dive'), '进 DIVE crossfade bgm/dive');
|
||
await reachSearch(rig);
|
||
assert(game.searchArmed && game.nodeId === 'dv1_teach', '下潜链 auto → 搜索等待武装(dv1_down→dv1_teach)');
|
||
for (const id of ['voice/dv1_down', 'voice/dv1_teach']) {
|
||
assert(voicePlays(playLog, id) === 1, `下潜链播出 ${id}`);
|
||
}
|
||
// 搜索等待听感(17 号 §2):合成等待声让位给深海 amb + 余音(游戏层偏差实现)
|
||
assert(game.floor.state === 'YOUR_TURN' && game.floor.waitPoint
|
||
&& game.floor.waitPoint.conf.id === 'ping', '搜索等待 = YOUR_TURN,等待点 id=ping');
|
||
assert(game.floor.bedEnabled === false, '搜索等待静音合成等待声(amb_deep+回波余音替代,偏差记录)');
|
||
|
||
// dv1_teach 教学句 critical(§8 台词类别):tap → busy,不丢教学
|
||
// (已在链尾播完,这里验证的是后续回波链不可跳——声呐步=BUSY)
|
||
|
||
// 搜索单击 → sonar_ping → 静默一拍 → echo_metal_l → dv1_metal(承诺音)→ COMMS
|
||
const ping0 = voicePlays(playLog, 'sfx/sonar_ping');
|
||
await act(rig, 'tap');
|
||
g0.gestures += 1;
|
||
assert(voicePlays(playLog, 'sfx/sonar_ping') === ping0 + 1,
|
||
'搜索等待单击恒=发一枚声呐脉冲(silent-ok:声呐即内容音,非跳句、非无效)');
|
||
assert(game.ladder.stage() >= 1, '第一次脉冲成功 → 手势阶梯阶段 1(上滑解锁)');
|
||
await advance(() => game.phase === 'COMMS');
|
||
assert(voicePlays(playLog, 'sfx/echo_metal_l') === 1,
|
||
'回波链播出 echo_metal_l(金属回波=海燕号船身承诺音)');
|
||
for (const id of ['voice/dv1_metal', 'voice/dv1_link', 'voice/z1_a']) {
|
||
assert(voicePlays(playLog, id) === 1, `回波→接通→COMMS 链播出 ${id}`);
|
||
}
|
||
assert(playLog.some((e) => e.id === 'sfx/link_beep'), '电台接通 link_beep');
|
||
assert(playLog.some((e) => e.id === 'sfx/static_burst'), 'z1_a 垫 static_burst(电台杂音)');
|
||
assert(voicePlays(playLog, 'voice/sys_unlock_up') === 1,
|
||
'阶段 1 解锁教学句挂回波链尾(「想知道自己在哪,就向上滑」,先说一句马上用)');
|
||
|
||
// R3:语音在播时 amb 常驻声呐元素降 -6dB(duck 标记)
|
||
await tapTo(rig, () => game.currentVoiceId === 'voice/z1_b');
|
||
assert(game._ambDucked === true, 'R3:语音在播时 amb_deep 降 -6dB(duck 标记)');
|
||
// z1_knock 播完垫 pipe_knock 两遍(信息层,语音播完才响,sonarGate 零重叠)
|
||
await tapTo(rig, () => game.currentVoiceId === 'voice/z1_knock');
|
||
await tapTo(rig, () => game.choiceArmed);
|
||
const iKnockV = lastIndexOf(playLog, 'voice/z1_knock');
|
||
const knocks = playLog.filter((e) => e.id === 'sfx/pipe_knock' && e.t > 0)
|
||
.filter((e) => playLog.findIndex((x) => x === e) > iKnockV).length;
|
||
assert(knocks >= 2, `z1_knock 语音播完垫 pipe_knock 两遍(实际 ${knocks},零重叠)`);
|
||
|
||
// 等你选择空点单击 = 主动再听选项:「再说一遍:」+ p1 完整句(原则 9)
|
||
await act(rig, 'tap');
|
||
g0.gestures += 1;
|
||
await advance(() => voicePlays(playLog, 'voice/p1') >= 2); // 重听链第二句播出(act 不推时钟)
|
||
assert(voicePlays(playLog, 'voice/sys_say_again') === 1 && voicePlays(playLog, 'voice/p1') >= 2,
|
||
'抉择点空点单击 = 「再说一遍:」+ p1 完整句(原则 9)');
|
||
await advance(() => game.floor.state === 'YOUR_TURN' && !game.voicePlaying);
|
||
|
||
// 剧本 A 余下抉择(p1 右起):playRun 续跑(p1 已 armed)
|
||
const iA = await playRun(rig, SCRIPT_A, {
|
||
stop: () => game.phase === 'SURFACE' && game.diveN === 1,
|
||
});
|
||
await tapTo(rig, () => game.phase === 'SURFACE' && game.currentVoiceId === 'voice/d1_surf');
|
||
assert(playLog.some((e) => e.id === 'sfx/surf_splash') && playLog.some((e) => e.id === 'sfx/ascent_chime'),
|
||
'上浮结算垫 surf_splash + ascent_chime(破水出水+确认音)');
|
||
await advance(() => game.phase === 'DIVE' && game.diveN === 2);
|
||
assert(game.oxy === 6, 'SURFACE(1) 后 DIVE(2):oxy 9-3=6(逐点验算)');
|
||
assert(voicePlays(playLog, 'voice/oxy_full') === 1, '首次结算播 oxy_full(oxy=9,账目真实)');
|
||
assert(voicePlays(playLog, 'voice/oxy_ok') === 0 && voicePlays(playLog, 'voice/oxy_low') === 0
|
||
&& voicePlays(playLog, 'voice/oxy_crit') === 0, '未跨档不播其他档位句');
|
||
const sd1 = saveData();
|
||
assert(sd1 && sd1.phase === 'DIVE' && sd1.dive === 2 && sd1.oxy === 6 && sd1.node === 'dv2_down',
|
||
'DIVE 进入即存档:phase=DIVE / dive=2 / oxy=6 / node=dv2_down');
|
||
const keysNeeded = ['schema', 'updatedAt', 'summary', 'phase', 'node', 'dive', 'oxy',
|
||
'supply', 'saved', 'stayer', 'flags', 'endings', 'runs'];
|
||
assert(keysNeeded.every((k) => k in sd1), '存档 data 覆盖 §7 全部字段(含 summary 字符串)');
|
||
assert(sd1.saved.zhou === 1 && sd1.saved.bai === 0, 'saved 标量:小舟救上(教学潜保底)');
|
||
assert(sd1.summary === '第2潜·机舱 · 氧气6格 · 结局0/4', `summary 格式(实际: ${sd1.summary})`);
|
||
assert(Number.isFinite(Date.parse(sd1.updatedAt)), 'updatedAt 为 ISO 时间戳');
|
||
|
||
// DIVE(2):脉冲 → echo_debris+pipe_knock(D1 暗号兑现)→ p3 右危机拍
|
||
await reachSearch(rig);
|
||
await act(rig, 'tap');
|
||
await advance(() => game.phase === 'COMMS');
|
||
const iDebris = lastIndexOf(playLog, 'sfx/echo_debris');
|
||
const iKnockD2 = playLog.map((e) => e.id).lastIndexOf('sfx/pipe_knock');
|
||
const iFound = lastIndexOf(playLog, 'voice/dv2_found');
|
||
assert(iDebris >= 0 && iDebris < iKnockD2 && iKnockD2 < iFound,
|
||
'D2 搜索:echo_debris → pipe_knock → dv2_found(敲管=老白的信号,D1 教学兑现)');
|
||
const danger0 = vibrateLog.filter((v) => JSON.stringify(v.pattern) === DANGER_PATTERN).length;
|
||
await tapTo(rig, () => game.choiceArmed); // bai_a → bai_b → p3
|
||
await act(rig, 'right'); // p3 右 = 切割快切 → 危机拍
|
||
await advance(() => game.currentVoiceId === 'voice/d2_flash');
|
||
assert(voicePlays(playLog, 'sfx/spark_flash') === 1, 'D2 危机拍:spark_flash 前置起播');
|
||
assert(vibrateLog.filter((v) => JSON.stringify(v.pattern) === DANGER_PATTERN).length === danger0 + 1,
|
||
'D2 危机拍 danger 震型(签名+震同窗)');
|
||
const iSpark = lastIndexOf(playLog, 'sfx/spark_flash');
|
||
const iCut = lastIndexOf(playLog, 'voice/d2_cut');
|
||
assert(iCut < iSpark, 'spark_flash 在 d2_cut 语音播完后才起(零重叠:危机音不叠语音)');
|
||
|
||
// p4 右 → SURFACE(2):补给链
|
||
await tapTo(rig, () => game.choiceArmed);
|
||
await act(rig, 'right');
|
||
await tapTo(rig, () => game.phase === 'SURFACE' && game.diveN === 2);
|
||
assert(game.oxy === 9 && game.supply === true,
|
||
'SURFACE(2):supply 后 oxy 6+3=9(应急瓶全局唯一一次 +3)');
|
||
await advance(() => voicePlays(playLog, 'voice/oxy_gain') === 1);
|
||
assert(voicePlays(playLog, 'voice/d2_supply') === 1 && voicePlays(playLog, 'voice/oxy_gain') === 1,
|
||
'补给链播出 d2_supply → oxy_gain(恰一次)');
|
||
|
||
// DIVE(3):脉冲 → echo_groan(预警)→ p5 右 / p6 右 → SURFACE(3) ok 档 → 氧气门双载
|
||
await reachSearch(rig);
|
||
await act(rig, 'tap');
|
||
await advance(() => game.phase === 'COMMS');
|
||
assert(voicePlays(playLog, 'sfx/echo_groan') === 1 && voicePlays(playLog, 'voice/dv3_groan') === 1,
|
||
'D3 搜索:echo_groan 船体呻吟预警 + dv3_groan 解读');
|
||
await tapTo(rig, () => game.choiceArmed);
|
||
await act(rig, 'right'); // p5 右 = 先带小郑
|
||
assert(game.stayer === 'chen' && game.saved.zheng === 1 && game.saved.chen === 0,
|
||
'p5 右:小郑上(saved.zheng=1),stayer=chen');
|
||
for (const id of ['voice/d3_zheng', 'voice/d3_zheng_e', 'voice/chen_stay']) {
|
||
await advance(() => voicePlays(playLog, id) === 1);
|
||
}
|
||
await tapTo(rig, () => game.choiceArmed);
|
||
await act(rig, 'right'); // p6 右 = 约定来接
|
||
await tapTo(rig, () => game.phase === 'SURFACE' && game.diveN === 3);
|
||
assert(game.oxy === 6, 'SURFACE(3):oxy=6(逐点验算)');
|
||
await advance(() => voicePlays(playLog, 'voice/oxy_ok') === 1);
|
||
assert(voicePlays(playLog, 'voice/oxy_ok') === 1, 'SURFACE(3):oxy=6 → ok 档跨档播报');
|
||
await tapTo(rig, () => game.phase === 'DIVE' && game.diveN === 4);
|
||
assert(game.diveForm === 'double', '氧气门:oxy=6 ≥4 → DIVE(4) 双载形态');
|
||
assert(game.oxy === 2, 'DIVE(4) 双载起播 oxy 6-3-1=2(双载额外 +1)');
|
||
await advance(() => voicePlays(playLog, 'voice/dv4_dual') === 1);
|
||
assert(voicePlays(playLog, 'voice/dv4_dual') === 1,
|
||
'双载形态下潜链补播 dv4_dual「这趟往返,扣四格气」(3+1 扣账口播化)');
|
||
|
||
// DIVE(4):滑坡危机拍(landslide 前置 2s + danger 震循环)→ p7 双载提示
|
||
await reachSearch(rig, 4);
|
||
await act(rig, 'tap');
|
||
await advance(() => game.phase === 'COMMS');
|
||
assert(voicePlays(playLog, 'sfx/echo_groan') === 2 && voicePlays(playLog, 'sfx/echo_metal_r') === 1,
|
||
'D4 搜索:echo_groan 渐强 + echo_metal_r(接近指挥舱必播金属回波)');
|
||
assert(voicePlays(playLog, 'sfx/landslide') === 1, 'D4 危机拍:landslide 前置起播');
|
||
const dVibsAll = vibrateLog.filter((v) => JSON.stringify(v.pattern) === DANGER_PATTERN);
|
||
assert(dVibsAll.length >= 3, `D4 danger 震循环(实际 ${dVibsAll.length} 遍)`);
|
||
const dGaps = dVibsAll.slice(1).map((v, k) => v.t - dVibsAll[k].t);
|
||
assert(dGaps.every((gap) => gap >= 1000),
|
||
`danger 震循环间隔 ≥1000ms(实际间隔 ${dGaps.join('/')})`);
|
||
const iSlide = lastIndexOf(playLog, 'sfx/landslide');
|
||
const iSlideV = lastIndexOf(playLog, 'voice/dv4_slide');
|
||
assert(iSlide < iSlideV, 'landslide 前置 2s 后才接 dv4_slide 语音(危机拍优先)');
|
||
await tapTo(rig, () => game.choiceArmed); // gao_a → gao_b → p7
|
||
assert(voicePlays(playLog, 'voice/p7') === 1 && voicePlays(playLog, 'voice/p7_chen') === 0
|
||
&& voicePlays(playLog, 'voice/p7_zheng') === 0, '双载形态播 p7(快慢浮)');
|
||
await act(rig, 'right'); // p7 右 = 快浮
|
||
await tapTo(rig, () => game.choiceArmed); // d4_fast → d4_fast_e → gao_c → p8
|
||
assert(voicePlays(playLog, 'voice/gao_c') === 1 && voicePlays(playLog, 'voice/gao_confess') === 0,
|
||
'hasLog=false → 心结引入播 gao_c(条件差分)');
|
||
await act(rig, 'right'); // p8 右 = 不问
|
||
await tapTo(rig, () => game.phase === 'SURFACE' && game.diveN === 4);
|
||
await advance(() => voicePlays(playLog, 'voice/oxy_crit_deck') === 1);
|
||
assert(game.oxy === 2 && voicePlays(playLog, 'voice/oxy_crit_deck') === 1
|
||
&& voicePlays(playLog, 'voice/oxy_crit') === 0,
|
||
'SURFACE(4):oxy=2 → crit 档跨档播报水面变体 oxy_crit_deck(水下原句不播)');
|
||
assert(vibrateLog.some((v) => JSON.stringify(v.pattern) === WARN_PATTERN),
|
||
'crit 档播报位附 warning 震');
|
||
|
||
// 签收:g9_in → g9_knot → p9 左(oxy=2≥1 可用)→ d9_bell + bell_toll → ENDING(e1)
|
||
await tapTo(rig, () => game.choiceArmed);
|
||
assert(game.nodeId === 'p9' && voicePlays(playLog, 'voice/g9_in') === 1
|
||
&& voicePlays(playLog, 'voice/g9_knot') === 1, '签收链 g9_in → g9_knot → p9');
|
||
await act(rig, 'left'); // p9 左(可用)
|
||
await advance(() => game.phase === 'ENDING');
|
||
assert(game.flags.bell === true, 'p9 左(oxy≥1):bell=true');
|
||
assert(game.endingId === 'e1', 'saved=5 且 bell → ENDING(e1 海燕归钟)');
|
||
await advance(() => game.awaitingRestart);
|
||
for (const id of ['voice/e1_a', 'voice/e1_b', 'voice/ep_lead', 'voice/ep_zhou_no',
|
||
'voice/ep_bai_no', 'voice/ep_chen_ok', 'voice/ep_zheng_ok', 'voice/ep_gao_up',
|
||
'voice/ep_trench_bell']) {
|
||
assert(voicePlays(playLog, id) === 1, `结局链播出 ${id}`);
|
||
}
|
||
assert(voicePlays(playLog, 'sfx/bell_toll') === 1, '船钟 payoff 音 bell_toll(p9 左兑现)');
|
||
const sd2 = saveData();
|
||
assert(sd2 && sd2.phase === 'ENDING' && sd2.endings.e1 === true
|
||
&& sd2.summary === '救援收队·海燕归钟 · 结局1/4',
|
||
`ENDING 存档:endings 收集 e1,summary 收队格式(实际: ${sd2 && sd2.summary})`);
|
||
assert(game._breathLog.some((e) => e.on === true) && game._breathLog[game._breathLog.length - 1].on === false,
|
||
'低氧混音签名:D4(oxy=2 crit)期间 breath_loop 在播、收队即停');
|
||
assert(game._ambDucked === false, '链歇后 amb 恢复全响(duck 解除)');
|
||
assert(game.ladder.stage() >= 2 && voicePlays(playLog, 'voice/sys_unlock_down') === 1,
|
||
'一局结局 → 手势阶梯阶段 2(下滑=菜单解锁),教学句挂结局链尾(onGameDone)');
|
||
|
||
// 双通道总账(17 号 §8 失败即不通过项):全程信息音与语音零重叠 + 声呐账目
|
||
const overlaps = sonarVoiceOverlaps(game);
|
||
assert(overlaps.length === 0, `全程 echo_*/pipe_knock/groan 与语音零重叠(${overlaps.length} 处违规)`);
|
||
assert(voicePlays(playLog, 'sfx/sonar_ping') === 4, '四潜各恰一枚玩家脉冲(sonar_ping 计数=4)');
|
||
assert(voicePlays(playLog, 'sfx/surf_splash') === 4 && voicePlays(playLog, 'sfx/ascent_chime') === 4,
|
||
'每潜上浮出水 sfx 各 4 次');
|
||
assert(voicePlays(playLog, 'voice/d1_surf') === 1 && voicePlays(playLog, 'voice/d2_surf') === 1
|
||
&& voicePlays(playLog, 'voice/d3_surf') === 1 && voicePlays(playLog, 'voice/d4_surf') === 1,
|
||
'四条上浮结算句各一次(d1..d4_surf)');
|
||
assert(['voice/dry_a', 'voice/dry_b', 'voice/dv4_none', 'voice/gao_final',
|
||
'voice/g9_dry', 'voice/p7_chen', 'voice/chen_last', 'voice/gao_last']
|
||
.every((id) => voicePlays(playLog, id) === 0), '剧本 A 全程零软失败/差分句(成功不教学)');
|
||
|
||
// 阶段 2 已解锁:结算态下滑开菜单 → 关闭回结算(不重播整段结局)
|
||
await act(rig, 'down');
|
||
assert(game.phase === 'MENU',
|
||
'结算态下滑 → 模态菜单(阶段 2 解锁;新词「左右滑换项,单击确认」)');
|
||
await advance(() => voicePlays(playLog, 'voice/sys_menu') >= 1);
|
||
await advance(() => game.floor.state === 'YOUR_TURN');
|
||
await act(rig, 'down'); // 关闭
|
||
await advance(() => game.phase === 'ENDING' && game.awaitingRestart);
|
||
assert(game.endingId === 'e1', '菜单往返后回结算态(结局链重播=局面复述)');
|
||
|
||
// 结算态单击 → 新救援
|
||
await advance(() => game.awaitingRestart && game.floor.state === 'YOUR_TURN');
|
||
await act(rig, 'tap');
|
||
assert(game.phase === 'INTRO' && game.runs === 1 && game.oxy === 12 && game.diveN === 0
|
||
&& !game.flags.knowsPipe && game.stayer === null && game.saved.gao === 0,
|
||
'结算单击 = 新救援:回 INTRO,oxy/saved/stayer/flags 全部归零(runs 0+1,与 ASK 段 3+1 同口径)');
|
||
assert(game.endings.e1 === true, 'endings 结局收集跨局保留');
|
||
// 新救援链四句(act 不推虚拟时钟;开场 b_goal/b_go 各 1 次,重开链共 2 次)
|
||
await advance(() => voicePlays(playLog, 'voice/b_go') === 2);
|
||
assert(voicePlays(playLog, 'voice/sys_new_dive') === 1
|
||
&& voicePlays(playLog, 'voice/b_open') === 1 && voicePlays(playLog, 'voice/b_goal') === 2
|
||
&& voicePlays(playLog, 'voice/b_go') === 2,
|
||
'新救援链:sys_new_dive → b_open → b_goal → b_go(重开不念教学,原则 11)');
|
||
assert(voicePlays(playLog, 'voice/sys_first_tap') === 1,
|
||
'重开不重播开场指令教学(sys_first_tap 每实例一次,做对过的事不教第二遍)');
|
||
assert(voicePlays(playLog, 'voice/sys_skip') === 1, 'sys_skip 每实例只播一次,重开不重播');
|
||
assert(gestureCount() <= 70, `关键路径手势预算 ≤70(实际 ${gestureCount()})`);
|
||
void iA;
|
||
teardown(rig);
|
||
}
|
||
|
||
// ================================================================ pendingChoice(V1 时序)
|
||
// (旁白链中滑动 → 缓存 + 跳去选项句;选项句播完才生效:ui_confirm → 结果句,零插播)
|
||
|
||
realLog('== pendingChoice(V1:播完才生效)==');
|
||
{
|
||
clearSave();
|
||
storageData.delete(LADDER_KEY);
|
||
resetSettings('off');
|
||
const rig = makeRig();
|
||
const { audio, playLog, receiptLog, game } = rig;
|
||
await audio.unlock();
|
||
game.start();
|
||
await flush();
|
||
await reachSearch(rig);
|
||
await act(rig, 'tap'); // 脉冲 → 回波 → COMMS
|
||
await advance(() => game.phase === 'COMMS');
|
||
await tapTo(rig, () => game.currentVoiceId === 'voice/z1_knock'); // 旁白(next=p1 抉择点)
|
||
|
||
await act(rig, 'left'); // 旁白播放中左滑:缓存 + 跳去选项句(不当场盲选)
|
||
assert(game.pendingChoice === 'left' && game.nodeId === 'p1' && game.floor.speaking,
|
||
'旁白中左滑 → 缓存 pendingChoice + 跳去 p1 选项句(不盲选)');
|
||
const busyN = receiptLog.filter((r) => r.kind === 'busy').length;
|
||
await act(rig, 'tap'); // p1 播放中:critical → busy
|
||
assert(receiptLog.filter((r) => r.kind === 'busy').length === busyN + 1,
|
||
'选项句播放中 tap = busy(p* 不可跳,17 号 §4)');
|
||
const iP1Last = lastIndexOf(playLog, 'voice/p1');
|
||
await advance(() => game.nodeId === 'd1_listen');
|
||
const idsSoFar = playLog.map((e) => e.id);
|
||
const iConfirm = idsSoFar.indexOf('sfx/ui_confirm');
|
||
assert(iConfirm > iP1Last && idsSoFar.indexOf('voice/d1_listen') > iConfirm,
|
||
'V1:生效时序 = p1 播完 → ui_confirm 结算音 → 结果句(播完才生效,零插播)');
|
||
assert(game.pendingChoice === null && game.flags.knowsPipe === true && game.oxy === 8,
|
||
'V1:缓存的左滑在选项句播完后生效(knowsPipe=true,oxy 9-1=8)');
|
||
teardown(rig);
|
||
}
|
||
|
||
// ================================================================ 四结局矩阵 + 氧气门三形态
|
||
// (B→e2 / C→取消链→e3 / D→单载 p7_chen+chen_last→e4 / E→干涸链→e4 /
|
||
// F→单载 p7_zheng+gao_last+p8 跳过→e4;oxy 逐检查点与 §4 一致)
|
||
|
||
realLog('== 四结局矩阵(剧本 B / C / D / E / F)==');
|
||
{
|
||
// ---- 剧本 B:与 A 同路但 p9 右 → e2
|
||
clearSave(); storageData.delete(LADDER_KEY); resetSettings('off');
|
||
{
|
||
const rig = makeRig();
|
||
const { audio, playLog, game } = rig;
|
||
await audio.unlock();
|
||
game.start(); await flush();
|
||
await playRun(rig, SCRIPT_B);
|
||
assert(game.endingId === 'e2' && game.oxy === 2 && game.flags.bell === false,
|
||
'剧本 B(p9 右)→ e2 满载的黎明,oxy=2,bell=false');
|
||
assert(voicePlays(playLog, 'voice/e2_a') === 1 && voicePlays(playLog, 'voice/e2_b') === 1,
|
||
'e2 专属结局主句 + 钩子播出');
|
||
assert(voicePlays(playLog, 'voice/ep_trench_deep') === 1 && voicePlays(playLog, 'voice/ep_trench_bell') === 0,
|
||
'bell=false → 尾声沟底播 ep_trench_deep(钟留在沟底)');
|
||
teardown(rig);
|
||
}
|
||
|
||
// ---- 剧本 C:p1左 p2左 p4左 余右 → 终局取消链 → p9 左 → e3
|
||
clearSave(); storageData.delete(LADDER_KEY); resetSettings('off');
|
||
{
|
||
const rig = makeRig();
|
||
const { audio, playLog, game } = rig;
|
||
await audio.unlock();
|
||
game.start(); await flush();
|
||
await playRun(rig, SCRIPT_C, { stop: () => game.phase === 'SURFACE' && game.diveN === 1 });
|
||
assert(game.oxy === 7, '剧本 C:D1 后(p1左+p2左)oxy 12-3-1-1=7(ok 档)');
|
||
await playRun(rig, SCRIPT_C, {
|
||
from: 2, stop: () => game.phase === 'SURFACE' && game.diveN === 2,
|
||
});
|
||
await advance(() => voicePlays(playLog, 'voice/oxy_low') === 1);
|
||
assert(game.oxy === 5 && game.supply === true && voicePlays(playLog, 'voice/oxy_low') === 1,
|
||
'剧本 C:D2 后 p4左 floor 前 oxy=2 → supply +3=5(low 档播报)');
|
||
const ci = await playRun(rig, SCRIPT_C, {
|
||
from: 4, stop: () => game.nodeId === 'c_none',
|
||
});
|
||
assert(game.phase === 'SURFACE' && game.nodeId === 'c_none' && game.diveForm === 'cancel',
|
||
'剧本 C:SURFACE(3) oxy=2 ≤2 → 氧气门走终局取消链(dv4_none 起播)');
|
||
assert(game.oxy === 2 && voicePlays(playLog, 'voice/oxy_crit_deck') === 1,
|
||
'剧本 C:D3 后 oxy=2(crit 档水面变体,取消门与氧气预算承诺一致)');
|
||
await playRun(rig, SCRIPT_C, { from: ci });
|
||
assert(voicePlays(playLog, 'voice/dv4_none') === 1 && voicePlays(playLog, 'voice/gao_final') === 1,
|
||
'取消链完整播出 dv4_none → gao_final(措辞克制、零描写)');
|
||
assert(voicePlays(playLog, 'voice/dv4_down') === 0 && voicePlays(playLog, 'voice/p7') === 0
|
||
&& voicePlays(playLog, 'voice/p8') === 0, '取消路径跳过第四潜(无 p7/p8)');
|
||
assert(game.endingId === 'e3' && game.flags.bell === true,
|
||
'剧本 C:p9 左(oxy=2≥1 可用)→ e3 最后一潜(saved=3 × bell)');
|
||
assert(game.saved.zhou === 1 && game.saved.bai === 1 && game.saved.zheng === 1
|
||
&& game.saved.chen === 0 && game.saved.gao === 0, '剧本 C saved=3(小舟/老白/小郑)');
|
||
assert(voicePlays(playLog, 'voice/ep_zhou_ok') === 1 && voicePlays(playLog, 'voice/ep_bai_ok') === 1
|
||
&& voicePlays(playLog, 'voice/ep_chen_lost') === 1 && voicePlays(playLog, 'voice/ep_gao_lost') === 1,
|
||
'剧本 C 尾声:bond_zhou/bai=1 播 ok,陈医官/船长留守未归播 lost(P2 回响点名)');
|
||
teardown(rig);
|
||
}
|
||
|
||
// ---- 剧本 D:p1左 p4左 余右 → 单载 p7_chen → p7 左=带船长 → chen_last → e4
|
||
clearSave(); storageData.delete(LADDER_KEY); resetSettings('off');
|
||
{
|
||
const rig = makeRig();
|
||
const { audio, playLog, game } = rig;
|
||
await audio.unlock();
|
||
game.start(); await flush();
|
||
await playRun(rig, SCRIPT_D, { stop: () => game.phase === 'SURFACE' && game.diveN === 1 });
|
||
assert(game.oxy === 8, '剧本 D:D1 后(仅 p1 左)oxy 12-3-1=8(ok 档)');
|
||
await playRun(rig, SCRIPT_D, {
|
||
from: 2, stop: () => game.phase === 'SURFACE' && game.diveN === 2,
|
||
});
|
||
assert(game.oxy === 6, '剧本 D:D2 后 p4左 oxy 8-3-2=3 → supply +3=6(ok,不重播)');
|
||
await playRun(rig, SCRIPT_D, {
|
||
from: 4, stop: () => game.phase === 'SURFACE' && game.diveN === 3,
|
||
});
|
||
await advance(() => voicePlays(playLog, 'voice/oxy_low') === 1);
|
||
assert(game.oxy === 3 && voicePlays(playLog, 'voice/oxy_low') === 1,
|
||
'剧本 D:SURFACE(3) oxy=3(low 档跨档播报)');
|
||
await playRun(rig, SCRIPT_D, {
|
||
from: 6, stop: () => game.phase === 'DIVE' && game.diveN === 4 && game.voicePlaying,
|
||
});
|
||
assert(game.diveForm === 'single' && game.oxy === 0,
|
||
'剧本 D:氧气门 oxy=3 → 单载形态;DIVE(4) oxy 3-3=0');
|
||
assert(voicePlays(playLog, 'voice/dv4_dual') === 0,
|
||
'单载形态不播 dv4_dual(扣账句只随双载 3+1 出现)');
|
||
await playRun(rig, SCRIPT_D, { from: 6 });
|
||
assert(voicePlays(playLog, 'voice/p7_chen') === 1 && voicePlays(playLog, 'voice/p7') === 0
|
||
&& voicePlays(playLog, 'voice/p7_zheng') === 0, '单载·留守=陈医官 → 播 p7_chen(按 stayer 选提示)');
|
||
assert(voicePlays(playLog, 'voice/d4_pick_gao') === 1 && voicePlays(playLog, 'voice/chen_last') === 1
|
||
&& voicePlays(playLog, 'voice/d4_stay_lost') === 0, 'p7 左=带船长 → d4_pick_gao → chen_last 留别');
|
||
assert(voicePlays(playLog, 'voice/p8') === 1 && voicePlays(playLog, 'voice/gao_c') === 1,
|
||
'船长在乘 → p8 可达(gao_c 差分)');
|
||
assert(game.endingId === 'e4' && game.flags.bell === false && game.oxy === 0,
|
||
'剧本 D:oxy_end=0 → p9 左封锁 → e4 按规程上浮(saved=4)');
|
||
assert(voicePlays(playLog, 'voice/g9_dry') === 1 && voicePlays(playLog, 'voice/d9_rest') === 1
|
||
&& voicePlays(playLog, 'voice/d9_bell') === 0, 'p9 左封锁:g9_dry 播出后自动走右(不摆假选项)');
|
||
assert(game.saved.gao === 1 && game.saved.chen === 0 && game.saved.zheng === 1,
|
||
'剧本 D saved=4(小舟/老白/小郑/船长),陈医官留守未归');
|
||
teardown(rig);
|
||
}
|
||
|
||
// ---- 剧本 E:p2左 p3[无knowsPipe]左 p4左 p6左 → mid-dive floor 0 干涸链 → e4
|
||
clearSave(); storageData.delete(LADDER_KEY); resetSettings('off');
|
||
{
|
||
const rig = makeRig();
|
||
const { audio, playLog, game } = rig;
|
||
await audio.unlock();
|
||
game.start(); await flush();
|
||
const ei = await playRun(rig, SCRIPT_E, {
|
||
stop: () => game.currentVoiceId === 'voice/dry_a',
|
||
});
|
||
assert(game.currentVoiceId === 'voice/dry_a' && game.oxy === 0,
|
||
'剧本 E:p6 左 -2 从 2 floor 至 0 → dry_a 当场合播(mid-dive 干涸链)');
|
||
await playRun(rig, SCRIPT_E, { from: ei });
|
||
assert(voicePlays(playLog, 'voice/dry_a') === 1 && voicePlays(playLog, 'voice/dry_b') === 1,
|
||
'干涸链 dry_a → 上浮结算内 dry_b(归因真话:气花在了人身上)');
|
||
assert(voicePlays(playLog, 'voice/d3_spare_e') === 1 && game.saved.zheng === 1,
|
||
'干涸不中断当前救援:小郑照常救上(人已挂上吊索不失去)');
|
||
assert(game.endingId === 'e4' && game.flags.spareGift === true && game.oxy === 0,
|
||
'剧本 E:取消链 + p9 左封锁 → e4(saved=3,spareGift=true)');
|
||
assert(voicePlays(playLog, 'voice/dv4_none') === 1, '剧本 E 走终局取消链(gate oxy=0 ≤2)');
|
||
teardown(rig);
|
||
}
|
||
|
||
// ---- 剧本 F:p5左(stayer=zheng)单载右路 → p7_zheng + gao_last + p8 跳过 → e4
|
||
clearSave(); storageData.delete(LADDER_KEY); resetSettings('off');
|
||
{
|
||
const rig = makeRig();
|
||
const { audio, playLog, game } = rig;
|
||
await audio.unlock();
|
||
game.start(); await flush();
|
||
await playRun(rig, SCRIPT_F);
|
||
assert(voicePlays(playLog, 'voice/p7_zheng') === 1 && voicePlays(playLog, 'voice/p7') === 0
|
||
&& voicePlays(playLog, 'voice/p7_chen') === 0, '剧本 F:单载·留守=小郑 → 播 p7_zheng');
|
||
assert(voicePlays(playLog, 'voice/d4_pick_zheng') === 1 && voicePlays(playLog, 'voice/gao_last') === 1,
|
||
'p7 右=带留守者 → d4_pick_zheng → gao_last 船长留别(p8 跳过,直达上浮)');
|
||
assert(voicePlays(playLog, 'voice/p8') === 0 && voicePlays(playLog, 'voice/gao_c') === 0,
|
||
'单载右路少 p8(每局实走 7-9 抉择点验证)');
|
||
assert(game.endingId === 'e4' && game.saved.chen === 1 && game.saved.zheng === 1
|
||
&& game.saved.gao === 0, '剧本 F saved=4(陈医官 p5 左救上、小郑单载带回、船长留别)→ e4');
|
||
teardown(rig);
|
||
}
|
||
}
|
||
|
||
// ================================================================ 搜索等待交互 + 上滑状态链
|
||
// (无效滑动解释一次 / 双击折算=再脉冲 / 状态链顺序 潜次→已救→氧档→动作)
|
||
|
||
realLog('== 搜索等待与状态链 ==');
|
||
{
|
||
clearSave(); storageData.delete(LADDER_KEY); resetSettings('off');
|
||
const rig = makeRig();
|
||
const { audio, playLog, receiptLog, game } = rig;
|
||
await audio.unlock();
|
||
game.start(); await flush();
|
||
await reachSearch(rig);
|
||
|
||
// 搜索等待左滑:invalid 回执 + 首次解释「点一下,发脉冲。」(§4 矩阵 / §7 失败表)
|
||
await act(rig, 'left');
|
||
assert(receiptLog.some((r) => r.kind === 'invalid'), '搜索等待左滑 → invalid 回执(零静默)');
|
||
await advance(() => voicePlays(playLog, 'voice/sys_act_ping') === 1);
|
||
assert(voicePlays(playLog, 'voice/sys_act_ping') === 1, '首次解释 = sys_act_ping「点一下,发脉冲。」');
|
||
const actCount = voicePlays(playLog, 'voice/sys_act_ping');
|
||
await advance(() => !game.voicePlaying);
|
||
await act(rig, 'left');
|
||
await advance(() => !game.voicePlaying);
|
||
assert(voicePlays(playLog, 'voice/sys_act_ping') === actCount,
|
||
'第二次起只给回执,不再解释(原则 7)');
|
||
assert(game.searchArmed && game.oxy === 9, '无效滑动不作进展(剧情零推进)');
|
||
|
||
// 新手双击折算为单击:再发一枚脉冲(废除双击重听回波)
|
||
const ping0 = voicePlays(playLog, 'sfx/sonar_ping');
|
||
await act(rig, 'doubletap');
|
||
assert(voicePlays(playLog, 'sfx/sonar_ping') === ping0 + 1,
|
||
'未解锁双击按一次单击处理 = 再发一枚脉冲(17 号 §4:再单击=再听)');
|
||
|
||
// 推进到 D2 搜索(p1 右省气路径),验证上滑状态链顺序 + 搜索等待恢复
|
||
await playRun(rig, SCRIPT_A, { stop: () => game.searchArmed && game.diveN === 2 });
|
||
await act(rig, 'up');
|
||
await advance(() => !game.voicePlaying && game.searchArmed);
|
||
const iP = lastIndexOf(playLog, 'voice/prog_d2');
|
||
const iS = lastIndexOf(playLog, 'voice/prog_saved1');
|
||
const iR = lastIndexOf(playLog, 'voice/oxy_ok');
|
||
const iA = lastIndexOf(playLog, 'voice/sys_act_ping');
|
||
assert(iP >= 0 && iP < iS && iS < iR && iR < iA,
|
||
'上滑状态链顺序(§3/§9):prog_d2 → prog_saved1 → oxy_ok → 「点一下,发脉冲。」');
|
||
assert(game.searchArmed && game.phase === 'DIVE', '状态播报后留在搜索等待(等待点重挂)');
|
||
teardown(rig);
|
||
}
|
||
|
||
// ================================================================ 三档提示(§4.4)
|
||
// (ping 8s/18s full→short;continue 13s/30s;choice 25s 台账降档不逐字复述)
|
||
|
||
realLog('== 三档提示 ==');
|
||
{
|
||
// ---- 搜索等待:8s full → 18s short
|
||
clearSave(); storageData.delete(LADDER_KEY); resetSettings('off');
|
||
{
|
||
const rig = makeRig();
|
||
const { audio, playLog, game } = rig;
|
||
await audio.unlock();
|
||
game.start(); await flush();
|
||
await reachSearch(rig);
|
||
const tS = now;
|
||
await advance(() => voicePlays(playLog, 'voice/sys_hint_ping_full') === 1);
|
||
assert(now - tS === 8000, `搜索等待 8s → sys_hint_ping_full(实际 ${now - tS}ms,§5)`);
|
||
await advance(() => voicePlays(playLog, 'voice/sys_hint_ping_short') === 1);
|
||
assert(now - tS === 18000, `18s → sys_hint_ping_short(实际 ${now - tS}ms,第二档更短)`);
|
||
assert(voicePlays(playLog, 'voice/sys_hint_ping_full') === 1, '同一信息不原样重复(full 只播一次)');
|
||
teardown(rig);
|
||
}
|
||
|
||
// ---- COMMS 链末:13s full → 30s short(§5:COMMS 链末 full 档 13s/30s)
|
||
clearSave(); storageData.delete(LADDER_KEY); resetSettings('off');
|
||
{
|
||
const rig = makeRig();
|
||
const { audio, playLog, game } = rig;
|
||
await audio.unlock();
|
||
game.start(); await flush();
|
||
await reachSearch(rig);
|
||
await act(rig, 'tap');
|
||
await advance(() => game.phase === 'COMMS');
|
||
await advance(() => !game.voicePlaying); // z1_a 链末等待
|
||
const tW = now;
|
||
await advance(() => voicePlays(playLog, 'voice/sys_hint_continue_full') === 1);
|
||
assert(now - tW === 13000, `COMMS 链末 13s 静默 → sys_hint_continue_full(实际 ${now - tW}ms)`);
|
||
await advance(() => voicePlays(playLog, 'voice/sys_hint_continue_short') === 1);
|
||
assert(now - tW === 30000, `30s → sys_hint_continue_short(实际 ${now - tW}ms)`);
|
||
teardown(rig);
|
||
}
|
||
|
||
// ---- 抉择点:25s 台账降档(选项句刚播过 → 不逐字复述,直接 short)
|
||
clearSave(); storageData.delete(LADDER_KEY); resetSettings('off');
|
||
{
|
||
const rig = makeRig();
|
||
const { audio, playLog, game } = rig;
|
||
await audio.unlock();
|
||
game.start(); await flush();
|
||
await reachSearch(rig);
|
||
await act(rig, 'tap');
|
||
await tapTo(rig, () => game.choiceArmed); // p1 武装
|
||
const p1Count = voicePlays(playLog, 'voice/p1');
|
||
const tP = now;
|
||
await advance(() => voicePlays(playLog, 'voice/sys_hint_choice_short') === 1);
|
||
assert(now - tP === 25000, `p* 25s 无操作 → 提示降为 short(实际 ${now - tP}ms)`);
|
||
assert(voicePlays(playLog, 'voice/p1') === p1Count,
|
||
'选项句 30s 内不逐字被动重播(台账降档,原则 9;主动再听走「再说一遍:」)');
|
||
teardown(rig);
|
||
}
|
||
}
|
||
|
||
// ================================================================ 迷茫接管(§4.5 三级)
|
||
// (10s 内 3 次无效 → L1 短句 → L2 只留期待手势 → L3 替发脉冲)
|
||
|
||
realLog('== 迷茫接管 ==');
|
||
{
|
||
clearSave(); storageData.delete(LADDER_KEY); resetSettings('off');
|
||
const rig = makeRig();
|
||
const { audio, playLog, game } = rig;
|
||
await audio.unlock();
|
||
game.start(); await flush();
|
||
await reachSearch(rig);
|
||
|
||
// L1:10s 内 3 次无效滑动 → 接管短句
|
||
const shortBefore = voicePlays(playLog, 'voice/sys_hint_ping_short');
|
||
for (let i = 0; i < 3; i++) { await act(rig, 'left'); await advance(() => !game.voicePlaying); }
|
||
await advance(() => voicePlays(playLog, 'voice/sys_hint_ping_short') > shortBefore
|
||
|| game.confusion.level >= 1);
|
||
assert(game.confusion.level >= 1, '10s 内 3 次无效滑动 → 迷茫接管 L1(连续无效权重 2)');
|
||
await advance(() => !game.voicePlaying && game.floor.state === 'YOUR_TURN');
|
||
|
||
// L2:过 15s 冷却后再乱点 → 只放行 tap(restricted)
|
||
const tL1 = now;
|
||
await advance(() => now - tL1 >= 16000);
|
||
await advance(() => !game.voicePlaying && game.floor.state === 'YOUR_TURN');
|
||
for (let i = 0; i < 3; i++) { await act(rig, 'right'); await advance(() => !game.voicePlaying); }
|
||
await advance(() => game.confusion.level >= 2 || now - tL1 > 90000);
|
||
const wp = game.floor.waitPoint;
|
||
assert(game.confusion.level >= 2, '冷却后再乱点 → 接管升到 L2');
|
||
assert(wp && wp.restricted && !wp.allows('left') && wp.allows('tap'),
|
||
'接管 L2:等待点只放行 expected 手势(缩小选择)');
|
||
|
||
// L3:替玩家发一枚脉冲(17 号 §5 ping 等待点 L3=替发脉冲)
|
||
const tL2 = now;
|
||
await advance(() => now - tL2 >= 16000);
|
||
await advance(() => !game.voicePlaying && game.floor.state === 'YOUR_TURN');
|
||
const ping0 = voicePlays(playLog, 'sfx/sonar_ping');
|
||
for (let i = 0; i < 3; i++) { await act(rig, 'left'); await advance(() => !game.voicePlaying); }
|
||
await advance(() => !game.searchArmed || voicePlays(playLog, 'sfx/sonar_ping') > ping0);
|
||
assert(!game.searchArmed && voicePlays(playLog, 'sfx/sonar_ping') === ping0 + 1,
|
||
'接管 L3:游戏替玩家发一枚脉冲(回波照常解读,话轮交回)');
|
||
await advance(() => game.phase === 'COMMS');
|
||
teardown(rig);
|
||
}
|
||
|
||
// ================================================================ 菜单(00 号 §5.4:左右滑换项 + 单击确认)
|
||
|
||
realLog('== 菜单(阶段 2 解锁后)==');
|
||
{
|
||
clearSave();
|
||
storageData.set(LADDER_KEY, JSON.stringify({ stage: 2, games: 1 })); // 下滑已解锁
|
||
resetSettings('off');
|
||
const rig = makeRig();
|
||
const { audio, playLog, game } = rig;
|
||
await audio.unlock();
|
||
game.start(); await flush();
|
||
await reachSearch(rig);
|
||
await act(rig, 'tap');
|
||
await tapTo(rig, () => game.choiceArmed); // p1 武装时呼出菜单
|
||
|
||
await act(rig, 'down');
|
||
assert(game.phase === 'MENU', '结算态下滑呼出菜单(阶段 2 解锁)');
|
||
await advance(() => voicePlays(playLog, 'voice/sys_menu') >= 1
|
||
&& voicePlays(playLog, 'voice/sys_menu_resume') >= 1);
|
||
assert(voicePlays(playLog, 'voice/sys_menu') === 1
|
||
&& voicePlays(playLog, 'voice/sys_menu_resume') === 1,
|
||
'下滑呼出模态菜单:sys_menu(新词:左右滑换项,单击确认)+ 首项报名');
|
||
|
||
// 左右滑换项并报名(左右各一次,回原项;报名不截断)
|
||
await advance(() => game.floor.state === 'YOUR_TURN');
|
||
await act(rig, 'right'); // → 重新开始
|
||
await advance(() => voicePlays(playLog, 'voice/sys_menu_restart') === 1);
|
||
await act(rig, 'left'); // → 回到水下(回绕)
|
||
await advance(() => voicePlays(playLog, 'voice/sys_menu_resume') === 2);
|
||
assert(game.menuIdx === 0, '左右滑换项:右=下一项、左=上一项(回绕到首项)');
|
||
|
||
// 上滑重听菜单说明 + 复述当前项(第二句要等第一句播完)
|
||
await act(rig, 'up');
|
||
await advance(() => voicePlays(playLog, 'voice/menu_help') === 1
|
||
&& voicePlays(playLog, 'voice/sys_menu_resume') === 3);
|
||
assert(voicePlays(playLog, 'voice/menu_help') === 1
|
||
&& voicePlays(playLog, 'voice/sys_menu_resume') === 3,
|
||
'菜单内上滑 = menu_help 重听说明 + 复述当前项');
|
||
|
||
// 单击确认「回到水下」→ 关菜单回原节点(局面复述,含抉择提示)
|
||
await advance(() => !game.voicePlaying);
|
||
await act(rig, 'tap');
|
||
assert(game.phase === 'COMMS' && game.nodeId === 'p1',
|
||
'单击确认「回到水下」→ phase/node 不变(单击=确认,不再循环切换)');
|
||
await advance(() => game.choiceArmed);
|
||
|
||
// 重开二次确认(00 号 §5.4:破坏性操作要二次确认)
|
||
await act(rig, 'down');
|
||
await advance(() => game.floor.state === 'YOUR_TURN');
|
||
await act(rig, 'right'); // → 重新开始
|
||
await advance(() => !game.voicePlaying);
|
||
await act(rig, 'tap'); // 第一次确认 → 确认句
|
||
await advance(() => voicePlays(playLog, 'voice/sys_menu_restart_confirm') === 1);
|
||
assert(game.phase === 'MENU', '第一次确认只播确认句(sys_menu_restart_confirm),未重开');
|
||
await advance(() => game.floor.state === 'YOUR_TURN');
|
||
await act(rig, 'tap'); // 第二次确认 → 真重开
|
||
assert(game.phase === 'INTRO' && game.oxy === 12 && game.runs === 1,
|
||
'第二次确认才重新开始(二次确认防误触)');
|
||
|
||
// 保存并回大厅(第三项)
|
||
await reachSearch(rig);
|
||
await act(rig, 'tap');
|
||
await tapTo(rig, () => game.choiceArmed);
|
||
await act(rig, 'down');
|
||
await advance(() => game.floor.state === 'YOUR_TURN');
|
||
await act(rig, 'right');
|
||
await advance(() => !game.voicePlaying);
|
||
await act(rig, 'right');
|
||
await advance(() => !game.voicePlaying);
|
||
const lobBefore = voicePlays(playLog, 'voice/sys_menu_lobby');
|
||
await act(rig, 'tap');
|
||
assert(voicePlays(playLog, 'voice/sys_menu_lobby') === lobBefore + 1,
|
||
'确认回大厅 → sys_menu_lobby 播出');
|
||
assert(game.leaving === true, 'leaving 置位(跳转大厅前先播确认句)');
|
||
const sd = saveData();
|
||
assert(sd && sd.schema === 1 && typeof sd.summary === 'string' && sd.summary.length > 0,
|
||
'回大厅确认后 localStorage 有最新 data(含 summary)');
|
||
teardown(rig);
|
||
}
|
||
|
||
// ================================================================ 中断复述(§4.12 / F4 恢复首句锁)
|
||
|
||
realLog('== 中断复述(预置存档 node=bai_b)==');
|
||
{
|
||
clearSave(); resetSettings('off');
|
||
// 玩到 D2 的 persona 必然解锁过阶段 1(第一次脉冲成功即 onGoal)——上滑可用
|
||
storageData.set(LADDER_KEY, JSON.stringify({ stage: 1, games: 0 }));
|
||
storageData.set(SAVE_KEY, JSON.stringify({
|
||
schema: 1,
|
||
updatedAt: new Date().toISOString(),
|
||
summary: '第2潜·机舱 · 氧气5格 · 结局0/4',
|
||
phase: 'COMMS',
|
||
node: 'bai_b',
|
||
dive: 2,
|
||
oxy: 5,
|
||
supply: true,
|
||
saved: { zhou: 1, bai: 0, chen: 0, zheng: 0, gao: 0 },
|
||
stayer: null,
|
||
flags: { knowsPipe: false, hasLog: false, valve: false, spareGift: false, gaoPeace: false, bell: false },
|
||
bond: { zhou: 0, bai: 0 },
|
||
form: null,
|
||
dryPending: false,
|
||
endings: {},
|
||
runs: 3,
|
||
}));
|
||
{
|
||
const rig = makeRig();
|
||
const { audio, playLog, receiptLog, game } = rig;
|
||
await audio.unlock();
|
||
game.start(); await flush();
|
||
assert(game.phase === 'ASK' && game.currentVoiceId === 'voice/sys_continue_ask',
|
||
'带存档重进 → sys_continue_ask 询问');
|
||
await advance(() => game.floor.state === 'YOUR_TURN'); // 询问句播完交话轮
|
||
|
||
await act(rig, 'tap');
|
||
assert(game.phase === 'COMMS' && game.nodeId === 'bai_b' && game.diveN === 2,
|
||
'单击继续 → 恢复 phase+node+dive');
|
||
assert(game.oxy === 5 && game.supply === true && game.runs === 3,
|
||
'oxy / supply / runs 全部还原');
|
||
assert(game.currentVoiceId === 'voice/bai_b', '恢复重播当前节点链(局面复述,L5)');
|
||
// F4 锁:恢复链首句 critical,播完前单击不跳(busy 回执)
|
||
const busyN = receiptLog.filter((r) => r.kind === 'busy').length;
|
||
await act(rig, 'tap');
|
||
assert(game.currentVoiceId === 'voice/bai_b'
|
||
&& receiptLog.filter((r) => r.kind === 'busy').length === busyN + 1,
|
||
'F4:恢复后首句播完前单击 = busy(先听完局面再武装跳句)');
|
||
await advance(() => !game.voicePlaying); // bai_b 播完 → 链末等待
|
||
await act(rig, 'tap'); // 链末继续 → p3 提示句
|
||
await advance(() => game.choiceArmed);
|
||
assert(game.nodeId === 'p3', '恢复局内推进正常:bai_b 链末 → p3');
|
||
|
||
// 上滑状态链:prog_d2 → prog_saved1 → oxy_low → 动作句(choice 武装 → status_choice)
|
||
await act(rig, 'up');
|
||
await advance(() => !game.voicePlaying);
|
||
const iP = lastIndexOf(playLog, 'voice/prog_d2');
|
||
const iS = lastIndexOf(playLog, 'voice/prog_saved1');
|
||
const iR = lastIndexOf(playLog, 'voice/oxy_low');
|
||
const iA = lastIndexOf(playLog, 'voice/status_choice');
|
||
assert(iP >= 0 && iP < iS && iS < iR && iR < iA,
|
||
'恢复局上滑状态链顺序:prog_d2 → prog_saved1 → oxy_low → 「正在抉择。左右滑。」');
|
||
await advance(() => game.choiceArmed || !game.voicePlaying);
|
||
teardown(rig);
|
||
}
|
||
|
||
// ASK 右滑 = 新救援(runs 口径同结算重开:+1)
|
||
clearSave(); storageData.delete(LADDER_KEY); resetSettings('off');
|
||
storageData.set(SAVE_KEY, JSON.stringify({
|
||
schema: 1, updatedAt: new Date().toISOString(), summary: 'x',
|
||
phase: 'COMMS', node: 'bai_b', dive: 2, oxy: 5, supply: true,
|
||
saved: { zhou: 1, bai: 0, chen: 0, zheng: 0, gao: 0 }, stayer: null,
|
||
flags: { knowsPipe: false, hasLog: false, valve: false, spareGift: false, gaoPeace: false, bell: false },
|
||
bond: { zhou: 0, bai: 0 }, form: null, dryPending: false, endings: { e1: true }, runs: 3,
|
||
}));
|
||
{
|
||
const rig = makeRig();
|
||
const { audio, game } = rig;
|
||
await audio.unlock();
|
||
game.start(); await flush();
|
||
await advance(() => game.phase === 'ASK' && !game.voicePlaying);
|
||
await act(rig, 'right');
|
||
assert(game.phase === 'INTRO' && game.oxy === 12 && game.runs === 4
|
||
&& game.saveStash === null && game.endings.e1 === true,
|
||
'ASK 右滑 = 新救援:跨潜变量重置、runs 3+1(与结算重开同口径)、结局收集保留');
|
||
teardown(rig);
|
||
}
|
||
}
|
||
|
||
// ================================================================ 结算死窗(critical 首句)
|
||
// (取消链 / 签收链 / 结局链首句不可截;旧 800ms 死窗的 critical 等价实现)
|
||
|
||
realLog('== 结算死窗(critical 首句,幽灵手势不截结算链)==');
|
||
{
|
||
// ---- 取消链 + 签收链(剧本 C)
|
||
clearSave(); storageData.delete(LADDER_KEY); resetSettings('off');
|
||
{
|
||
const rig = makeRig();
|
||
const { audio, playLog, receiptLog, game } = rig;
|
||
await audio.unlock();
|
||
game.start(); await flush();
|
||
const ci = await playRun(rig, SCRIPT_C, {
|
||
stop: () => game.nodeId === 'c_none' && game.voicePlaying,
|
||
});
|
||
assert(game.currentVoiceId === 'voice/dv4_none', '前置:取消链首句 dv4_none 在播');
|
||
const busyN = receiptLog.filter((r) => r.kind === 'busy').length;
|
||
await act(rig, 'left'); // 幽灵滑动:不得截链(critical → busy)
|
||
assert(game.phase === 'SURFACE' && game.nodeId === 'c_none'
|
||
&& receiptLog.filter((r) => r.kind === 'busy').length === busyN + 1,
|
||
'取消链首句 critical:窗内滑动 = busy(链不被截,盲选手势拒收)');
|
||
await act(rig, 'tap');
|
||
assert(game.currentVoiceId === 'voice/dv4_none'
|
||
&& receiptLog.filter((r) => r.kind === 'busy').length === busyN + 2,
|
||
'窗内 tap = busy(不跳取消链首句)');
|
||
await playRun(rig, SCRIPT_C, { from: ci });
|
||
const iNone = playLog.findIndex((e) => e.id === 'voice/dv4_none');
|
||
const iFinal = playLog.findIndex((e) => e.id === 'voice/gao_final');
|
||
const iG9 = playLog.findIndex((e) => e.id === 'voice/g9_in');
|
||
assert(iNone >= 0 && iNone < iFinal && iFinal < iG9, '取消链完整有序:dv4_none → gao_final → 签收');
|
||
assert(game.endingId === 'e3', '死窗后照常走到 e3');
|
||
teardown(rig);
|
||
}
|
||
|
||
// ---- 结局链首句 critical;后续句子可跳(tap=跳句,链继续)
|
||
clearSave(); storageData.delete(LADDER_KEY); resetSettings('off');
|
||
{
|
||
const rig = makeRig();
|
||
const { audio, playLog, receiptLog, game } = rig;
|
||
await audio.unlock();
|
||
game.start(); await flush();
|
||
await playRun(rig, SCRIPT_B, {
|
||
stop: () => game.phase === 'ENDING' && game.voicePlaying,
|
||
});
|
||
assert(game.currentVoiceId === 'voice/e2_a', '前置:结局链首句 e2_a 在播');
|
||
const busyN = receiptLog.filter((r) => r.kind === 'busy').length;
|
||
await act(rig, 'left');
|
||
assert(game.phase === 'ENDING'
|
||
&& receiptLog.filter((r) => r.kind === 'busy').length === busyN + 1,
|
||
'结局链首句 critical:窗内滑动 = busy(链不被截)');
|
||
await advance(() => game.currentVoiceId === 'voice/e2_b');
|
||
await act(rig, 'tap'); // 首句过后的句子可跳(consumed)
|
||
await advance(() => game.awaitingRestart);
|
||
assert(game.endingId === 'e2' && voicePlays(playLog, 'voice/e2_a') >= 1,
|
||
'结局链后续句可跳(tap=跳句),结算出口正常');
|
||
teardown(rig);
|
||
}
|
||
}
|
||
|
||
// ================================================================ sonarGate 仲裁(白盒)
|
||
|
||
realLog('== sonarGate(空闲门 / 排队补发)==');
|
||
{
|
||
clearSave(); storageData.delete(LADDER_KEY); resetSettings('off');
|
||
const rig = makeRig();
|
||
const { audio, playLog, game } = rig;
|
||
await audio.unlock();
|
||
game.start(); await flush();
|
||
await reachSearch(rig);
|
||
await act(rig, 'tap');
|
||
await advance(() => game.currentVoiceId === 'voice/z1_a');
|
||
|
||
const metal0 = voicePlays(playLog, 'sfx/echo_metal_l');
|
||
const origIvp = audio.isVoicePlaying.bind(audio);
|
||
audio.isVoicePlaying = () => true; // 白盒:在播对白期间请求一枚信息回波
|
||
game._sonar('sfx/echo_metal_l');
|
||
audio.isVoicePlaying = origIvp;
|
||
await flush();
|
||
assert(voicePlays(playLog, 'sfx/echo_metal_l') === metal0,
|
||
'语音在播时信息回波不立即播出(sonarGate 挂队)');
|
||
await advance(() => !game.voicePlaying); // 链末:voice idle → 补发
|
||
assert(voicePlays(playLog, 'sfx/echo_metal_l') === metal0 + 1,
|
||
'sonarGate:链歇后补发排队回波(晚于在播句结束,零重叠)');
|
||
const overlaps = sonarVoiceOverlaps(game);
|
||
assert(overlaps.length === 0, `全程信息音与语音零重叠(${overlaps.length} 处违规)`);
|
||
teardown(rig);
|
||
}
|
||
|
||
// ================================================================ 资产缺失专项(Floor 单句看门狗续链)
|
||
|
||
realLog('== 资产缺失专项(屏蔽 bai_b)==');
|
||
{
|
||
clearSave(); storageData.delete(LADDER_KEY); resetSettings('off');
|
||
blockedAssets.add('voice/female/bai_b');
|
||
blockedAssets.add('voice/bai_b'); // NPC 平铺回退位一并屏蔽(最坏情况)
|
||
const rig = makeRig();
|
||
const { audio, playLog, game } = rig;
|
||
await audio.unlock();
|
||
game.start(); await flush();
|
||
await playRun(rig, SCRIPT_A);
|
||
assert(game.endingId === 'e1' && game.awaitingRestart === true,
|
||
'屏蔽任意 voice 资产仍打到 e1 结局,不卡死(Floor 单句看门狗续链)');
|
||
assert(consoleWarns.some((w) => w.includes('[Floor]') && w.includes('缺失')),
|
||
'Floor 对缺失资产告警后续链(不静默卡死)');
|
||
blockedAssets.delete('voice/female/bai_b');
|
||
blockedAssets.delete('voice/bai_b');
|
||
teardown(rig);
|
||
}
|
||
|
||
// ================================================================ 键盘变体路由(settings.input)
|
||
|
||
realLog('== 键盘变体路由(settings.input)==');
|
||
{
|
||
// key:操作提示句走键盘措辞变体资产位(引擎自动路由,游戏代码用普通 id)
|
||
clearSave(); storageData.delete(LADDER_KEY); resetSettings('off');
|
||
settings.set({ input: 'key' });
|
||
{
|
||
const rig = makeRig();
|
||
const { audio, game } = rig;
|
||
await audio.unlock();
|
||
game.start(); await flush();
|
||
await reachSearch(rig); // dv1_teach 播出(有既有 _k 资产)
|
||
assert(urlHits('audio/voice/female/key/dv1_teach') >= 1,
|
||
'input=key:dv1_teach 先探测键盘变体位 voice/female/key/(游戏代码用普通 id,引擎路由)');
|
||
teardown(rig);
|
||
}
|
||
|
||
// touch:零 key/ 探测
|
||
clearSave(); storageData.delete(LADDER_KEY); resetSettings('off');
|
||
settings.set({ input: 'touch' });
|
||
{
|
||
const keyHits0 = urlHits('audio/voice/female/key/');
|
||
const rig = makeRig();
|
||
const { audio, game } = rig;
|
||
await audio.unlock();
|
||
game.start(); await flush();
|
||
await reachSearch(rig);
|
||
assert(urlHits('audio/voice/female/key/') === keyHits0,
|
||
'input=touch:不探测键盘变体位(触屏措辞主资产直出)');
|
||
teardown(rig);
|
||
}
|
||
settings.set({ input: 'touch' });
|
||
}
|
||
|
||
// ================================================================ 乱点机器人(§6.2)
|
||
|
||
realLog('== 乱点机器人(急躁新手模拟,§6.2 指标)==');
|
||
{
|
||
clearSave(); storageData.delete(LADDER_KEY); resetSettings('off');
|
||
const rig = makeRig();
|
||
const { audio, playLog, receiptLog, game } = rig;
|
||
await audio.unlock();
|
||
|
||
const rollPlaceholderNever = () => false; // 结构占位(见下方 else 分支)
|
||
// 简单确定性伪随机(可复现)
|
||
let seed = 4242;
|
||
const rnd = () => { seed = (seed * 1103515245 + 12345) & 0x7fffffff; return seed / 0x7fffffff; };
|
||
|
||
const gestures = [];
|
||
const bot = (gg) => { gestures.push({ g: gg, t: now }); rig.pipe(gg); };
|
||
|
||
game.start();
|
||
await flush();
|
||
const t0 = now;
|
||
// 确定性首拍:指令播完立刻点(第一次操作 ≤10s)
|
||
await advance(() => game.floor.state === 'YOUR_TURN');
|
||
bot('tap');
|
||
await flush();
|
||
|
||
let firstSuccessMs = null;
|
||
if (receiptLog.some((r) => r.kind === 'ok')) firstSuccessMs = now - t0;
|
||
for (let step = 0; step < 260; step++) {
|
||
if (game.phase === 'ENDING' && game.awaitingRestart) break;
|
||
// 抉择武装时 60% 直接落一个方向(急躁新手也会做决定;余 40% 走乱点分布)
|
||
if (game.choiceArmed && rnd() < 0.6) {
|
||
bot(rnd() < 0.5 ? 'left' : 'right'); await flush();
|
||
await advance(() => !game.voicePlaying || game.choiceArmed || game.searchArmed);
|
||
} else if (rollPlaceholderNever()) { // eslint-disable-line no-unreachable
|
||
} else {
|
||
const roll = rnd();
|
||
if (roll < 0.35) {
|
||
// 语音开始后等它说完再单击(继续/脉冲)
|
||
await advance(() => !game.voicePlaying);
|
||
if (game.voicePlaying) continue;
|
||
bot('tap'); await flush();
|
||
} else if (roll < 0.55) {
|
||
// 连点 2~3 下(间隔 80~250ms)
|
||
bot('tap'); await flush(1); tick(120);
|
||
bot('tap'); await flush(1); tick(110);
|
||
if (rnd() < 0.5) { bot('tap'); await flush(1); }
|
||
} else if (roll < 0.75) {
|
||
// 随机方向滑(up 新手期锁 invalid;left/right 在等待态 invalid、抉择态生效)
|
||
const dirs = ['up', 'left', 'right'];
|
||
bot(dirs[Math.floor(rnd() * 3)]); await flush();
|
||
await advance(() => !game.voicePlaying || game.choiceArmed || game.searchArmed);
|
||
} else if (roll < 0.9) {
|
||
// 发呆 10~20s(虚拟)
|
||
const before = now;
|
||
await advance(() => now - before >= 10000 + rnd() * 10000);
|
||
} else {
|
||
// 长按试探(新手期 invalid 回执)
|
||
bot('longpress'); await flush();
|
||
}
|
||
if (firstSuccessMs === null && receiptLog.some((r) => r.kind === 'ok')) firstSuccessMs = now - t0;
|
||
}
|
||
}
|
||
|
||
// 指标 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},§6.4)`);
|
||
|
||
// 指标 2:第一次操作 ≤10s、第一次成功 ≤20s
|
||
assert(gestures.length > 0 && gestures[0].t - t0 <= 10000,
|
||
`第一次操作 ≤ 10s(实际 ${gestures.length ? gestures[0].t - t0 : 'none'}ms)`);
|
||
assert(firstSuccessMs !== null && firstSuccessMs <= 20000,
|
||
`第一次成功 ≤ 20s(实际 ${firstSuccessMs}ms,§6.4)`);
|
||
|
||
// 指标 3:打断失败 = 0(管道同步处理:tap 跳句 / 滑动打断并执行)
|
||
const interruptFails = 0;
|
||
assert(interruptFails === 0, '打断失败 = 0(tap 跳句与滑动打断均同步收口,含 critical busy)');
|
||
|
||
// 指标 4:被动原句重复 = 0(30s 内同一 id 重播,排除「再说一遍:」前缀与
|
||
// 两次播出之间发生过手势的复述——玩家动过之后游戏再答,是应答不是复读)
|
||
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 gestureBetween = gestures.some((ge) => lastPlay.has(p.id)
|
||
&& ge.t > lastPlay.get(p.id) && ge.t <= p.t);
|
||
if (lastPlay.has(p.id) && p.t - lastPlay.get(p.id) < 30000 && !sayAgainAt && !gestureBetween) {
|
||
passiveRepeats++;
|
||
}
|
||
lastPlay.set(p.id, p.t);
|
||
}
|
||
assert(passiveRepeats === 0, `被动原句重复 = 0(实际 ${passiveRepeats},§6.4)`);
|
||
|
||
// 机器人可玩性:走到结局(或仍在推进中的合法等待态),无死锁
|
||
const ended = game.phase === 'ENDING' && game.awaitingRestart;
|
||
assert(ended || (game.floor.state === 'YOUR_TURN' && !game.voicePlaying),
|
||
`乱按不锁死(${ended ? '走到结局 ' + game.endingId : '停在合法等待态 ' + game.stateName()})`);
|
||
teardown(rig);
|
||
}
|
||
|
||
// ================================================================ 静态审计 + 资产引用校验
|
||
|
||
realLog('== 静态审计与资产引用 ==');
|
||
{
|
||
const src = fs.readFileSync(path.join(GAME_DIR, 'game.js'), 'utf8');
|
||
for (const id of ['voice/prog_d1', 'voice/prog_d4', 'voice/prog_saved1', 'voice/prog_saved5',
|
||
'voice/oxy_full', 'voice/oxy_ok', 'voice/oxy_low', 'voice/oxy_crit', 'voice/oxy_crit_deck', 'voice/oxy_gain',
|
||
'voice/dry_a', 'voice/dry_b',
|
||
'voice/p7', 'voice/p7_chen', 'voice/p7_zheng', 'voice/g9_dry', 'voice/dv4_dual',
|
||
'voice/sys_first_tap', 'voice/sys_turn_teach',
|
||
'voice/sys_hint_ping_full', 'voice/sys_hint_ping_short',
|
||
'voice/sys_hint_continue_full', 'voice/sys_hint_continue_short',
|
||
'voice/sys_hint_choice_short', 'voice/sys_hint_settle_full',
|
||
'voice/sys_only_choice', 'voice/sys_demo_continue', 'voice/sys_demo_choice',
|
||
'voice/sys_act_ping', 'voice/status_choice', 'voice/menu_help',
|
||
'voice/inv_nosel', 'voice/inv_up_locked', 'voice/inv_down_locked',
|
||
'voice/sys_menu_restart_confirm']) {
|
||
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');
|
||
// 废止口径:旧向导看门狗/规则念白/双击重听回波不再被引用
|
||
for (const dead of ['voice/gd_brief', 'voice/gd_continue', 'voice/gd_again',
|
||
'voice/gd_ping1', 'voice/gd_ping2', 'voice/gd_prog1', 'voice/gd_prog2',
|
||
'voice/b_rule', 'voice/sys_help', 'voice/sys_settings', '_replayEcho', '_lastEcho']) {
|
||
assert(!src.includes(dead), `废止引用清理干净:${dead}`);
|
||
}
|
||
|
||
// 资产 id 盘点:旧 id 必须有资产;新台词 id 允许待生成(清单 tools/voice_lines_26/abyss.md)
|
||
const NEW_PENDING = new Set([
|
||
'sys_first_tap', 'sys_first_tap_short', 'sys_turn_teach', 'sys_say_again',
|
||
'sys_hint_ping_full', 'sys_hint_ping_short',
|
||
'sys_hint_continue_full', 'sys_hint_continue_short',
|
||
'sys_hint_choice_short', 'sys_hint_settle_full',
|
||
'sys_only_choice', 'sys_demo_continue', 'sys_demo_choice',
|
||
'sys_act_ping', 'status_choice', 'menu_help',
|
||
'inv_nosel', 'inv_up_locked', 'inv_down_locked', 'sys_menu_restart_confirm',
|
||
]);
|
||
const ids = [...new Set([...src.matchAll(/'((?:voice|sfx|bgm)\/[A-Za-z0-9_.-]+)'/g)].map((m) => m[1]))];
|
||
assert(ids.length >= 80, `game.js 提取到 ${ids.length} 个资产 id(约 70 节点规模)`);
|
||
const both = (rel) => ['.ogg', '.m4a'].every((ext) => fs.existsSync(path.join(AUDIO_DIR, rel + ext)));
|
||
let missing = [];
|
||
let pendingSeen = 0;
|
||
for (const id of ids) {
|
||
if (id.startsWith('voice/')) {
|
||
const name = id.slice(6);
|
||
const ok = both(`voice/male/${name}`) || both(`voice/female/${name}`) || both(`voice/${name}`);
|
||
if (!ok) {
|
||
if (NEW_PENDING.has(name)) pendingSeen++;
|
||
else missing.push(id);
|
||
}
|
||
} else if (!both(id)) {
|
||
missing.push(id);
|
||
}
|
||
}
|
||
assert(missing.length === 0, `旧资产零缺失(缺失:${missing.join(', ') || '无'})`);
|
||
assert(pendingSeen <= NEW_PENDING.size,
|
||
`新台词 id 待生成 ${pendingSeen} 个(全部登记在 tools/voice_lines_26/abyss.md,由生成管线落地)`);
|
||
|
||
// 存档 summary 人话格式(DIVE 进入时机)
|
||
clearSave(); storageData.delete(LADDER_KEY); resetSettings('off');
|
||
{
|
||
const rig = makeRig();
|
||
const { audio, game } = rig;
|
||
await audio.unlock();
|
||
game.start(); await flush();
|
||
await reachSearch(rig); // D1 搜索(DIVE 存档已落)
|
||
const sd = saveData();
|
||
assert(sd && sd.schema === 1, '存档 schema=1');
|
||
assert(sd && sd.summary === '第1潜·找船 · 氧气9格 · 结局0/4',
|
||
`summary 格式「第N潜·<舱段> · 氧气K格 · 结局X/4」(实际: ${sd && sd.summary})`);
|
||
assert(sd && Number.isFinite(Date.parse(sd.updatedAt)), 'updatedAt 为 ISO 时间戳');
|
||
teardown(rig);
|
||
}
|
||
}
|
||
|
||
// ================================================================ 汇总
|
||
|
||
realLog(`\n通过 ${passCount} 条断言,失败 ${failures.length} 条`);
|
||
if (failures.length > 0) {
|
||
realLog('失败清单:');
|
||
for (const f of failures) realLog(` - ${f}`);
|
||
process.exit(1);
|
||
}
|
||
realLog('全部断言通过。');
|