/** * 《雨夜专案》R3 无头试玩仿真测试(26 号 playbook 重构版;18 号附录 C §10 断言)。 * * 覆盖: * - §4.10 首 3 分钟脚本:3 秒内第一次操作(sys_first_tap critical)、开场教学链 * 嵌在真实案卷(听第一份→教右滑+解锁上滑→听第二份→「滑到指证」→练习零账目); * - §4.1 发言权:听取=ACTION(tap=停、滑动=打断切条目)、链间不掉回「轮到你」; * - §4.2/§4.3 回执+防连点+手势阶梯(up/down/doubletap 未解锁折算与首次解释); * - §4.4 三档提示(board 18s/40s:hint_board_full→short)+ 主动重听「再说一遍:」; * - §4.5 迷茫接管(L1 短句 → L2 只留期待手势); * - 玩法不变式:seed 复现、矩阵不变式(同轴异值对≡矛盾表)、指证 M1-M4/三档 miss、 * 结案三问、VERDICT 死窗+链锁(critical 全链)、评级六变量、pack B 变体; * - §4.12 断点复述(回来问继续/重开 + 状态复述链); * - 无进展轨 45/90/150(必结案保底); * - §6.2 乱点机器人:无声操作=0、被动原句重复=0、打断失败=0、首操作/首成功达标。 * * mock 层机制对齐 web/storyline/test/playtest.mjs(虚拟时钟 + Receipts 探针 + * makeInputPipeline 同构接线;game.floor.bedEnabled=false 防周期定时器不排水)。 * 资产契约:缺失 voice 资产按可用处理(1s 句长推进,与 TTS 资产生成解耦); * 资产审计只做信息性报告不判失败。 * * 运行:tools/node/bin/node web/detective/test/playtest.mjs * 退出码 0 = 全部断言通过。seed/pack 由 opts 注入复现。 */ import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; const DETECTIVE_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 < 500000; 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', { // maxTouchPoints: 1 —— 手机 persona:inputMode() 判 touch(键盘路由由场景 13 专测) 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); const origConsoleWarn = console.warn.bind(console); console.error = () => {}; // 资产缺失走信息性报告,不刷屏 console.warn = () => {}; 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(DETECTIVE_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 ================================ class MockAudioEngine { constructor() { this.log = []; // {t, layer, id, opts, missing} this._voice = null; // 当前 voice token(isVoicePlaying 判定依据) this.bgmNow = null; this.blocked = new Set(); // 被屏蔽的 voice id(onComplete 永不触发 → 15s 兜底续链) this._duckHolds = 0; // Floor/Receipts 合成守卫用(无 ctx:回执声/等待声/交接音全部 no-op,接口仍在) this.ctx = null; this._master = null; this.unlocked = false; } isVoicePlaying() { return this._voice !== null; } setRateBoostEnabled() {} holdDuck(on) { this._duckHolds = Math.max(0, this._duckHolds + (on ? 1 : -1)); } _dur() { return 1.0; } // 假句长(秒):虚拟时钟下语音链按 1s/句推进(缺失资产同速,解耦) 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,对齐引擎语义) if (this.blocked.has(id)) return; // 屏蔽:永不完成(游戏层 15s 兜底接管) const token = { id, onComplete: opts.onComplete }; this._voice = token; schedule(this._dur(id) * 1000, () => { if (this._voice === token) { this._voice = null; if (token.onComplete) token.onComplete(); } }); } else if (layer === 'bgm') { this.bgmNow = id; } // sfx:即发即弃,无后续 } /** 引擎语义:停掉当前句并视为自然播完(链继续走下一句)。 */ skipVoice() { const cur = this._voice; if (!cur) return; this._voice = null; if (cur.onComplete) cur.onComplete(); } 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, hasRe: (re) => L.some((e) => re.test(e.id)), countRe: (re) => L.filter((e) => re.test(e.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), }; } const SUCCESS_PATTERN = JSON.stringify([100]); function vibratedSuccess(fromIdx = 0) { return vibrateLog.slice(fromIdx).some((v) => JSON.stringify(v.p) === SUCCESS_PATTERN); } // ================================ 游戏装配(与 index.html 同构) ================================ const { DetectiveGame } = await import('../game.js'); const { settings } = await import('../../engine/settings.js'); const { makeInputPipeline } = await import('../../engine/pipeline.js'); settings.set({ guide: 'off', voice: 'female', speed: 1.0, bgm: 1.0, input: 'touch' }); const LISTEN_ITEMS = ['w1', 'w2', 'w3', 'w4', 'scene', 'fore']; const ITEM_ORDER = [...LISTEN_ITEMS, 'cf', 'case']; const audioRegistry = []; function makeRig(opts = {}) { const audio = new MockAudioEngine(); audioRegistry.push(audio); const game = new DetectiveGame(audio, {}, opts); // 回执记录(§6.2 无声操作指标的探针;底层无 ctx 静默,只记日志) const receiptLog = []; const origReceipt = game.receipts.play.bind(game.receipts); game.receipts.play = (kind) => { receiptLog.push({ kind, t: vnow }); return origReceipt(kind); }; // 等待声关掉:虚拟时钟下周期定时器不排水(26 号 storyline 测试同款) game.floor.bedEnabled = false; const pipe = makeInputPipeline({ floor: game.floor, confusion: game.confusion, receipts: game.receipts, invalidHints: game.invalidHints, game, ladder: game.ladder, }); return { audio, game, pipe, receiptLog }; } const g = async (rig, gesture) => { rig.pipe(gesture); await flushMicro(); }; /** 有界等待:条件满足或超时即返回。 */ async function waitState(pred, maxMs) { const t0 = vnow; while (!pred() && vnow - t0 < maxMs) await tick(200); return pred(); } /** 等到「轮到你」(链播完、话轮已交、等待点已挂)。 */ const atTurn = (rig) => waitState(() => !rig.game.floor.speaking && rig.game.floor.state === 'YOUR_TURN', 30000); /** 导航到证词板条目(最短环绕方向;签名 450ms + 条目名落声 + 交话轮)。 */ async function navToItem(rig, itemId) { const { game } = rig; const target = ITEM_ORDER.indexOf(itemId); let guard = 0; while (game.itemIdx !== target && guard++ < 12) { const d = (target - game.itemIdx + ITEM_ORDER.length) % ITEM_ORDER.length; await g(rig, d * 2 <= ITEM_ORDER.length ? 'right' : 'left'); await tick(2500); } return atTurn(rig); } /** 完整听取一个条目(nav + tap + 等链播完回到可操作)。 */ async function listenItem(rig, itemId) { await navToItem(rig, itemId); await g(rig, 'tap'); return waitState(() => rig.game._play === null && rig.game.floor.state === 'YOUR_TURN', 120000); } /** CONFRONT 内选一句:滑到条目 → 单击进选句 → 滑到句 → 单击确认 → 听完单击定下。 * entryIdx 可选:选句位置记忆——进选句相位时断言起始 lineIdx。 */ async function cfSelectLine(rig, caseData, lineId, entryIdx, { stopAtConfirm = false } = {}) { const { game } = rig; const itemId = caseData.lineItem[lineId]; const target = LISTEN_ITEMS.indexOf(itemId); let guard = 0; while ((game._cf?.idx ?? -1) !== target && guard++ < 8) { const d = (target - game._cf.idx + LISTEN_ITEMS.length) % LISTEN_ITEMS.length; await g(rig, d * 2 <= LISTEN_ITEMS.length ? 'right' : 'left'); await tick(2500); } await atTurn(rig); await g(rig, 'tap'); // 确认条目 → 进选句 if (entryIdx != null) { assert(game._cf?.lineIdx === entryIdx, `选句入口=记忆位 ${entryIdx}(实际 ${game._cf?.lineIdx},位置记忆)`); } await atTurn(rig); // cf_pick_line(首次)+ 入口句 const ids = caseData.layout.lines[itemId]; const li = ids.indexOf(lineId); let lg = 0; while ((game._cf?.lineIdx ?? -1) !== li && lg++ < 8) { const d = (li - game._cf.lineIdx + ids.length) % ids.length; await g(rig, d * 2 <= ids.length ? 'right' : 'left'); await tick(2500); } await atTurn(rig); await g(rig, 'tap'); // 进确认(重播该句 + cf_confirm) await atTurn(rig); if (!stopAtConfirm) await g(rig, 'tap'); // 定下这句 } /** 从 BOARD 空闲发起一次完整指证(滑到「指证」条目 + 单击进入 + 两侧选句)。 */ async function confront(rig, caseData, lineA, lineB, { entryIdxA } = {}) { await navToItem(rig, 'cf'); await g(rig, 'tap'); await waitState(() => rig.game.state === 'CONFRONT' && rig.game._cf && rig.game._cf.phase === 'item1', 12000); await atTurn(rig); // cf_start(+练习句)→ 条目报名 await cfSelectLine(rig, caseData, lineA, entryIdxA); await waitState(() => rig.game._cf && (rig.game._cf.phase === 'item2'), 20000); await tick(4000); // cf_second + 第一份自动重播(记忆契约) await atTurn(rig); await cfSelectLine(rig, caseData, lineB); return waitState(() => rig.game.state === 'BOARD' && rig.game.floor.state === 'YOUR_TURN', 120000); } /** 从 BOARD 走到 FINISH(滑到结案陈词 → 单击进确认闸 → 闸内再单击进三问)。 */ async function enterFinish(rig) { await navToItem(rig, 'case'); await g(rig, 'tap'); // 首击=确认闸 fin_pre await atTurn(rig); await g(rig, 'tap'); // 闸内再击 → FINISH return waitState(() => rig.game.state === 'FINISH' && rig.game.floor.state === 'YOUR_TURN', 20000); } /** * 熟手开局:跳过开场教学(drillDone=true),直达 BOARD(做对过的事不教第二遍)。 * stage=手势阶梯等效阶段(2=「结过一案」:上滑+菜单解锁、双击仍按单击折算,3=全解锁)。 */ async function startSeasoned(rig, stage = 2) { rig.game.meta.drillDone = true; for (let i = 0; i < stage; i++) rig.game.ladder.onGameDone(); // 1 局→阶段2;3 局→阶段3 if (stage >= 1) rig.game.ladder.onGoal(); rig.game.start(); await atTurn(rig); // sys_first_tap(critical) await g(rig, 'tap'); // 开新案 return waitState(() => rig.game.run && rig.game.floor.state === 'YOUR_TURN', 30000); } /** 矩阵不变式独立重算:同轴异值对集合 ≡ 矛盾表。 */ function matrixInvariant(c) { const all = c.items.flatMap((it) => it.lines); const norm = (x, y, ax) => (x < y ? `${x}|${y}|${ax}` : `${y}|${x}|${ax}`); const derived = new Set(); for (let i = 0; i < all.length; i++) { for (let j = i + 1; j < all.length; j++) { const A = all[i], B = all[j]; for (const ax of Object.keys(A.clues)) { if (B.clues[ax] !== undefined && B.clues[ax] !== A.clues[ax]) derived.add(norm(A.id, B.id, ax)); } } } const want = new Set(c.contradictions.map((c2) => norm(c2.a, c2.b, c2.axis))); const extra = [...derived].filter((k) => !want.has(k)); const missing = [...want].filter((k) => !derived.has(k)); return { ok: extra.length === 0 && missing.length === 0, extra, missing, size: derived.size }; } // ================================ 场景 1:开场 §4.10 + 教学嵌在真实案卷 ================================ async function scenario1() { origConsoleLog('\n=== 场景 1:开场(§4.10:3 秒动手/首成功/教右滑/解锁上滑/滑到指证/练习零账目)==='); storageMap.clear(); const rig = makeRig({ seed: 42 }); const { game, audio, receiptLog } = rig; game.start(); await tick(400); assert(game.floor.speaking && plays(audio).has('voice/sys_first_tap'), '0~3 秒:开场第一句 = 「点一下屏幕。」(第一句就让动手)'); // 指令句 critical:抢拍单击 = busy 回执(不打断唯一一次开场指令) await g(rig, 'tap'); assert(receiptLog.some((r) => r.kind === 'busy'), '指令播放中单击 = busy 回执(关键句不可打断)'); assert(await atTurn(rig), '开场指令播完交话轮(YOUR_TURN)'); let mark = audio.log.length; const vib0 = vibrateLog.length; await g(rig, 'tap'); // 第一次有效操作 → 新案 + 开场教学链 assert(game.run && game.run.seed === 42, 'opts.seed=42 注入新案'); assert(plays(audio).has('bgm/board'), '新案 bgm/board'); await waitState(() => plays(audio).count('voice/sys_turn_teach') === 1, 30000); assert(plays(audio, mark).orderOk('voice/sys_new_case', 'voice/intro_1', 'voice/item_w1', 'voice/tut_listen', 'voice/sys_turn_teach'), '开场链:新案 → 极短案情一句(intro_1)→ 签名+老葛证词名 →「单击,听取。」→「轮到你了」教学(砍规则念白)'); assert(plays(audio, mark).has('sfx/sig_ge'), '条目播报签名前置(sig_ge)'); assert(await atTurn(rig), '开场链完交话轮'); // 单击听取老葛真实证词(8~10 秒:第一次成功=听到证词) mark = audio.log.length; await g(rig, 'tap'); assert(game._play !== null && game.floor.state === 'ACTION', '单击听取 → 证词链起播(floor=ACTION:玩法声即状态,tap=停/滑动=切条目直达游戏层)'); assert(audio._duckHolds > 0, '听取期间持有音乐压低(holdDuck 跨整链)'); const w1Ids = game._case.layout.lines.w1; const done1 = await waitState(() => game._play === null && game.floor.state === 'YOUR_TURN', 120000); assert(done1, 'w1 证词链播完交话轮'); { const seg = plays(audio, mark); let pos = -1, orderOk1 = true; for (const id of w1Ids) { const i = seg.entries().findIndex((e, j) => j > pos && e.id === 'voice/' + id); if (i < 0) { orderOk1 = false; break; } pos = i; } assert(orderOk1, `w1 句序 = layout.lines.w1(${w1Ids.join(',')})`); } assert(game.run.heard.includes('w1'), '听完 w1 入账 heard(st_asked 真实来源)'); // 10~50 秒:教右滑 + 听完第一份 → 解锁上滑(阶梯阶段 1)——mark 仍是听取前那个 assert(plays(audio, mark).orderOk('voice/tut_next_full', 'voice/sys_unlock_up'), '第一份听完 →「向右滑,下一份。」+ 上滑解锁教学(§4.3 阶梯:小目标=听完第一份证词)'); assert(game.ladder.stage() >= 1, 'ladder.onGoal() → 手势阶梯阶段 1(上滑解锁)'); assert(await atTurn(rig), '教学链完交话轮'); // 右滑换条目(签名+报名) mark = audio.log.length; await g(rig, 'right'); assert(game.itemIdx === 1, '右滑 → 下一份(学徒小顺)'); await waitState(() => plays(audio, mark).has('voice/item_w2'), 8000); assert(plays(audio, mark).has('sfx/paper_turn') && plays(audio, mark).has('sfx/sig_shun') && plays(audio, mark).has('voice/item_w2'), '换条目播报:paper_turn + sig_shun + item_w2(签名前置)'); await atTurn(rig); // 听第二份 → 50~90 秒教「滑到指证,点一下。」 await g(rig, 'tap'); await waitState(() => game._play === null, 120000); assert(plays(audio).has('voice/tut_confront'), '第二份听完 →「滑到指证,点一下。」(新 tut_confront 词)'); await atTurn(rig); // 滑到「指证」条目 + 单击进入(指证=案卷列表项,非长按) await navToItem(rig, 'cf'); assert(game.itemIdx === ITEM_ORDER.indexOf('cf'), '左右滑到「指证」条目'); assert(plays(audio).has('voice/item_cf'), '「指证」条目报名(item_cf,无签名=交接提示音代位)'); await g(rig, 'tap'); assert(await waitState(() => game.state === 'CONFRONT' && game._cf, 10000), '单击进入指证(非长按)'); await waitState(() => plays(audio).has('voice/tut_cf_try'), 20000); assert(plays(audio).has('voice/cf_start') && plays(audio).has('voice/tut_cf_try'), '练习句 tut_cf_try 接在指证入口链上(首次指证=练习)'); await atTurn(rig); // 练习:任选两句(真判定必 miss 的组合)→ 只播 tut_cf_ok,零账目污染 const before = { cfMiss: game.run.cfMiss, doubts: [...game.run.doubts], zHits: game.run.zHits }; await cfSelectLine(rig, game._case, game._case.layout.lines.fore[0]); await waitState(() => game._cf && game._cf.phase === 'item2', 20000); await tick(4000); await atTurn(rig); mark = audio.log.length; const vib5 = vibrateLog.length; await cfSelectLine(rig, game._case, game._case.layout.lines.w1[0]); const done5 = await waitState(() => game.state === 'BOARD' && game.floor.state === 'YOUR_TURN', 30000); assert(done5, '练习判定被截获 → 回 BOARD'); assert(plays(audio, mark).has('voice/tut_cf_ok'), '练习只播 tut_cf_ok(练习不算数)'); assert(!plays(audio, mark).hasRe(/^voice\/(cf_hit|cf_same|cf_diff|cf_self)$/), '真判定句(hit/三档 miss)零出现'); assert(!plays(audio, mark).has('sfx/contra_hit') && !plays(audio, mark).has('sfx/contra_miss'), '判定音零出现(练习不算数)'); assert(vibratedSuccess(vib5), '练习完成附 success 震'); assert(game.run.cfMiss === before.cfMiss && game.run.zHits === before.zHits && JSON.stringify(game.run.doubts) === JSON.stringify(before.doubts), '练习零账目污染(cfMiss/doubts/zHits 不变)'); assert(game.meta.drillDone === true, '教学收官入元进度(二周目不再练习)'); assert(game.run.heard.length === 2, '教学期真实听取了 w1+w2(教学寄生于真实案卷)'); // 上滑状态链随时可用(教过后) mark = audio.log.length; await g(rig, 'up'); await waitState(() => plays(audio, mark).count('voice/st_doubt_0') === 1, 30000); assert(plays(audio, mark).orderOk('voice/st_asked_2', 'voice/st_doubt_0'), '上滑状态链随时可用(已问 2/疑点 0)'); game.stop(); // ---- 乱做一气也能开局:教学等待点三档 + 不会死锁 ---- storageMap.clear(); const b = makeRig({ seed: 7 }); b.game.start(); assert(await atTurn(b), '第二局:开场指令播完'); await g(b, 'up'); // 未解锁上滑 → invalid 回执 + 首次解释(阶梯阶段 0) assert(b.receiptLog.some((r) => r.kind === 'invalid'), '未解锁上滑 → invalid 回执'); await atTurn(b); // 解释句播完(听取指令前先回话轮) await g(b, 'tap'); // 照常开新案(试错无代价) assert(await waitState(() => b.game.run && b.game.floor.state === 'YOUR_TURN', 30000), '无效操作不卡流程(开新案照常)'); b.game.stop(); } // ================================ 场景 2:seed 复现 ================================ async function scenario2() { origConsoleLog('\n=== 场景 2:seed 复现——(42,0) 两次构造 _case 深相等;(42,1)≠(43,1) ==='); storageMap.clear(); const a = makeRig({ seed: 42 }); const b = makeRig({ seed: 42 }); const c1 = a.game._buildCase(42, 0); const c2 = b.game._buildCase(42, 0); assert(JSON.stringify(c1) === JSON.stringify(c2), '同 (seed=42, pack=0) 两次构造 _case 深相等(布局/选项序/提示序)'); const d1 = a.game._buildCase(42, 1); const d2 = a.game._buildCase(43, 1); assert(JSON.stringify(d1.layout) !== JSON.stringify(d2.layout), '(42,1) 与 (43,1) 布局不同'); assert(JSON.stringify(c1.layout) !== JSON.stringify(d1.layout), '(42,0) 与 (42,1) 布局不同(包内 fork + 洗牌差)'); const fixedLines = ['w1_1', 'w1_3', 'w1_6']; assert(fixedLines.every((id) => c1.items.find((it) => it.id === 'w1').lines.some((l) => l.id === id)), '非 shuffle 句全部在场(集合不变,仅槽位置换)'); const slotsA = c1.items.find((it) => it.id === 'w1').lines.map((l) => l.id).sort().join(','); const slotsB = c2.items.find((it) => it.id === 'w1').lines.map((l) => l.id).sort().join(','); assert(slotsA === slotsB, 'w1 句集合不变(置换非增删)'); assert(c1.items.map((it) => it.id).join(',') === 'w1,w2,w3,w4,scene,fore,cf,case', '案卷列表 8 项:六份卷宗 + 指证 + 结案陈词'); ok(`seed 复现三组对比通过(wpOpts=${JSON.stringify(c1.layout.wpOpts)} hintOrder=${JSON.stringify(c1.layout.hintOrder)})`); } // ================================ 场景 3:矩阵不变式 ================================ async function scenario3() { origConsoleLog('\n=== 场景 3:矩阵不变式——同轴异值对 ≡ 矛盾表(pack A/B 各验一遍) ==='); const { game } = makeRig({}); for (const [seed, pack] of [[42, 0], [43, 0], [100, 1], [101, 1]]) { const c = game._buildCase(seed, pack); const r = matrixInvariant(c); assert(r.ok, `矩阵不变式 (seed=${seed},pack=${pack}):derived=${r.size} 对 ≡ contradictions(extra=${r.extra.join(',')} missing=${r.missing.join(',')})`); } const c = game._buildCase(42, 0); assert(c.contradictions.length === 4 && c.contradictions.map((x) => x.id).join(',') === 'm1,m2,m3,m4', 'pack A 矛盾表 = M1-M4(lamp/key/weapon/scale)'); const cb = game._buildCase(100, 1); assert(cb.contradictions.map((x) => x.a).join(',').includes('b_shun_1'), 'pack B 矛盾指向 b_shun_*'); // 三档归因真话来源可达性:一致对 / 无共享轴对 / 同句 const all = c.items.flatMap((it) => it.lines); let sameAxisDiffVal = 0, sameAxisSameVal = 0, noShared = 0; for (let i = 0; i < all.length; i++) for (let j = i + 1; j < all.length; j++) { const A = all[i], B = all[j]; const shared = Object.keys(A.clues).filter((ax) => B.clues[ax] !== undefined); if (!shared.length) noShared++; else if (shared.every((ax) => A.clues[ax] === B.clues[ax])) sameAxisSameVal++; else sameAxisDiffVal++; } assert(sameAxisDiffVal === 4, `全案卷同轴异值对恰 4(M1-M4,实际 ${sameAxisDiffVal})`); assert(sameAxisSameVal >= 6, `一致对 ≥6 组(cf_same 真话来源,实际 ${sameAxisSameVal})`); assert(noShared >= 13, `无共享轴句对 ≥13(cf_diff 真话来源,实际 ${noShared})`); ok('三档归因档位全部真实可达(数据单源推导)'); } // ================================ 场景 4:听取细节(ACTION 态手势矩阵) ================================ async function scenario4() { origConsoleLog('\n=== 场景 4:听取——句序/续听/tap 停/连点 busy/滑动打断切条目/上滑状态链 ==='); storageMap.clear(); const rig = makeRig({ seed: 42 }); const { game, audio, receiptLog } = rig; await startSeasoned(rig); assert(game.run.seed === 42, 'seed 注入(熟手开局)'); // w2 完整听取:句序 = layout、播完 st_asked 升档 let mark = audio.log.length; assert(await listenItem(rig, 'w2'), 'w2 链播完成'); { const ids = game._case.layout.lines.w2; const seg = plays(audio, mark); let pos = -1, okOrder = true; for (const id of ids) { const i = seg.entries().findIndex((e, j) => j > pos && e.id === 'voice/' + id); if (i < 0) { okOrder = false; break; } pos = i; } assert(okOrder, `w2 句序=layout(${ids.join(',')})`); } assert(game.run.heard.includes('w2') && game.run.heardLines.w2 === game._case.layout.lines.w2.length, 'w2 入账 heard + heardLines 全量'); mark = audio.log.length; await g(rig, 'up'); await waitState(() => plays(audio, mark).has('voice/st_asked_1'), 30000); assert(plays(audio, mark).has('voice/st_asked_1'), '听完 1 份 → 上滑 st_asked_1'); // w4:播放中 tap=停止、连点 busy、左右滑=打断切条目、断句续听 await navToItem(rig, 'w4'); mark = audio.log.length; // 连点防抖:起播 tap 后 400ms 内的第二下只给 busy(不把自己刚起的播掐死) await g(rig, 'tap'); // 起播 const busy0 = receiptLog.filter((r) => r.kind === 'busy').length; await g(rig, 'tap'); // 同拍第二下(虚拟时钟未推进 = 400ms 内)→ pipeline busy assert(receiptLog.filter((r) => r.kind === 'busy').length === busy0 + 1, '起播后 400ms 内连点:第二下只给 busy 回执(不打断自己刚起的播)'); assert(game._play !== null && game.floor.state === 'ACTION', '连点未误停(仍在播)'); await waitState(() => game._play && game._play.cur >= 1, 30000); // 第二句起播(跨过防抖窗) await g(rig, 'tap'); // 停止 assert(game._play === null, '听取中单击=停止'); await tick(50); // 让停止的延迟交话轮落定 mark = audio.log.length; await tick(25000); assert(!plays(audio, mark).hasRe(/^voice\/(wen_|z_|w2_|b_)/), '停止后无后续证词句播出'); // 续听:从首个未听句起 const resumeIdx = game.run.heardLines.w4 || 0; assert(resumeIdx >= 1 && resumeIdx < game._case.layout.lines.w4.length, `w4 断点在句 ${resumeIdx}(1≤断点<6)`); mark = audio.log.length; await g(rig, 'tap'); await waitState(() => game._play && game._play.cur === resumeIdx, 10000); assert(game._play && game._play.cur === resumeIdx, `续听从首个未听句起(cur=${game._play?.cur} = heardLines=${resumeIdx})`); // 播放中左右滑:打断并切条目(滑动有明确意图,不再静默忽略) const idxBefore = game.itemIdx; await g(rig, 'left'); assert(game._play === null && game.itemIdx !== idxBefore, '播放中左滑:打断并切条目(旧「静默忽略」废除)'); assert(plays(audio).has('sfx/paper_turn'), '切条目有翻页音(内容反馈)'); await atTurn(rig); // 播放中上滑:打断证词播状态链 await navToItem(rig, 'scene'); await g(rig, 'tap'); await waitState(() => game._play && game._play.cur >= 0, 30000); const scCur = game._play ? game._case.layout.lines.scene[game._play.cur] : '(未起播)'; mark = audio.log.length; await g(rig, 'up'); await tick(3000); assert(game._play === null && plays(audio, mark).hasRe(/^voice\/st_asked_/), '播放中上滑打断证词播状态链(播报值=实值)'); await atTurn(rig); // 全部听齐 → st_asked_6;尾句换指路 recap_cf(滑到指证的新词) for (const id of ['w1', 'w3', 'w4', 'fore', 'scene']) assert(await listenItem(rig, id), `${id} 听取完成`); assert(game.run.heard.length === 6, '六份卷宗全部入账'); mark = audio.log.length; await g(rig, 'up'); await waitState(() => plays(audio, mark).has('voice/recap_cf'), 50000); assert(plays(audio, mark).has('voice/st_asked_6'), '六份听齐 → st_asked_6'); assert(plays(audio, mark).has('voice/recap_cf'), '听齐未破时状态链尾=recap_cf(「滑到指证,去比对」新词)'); await atTurn(rig); // 单句资产缺失 15s 兜底续链(屏蔽一个未听句重听场景:整体重听 w1) audio.blocked.add('voice/' + game._case.layout.lines.w1[0]); await navToItem(rig, 'w1'); mark = audio.log.length; await g(rig, 'tap'); const doneBlk = await waitState(() => game._play === null, 120000); assert(doneBlk && plays(audio, mark).count('voice/' + game._case.layout.lines.w1[1]) === 1, '单句资产缺失:15s 兜底告警续链(流程不卡死)'); audio.blocked.clear(); game.stop(); } // ================================ 场景 5:指证 M1-M4 逐条走通 ================================ async function scenario5() { origConsoleLog('\n=== 场景 5:M1-M4 指证(hit 链按轴/共用 z_4/自动重播/疑点 4-4 hint_fin/选句记忆)==='); storageMap.clear(); const rig = makeRig({ seed: 42 }); const { game, audio } = rig; await startSeasoned(rig); const cs = game._case; // M1(lamp:z_2 vs wen_2) let mark = audio.log.length; let vib = vibrateLog.length; assert(await confront(rig, cs, 'z_2', 'wen_2'), 'M1 指证链收尾回板'); { const p = plays(audio, mark); assert(p.has('sfx/contra_hit') && p.has('voice/cf_hit') && p.has('voice/z_react_1'), 'M1 hit 链:contra_hit → cf_hit → z_react_1(按轴取句 lamp→react_1)'); assert(p.orderOk('sfx/contra_hit', 'voice/cf_hit', 'voice/z_react_1'), 'M1 hit 链序:音先响、语音后接'); assert(p.orderOk('voice/cf_confirm', 'voice/cf_second', 'voice/z_2'), '第二份选择前自动重播第一份选定句(记忆契约)'); } assert(vibratedSuccess(vib), 'hit 附 success 震'); assert(game.run.doubts[0] === true && game.run.zHits === 1, 'M1 doubts[0] 置位 + zHits=1'); assert(game.run.cfMiss === 0, 'hit 不计 cfMiss(账目语义=冗余/错过)'); // M2(key:z_3 vs w2_3)——选句入口应记忆在 z_2(idx 1),不再从 z_1 扫起 mark = audio.log.length; assert(await confront(rig, cs, 'z_3', 'w2_3', { entryIdxA: 1 }), 'M2 指证完成(选句入口=记忆位 1)'); assert(plays(audio, mark).has('voice/z_react_2') && game.run.doubts[1] === true, 'M2 → z_react_2(key 轴)+ doubts[1]'); // M3/M4 共用 z_4(一句两断言) mark = audio.log.length; assert(await confront(rig, cs, 'z_4', 'fo_3', { entryIdxA: 2 }), 'M3 指证完成(z_4 vs fo_3,入口=记忆位 2)'); assert(plays(audio, mark).has('voice/z_react_w') && game.run.doubts[2] === true, 'M3 → z_react_w(weapon 轴专属慌「铜器…我怎么会知道」)'); mark = audio.log.length; assert(await confront(rig, cs, 'z_4', 'sc_4', { entryIdxA: 3 }), 'M4 指证完成(z_4 vs sc_4,同一句两次,入口=记忆位 3)'); assert(plays(audio, mark).has('voice/z_react_3') && game.run.doubts[3] === true, 'M4 → z_react_3(scale 轴)+ doubts[3]'); // 疑点 4/4:链尾 reactFinal 收官句 + 自动播一次 hint_fin assert(game._doubtCount() === 4 && game.run.zHits === 4, '四疑点齐 + zHits=4'); assert(plays(audio, mark).orderOk('voice/z_react_3', 'voice/z_react_4', 'voice/hint_fin'), '4/4 齐时链尾:轴句 → reactFinal(z_react_4 逐客)→ hint_fin(一次)'); assert(!game.run.hintsGiven.includes('hint_fin'), '庆祝性 hint_fin 不计入保底提示账(S 档可达)'); mark = audio.log.length; await g(rig, 'up'); await waitState(() => plays(audio, mark).has('voice/hint_fin'), 50000); assert(plays(audio, mark).has('voice/st_doubt_4'), '状态链 st_doubt_4(疑点实数)'); assert(plays(audio, mark).has('voice/hint_fin'), '疑点齐时状态链尾=hint_fin(去结案)'); // 指证确认态:左右滑=取消回预览(矩阵) await navToItem(rig, 'cf'); await g(rig, 'tap'); await waitState(() => game.state === 'CONFRONT' && game._cf && game._cf.phase === 'item1', 12000); await atTurn(rig); await cfSelectLine(rig, cs, cs.layout.lines.w3[0], null, { stopAtConfirm: true }); // 进到 confirm1(未定下) await waitState(() => game._cf && game._cf.phase === 'confirm1', 20000); mark = audio.log.length; await g(rig, 'left'); // 确认态左滑=取消 assert(game._cf && game._cf.phase === 'line1', '确认态左滑=取消回预览(矩阵:左右滑=取消)'); await waitState(() => plays(audio, mark).hasRe(/^voice\/z_/), 8000); assert(plays(audio, mark).hasRe(/^voice\/z_/), '取消后重播当前预览句'); // 再进确认相位,上滑=简状态(疑点数) await atTurn(rig); await g(rig, 'tap'); await waitState(() => game._cf && game._cf.phase === 'confirm1', 20000); mark = audio.log.length; await atTurn(rig); await g(rig, 'up'); await waitState(() => plays(audio, mark).has('voice/st_doubt_4'), 30000); assert(plays(audio, mark).has('voice/st_doubt_4'), '确认相位上滑=简状态(疑点数)'); // 菜单「返回证词板」退出指证 await atTurn(rig); await g(rig, 'down'); await waitState(() => game.menuOpen && plays(audio).has('voice/sys_menu_resume'), 15000); assert(plays(audio).has('voice/sys_menu') && plays(audio).has('voice/sys_menu_resume'), '菜单开场:sys_menu + 首项 resume(左右滑切换、单击确认)'); await atTurn(rig); mark = audio.log.length; await g(rig, 'tap'); // 确认 resume = 返回证词板 assert(await waitState(() => game.state === 'BOARD' && !game.menuOpen, 15000), '菜单「返回证词板」退出指证'); assert(!plays(audio, mark).hasRe(/^voice\/(gd_board|gd_cf|gd_fin|gd_invalid)$/), 'guide 看门狗句零出现(26 号:等待点三档接管)'); game.stop(); } // ================================ 场景 6:miss 三档真话 + 无序 ================================ async function scenario6() { origConsoleLog('\n=== 场景 6:miss 三档(cf_same/cf_diff/cf_self)+ 指证对无序 ==='); storageMap.clear(); const rig = makeRig({ seed: 43 }); const { game, audio } = rig; await startSeasoned(rig); const cs = game._case; // 指证对无序:反向选 M1(wen_2 在前)也 hit let mark = audio.log.length; assert(await confront(rig, cs, 'wen_2', 'z_2'), '反向 M1 指证完成'); assert(plays(audio, mark).has('sfx/contra_hit') && plays(audio, mark).has('voice/cf_hit'), '指证对无序(任一端在前都判 hit)'); assert(game.run.doubts[0] === true && game.run.zHits === 1, '反向 M1 同样置疑点'); // 同轴同值(w1_2 vs wen_1:都说雨 21:40)→ cf_same mark = audio.log.length; assert(await confront(rig, cs, 'w1_2', 'wen_1'), '同轴同值指证完成'); assert(plays(audio, mark).has('sfx/contra_miss') && plays(audio, mark).has('voice/cf_same'), 'miss 链:contra_miss → cf_same(说得一致,不矛盾——轴数据同值真话)'); assert(!plays(audio, mark).has('voice/cf_diff') && !plays(audio, mark).has('voice/cf_self'), '同轴同值只播 cf_same'); // 无共享轴(z_5 vs sc_1)→ cf_diff mark = audio.log.length; assert(await confront(rig, cs, 'z_5', 'sc_1'), '无共享轴指证完成'); assert(plays(audio, mark).has('voice/cf_diff') && !plays(audio, mark).has('voice/cf_same'), '无共享轴只播 cf_diff(说的不是一件事)'); // 同句自比(z_5 vs z_5)→ cf_self mark = audio.log.length; assert(await confront(rig, cs, 'z_5', 'z_5'), '同句自比完成'); assert(plays(audio, mark).has('voice/cf_self'), '同句播 cf_self(和自己比不出矛盾)'); assert(game.run.cfMiss === 3, `cfMiss=3(三档 miss 各计一次,实际 ${game.run.cfMiss})`); assert(game.run.doubts.filter(Boolean).length === 1 && game.run.zHits === 1, 'miss 不动 doubts/zHits'); // 无资源/次数惩罚:miss 后仍可继续指证与听取 assert(await listenItem(rig, 'fore'), 'miss 后听取照常(无惩罚性失败)'); game.stop(); } // ================================ 场景 7+8:结案三问 + VERDICT 死窗 ================================ async function scenario7() { origConsoleLog('\n=== 场景 7+8:确认闸/错指不在场证明/三问/VERDICT 死窗+链锁/接新案翻包 ==='); storageMap.clear(); const rig = makeRig({ seed: 42 }); const { game, audio, receiptLog } = rig; await startSeasoned(rig); const cs = game._case; // 四疑点(评级账目素材) for (const [a, b] of [['z_2', 'wen_2'], ['z_3', 'w2_3'], ['z_4', 'fo_3'], ['z_4', 'sc_4']]) { assert(await confront(rig, cs, a, b), `指证 ${a} vs ${b}`); } assert(game._doubtCount() === 4, '四疑点齐'); // 结案确认闸:首击只播 fin_pre 不进 FINISH;滑走撤闸;闸内再击才进 let mark = audio.log.length; await navToItem(rig, 'case'); await g(rig, 'tap'); assert(game.state === 'BOARD' && game._caseGate === true && plays(audio, mark).has('voice/fin_pre'), '结案陈词首击=确认闸(fin_pre),仍 BOARD'); await atTurn(rig); await g(rig, 'left'); // 滑走撤闸(同时换走条目) assert(game._caseGate === false && game.state === 'BOARD', '滑走撤闸留板(继续查)'); await navToItem(rig, 'case'); // 滑回结案陈词 await g(rig, 'tap'); // 再进闸 assert(game._caseGate === true && plays(audio).has('voice/fin_pre'), '滑回再击=重新进闸'); await atTurn(rig); mark = audio.log.length; await g(rig, 'tap'); // 闸内再击 → FINISH assert(await waitState(() => game.state === 'FINISH' && game._fin && game._fin.phase === 'who', 20000), '闸内再击 → FINISH 三问'); assert(plays(audio, mark).has('voice/fin_start'), 'FINISH 入口 fin_start'); await atTurn(rig); // 问句链 + 首选项报名落声 // 错指小顺(optIdx 1)→ acc_shun + fin_clear + st_wrong → 回 BOARD(无限再机会) mark = audio.log.length; await g(rig, 'right'); await waitState(() => plays(audio, mark).has('voice/item_who_w2'), 8000); assert(plays(audio, mark).has('voice/item_who_w2'), '真凶问右滑播人名(item_who_w2「学徒小顺。」)'); await atTurn(rig); await g(rig, 'tap'); // 进两段确认 await waitState(() => game._fin && game._fin.phase === 'whoConfirm' && plays(audio, mark).has('voice/fin_who_confirm'), 10000); assert(plays(audio, mark).has('voice/fin_who_confirm'), '真凶问两段确认(重播人名 + fin_who_confirm「就指认这位?」)'); await atTurn(rig); await g(rig, 'tap'); // 正式指认(错) assert(await waitState(() => game.state === 'BOARD', 60000), '错指 → 回 BOARD'); assert(plays(audio, mark).orderOk('voice/acc_shun', 'voice/fin_clear', 'voice/st_wrong'), '错指链:acc_shun(不在场证明自述)→ fin_clear → st_wrong(当场记账)'); assert(game.run.wrongWho === 1, 'wrongWho 计数'); // FINISH 上滑 = 重听问句 await enterFinish(rig); mark = audio.log.length; await g(rig, 'up'); await waitState(() => plays(audio, mark).count('voice/fin_start') === 1, 30000); assert(plays(audio, mark).orderOk('voice/fin_start'), '结案三问上滑=重听问句(+当前选项)'); await atTurn(rig); // 错指老葛(optIdx 0,无需滑动)→ acc_ge(无限再机会) await g(rig, 'tap'); await waitState(() => game._fin.phase === 'whoConfirm', 10000); await atTurn(rig); mark = audio.log.length; await g(rig, 'tap'); assert(await waitState(() => game.state === 'BOARD', 60000), '再错指仍回 BOARD(无限再机会)'); assert(plays(audio, mark).has('voice/acc_ge'), '错指老葛 → acc_ge'); assert(game.run.wrongWho === 2, 'wrongWho=2'); // 正指周先生(optIdx 2)→ 凶器问(选项序=layout)→ 动机问 → VERDICT await enterFinish(rig); mark = audio.log.length; await g(rig, 'right'); await g(rig, 'right'); await tick(300); assert(plays(audio, mark).has('voice/item_who_w3'), '滑到周先生(item_who_w3 人名)'); await atTurn(rig); await g(rig, 'tap'); await waitState(() => game._fin.phase === 'whoConfirm', 10000); await atTurn(rig); mark = audio.log.length; await g(rig, 'tap'); // 正式指认(对) assert(await waitState(() => game._fin && game._fin.phase === 'weapon', 45000), '指认对 → 凶器问'); assert(plays(audio, mark).has('voice/fin_ask_weapon'), '凶器问入口 fin_ask_weapon'); await atTurn(rig); // 凶器 4 选项全可预览(左右滑一次听一项,选项序=layout.wpOpts) for (let i = 0; i < 4; i++) { await g(rig, 'right'); await tick(300); } await atTurn(rig); { const seg = plays(audio, mark); const heard = cs.layout.wpOpts.filter((id) => seg.has('voice/' + id)); assert(heard.length === 4, `凶器 4 选项全可预览(${heard.join(',')},序=${cs.layout.wpOpts.join(',')})`); } // 答对 wp_2(滑到其 layout 下标;确认选项即答题——凶器问无两段确认) const wpIdx = cs.layout.wpOpts.indexOf('wp_2'); for (let i = 0; i < wpIdx; i++) { await g(rig, 'right'); await tick(200); } await atTurn(rig); await g(rig, 'tap'); assert(await waitState(() => game._fin && game._fin.phase === 'motive', 45000), '凶器答完 → 动机问'); assert(plays(audio).has('voice/fin_wp_right') && !plays(audio).has('voice/fin_wp_wrong'), '答对凶器播 fin_wp_right'); assert(game.run.weaponAns === 'wp_2', 'weaponAns 入账'); await atTurn(rig); // 动机答错 mv_2 → fin_mv_wrong(真话归因,不重答)→ VERDICT const mvIdx = cs.layout.mvOpts.indexOf('mv_2'); for (let i = 0; i < mvIdx; i++) { await g(rig, 'right'); await tick(200); } await atTurn(rig); mark = audio.log.length; await g(rig, 'tap'); assert(await waitState(() => game.state === 'VERDICT', 45000), '动机答完 → VERDICT'); assert(plays(audio, mark).has('voice/fin_mv_wrong'), '动机答错播 fin_mv_wrong(不重答,记账)'); // ---- VERDICT 死窗 + 链锁:链完成前一切手势吞掉(critical=busy 回执)---- const busyBefore = receiptLog.filter((r) => r.kind === 'busy').length; await g(rig, 'tap'); await g(rig, 'up'); await g(rig, 'down'); await g(rig, 'doubletap'); await g(rig, 'right'); await g(rig, 'longpress'); assert(game.state === 'VERDICT', '死窗:指认对瞬间在途 tap/up/down/doubletap/right/longpress 全部吞掉(仍 VERDICT)'); assert(receiptLog.filter((r) => r.kind === 'busy').length > busyBefore || receiptLog.filter((r) => r.kind === 'invalid').length > 0, '链锁期手势有回执(busy/invalid,不静默)'); const settleDone = await waitState(() => game.state === 'SETTLE' && game.floor.state === 'YOUR_TURN', 220000); assert(settleDone, 'VERDICT 全链播完 → SETTLE'); const p = plays(audio, mark); assert(p.orderOk('voice/fin_right', 'voice/z_break_1', 'voice/z_break_2', 'voice/z_break_3', 'sfx/gavel'), '认罪链:fin_right → z_break_1..3 → gavel(完整有序,未被手势截杀)'); assert(p.has('sfx/rain_swell'), '认罪链垫 rain_swell'); assert(p.orderOk('voice/settle_open', 'voice/rate_b', 'voice/st_doubt_4', 'voice/st_miss_0', 'voice/settle_hook'), '结算链:settle_open → rate_b(wrongWho=2→B 档)→ st_doubt_4 → st_miss_0 → settle_hook(播报值=运行值)'); assert(game.meta.casesClosed === 1 && game.meta.bestGrade === 'B', '元进度入账 casesClosed=1 bestGrade=B'); assert(game.run === null, '结案清 run(summary 切已结案口径)'); { const save = JSON.parse(storageMap.get('soundgame.progress.detective')); assert(save.summary === '已结案1起 · 最佳B', `结案 summary 规则("${save.summary}")`); } // SETTLE:上滑=评级一句;左右滑=invalid+解释;单击=接新案(pack 翻转) mark = audio.log.length; await g(rig, 'left'); assert(receiptLog.filter((r) => r.kind === 'invalid').length > 0, '结算态左右滑=invalid 回执'); await atTurn(rig); await g(rig, 'up'); await waitState(() => plays(audio, mark).has('voice/st_miss_0'), 30000); assert(plays(audio, mark).orderOk('voice/rate_b', 'voice/st_doubt_4', 'voice/st_miss_0'), 'SETTLE 上滑=评级账目段'); await atTurn(rig); mark = audio.log.length; await g(rig, 'tap'); const fresh = await waitState(() => game.state === 'BOARD' && game.run && game.run.pack === 1 && game.run.seed !== 42, 30000); assert(fresh, 'SETTLE 单击=接新案(pack 0→1 强制翻转、seed 重掷、不再教学)'); assert(plays(audio, mark).has('voice/sys_new_case'), '新案播 sys_new_case'); assert(game._case.contradictions[0].a === 'b_shun_1', '新案即 pack B(真凶=小顺)——二周目必遇变体'); game.stop(); } // ================================ 场景 9:评级账目矩阵 ================================ async function scenario9() { origConsoleLog('\n=== 场景 9:评级六变量条件式注入矩阵 + 结算链选句与运行值一致 ==='); const { game } = makeRig({}); const cases = [ // [doubts, weaponOk, motiveOk, wrongWho, cfMiss, hints, expect] [4, true, true, 0, 0, 0, 'S'], // 教科书结案 [4, true, true, 0, 2, 0, 'S'], // cfMiss 恰在阈值内(≤2) [4, true, true, 0, 3, 0, 'A'], // cfMiss 超阈值降 A [4, true, true, 1, 0, 0, 'A'], // 错指一次降 A [3, true, true, 1, 9, 1, 'A'], // A 档边界(hints=1 wrongWho=1) [4, true, true, 0, 0, 1, 'A'], // 一次保底提示即失 S [4, true, false, 1, 0, 0, 'B'], // 动机错失 A [3, true, true, 2, 0, 0, 'B'], // wrongWho=2 失 A [2, false, false, 2, 9, 9, 'B'], // B 档下边界(疑点 2 + 错指 2) [1, true, true, 0, 0, 0, 'C'], // 疑点 <2 → C [4, false, false, 3, 0, 0, 'C'], // 错指 ≥3 → C(全靠保底也能结案) [0, false, false, 5, 5, 5, 'C'], // C 兜底(案子必然已结) ]; for (const [doubts, weaponOk, motiveOk, wrongWho, cfMiss, hints, expect] of cases) { const o = { doubts, weaponOk, motiveOk, wrongWho, cfMiss, hints }; const got = game._gradeOf(o); assert(got === expect, `评级注入 {d=${doubts},wp=${+weaponOk},mv=${+motiveOk},w=${wrongWho},miss=${cfMiss},h=${hints}} → ${expect}(实际 ${got})`); const vs = game._settlePartVoices(o); const rateId = 'voice/rate_' + expect.toLowerCase(); const missBand = cfMiss === 0 ? 0 : cfMiss <= 2 ? 1 : 2; assert(vs.includes(rateId) && vs.includes('voice/st_doubt_' + doubts) && vs.includes('voice/st_miss_' + missBand), `结算段选句与账目一致(${rateId} + st_doubt_${doubts} + st_miss_${missBand})`); assert(vs[0] === 'voice/settle_open' && vs[4] === 'voice/settle_hook', '结算段骨架 settle_open → … → settle_hook'); } ok(`评级矩阵 ${cases.length} 组全对(六变量边界:cfMiss 2/3、wrongWho 0/1/2/3、hints 0/1、doubts 0..4)`); } // ================================ 场景 10:三档提示 / 迷茫接管 / 无进展轨 ================================ async function scenario10() { origConsoleLog('\n=== 场景 10:三档提示(18s/40s)+ 迷茫接管 L1/L2 + 无进展轨 45/90/150 ==='); // ---- A. 板上空闲静默:18s full → 40s short(同一信息不原样重复) ---- storageMap.clear(); { const rig = makeRig({ seed: 42 }); const { game, audio } = rig; await startSeasoned(rig); const t0 = vnow; // 无进展基线在开案链前(含链时长,窗口放宽 ±2s) await waitState(() => plays(audio).count('voice/hint_board_full') >= 1, 25000); const tf = vnow - t0; assert(tf >= 16000 && tf < 20000, `板上空闲 ~18s 给 full 档(实际 +${tf}ms)`); await waitState(() => plays(audio).count('voice/hint_board_short') >= 1, 45000); assert(vnow - t0 >= 38000 && vnow - t0 < 42000, `持续静默 ~40s 降为 short 档(实际 +${vnow - t0}ms)`); assert(plays(audio).count('voice/hint_board_full') === 1, '同一信息不原样重复(full 只播一次)'); game.stop(); } // ---- B. 迷茫接管:TITLE 左滑连无效 ×3 → L1 短句;冷却后再 ×3 → L2 只留期待手势 ---- storageMap.clear(); { const rig = makeRig({}); const { game, audio } = rig; game.start(); await atTurn(rig); for (let i = 0; i < 3; i++) { await g(rig, 'left'); await tick(100); } await waitState(() => plays(audio).count('voice/sys_first_tap_short') >= 1, 20000); assert(game.confusion.level >= 1, '10s 内 3 次无效 → 迷茫接管 L1(§4.5 连续无效权重 2)'); await atTurn(rig); const tL1 = vnow; await tick(16000); // 冷却期(15s 内不再触发) for (let i = 0; i < 3; i++) { await g(rig, 'left'); await tick(100); } await waitState(() => game.confusion.level >= 2, 30000); const wp = game.floor.waitPoint; assert(game.confusion.level >= 2, '冷却后再乱点 → 接管升到 L2'); assert(wp && wp.restricted && !wp.allows('left') && wp.allows('tap'), '接管 L2:等待点只放行期待手势(tap/right,缩小选择)'); const inv0 = rig.receiptLog.filter((r) => r.kind === 'invalid').length; await g(rig, 'left'); assert(rig.receiptLog.filter((r) => r.kind === 'invalid').length === inv0 + 1, 'L2 限制期左滑=invalid 回执(被等待点拦下)'); game.stop(); } // ---- C. 无进展轨:156s 纯滑动零进展 → 45s earcon / 90s 短句 / 150s 保底矛盾对 ---- storageMap.clear(); { const rig = makeRig({ seed: 42 }); const { game, audio } = rig; await startSeasoned(rig); const t0 = vnow; const m0 = audio.log.length; while (vnow - t0 < 156000) { await g(rig, 'right'); // 换条目≠进展(滑动只换位置不产生 heard/判定/结案) await tick(3600); // 报名链落声 + 留 L2/L3 空闲落点 } assert(game.run.heard.length === 0, `滑动 156s 零进展(heard=0,无进展轨不被乱滑重置)`); const seg = plays(audio, m0); const tick45 = seg.entryOf('sfx/turn_tick'); assert(!!tick45 && tick45.t - t0 >= 43000 && tick45.t - t0 < 60000, `无进展 45s 级 earcon(实测 +${tick45 ? Math.round((tick45.t - t0) / 1000) : '未触发'}s)`); const hint90 = seg.entries().find((e) => e.id === 'voice/hint_a' || e.id === 'voice/hint_b'); assert(!!hint90 && hint90.t - t0 >= 88000 && hint90.t - t0 < 105000, `无进展 90s 级短句(实测 +${hint90 ? Math.round((hint90.t - t0) / 1000) : '未触发'}s)`); assert(hint90?.id === 'voice/hint_a', '90s 级选句真实:有未听条目播 hint_a'); const l3 = seg.entries().find((e) => /^voice\/(hint_c1|hint_c2)$/.test(e.id)); assert(!!l3 && l3.t - t0 >= 148000 && l3.t - t0 < 165000, `无进展 150s 级保底矛盾对(${l3 ? l3.id : '未触发'},实测 +${l3 ? Math.round((l3.t - t0) / 1000) : '未触发'}s)`); assert(game.run.hintsGiven.length >= 2, `保底提示入账 hintsGiven(${game.run.hintsGiven.join(',') || '无'})`); // 保底 hint 给出的矛盾对可走通指证(失败即不通过项) if (l3) { const hintText = l3.id === 'voice/hint_c1' ? 'm1' : 'm4'; const contra = game._case.contradictions.find((c) => c.id === hintText); const idxC = game._case.contradictions.indexOf(contra); await atTurn(rig); assert(await confront(rig, game._case, contra.a, contra.b), '保底 hint 对走通指证'); assert(game.run.doubts[idxC] === true, `保底 hint 对命中(${hintText} 置疑点)`); } game.stop(); } // ---- D. 有真实进展零提示 ---- storageMap.clear(); { const rig = makeRig({ seed: 45 }); const { game, audio } = rig; await startSeasoned(rig); const mC = audio.log.length; for (const id of LISTEN_ITEMS) assert(await listenItem(rig, id), `真实进展:听取 ${id}`); assert(!plays(audio, mC).has('sfx/turn_tick') && !plays(audio, mC).hasRe(/^voice\/hint_[ab]/), '持续听完新条目(真实进展)零提示零 earcon(不误伤正常玩家)'); game.stop(); } // ---- E. 4 疑点齐后 150s 无进展 → hint_fin(去结案,计入保底账) ---- storageMap.clear(); { const rig = makeRig({ seed: 46 }); const { game, audio } = rig; await startSeasoned(rig); game.run.doubts = [true, true, true, true]; // 测试钩子:疑点已齐(真实路径由场景 5 覆盖) const mD = audio.log.length; await tick(156000); assert(plays(audio, mD).has('voice/hint_fin'), '4 疑点齐 + 150s 无进展 → hint_fin(去结案)'); assert(game.run.hintsGiven.includes('hint_fin'), '看门狗 hint_fin 计入保底提示账(与 4/4 庆祝播报区分)'); game.stop(); } } // ================================ 场景 11:断点复述 + TITLE 右滑换新案 ================================ async function scenario11() { origConsoleLog('\n=== 场景 11:存档重进 → 复述链(未听 ≤3 名直报)+ 上滑同链重放 ==='); storageMap.clear(); const a1 = makeRig({ seed: 55 }); await startSeasoned(a1); for (const id of ['w1', 'scene', 'fore']) assert(await listenItem(a1, id), `听取 ${id}`); a1.game._save(); a1.game.stop(); const a2 = makeRig({}); a2.game.ladder.onGoal(); // 等效「已听完第一份证词」(上滑解锁,阶段 1) a2.game.start(); await tick(400); assert(a2.game.state === 'TITLE' && plays(a2.audio).has('voice/sys_continue_ask'), '重进 TITLE 播 sys_continue_ask(回来问继续/重开)'); await atTurn(a2); let mark = a2.audio.log.length; await g(a2, 'tap'); assert(await waitState(() => a2.game.state === 'BOARD', 30000), '单击 = 从存档继续'); assert(a2.game.run.seed === 55 && a2.game.run.pack === 0, '读档还原 (seed,pack)'); assert(JSON.stringify(a2.game._case.layout) === JSON.stringify(a1.game._case.layout), '读档 layout 复用存档值(不重洗)'); await tick(100000); // 复述链整链走完 const pAfter = plays(a2.audio, mark); assert(pAfter.orderOk('voice/st_asked_3', 'voice/item_w2', 'voice/item_w3', 'voice/item_w4', 'voice/st_doubt_0', 'voice/recap_go'), '复述链序:st_asked_3 → 未听 3 名直报(item_w2/w3/w4)→ st_doubt_0 → recap_go'); assert(a2.game.run.heard.length === 3, 'heard 还原 3 份'); await atTurn(a2); mark = a2.audio.log.length; await g(a2, 'up'); await waitState(() => plays(a2.audio, mark).has('voice/st_doubt_0'), 50000); assert(plays(a2.audio, mark).orderOk('voice/st_asked_3', 'voice/item_w2', 'voice/st_doubt_0'), '上滑同链可重放(断点复述链=状态链同构)'); a2.game.stop(); // TITLE 右滑 = 换新案(seed/pack 重掷,meta 保留) const a3 = makeRig({}); a3.game.start(); await tick(400); assert(plays(a3.audio).has('voice/sys_continue_ask'), '实例 3 仍在 TITLE 询问继续'); const oldSeed = JSON.parse(storageMap.get('soundgame.progress.detective')).run.seed; mark = a3.audio.log.length; await atTurn(a3); await g(a3, 'right'); const fresh = await waitState(() => a3.game.state === 'BOARD' && a3.game.run && a3.game.run.seed !== oldSeed, 30000); assert(fresh, 'TITLE 右滑 → 新案(seed 重掷)'); assert(plays(a3.audio, mark).has('voice/sys_new_case'), '新案播 sys_new_case'); assert(!a3.game.run.heard.includes('w1'), '新案 heard 清空(meta 保留 drillDone 不再教学)'); a3.game.stop(); } // ================================ 场景 12:二周目变体 pack B ================================ async function scenario12() { origConsoleLog('\n=== 场景 12:pack B 变体——真凶小顺 / acc_zhou / b_shun 链 / b_hint ==='); storageMap.clear(); const rig = makeRig({ seed: 100, pack: 1 }); const { game, audio } = rig; await startSeasoned(rig); const cs = game._case; assert(cs.pack === 1 && cs.culprit === 'w2' && cs.culpritName === '小顺', 'pack B 真凶=小顺(culprit=w2)'); assert(cs.contradictions.map((c) => c.id).join(',') === 'm1b,m2b,m3b,m4b', '矛盾表 = m1b..m4b'); assert(cs.contradictions.every((c) => ['b_shun_1', 'b_shun_2', 'b_shun_3'].includes(c.a)), '矛盾真凶侧 = b_shun_*'); assert(cs.contradictions[1].a === 'b_shun_2' && cs.contradictions[1].b === 'b_zhou_2', 'M2b = b_shun_2 vs b_zhou_2'); assert(cs.verdict.accuseLine === 'fin_right_b' && cs.verdict.break[0] === 'b_shun_break_1', '认罪链 = fin_right_b + b_shun_break_*'); assert(cs.wrongAccuse.w3 === 'acc_zhou', '错指周先生走 acc_zhou'); assert(matrixInvariant(cs).ok, 'pack B 矩阵不变式复验'); // M1b 指证(b_shun_1 vs wen_2)→ b_shun_react_1 let mark = audio.log.length; assert(await confront(rig, cs, 'b_shun_1', 'wen_2'), 'M1b 指证完成'); assert(plays(audio, mark).has('voice/cf_hit') && plays(audio, mark).has('voice/b_shun_react_1'), 'M1b hit → b_shun_react_1'); // 错指周先生(optIdx 2)→ acc_zhou + fin_clear + st_wrong await enterFinish(rig); mark = audio.log.length; await g(rig, 'right'); await g(rig, 'right'); await tick(300); await atTurn(rig); await g(rig, 'tap'); await waitState(() => game._fin.phase === 'whoConfirm', 10000); await atTurn(rig); await g(rig, 'tap'); assert(await waitState(() => game.state === 'BOARD', 60000), '错指周回 BOARD'); assert(plays(audio, mark).orderOk('voice/acc_zhou', 'voice/fin_clear', 'voice/st_wrong'), '错指周先生 → acc_zhou(不在场证明)+ fin_clear + st_wrong 记账'); // 保底提示 = b_hint_c1/c2(m1b 已发现 → 轮询到 m4b) game.run.doubts = [true, false, false, false]; // 钩子:仅 m1b 已发现(真实路径同上) const mHint = audio.log.length; await tick(156000); const bh = plays(audio, mHint).entries().find((e) => /^voice\/b_hint_c/.test(e.id)); assert(bh, `pack B 保底提示走 b_hint_c*(${bh ? bh.id : '无'})`); // 正指小顺 → 凶器对 → 动机对 → VERDICT(b_shun_break 链) await enterFinish(rig); await g(rig, 'right'); // optIdx 1 = w2 小顺 await tick(300); await atTurn(rig); await g(rig, 'tap'); await waitState(() => game._fin.phase === 'whoConfirm', 10000); await atTurn(rig); await g(rig, 'tap'); assert(await waitState(() => game._fin && game._fin.phase === 'weapon', 45000), '指认小顺 → 凶器问'); await atTurn(rig); const wpIdx = cs.layout.wpOpts.indexOf('wp_2'); for (let i = 0; i < wpIdx; i++) { await g(rig, 'right'); await tick(200); } await atTurn(rig); await g(rig, 'tap'); assert(await waitState(() => game._fin && game._fin.phase === 'motive', 45000), '凶器对 → 动机问'); await atTurn(rig); const mvIdx = cs.layout.mvOpts.indexOf('mv_1'); for (let i = 0; i < mvIdx; i++) { await g(rig, 'right'); await tick(200); } await atTurn(rig); mark = audio.log.length; await g(rig, 'tap'); assert(await waitState(() => game.state === 'VERDICT', 45000), '三问齐 → VERDICT'); assert(await waitState(() => game.state === 'SETTLE' && game.floor.state === 'YOUR_TURN', 220000), '结算链完整(链锁)'); const p = plays(audio, mark); assert(p.orderOk('voice/fin_right_b', 'voice/b_shun_break_1', 'voice/b_shun_break_2', 'voice/b_shun_break_3', 'sfx/gavel'), 'pack B 认罪链:fin_right_b → b_shun_break_1..3 → gavel'); assert(plays(audio, mark).has('voice/settle_hook'), 'settle_hook 落声'); await g(rig, 'tap'); assert(await waitState(() => game.state === 'BOARD' && game.run && game.run.pack === 0, 30000), '接新案 pack 1→0 翻转'); game.stop(); } // ================================ 场景 13:键盘变体路由(V 组) ================================ async function scenario13() { origConsoleLog('\n=== 场景 13:settings.input 联动(游戏代码用普通 id,_k 变体引擎路由) ==='); const { inputMode } = await import('../../engine/gesture.js'); assert(inputMode() === 'touch', '手机 persona 默认 touch'); const { WebAudioEngine } = await import('../../engine/audio.js'); const eng = new WebAudioEngine('audio/'); { settings.set({ input: 'key' }); assert(inputMode() === 'key', "settings.input='key' → inputMode()=key"); const cand = eng._candidates('voice/sys_help'); assert(cand[0].includes('/voice/female/key/sys_help.'), `key 模式候选链前置 key/ 变体(${cand[0]})`); assert(cand.some((x) => x.includes('/voice/female/sys_help.')), 'key 变体 404 后自然落回主资产'); } { settings.set({ input: 'touch' }); assert(inputMode() === 'touch', "回 'touch' → 基础句"); const cand = eng._candidates('voice/settle_hook'); assert(cand[0].includes('/voice/female/settle_hook.'), `touch 模式直接主资产(${cand[0]})`); assert(!cand.some((x) => x.includes('/key/')), 'touch 模式无 key/ 候选'); } // 游戏代码零 _k 硬编码(平台约定:提示句 _k 变体引擎自动路由,游戏用普通 id) const src = fs.readFileSync(path.join(DETECTIVE_DIR, 'game.js'), 'utf8'); assert(!/'[a-z0-9_]+_k'/.test(src), 'game.js 无 _k 后缀 id 硬编码(V 组由引擎路由)'); } // ================================ 场景 14:菜单(左右滑+单击)/ 二次确认 / 保存 ================================ async function scenario14() { origConsoleLog('\n=== 场景 14:模态菜单左右滑切换单击确认 / 重启二次确认 / 保存 PUT summary ==='); storageMap.clear(); storageMap.set('soundgame.auth', JSON.stringify({ token: 'test-token' })); const rig = makeRig({ seed: 42 }); const { game, audio } = rig; await startSeasoned(rig); assert(await listenItem(rig, 'w1'), '听取 w1(summary 素材)'); let mark = audio.log.length; await g(rig, 'down'); assert(await waitState(() => game.menuOpen && plays(audio, mark).has('voice/sys_menu_resume'), 15000), '下滑呼出模态菜单(阶段 2 解锁后)'); { const p0 = plays(audio, mark); assert(p0.has('voice/sys_menu') && p0.has('voice/sys_menu_resume'), '菜单开场:sys_menu(新词:左右滑切换,单击确认)+ 首项 resume'); } await atTurn(rig); // 上滑=重听菜单说明 mark = audio.log.length; await g(rig, 'up'); await waitState(() => plays(audio, mark).has('voice/settings_hint'), 30000); assert(plays(audio, mark).orderOk('voice/sys_help', 'voice/settings_hint'), '菜单上滑=帮助链(sys_help 新词 + settings_hint)'); await atTurn(rig); // 左右滑切项并报名:resume → save → restart(30s 台账:重访只给提示音) mark = audio.log.length; await g(rig, 'right'); // → save assert(game._menuIdx === 1 && plays(audio, mark).has('voice/sys_menu_save'), '右滑切到「保存并回大厅」并报名'); await g(rig, 'right'); // → restart assert(game._menuIdx === 2 && plays(audio, mark).has('voice/sys_menu_restart'), '右滑切到「重新查这桩案子」并报名'); // 破坏性二次确认:首击只重报项名(gate),再击才执行 await atTurn(rig); await g(rig, 'tap'); assert(game._menuGate === true && plays(audio, mark).count('voice/sys_menu_restart') === 2, '重启项首击=二次确认闸(重报项名),不立即执行'); mark = audio.log.length; await atTurn(rig); await g(rig, 'tap'); // 闸内再击 → 重掷 seed 开新案 assert(await waitState(() => !game.menuOpen && game.state === 'BOARD' && game.run && game.run.seed !== 42, 30000), '闸内再击 → 同 pack 重掷 seed 重开(sys_new_case)'); assert(plays(audio, mark).has('voice/sys_new_case'), '重开播 sys_new_case'); // 重启后重听一份(summary 素材),再验证「返回证词板」确认关闭 assert(await listenItem(rig, 'w1'), '重开案后听取 w1(summary 素材)'); await g(rig, 'down'); await waitState(() => game.menuOpen && plays(audio).count('voice/sys_menu') >= 2, 15000); await atTurn(rig); mark = audio.log.length; await g(rig, 'tap'); // 当前项 resume(idx 0)→ 确认=返回证词板 assert(await waitState(() => !game.menuOpen && game.state === 'BOARD', 15000), '单击确认当前项=返回证词板(关菜单)'); // 保存并回大厅:PUT data.summary 命中生成规则 // (关菜单有 450ms 签名 lead 的 BUSY 演出窗——真人会听完条目名再动手,测试同律先等话轮) const fetch0 = fetchLog.length; await atTurn(rig); await g(rig, 'down'); await waitState(() => game.menuOpen, 15000); await g(rig, 'right'); // → save await atTurn(rig); mark = audio.log.length; await g(rig, 'tap'); // 确认保存 assert(await waitState(() => plays(audio, mark).has('voice/sys_saved'), 25000), '确认保存播 sys_saved'); const put = fetchLog.slice(fetch0).find((f) => f.url === '/api/progress' && f.opts && f.opts.method === 'PUT'); assert(put, '保存触发 PUT /api/progress'); if (put) { const body = JSON.parse(put.opts.body); assert(body.game === 'detective' && body.data.schema === 1, 'PUT body game=detective schema=1'); assert(body.data.summary === '雨夜·聚宝斋 · 已问1/6 · 疑点0/4', `summary 命中生成规则("${body.data.summary}")`); } assert(await waitState(() => window.location.href === '../', 15000), '保存并回大厅跳转'); game.stop(); } // ================================ 场景 15:乱点机器人(§6.2) ================================ async function scenario15() { origConsoleLog('\n=== 场景 15:乱点机器人(急躁新手模拟,§6.2 指标)==='); storageMap.clear(); const rig = makeRig({ seed: 2026 }); const { game, audio, receiptLog } = rig; // 确定性伪随机(可复现) let seed = 42; const rnd = () => { seed = (seed * 1103515245 + 12345) & 0x7fffffff; return seed / 0x7fffffff; }; const gestures = []; // { g, t } const origPipe = rig.pipe; const botPipe = async (gg) => { gestures.push({ g: gg, t: vnow }); origPipe(gg); await flushMicro(); }; game.start(); const t0 = vnow; await waitState(() => game.floor.state === 'YOUR_TURN', 20000); // 开场指令播完 await botPipe('tap'); // 确定性首拍:指令播完立刻点(第一次操作 ≤10s) let firstSuccessMs = null; for (let step = 0; step < 140; step++) { if (game.state === 'VERDICT') { await tick(3000); continue; } // 链锁期只等 if (game.menuOpen) { await botPipe('down'); await tick(1500); continue; } // 菜单里只会下滑关 const roll = rnd(); if (roll < 0.4) { // 语音开始后 0.5~2s 单击(跳句/继续/停止) await tick(500 + rnd() * 1500); await botPipe('tap'); } else if (roll < 0.6) { // 连点 2~3 下(间隔 80~250ms) await botPipe('tap'); await tick(80 + rnd() * 170); await botPipe('tap'); if (rnd() < 0.5) { await tick(80 + rnd() * 170); await botPipe('tap'); } } else if (roll < 0.8) { // 随机方向滑 const dirs = ['up', 'down', 'left', 'right']; await botPipe(dirs[Math.floor(rnd() * 4)]); await tick(300); } else { // 发呆 10~30s(虚拟) await tick(10000 + rnd() * 20000); } if (firstSuccessMs === null && receiptLog.some((r) => r.kind === 'ok')) firstSuccessMs = vnow - t0; if (game.meta.casesClosed >= 1 && game.state === 'SETTLE') break; // 结过一案即止 } // 指标 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(手势后 150ms 内旧句仍在播且无回执=critical 豁免外) // 虚拟时钟下手势同步处理:检查每个手势后旧 voice token 是否被顶掉/停掉。 let interruptFails = 0; for (const ge of gestures) { // 该手势前最后一条 voice 播放即「旧句」;若它 150ms 后仍未被任何新调度顶替且无回执 → 失败。 const before = audio.log.filter((p) => p.id.startsWith('voice/') && p.t <= ge.t).pop(); if (!before) continue; const replaced = audio.log.some((p) => p.t > ge.t && p.t <= ge.t + 150); const receipt = receiptLog.some((r) => r.t >= ge.t && r.t <= ge.t + 150); const oldStillOnly = !replaced && !receipt; if (oldStillOnly && audio.isVoicePlaying()) interruptFails++; } assert(interruptFails === 0, `打断失败 = 0(实际 ${interruptFails};critical 链有 busy 回执豁免)`); // 指标 4:被动原句重复 = 0(30s 内同一 id 原样重播,且非玩家直接触发/主动重听) // 证词/证物句(NPC 平铺)是玩家主动听取的内容,不属「游戏主动重复」; // 「再说一遍:」前缀豁免。玩家触发 = 该句落声于某次手势的响应链内:响应链 // 含 onDone 续播(入口句 → 首选项报名)与多句反馈链(错指 → 不在场证明 → // 记档),最长约 11s(裁决+结算链);窗口取 <12s——恰低于最早等待点静默阈值 // (title/caseGate 12s,且等待点在手势 + 链长之后才起表),看门狗句仍全计被动。 const NPC_RE = /^(voice\/(w\d_|z_|wen_|sc_|fo_|b_|acc_|z_react|z_break|intro_w3))/; const RESP_CHAIN_MS = 12000; let passiveRepeats = 0; const lastPlay = new Map(); for (const p of audio.log) { if (!p.id.startsWith('voice/') || NPC_RE.test(p.id)) { lastPlay.set(p.id, p.t); continue; } const sayAgainAt = audio.log.some((q) => q.id === 'voice/sys_say_again' && q.t <= p.t && p.t - q.t < 2500); const playerDriven = gestures.some((ge) => ge.t <= p.t && p.t - ge.t < RESP_CHAIN_MS); if (lastPlay.has(p.id) && p.t - lastPlay.get(p.id) < 30000 && !sayAgainAt && !playerDriven) passiveRepeats++; lastPlay.set(p.id, p.t); } assert(passiveRepeats === 0, `被动原句重复 = 0(实际 ${passiveRepeats})`); // 零死锁:机器人跑完仍处于可操作态(YOUR_TURN/SPEAKING/ACTION 之一) assert(['YOUR_TURN', 'SPEAKING', 'ACTION'].includes(game.floor.state), `零死锁:机器人结束时发言权状态健康(${game.floor.state})`); game.stop(); } // ================================ 资产引用报告(信息性,不判失败) ================================ function assetReport() { origConsoleLog('\n=== 资产引用报告(并行生产期:缺失不判失败,齐套后应为 0) ==='); const missing = new Set(); const allIds = new Set(); for (const inst of audioRegistry) { for (const e of inst.log) { allIds.add(e.id); if (e.missing) missing.add(e.id); } } const femDir = path.join(DETECTIVE_DIR, 'audio', 'voice', 'female'); const maleDir = path.join(DETECTIVE_DIR, 'audio', 'voice', 'male'); if (fs.existsSync(femDir) && fs.existsSync(maleDir)) { const idsOf = (d) => new Set(fs.readdirSync(d).filter((f) => f.endsWith('.ogg')).map((f) => f.replace(/\.ogg$/, ''))); const fem = idsOf(femDir); const male = idsOf(maleDir); const same = fem.size === male.size && [...fem].every((id) => male.has(id)); ok(`voice 双性别目录集合${same ? '一致' : '不一致'}(female ${fem.size} / male ${male.size})`); } else { ok('voice 双性别目录未落盘(资产 agent 并行生产中)'); } ok(`运行期引用 id 共 ${allIds.size} 个,其中缺失 ${missing.size} 个${missing.size ? '(新台词资产由语音管线统一生成;mock 按可用推进,流程未卡死)' : ''}`); } // ================================ 主流程 ================================ try { await scenario1(); await scenario2(); await scenario3(); await scenario4(); await scenario5(); await scenario6(); await scenario7(); await scenario9(); await scenario10(); await scenario11(); await scenario12(); await scenario13(); await scenario14(); await scenario15(); } catch (e) { failures.push(`未捕获异常: ${e.stack}`); } assetReport(); origConsoleLog('\n========================================'); origConsoleLog(`断言通过 ${passCount} 条,失败 ${failures.length} 条`); if (failures.length) { origConsoleLog('失败清单:'); for (const f of failures) origConsoleLog(` - ${f}`); } process.exit(failures.length ? 1 : 0);