/** * 《星环舰长》无头试玩测试(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');