Files
SoundGame/web/detective/test/playtest.mjs
T
xsl ee5d4d2544 feat(playbook): 26 号交互手册全量落地——剩余 12 款深度重构 + 语音全量重制
storyline/escape/simon 首批之后的推广收官:15 款全部按 26 号模式运行
(发言权/回执/三档提示/迷茫接管/手势阶梯/断点复述),旧「双击重听/格挡/
菜单确认」等反模式口径全线废除,开场零规则念白(首句即动手)。

游戏层(12 款):rpg(格挡=前摇窗口内再单击)/ rhythm / space / radio /
dungeon / abyss / detective / cipher / teahouse / keeper / bridge /
hunt(联机切片):
- 每款 game.js 接引擎六模块 + makeInputPipeline(doubleTapWindow:0);
  四向玩法类 swipeCore + 长按帮助;双击是玩法的(space 护盾 / hunt
  旋风斩)按手势阶梯阶段 3 分档恢复双击窗口(doubletapCore)
- 每款 playtest 重写:玩法断言 + §6.2 乱点机器人(无声操作 / 被动
  原句重复 / 打断失败 = 0);15 款 3900+ 断言全绿
- 12 份 docs/design/26-apply-*.md 施工单 + tools/voice_lines_26/
  台词清单 + voice-ids 文档同步 + 转写会话剧本按新口径重写

引擎层(web/engine/,6 处修复):pipeline 增 doubletapCore;novice
teachLine 带 voice/ 前缀(此前教学句被静默丢弃);confusion.record
前置到新手门(锁定手势的乱点也计信号);InvalidHints 播完恢复解释前
状态(根治孤儿 BUSY);WaitPoint takeover(2) 台账去重;台账只在
speak 真正播出时登记。遗留引擎债登记 roadmap §3.1。

语音资产:台词表合并(新增 ~250 系统句 / 改 ~50 旧口径 / 删 ~110
废止句;合并器 tools/merge_voice_lines_26.py + 引用校验器
tools/validate_voice_refs.py),GPU 重录 1536 wav → 4204 ogg/m4a
落地,check_all_assets + validate_voice_refs 双绿;键盘变体同步扩表。

大厅/文档:15 款统一新交互口径(「新交互」徽标退役);README /
HANDOVER / rpg README 旧口径修正;roadmap P0-P3 置完成。
2026-10-02 12:33:20 +08:00

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