/** * 《雨夜专案》无头试玩测试(docs/design/18-detective.md §11 断言清单 14 条关键路径)。 * * 以玩家身份模拟:mock 浏览器环境(window/navigator/localStorage/fetch/虚拟时钟) * 与音频引擎接口(播放日志 + voice onComplete 按虚拟时钟推进语音链),加载真实 * game.js,手势直接注入 game.onGesture。mock 层机制对齐 web/dungeon/test/playtest.mjs * (navigator.maxTouchPoints=1 手机 persona;guide:'off' 回归纯玩法逻辑)。 * * 资产契约:引擎对缺失资产只 console.error 不崩——本测试在资产未落盘时也必须 * 全绿(每句 15s 兜底续链推进,R2 F1 起 8s→15s:兜底须高于全部真实资产), * 故 plays() 查询不过滤 missing 项;资产齐套后同样全绿(顺序/次数断言与时长无关)。 * 资产审计只做信息性报告不判失败。 * * 运行:tools/node/bin/node web/detective/test/playtest.mjs * 退出码 0 = 全部断言通过。seed/pack 由 opts 注入复现(§6.2)。 */ 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 < 200000; 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); console.error = () => {}; // 资产缺失走信息性报告,不刷屏 const origConsoleLog = console.log.bind(console); // ================================ 资产存在性 ================================ function hasPair(base) { return fs.existsSync(base + '.ogg') && fs.existsSync(base + '.m4a'); } /** voice:旁白双性别齐 或 NPC 平铺(引擎平铺回退命中);sfx/bgm:双格式。 */ function assetExists(id) { const audioDir = path.join(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 永不触发 → 8s 兜底续链) } isVoicePlaying() { return this._voice !== null; } setRateBoostEnabled() {} _dur() { return 1.0; } // 假 buffer 时长(秒):资产存在时语音链按虚拟时钟推进 play(id, opts = {}) { const layer = id.split('/')[0]; const missing = !assetExists(id); this.log.push({ t: vnow, layer, id, opts, missing }); if (missing) return; // 引擎静默跳过语义(缺失时 onComplete 不来,游戏 8s 兜底接管) if (layer === 'voice') { this._voice = null; // 顶掉当前句(不触发其 onComplete,对齐引擎语义) const token = { id, onComplete: opts.onComplete }; this._voice = token; if (!this.blocked.has(id)) { 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:即发即弃,无后续 } 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 ERROR_PATTERN = JSON.stringify([60, 40, 60]); function vibratedError(fromIdx = 0) { return vibrateLog.slice(fromIdx).some((v) => JSON.stringify(v.p) === ERROR_PATTERN); } const SUCCESS_PATTERN = JSON.stringify([100]); function vibratedSuccess(fromIdx = 0) { return vibrateLog.slice(fromIdx).some((v) => JSON.stringify(v.p) === SUCCESS_PATTERN); } // ================================ 游戏装配 ================================ const { DetectiveGame } = await import('../game.js'); const { settings } = await import('../../engine/settings.js'); settings.set({ guide: 'off', voice: 'female', speed: 1.0, bgm: 1.0, input: 'touch' }); const LISTEN_ITEMS = ['w1', 'w2', 'w3', 'w4', 'scene', 'fore']; const ITEM_ORDER = [...LISTEN_ITEMS, 'case']; const audioRegistry = []; function newGame(opts = {}) { const audio = new MockAudioEngine(); audioRegistry.push(audio); const game = new DetectiveGame(audio, {}, opts); return { game, audio }; } const g = (game, gesture) => game.onGesture(gesture); /** 有界等待:条件满足或超时即返回。 */ async function waitState(pred, maxMs) { const t0 = vnow; while (!pred() && vnow - t0 < maxMs) await tick(200); return pred(); } /** 导航到证词板条目(最短环绕方向;播报 650ms 落声)。 */ async function navToItem(game, itemId) { const target = ITEM_ORDER.indexOf(itemId); let guard = 0; while (game.itemIdx !== target && guard++ < 10) { const d = (target - game.itemIdx + ITEM_ORDER.length) % ITEM_ORDER.length; g(game, d * 2 <= ITEM_ORDER.length ? 'right' : 'left'); await tick(150); } await tick(650); } /** 完整听取一个条目(nav + tap + 等链播完;资产缺失时 15s/句兜底 → 6 句 ≈95s 余量)。 */ async function listenItem(game, itemId) { await navToItem(game, itemId); g(game, 'tap'); return waitState(() => game._play === null && game.state === 'BOARD', 120000); } /** CONFRONT 内选一句:滑到条目 → 单击进选句 → 滑到句 → 单击确认 → 单击定下。 * entryIdx 可选:R2 F7 选句位置记忆——进选句相位时断言起始 lineIdx。 */ async function cfSelectLine(game, caseData, lineId, entryIdx) { 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; g(game, d * 2 <= LISTEN_ITEMS.length ? 'right' : 'left'); await tick(120); } await tick(650); g(game, 'tap'); // 确认条目 → 进选句 await tick(300); if (entryIdx != null) { assert(game._cf?.lineIdx === entryIdx, `选句入口=记忆位 ${entryIdx}(实际 ${game._cf?.lineIdx},R2 F7)`); } 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; g(game, d * 2 <= ids.length ? 'right' : 'left'); await tick(120); } g(game, 'tap'); // 进确认(重播该句 + cf_confirm) await tick(32000); // 确认链(句 + cf_confirm)走完——玩家听清「就这句?」再定下(15s 兜底余量) g(game, 'tap'); // 定下这句 } /** 从 BOARD 空闲发起一次完整指证(长按进入 + 两侧选句),等判定链收尾回板。 * 长按带重试:E2 受理条件=无语音在播——播报尾音未排空时补一拍重试(与时长解耦)。 */ async function confront(game, caseData, lineA, lineB, { maxMs = 120000, entryIdxA } = {}) { for (let attempt = 0; attempt < 3 && game.state !== 'CONFRONT'; attempt++) { g(game, 'longpress'); if (game.state !== 'CONFRONT') await tick(1500); } await waitState(() => game.state === 'CONFRONT' && game._cf && game._cf.phase === 'item1', 12000); await tick(16000); // cf_start(缺失时 15s 兜底)→ 条目报名 await cfSelectLine(game, caseData, lineA, entryIdxA); await tick(32000); // cf_second + 第一份自动重播(P1 记忆契约)走完 await cfSelectLine(game, caseData, lineB); return waitState(() => game.state === 'BOARD' && !game._play, maxMs); } /** 从 BOARD 走到 FINISH(滑到结案陈词条目 → 单击进确认闸(fin_pre)→ 闸内再单击进三问,R2 F5)。 */ async function enterFinish(game) { await navToItem(game, 'case'); g(game, 'tap'); await tick(200); g(game, 'tap'); await tick(200); } /** 快速跳过教学直达 BOARD(新案已开)。 */ async function skipDrill(game) { await waitState(() => game.state === 'DRILL', 20000); g(game, 'down'); return waitState(() => game.state === 'BOARD', 20000); } /** 矩阵不变式独立重算(§6.4 playtest 双保险):同轴异值对集合 ≡ 矛盾表。 */ 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:DRILL 教学全链(§5/§11.1) ================================ async function scenario1() { origConsoleLog('\n=== 场景 1:TITLE→DRILL D0..D6 + D5 练习截获零污染 + 跳过路径 ==='); storageMap.clear(); const { game, audio } = newGame({ seed: 42 }); game.start(); await tick(400); assert(game.state === 'TITLE' && !game.run, '启动进 TITLE(无存档)'); assert(plays(audio).has('bgm/menu'), 'TITLE bgm/menu'); let mark = audio.log.length; g(game, 'tap'); await waitState(() => game.state === 'DRILL' && game._drill && game._drill.step === 0, 20000); await tick(80000); // sys_new_case + intro_1..3 + tut_d0 五句整链(缺失时 15s/句兜底) assert(game.run && game.run.seed === 42, 'opts.seed=42 注入新案'); assert(plays(audio, mark).orderOk('voice/sys_new_case', 'voice/intro_1', 'voice/intro_2', 'voice/intro_3', 'voice/tut_d0'), 'D0 序:sys_new_case → intro_1/2/3 → tut_d0(资产缺失时整链仍经 15s 兜底推进——§4.3 契约)'); assert(plays(audio).has('bgm/board'), 'DRILL bgm/board'); // D0:左滑或右滑任一(首输入达成点) mark = audio.log.length; const vib0 = vibrateLog.length; g(game, 'right'); await tick(800); assert(game._drill?.step === 1, 'D0 右滑通过 → D1'); assert(plays(audio, mark).has('sfx/paper_turn') && plays(audio, mark).has('sfx/sig_shun') && plays(audio, mark).has('voice/item_w2'), 'D0 条目播报:paper_turn + sig_shun + item_w2(右滑换到下一份,签名前置)'); { const conf = vibrateLog.slice(vib0).some((v) => JSON.stringify(v.p) === JSON.stringify([20])); assert(conf, 'D0 通过附 confirm 震'); } // D1:单击 → 老葛真实证词链播(教学寄生于真实案卷) await tick(32000); // R2 F2:item_w2 报名落声 → tut_listen 串行接播 → 回正报名(互不掐) { const seg = plays(audio, mark); assert(seg.orderOk('voice/item_w2', 'voice/tut_listen'), 'R2 F2:D0 报名(item_w2)先于 D1 指令(tut_listen)串行落声(原并行互掐,live R1 实测指令只播 0.4s)'); } mark = audio.log.length; g(game, 'tap'); assert(game._play !== null, 'D1 单击开始证词链播'); const w1Ids = game._case.layout.lines.w1; const done1 = await waitState(() => game._drill && game._drill.step === 2, 120000); assert(done1, 'D1 证词链播完 → D2'); { 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, `D1 句序 = layout.lines.w1(${w1Ids.join(',')})`); } assert(game.run.heard.includes('w1'), 'D1 听完 w1 入账 heard(st_asked 真实来源)'); // D2:双击重新听最后一句(免费) await tick(16000); // tut_replay mark = audio.log.length; const lastW1 = game.run.lastLine.w1; g(game, 'doubletap'); await tick(16000); assert(plays(audio, mark).has('voice/' + lastW1), `D2 双击重播最后一句(${lastW1})`); assert(game._drill?.step === 3, 'D2 重播完 → D3'); // D3:上滑状态链(st_asked_1 → st_doubt_0 → recap_go) await tick(16000); // tut_status mark = audio.log.length; g(game, 'up'); await waitState(() => game._drill?.step === 4, 70000); assert(plays(audio, mark).orderOk('voice/st_asked_1', 'voice/st_doubt_0'), 'D3 状态链:st_asked_1 → st_doubt_0'); assert(plays(audio, mark).has('voice/recap_go'), 'D3 状态链尾 recap_go(未听齐时仍是「接着查」)'); // D4:长按进入指证(E2 豁免教学) await tick(16000); // tut_confront mark = audio.log.length; g(game, 'longpress'); await tick(200); assert(game.state === 'CONFRONT' && game._cf, 'D4 长按 → CONFRONT'); assert(game._drill?.step === 5, 'D4 通过 → D5'); await waitState(() => plays(audio, mark).has('voice/cf_start'), 12000); await waitState(() => plays(audio, mark).has('voice/tut_cf_try'), 12000); assert(plays(audio, mark).has('voice/tut_cf_try'), 'D5 练习句 tut_cf_try 接在 cf_start 链上'); // D5:任意选择组合(选 w2 首句 vs scene 首句——真判定必 miss 的组合)只播 tut_cf_ok const before = { cfMiss: game.run.cfMiss, doubts: [...game.run.doubts], zHits: game.run.zHits }; await tick(32000); // cf_start + tut_cf_try 链走完 → 条目报名 await cfSelectLine(game, game._case, game._case.layout.lines.w2[0], 0); // R2 F7:首访条目记忆位=0 await tick(32000); // cf_second + 自动重播 mark = audio.log.length; const vib5 = vibrateLog.length; await cfSelectLine(game, game._case, game._case.layout.lines.scene[0], 0); const done5 = await waitState(() => game.state === 'DRILL' && game._drill?.step === 6, 30000); assert(done5, 'D5 判定被教学截获 → 回 DRILL D6'); assert(plays(audio, mark).has('voice/tut_cf_ok'), 'D5 任意组合只播 tut_cf_ok(练习不算数)'); assert(!plays(audio, mark).hasRe(/^voice\/(cf_hit|cf_same|cf_diff|cf_self)$/), 'D5 真判定句(hit/三档 miss)零出现'); assert(!plays(audio, mark).has('sfx/contra_hit') && !plays(audio, mark).has('sfx/contra_miss'), 'D5 判定音零出现(练习不算数)'); assert(vibratedSuccess(vib5), 'D5 完成附 success 震(首个正反馈 ≤3min)'); assert(game.run.cfMiss === before.cfMiss && game.run.zHits === before.zHits && JSON.stringify(game.run.doubts) === JSON.stringify(before.doubts), 'D5 练习零账目污染(cfMiss/doubts/zHits 不变——教学承诺兑现)'); // D6:tut_done → BOARD 自由调查 await waitState(() => game.state === 'BOARD', 30000); assert(plays(audio, mark).has('voice/tut_done'), 'D6 tut_done'); assert(game.meta.drillDone === true, '教学标志入元进度'); assert(game.run.heard.length === 1, '教学期只真实听取了 w1(账目真实)'); // 上滑随时可用(教过后) mark = audio.log.length; g(game, 'up'); await tick(50000); assert(plays(audio, mark).orderOk('voice/st_asked_1', 'voice/st_doubt_0'), 'BOARD 上滑状态链随时可用'); game.stop(); // ---- 跳过路径 + 二周目不再进 ---- storageMap.clear(); const b = newGame({ seed: 7 }); b.game.start(); await tick(400); g(b.game, 'tap'); await waitState(() => b.game.state === 'DRILL', 20000); mark = b.audio.log.length; g(b.game, 'down'); assert(await waitState(() => b.game.state === 'BOARD', 25000), '下滑跳过教学直达 BOARD'); assert(plays(b.audio, mark).has('voice/tut_skip'), '下滑跳过教学播 tut_skip'); assert(b.game.meta.drillDone === true, '跳过也置 drillDone'); b.game._save(); b.game.stop(); const c = newGame({}); c.game.start(); await tick(400); assert(c.game.state === 'TITLE' && plays(c.audio).has('voice/sys_continue_ask'), '有档重进播 sys_continue_ask'); mark = c.audio.log.length; g(c.game, 'tap'); await waitState(() => c.game.state === 'BOARD', 30000); assert(c.game.state === 'BOARD', '二周目单击继续直达 BOARD(不再进 DRILL)'); c.game.stop(); } // ================================ 场景 2:seed 复现(§6.2/§11.2) ================================ async function scenario2() { origConsoleLog('\n=== 场景 2:seed 复现——(42,0) 两次构造 _case 深相等;(42,1)≠(43,1) ==='); storageMap.clear(); const a = newGame({ seed: 42 }); const b = newGame({ 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 + 洗牌差)'); // 洗牌结构断言:filler 置换只动 shuffle 槽位集合(句集合不变) 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 句集合不变(置换非增删)'); ok(`seed 复现三组对比通过(wpOpts=${JSON.stringify(c1.layout.wpOpts)} hintOrder=${JSON.stringify(c1.layout.hintOrder)})`); } // ================================ 场景 3:矩阵不变式(§6.4/§11.3) ================================ async function scenario3() { origConsoleLog('\n=== 场景 3:矩阵不变式——同轴异值对 ≡ 矛盾表(pack A/B 各验一遍) ==='); const { game } = newGame({}); 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_*'); // 三档归因真话来源可达性(§3.4 验算):一致对 / 无共享轴对 / 同句 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:听取细节(§11.4 + 边界) ================================ async function scenario4() { origConsoleLog('\n=== 场景 4:听取——句序/双击重听/单击停止/断句续听/播放中静默忽略 ==='); storageMap.clear(); const { game, audio } = newGame({ seed: 42 }); game.start(); await tick(400); g(game, 'tap'); assert(await skipDrill(game), '跳教学直达 BOARD'); assert(game.run.seed === 42, 'seed 注入'); // w2 完整听取:句序 = layout、播完 st_asked 升档 let mark = audio.log.length; assert(await listenItem(game, '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; g(game, 'up'); await tick(50000); assert(plays(audio, mark).has('voice/st_asked_1'), '听完 1 份 → 上滑 st_asked_1'); // w4:播放中双击=当前句重播、左右滑静默忽略、单击停止、断句续听 await navToItem(game, 'w4'); mark = audio.log.length; const vib0 = vibrateLog.length; g(game, 'tap'); await waitState(() => game._play && game._play.cur >= 1, 30000); // 第二句起播 const curId = game._play ? game._case.layout.lines.w4[game._play.cur] : '(未起播)'; g(game, 'doubletap'); await tick(300); assert(plays(audio, mark).count('voice/' + curId) >= 2, `播放中双击重播当前句(${curId} 播 ≥2 次,免费零消耗)`); const itemIdxBefore = game.itemIdx; g(game, 'left'); g(game, 'right'); await tick(500); assert(game.itemIdx === itemIdxBefore && !vibratedError(vib0), '播放中左右滑静默忽略(无 error 震、条目不变)'); g(game, 'tap'); assert(game._play === null, '播放中单击=停止'); 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; g(game, 'tap'); await waitState(() => game._play && game._play.cur === resumeIdx, 10000); assert(game._play && game._play.cur === resumeIdx, `续听从首个未听句起(cur=${game._play?.cur} = heardLines=${resumeIdx})`); g(game, 'tap'); // 停止收尾 // 播放中上滑=状态链(打断证词),打断后双击可重听当前(最后)句 await navToItem(game, 'scene'); g(game, '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; g(game, 'up'); await tick(3000); assert(game._play === null && plays(audio, mark).hasRe(/^voice\/st_asked_/), '播放中上滑打断证词播状态链(播报值=实值)'); mark = audio.log.length; g(game, 'doubletap'); await tick(9000); assert(plays(audio, mark).has('voice/' + scCur), `打断后双击重听当前句(${scCur})`); // 全部听齐 → st_asked_6;R2 F6:尾句换指路 recap_cf(不再是干巴巴的「接着查」) for (const id of ['w1', 'w3', 'w4', 'fore', 'scene']) assert(await listenItem(game, id), `${id} 听取完成`); assert(game.run.heard.length === 6, '六份卷宗全部入账'); mark = audio.log.length; g(game, 'up'); await tick(50000); assert(plays(audio, mark).has('voice/st_asked_6'), '六份听齐 → st_asked_6'); assert(plays(audio, mark).has('voice/recap_cf') && !plays(audio, mark).has('voice/recap_go'), 'R2 F6:听齐未破时状态链尾=recap_cf(「挑一句你不信的,按住指证」),不再播 recap_go'); game.stop(); } // ================================ 场景 5:指证 4 矛盾逐条走通(§11.5) ================================ async function scenario5() { origConsoleLog('\n=== 场景 5:M1-M4 指证(hit 链/共用 z_4/自动重播/疑点 4/4 hint_fin/E2 边界) ==='); storageMap.clear(); const { game, audio } = newGame({ seed: 42 }); game.start(); await tick(400); g(game, 'tap'); assert(await skipDrill(game), '跳教学进板'); const cs = game._case; // M1(lamp:z_2 vs wen_2) let mark = audio.log.length; let vib = vibrateLog.length; assert(await confront(game, 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(R2 F3:按轴取句 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'), '第二份选择前自动重播第一份选定句(P1 记忆契约)'); } 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)——R2 F7:w3 选句入口应记忆在 z_2(idx 1),不再从 z_1 扫起 mark = audio.log.length; assert(await confront(game, 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(一句两断言,选 z_4 两次分别对 fo_3 与 sc_4) mark = audio.log.length; assert(await confront(game, 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(R2 F3:weapon 轴专属慌「铜器…我怎么会知道」,不再错配秤/灯)'); mark = audio.log.length; assert(await confront(game, 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:hit 链尾追加 reactFinal 收官句 + 自动播一次 hint_fin(R2 F3) 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; g(game, 'up'); await tick(50000); assert(plays(audio, mark).has('voice/st_doubt_4'), '状态链 st_doubt_4(疑点实数)'); assert(plays(audio, mark).has('voice/hint_fin'), 'R2 F6:疑点齐时状态链尾=hint_fin(去结案)'); // 长按豁免边界(§11 边界):播放中长按=引擎快进不进指证;空闲长按=CONFRONT await navToItem(game, 'w1'); g(game, 'tap'); await waitState(() => game._play && game._play.cur >= 0, 30000); g(game, 'longpress'); await tick(600); assert(game.state === 'BOARD' && game._play !== null, 'E2:播放中长按不进指证(引擎快进接管,时间互斥)'); g(game, 'tap'); // 停止 await tick(400); g(game, 'longpress'); await tick(200); assert(game.state === 'CONFRONT', 'E2:空闲长按 → CONFRONT'); // CONFRONT 条目相位长按 = error 震(选择语境不作输入) vib = vibrateLog.length; g(game, 'longpress'); await tick(200); assert(vibratedError(vib) && game.state === 'CONFRONT', 'CONFRONT 条目相位长按 error 震不受理'); // R2 F7:选句相位长按放行引擎快进——语音在播不震;空闲无可快进=error 震 await tick(16000); // cf_start + 报名落声 g(game, 'tap'); // 确认当前条目 → 进选句(播 cf_pick_line + 入口句) await tick(400); // 语音在播中 vib = vibrateLog.length; g(game, 'longpress'); await tick(200); assert(!vibratedError(vib) && game._cf && (game._cf.phase === 'line1' || game._cf.phase === 'line2'), 'R2 F7:选句相位语音在播长按=引擎快进接管(无 error 震、相位不变)'); await tick(2000); // 等落声(空闲) vib = vibrateLog.length; g(game, 'longpress'); await tick(200); assert(vibratedError(vib), 'R2 F7:选句相位空闲长按=error 震(无可快进)'); // 菜单「返回证词板」退出指证 g(game, 'down'); await tick(32000); // sys_menu + 首项 mark = audio.log.length; g(game, 'doubletap'); // 双击确认 sys_menu_resume(=返回证词板) await waitState(() => game.state === 'BOARD', 15000); assert(game.state === 'BOARD', '菜单「返回证词板」退出指证'); assert(!plays(audio).hasRe(/^voice\/gd_/), 'guide=off:全程零 gd_* 向导语音'); game.stop(); } // ================================ 场景 6:指证 miss 三档真话 + 无序(§11.6) ================================ async function scenario6() { origConsoleLog('\n=== 场景 6:miss 三档(cf_same/cf_diff/cf_self)+ 指证对无序 ==='); storageMap.clear(); const { game, audio } = newGame({ seed: 43 }); game.start(); await tick(400); g(game, 'tap'); assert(await skipDrill(game), '跳教学进板'); const cs = game._case; // 指证对无序:反向选 M1(wen_2 在前)也 hit let mark = audio.log.length; assert(await confront(game, 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(game, 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(game, 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(game, 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(game, 'fore'), 'miss 后听取照常(无惩罚性失败)'); game.stop(); } // ================================ 场景 7+8:结案三问 + VERDICT 死窗(§11.7/11.8) ================================ async function scenario7() { origConsoleLog('\n=== 场景 7+8:错指不在场证明 / 三问 / VERDICT 死窗+链锁 / 接新案翻包 ==='); storageMap.clear(); const { game, audio } = newGame({ seed: 42 }); game.start(); await tick(400); g(game, 'tap'); assert(await skipDrill(game), '跳教学进板'); 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(game, cs, a, b), `指证 ${a} vs ${b}`); } assert(game._doubtCount() === 4, '四疑点齐'); // 错指小顺(optIdx 1)→ acc_shun + fin_clear + st_wrong → 回 BOARD(无限再机会) let mark = audio.log.length; // R2 F5:结案确认闸——首击只播 fin_pre 不进 FINISH;滑走撤闸;闸内再击才进 await navToItem(game, 'case'); g(game, 'tap'); await tick(200); assert(game.state === 'BOARD' && game._caseGate === true && plays(audio, mark).has('voice/fin_pre'), 'R2 F5:结案陈词首击=确认闸(fin_pre),仍 BOARD'); g(game, 'left'); // 滑走撤闸 await tick(200); assert(game._caseGate === false && game.state === 'BOARD', 'R2 F5:滑走撤闸留板(继续查)'); await enterFinish(game); assert(plays(audio, mark).orderOk('voice/fin_pre', 'voice/fin_start'), '闸内再击 → FINISH(fin_pre → fin_start)'); await waitState(() => game._fin && game._fin.phase === 'who', 15000); mark = audio.log.length; g(game, 'right'); await tick(650); assert(plays(audio, mark).has('voice/item_who_w2'), 'R2 F4:真凶问右滑播人名(item_who_w2「学徒小顺。」),不再念卷宗名'); g(game, 'tap'); await tick(32000); // 两段确认链(重播人名 + fin_who_confirm)走完 assert(game._fin?.phase === 'whoConfirm' && plays(audio, mark).has('voice/fin_who_confirm'), '真凶问两段确认(重播人名 + fin_who_confirm「就指认这位?」——R2 F4 中性化)'); g(game, '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(R2 F9 当场记账)'); assert(game.run.wrongWho === 1, 'wrongWho 计数'); // 错指老葛(optIdx 0,无需滑动)→ acc_ge(无限再机会) await enterFinish(game); await waitState(() => game._fin && game._fin.phase === 'who', 15000); mark = audio.log.length; g(game, 'tap'); await tick(200); g(game, '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'); // FINISH 长按 = error(§11 边界) await enterFinish(game); await waitState(() => game._fin && game._fin.phase === 'who', 15000); const vib = vibrateLog.length; g(game, 'longpress'); await tick(200); assert(vibratedError(vib), 'FINISH 长按 error 震'); // 正指周先生(optIdx 2)→ 凶器问(选项序=layout)→ 动机问 → VERDICT g(game, 'right'); g(game, 'right'); await tick(650); assert(plays(audio, mark).has('voice/item_who_w3'), '滑到周先生(item_who_w3,R2 F4 人名)'); mark = audio.log.length; g(game, 'tap'); await tick(200); g(game, 'tap'); // 正式指认 assert(await waitState(() => game._fin && game._fin.phase === 'weapon', 45000), '指认对 → 凶器问'); assert(plays(audio, mark).has('voice/fin_ask_weapon'), '凶器问入口 fin_ask_weapon'); await tick(17000); // 问句链 + busy 解除 + 首选项报名落声 // 凶器 4 选项全可预览(左右滑一次听一项,选项序=layout.wpOpts) for (let i = 0; i < 4; i++) { g(game, 'right'); await tick(300); } { 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++) g(game, 'right'); await tick(300); g(game, '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 tick(17000); // 问句链 + busy 解除 // 动机答错 mv_2 → fin_mv_wrong(真话归因,不重答)→ VERDICT const mvIdx = cs.layout.mvOpts.indexOf('mv_2'); for (let i = 0; i < mvIdx; i++) g(game, 'right'); await tick(300); mark = audio.log.length; g(game, 'tap'); assert(await waitState(() => game.state === 'VERDICT', 45000), '动机答完 → VERDICT'); assert(plays(audio, mark).has('voice/fin_mv_wrong'), '动机答错播 fin_mv_wrong(不重答,记账)'); // ---- 场景 8:VERDICT 死窗 + 链锁(§7.4:dungeon H1 同款) ---- g(game, 'tap'); assert(game.state === 'VERDICT', 'H1: 指认对瞬间同拍在途 tap 被死窗吞掉(仍 VERDICT)'); await tick(300); g(game, 'tap'); assert(game.state === 'VERDICT', 'H1: +300ms 在途 tap 同样被吞(引擎双击窗回调延迟)'); g(game, 'up'); g(game, 'down'); g(game, 'doubletap'); g(game, 'right'); g(game, 'longpress'); assert(game.state === 'VERDICT', '链内上滑/下滑/双击/右滑/长按全部吞掉(链锁)'); const settleDone = await waitState(() => game.state === 'SETTLE', 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}")`); } // 链后:单击=接新案(pack 翻转、seed 重掷——settle_hook 言行一致) mark = audio.log.length; g(game, '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 重掷、drillDone 不再教学)'); 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:评级账目矩阵(§7.2/§11.9) ================================ async function scenario9() { origConsoleLog('\n=== 场景 9:评级六变量条件式注入矩阵 + 结算链选句与运行值一致 ==='); const { game } = newGame({}); 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:看门狗双轨(§9/§11.10) ================================ async function scenario10() { origConsoleLog('\n=== 场景 10:无操作轨(guide=full)+ 无进展轨 45/90/150(乱按必被抢救) ==='); // ---- A. 无操作轨:BOARD 10s/25s、CONFRONT 15s、FINISH 20s(guide=full) ---- storageMap.clear(); settings.set({ guide: 'full' }); let a = null; try { a = newGame({ seed: 42 }); a.game.start(); await tick(400); g(a.game, 'tap'); assert(await skipDrill(a.game), '跳教学进板(guide=full)'); let mark = a.audio.log.length; await tick(11000); assert(plays(a.audio, mark).has('voice/gd_board'), '无操作轨:BOARD 10s 播 gd_board'); await tick(15000); assert(plays(a.audio, mark).countRe(/^voice\/gd_board$/) === 1, '25s 第 2 级纯 nudge(不重复播句)'); // CONFRONT 15s g(a.game, 'longpress'); await waitState(() => a.game.state === 'CONFRONT', 5000); mark = a.audio.log.length; await tick(16000); assert(plays(a.audio, mark).has('voice/gd_cf'), '无操作轨:CONFRONT 15s 播 gd_cf'); g(a.game, 'down'); // 菜单 → 「返回证词板」退出指证 await tick(10000); g(a.game, 'doubletap'); await waitState(() => a.game.state === 'BOARD', 15000); // R2 F6:六份听齐且疑点未齐时,BOARD 一级提示换 gd_board_cf a.game.run.heard = [...LISTEN_ITEMS]; // 测试钩子:六份听齐(真实路径由场景 4 覆盖) g(a.game, 'right'); // 手势触发重挂(一级提示词按局面重取) await tick(11000); assert(plays(a.audio, mark).has('voice/gd_board_cf'), 'R2 F6:听齐未破时 BOARD 一级=gd_board_cf(不再教「换卷宗」)'); a.game.run.heard = []; // 还原,免污染后续断言 // FINISH 20s await enterFinish(a.game); await waitState(() => a.game._fin && a.game._fin.phase === 'who', 15000); mark = a.audio.log.length; await tick(21000); assert(plays(a.audio, mark).has('voice/gd_fin'), '无操作轨:FINISH 20s 播 gd_fin'); } finally { if (a) a.game.stop(); settings.set({ guide: 'off' }); } // ---- B. 无进展轨:乱按 156s 零进展 → 45s earcon / 90s 短句 / 150s 保底矛盾对 ---- storageMap.clear(); const { game, audio } = newGame({ seed: 42 }); game.start(); await tick(400); g(game, 'tap'); assert(await skipDrill(game), '跳教学进板'); const t0 = vnow; // 乱按轮转(与语音时长解耦):起停对 = 同拍双 tap——起播与停止零时距,任何语音 // 时长下一句都不可能完成 = 确定性零进展(「听完新条目=进展」是设计内行为,测试 // 不得让它发生);其余为换条目滑动/重听噪声;不含 longpress(不进指证);到 // case 条目强制左滑(不误入 FINISH)。每轮转歇一口气——给等空闲拍的句子级 // 提示(L2/L3)留落点。 const ROT = ['pair', 'right', 'left', 'doubletap', 'left', 'right']; let ri = 0; const m0 = audio.log.length; const heard0 = game.run.heard.length; while (vnow - t0 < 156000) { const gg = ROT[ri % ROT.length]; const atCase = game.itemIdx === ITEM_ORDER.length - 1; if (gg === 'pair' && !atCase) { g(game, 'tap'); // 起播 g(game, 'tap'); // 同拍停止——句完成定时器永不可达(时长无关) } else if (atCase) { g(game, 'left'); } else { g(game, gg); } await tick(ri++ % ROT.length === ROT.length - 1 ? 3600 : 1100); } assert(game.run.heard.length === heard0, `乱按 156s 零进展(heard 不变=${heard0},无操作轨被手势持续重置不轰炸)`); const seg = plays(audio, m0); const tick45 = seg.entryOf('sfx/turn_tick'); assert(!!tick45 && tick45.t - t0 >= 45000 && 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 >= 90000 && 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 >= 150000 && 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); assert(await confront(game, game._case, contra.a, contra.b), '保底 hint 对走通指证'); assert(game.run.doubts[idxC] === true, `保底 hint 对命中(${hintText} 置疑点)`); } game.stop(); // ---- C. 有真实进展零提示 ---- storageMap.clear(); const c2 = newGame({ seed: 45 }); c2.game.start(); await tick(400); g(c2.game, 'tap'); assert(await skipDrill(c2.game), '跳教学进板'); { const mC = c2.audio.log.length; for (const id of LISTEN_ITEMS) assert(await listenItem(c2.game, id), `真实进展:听取 ${id}`); assert(!plays(c2.audio, mC).has('sfx/turn_tick') && !plays(c2.audio, mC).hasRe(/^voice\/hint_/), '持续听完新条目(真实进展)零提示零 earcon(不误伤正常玩家)'); } c2.game.stop(); // ---- D. 4 疑点齐后 150s 无进展 → hint_fin(去结案,计入保底账) ---- storageMap.clear(); const d2 = newGame({ seed: 46 }); d2.game.start(); await tick(400); g(d2.game, 'tap'); assert(await skipDrill(d2.game), '跳教学进板'); d2.game.run.doubts = [true, true, true, true]; // 测试钩子:疑点已齐(真实路径由场景 5 覆盖) const mD = d2.audio.log.length; await tick(156000); assert(plays(d2.audio, mD).has('voice/hint_fin'), '4 疑点齐 + 150s 无进展 → hint_fin(去结案)'); assert(d2.game.run.hintsGiven.includes('hint_fin'), '看门狗 hint_fin 计入保底提示账(与 4/4 庆祝播报区分)'); d2.game.stop(); } // ================================ 场景 11:中断复述 + TITLE 右滑换新案(§10/§11.11) ================================ async function scenario11() { origConsoleLog('\n=== 场景 11:存档重进 → 复述链(未听 ≤3 名直报)+ 上滑同链重放 ==='); storageMap.clear(); const a1 = newGame({ seed: 55 }); a1.game.start(); await tick(400); g(a1.game, 'tap'); assert(await skipDrill(a1.game), '跳教学进板'); for (const id of ['w1', 'scene', 'fore']) assert(await listenItem(a1.game, id), `听取 ${id}`); a1.game._save(); a1.game.stop(); const a2 = newGame({}); a2.game.start(); await tick(400); assert(a2.game.state === 'TITLE' && plays(a2.audio).has('voice/sys_continue_ask'), '重进 TITLE 播 sys_continue_ask'); let mark = a2.audio.log.length; g(a2.game, 'tap'); 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 复用存档值(不重洗)'); const p = plays(a2.audio, mark); await tick(100000); // 复述链 6 句(缺失时 15s/句兜底)整链走完 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 份'); mark = a2.audio.log.length; g(a2.game, 'up'); await tick(100000); 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 = newGame({}); 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; g(a3.game, '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(§11.12) ================================ async function scenario12() { origConsoleLog('\n=== 场景 12:pack B 变体——真凶小顺 / acc_zhou / b_shun 链 / b_hint ==='); storageMap.clear(); const { game, audio } = newGame({ seed: 100, pack: 1 }); game.start(); await tick(400); g(game, 'tap'); assert(await skipDrill(game), '跳教学进板'); 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(game, 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 await enterFinish(game); await waitState(() => game._fin && game._fin.phase === 'who', 15000); mark = audio.log.length; g(game, 'right'); g(game, 'right'); await tick(650); g(game, 'tap'); await tick(200); g(game, '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 记账(R2 F9)'); // 保底提示 = 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(game); await waitState(() => game._fin && game._fin.phase === 'who', 15000); g(game, 'right'); // optIdx 1 = w2 小顺 await tick(650); g(game, 'tap'); await tick(200); g(game, 'tap'); assert(await waitState(() => game._fin && game._fin.phase === 'weapon', 45000), '指认小顺 → 凶器问'); await tick(17000); // 问句链 + busy 解除 const wpIdx = cs.layout.wpOpts.indexOf('wp_2'); for (let i = 0; i < wpIdx; i++) g(game, 'right'); await tick(300); g(game, 'tap'); assert(await waitState(() => game._fin && game._fin.phase === 'motive', 45000), '凶器对 → 动机问'); await tick(17000); // 问句链 + busy 解除 const mvIdx = cs.layout.mvOpts.indexOf('mv_1'); for (let i = 0; i < mvIdx; i++) g(game, 'right'); await tick(300); mark = audio.log.length; g(game, 'tap'); assert(await waitState(() => game.state === 'VERDICT', 45000), '三问齐 → VERDICT'); assert(await waitState(() => game.state === 'SETTLE', 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 落声'); g(game, 'tap'); assert(await waitState(() => game.state === 'BOARD' && game.run && game.run.pack === 0, 30000), '接新案 pack 1→0 翻转'); game.stop(); } // ================================ 场景 13:键盘变体路由(V 组,§11.13) ================================ 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:菜单/帮助/存档(§11.14) ================================ async function scenario14() { origConsoleLog('\n=== 场景 14:模态菜单循环-确认-关闭 / 去指证=长按同效 / 保存 PUT summary ==='); storageMap.clear(); storageMap.set('soundgame.auth', JSON.stringify({ token: 'test-token' })); const { game, audio } = newGame({ seed: 42 }); game.start(); await tick(400); g(game, 'tap'); assert(await skipDrill(game), '跳教学进板'); assert(await listenItem(game, 'w1'), '听取 w1(summary 素材)'); let mark = audio.log.length; g(game, 'down'); await tick(32000); // sys_menu + 首项(15s/句兜底余量) assert(game.menuOpen, '下滑呼出模态菜单'); const p0 = plays(audio, mark); assert(p0.has('voice/sys_menu') && p0.has('voice/sys_menu_resume'), '菜单开场:sys_menu + 首项 resume'); // 帮助(上滑) mark = audio.log.length; g(game, 'up'); await tick(32000); assert(plays(audio, mark).orderOk('voice/sys_help', 'voice/settings_hint'), '菜单上滑=帮助链(sys_help + settings_hint)'); // 循环切项:单击 resume→cf→save→help→restart→resume(5 项循环回绕) mark = audio.log.length; for (let i = 0; i < 5; i++) { g(game, 'tap'); await tick(300); } const pC = plays(audio, mark); assert(pC.has('voice/sys_menu_cf') && pC.has('voice/sys_menu_save') && pC.has('voice/sys_menu_restart') && pC.has('voice/sys_menu_help') && pC.count('voice/sys_menu_resume') === 1, '单击循环切项(5 项回绕,每切播项名)'); // 「去指证」双击确认 = 长按同效(P4 冗余入口)——当前项恰回 resume,切一格到 cf g(game, 'tap'); // → cf await tick(300); mark = audio.log.length; g(game, 'doubletap'); assert(await waitState(() => game.state === 'CONFRONT', 25000), '菜单「去指证」进 CONFRONT'); assert(plays(audio, mark).has('voice/cf_start'), '去指证 = 长按同效(cf_start 同点)'); // 「返回证词板」退出 g(game, 'down'); await tick(32000); mark = audio.log.length; g(game, 'doubletap'); // resume(=返回证词板) assert(await waitState(() => game.state === 'BOARD', 15000), '菜单「返回证词板」关闭回板'); // 保存并回大厅:PUT data.summary 命中生成规则 const fetch0 = fetchLog.length; g(game, 'down'); await tick(32000); // 开菜单(idx=0 resume) g(game, 'tap'); // → cf await tick(300); g(game, 'tap'); // → save await tick(300); mark = audio.log.length; g(game, 'doubletap'); // 确认保存 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(); } // ================================ 资产引用报告(信息性,不判失败) ================================ 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 ? '(每句 15s 兜底全链推进,流程未卡死——§4.3 契约)' : ''}`); } // ================================ 主流程 ================================ 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(); } 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);