/** * 《星环舰长》无头试玩测试(26 号重构版)。 * * 玩法断言保留适配(22 号 §12 断言清单 14 组:四象限节奏、危机矩阵全组合、 * R1-R8 仲裁、忠诚/经济/评级三本账、seed 复现、结构兜底、断点复述、REPORT * 死窗、模态菜单、E1/E2、资产齐套);交互层全部改走 26 号输入管道 * (makeInputPipeline + floor/等待点/迷茫检测,game.floor.bedEnabled=false)。 * * 26 号新增覆盖: * - §4.10 首 3 分钟:第一句 critical 指令、3~10s 巡检教学、开场规则念白废除; * - §4.3 手势阶梯:D0 提前解锁上滑、双击未解锁折算单击、锁定手势首次解释; * - §4.1 发言权:跳句/打断、结算链 critical=吞、链间不掉回「轮到你」; * - §4.4/§4.5 等待点三档提示 + 迷茫三级接管(含巡航自接管与 L3 替做); * - §6.2 乱点机器人:无声操作=0、被动原句重复=0、打断失败=0、首操作/首成功时限。 * * 以玩家身份模拟:mock 浏览器环境(window/navigator/localStorage/fetch/虚拟时钟) * 与音频引擎接口(播放日志 + voice onComplete/skipVoice 按虚拟时钟推进语音链), * 加载真实 game.js,手势经 makeInputPipeline 注入(与 index.html 同构)。 * * 资产契约:引擎对缺失资产不崩——新台词(sys_* / hint_* / inv_*)未落盘时同样 * 全绿(mock 按固定句长推进,顺序/次数断言与时长无关);资产审计做信息性报告 * 不判失败,硬格式断言只针对「必须存在」的存量键(场景 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); /** 手势解锁阶梯存储(engine/novice.js 的全局键)。 */ function setLadder(stage, games = 9) { storageMap.set('soundgame.gestures', JSON.stringify({ stage, games })); } // ================================ 资产存在性 ================================ 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; this.unlocked = true; } isVoicePlaying() { return this._voice !== null; } setRateBoostEnabled() {} holdDuck() {} 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; } /** 跳过当前句:视为自然播完(触发 onComplete,链继续走,对齐引擎语义)。 */ skipVoice() { const cur = this._voice; if (!cur) return; this._voice = null; if (cur.onComplete) cur.onComplete(); } 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]); function vibrated(pattern, fromIdx = 0) { return vibrateLog.slice(fromIdx).some((v) => JSON.stringify(v.p) === pattern); } // ================================ 游戏装配(26 号输入管道) ================================ const { BridgeGame } = await import('../game.js'); const { makeInputPipeline } = await import('../../engine/pipeline.js'); function newGame(opts = {}) { const audio = new MockAudioEngine(); const game = new BridgeGame(audio, {}, opts); game.floor.bedEnabled = false; // 等待声是周期定时器:虚拟时钟测试关掉(防不排水) const pipe = makeInputPipeline({ floor: game.floor, confusion: game.confusion, receipts: game.receipts, invalidHints: game.invalidHints, game, ladder: game.ladder, opts: game.PIPELINE_OPTS || {}, }); return { game, audio, pipe }; } const g = (rig, gesture) => rig.pipe(gesture); async function waitState(pred, maxMs = 30000) { const t0 = vnow; while (!pred() && vnow - t0 < maxMs) await tick(100); return pred(); } /** 语音链/巡检链/教学兜底全空闲(有界)。 */ 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; } /** 标题→新巡逻→(非首局)选航线→稳妥线→巡航 的通用铺轨(等等待点武装再出手)。 */ async function cruiseRig(seed = 42) { const rig = newGame({ seed }); rig.game.meta.patrols = 1; rig.game.meta.drillDone = true; setLadder(3); rig.game.start(); await tick(300); await waitState(() => rig.game.state === 'TITLE' && rig.game.floor.state === 'YOUR_TURN', 20000); g(rig, 'tap'); // 新巡逻(无 run) await waitState(() => rig.game.state === 'ROUTE', 20000); await waitState(() => rig.game.floor.waitPoint?.conf.id === 'route', 20000); // 入场链播完挂等待点 g(rig, 'tap'); // 稳妥线 await waitState(() => rig.game.state === 'CRUISE', 20000); await waitIdle(rig.audio, rig.game); return rig; } // ================================ 场景 1:TITLE / PORT / 经济骨架 ================================ async function scenario1() { origConsoleLog('\n=== 场景 1:TITLE/PORT/概况链/存档摘要 ==='); storageMap.clear(); setLadder(3); const rig = newGame({ seed: 42 }); const { game, audio } = rig; game.start(); await tick(400); assert(game.state === 'TITLE' && !game.run, '启动进 TITLE(无存档)'); assert(plays(audio).has('bgm/menu'), 'TITLE bgm/menu'); assert(plays(audio).has('voice/sys_first_tap'), '26 §4.10:第一句=critical 指令「点一下屏幕。」'); assert(game._graceUntil === 0, 'REPORT 死窗戳初始为 0'); // 等待话轮交出后测概况链:无改装 → 仅功勋档 await waitState(() => game.floor.state === 'YOUR_TURN', 20000); let mark = audio.log.length; g(rig, '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(rig, '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'); await waitState(() => game.floor.waitPoint?.conf.id === 'port', 20000); g(rig, 'tap'); await waitState(() => plays(audio).has('voice/port_poor'), 10000); 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(rig, 'right'); await tick(1300); } assert(game._port.idx === 0, 'PORT 5 项+返回共 6 位循环回 0(armor2 依赖未出现)'); for (let i = 0; i < 4; i++) { g(rig, 'right'); await tick(1300); } assert(game._port.idx === 4, '右滑 4 次到第 5 项'); g(rig, 'right'); await tick(1300); assert(plays(audio).has('voice/port_back'), '末项=port_back'); g(rig, 'tap'); await tick(300); assert(game.state === 'TITLE', '返回项回 TITLE'); await waitState(() => game.floor.state === 'YOUR_TURN', 20000); // 询问句播完交话轮 // armor2 依赖:未购 armor1 时列表无 armor2 game.meta.unlocks = ['spare']; g(rig, '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 出现'); await waitState(() => game.floor.waitPoint?.conf.id === 'port', 20000); // 入场链播完挂等待点 // 购买成功 + 已购预览 upg_done(左右滑报名,非双击) game.meta.merit = 100; game._port.idx = 1; // harvest mark = audio.log.length; g(rig, '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(rig, 'right'); // 到下一项 await tick(1300); g(rig, 'left'); // 循环回 harvest(已购) await tick(1300); 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})`); game.stop(); ok('场景 1 通过'); } // ================================ 场景 2:首局 DRILL 全链 + 学期零惩罚 ================================ async function scenario2() { origConsoleLog('\n=== 场景 2:TITLE→DRILL D0..D4 + S1 驰援成功链 + 二周目不再进 ==='); storageMap.clear(); // 手势阶梯 stage=0:验证 D0 提前解锁上滑(22 附录 C §3) const rig = newGame({ seed: 42 }); const { game, audio } = rig; game.start(); await tick(400); await waitState(() => game.floor.state === 'YOUR_TURN', 20000); // 阶段 0:上滑未解锁 → invalid 回执 + 首次解释(§4.3) let mark = audio.log.length; g(rig, 'up'); await tick(1500); assert(plays(audio, mark).has('voice/inv_up_locked'), 'stage 0 上滑未解锁 → 首次解释 inv_up_locked(回执无 ctx 由管道记账)'); mark = audio.log.length; g(rig, 'tap'); // 新巡逻(开场第一下单击 = 26 §4.10 首次成功) await waitState(() => game.run && game.state === 'CRUISE' && game.floor.waitPoint?.conf.id === 'inspect', 40000); assert(game.run.seed === 42, 'opts.seed=42 注入新巡逻'); assert(plays(audio, mark).orderOk('voice/sys_new_patrol', 'voice/sys_turn_teach', 'voice/hint_inspect_full'), 'D0 序:sys_new_patrol → 轮到你状态教学 → 巡检指令(规则念白 intro_1..5 废除)'); assert(!plays(audio, mark).has('voice/intro_1') && !plays(audio, mark).has('voice/tut_d0'), '开场零规则念白(§4.10)'); assert(plays(audio).has('bgm/cruise'), '巡航 bgm/cruise'); assert(game.run.rp === 2, 'S1 采集 +2(稳妥线,首局固定)'); assert(game._drill.step === 0, 'D0 已武装(链播完才挂等待点)'); assert(game.ladder.stage() >= 1, 'D0 教学期提前解锁上滑(22 附录 C §3:阶段 1 解锁条件=巡检教学)'); // D0:上滑 → 巡检全链(全 ok → tut_all_ok + 阶梯教学句尾) mark = audio.log.length; g(rig, '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).has('voice/sys_unlock_up'), '巡检链尾挂阶梯教学句 sys_unlock_up(一次只教一个)'); assert(plays(audio, mark).orderOk('voice/tut_all_ok', 'voice/sys_unlock_up', 'voice/tut_crisis'), 'D0→D1 衔接:tut_all_ok→解锁句→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'); await waitState(() => plays(audio, mark).has('voice/opt_aid'), 30000); assert(plays(audio, mark).orderOk('voice/cr_decide', 'voice/tut_decide', 'voice/tut_aid', 'voice/opt_aid'), '决策教学并入首选项链:tut_decide→tut_aid→opt_aid'); assert(plays(audio).has('bgm/crisis'), '危机 bgm/crisis'); // D2:右滑预览 → 左滑回 aid → 单击下令驰援 mark = audio.log.length; g(rig, 'right'); await tick(1500); assert(plays(audio, mark).has('voice/opt_ignore'), '右滑预览第二选项 opt_ignore'); g(rig, 'left'); await tick(1500); assert(plays(audio, mark).has('voice/opt_aid'), '左滑循环回 opt_aid'); const hullBefore = game.run.hull, rpBefore = game.run.rp; mark = audio.log.length; g(rig, '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.floor.waitPoint?.conf.id === 'inspect', 20000); mark = audio.log.length; g(rig, '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 完成后不在决策窗下令 → 超时默认结局 → D3 L3 替播巡检 → DOCK storageMap.clear(); const rig3 = newGame({ seed: 7 }); rig3.game.start(); await tick(300); await waitState(() => rig3.game.floor.state === 'YOUR_TURN', 20000); g(rig3, 'tap'); await waitState(() => rig3.game.floor.waitPoint?.conf.id === 'inspect', 40000); g(rig3, 'up'); // D0 巡检 await waitState(() => rig3.game._drill?.step === 1, 60000); await waitState(() => rig3.game.state === 'CRISIS', 120000); const m3 = rig3.audio.log.length; await waitState(() => rig3.game.state === 'DOCK', 240000); // 20s 窗超时→默认结局→D3 L3 替播巡检→tut_done→段末 assert(plays(rig3.audio, m3).has('voice/cr_timeout'), 'R11:教学危机超时走默认结局(真话)'); assert(plays(rig3.audio, m3).has('voice/tut_done'), 'R11:收束播 tut_done(D3 由迷茫 L3 替播巡检自动完成)'); assert(rig3.game.meta.drillDone && rig3.game.run.drillDone, 'R11:drillDone 持久化(次局不再进教学)'); game.stop(); rig3.game.stop(); ok('场景 2 通过(首局 DRILL 全链 + R11 超时收束)'); } // ================================ 场景 3:seed 复现与首局约束 ================================ async function scenario3() { origConsoleLog('\n=== 场景 3:seed 复现 / 首局约束 / 非首局 S1≠辐射 ==='); storageMap.clear(); const mk = () => { const rig = newGame({ seed: 42 }); rig.game.meta.patrols = 0; return rig.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 rig = newGame({ seed }); rig.game.meta.patrols = 1; const run = rig.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 rig = await cruiseRig(7); const { game, audio } = rig; // 注入两系统异常(scan dmg / engine off) game.run.sys.scan = 'dmg'; game.run.sys.engine = 'off'; game.run.offlineEver = true; let mark = audio.log.length; g(rig, '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'), '异常句按系统固定序'); // 中断保护:语音段左右滑=invalid 回执+解释+续完剩余句(保护链,当前句不重播) mark = audio.log.length; g(rig, 'up'); await tick(10300); // tone 段 await waitState(() => audio.isVoicePlaying(), 20000); // 语音段开播 const tInterrupt = vnow; const curId = game._lastVoiceStart.id; const curPlays = plays(audio).count(curId); const vibBefore = vibrateLog.length; g(rig, 'left'); await tick(200); assert(!vibrated(ERROR_PATTERN, vibBefore), '巡检链播中左右滑不产生 error 震(invalid 回执承担反馈)'); assert(plays(audio, mark).has('voice/inv_tour'), '巡检中断首次解释 inv_tour(并入续播链头)'); assert(plays(audio).count(curId) === curPlays, `被掐句 ${curId} 不原样重播(续完剩余句,原则 9)`); await waitState(() => !game._tourState && !audio.isVoicePlaying(), 40000); // 跳段:tone 段单击→语音段;语音段单击=逐句跳(floor consumed) mark = audio.log.length; g(rig, 'up'); await tick(3000); g(rig, 'tap'); // tone 段 → 跳语音段 await waitState(() => audio.isVoicePlaying(), 20000); let guard = 0; while (game._tourState && guard++ < 12) { g(rig, 'tap'); await tick(600); } await waitState(() => !game._tourState, 20000); assert(!game._tourState, '单击跳句推进巡检链至结束'); game.stop(); ok('场景 4 通过'); } // ================================ 场景 5:危机矩阵(§4.2 全组合注入) ================================ /** 矩阵注入:给定类型/选项/系统态,结算并断言语音序与账目差分。 */ async function matrixCase(type, opt, st, expect) { storageMap.clear(); const { game, audio } = await cruiseRig(42); 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}`); game.stop(); // 清残留等待点档位定时器(防跨场景污染 30s 提示台账) } 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 } = await cruiseRig(42); // 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'); 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(['voice/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 进入告警清空(结算独占)'); game.stop(); ok('场景 6 通过(仲裁 + 双场景并发)'); } // ================================ 场景 7:忠诚账本 / 支援 / 离心 ================================ async function scenario7() { origConsoleLog('\n=== 场景 7:忠诚账本差分 / band 句 / 支援触发 / sup_none ==='); storageMap.clear(); const { game, audio } = await cruiseRig(42); 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 rig2 = await cruiseRig(42); rig2.game.run.seg = 2; rig2.game.run.sys.engine = 'dmg'; rig2.game.run.hullLostSeg = 1; mark = rig2.audio.log.length; rig2.game._endSeg(); await waitState(() => rig2.game.state === 'DOCK', 60000); assert(plays(rig2.audio, mark).has('voice/sup_none'), 'sup_none:条件不满足(band<2)支援缺席'); rig2.game.run.seg = 3; rig2.game.run.hullLostSeg = 1; mark = rig2.audio.log.length; rig2.game.state = 'CRUISE'; rig2.game._endSeg(); await waitState(() => rig2.game.state === 'DOCK', 60000); assert(!plays(rig2.audio, mark).has('voice/sup_none'), 'sup_none 每局限 1 次(第二次静默)'); game.stop(); rig2.game.stop(); ok('场景 7 通过'); } // ================================ 场景 8:评级验算(剧本 A/B/失败,§8.4-8.6) ================================ async function reportCase(mutate, expect) { storageMap.clear(); const { game, audio } = await cruiseRig(42); 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'); game.stop(); } 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 rig = await cruiseRig(42); const { game, audio } = rig; // 矿富线:回 ROUTE 重选(右滑换线+单击选定) game.run.seg = 2; game.run.route = null; game._enterSeg(2); await waitState(() => game.state === 'ROUTE', 10000); await waitState(() => game.floor.waitPoint?.conf.id === 'route', 20000); g(rig, 'right'); // 换矿富线 await tick(1500); assert(plays(audio).has('voice/opt_route_rich'), '右滑换线报名 opt_route_rich'); g(rig, 'tap'); await waitState(() => game.state === 'CRUISE', 10000); assert(game.run.rp === 6, '矿富线采集 +4/段(叠加 cruiseRig 稳妥腿 +2 = 6)'); assert(plays(audio).has('voice/st_rp_2'), 'RP 2→6 跨档播 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' && game.floor.waitPoint?.conf.id === 'route', 20000); g(rig, 'tap'); // 稳妥线:2+1=3 await waitState(() => game.state === 'CRUISE', 10000); assert(game.run.rp === 9, '采集协议:稳妥 2+1=3(RP 6→9,跨档 st_rp_3)'); assert(plays(audio).has('voice/st_rp_3'), 'RP 6→9 跨档播 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 waitState(() => game.floor.waitPoint?.conf.id === 'dock', 20000); const nItems = game._dock.items.length; assert(game._dock.items.filter((i) => i.kind === 'repair').length === 1, 'DOCK 修理项=受损/离线系统各一项'); let mark = audio.log.length; g(rig, '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(rig, '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 waitState(() => game.floor.waitPoint?.conf.id === 'dock', 20000); g(rig, 'tap'); await waitState(() => plays(audio).has('voice/rep_poor'), 10000); 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 / 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 rig2 = newGame({ seed: 9 }); setLadder(3); rig2.game.start(); await tick(300); assert(rig2.game._hullMax() === 7, 'armor1 舰体上限 6→7'); rig2.game.meta.patrols = 1; // 非首局(无 DRILL) await waitState(() => rig2.game.floor.state === 'YOUR_TURN', 20000); g(rig2, 'tap'); await waitState(() => rig2.game.state === 'ROUTE' && rig2.game.floor.waitPoint?.conf.id === 'route', 20000); g(rig2, 'tap'); await waitState(() => rig2.game.state === 'CRUISE', 10000); assert(rig2.game.run.rp === 4, 'spare 开局 RP+2(+采集 2 = 4)'); assert(rig2.game.run.crew.xo === 1 && rig2.game.run.crew.eng === 1 && rig2.game.run.crew.doc === 1, 'vet 开局全员忠诚+1'); // aegis:每段首次伤害 −1(减至 0 无伤播报) const r2 = rig2.game.run; r2.hull = 7; r2.seg = 2; const out = rig2.game._hurt(1, 'meteor'); assert(r2.hull === 7 && out[0] === 'res_clean', 'aegis:每段首次受创减至 0 → 无伤'); const out2 = rig2.game._hurt(1, 'meteor'); assert(r2.hull === 6 && out2[0] === 'res_hurt', 'aegis 只挡每段第一刀'); game.stop(); rig2.game.stop(); ok('场景 9 通过'); } // ================================ 场景 10:结构兜底(45s 自动线 / 等待点三档+L3 替启航 / DRILL L3 替播) ================================ async function scenario10() { origConsoleLog('\n=== 场景 10:硬计时兜底 + 等待点三档 + 迷茫 L3 替做 ==='); storageMap.clear(); const rig = await cruiseRig(42); const { game, audio } = rig; game.run.seg = 1; game.run.route = null; game._enterSeg(1); await waitState(() => game.state === 'ROUTE', 10000); await tick(49000); // 45s 无选择 → 自动稳妥线 assert(game.state === 'CRUISE' && game.run.route === 'safe', 'ROUTE 45s 自动稳妥线兜底(等待点 L3 同义,结构双保险)'); assert(plays(audio).has('voice/opt_route_safe'), '自动线播 opt_route_safe'); // DOCK 无进展:等待点三档(12s full → 30s short)+ 迷茫 L3 替启航(不替玩家花修理资源) game.run.seg = 2; game.run.sys.scan = 'dmg'; game.run.rp = 5; game.state = 'CRUISE'; game._endSeg(); await waitState(() => game.state === 'DOCK' && game.floor.waitPoint?.conf.id === 'dock', 60000); game._dock.idx = 0; const mark = audio.log.length; const t0dock = vnow; await waitState(() => plays(audio, mark).has('voice/hint_dock_full'), 30000); const tFull = plays(audio, mark).entryOf('voice/hint_dock_full').t; assert(Math.abs(tFull - t0dock - 12000) <= 1500, `DOCK 静默 12s 给 full 档(实际 ${tFull - t0dock}ms)`); await waitState(() => plays(audio, mark).has('voice/hint_dock_short'), 40000); assert(true, 'DOCK 持续静默 30s 降 short 档(26 §4.4)'); await waitState(() => game.state !== 'DOCK', 120000); // 迷茫 L3 替启航 assert(game.state === 'ROUTE' || game.state === 'CRUISE', 'DOCK 迷茫 L3 替启航(22 附录 C §5)自动离港'); assert(game.run.sys.scan === 'dmg' && game.run.rp === 5, 'L3 替启航零修理资源消耗(不替玩家花资源,真话)'); // DRILL D0:无进展 → 等待点提示 → 迷茫 L3 替播巡检自动过步 storageMap.clear(); const rig2 = newGame({ seed: 42 }); rig2.game.start(); await tick(300); await waitState(() => rig2.game.floor.state === 'YOUR_TURN', 20000); g(rig2, 'tap'); await waitState(() => rig2.game._drill && rig2.game._drill.step === 0, 20000); const m2 = rig2.audio.log.length; await waitState(() => rig2.game._drill && rig2.game._drill.step === 1, 120000); assert(plays(rig2.audio, m2).orderOk('sfx/sys_engine_ok', 'sfx/sys_shield_ok', 'sfx/sys_scan_ok', 'sfx/sys_weapon_ok'), 'DRILL D0 无进展 → 迷茫 L3 替播巡检(自动过步不卡死)'); game.stop(); rig2.game.stop(); ok('场景 10 通过'); } // ================================ 场景 11:中断复述与恢复 ================================ async function scenario11() { origConsoleLog('\n=== 场景 11:存档重进 → 中断复述链 → 当前段 ROUTE ==='); storageMap.clear(); const rig1 = await cruiseRig(42); rig1.game.run.sys.engine = 'dmg'; rig1.game.run.hull = 4; rig1.game._save(); // 新实例模拟重进 const rig2 = newGame({ seed: 42 }); rig2.game.meta.patrols = 2; rig2.game.meta.drillDone = true; setLadder(3); rig2.game.start(); await tick(500); assert(rig2.game.state === 'TITLE' && rig2.game.run, '有档重进 TITLE 且 run 恢复'); assert(plays(rig2.audio).has('voice/sys_continue_ask'), 'TITLE 播 sys_continue_ask(回来问继续,§4.12)'); await waitState(() => rig2.game.floor.state === 'YOUR_TURN', 20000); let mark = rig2.audio.log.length; g(rig2, 'tap'); // 继续 await waitState(() => rig2.game.state === 'ROUTE', 30000); const P = plays(rig2.audio, mark); assert(P.orderOk('voice/st_hull_2', 'voice/sys_engine_dmg', 'voice/st_seg_1', 'voice/recap_go'), '中断复述链:舰体档→最紧要异常→巡航段→recap_go'); assert(rig2.game.run.sys.engine === 'dmg' && rig2.game.run.hull === 4, '恢复后账目一致(复用 plan 不重洗)'); rig1.game.stop(); rig2.game.stop(); ok('场景 11 通过'); } // ================================ 场景 12:REPORT 死窗 + 链锁 + 死亡链 ================================ async function scenario12() { origConsoleLog('\n=== 场景 12:REPORT 800ms 死窗 / 链锁(critical 吞)/ 链完单击返航 ==='); storageMap.clear(); const rig = await cruiseRig(42); const { game, audio } = rig; 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(rig, 'tap'); // 死窗内(critical 链在播 → busy 回执,不算输入) await tick(300); g(rig, 'tap'); // +300ms 仍在死窗 await tick(600); g(rig, 'down'); g(rig, '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(rig, 'tap'); // 链完单击=返航(settle_hook 言行一致) await tick(300); assert(game.state === 'TITLE', '链完单击返航 TITLE'); await waitState(() => game.floor.state === 'YOUR_TURN', 20000); g(rig, 'up'); await tick(1500); assert(plays(audio, nLog).has('voice/merit_3'), '上滑重听评级段(rate+merit)'); game.stop(); ok('场景 12 通过'); } // ================================ 场景 13:模态菜单(左右滑换项 + 单击确认) ================================ async function scenario13() { origConsoleLog('\n=== 场景 13:菜单换项/确认/二次确认/冻结/保存/放弃 ==='); storageMap.clear(); const rig = await cruiseRig(42); const { game, audio } = rig; // 危机中开菜单冻结倒计时 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); g(rig, 'down'); // 开菜单(冻结)——阶段 2 已解锁(cruiseRig 设 stage 3) await waitState(() => game._menu.open && game.floor.waitPoint?.conf.id === 'menu', 20000); assert(plays(audio).orderOk('voice/sys_menu', 'voice/sys_menu_resume'), '菜单打开播 sys_menu+首项(单击循环菜单废止)'); await tick(20000); // 冻结期走过远超剩余窗长 assert(c.subphase === 'decide', '菜单冻结倒计时(零超时触发,R8)'); g(rig, 'right'); // 换到 save await tick(1500); assert(plays(audio).has('voice/sys_menu_save'), '右滑换项报名 sys_menu_save(左右滑=换项)'); g(rig, 'right'); // 换到 restart(破坏性 → 二次确认) await tick(1500); assert(plays(audio).has('voice/sys_menu_restart'), '第三项=sys_menu_restart'); g(rig, 'tap'); await tick(1500); assert(plays(audio).has('voice/menu_restart_confirm'), '放弃巡逻单击=二次确认句(原则 6)'); g(rig, 'up'); // 帮助(重听说明 + 设置提示) await tick(2000); assert(plays(audio).has('voice/sys_help') && plays(audio).has('voice/settings_hint'), '菜单内上滑=sys_help+settings_hint'); g(rig, 'down'); // 关闭 await tick(1500); 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(rig, 'down'); await waitState(() => game._menu.open && game.floor.waitPoint?.conf.id === 'menu', 20000); g(rig, 'right'); // resume → save await tick(1500); g(rig, 'tap'); // 确认 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 rig2 = newGame({ seed: 42 }); rig2.game.meta.patrols = 3; rig2.game.meta.merit = 21; setLadder(3); rig2.game.start(); await tick(300); await waitState(() => rig2.game.floor.state === 'YOUR_TURN', 20000); g(rig2, 'tap'); await waitState(() => rig2.game.run && rig2.game.state === 'ROUTE', 20000); rig2.game._menuAbortPatrol(); await tick(300); assert(rig2.game.run === null && rig2.game.meta.merit === 21 && rig2.game.state === 'TITLE', '放弃巡逻:run 清空、meta 保留、回 TITLE'); // —— 转移链(段末 evt_dock)播中开菜单 → 掐链不丢流程,关闭后续走,零死锁 storageMap.clear(); const rig3 = await cruiseRig(42); const r3 = rig3.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 }; rig3.game.state = 'CRISIS'; rig3.game._crisis = c3; const m3 = rig3.audio.log.length; rig3.game._settleCrisis(c3, 'evade'); // res_clean 结算链(critical,菜单开不进) await waitState(() => plays(rig3.audio, m3).has('voice/evt_dock') && rig3.audio.isVoicePlaying(), 60000); g(rig3, 'down'); // evt_dock 转移链播中开菜单(非 critical:允许开,兜底定时器顺延) await tick(300); assert(rig3.game._menu.open, '转移链播中可开菜单(R8 冻结,链由 _sayFlow 兜底)'); const settled = await waitState(() => !rig3.game._menu.open || true, 1000); g(rig3, 'down'); // 关菜单 → 兜底续走 await waitState(() => rig3.game.state === 'DOCK', 60000); assert(rig3.game.state === 'DOCK', '转移链兜底推进:菜单关闭后段末照常落地(零死锁)'); game.stop(); rig2.game.stop(); rig3.game.stop(); ok('场景 13 通过(含转移链兜底)'); } // ================================ 场景 14:E1/E2/资产齐套 ================================ async function scenario14() { origConsoleLog('\n=== 场景 14:E1 重播告警 / E2 决策窗长按 / 存量资产 ==='); storageMap.clear(); const rig = await cruiseRig(42); const { game, audio } = rig; // E1:无近期告警 → cruise_ping let mark = audio.log.length; g(rig, 'tap'); await tick(100); assert(plays(audio, mark).has('sfx/cruise_ping'), 'E1:60s 内无告警 → cruise_ping(空态绝不静默,silent-ok)'); // 有近期告警 → 重播 game._noteAlarm('alm_engine'); game._lastAlarmPlay.at = vnow - 10000; mark = audio.log.length; g(rig, 'tap'); await tick(100); assert(plays(audio, mark).has('sfx/alm_engine'), 'E1:60s 窗内单击重播最近告警'); // E2:决策窗长按=invalid 回执+首次解释(管道锁定门,gd_invalid 复用为通用解释句) game.state = 'CRISIS'; const c = { type: 'meteor', dir: 'bow', optIdx: 0, subphase: 'decide', trap: false, remainMs: 12000, startedAt: vnow, echo: false }; game._crisis = c; mark = audio.log.length; g(rig, 'longpress'); await tick(1500); assert(plays(audio, mark).has('voice/gd_invalid'), 'E2:决策窗长按=invalid 回执+gd_invalid 首次解释(长按新手期无效)'); // 存量资产:旁白 144 基表 ×2 性别(26 号合并后) + NPC 12 平铺 + sfx + bgm(新句 sys_*/hint_*/inv_* // 未落盘不判失败——由主会话统一生成;这里只锁存量集合不被误删) 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 === 31 && keyM.length === 31, `键盘变体 key/ 26 号合并后两性别各 31(实际 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 集合一致'); const ids = (dir) => fs.readdirSync(dir).filter((f) => f.endsWith('.ogg')).map((f) => f.replace('.ogg', '')); const base = ids(path.join(audioDir, 'female')).filter((x) => x !== 'key'); const male = ids(path.join(audioDir, 'male')).filter((x) => x !== 'key'); assert(base.length === 144 && male.length === 144, `旁白基表 26 号合并后 144×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)'); game.stop(); ok('场景 14 通过'); } // ================================ 场景 15:§6.2 乱点机器人 ================================ /** * 急躁新手模拟(26 §6.2 行为表):语音开始 0.5~2s 就单击、连点 2~3 下、 * 随机方向滑、1s 内没回应再点、发呆 10~30s。跑完首局教学 + S1 + 靠港。 * 指标:无声操作=0、被动原句重复=0、打断失败=0(§6.4 合格线)。 * * 被动原句重复口径:同一 voice id 30s 内两次,且第二次不处于玩家请求链—— * 请求链 = floor.speak 起 播 前 12s 内有手势(巡检/选项报名/续播等),或前面 * 2.5s 内有「再说一遍:」;被动链(hint/feedback 类被动提示)重复即计数。 */ async function scenario15() { origConsoleLog('\n=== 场景 15:乱点机器人(§6.2:无声操作 0 / 被动原句重复 0 / 打断失败 0) ==='); storageMap.clear(); // stage 0 起:D0 提前解锁路径同真人冷启动 const rig = newGame({ seed: 2026 }); const { game, audio } = rig; // 回执记录(无声操作指标探针;mock 无 ctx,receipts.play 只记账不出声) const receiptLog = []; const origReceipt = game.receipts.play.bind(game.receipts); game.receipts.play = (kind) => { receiptLog.push({ kind, t: vnow }); return origReceipt(kind); }; // 语音链记账(被动原句重复指标:链起点 + 是否被动 hint/feedback 类) const chainTags = []; const origSpeak = game.floor.speak.bind(game.floor); game.floor.speak = (ids, opts = {}) => { chainTags.push({ t: vnow, ids: [...ids], passive: opts.kind === 'hint' || opts.kind === 'feedback' }); return origSpeak(ids, opts); }; let seed = 42; const rnd = () => { seed = (seed * 1103515245 + 12345) & 0x7fffffff; return seed / 0x7fffffff; }; const gestures = []; const bot = (gg) => { const voiceBefore = audio._voice; // 打断失败指标:手势前的在播句 token gestures.push({ g: gg, t: vnow, token: voiceBefore }); rig.pipe(gg); return voiceBefore; }; game.start(); await tick(50); const t0 = vnow; // 确定性首拍:开场指令句播完立刻点(第一次操作 ≤10s,§6.4) await waitState(() => game.floor.state === 'YOUR_TURN', 20000); bot('tap'); const idleChunks = []; let firstSuccessMs = null; for (let step = 0; step < 150; step++) { if (game.state === 'REPORT' && game._report?.done) break; const roll = rnd(); if (roll < 0.35) { // 语音开始后单击(跳句/继续/下令) await tick(300 + Math.floor(rnd() * 900)); bot('tap'); } else if (roll < 0.55) { // 连点 2~3 下(间隔 80~250ms;第二下多落在 400ms 防连点窗 → busy) bot('tap'); await tick(80 + Math.floor(rnd() * 170)); bot('tap'); if (rnd() < 0.5) { await tick(80 + Math.floor(rnd() * 170)); bot('tap'); } } else if (roll < 0.8) { // 随机方向滑(含未解锁的 up/down——锁定门给回执+首次解释) const dirs = ['up', 'down', 'left', 'right']; bot(dirs[Math.floor(rnd() * 4)]); await tick(150); } else if (roll < 0.9) { // 1 秒没听到回应就再点一次 await tick(1000); bot('tap'); } else { // 发呆 10~30s(虚拟) const before = vnow; await tick(10000 + Math.floor(rnd() * 20000)); idleChunks.push(vnow - before); } if (firstSuccessMs === null && receiptLog.some((r) => r.kind === 'ok')) firstSuccessMs = vnow - t0; await tick(120); } // ---- 指标 1:无声操作 = 0(每个手势 100ms 内有回执或内容音)---- let silentOps = 0; for (const ge of gestures) { const receipt = receiptLog.some((r) => r.t >= ge.t && r.t <= ge.t + 100); const content = audio.log.some((p) => p.t >= ge.t && p.t <= ge.t + 100); if (!receipt && !content) silentOps++; } assert(silentOps === 0, `无声操作 = 0(实际 ${silentOps}/${gestures.length})`); // ---- 指标 2:第一次操作 ≤10s、第一次成功 ≤20s(§6.4)---- assert(gestures.length > 0 && gestures[0].t - t0 <= 10000, `第一次操作 ≤10s(实际 ${gestures.length ? gestures[0].t - t0 : 'none'}ms)`); assert(firstSuccessMs !== null && firstSuccessMs <= 20000, `第一次成功 ≤20s(实际 ${firstSuccessMs}ms)`); // ---- 指标 3:打断失败 = 0(手势后同一句语音不应还在播;critical 句除外)---- let interruptFails = 0; for (const ge of gestures) { if (!ge.token) continue; await tick(0); if (audio._voice === ge.token) interruptFails++; // 旧句 token 仍存活 = 打断失败 } assert(interruptFails === 0, `打断失败 = 0(实际 ${interruptFails})`); // ---- 指标 4:被动原句重复 = 0(30s 内同 id 重播且非玩家请求)---- const chainAt = (t) => { let best = null; for (const c of chainTags) if (c.t <= t) best = c; else break; return best; }; let passiveRepeats = 0; const lastPlay = new Map(); for (const p of audio.log) { if (!p.id.startsWith('voice/')) continue; const sayAgainAt = audio.log.some((q) => q.id === 'voice/sys_say_again' && q.t <= p.t && p.t - q.t < 2500); const chain = chainAt(p.t); const gestureBefore = gestures.some((gg) => gg.t <= chain.t && chain.t - gg.t <= 12000); const requested = !sayAgainAt && chain && !chain.passive && gestureBefore; if (!requested && lastPlay.has(p.id) && p.t - lastPlay.get(p.id) < 30000 && !sayAgainAt) passiveRepeats++; lastPlay.set(p.id, p.t); } assert(passiveRepeats === 0, `被动原句重复 = 0(实际 ${passiveRepeats})`); // 行为完整性:机器人确实走了一段完整交互(教学 + 至少一次危机或靠港) assert(gestures.length >= 40, `机器人手势量足够(实际 ${gestures.length})`); const seen = new Set(audio.log.map((e) => e.id)); assert(seen.has('voice/sys_first_tap') && seen.has('voice/hint_inspect_full'), '机器人经历了开场指令与巡检教学'); game.stop(); ok(`场景 15 通过(${gestures.length} 手势 / 发呆 ${idleChunks.length} 次 / 首成功 ${firstSuccessMs}ms)`); } // ================================ 主流程 ================================ const scenarios = [ scenario1, scenario2, scenario3, scenario4, scenario5, scenario6, scenario7, scenario8, scenario9, scenario10, scenario11, scenario12, scenario13, scenario14, scenario15, ]; for (const s of scenarios) { try { await s(); } catch (e) { failures.push(`${s.name} 抛异常: ${e.stack}`); origConsoleError(` ✗ ${s.name} 抛异常: ${e.message}`); } } // ---- 资产信息性审计(不判失败;新句 sys_*/hint_*/inv_* 待主会话生成) ---- { 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} 缺配对`); } } const pendingNew = []; for (const id of ['sys_first_tap', 'sys_first_tap_short', 'sys_turn_teach', 'sys_say_again', 'sys_unlock_up', 'sys_unlock_down', 'sys_unlock_dt', 'hint_inspect_full', 'hint_inspect_short', 'hint_route_full', 'hint_route_short', 'hint_decide_full', 'hint_dock_full', 'hint_dock_short', 'inv_detect', 'inv_cruise', 'inv_drill', 'inv_title', 'inv_report', 'inv_tour', 'inv_up_locked', 'inv_down_locked', 'menu_restart_confirm']) { if (!assetExists('voice/' + id)) pendingNew.push(id); } origConsoleLog(`\n资产审计:缺失配对 ${missing.length};26 号新句待生成 ${pendingNew.length}(信息性)`); for (const m of missing.slice(0, 10)) origConsoleLog(' - ' + m); if (pendingNew.length) origConsoleLog(' 待生成: ' + pendingNew.join(', ')); } origConsoleLog(`\n========================================`); origConsoleLog(`playtest 断言通过 ${passCount},失败 ${failures.length}`); if (failures.length) { for (const f of failures) origConsoleError('FAIL: ' + f); process.exit(1); } origConsoleLog('ALL GREEN');