Files
SoundGame/web/detective/test/playtest.mjs
T
xsl c8843233b0 research(new8/batch2): abyss + detective 迭代闭环收口——双双 R1-R2 连续达标裁定可交付
abyss《深海回音》:
- R1 补 synthesis(5/5 愿意玩 P0=0)+ 补录 R1 缺失裁决材料
- R2 十修全部现场坐实:F1 ping迟到/尾句保护首播制/链末看门狗不拆/回波静默一拍/dv4_dual扣账口播/crit水面变体/看门狗13s/震循环1.1s/震去重/状态链人数句
- 补录语音18段(flock串行,QC无离群);playtest 242→278
- 终裁:4/4 人设满分档,连续 2 轮达标 → 可交付 ✅

detective《雨夜专案》:
- R1 取证轮:补跑 live 两会话(认真新手33分钟全弧线/乱按200s三级保底抢救),5/5 愿意玩 P0=0
- R2 十一修全部坐实:intro完整落声/tut不被掐/按轴取句/人名指认/结案确认闸/指路句/选句记忆位/hint轻震/st_wrong当场记账/tut_skip补动词/L2疑点齐分支
- 补录+改词33条(29新增+4重录);playtest 265→279
- 终裁:6/6 愿意玩,连续 2 轮达标 → 可交付 ✅

公共:00-program 批次2进度账收账、13号文档 R2 断言关键词、gen_voice_dual 两游戏段、voice_text.json
验证:abyss 278/278 + detective 279/279 + guide 122/122 + check_all_assets 全项目全绿
2026-09-14 00:05:42 +08:00

1321 lines
63 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 《雨夜专案》无头试玩测试(docs/design/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);