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 置完成。
1452 lines
73 KiB
JavaScript
1452 lines
73 KiB
JavaScript
/**
|
||
* 《雨夜专案》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);
|