Files
SoundGame/web/bridge/test/playtest.mjs
T
xsl 4b39ae2e49 feat(new8/batch3-4): cipher/teahouse/keeper/bridge 全套交付——new8 八款全部可交付收官
四泳道并行全流水线(资产→开发→测试→玩家迭代闭环),双双 R1-R2 连续达标裁定可交付:
- cipher《暗码》223 断言:摩尔斯解码嵌叙事/四结局矩阵/暴露度逐NIGHT验算/三层兜底;R2 三修(链首标记音/预警震/结局计数句+4句补录)4/4 全满分档
- teahouse《深夜茶馆》205 断言:泡茶时机窗/经济逐晚对账/多晚弧线;连续 2 轮 4/4 愿意玩 P0=0
- keeper《守塔人》257 断言:时空签名教学-兑现/插叙触发回返/trust×truth 档位/4结局;R2 F1/F2 坐实(震型豁免/首播跳句锁)4/4
- bridge《星环舰长》336 断言:巡检节奏逐毫秒/危机×系统态矩阵/仲裁7场景/忠诚差分/评级验算;R1 当场修F1-F4、R2 F5 菜单顺延保护 4/4 上行

公共:index.html 四卡片+profiles.js 四桶+check_all_assets 登记(预接线)、00-program 八款进度账收账、
gen_voice_dual 四游戏段、README/HANDOVER 阵容更新(11 款)、voice_text.json 四游戏键、mureka 台账(累计$1.26/$2.00)

验证:四游戏 playtest 223/205/257/336 全绿 + guide 122/122 + 全项目 15 游戏资产终检绿
遗留:keeper 三代人声滤波人耳试听(最高优先,ASR 对滤波句失明)、bridge 音频 32MB 超软指引 2MB(压缩候选)
2026-09-14 05:48:13 +08:00

1153 lines
55 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 《星环舰长》无头试玩测试(docs/design/22-bridge.md §12 断言清单 14 组)。
*
* 以玩家身份模拟:mock 浏览器环境(window/navigator/localStorage/fetch/虚拟时钟)
* 与音频引擎接口(播放日志 + voice onComplete 按虚拟时钟推进语音链),加载真实
* game.js,手势直接注入 game.onGesture。mock 层机制对齐 web/detective/test/playtest.mjs
* (navigator.maxTouchPoints=1 手机 persona;guide:'off' 回归纯玩法逻辑)。
*
* 资产契约:引擎对缺失资产只 console.error 不崩——本测试在资产未落盘时也必须
* 全绿(每句 8s 兜底续链推进),故 plays() 查询不过滤 missing 项;资产齐套后同样
* 全绿(顺序/次数断言与时长无关)。资产审计(双格式/双性别集合/pan 派生)做信息性
* 报告不判失败,硬格式断言只针对「必须存在」的键(场景 14)。
*
* 运行:tools/node/bin/node web/bridge/test/playtest.mjs
* 退出码 0 = 全部断言通过。seed 由 opts 注入复现(§4.3)。
*/
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const BRIDGE_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
// ================================ 虚拟时钟 ================================
let vnow = 0; // 虚拟毫秒
const timers = new Map();
let nextTimerId = 1;
function schedule(ms, fn) {
const id = nextTimerId++;
timers.set(id, { at: vnow + ms, fn });
return id;
}
function unschedule(id) { timers.delete(id); }
async function flushMicro() {
for (let i = 0; i < 5; i++) await Promise.resolve();
}
/** 推进虚拟时钟 ms 毫秒,期间到期的计时器按序触发(含语音 onComplete 链)。 */
async function tick(ms) {
const target = vnow + ms;
for (let guard = 0; guard < 300000; guard++) {
await flushMicro();
let best = null;
for (const [id, t] of timers) {
if (t.at <= target && (!best || t.at < best.t.at)) best = { id, t };
}
if (!best) break;
timers.delete(best.id);
vnow = best.t.at;
best.t.fn();
}
vnow = target;
await flushMicro();
}
// ================================ 浏览器环境 mock ================================
const storageMap = new Map();
const localStorageMock = {
getItem: (k) => (storageMap.has(k) ? storageMap.get(k) : null),
setItem: (k, v) => { storageMap.set(k, String(v)); },
removeItem: (k) => { storageMap.delete(k); },
clear: () => storageMap.clear(),
};
const vibrateLog = [];
const fetchLog = [];
Object.defineProperty(globalThis, 'performance', { value: { now: () => vnow }, configurable: true });
globalThis.setTimeout = (fn, ms = 0) => schedule(ms, fn);
globalThis.clearTimeout = unschedule;
globalThis.localStorage = localStorageMock;
globalThis.fetch = (url, opts) => { fetchLog.push({ url, opts }); return Promise.resolve({ ok: true, json: async () => ({}) }); };
Object.defineProperty(globalThis, 'navigator', {
value: { maxTouchPoints: 1, vibrate: (p) => { vibrateLog.push({ t: vnow, p }); return true; } },
configurable: true,
});
globalThis.window = { addEventListener: () => {}, location: { href: '' } };
const origConsoleError = console.error.bind(console);
console.error = () => {}; // 资产缺失走信息性报告,不刷屏
const origConsoleLog = console.log.bind(console);
// ================================ 资产存在性 ================================
function hasPair(base) {
return fs.existsSync(base + '.ogg') && fs.existsSync(base + '.m4a');
}
/** voice:旁白双性别齐 或 NPC 平铺;sfx/bgm:双格式。 */
function assetExists(id) {
const audioDir = path.join(BRIDGE_DIR, 'audio');
if (id.startsWith('voice/')) {
const name = id.slice(6);
return (hasPair(path.join(audioDir, 'voice', 'female', name))
&& hasPair(path.join(audioDir, 'voice', 'male', name)))
|| hasPair(path.join(audioDir, 'voice', name));
}
return hasPair(path.join(audioDir, id));
}
// ================================ 音频引擎 mock ================================
const VOICE_DUR = 1.0; // 假句长(秒):资产存在时语音链按虚拟时钟推进
class MockAudioEngine {
constructor() {
this.log = [];
this._voice = null;
this.bgmNow = null;
}
isVoicePlaying() { return this._voice !== null; }
setRateBoostEnabled() {}
play(id, opts = {}) {
const layer = id.split('/')[0];
const missing = !assetExists(id);
this.log.push({ t: vnow, layer, id, opts, missing });
if (layer === 'voice') {
this._voice = null; // 顶掉当前句(不触发其 onComplete,对齐引擎语义)
const token = { id, onComplete: opts.onComplete };
this._voice = token;
schedule(VOICE_DUR * 1000, () => {
if (this._voice === token) {
this._voice = null;
if (token.onComplete) token.onComplete();
}
});
} else if (layer === 'bgm') {
this.bgmNow = id;
}
}
stopVoice() { this._voice = null; }
stopBgm() { this.log.push({ t: vnow, layer: 'bgm', id: '(stop)', opts: {} }); this.bgmNow = null; }
stopAll() { this.stopBgm(); this.stopVoice(); }
prewarm() {}
}
// ================================ 断言设施 ================================
let passCount = 0;
const failures = [];
function assert(cond, msg) {
if (cond) { passCount++; }
else { failures.push(msg); origConsoleError(` ✗ ${msg}`); }
}
function ok(msg) { passCount++; origConsoleLog(` ✓ ${msg}`); }
/** 播放日志查询(含 missing 项——顺序/次数断言与资产落盘与否无关)。 */
function plays(audio, from = 0) {
const L = audio.log.slice(from);
return {
has: (id) => L.some((e) => e.id === id),
count: (id) => L.filter((e) => e.id === id).length,
orderOk: (...ids) => {
let pos = -1;
for (const id of ids) {
const i = L.findIndex((e, j) => j > pos && e.id === id);
if (i < 0) return false;
pos = i;
}
return true;
},
entries: () => L,
entryOf: (id) => L.find((e) => e.id === id),
sfxBetween: (t0, t1, id) => L.filter((e) => e.id === id && e.t >= t0 && e.t < t1),
};
}
const ERROR_PATTERN = JSON.stringify([60, 40, 60]);
const HIT_PATTERN = JSON.stringify([80]);
function vibrated(pattern, fromIdx = 0) {
return vibrateLog.slice(fromIdx).some((v) => JSON.stringify(v.p) === pattern);
}
// ================================ 游戏装配 ================================
const { BridgeGame } = await import('../game.js');
const { settings } = await import('../../engine/settings.js');
settings.set({ guide: 'off', voice: 'female', speed: 1.0, bgm: 1.0, input: 'touch' });
function newGame(opts = {}) {
const audio = new MockAudioEngine();
const game = new BridgeGame(audio, {}, opts);
return { game, audio };
}
const g = (game, gesture) => game.onGesture(gesture);
async function waitState(pred, maxMs = 30000) {
const t0 = vnow;
while (!pred() && vnow - t0 < maxMs) await tick(100);
return pred();
}
/** 巡检链等链播完的通用等待:无 voice 在播且无进行中的 tone 段。 */
async function waitIdle(audio, game, maxMs = 60000) {
const t0 = vnow;
while (vnow - t0 < maxMs) {
await tick(200);
if (!audio.isVoicePlaying() && !game._tourState) return true;
}
return false;
}
// ================================ 场景 1:TITLE / PORT / 经济骨架 ================================
async function scenario1() {
origConsoleLog('\n=== 场景 1:TITLE/PORT/概况链/存档摘要 ===');
storageMap.clear();
const { game, audio } = newGame({ seed: 42 });
game.start();
await tick(400);
assert(game.state === 'TITLE' && !game.run, '启动进 TITLE(无存档)');
assert(plays(audio).has('bgm/menu'), 'TITLE bgm/menu');
assert(game._graceUntil === 0, 'REPORT 死窗戳初始为 0');
// 无档双击 → intro 链(22 §15-R2 裁定)
let mark = audio.log.length;
g(game, 'doubletap');
await waitState(() => plays(audio, mark).has('voice/intro_5'), 20000);
assert(plays(audio, mark).orderOk('voice/intro_1', 'voice/intro_5'), '无档双击重听 intro_1..5');
await waitIdle(audio, game);
// 概况链:无改装 → 仅功勋档
mark = audio.log.length;
g(game, 'up');
await waitState(() => plays(audio, mark).has('voice/merit_1'), 10000);
assert(true, '概况链播功勋档 merit_1(merit 0)');
await waitIdle(audio, game);
// PORT:全部 6 项 + 返回(R9 裁定);功勋不够 → port_poor
mark = audio.log.length;
g(game, 'right');
await waitState(() => plays(audio, mark).has('voice/upg_spare'), 10000);
assert(game.state === 'PORT', '右滑进 PORT');
assert(plays(audio, mark).orderOk('voice/port_open', 'voice/upg_spare'), 'PORT 开场 port_open+首项 upg_spare');
g(game, 'tap');
await tick(1400);
assert(plays(audio).has('voice/port_poor') && vibrated(ERROR_PATTERN), '功勋不足购买 → port_poor + error 震');
assert(game.meta.merit === 0 && game.meta.unlocks.length === 0, '购买失败不扣账');
// 左右滑循环 + 已购预览路径(造一个已购项)
game.meta.merit = 100;
game.meta.unlocks.push('spare');
for (let i = 0; i < 6; i++) { g(game, 'right'); await tick(1200); }
assert(game._port.idx === 0, 'PORT 5 项+返回共 6 位循环回 0(armor2 依赖未出现)');
g(game, 'right'); g(game, 'right'); g(game, 'right'); g(game, 'right');
await tick(1200);
assert(game._port.idx === 4, '右滑 4 次到第 5 项');
g(game, 'right');
await tick(1200);
assert(plays(audio).has('voice/port_back'), '末项=port_back');
g(game, 'tap');
await tick(300);
assert(game.state === 'TITLE', '返回项回 TITLE');
// armor2 依赖:未购 armor1 时列表无 armor2
game.meta.unlocks = ['spare'];
g(game, 'right');
await tick(200);
const items = game._portItems().map((u) => u.id);
assert(!items.includes('armor2'), 'armor2 依赖 armor1(未购不出现)');
assert(items.length === 5, '未购 armor1 时 PORT 5 项');
game.meta.unlocks.push('armor1');
assert(game._portItems().some((u) => u.id === 'armor2'), 'armor1 已购后 armor2 出现');
// 购买成功 + 已购预览 upg_done + 不重复购买
game.meta.merit = 100;
game._port.idx = 1; // harvest
mark = audio.log.length;
g(game, 'tap');
await tick(200);
assert(game.meta.unlocks.includes('harvest') && game.meta.merit === 70, '购买 harvest 扣 30 功勋');
assert(plays(audio, mark).has('voice/upg_buy') && vibrated(JSON.stringify([100])), '购买成功 upg_buy + success 震');
g(game, 'doubletap');
await tick(200);
assert(plays(audio).has('voice/upg_done'), '已购项预览播 upg_done(不重复购买路径)');
// 存档摘要规则(有 run):登录态才有 PUT
storageMap.set('soundgame.auth', JSON.stringify({ username: 't', token: 'x' }));
game.run = { ...game._newRun(), seg: 2, hull: 4, rp: 5, sys: { engine: 'dmg', shield: 'ok', scan: 'ok', weapon: 'ok' } };
game._save();
const saved = JSON.parse(storageMap.get('soundgame.progress.bridge'));
assert(saved.summary === '第2巡航段 · 舰体带伤 · 引擎异常 · 资源5', `summary 生成规则(实际 ${saved.summary})`);
assert(fetchLog.some((f) => f.url === '/api/progress' && f.opts?.method === 'PUT'
&& JSON.parse(f.opts.body).data.summary === saved.summary), 'PUT 进度必带 summary');
game.run = null;
game._save();
const saved2 = JSON.parse(storageMap.get('soundgame.progress.bridge'));
assert(/^功勋\d+ · 最佳.+ · 改装\d\/6$/.test(saved2.summary), `巡逻终局 summary(实际 ${saved2.summary})`);
ok('场景 1 通过');
}
// ================================ 场景 2:首局 DRILL 全链 + 学期零惩罚 ================================
async function scenario2() {
origConsoleLog('\n=== 场景 2:TITLE→DRILL D0..D4 + S1 驰援成功链 + 二周目不再进 ===');
storageMap.clear();
const { game, audio } = newGame({ seed: 42 });
game.start();
await tick(400);
let mark = audio.log.length;
g(game, 'tap'); // 新巡逻
await waitState(() => game.run && game.state === 'CRUISE' && game._drill?.armed, 40000);
assert(game.run.seed === 42, 'opts.seed=42 注入新巡逻');
assert(plays(audio, mark).orderOk('voice/sys_new_patrol', 'voice/intro_1', 'voice/intro_2', 'voice/intro_3', 'voice/intro_4', 'voice/intro_5', 'voice/tut_d0'),
'D0 序:sys_new_patrol → intro_1..5 → tut_d0');
assert(plays(audio).has('bgm/cruise'), '巡航 bgm/cruise');
assert(game.run.rp === 2, 'S1 采集 +2(稳妥线,首局固定)');
assert(game._drill.step === 0 && game._drill.armed, 'D0 已武装(链播完才武装)');
// D0:上滑 → 巡检全链(全 ok → tut_all_ok 尾句)
mark = audio.log.length;
g(game, 'up');
await tick(10300); // tone 段 4×2.4s 播完
const tones = ['sfx/sys_engine_ok', 'sfx/sys_shield_ok', 'sfx/sys_scan_ok', 'sfx/sys_weapon_ok'];
assert(plays(audio, mark).orderOk(...tones), 'D0 巡检全链四状态音固定序(引擎→护盾→扫描→武器)');
{
const ts = tones.map((id) => plays(audio, mark).entryOf(id).t);
for (let i = 1; i < 4; i++) {
assert(Math.abs(ts[i] - ts[i - 1] - 2400) <= 50, `巡检音间隔 2.0s+0.4s(第 ${i} 间距 ${ts[i] - ts[i - 1]}ms)`);
}
}
await waitState(() => game._drill.step === 1, 40000);
await waitState(() => plays(audio, mark).has('voice/tut_crisis'), 30000);
assert(plays(audio, mark).has('voice/tut_all_ok'), '全 ok 巡检链尾播 tut_all_ok');
assert(plays(audio, mark).orderOk('voice/tut_all_ok', 'voice/tut_crisis'), 'D0→D1 衔接 tut_crisis');
assert(plays(audio, mark).orderOk('voice/st_hull_1', 'voice/st_seg_1', 'voice/st_rp_1', 'voice/st_xo_2', 'voice/st_eng_2', 'voice/st_doc_2'),
'巡检账目段:舰体/段/资源/三船员档');
// D1:等 S1 段计时到 → 求救检测链(首局固定 distress 无陷阱)
mark = audio.log.length;
await waitState(() => game.state === 'CRISIS', 95000);
assert(game._crisis.type === 'distress' && !game._crisis.trap, '首局 S1 危机=求救且无陷阱(§4.3 承诺)');
const dir0 = game.run.plan.dirs[0];
const sigId = 'sfx/sig_distress' + ({ port: '_l', star: '_r', bow: '', stern: '' })[dir0];
assert(plays(audio, mark).has(sigId), `检测链签名音 ${sigId}(方位 ${dir0} 预 pan 派生)`);
if (dir0 === 'bow') assert(plays(audio, mark).has('sfx/dir_bow'), '舰首方位附 dir_bow 上行尾音');
if (dir0 === 'stern') assert(plays(audio, mark).has('sfx/dir_stern'), '舰尾方位附 dir_stern 下行尾音');
assert(!plays(audio, mark).has('voice/trap_hint'), '非陷阱局零 trap_hint');
await waitState(() => game._crisis && game._crisis.subphase === 'decide' && game._drill?.step === 2, 40000);
assert(plays(audio, mark).orderOk('voice/evt_distress', 'voice/dir_' + dir0, 'voice/cr_decide'), '检测链:类型句→方位句→cr_decide');
assert(plays(audio, mark).orderOk('voice/cr_decide', 'voice/opt_aid'), '决策窗自动预览第一选项 opt_aid');
assert(plays(audio, mark).has('bgm/crisis'), '危机 bgm/crisis');
// D2:右滑预览 → 左滑回 aid → 单击下令驰援
mark = audio.log.length;
g(game, 'right');
await tick(200);
assert(plays(audio, mark).has('voice/opt_ignore'), '右滑预览第二选项 opt_ignore');
g(game, 'left');
await tick(200);
assert(plays(audio, mark).has('voice/opt_aid'), '左滑循环回 opt_aid');
const hullBefore = game.run.hull, rpBefore = game.run.rp;
mark = audio.log.length;
g(game, 'tap'); // 下令驰援(D2 教学接管)
await tick(300);
assert(plays(audio, mark).orderOk('sfx/pa_chime', 'sfx/warp'), '下令链:pa_chime→warp(驰援执行音)');
await waitState(() => game._drill && game._drill.step === 3, 40000);
await waitState(() => plays(audio, mark).has('voice/tut_loyal'), 30000);
assert(plays(audio, mark).orderOk('voice/res_reward', 'voice/loy_aid', 'voice/doc_aid'), '驰援成功链 res_reward→loy_aid→doc_aid');
assert(plays(audio, mark).has('voice/st_rp_2'), 'RP 2→4 跨档播 st_rp_2(播报值=运行值)');
assert(game.run.hull === hullBefore, '学期零惩罚:驰援成功无伤');
assert(game.run.rp === rpBefore + 2 && game.run.aidOk === 1, '驰援入账 +2 RP / aidOk=1');
assert(game.run.crew.xo === 1 && game.run.crew.doc === 1, '忠诚差分:大副+1 医师+1(§5.1 驰援成功行)');
assert(plays(audio, mark).orderOk('voice/tut_after', 'voice/tut_loyal'), 'D2→D3:tut_after→tut_loyal');
// D3:上滑 → 巡检链 → tut_done → 自由巡航 → 段末 DOCK
await waitState(() => game._drill?.armed, 20000); // 等 tut_loyal 落声武装
mark = audio.log.length;
g(game, 'up');
await waitState(() => plays(audio, mark).has('voice/tut_done'), 40000);
await waitState(() => game.state === 'DOCK', 60000);
assert(plays(audio, mark).has('voice/tut_done'), 'D3→D4 tut_done');
assert(game.run.drillDone && game.meta.drillDone, 'drillDone 持久化(meta)');
assert(plays(audio).has('voice/dock_empty') || plays(audio).has('voice/dock_open'), '段末进 DOCK');
// —— R11 教学危机超时收束:D0 完成后不在决策窗下令 → tut_done 收束 → DOCK + drillDone 持久化
storageMap.clear();
const g3 = newGame({ seed: 7 });
g3.game.start();
await tick(300);
g(g3.game, 'tap');
await waitState(() => g3.game._drill?.armed, 40000);
g(g3.game, 'up'); // D0 巡检
await waitState(() => g3.game._drill?.step === 1, 60000);
await waitState(() => g3.game.state === 'CRISIS', 120000);
const m3 = g3.audio.log.length;
await waitState(() => g3.game.state === 'DOCK', 210000); // 20s 窗超时 → 收束(D3 90s 自动演示)→ 段末
assert(plays(g3.audio, m3).has('voice/cr_timeout'), 'R11:教学危机超时走默认结局(真话)');
assert(plays(g3.audio, m3).has('voice/tut_done'), 'R11:收束播 tut_done');
assert(g3.game.meta.drillDone && g3.game.run.drillDone, 'R11:drillDone 持久化(次局不再进教学)');
ok('场景 2 通过(首局 DRILL 全链 + R11 超时收束)');
}
// ================================ 场景 3:seed 复现与首局约束 ================================
async function scenario3() {
origConsoleLog('\n=== 场景 3:seed 复现 / 首局约束 / 非首局 S1≠辐射 ===');
storageMap.clear();
const mk = () => { const { game } = newGame({ seed: 42 }); game.meta.patrols = 0; return game._newRun(); };
const a = mk(), b = mk();
assert(JSON.stringify(a.plan) === JSON.stringify(b.plan), '同 seed 两次构造 plan 深相等(类型序/方位/陷阱/磨损/余波)');
assert(JSON.stringify(a.plan.types) === JSON.stringify(['distress', 'meteor', 'pirate', 'radiation']), '首局固定危机序 求救→陨石→海盗→辐射');
assert(a.plan.trap === false && a.plan.echo === false && a.plan.wears[0].length === 0, '首局约束:无陷阱/无余波/S1 无磨损');
// 非首局:洗牌 + S1≠辐射(遍历若干 seed 验证约束恒成立)
let checked = 0;
for (let seed = 1; seed <= 25; seed++) {
const gm = newGame({ seed });
gm.game.meta.patrols = 1;
const run = gm.game._newRun();
assert(run.plan.types[0] !== 'radiation', `seed=${seed} 非首局 S1≠辐射`);
assert(new Set(run.plan.types).size === 4, `seed=${seed} 四类型各一场`);
checked++;
}
assert(checked === 25, '非首局约束遍历 25 seed');
ok('场景 3 通过');
}
// ================================ 场景 4:巡检链节奏与异常段 ================================
async function scenario4() {
origConsoleLog('\n=== 场景 4:巡检链节奏 / dmg/off 异常句 / 中断与跳段 ===');
storageMap.clear();
const { game, audio } = newGame({ seed: 7 });
game.meta.patrols = 1;
game.meta.drillDone = true;
game.start();
await tick(300);
g(game, 'tap');
await waitState(() => game.state === 'ROUTE', 10000);
g(game, 'tap'); // 稳妥线
await waitState(() => game.state === 'CRUISE', 10000);
// 注入两系统异常(scan dmg / engine off)
game.run.sys.scan = 'dmg';
game.run.sys.engine = 'off';
game.run.offlineEver = true;
let mark = audio.log.length;
g(game, 'up');
await tick(10300); // tone 段播完
const P = plays(audio, mark);
const tOff = P.entryOf('sfx/sys_offline').t; // slot1 = engine(off)
const tShield = P.entryOf('sfx/sys_shield_ok').t; // slot2
const tScan = P.entryOf('sfx/sys_scan_dmg').t; // slot3
const tWeapon = P.entryOf('sfx/sys_weapon_ok').t; // slot4
assert(tOff < tShield && tShield < tScan && tScan < tWeapon, '离线=死寂平线签名 sys_offline 占引擎槽位');
assert(Math.abs(tShield - tOff - 2400) <= 50 && Math.abs(tScan - tShield - 2400) <= 50, '异常态仍保持 2.4s 节奏');
assert(!P.has('voice/tut_all_ok'), '有异常时零 tut_all_ok');
await waitState(() => !game._tourState && !audio.isVoicePlaying(), 40000);
const V = plays(audio, mark);
assert(V.orderOk('sfx/sys_offline', 'voice/sys_engine_off'), '引擎离线句接在对应 tone 后');
assert(V.orderOk('sfx/sys_scan_dmg', 'voice/sys_scan_dmg'), '扫描受损句接在对应 tone 后');
assert(V.orderOk('voice/sys_engine_off', 'voice/sys_scan_dmg'), '异常句按系统固定序');
// 中断保护:左右滑被忽略;单击跳段(语音段→链作废);再开全链
mark = audio.log.length;
g(game, 'up');
await tick(300);
const t0 = vnow;
g(game, 'left'); g(game, 'right');
await tick(500);
assert(!vibrated(ERROR_PATTERN, vibrateLog.length - 2), '巡检链播中左右滑静默忽略(无 error 震)');
g(game, 'tap'); // 第一拍:tone 段 → 跳语音段
await tick(11000); // 语音段开播
g(game, 'tap'); // 第二拍:作废语音链
await tick(500);
assert(!game._tourState, '单击跳段后巡检链关闭');
ok('场景 4 通过');
}
// ================================ 场景 5:危机矩阵(§4.2 全组合注入) ================================
/** 矩阵注入:给定类型/选项/系统态,结算并断言语音序与账目差分。 */
async function matrixCase(type, opt, st, expect) {
storageMap.clear();
const { game, audio } = newGame({ seed: 42 });
game.meta.patrols = 1;
game.meta.drillDone = true;
game.start();
await tick(300);
g(game, 'tap');
await waitState(() => game.state === 'ROUTE', 10000);
g(game, 'tap');
await waitState(() => game.state === 'CRUISE', 10000);
const r = game.run;
r.seg = 2;
const needSys = { evade: 'engine', fight: 'weapon', breach: 'shield', aid: 'scan', ignore: null }[opt];
if (needSys) r.sys[needSys] = st;
r.hull = 6; r.rp = 5; r.crew = { xo: 0, eng: 0, doc: 0 };
const c0 = { type, dir: 'port', optIdx: 0, subphase: 'decide', trap: false, remainMs: 15000, echo: false };
game.state = 'CRISIS';
game._crisis = c0;
const mark = audio.log.length;
game._settleCrisis(c0, opt);
await waitState(() => game._crisis === null && !audio.isVoicePlaying(), 60000);
const P = plays(audio, mark);
const name = `${type}/${opt}/${st}`;
for (const id of expect.voice || []) {
assert(P.has('voice/' + id), `[${name}] 语音 ${id}`);
}
for (const id of expect.noVoice || []) {
assert(!P.has('voice/' + id), `[${name}] 不播 ${id}`);
}
if (expect.order) assert(P.orderOk(...expect.order.map((x) => 'voice/' + x).concat(expect.orderSfx || [])), `[${name}] 句序`);
if (expect.hull !== undefined) assert(r.hull === expect.hull, `[${name}] hull=${r.hull} 期望 ${expect.hull}`);
if (expect.rp !== undefined) assert(r.rp === expect.rp, `[${name}] rp=${r.rp} 期望 ${expect.rp}`);
if (expect.crew) {
for (const [k, v] of Object.entries(expect.crew)) assert(r.crew[k] === v, `[${name}] crew.${k}=${r.crew[k]} 期望 ${v}`);
}
if (expect.sys) {
for (const [k, v] of Object.entries(expect.sys)) assert(r.sys[k] === v, `[${name}] sys.${k}=${r.sys[k]} 期望 ${v}`);
}
if (expect.aidOk !== undefined) assert(r.aidOk === expect.aidOk, `[${name}] aidOk=${r.aidOk}`);
if (expect.kills !== undefined) assert(r.kills === expect.kills, `[${name}] kills=${r.kills}`);
if (expect.offlineFails !== undefined) assert(r.offlineFails === expect.offlineFails, `[${name}] offlineFails=${r.offlineFails}`);
}
async function scenario5() {
origConsoleLog('\n=== 场景 5:危机全类型 × 选项 × 系统态矩阵(§4.2) ===');
// —— 陨石 ——
await matrixCase('meteor', 'evade', 'ok', { voice: ['res_clean'], hull: 6 });
await matrixCase('meteor', 'evade', 'dmg', { voice: ['res_hurt'], hull: 5 });
await matrixCase('meteor', 'evade', 'off', { voice: ['sys_engine_off', 'res_bad'], order: ['sys_engine_off', 'res_bad'], hull: 4 });
await matrixCase('meteor', 'fight', 'ok', { voice: ['res_mine', 'loy_fight_win'], rp: 6, crew: { xo: 1 } });
await matrixCase('meteor', 'fight', 'dmg', { voice: ['res_hurt'], noVoice: ['res_mine', 'loy_fight_win'], hull: 5, rp: 5, crew: { xo: 0 } });
await matrixCase('meteor', 'fight', 'off', { voice: ['sys_weapon_off', 'res_bad'], order: ['sys_weapon_off', 'res_bad'], hull: 4, offlineFails: 1 });
await matrixCase('meteor', 'breach', 'ok', { voice: ['sys_shield_dmg', 'loy_breach'], sys: { shield: 'dmg' }, crew: { eng: -1 }, hull: 6 });
await matrixCase('meteor', 'breach', 'dmg', { voice: ['res_bad'], hull: 4 });
await matrixCase('meteor', 'breach', 'off', { voice: ['sys_shield_off', 'res_bad'], order: ['sys_shield_off', 'res_bad'], hull: 4 });
// —— 海盗 ——
await matrixCase('pirate', 'fight', 'ok', { voice: ['res_hurt', 'res_plunder', 'loy_fight_win', 'xo_win'], hull: 5, rp: 7, kills: 1, crew: { xo: 1 } });
await matrixCase('pirate', 'evade', 'ok', { voice: ['res_clean', 'loy_evade_pirate'], hull: 6, crew: { xo: -1 } });
await matrixCase('pirate', 'evade', 'dmg', { voice: ['res_hurt'], hull: 5 });
await matrixCase('pirate', 'evade', 'off', { voice: ['sys_engine_off', 'res_bad'], order: ['sys_engine_off', 'res_bad'], hull: 4 });
await matrixCase('pirate', 'ignore', 'ok', { voice: ['res_bad', 'loy_rad'], hull: 4, crew: { doc: -1 } });
// —— 求救 ——
await matrixCase('distress', 'aid', 'ok', { voice: ['res_reward', 'loy_aid', 'doc_aid'], rp: 7, aidOk: 1, crew: { xo: 1, doc: 1 } });
await matrixCase('distress', 'aid', 'off', { voice: ['sys_scan_off'], noVoice: ['res_reward', 'loy_aid'], rp: 5, aidOk: 0, offlineFails: 1 });
await matrixCase('distress', 'ignore', 'ok', { voice: ['loy_ignore', 'doc_ignore'], rp: 5, crew: { doc: -1 } });
// —— 辐射 ——
await matrixCase('radiation', 'evade', 'ok', { voice: ['res_clean'], hull: 6 });
await matrixCase('radiation', 'evade', 'dmg', { voice: ['res_hurt'], hull: 5 });
await matrixCase('radiation', 'breach', 'ok', { voice: ['sys_shield_dmg', 'loy_breach'], sys: { shield: 'dmg' }, crew: { eng: -1 }, hull: 6 });
await matrixCase('radiation', 'breach', 'dmg', { voice: ['res_bad'], hull: 4 });
ok('场景 5 通过(矩阵核心组合)');
}
// ================================ 场景 6:仲裁 R1-R8 + 双危机 ================================
async function scenario6() {
origConsoleLog('\n=== 场景 6:告警仲裁 R1-R8 / §3.5 并发场景 ===');
storageMap.clear();
const { game, audio } = newGame({ seed: 42 });
game.meta.patrols = 1;
game.meta.drillDone = true;
game.start();
await tick(300);
g(game, 'tap');
await waitState(() => game.state === 'ROUTE', 10000);
g(game, 'tap');
await waitState(() => game.state === 'CRUISE', 10000);
// R1+R2:双受损 3:1 轮转(shield 优先于 scan)
game.run.sys.shield = 'dmg';
game.run.sys.scan = 'dmg';
let mark = audio.log.length;
game._almOnDamaged('shield');
game._almOnDamaged('scan');
const t0 = vnow;
assert(plays(audio, mark).has('sfx/alm_shield'), 'R1:榜首 shield 立即响');
assert(game._alm.current === 'shield', 'R1 威胁序:shield > scan');
await tick(15400);
assert(plays(audio, mark).has('sfx/alm_scan'), 'R2:15s 后让位 scan 5s');
const shieldPlays = plays(audio, mark).count('sfx/alm_shield');
await tick(60000);
const shieldPlays2 = plays(audio, mark).count('sfx/alm_shield');
assert(shieldPlays2 > shieldPlays, 'R2:轮转回榜首续响(公平不饿死)');
// R3:600ms 同 id 去重(手动占窗防轮转干扰)
mark = audio.log.length;
game._alm.current = 'shield';
game._almKick('shield');
game._almKick('shield');
await tick(100);
assert(plays(audio, mark).count('sfx/alm_shield') === 1, 'R3:600ms 窗内同 id 重复调度只响一次');
// R4:voice 播出期间零告警调度,链落声+0.5s 恢复
mark = audio.log.length;
game._speak(['gd_cruise']);
await tick(100); // voice 在播
game._almKick('shield');
await tick(300);
assert(plays(audio, mark).count('sfx/alm_shield') === 0, 'R4:voice 在播期间告警零播放(延迟不丢弃)');
await waitState(() => plays(audio, mark).count('sfx/alm_shield') >= 1, 30000);
assert(true, 'R4:voice 结束+0.5s 告警恢复');
// R5:决策窗内仅危机关联受损系统参与
game.run.sys.engine = 'dmg';
const c = { type: 'meteor', dir: 'bow', optIdx: 0, subphase: 'decide', trap: false, remainMs: 12000, echo: false };
game.state = 'CRISIS';
game._crisis = c;
game._almStartRotate();
assert(game._alm.current === 'engine', 'R5/R1①:陨石窗内引擎(关联)居首');
assert(!game._almCandidates().includes('scan'), 'R5:无关系统 scan 挂起(不参与窗内仲裁)');
game._crisis = null;
game.state = 'CRUISE';
// R6:双危机 FIFO —— B 检测落在 A 决策窗内 → 排队,A 结算句+2s 前零 B 声
const cA = { type: 'meteor', dir: 'stern', optIdx: 0, subphase: 'decide', trap: false, remainMs: 12000, echo: false };
game.state = 'CRISIS';
game._crisis = cA;
mark = audio.log.length;
game._startCrisis(); // 尝试开 B → 排队
assert(game._crisisQueue.length === 1, 'R6:A 占窗时 B 进入 FIFO');
assert(!plays(audio, mark).has('sfx/sig_distress') && !plays(audio, mark).has('sfx/sig_meteor'), 'R6:排队期零 B 检测声');
game._settleCrisis(cA, 'evade'); // 引擎 dmg → hull−1 res_hurt
await waitState(() => game._crisis !== null && game._crisis !== cA, 40000);
assert(game._crisis.type === game.run.plan.types[game.run.seg - 1], 'R6:B 检测链在 A 结算后补播(B 窗从 B 检测链起算)');
const cB = game._crisis;
game._settleCrisis(cB, cB.type === 'distress' ? 'ignore' : 'evade');
await waitState(() => game._crisis === null, 60000);
assert(true, 'R6:双危机串行消化(无叠加签名)');
// R7:修复即止
game.run.sys.shield = 'ok';
game._almOnRepaired('shield');
assert(game._alm.loops.shield === undefined, 'R7:修复后周期循环截断');
assert(!game._alm.deferred.some((d) => d.sys === 'shield'), 'R7:排队项移除');
// R8:菜单冻结 + REPORT 清空
mark = audio.log.length;
game._menu.open = true;
game._almKick('engine');
await tick(300);
assert(plays(audio, mark).count('sfx/alm_engine') === 0, 'R8:菜单期告警冻结');
game._menu.open = false;
game._enterReport(false);
mark = audio.log.length;
await tick(5000);
assert(plays(audio, mark).count('sfx/alm_engine') === 0 && plays(audio, mark).count('sfx/alm_scan') === 0, 'R8:REPORT 进入告警清空(结算独占)');
ok('场景 6 通过(仲裁 + 双场景并发)');
}
// ================================ 场景 7:忠诚账本 / 支援 / 离心 ================================
async function scenario7() {
origConsoleLog('\n=== 场景 7:忠诚账本差分 / band 句 / 支援触发 / sup_none ===');
storageMap.clear();
const { game, audio } = newGame({ seed: 42 });
game.meta.patrols = 1;
game.meta.drillDone = true;
game.start();
await tick(300);
g(game, 'tap');
await waitState(() => game.state === 'ROUTE', 10000);
g(game, 'tap');
await waitState(() => game.state === 'CRUISE', 10000);
const r = game.run;
r.seg = 2;
// clamp −3..+3
r.crew.xo = 3;
r.crew.xo = Math.max(-3, Math.min(3, r.crew.xo + 10));
assert(r.crew.xo === 3, '忠诚 clamp 上限 +3');
// 大副信赖 sup_xo:海盗迎战下令瞬间 band≥2 → 战果升一级(hull−1→0)
r.sys.weapon = 'ok'; r.crew.xo = 2; r.crew.eng = 0; r.crew.doc = 0;
r.hull = 6; r.rp = 5; r.kills = 0;
let c = { type: 'pirate', dir: 'star', optIdx: 0, subphase: 'decide', trap: false, remainMs: 15000, echo: false };
game.state = 'CRISIS'; game._crisis = c;
let mark = audio.log.length;
game._settleCrisis(c, 'fight');
await waitState(() => game._crisis === null && !audio.isVoicePlaying(), 60000);
assert(r.hull === 6, 'sup_xo:大副信赖 hull−1→0(战果升一级)');
assert(plays(audio, mark).orderOk('voice/sup_xo', 'voice/xo_fight', 'voice/res_clean', 'voice/res_plunder'), 'sup_xo+xo_fight 播在结果句前');
assert(r.crew.xo === 3, '迎战胜 大副+1(clamp 3)');
// 大副离心:战果降一级(hull−1→−2)
r.crew.xo = -2; r.crew.eng = 0; r.crew.doc = 0; r.hull = 6; r.kills = 0;
c = { type: 'pirate', dir: 'star', optIdx: 0, subphase: 'decide', trap: false, remainMs: 15000, echo: false };
game.state = 'CRISIS'; game._crisis = c;
game._settleCrisis(c, 'fight');
await waitState(() => game._crisis === null && !audio.isVoicePlaying(), 60000);
assert(r.hull === 4, '大副离心:基础 −1 降一级 → −2');
// 工程师/医师段末支援:band≥2 + 条件
r.crew.xo = 0; r.crew.eng = 2; r.crew.doc = 2;
r.sys.scan = 'dmg'; r.hull = 4; r.hullLostSeg = 2; r.seg = 2;
mark = audio.log.length;
game.state = 'CRUISE';
game._endSeg();
await waitState(() => game.state === 'DOCK', 60000);
assert(r.sys.scan === 'ok', 'sup_eng:工程师信赖自动修 1 级(受损→完好)');
assert(r.hull === 5, 'sup_doc:医师信赖舰体+1(不超上限)');
assert(plays(audio, mark).orderOk('voice/sup_eng', 'voice/eng_sup', 'voice/sup_doc', 'voice/doc_care', 'voice/evt_dock'), '段末支援链 sup_eng→eng_sup→sup_doc→doc_care→evt_dock');
// sup_none 每局限 1 次
storageMap.clear();
const g2 = newGame({ seed: 42 });
g2.game.meta.patrols = 1; g2.game.meta.drillDone = true;
g2.game.start();
await tick(300);
g(g2.game, 'tap');
await waitState(() => g2.game.state === 'ROUTE', 10000);
g(g2.game, 'tap');
await waitState(() => g2.game.state === 'CRUISE', 10000);
g2.game.run.seg = 2;
g2.game.run.sys.engine = 'dmg';
g2.game.run.hullLostSeg = 1;
mark = g2.audio.log.length;
g2.game._endSeg();
await waitState(() => g2.game.state === 'DOCK', 60000);
assert(g2.audio && plays(g2.audio, mark).has('voice/sup_none'), 'sup_none:条件不满足(band<2)支援缺席');
g2.game.run.seg = 3;
g2.game.run.hullLostSeg = 1;
mark = g2.audio.log.length;
g2.game.state = 'CRUISE';
g2.game._endSeg();
await waitState(() => g2.game.state === 'DOCK', 60000);
assert(!plays(g2.audio, mark).has('voice/sup_none'), 'sup_none 每局限 1 次(第二次静默)');
ok('场景 7 通过');
}
// ================================ 场景 8:评级验算(剧本 A/B/失败,§8.4-8.6) ================================
async function reportCase(mutate, expect) {
storageMap.clear();
const { game, audio } = newGame({ seed: 42 });
game.meta.patrols = 1;
game.meta.drillDone = true;
game.start();
await tick(300);
g(game, 'tap');
await waitState(() => game.state === 'ROUTE', 10000);
g(game, 'tap');
await waitState(() => game.state === 'CRUISE', 10000);
const r = game.run;
mutate(r, game);
const mark = audio.log.length;
game._enterReport(expect.died);
await waitState(() => game._report?.done, 60000);
const P = plays(audio, mark);
assert(P.orderOk(...expect.chain.map((x) => 'voice/' + x)), `结算链序 ${expect.chain.join('→')}`);
assert(game._report.merit === expect.merit, `功勋=${game._report.merit} 期望 ${expect.merit}(${expect.name})`);
assert(game.meta.merit === expect.merit, `meta.merit 入账=${game.meta.merit}(${expect.name})`);
if (expect.bestRate) assert(game.meta.bestRate === expect.bestRate, `bestRate=${game.meta.bestRate}`);
assert(game.run === null, '巡逻终局 run=null');
}
async function scenario8() {
origConsoleLog('\n=== 场景 8:评级公式验算(§8.4 剧本 A / §8.5 剧本 B / §8.6 失败) ===');
// 剧本 A 终局:4 段/hull6/RP9/驰援1/击退1/无离线 → score=8+6+2+2+1=19 → S;功勋=4+8+3=15
await reportCase((r) => {
r.seg = 4; r.hull = 6; r.rp = 9; r.aidOk = 1; r.kills = 1; r.offlineEver = false;
}, { died: false, merit: 15, bestRate: 'S', name: '剧本A', chain: ['rep_open', 'rate_s', 'merit_3', 'settle_hook'] });
// 剧本 B:全超时 hull3/RP4/无驰援/无击退/无离线 → 8+3+0+0+1=12 → B;功勋=4+3+1=8
await reportCase((r) => {
r.seg = 4; r.hull = 3; r.rp = 4; r.aidOk = 0; r.kills = 0; r.offlineEver = false;
}, { died: false, merit: 8, bestRate: 'B', name: '剧本B', chain: ['rep_open', 'rate_b', 'merit_2', 'settle_hook'] });
// 失败路径(§8.6):S3 海盗迎战武器离线 → hull−3→0;timeouts=2 → die_adv_to
await reportCase((r) => {
r.seg = 3; r.hull = 0; r.rp = 4; r.lastHit = 'pirate'; r.timeouts = 2; r.offlineFails = 1;
}, { died: true, merit: 3 + 0 + 1, bestRate: 'D', name: '失败', chain: ['die_intro', 'die_pirate', 'die_adv_to', 'rep_open', 'rate_d', 'merit_1', 'settle_hook'] });
ok('场景 8 通过(账目直证)');
}
// ================================ 场景 9:经济(采集/修理/折算/改装效果) ================================
async function scenario9() {
origConsoleLog('\n=== 场景 9:经济逐笔 / 修理两级 / 改装效果 ===');
storageMap.clear();
const { game, audio } = newGame({ seed: 42 });
game.meta.patrols = 1;
game.meta.drillDone = true;
game.start();
await tick(300);
g(game, 'tap');
await waitState(() => game.state === 'ROUTE', 10000);
g(game, 'right'); // 矿富线
await tick(200);
g(game, 'tap');
await waitState(() => game.state === 'CRUISE', 10000);
assert(game.run.rp === 4, '矿富线采集 +4/段');
assert(plays(audio).has('voice/st_rp_2'), 'RP 0→4 跨档播 st_rp_2');
// 采集协议 +1
game.meta.unlocks.push('harvest');
game.run.seg = 2; game.run.route = null;
game._enterSeg(2);
await waitState(() => game.state === 'ROUTE', 10000);
g(game, 'tap'); // 稳妥线:2+1=3
await waitState(() => game.state === 'CRUISE', 10000);
assert(game.run.rp === 7, '采集协议:稳妥 2+1=3(RP 4→7,跨档 st_rp_3)');
assert(plays(audio).has('voice/st_rp_3'), 'RP 4→7 跨档播 st_rp_3');
game.meta.unlocks = game.meta.unlocks.filter((x) => x !== 'harvest');
// 修理两级:离线→受损(2)→完好(2);告警即止;RP 跨档双向
game.run.sys.weapon = 'off';
game.run.rp = 5; // 5→3(无跨档)→1(跨档 st_rp_1)
game.state = 'DOCK';
game._enterDock();
await tick(6800); // dock_open + 首项预览播完
const nItems = game._dock.items.length;
assert(game._dock.items.filter((i) => i.kind === 'repair').length === 1, 'DOCK 修理项=受损/离线系统各一项');
// 选中 weapon 修理项(首项即 weapon)
let mark = audio.log.length;
g(game, 'tap');
await tick(3600);
assert(game.run.sys.weapon === 'dmg' && game.run.rp === 3, '离线→受损一步(2 资源)');
assert(plays(audio, mark).orderOk('sfx/wrench', 'voice/rep_ok', 'voice/eng_fix'), '修理链 wrench→rep_ok→eng_fix');
assert(game._dock.items.filter((i) => i.kind === 'repair').length === 1, '修一级后该项仍在(受损→完好第二步)');
g(game, 'tap');
await tick(3600);
assert(game.run.sys.weapon === 'ok' && game.run.rp === 1, '受损→完好第二步(再 2 资源)');
assert(plays(audio, mark).has('voice/st_rp_1'), 'RP 3→1 跨档回播 st_rp_1(双向)');
assert(game._dock.items.length === 1 && game._dock.items[0].kind === 'go', '修复即止:修理项消失,仅剩启航');
// rep_poor
game.run.sys.scan = 'dmg'; game.run.rp = 1;
game._enterDock();
await tick(300);
g(game, 'tap');
await tick(200);
assert(plays(audio).has('voice/rep_poor') && vibrated(ERROR_PATTERN), '资源<2 修理 → rep_poor + error 震');
assert(game.run.sys.scan === 'dmg', 'rep_poor 不修理(真话账)');
// 改装效果:armor1 上限 6→7(st_hull 档按新比例)/ vet 开局+1 / spare RP+2 / aegis 首伤−1
game.meta.unlocks = ['armor1', 'vet', 'spare', 'aegis'];
storageMap.set('soundgame.progress.bridge', JSON.stringify({ meta: game.meta, run: null }));
const g2 = newGame({ seed: 9 });
g2.game.start();
await tick(300);
assert(g2.game._hullMax() === 7, 'armor1 舰体上限 6→7');
g2.game.meta.patrols = 1; // 非首局(无 DRILL)
g(g2.game, 'tap');
await waitState(() => g2.game.state === 'ROUTE', 10000);
g(g2.game, 'tap');
await waitState(() => g2.game.state === 'CRUISE', 10000);
assert(g2.game.run.rp === 4, 'spare 开局 RP+2(+采集 2 = 4)');
assert(g2.game.run.crew.xo === 1 && g2.game.run.crew.eng === 1 && g2.game.run.crew.doc === 1, 'vet 开局全员忠诚+1');
// aegis:每段首次伤害 −1(减至 0 无伤播报)
const r2 = g2.game.run;
r2.hull = 7; r2.seg = 2;
const out = g2.game._hurt(1, 'meteor');
assert(r2.hull === 7 && out[0] === 'res_clean', 'aegis:每段首次受创减至 0 → 无伤');
const out2 = g2.game._hurt(1, 'meteor');
assert(r2.hull === 6 && out2[0] === 'res_hurt', 'aegis 只挡每段第一刀');
ok('场景 9 通过');
}
// ================================ 场景 10:结构兜底(45s 自动线 / DOCK 无进展 / DRILL 自动演示) ================================
async function scenario10() {
origConsoleLog('\n=== 场景 10:硬计时兜底(结构性无真空) ===');
storageMap.clear();
const { game, audio } = newGame({ seed: 42 });
game.meta.patrols = 1;
game.meta.drillDone = true;
game.start();
await tick(300);
g(game, 'tap');
await waitState(() => game.state === 'ROUTE', 10000);
await tick(49000); // 45s 无选择 + 选项句落声
assert(game.state === 'CRUISE' && game.run.route === 'safe', 'ROUTE 45s 自动稳妥线兜底');
assert(plays(audio).has('voice/opt_route_safe'), '自动线播 opt_route_safe');
// DOCK 无进展轨:45s turn_tick → 150s 自动预览启航项(不替玩家花资源)
game.run.seg = 2; game.run.sys.scan = 'dmg'; game.run.rp = 5;
game._enterReport !== null;
game.state = 'CRUISE';
game._endSeg();
await waitState(() => game.state === 'DOCK', 60000);
game._dock.idx = 0;
const mark = audio.log.length;
await tick(46000);
assert(plays(audio, mark).has('sfx/turn_tick'), 'DOCK 无进展 L1(45s)turn_tick');
await tick(105000); // 到 150s
assert(game._dock.idx === game._dock.items.length - 1, 'DOCK 无进展 L3:自动预览启航项(选择移到 go,不替玩家花资源)');
assert(game.run.sys.scan === 'dmg' && game.run.rp === 5, 'L3 零资源消耗(真话)');
// DRILL 自动演示:90s 无进展自动通过 D0
storageMap.clear();
const g2 = newGame({ seed: 42 });
g2.game.start();
await tick(300);
g(g2.game, 'tap');
await waitState(() => g2.game._drill && g2.game._drill.step === 0, 20000);
await waitState(() => g2.game._drill && g2.game._drill.step === 1, 100000);
assert(true, 'DRILL 30/60/90 自动演示通过 D0(无进展不卡死)');
ok('场景 10 通过');
}
// ================================ 场景 11:中断复述与恢复 ================================
async function scenario11() {
origConsoleLog('\n=== 场景 11:存档重进 → 中断复述链 → 当前段 ROUTE ===');
storageMap.clear();
const { game, audio } = newGame({ seed: 42 });
game.meta.patrols = 2;
game.meta.drillDone = true;
game.start();
await tick(300);
g(game, 'tap'); // 继续(无 run → 新巡逻?有档与否:无 run)
await waitState(() => game.run && game.state === 'ROUTE', 20000);
g(game, 'tap'); // 稳妥线
await waitState(() => game.state === 'CRUISE', 10000);
game.run.sys.engine = 'dmg';
game.run.hull = 4;
game._save();
// 新实例模拟重进
const g2 = newGame({ seed: 42 });
g2.game.meta.patrols = 2;
g2.game.meta.drillDone = true;
g2.game.start();
await tick(500);
assert(g2.game.state === 'TITLE' && g2.game.run, '有档重进 TITLE 且 run 恢复');
assert(plays(g2.audio).has('voice/sys_continue_ask'), 'TITLE 播 sys_continue_ask');
let mark = g2.audio.log.length;
g(g2.game, 'tap'); // 继续
await waitState(() => g2.game.state === 'ROUTE', 30000);
const P = plays(g2.audio, mark);
assert(P.orderOk('voice/st_hull_2', 'voice/sys_engine_dmg', 'voice/st_seg_1', 'voice/recap_go'),
'中断复述链:舰体档→最紧要异常→巡航段→recap_go');
assert(g2.game.run.sys.engine === 'dmg' && g2.game.run.hull === 4, '恢复后账目一致(复用 plan 不重洗)');
ok('场景 11 通过');
}
// ================================ 场景 12:REPORT 死窗 + 链锁 + 死亡链 ================================
async function scenario12() {
origConsoleLog('\n=== 场景 12:REPORT 800ms 死窗 / 链锁 / 链完单击返航 ===');
storageMap.clear();
const { game, audio } = newGame({ seed: 42 });
game.meta.patrols = 1;
game.meta.drillDone = true;
game.start();
await tick(300);
g(game, 'tap');
await waitState(() => game.state === 'ROUTE', 10000);
g(game, 'tap');
await waitState(() => game.state === 'CRUISE', 10000);
const r = game.run;
r.seg = 4; r.hull = 6; r.rp = 9; r.aidOk = 1; r.kills = 1;
const mark = audio.log.length;
game._enterReport(false);
assert(performance.now() < game._graceUntil, '进 REPORT 盖 800ms 死窗戳');
g(game, 'tap'); // 死窗内
await tick(300);
g(game, 'tap'); // +300ms 仍在死窗(800 全盖)
await tick(600);
g(game, 'down'); g(game, 'doubletap'); // 死窗内全吞
await tick(200);
assert(game.state === 'REPORT' && !game._menu.open, '死窗内一切手势吞掉(ghost 不算输入)');
await waitState(() => game._report.done, 60000);
assert(plays(audio, mark).orderOk('voice/rep_open', 'voice/rate_s', 'voice/merit_3', 'voice/settle_hook'), '结算链完整有序(链锁期未被扰动)');
const nLog = audio.log.length;
g(game, 'tap'); // 链完单击=返航(settle_hook 言行一致)
await tick(300);
assert(game.state === 'TITLE', '链完单击返航 TITLE');
g(game, 'up');
await tick(200);
assert(plays(audio, nLog).has('voice/merit_3'), '上滑重听评级段(rate+merit)');
ok('场景 12 通过');
}
// ================================ 场景 13:模态菜单 ================================
async function scenario13() {
origConsoleLog('\n=== 场景 13:菜单循环/确认/冻结/保存/放弃 ===');
storageMap.clear();
const { game, audio } = newGame({ seed: 42 });
game.meta.patrols = 1;
game.meta.drillDone = true;
game.start();
await tick(300);
g(game, 'tap');
await waitState(() => game.state === 'ROUTE', 10000);
// 危机中开菜单冻结倒计时
game.state = 'CRISIS';
const c = { type: 'meteor', dir: 'bow', optIdx: 0, subphase: 'decide', trap: false, remainMs: 12000, startedAt: vnow, echo: false };
game._crisis = c;
game._armCountdown(c);
await tick(300);
const remainBefore = c.remainMs - (vnow - c.startedAt);
g(game, 'down'); // 开菜单(冻结)
await tick(6800); // sys_menu + 首项 + settings_hint 三句链播完
assert(game._menu.open && plays(audio).orderOk('voice/sys_menu', 'voice/sys_menu_resume', 'voice/settings_hint'), '菜单打开播 sys_menu+首项+settings_hint');
await tick(20000); // 冻结期走过远超剩余窗长
assert(c.subphase === 'decide', '菜单冻结倒计时(零超时触发)');
g(game, 'tap'); // 循环到 save
await tick(1400);
assert(plays(audio).has('voice/sys_menu_save'), '单击循环切换菜单项(resume→save)');
g(game, 'tap'); // 循环到 help
await tick(1400);
g(game, 'up'); // 帮助(上滑=sys_help)
await tick(1400);
assert(plays(audio).has('voice/sys_help'), '菜单内上滑=sys_help');
g(game, 'down'); // 关闭
await tick(1400);
assert(!game._menu.open, '下滑关闭菜单');
assert(plays(audio).has('voice/opt_evade'), '危机关闭菜单重播当前选项句');
assert(c.subphase === 'decide', '关闭后倒计时续走(仍未超时)');
game._settleCrisis(c, 'evade');
await waitState(() => game.state === 'DOCK', 60000); // 等段末过渡落地再开菜单
// 保存并回大厅:PUT summary + 导航
storageMap.set('soundgame.auth', JSON.stringify({ username: 't', token: 'x' }));
const putBefore = fetchLog.length;
g(game, 'down');
await tick(6800);
g(game, 'tap'); // resume → save
await tick(1400);
g(game, 'doubletap'); // 确认 save
await waitState(() => game._goingHome, 20000);
assert(game._goingHome, '保存并回大厅执行');
assert(fetchLog.length > putBefore, '菜单保存触发 PUT');
assert(JSON.parse(fetchLog[fetchLog.length - 1].opts.body).data.summary.length > 0, 'PUT data.summary 命中生成规则');
// 放弃本次巡逻:run 清空 meta 保留
storageMap.clear();
const g2 = newGame({ seed: 42 });
g2.game.meta.patrols = 3;
g2.game.meta.merit = 21;
g2.game.start();
await tick(300);
g(g2.game, 'tap');
await waitState(() => g2.game.run && g2.game.state === 'ROUTE', 20000);
g2.game._menuAbortPatrol();
await tick(300);
assert(g2.game.run === null && g2.game.meta.merit === 21 && g2.game.state === 'TITLE', '放弃巡逻:run 清空、meta 保留、回 TITLE');
// —— R2 F5:结算链(转移承载链)播中开菜单 → 顺延不掐;转移完成后补开,零死锁
storageMap.clear();
const g3 = newGame({ seed: 42 });
g3.game.meta.patrols = 1;
g3.game.meta.drillDone = true;
g3.game.start();
await tick(300);
g(g3.game, 'tap');
await waitState(() => g3.game.state === 'ROUTE', 10000);
g(g3.game, 'tap');
await waitState(() => g3.game.state === 'CRUISE', 10000);
const r3 = g3.game.run;
r3.seg = 2; r3.sys.engine = 'ok'; r3.hull = 6;
const c3 = { type: 'meteor', dir: 'bow', optIdx: 0, subphase: 'decide', trap: false, remainMs: 12000, startedAt: vnow, echo: false };
g3.game.state = 'CRISIS';
g3.game._crisis = c3;
g3.game._settleCrisis(c3, 'evade'); // res_clean 结算链(带 _afterCrisis 转移)
await tick(150); // 链在播
g(g3.game, 'down'); // 开菜单请求——应顺延(转移链 live)
assert(!g3.game._menu.open, 'F5:结算链播中菜单不立即开(顺延)');
const settled = await waitState(() => g3.game._menu.open, 60000);
assert(settled, 'F5:转移链完成后菜单补开(零死锁)');
assert(g3.game.state === 'DOCK', 'F5:段末转移照常落地(DOCK)');
g(g3.game, 'down'); // 关菜单
await tick(1500);
assert(!g3.game._menu.open, 'F5:菜单正常关闭');
ok('场景 13 通过(含 F5 转移链保护)');
}
// ================================ 场景 14:E1/E2/键盘变体/资产齐套 ================================
async function scenario14() {
origConsoleLog('\n=== 场景 14:E1 重播告警 / E2 决策窗长按 / 键盘资产 / 资产齐套 ===');
storageMap.clear();
const { game, audio } = newGame({ seed: 42 });
game.meta.patrols = 1;
game.meta.drillDone = true;
game.start();
await tick(300);
g(game, 'tap');
await waitState(() => game.state === 'ROUTE', 10000);
g(game, 'tap');
await waitState(() => game.state === 'CRUISE', 10000);
// E1:无近期告警 → cruise_ping
let mark = audio.log.length;
g(game, 'tap');
await tick(100);
assert(plays(audio, mark).has('sfx/cruise_ping'), 'E1:60s 内无告警 → cruise_ping(空态绝不静默)');
// 有近期告警 → 重播
game._noteAlarm('alm_engine');
game._lastAlarmPlay.at = vnow - 10000;
mark = audio.log.length;
g(game, 'tap');
await tick(100);
assert(plays(audio, mark).has('sfx/alm_engine'), 'E1:60s 窗内单击重播最近告警');
// E2:决策窗长按=error
game.state = 'CRISIS';
const c = { type: 'meteor', dir: 'bow', optIdx: 0, subphase: 'decide', trap: false, remainMs: 12000, startedAt: vnow, echo: false };
game._crisis = c;
const vib = vibrateLog.length;
g(game, 'longpress');
await tick(100);
assert(vibrated(ERROR_PATTERN, vib), 'E2:决策窗长按=error 震(选择语境不作输入)');
// 键盘变体资产:key/ 目录 10 对(两性别一致且 ⊆ 主集由 check_all_assets 锁)
const audioDir = path.join(BRIDGE_DIR, 'audio', 'voice');
const keyF = fs.readdirSync(path.join(audioDir, 'female', 'key')).filter((f) => f.endsWith('.ogg'));
const keyM = fs.readdirSync(path.join(audioDir, 'male', 'key')).filter((f) => f.endsWith('.ogg'));
assert(keyF.length === 10 && keyM.length === 10, `键盘变体 key/ 两性别各 10(实际 f=${keyF.length} m=${keyM.length})`);
assert(keyF.map((f) => f.replace('.ogg', '')).sort().join() === keyM.map((f) => f.replace('.ogg', '')).sort().join(), 'key 两性别 id 集合一致');
// 资产齐套:旁白 132 基表 ×2 性别 + NPC 12 平铺 + sfx 31 基 + 8 pan 派生 + bgm 3
const ids = (dir) => fs.readdirSync(dir).filter((f) => f.endsWith('.ogg')).map((f) => f.replace('.ogg', ''));
const base = ids(path.join(audioDir, 'female'));
const male = ids(path.join(audioDir, 'male'));
assert(base.length === 132 && male.length === 132, `旁白基表 132×2(实际 f=${base.length} m=${male.length})`);
assert(base.slice().sort().join() === male.slice().sort().join(), '旁白双性别 id 集合完全一致');
const flat = ids(audioDir).filter((x) => !['female', 'male'].includes(x));
assert(flat.length === 12, `NPC 平铺 12(实际 ${flat.length})`);
for (const need of ['xo_fight', 'xo_win', 'xo_uneasy', 'xo_aid', 'eng_wear', 'eng_fix', 'eng_sup', 'eng_gripe', 'doc_aid', 'doc_ignore', 'doc_care', 'doc_trap']) {
assert(flat.includes(need), `NPC 平铺 ${need} 在位`);
}
const sfx = ids(path.join(BRIDGE_DIR, 'audio', 'sfx'));
assert(sfx.length === 39, `sfx 39 文件(31 基 + 8 pan 派生,实际 ${sfx.length})`);
for (const s of ['sig_meteor_l', 'sig_meteor_r', 'sig_pirate_l', 'sig_pirate_r', 'sig_distress_l', 'sig_distress_r', 'sig_radiation_l', 'sig_radiation_r']) {
assert(sfx.includes(s), `pan 派生 ${s} 在位`);
}
const bgm = ids(path.join(BRIDGE_DIR, 'audio', 'bgm'));
assert(bgm.length === 3 && bgm.includes('menu') && bgm.includes('cruise') && bgm.includes('crisis'), 'bgm 3 首(menu/cruise/crisis)');
ok('场景 14 通过');
}
// ================================ 主流程 ================================
const scenarios = [
scenario1, scenario2, scenario3, scenario4, scenario5, scenario6, scenario7,
scenario8, scenario9, scenario10, scenario11, scenario12, scenario13, scenario14,
];
for (const s of scenarios) {
try {
await s();
} catch (e) {
failures.push(`${s.name} 抛异常: ${e.stack}`);
origConsoleError(` ✗ ${s.name} 抛异常: ${e.message}`);
}
}
// ---- 资产信息性审计(不判失败) ----
{
const missing = [];
const audioDir = path.join(BRIDGE_DIR, 'audio');
for (const gdir of ['female', 'male']) {
for (const f of fs.readdirSync(path.join(audioDir, 'voice', gdir))) {
if (!f.endsWith('.ogg')) continue;
const base = path.join(audioDir, 'voice', gdir, f.slice(0, -4));
if (!hasPair(base)) missing.push(`voice/${gdir}/${f} 缺配对`);
}
}
origConsoleLog(`\n资产审计:缺失配对 ${missing.length}(信息性)`);
for (const m of missing.slice(0, 10)) origConsoleLog(' - ' + m);
}
origConsoleLog(`\n========================================`);
origConsoleLog(`playtest 断言通过 ${passCount},失败 ${failures.length}`);
if (failures.length) {
for (const f of failures) origConsoleError('FAIL: ' + f);
process.exit(1);
}
origConsoleLog('ALL GREEN');