abyss《深海回音》: - R1 补 synthesis(5/5 愿意玩 P0=0)+ 补录 R1 缺失裁决材料 - R2 十修全部现场坐实:F1 ping迟到/尾句保护首播制/链末看门狗不拆/回波静默一拍/dv4_dual扣账口播/crit水面变体/看门狗13s/震循环1.1s/震去重/状态链人数句 - 补录语音18段(flock串行,QC无离群);playtest 242→278 - 终裁:4/4 人设满分档,连续 2 轮达标 → 可交付 ✅ detective《雨夜专案》: - R1 取证轮:补跑 live 两会话(认真新手33分钟全弧线/乱按200s三级保底抢救),5/5 愿意玩 P0=0 - R2 十一修全部坐实:intro完整落声/tut不被掐/按轴取句/人名指认/结案确认闸/指路句/选句记忆位/hint轻震/st_wrong当场记账/tut_skip补动词/L2疑点齐分支 - 补录+改词33条(29新增+4重录);playtest 265→279 - 终裁:6/6 愿意玩,连续 2 轮达标 → 可交付 ✅ 公共:00-program 批次2进度账收账、13号文档 R2 断言关键词、gen_voice_dual 两游戏段、voice_text.json 验证:abyss 278/278 + detective 279/279 + guide 122/122 + check_all_assets 全项目全绿
1321 lines
63 KiB
JavaScript
1321 lines
63 KiB
JavaScript
/**
|
||
* 《雨夜专案》无头试玩测试(docs/design/18-detective.md §11 断言清单 14 条关键路径)。
|
||
*
|
||
* 以玩家身份模拟:mock 浏览器环境(window/navigator/localStorage/fetch/虚拟时钟)
|
||
* 与音频引擎接口(播放日志 + voice onComplete 按虚拟时钟推进语音链),加载真实
|
||
* game.js,手势直接注入 game.onGesture。mock 层机制对齐 web/dungeon/test/playtest.mjs
|
||
* (navigator.maxTouchPoints=1 手机 persona;guide:'off' 回归纯玩法逻辑)。
|
||
*
|
||
* 资产契约:引擎对缺失资产只 console.error 不崩——本测试在资产未落盘时也必须
|
||
* 全绿(每句 15s 兜底续链推进,R2 F1 起 8s→15s:兜底须高于全部真实资产),
|
||
* 故 plays() 查询不过滤 missing 项;资产齐套后同样全绿(顺序/次数断言与时长无关)。
|
||
* 资产审计只做信息性报告不判失败。
|
||
*
|
||
* 运行:tools/node/bin/node web/detective/test/playtest.mjs
|
||
* 退出码 0 = 全部断言通过。seed/pack 由 opts 注入复现(§6.2)。
|
||
*/
|
||
import fs from 'node:fs';
|
||
import path from 'node:path';
|
||
import { fileURLToPath } from 'node:url';
|
||
|
||
const DETECTIVE_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||
|
||
// ================================ 虚拟时钟 ================================
|
||
|
||
let vnow = 0; // 虚拟毫秒
|
||
const timers = new Map();
|
||
let nextTimerId = 1;
|
||
|
||
function schedule(ms, fn) {
|
||
const id = nextTimerId++;
|
||
timers.set(id, { at: vnow + ms, fn });
|
||
return id;
|
||
}
|
||
function unschedule(id) { timers.delete(id); }
|
||
|
||
async function flushMicro() {
|
||
for (let i = 0; i < 5; i++) await Promise.resolve();
|
||
}
|
||
|
||
/** 推进虚拟时钟 ms 毫秒,期间到期的计时器按序触发(含语音 onComplete 链)。 */
|
||
async function tick(ms) {
|
||
const target = vnow + ms;
|
||
for (let guard = 0; guard < 200000; guard++) {
|
||
await flushMicro();
|
||
let best = null;
|
||
for (const [id, t] of timers) {
|
||
if (t.at <= target && (!best || t.at < best.t.at)) best = { id, t };
|
||
}
|
||
if (!best) break;
|
||
timers.delete(best.id);
|
||
vnow = best.t.at;
|
||
best.t.fn();
|
||
}
|
||
vnow = target;
|
||
await flushMicro();
|
||
}
|
||
|
||
// ================================ 浏览器环境 mock ================================
|
||
|
||
const storageMap = new Map();
|
||
const localStorageMock = {
|
||
getItem: (k) => (storageMap.has(k) ? storageMap.get(k) : null),
|
||
setItem: (k, v) => { storageMap.set(k, String(v)); },
|
||
removeItem: (k) => { storageMap.delete(k); },
|
||
clear: () => storageMap.clear(),
|
||
};
|
||
|
||
const vibrateLog = [];
|
||
const fetchLog = [];
|
||
|
||
Object.defineProperty(globalThis, 'performance', { value: { now: () => vnow }, configurable: true });
|
||
globalThis.setTimeout = (fn, ms = 0) => schedule(ms, fn);
|
||
globalThis.clearTimeout = unschedule;
|
||
globalThis.localStorage = localStorageMock;
|
||
globalThis.fetch = (url, opts) => { fetchLog.push({ url, opts }); return Promise.resolve({ ok: true, json: async () => ({}) }); };
|
||
Object.defineProperty(globalThis, 'navigator', {
|
||
// maxTouchPoints: 1 —— 手机 persona:inputMode() 判 touch(键盘路由由场景 13 专测)
|
||
value: { maxTouchPoints: 1, vibrate: (p) => { vibrateLog.push({ t: vnow, p }); return true; } },
|
||
configurable: true,
|
||
});
|
||
globalThis.window = {
|
||
addEventListener: () => {},
|
||
location: { href: '' },
|
||
};
|
||
|
||
const origConsoleError = console.error.bind(console);
|
||
console.error = () => {}; // 资产缺失走信息性报告,不刷屏
|
||
const origConsoleLog = console.log.bind(console);
|
||
|
||
// ================================ 资产存在性 ================================
|
||
|
||
function hasPair(base) {
|
||
return fs.existsSync(base + '.ogg') && fs.existsSync(base + '.m4a');
|
||
}
|
||
|
||
/** voice:旁白双性别齐 或 NPC 平铺(引擎平铺回退命中);sfx/bgm:双格式。 */
|
||
function assetExists(id) {
|
||
const audioDir = path.join(DETECTIVE_DIR, 'audio');
|
||
if (id.startsWith('voice/')) {
|
||
const name = id.slice(6);
|
||
return (hasPair(path.join(audioDir, 'voice', 'female', name))
|
||
&& hasPair(path.join(audioDir, 'voice', 'male', name)))
|
||
|| hasPair(path.join(audioDir, 'voice', name));
|
||
}
|
||
return hasPair(path.join(audioDir, id));
|
||
}
|
||
|
||
// ================================ 音频引擎 mock ================================
|
||
|
||
class MockAudioEngine {
|
||
constructor() {
|
||
this.log = []; // {t, layer, id, opts, missing}
|
||
this._voice = null; // 当前 voice token(isVoicePlaying 判定依据)
|
||
this.bgmNow = null;
|
||
this.blocked = new Set(); // 被屏蔽的 voice id(onComplete 永不触发 → 8s 兜底续链)
|
||
}
|
||
isVoicePlaying() { return this._voice !== null; }
|
||
setRateBoostEnabled() {}
|
||
_dur() { return 1.0; } // 假 buffer 时长(秒):资产存在时语音链按虚拟时钟推进
|
||
play(id, opts = {}) {
|
||
const layer = id.split('/')[0];
|
||
const missing = !assetExists(id);
|
||
this.log.push({ t: vnow, layer, id, opts, missing });
|
||
if (missing) return; // 引擎静默跳过语义(缺失时 onComplete 不来,游戏 8s 兜底接管)
|
||
if (layer === 'voice') {
|
||
this._voice = null; // 顶掉当前句(不触发其 onComplete,对齐引擎语义)
|
||
const token = { id, onComplete: opts.onComplete };
|
||
this._voice = token;
|
||
if (!this.blocked.has(id)) {
|
||
schedule(this._dur(id) * 1000, () => {
|
||
if (this._voice === token) {
|
||
this._voice = null;
|
||
if (token.onComplete) token.onComplete();
|
||
}
|
||
});
|
||
}
|
||
} else if (layer === 'bgm') {
|
||
this.bgmNow = id;
|
||
}
|
||
// sfx:即发即弃,无后续
|
||
}
|
||
stopVoice() { this._voice = null; }
|
||
stopBgm() { this.log.push({ t: vnow, layer: 'bgm', id: '(stop)', opts: {} }); this.bgmNow = null; }
|
||
stopAll() { this.stopBgm(); this.stopVoice(); }
|
||
prewarm() {}
|
||
}
|
||
|
||
// ================================ 断言设施 ================================
|
||
|
||
let passCount = 0;
|
||
const failures = [];
|
||
function assert(cond, msg) {
|
||
if (cond) { passCount++; }
|
||
else { failures.push(msg); origConsoleError(` ✗ ${msg}`); }
|
||
}
|
||
function ok(msg) { passCount++; origConsoleLog(` ✓ ${msg}`); }
|
||
|
||
/** 播放日志查询(含 missing 项——顺序/次数断言与资产落盘与否无关)。 */
|
||
function plays(audio, from = 0) {
|
||
const L = audio.log.slice(from);
|
||
return {
|
||
has: (id) => L.some((e) => e.id === id),
|
||
count: (id) => L.filter((e) => e.id === id).length,
|
||
hasRe: (re) => L.some((e) => re.test(e.id)),
|
||
countRe: (re) => L.filter((e) => re.test(e.id)).length,
|
||
orderOk: (...ids) => {
|
||
let pos = -1;
|
||
for (const id of ids) {
|
||
const i = L.findIndex((e, j) => j > pos && e.id === id);
|
||
if (i < 0) return false;
|
||
pos = i;
|
||
}
|
||
return true;
|
||
},
|
||
entries: () => L,
|
||
entryOf: (id) => L.find((e) => e.id === id),
|
||
};
|
||
}
|
||
|
||
const ERROR_PATTERN = JSON.stringify([60, 40, 60]);
|
||
function vibratedError(fromIdx = 0) {
|
||
return vibrateLog.slice(fromIdx).some((v) => JSON.stringify(v.p) === ERROR_PATTERN);
|
||
}
|
||
const SUCCESS_PATTERN = JSON.stringify([100]);
|
||
function vibratedSuccess(fromIdx = 0) {
|
||
return vibrateLog.slice(fromIdx).some((v) => JSON.stringify(v.p) === SUCCESS_PATTERN);
|
||
}
|
||
|
||
// ================================ 游戏装配 ================================
|
||
|
||
const { DetectiveGame } = await import('../game.js');
|
||
const { settings } = await import('../../engine/settings.js');
|
||
|
||
settings.set({ guide: 'off', voice: 'female', speed: 1.0, bgm: 1.0, input: 'touch' });
|
||
|
||
const LISTEN_ITEMS = ['w1', 'w2', 'w3', 'w4', 'scene', 'fore'];
|
||
const ITEM_ORDER = [...LISTEN_ITEMS, 'case'];
|
||
|
||
const audioRegistry = [];
|
||
function newGame(opts = {}) {
|
||
const audio = new MockAudioEngine();
|
||
audioRegistry.push(audio);
|
||
const game = new DetectiveGame(audio, {}, opts);
|
||
return { game, audio };
|
||
}
|
||
const g = (game, gesture) => game.onGesture(gesture);
|
||
|
||
/** 有界等待:条件满足或超时即返回。 */
|
||
async function waitState(pred, maxMs) {
|
||
const t0 = vnow;
|
||
while (!pred() && vnow - t0 < maxMs) await tick(200);
|
||
return pred();
|
||
}
|
||
|
||
/** 导航到证词板条目(最短环绕方向;播报 650ms 落声)。 */
|
||
async function navToItem(game, itemId) {
|
||
const target = ITEM_ORDER.indexOf(itemId);
|
||
let guard = 0;
|
||
while (game.itemIdx !== target && guard++ < 10) {
|
||
const d = (target - game.itemIdx + ITEM_ORDER.length) % ITEM_ORDER.length;
|
||
g(game, d * 2 <= ITEM_ORDER.length ? 'right' : 'left');
|
||
await tick(150);
|
||
}
|
||
await tick(650);
|
||
}
|
||
|
||
/** 完整听取一个条目(nav + tap + 等链播完;资产缺失时 15s/句兜底 → 6 句 ≈95s 余量)。 */
|
||
async function listenItem(game, itemId) {
|
||
await navToItem(game, itemId);
|
||
g(game, 'tap');
|
||
return waitState(() => game._play === null && game.state === 'BOARD', 120000);
|
||
}
|
||
|
||
/** CONFRONT 内选一句:滑到条目 → 单击进选句 → 滑到句 → 单击确认 → 单击定下。
|
||
* entryIdx 可选:R2 F7 选句位置记忆——进选句相位时断言起始 lineIdx。 */
|
||
async function cfSelectLine(game, caseData, lineId, entryIdx) {
|
||
const itemId = caseData.lineItem[lineId];
|
||
const target = LISTEN_ITEMS.indexOf(itemId);
|
||
let guard = 0;
|
||
while ((game._cf?.idx ?? -1) !== target && guard++ < 8) {
|
||
const d = (target - game._cf.idx + LISTEN_ITEMS.length) % LISTEN_ITEMS.length;
|
||
g(game, d * 2 <= LISTEN_ITEMS.length ? 'right' : 'left');
|
||
await tick(120);
|
||
}
|
||
await tick(650);
|
||
g(game, 'tap'); // 确认条目 → 进选句
|
||
await tick(300);
|
||
if (entryIdx != null) {
|
||
assert(game._cf?.lineIdx === entryIdx, `选句入口=记忆位 ${entryIdx}(实际 ${game._cf?.lineIdx},R2 F7)`);
|
||
}
|
||
const ids = caseData.layout.lines[itemId];
|
||
const li = ids.indexOf(lineId);
|
||
let lg = 0;
|
||
while ((game._cf?.lineIdx ?? -1) !== li && lg++ < 8) {
|
||
const d = (li - game._cf.lineIdx + ids.length) % ids.length;
|
||
g(game, d * 2 <= ids.length ? 'right' : 'left');
|
||
await tick(120);
|
||
}
|
||
g(game, 'tap'); // 进确认(重播该句 + cf_confirm)
|
||
await tick(32000); // 确认链(句 + cf_confirm)走完——玩家听清「就这句?」再定下(15s 兜底余量)
|
||
g(game, 'tap'); // 定下这句
|
||
}
|
||
|
||
/** 从 BOARD 空闲发起一次完整指证(长按进入 + 两侧选句),等判定链收尾回板。
|
||
* 长按带重试:E2 受理条件=无语音在播——播报尾音未排空时补一拍重试(与时长解耦)。 */
|
||
async function confront(game, caseData, lineA, lineB, { maxMs = 120000, entryIdxA } = {}) {
|
||
for (let attempt = 0; attempt < 3 && game.state !== 'CONFRONT'; attempt++) {
|
||
g(game, 'longpress');
|
||
if (game.state !== 'CONFRONT') await tick(1500);
|
||
}
|
||
await waitState(() => game.state === 'CONFRONT' && game._cf && game._cf.phase === 'item1', 12000);
|
||
await tick(16000); // cf_start(缺失时 15s 兜底)→ 条目报名
|
||
await cfSelectLine(game, caseData, lineA, entryIdxA);
|
||
await tick(32000); // cf_second + 第一份自动重播(P1 记忆契约)走完
|
||
await cfSelectLine(game, caseData, lineB);
|
||
return waitState(() => game.state === 'BOARD' && !game._play, maxMs);
|
||
}
|
||
|
||
/** 从 BOARD 走到 FINISH(滑到结案陈词条目 → 单击进确认闸(fin_pre)→ 闸内再单击进三问,R2 F5)。 */
|
||
async function enterFinish(game) {
|
||
await navToItem(game, 'case');
|
||
g(game, 'tap');
|
||
await tick(200);
|
||
g(game, 'tap');
|
||
await tick(200);
|
||
}
|
||
|
||
/** 快速跳过教学直达 BOARD(新案已开)。 */
|
||
async function skipDrill(game) {
|
||
await waitState(() => game.state === 'DRILL', 20000);
|
||
g(game, 'down');
|
||
return waitState(() => game.state === 'BOARD', 20000);
|
||
}
|
||
|
||
/** 矩阵不变式独立重算(§6.4 playtest 双保险):同轴异值对集合 ≡ 矛盾表。 */
|
||
function matrixInvariant(c) {
|
||
const all = c.items.flatMap((it) => it.lines);
|
||
const norm = (x, y, ax) => (x < y ? `${x}|${y}|${ax}` : `${y}|${x}|${ax}`);
|
||
const derived = new Set();
|
||
for (let i = 0; i < all.length; i++) {
|
||
for (let j = i + 1; j < all.length; j++) {
|
||
const A = all[i], B = all[j];
|
||
for (const ax of Object.keys(A.clues)) {
|
||
if (B.clues[ax] !== undefined && B.clues[ax] !== A.clues[ax]) derived.add(norm(A.id, B.id, ax));
|
||
}
|
||
}
|
||
}
|
||
const want = new Set(c.contradictions.map((c2) => norm(c2.a, c2.b, c2.axis)));
|
||
const extra = [...derived].filter((k) => !want.has(k));
|
||
const missing = [...want].filter((k) => !derived.has(k));
|
||
return { ok: extra.length === 0 && missing.length === 0, extra, missing, size: derived.size };
|
||
}
|
||
|
||
// ================================ 场景 1:DRILL 教学全链(§5/§11.1) ================================
|
||
|
||
async function scenario1() {
|
||
origConsoleLog('\n=== 场景 1:TITLE→DRILL D0..D6 + D5 练习截获零污染 + 跳过路径 ===');
|
||
storageMap.clear();
|
||
const { game, audio } = newGame({ seed: 42 });
|
||
game.start();
|
||
await tick(400);
|
||
assert(game.state === 'TITLE' && !game.run, '启动进 TITLE(无存档)');
|
||
assert(plays(audio).has('bgm/menu'), 'TITLE bgm/menu');
|
||
|
||
let mark = audio.log.length;
|
||
g(game, 'tap');
|
||
await waitState(() => game.state === 'DRILL' && game._drill && game._drill.step === 0, 20000);
|
||
await tick(80000); // sys_new_case + intro_1..3 + tut_d0 五句整链(缺失时 15s/句兜底)
|
||
assert(game.run && game.run.seed === 42, 'opts.seed=42 注入新案');
|
||
assert(plays(audio, mark).orderOk('voice/sys_new_case', 'voice/intro_1', 'voice/intro_2', 'voice/intro_3', 'voice/tut_d0'),
|
||
'D0 序:sys_new_case → intro_1/2/3 → tut_d0(资产缺失时整链仍经 15s 兜底推进——§4.3 契约)');
|
||
assert(plays(audio).has('bgm/board'), 'DRILL bgm/board');
|
||
|
||
// D0:左滑或右滑任一(首输入达成点)
|
||
mark = audio.log.length;
|
||
const vib0 = vibrateLog.length;
|
||
g(game, 'right');
|
||
await tick(800);
|
||
assert(game._drill?.step === 1, 'D0 右滑通过 → D1');
|
||
assert(plays(audio, mark).has('sfx/paper_turn') && plays(audio, mark).has('sfx/sig_shun') && plays(audio, mark).has('voice/item_w2'),
|
||
'D0 条目播报:paper_turn + sig_shun + item_w2(右滑换到下一份,签名前置)');
|
||
{
|
||
const conf = vibrateLog.slice(vib0).some((v) => JSON.stringify(v.p) === JSON.stringify([20]));
|
||
assert(conf, 'D0 通过附 confirm 震');
|
||
}
|
||
|
||
// D1:单击 → 老葛真实证词链播(教学寄生于真实案卷)
|
||
await tick(32000); // R2 F2:item_w2 报名落声 → tut_listen 串行接播 → 回正报名(互不掐)
|
||
{
|
||
const seg = plays(audio, mark);
|
||
assert(seg.orderOk('voice/item_w2', 'voice/tut_listen'),
|
||
'R2 F2:D0 报名(item_w2)先于 D1 指令(tut_listen)串行落声(原并行互掐,live R1 实测指令只播 0.4s)');
|
||
}
|
||
mark = audio.log.length;
|
||
g(game, 'tap');
|
||
assert(game._play !== null, 'D1 单击开始证词链播');
|
||
const w1Ids = game._case.layout.lines.w1;
|
||
const done1 = await waitState(() => game._drill && game._drill.step === 2, 120000);
|
||
assert(done1, 'D1 证词链播完 → D2');
|
||
{
|
||
const seg = plays(audio, mark);
|
||
let pos = -1, orderOk1 = true;
|
||
for (const id of w1Ids) {
|
||
const i = seg.entries().findIndex((e, j) => j > pos && e.id === 'voice/' + id);
|
||
if (i < 0) { orderOk1 = false; break; }
|
||
pos = i;
|
||
}
|
||
assert(orderOk1, `D1 句序 = layout.lines.w1(${w1Ids.join(',')})`);
|
||
}
|
||
assert(game.run.heard.includes('w1'), 'D1 听完 w1 入账 heard(st_asked 真实来源)');
|
||
|
||
// D2:双击重新听最后一句(免费)
|
||
await tick(16000); // tut_replay
|
||
mark = audio.log.length;
|
||
const lastW1 = game.run.lastLine.w1;
|
||
g(game, 'doubletap');
|
||
await tick(16000);
|
||
assert(plays(audio, mark).has('voice/' + lastW1), `D2 双击重播最后一句(${lastW1})`);
|
||
assert(game._drill?.step === 3, 'D2 重播完 → D3');
|
||
|
||
// D3:上滑状态链(st_asked_1 → st_doubt_0 → recap_go)
|
||
await tick(16000); // tut_status
|
||
mark = audio.log.length;
|
||
g(game, 'up');
|
||
await waitState(() => game._drill?.step === 4, 70000);
|
||
assert(plays(audio, mark).orderOk('voice/st_asked_1', 'voice/st_doubt_0'), 'D3 状态链:st_asked_1 → st_doubt_0');
|
||
assert(plays(audio, mark).has('voice/recap_go'), 'D3 状态链尾 recap_go(未听齐时仍是「接着查」)');
|
||
|
||
// D4:长按进入指证(E2 豁免教学)
|
||
await tick(16000); // tut_confront
|
||
mark = audio.log.length;
|
||
g(game, 'longpress');
|
||
await tick(200);
|
||
assert(game.state === 'CONFRONT' && game._cf, 'D4 长按 → CONFRONT');
|
||
assert(game._drill?.step === 5, 'D4 通过 → D5');
|
||
await waitState(() => plays(audio, mark).has('voice/cf_start'), 12000);
|
||
await waitState(() => plays(audio, mark).has('voice/tut_cf_try'), 12000);
|
||
assert(plays(audio, mark).has('voice/tut_cf_try'), 'D5 练习句 tut_cf_try 接在 cf_start 链上');
|
||
|
||
// D5:任意选择组合(选 w2 首句 vs scene 首句——真判定必 miss 的组合)只播 tut_cf_ok
|
||
const before = { cfMiss: game.run.cfMiss, doubts: [...game.run.doubts], zHits: game.run.zHits };
|
||
await tick(32000); // cf_start + tut_cf_try 链走完 → 条目报名
|
||
await cfSelectLine(game, game._case, game._case.layout.lines.w2[0], 0); // R2 F7:首访条目记忆位=0
|
||
await tick(32000); // cf_second + 自动重播
|
||
mark = audio.log.length;
|
||
const vib5 = vibrateLog.length;
|
||
await cfSelectLine(game, game._case, game._case.layout.lines.scene[0], 0);
|
||
const done5 = await waitState(() => game.state === 'DRILL' && game._drill?.step === 6, 30000);
|
||
assert(done5, 'D5 判定被教学截获 → 回 DRILL D6');
|
||
assert(plays(audio, mark).has('voice/tut_cf_ok'), 'D5 任意组合只播 tut_cf_ok(练习不算数)');
|
||
assert(!plays(audio, mark).hasRe(/^voice\/(cf_hit|cf_same|cf_diff|cf_self)$/), 'D5 真判定句(hit/三档 miss)零出现');
|
||
assert(!plays(audio, mark).has('sfx/contra_hit') && !plays(audio, mark).has('sfx/contra_miss'), 'D5 判定音零出现(练习不算数)');
|
||
assert(vibratedSuccess(vib5), 'D5 完成附 success 震(首个正反馈 ≤3min)');
|
||
assert(game.run.cfMiss === before.cfMiss && game.run.zHits === before.zHits
|
||
&& JSON.stringify(game.run.doubts) === JSON.stringify(before.doubts),
|
||
'D5 练习零账目污染(cfMiss/doubts/zHits 不变——教学承诺兑现)');
|
||
|
||
// D6:tut_done → BOARD 自由调查
|
||
await waitState(() => game.state === 'BOARD', 30000);
|
||
assert(plays(audio, mark).has('voice/tut_done'), 'D6 tut_done');
|
||
assert(game.meta.drillDone === true, '教学标志入元进度');
|
||
assert(game.run.heard.length === 1, '教学期只真实听取了 w1(账目真实)');
|
||
|
||
// 上滑随时可用(教过后)
|
||
mark = audio.log.length;
|
||
g(game, 'up');
|
||
await tick(50000);
|
||
assert(plays(audio, mark).orderOk('voice/st_asked_1', 'voice/st_doubt_0'), 'BOARD 上滑状态链随时可用');
|
||
game.stop();
|
||
|
||
// ---- 跳过路径 + 二周目不再进 ----
|
||
storageMap.clear();
|
||
const b = newGame({ seed: 7 });
|
||
b.game.start();
|
||
await tick(400);
|
||
g(b.game, 'tap');
|
||
await waitState(() => b.game.state === 'DRILL', 20000);
|
||
mark = b.audio.log.length;
|
||
g(b.game, 'down');
|
||
assert(await waitState(() => b.game.state === 'BOARD', 25000), '下滑跳过教学直达 BOARD');
|
||
assert(plays(b.audio, mark).has('voice/tut_skip'), '下滑跳过教学播 tut_skip');
|
||
assert(b.game.meta.drillDone === true, '跳过也置 drillDone');
|
||
b.game._save();
|
||
b.game.stop();
|
||
|
||
const c = newGame({});
|
||
c.game.start();
|
||
await tick(400);
|
||
assert(c.game.state === 'TITLE' && plays(c.audio).has('voice/sys_continue_ask'), '有档重进播 sys_continue_ask');
|
||
mark = c.audio.log.length;
|
||
g(c.game, 'tap');
|
||
await waitState(() => c.game.state === 'BOARD', 30000);
|
||
assert(c.game.state === 'BOARD', '二周目单击继续直达 BOARD(不再进 DRILL)');
|
||
c.game.stop();
|
||
}
|
||
|
||
// ================================ 场景 2:seed 复现(§6.2/§11.2) ================================
|
||
|
||
async function scenario2() {
|
||
origConsoleLog('\n=== 场景 2:seed 复现——(42,0) 两次构造 _case 深相等;(42,1)≠(43,1) ===');
|
||
storageMap.clear();
|
||
const a = newGame({ seed: 42 });
|
||
const b = newGame({ seed: 42 });
|
||
const c1 = a.game._buildCase(42, 0);
|
||
const c2 = b.game._buildCase(42, 0);
|
||
assert(JSON.stringify(c1) === JSON.stringify(c2), '同 (seed=42, pack=0) 两次构造 _case 深相等(布局/选项序/提示序)');
|
||
const d1 = a.game._buildCase(42, 1);
|
||
const d2 = a.game._buildCase(43, 1);
|
||
assert(JSON.stringify(d1.layout) !== JSON.stringify(d2.layout), '(42,1) 与 (43,1) 布局不同');
|
||
assert(JSON.stringify(c1.layout) !== JSON.stringify(d1.layout), '(42,0) 与 (42,1) 布局不同(包内 fork + 洗牌差)');
|
||
// 洗牌结构断言:filler 置换只动 shuffle 槽位集合(句集合不变)
|
||
const fixedLines = ['w1_1', 'w1_3', 'w1_6'];
|
||
assert(fixedLines.every((id) => c1.items.find((it) => it.id === 'w1').lines.some((l) => l.id === id)),
|
||
'非 shuffle 句全部在场(集合不变,仅槽位置换)');
|
||
const slotsA = c1.items.find((it) => it.id === 'w1').lines.map((l) => l.id).sort().join(',');
|
||
const slotsB = c2.items.find((it) => it.id === 'w1').lines.map((l) => l.id).sort().join(',');
|
||
assert(slotsA === slotsB, 'w1 句集合不变(置换非增删)');
|
||
ok(`seed 复现三组对比通过(wpOpts=${JSON.stringify(c1.layout.wpOpts)} hintOrder=${JSON.stringify(c1.layout.hintOrder)})`);
|
||
}
|
||
|
||
// ================================ 场景 3:矩阵不变式(§6.4/§11.3) ================================
|
||
|
||
async function scenario3() {
|
||
origConsoleLog('\n=== 场景 3:矩阵不变式——同轴异值对 ≡ 矛盾表(pack A/B 各验一遍) ===');
|
||
const { game } = newGame({});
|
||
for (const [seed, pack] of [[42, 0], [43, 0], [100, 1], [101, 1]]) {
|
||
const c = game._buildCase(seed, pack);
|
||
const r = matrixInvariant(c);
|
||
assert(r.ok, `矩阵不变式 (seed=${seed},pack=${pack}):derived=${r.size} 对 ≡ contradictions(extra=${r.extra.join(',')} missing=${r.missing.join(',')})`);
|
||
}
|
||
const c = game._buildCase(42, 0);
|
||
assert(c.contradictions.length === 4 && c.contradictions.map((x) => x.id).join(',') === 'm1,m2,m3,m4',
|
||
'pack A 矛盾表 = M1-M4(lamp/key/weapon/scale)');
|
||
const cb = game._buildCase(100, 1);
|
||
assert(cb.contradictions.map((x) => x.a).join(',').includes('b_shun_1'), 'pack B 矛盾指向 b_shun_*');
|
||
// 三档归因真话来源可达性(§3.4 验算):一致对 / 无共享轴对 / 同句
|
||
const all = c.items.flatMap((it) => it.lines);
|
||
let sameAxisDiffVal = 0, sameAxisSameVal = 0, noShared = 0;
|
||
for (let i = 0; i < all.length; i++) for (let j = i + 1; j < all.length; j++) {
|
||
const A = all[i], B = all[j];
|
||
const shared = Object.keys(A.clues).filter((ax) => B.clues[ax] !== undefined);
|
||
if (!shared.length) noShared++;
|
||
else if (shared.every((ax) => A.clues[ax] === B.clues[ax])) sameAxisSameVal++;
|
||
else sameAxisDiffVal++;
|
||
}
|
||
assert(sameAxisDiffVal === 4, `全案卷同轴异值对恰 4(M1-M4,实际 ${sameAxisDiffVal})`);
|
||
assert(sameAxisSameVal >= 6, `一致对 ≥6 组(cf_same 真话来源,实际 ${sameAxisSameVal})`);
|
||
assert(noShared >= 13, `无共享轴句对 ≥13(cf_diff 真话来源,实际 ${noShared})`);
|
||
ok('三档归因档位全部真实可达(数据单源推导)');
|
||
}
|
||
|
||
// ================================ 场景 4:听取细节(§11.4 + 边界) ================================
|
||
|
||
async function scenario4() {
|
||
origConsoleLog('\n=== 场景 4:听取——句序/双击重听/单击停止/断句续听/播放中静默忽略 ===');
|
||
storageMap.clear();
|
||
const { game, audio } = newGame({ seed: 42 });
|
||
game.start();
|
||
await tick(400);
|
||
g(game, 'tap');
|
||
assert(await skipDrill(game), '跳教学直达 BOARD');
|
||
assert(game.run.seed === 42, 'seed 注入');
|
||
|
||
// w2 完整听取:句序 = layout、播完 st_asked 升档
|
||
let mark = audio.log.length;
|
||
assert(await listenItem(game, 'w2'), 'w2 链播完成');
|
||
{
|
||
const ids = game._case.layout.lines.w2;
|
||
const seg = plays(audio, mark);
|
||
let pos = -1, okOrder = true;
|
||
for (const id of ids) {
|
||
const i = seg.entries().findIndex((e, j) => j > pos && e.id === 'voice/' + id);
|
||
if (i < 0) { okOrder = false; break; }
|
||
pos = i;
|
||
}
|
||
assert(okOrder, `w2 句序=layout(${ids.join(',')})`);
|
||
}
|
||
assert(game.run.heard.includes('w2') && game.run.heardLines.w2 === game._case.layout.lines.w2.length,
|
||
'w2 入账 heard + heardLines 全量');
|
||
|
||
mark = audio.log.length;
|
||
g(game, 'up');
|
||
await tick(50000);
|
||
assert(plays(audio, mark).has('voice/st_asked_1'), '听完 1 份 → 上滑 st_asked_1');
|
||
|
||
// w4:播放中双击=当前句重播、左右滑静默忽略、单击停止、断句续听
|
||
await navToItem(game, 'w4');
|
||
mark = audio.log.length;
|
||
const vib0 = vibrateLog.length;
|
||
g(game, 'tap');
|
||
await waitState(() => game._play && game._play.cur >= 1, 30000); // 第二句起播
|
||
const curId = game._play ? game._case.layout.lines.w4[game._play.cur] : '(未起播)';
|
||
g(game, 'doubletap');
|
||
await tick(300);
|
||
assert(plays(audio, mark).count('voice/' + curId) >= 2, `播放中双击重播当前句(${curId} 播 ≥2 次,免费零消耗)`);
|
||
const itemIdxBefore = game.itemIdx;
|
||
g(game, 'left');
|
||
g(game, 'right');
|
||
await tick(500);
|
||
assert(game.itemIdx === itemIdxBefore && !vibratedError(vib0), '播放中左右滑静默忽略(无 error 震、条目不变)');
|
||
g(game, 'tap');
|
||
assert(game._play === null, '播放中单击=停止');
|
||
mark = audio.log.length;
|
||
await tick(25000);
|
||
assert(!plays(audio, mark).hasRe(/^voice\/(wen_|z_|w2_|b_)/), '停止后无后续证词句播出');
|
||
// 续听:从首个未听句起
|
||
const resumeIdx = game.run.heardLines.w4 || 0;
|
||
assert(resumeIdx >= 1 && resumeIdx < game._case.layout.lines.w4.length, `w4 断点在句 ${resumeIdx}(1≤断点<6)`);
|
||
mark = audio.log.length;
|
||
g(game, 'tap');
|
||
await waitState(() => game._play && game._play.cur === resumeIdx, 10000);
|
||
assert(game._play && game._play.cur === resumeIdx, `续听从首个未听句起(cur=${game._play?.cur} = heardLines=${resumeIdx})`);
|
||
g(game, 'tap'); // 停止收尾
|
||
|
||
// 播放中上滑=状态链(打断证词),打断后双击可重听当前(最后)句
|
||
await navToItem(game, 'scene');
|
||
g(game, 'tap');
|
||
await waitState(() => game._play && game._play.cur >= 0, 30000);
|
||
const scCur = game._play ? game._case.layout.lines.scene[game._play.cur] : '(未起播)';
|
||
mark = audio.log.length;
|
||
g(game, 'up');
|
||
await tick(3000);
|
||
assert(game._play === null && plays(audio, mark).hasRe(/^voice\/st_asked_/), '播放中上滑打断证词播状态链(播报值=实值)');
|
||
mark = audio.log.length;
|
||
g(game, 'doubletap');
|
||
await tick(9000);
|
||
assert(plays(audio, mark).has('voice/' + scCur), `打断后双击重听当前句(${scCur})`);
|
||
|
||
// 全部听齐 → st_asked_6;R2 F6:尾句换指路 recap_cf(不再是干巴巴的「接着查」)
|
||
for (const id of ['w1', 'w3', 'w4', 'fore', 'scene']) assert(await listenItem(game, id), `${id} 听取完成`);
|
||
assert(game.run.heard.length === 6, '六份卷宗全部入账');
|
||
mark = audio.log.length;
|
||
g(game, 'up');
|
||
await tick(50000);
|
||
assert(plays(audio, mark).has('voice/st_asked_6'), '六份听齐 → st_asked_6');
|
||
assert(plays(audio, mark).has('voice/recap_cf') && !plays(audio, mark).has('voice/recap_go'),
|
||
'R2 F6:听齐未破时状态链尾=recap_cf(「挑一句你不信的,按住指证」),不再播 recap_go');
|
||
game.stop();
|
||
}
|
||
|
||
// ================================ 场景 5:指证 4 矛盾逐条走通(§11.5) ================================
|
||
|
||
async function scenario5() {
|
||
origConsoleLog('\n=== 场景 5:M1-M4 指证(hit 链/共用 z_4/自动重播/疑点 4/4 hint_fin/E2 边界) ===');
|
||
storageMap.clear();
|
||
const { game, audio } = newGame({ seed: 42 });
|
||
game.start();
|
||
await tick(400);
|
||
g(game, 'tap');
|
||
assert(await skipDrill(game), '跳教学进板');
|
||
|
||
const cs = game._case;
|
||
// M1(lamp:z_2 vs wen_2)
|
||
let mark = audio.log.length;
|
||
let vib = vibrateLog.length;
|
||
assert(await confront(game, cs, 'z_2', 'wen_2'), 'M1 指证链收尾回板');
|
||
{
|
||
const p = plays(audio, mark);
|
||
assert(p.has('sfx/contra_hit') && p.has('voice/cf_hit') && p.has('voice/z_react_1'),
|
||
'M1 hit 链:contra_hit → cf_hit → z_react_1(R2 F3:按轴取句 lamp→react_1)');
|
||
assert(p.orderOk('sfx/contra_hit', 'voice/cf_hit', 'voice/z_react_1'), 'M1 hit 链序:音先响、语音后接');
|
||
assert(p.orderOk('voice/cf_confirm', 'voice/cf_second', 'voice/z_2'), '第二份选择前自动重播第一份选定句(P1 记忆契约)');
|
||
}
|
||
assert(vibratedSuccess(vib), 'hit 附 success 震');
|
||
assert(game.run.doubts[0] === true && game.run.zHits === 1, 'M1 doubts[0] 置位 + zHits=1');
|
||
assert(game.run.cfMiss === 0, 'hit 不计 cfMiss(账目语义=冗余/错过)');
|
||
|
||
// M2(key:z_3 vs w2_3)——R2 F7:w3 选句入口应记忆在 z_2(idx 1),不再从 z_1 扫起
|
||
mark = audio.log.length;
|
||
assert(await confront(game, cs, 'z_3', 'w2_3', { entryIdxA: 1 }), 'M2 指证完成(选句入口=记忆位 1)');
|
||
assert(plays(audio, mark).has('voice/z_react_2') && game.run.doubts[1] === true, 'M2 → z_react_2(key 轴)+ doubts[1]');
|
||
|
||
// M3/M4 共用 z_4(一句两断言,选 z_4 两次分别对 fo_3 与 sc_4)
|
||
mark = audio.log.length;
|
||
assert(await confront(game, cs, 'z_4', 'fo_3', { entryIdxA: 2 }), 'M3 指证完成(z_4 vs fo_3,入口=记忆位 2)');
|
||
assert(plays(audio, mark).has('voice/z_react_w') && game.run.doubts[2] === true,
|
||
'M3 → z_react_w(R2 F3:weapon 轴专属慌「铜器…我怎么会知道」,不再错配秤/灯)');
|
||
mark = audio.log.length;
|
||
assert(await confront(game, cs, 'z_4', 'sc_4', { entryIdxA: 3 }), 'M4 指证完成(z_4 vs sc_4,同一句两次,入口=记忆位 3)');
|
||
assert(plays(audio, mark).has('voice/z_react_3') && game.run.doubts[3] === true, 'M4 → z_react_3(scale 轴)+ doubts[3]');
|
||
|
||
// 疑点 4/4:hit 链尾追加 reactFinal 收官句 + 自动播一次 hint_fin(R2 F3)
|
||
assert(game._doubtCount() === 4 && game.run.zHits === 4, '四疑点齐 + zHits=4');
|
||
assert(plays(audio, mark).orderOk('voice/z_react_3', 'voice/z_react_4', 'voice/hint_fin'),
|
||
'4/4 齐时链尾:轴句 → reactFinal(z_react_4 逐客)→ hint_fin(一次)');
|
||
assert(!game.run.hintsGiven.includes('hint_fin'), '庆祝性 hint_fin 不计入保底提示账(S 档可达)');
|
||
mark = audio.log.length;
|
||
g(game, 'up');
|
||
await tick(50000);
|
||
assert(plays(audio, mark).has('voice/st_doubt_4'), '状态链 st_doubt_4(疑点实数)');
|
||
assert(plays(audio, mark).has('voice/hint_fin'), 'R2 F6:疑点齐时状态链尾=hint_fin(去结案)');
|
||
|
||
// 长按豁免边界(§11 边界):播放中长按=引擎快进不进指证;空闲长按=CONFRONT
|
||
await navToItem(game, 'w1');
|
||
g(game, 'tap');
|
||
await waitState(() => game._play && game._play.cur >= 0, 30000);
|
||
g(game, 'longpress');
|
||
await tick(600);
|
||
assert(game.state === 'BOARD' && game._play !== null, 'E2:播放中长按不进指证(引擎快进接管,时间互斥)');
|
||
g(game, 'tap'); // 停止
|
||
await tick(400);
|
||
g(game, 'longpress');
|
||
await tick(200);
|
||
assert(game.state === 'CONFRONT', 'E2:空闲长按 → CONFRONT');
|
||
// CONFRONT 条目相位长按 = error 震(选择语境不作输入)
|
||
vib = vibrateLog.length;
|
||
g(game, 'longpress');
|
||
await tick(200);
|
||
assert(vibratedError(vib) && game.state === 'CONFRONT', 'CONFRONT 条目相位长按 error 震不受理');
|
||
// R2 F7:选句相位长按放行引擎快进——语音在播不震;空闲无可快进=error 震
|
||
await tick(16000); // cf_start + 报名落声
|
||
g(game, 'tap'); // 确认当前条目 → 进选句(播 cf_pick_line + 入口句)
|
||
await tick(400); // 语音在播中
|
||
vib = vibrateLog.length;
|
||
g(game, 'longpress');
|
||
await tick(200);
|
||
assert(!vibratedError(vib) && game._cf && (game._cf.phase === 'line1' || game._cf.phase === 'line2'),
|
||
'R2 F7:选句相位语音在播长按=引擎快进接管(无 error 震、相位不变)');
|
||
await tick(2000); // 等落声(空闲)
|
||
vib = vibrateLog.length;
|
||
g(game, 'longpress');
|
||
await tick(200);
|
||
assert(vibratedError(vib), 'R2 F7:选句相位空闲长按=error 震(无可快进)');
|
||
// 菜单「返回证词板」退出指证
|
||
g(game, 'down');
|
||
await tick(32000); // sys_menu + 首项
|
||
mark = audio.log.length;
|
||
g(game, 'doubletap'); // 双击确认 sys_menu_resume(=返回证词板)
|
||
await waitState(() => game.state === 'BOARD', 15000);
|
||
assert(game.state === 'BOARD', '菜单「返回证词板」退出指证');
|
||
assert(!plays(audio).hasRe(/^voice\/gd_/), 'guide=off:全程零 gd_* 向导语音');
|
||
game.stop();
|
||
}
|
||
|
||
// ================================ 场景 6:指证 miss 三档真话 + 无序(§11.6) ================================
|
||
|
||
async function scenario6() {
|
||
origConsoleLog('\n=== 场景 6:miss 三档(cf_same/cf_diff/cf_self)+ 指证对无序 ===');
|
||
storageMap.clear();
|
||
const { game, audio } = newGame({ seed: 43 });
|
||
game.start();
|
||
await tick(400);
|
||
g(game, 'tap');
|
||
assert(await skipDrill(game), '跳教学进板');
|
||
const cs = game._case;
|
||
|
||
// 指证对无序:反向选 M1(wen_2 在前)也 hit
|
||
let mark = audio.log.length;
|
||
assert(await confront(game, cs, 'wen_2', 'z_2'), '反向 M1 指证完成');
|
||
assert(plays(audio, mark).has('sfx/contra_hit') && plays(audio, mark).has('voice/cf_hit'),
|
||
'指证对无序(任一端在前都判 hit)');
|
||
assert(game.run.doubts[0] === true && game.run.zHits === 1, '反向 M1 同样置疑点');
|
||
|
||
// 同轴同值(w1_2 vs wen_1:都说雨 21:40)→ cf_same
|
||
mark = audio.log.length;
|
||
assert(await confront(game, cs, 'w1_2', 'wen_1'), '同轴同值指证完成');
|
||
assert(plays(audio, mark).has('sfx/contra_miss') && plays(audio, mark).has('voice/cf_same'),
|
||
'miss 链:contra_miss → cf_same(说得一致,不矛盾——轴数据同值真话)');
|
||
assert(!plays(audio, mark).has('voice/cf_diff') && !plays(audio, mark).has('voice/cf_self'), '同轴同值只播 cf_same');
|
||
|
||
// 无共享轴(z_5 vs sc_1)→ cf_diff
|
||
mark = audio.log.length;
|
||
assert(await confront(game, cs, 'z_5', 'sc_1'), '无共享轴指证完成');
|
||
assert(plays(audio, mark).has('voice/cf_diff') && !plays(audio, mark).has('voice/cf_same'),
|
||
'无共享轴只播 cf_diff(说的不是一件事)');
|
||
|
||
// 同句自比(z_5 vs z_5)→ cf_self
|
||
mark = audio.log.length;
|
||
assert(await confront(game, cs, 'z_5', 'z_5'), '同句自比完成');
|
||
assert(plays(audio, mark).has('voice/cf_self'), '同句播 cf_self(和自己比不出矛盾)');
|
||
|
||
assert(game.run.cfMiss === 3, `cfMiss=3(三档 miss 各计一次,实际 ${game.run.cfMiss})`);
|
||
assert(game.run.doubts.filter(Boolean).length === 1 && game.run.zHits === 1, 'miss 不动 doubts/zHits');
|
||
|
||
// 无资源/次数惩罚:miss 后仍可继续指证与听取
|
||
assert(await listenItem(game, 'fore'), 'miss 后听取照常(无惩罚性失败)');
|
||
game.stop();
|
||
}
|
||
|
||
// ================================ 场景 7+8:结案三问 + VERDICT 死窗(§11.7/11.8) ================================
|
||
|
||
async function scenario7() {
|
||
origConsoleLog('\n=== 场景 7+8:错指不在场证明 / 三问 / VERDICT 死窗+链锁 / 接新案翻包 ===');
|
||
storageMap.clear();
|
||
const { game, audio } = newGame({ seed: 42 });
|
||
game.start();
|
||
await tick(400);
|
||
g(game, 'tap');
|
||
assert(await skipDrill(game), '跳教学进板');
|
||
const cs = game._case;
|
||
|
||
// 四疑点(评级账目素材)
|
||
for (const [a, b] of [['z_2', 'wen_2'], ['z_3', 'w2_3'], ['z_4', 'fo_3'], ['z_4', 'sc_4']]) {
|
||
assert(await confront(game, cs, a, b), `指证 ${a} vs ${b}`);
|
||
}
|
||
assert(game._doubtCount() === 4, '四疑点齐');
|
||
|
||
// 错指小顺(optIdx 1)→ acc_shun + fin_clear + st_wrong → 回 BOARD(无限再机会)
|
||
let mark = audio.log.length;
|
||
// R2 F5:结案确认闸——首击只播 fin_pre 不进 FINISH;滑走撤闸;闸内再击才进
|
||
await navToItem(game, 'case');
|
||
g(game, 'tap');
|
||
await tick(200);
|
||
assert(game.state === 'BOARD' && game._caseGate === true && plays(audio, mark).has('voice/fin_pre'),
|
||
'R2 F5:结案陈词首击=确认闸(fin_pre),仍 BOARD');
|
||
g(game, 'left'); // 滑走撤闸
|
||
await tick(200);
|
||
assert(game._caseGate === false && game.state === 'BOARD', 'R2 F5:滑走撤闸留板(继续查)');
|
||
await enterFinish(game);
|
||
assert(plays(audio, mark).orderOk('voice/fin_pre', 'voice/fin_start'), '闸内再击 → FINISH(fin_pre → fin_start)');
|
||
await waitState(() => game._fin && game._fin.phase === 'who', 15000);
|
||
mark = audio.log.length;
|
||
g(game, 'right');
|
||
await tick(650);
|
||
assert(plays(audio, mark).has('voice/item_who_w2'), 'R2 F4:真凶问右滑播人名(item_who_w2「学徒小顺。」),不再念卷宗名');
|
||
g(game, 'tap');
|
||
await tick(32000); // 两段确认链(重播人名 + fin_who_confirm)走完
|
||
assert(game._fin?.phase === 'whoConfirm' && plays(audio, mark).has('voice/fin_who_confirm'),
|
||
'真凶问两段确认(重播人名 + fin_who_confirm「就指认这位?」——R2 F4 中性化)');
|
||
g(game, 'tap');
|
||
assert(await waitState(() => game.state === 'BOARD', 60000), '错指 → 回 BOARD');
|
||
assert(plays(audio, mark).orderOk('voice/acc_shun', 'voice/fin_clear', 'voice/st_wrong'),
|
||
'错指链:acc_shun(不在场证明自述)→ fin_clear → st_wrong(R2 F9 当场记账)');
|
||
assert(game.run.wrongWho === 1, 'wrongWho 计数');
|
||
|
||
// 错指老葛(optIdx 0,无需滑动)→ acc_ge(无限再机会)
|
||
await enterFinish(game);
|
||
await waitState(() => game._fin && game._fin.phase === 'who', 15000);
|
||
mark = audio.log.length;
|
||
g(game, 'tap');
|
||
await tick(200);
|
||
g(game, 'tap');
|
||
assert(await waitState(() => game.state === 'BOARD', 60000), '再错指仍回 BOARD(无限再机会)');
|
||
assert(plays(audio, mark).has('voice/acc_ge'), '错指老葛 → acc_ge');
|
||
assert(game.run.wrongWho === 2, 'wrongWho=2');
|
||
|
||
// FINISH 长按 = error(§11 边界)
|
||
await enterFinish(game);
|
||
await waitState(() => game._fin && game._fin.phase === 'who', 15000);
|
||
const vib = vibrateLog.length;
|
||
g(game, 'longpress');
|
||
await tick(200);
|
||
assert(vibratedError(vib), 'FINISH 长按 error 震');
|
||
|
||
// 正指周先生(optIdx 2)→ 凶器问(选项序=layout)→ 动机问 → VERDICT
|
||
g(game, 'right');
|
||
g(game, 'right');
|
||
await tick(650);
|
||
assert(plays(audio, mark).has('voice/item_who_w3'), '滑到周先生(item_who_w3,R2 F4 人名)');
|
||
mark = audio.log.length;
|
||
g(game, 'tap');
|
||
await tick(200);
|
||
g(game, 'tap'); // 正式指认
|
||
assert(await waitState(() => game._fin && game._fin.phase === 'weapon', 45000), '指认对 → 凶器问');
|
||
assert(plays(audio, mark).has('voice/fin_ask_weapon'), '凶器问入口 fin_ask_weapon');
|
||
await tick(17000); // 问句链 + busy 解除 + 首选项报名落声
|
||
// 凶器 4 选项全可预览(左右滑一次听一项,选项序=layout.wpOpts)
|
||
for (let i = 0; i < 4; i++) { g(game, 'right'); await tick(300); }
|
||
{
|
||
const seg = plays(audio, mark);
|
||
const heard = cs.layout.wpOpts.filter((id) => seg.has('voice/' + id));
|
||
assert(heard.length === 4, `凶器 4 选项全可预览(${heard.join(',')},序=${cs.layout.wpOpts.join(',')})`);
|
||
}
|
||
// 答对 wp_2(滑到其 layout 下标;确认选项即答题——凶器问无两段确认)
|
||
const wpIdx = cs.layout.wpOpts.indexOf('wp_2');
|
||
for (let i = 0; i < wpIdx; i++) g(game, 'right');
|
||
await tick(300);
|
||
g(game, 'tap');
|
||
assert(await waitState(() => game._fin && game._fin.phase === 'motive', 45000), '凶器答完 → 动机问');
|
||
assert(plays(audio).has('voice/fin_wp_right') && !plays(audio).has('voice/fin_wp_wrong'), '答对凶器播 fin_wp_right');
|
||
assert(game.run.weaponAns === 'wp_2', 'weaponAns 入账');
|
||
await tick(17000); // 问句链 + busy 解除
|
||
// 动机答错 mv_2 → fin_mv_wrong(真话归因,不重答)→ VERDICT
|
||
const mvIdx = cs.layout.mvOpts.indexOf('mv_2');
|
||
for (let i = 0; i < mvIdx; i++) g(game, 'right');
|
||
await tick(300);
|
||
mark = audio.log.length;
|
||
g(game, 'tap');
|
||
assert(await waitState(() => game.state === 'VERDICT', 45000), '动机答完 → VERDICT');
|
||
assert(plays(audio, mark).has('voice/fin_mv_wrong'), '动机答错播 fin_mv_wrong(不重答,记账)');
|
||
|
||
// ---- 场景 8:VERDICT 死窗 + 链锁(§7.4:dungeon H1 同款) ----
|
||
g(game, 'tap');
|
||
assert(game.state === 'VERDICT', 'H1: 指认对瞬间同拍在途 tap 被死窗吞掉(仍 VERDICT)');
|
||
await tick(300);
|
||
g(game, 'tap');
|
||
assert(game.state === 'VERDICT', 'H1: +300ms 在途 tap 同样被吞(引擎双击窗回调延迟)');
|
||
g(game, 'up');
|
||
g(game, 'down');
|
||
g(game, 'doubletap');
|
||
g(game, 'right');
|
||
g(game, 'longpress');
|
||
assert(game.state === 'VERDICT', '链内上滑/下滑/双击/右滑/长按全部吞掉(链锁)');
|
||
const settleDone = await waitState(() => game.state === 'SETTLE', 220000);
|
||
assert(settleDone, 'VERDICT 全链播完 → SETTLE');
|
||
const p = plays(audio, mark);
|
||
assert(p.orderOk('voice/fin_right', 'voice/z_break_1', 'voice/z_break_2', 'voice/z_break_3', 'sfx/gavel'),
|
||
'认罪链:fin_right → z_break_1..3 → gavel(完整有序,未被手势截杀)');
|
||
assert(p.has('sfx/rain_swell'), '认罪链垫 rain_swell');
|
||
assert(p.orderOk('voice/settle_open', 'voice/rate_b', 'voice/st_doubt_4', 'voice/st_miss_0', 'voice/settle_hook'),
|
||
'结算链:settle_open → rate_b(wrongWho=2→B 档)→ st_doubt_4 → st_miss_0 → settle_hook(播报值=运行值)');
|
||
assert(game.meta.casesClosed === 1 && game.meta.bestGrade === 'B', '元进度入账 casesClosed=1 bestGrade=B');
|
||
assert(game.run === null, '结案清 run(summary 切已结案口径)');
|
||
{
|
||
const save = JSON.parse(storageMap.get('soundgame.progress.detective'));
|
||
assert(save.summary === '已结案1起 · 最佳B', `结案 summary 规则("${save.summary}")`);
|
||
}
|
||
|
||
// 链后:单击=接新案(pack 翻转、seed 重掷——settle_hook 言行一致)
|
||
mark = audio.log.length;
|
||
g(game, 'tap');
|
||
const fresh = await waitState(() => game.state === 'BOARD' && game.run && game.run.pack === 1 && game.run.seed !== 42, 30000);
|
||
assert(fresh, 'SETTLE 单击=接新案(pack 0→1 强制翻转、seed 重掷、drillDone 不再教学)');
|
||
assert(plays(audio, mark).has('voice/sys_new_case'), '新案播 sys_new_case');
|
||
assert(game._case.contradictions[0].a === 'b_shun_1', '新案即 pack B(真凶=小顺)——二周目必遇变体');
|
||
game.stop();
|
||
}
|
||
|
||
// ================================ 场景 9:评级账目矩阵(§7.2/§11.9) ================================
|
||
|
||
async function scenario9() {
|
||
origConsoleLog('\n=== 场景 9:评级六变量条件式注入矩阵 + 结算链选句与运行值一致 ===');
|
||
const { game } = newGame({});
|
||
const cases = [
|
||
// [doubts, weaponOk, motiveOk, wrongWho, cfMiss, hints, expect]
|
||
[4, true, true, 0, 0, 0, 'S'], // 教科书结案
|
||
[4, true, true, 0, 2, 0, 'S'], // cfMiss 恰在阈值内(≤2)
|
||
[4, true, true, 0, 3, 0, 'A'], // cfMiss 超阈值降 A
|
||
[4, true, true, 1, 0, 0, 'A'], // 错指一次降 A
|
||
[3, true, true, 1, 9, 1, 'A'], // A 档边界(hints=1 wrongWho=1)
|
||
[4, true, true, 0, 0, 1, 'A'], // 一次保底提示即失 S
|
||
[4, true, false, 1, 0, 0, 'B'], // 动机错失 A
|
||
[3, true, true, 2, 0, 0, 'B'], // wrongWho=2 失 A
|
||
[2, false, false, 2, 9, 9, 'B'], // B 档下边界(疑点 2 + 错指 2)
|
||
[1, true, true, 0, 0, 0, 'C'], // 疑点 <2 → C
|
||
[4, false, false, 3, 0, 0, 'C'], // 错指 ≥3 → C(全靠保底也能结案)
|
||
[0, false, false, 5, 5, 5, 'C'], // C 兜底(案子必然已结)
|
||
];
|
||
for (const [doubts, weaponOk, motiveOk, wrongWho, cfMiss, hints, expect] of cases) {
|
||
const o = { doubts, weaponOk, motiveOk, wrongWho, cfMiss, hints };
|
||
const got = game._gradeOf(o);
|
||
assert(got === expect, `评级注入 {d=${doubts},wp=${+weaponOk},mv=${+motiveOk},w=${wrongWho},miss=${cfMiss},h=${hints}} → ${expect}(实际 ${got})`);
|
||
const vs = game._settlePartVoices(o);
|
||
const rateId = 'voice/rate_' + expect.toLowerCase();
|
||
const missBand = cfMiss === 0 ? 0 : cfMiss <= 2 ? 1 : 2;
|
||
assert(vs.includes(rateId) && vs.includes('voice/st_doubt_' + doubts) && vs.includes('voice/st_miss_' + missBand),
|
||
`结算段选句与账目一致(${rateId} + st_doubt_${doubts} + st_miss_${missBand})`);
|
||
assert(vs[0] === 'voice/settle_open' && vs[4] === 'voice/settle_hook', '结算段骨架 settle_open → … → settle_hook');
|
||
}
|
||
ok(`评级矩阵 ${cases.length} 组全对(六变量边界:cfMiss 2/3、wrongWho 0/1/2/3、hints 0/1、doubts 0..4)`);
|
||
}
|
||
|
||
// ================================ 场景 10:看门狗双轨(§9/§11.10) ================================
|
||
|
||
async function scenario10() {
|
||
origConsoleLog('\n=== 场景 10:无操作轨(guide=full)+ 无进展轨 45/90/150(乱按必被抢救) ===');
|
||
|
||
// ---- A. 无操作轨:BOARD 10s/25s、CONFRONT 15s、FINISH 20s(guide=full) ----
|
||
storageMap.clear();
|
||
settings.set({ guide: 'full' });
|
||
let a = null;
|
||
try {
|
||
a = newGame({ seed: 42 });
|
||
a.game.start();
|
||
await tick(400);
|
||
g(a.game, 'tap');
|
||
assert(await skipDrill(a.game), '跳教学进板(guide=full)');
|
||
let mark = a.audio.log.length;
|
||
await tick(11000);
|
||
assert(plays(a.audio, mark).has('voice/gd_board'), '无操作轨:BOARD 10s 播 gd_board');
|
||
await tick(15000);
|
||
assert(plays(a.audio, mark).countRe(/^voice\/gd_board$/) === 1, '25s 第 2 级纯 nudge(不重复播句)');
|
||
// CONFRONT 15s
|
||
g(a.game, 'longpress');
|
||
await waitState(() => a.game.state === 'CONFRONT', 5000);
|
||
mark = a.audio.log.length;
|
||
await tick(16000);
|
||
assert(plays(a.audio, mark).has('voice/gd_cf'), '无操作轨:CONFRONT 15s 播 gd_cf');
|
||
g(a.game, 'down'); // 菜单 → 「返回证词板」退出指证
|
||
await tick(10000);
|
||
g(a.game, 'doubletap');
|
||
await waitState(() => a.game.state === 'BOARD', 15000);
|
||
// R2 F6:六份听齐且疑点未齐时,BOARD 一级提示换 gd_board_cf
|
||
a.game.run.heard = [...LISTEN_ITEMS]; // 测试钩子:六份听齐(真实路径由场景 4 覆盖)
|
||
g(a.game, 'right'); // 手势触发重挂(一级提示词按局面重取)
|
||
await tick(11000);
|
||
assert(plays(a.audio, mark).has('voice/gd_board_cf'), 'R2 F6:听齐未破时 BOARD 一级=gd_board_cf(不再教「换卷宗」)');
|
||
a.game.run.heard = []; // 还原,免污染后续断言
|
||
// FINISH 20s
|
||
await enterFinish(a.game);
|
||
await waitState(() => a.game._fin && a.game._fin.phase === 'who', 15000);
|
||
mark = a.audio.log.length;
|
||
await tick(21000);
|
||
assert(plays(a.audio, mark).has('voice/gd_fin'), '无操作轨:FINISH 20s 播 gd_fin');
|
||
} finally {
|
||
if (a) a.game.stop();
|
||
settings.set({ guide: 'off' });
|
||
}
|
||
|
||
// ---- B. 无进展轨:乱按 156s 零进展 → 45s earcon / 90s 短句 / 150s 保底矛盾对 ----
|
||
storageMap.clear();
|
||
const { game, audio } = newGame({ seed: 42 });
|
||
game.start();
|
||
await tick(400);
|
||
g(game, 'tap');
|
||
assert(await skipDrill(game), '跳教学进板');
|
||
const t0 = vnow;
|
||
// 乱按轮转(与语音时长解耦):起停对 = 同拍双 tap——起播与停止零时距,任何语音
|
||
// 时长下一句都不可能完成 = 确定性零进展(「听完新条目=进展」是设计内行为,测试
|
||
// 不得让它发生);其余为换条目滑动/重听噪声;不含 longpress(不进指证);到
|
||
// case 条目强制左滑(不误入 FINISH)。每轮转歇一口气——给等空闲拍的句子级
|
||
// 提示(L2/L3)留落点。
|
||
const ROT = ['pair', 'right', 'left', 'doubletap', 'left', 'right'];
|
||
let ri = 0;
|
||
const m0 = audio.log.length;
|
||
const heard0 = game.run.heard.length;
|
||
while (vnow - t0 < 156000) {
|
||
const gg = ROT[ri % ROT.length];
|
||
const atCase = game.itemIdx === ITEM_ORDER.length - 1;
|
||
if (gg === 'pair' && !atCase) {
|
||
g(game, 'tap'); // 起播
|
||
g(game, 'tap'); // 同拍停止——句完成定时器永不可达(时长无关)
|
||
} else if (atCase) {
|
||
g(game, 'left');
|
||
} else {
|
||
g(game, gg);
|
||
}
|
||
await tick(ri++ % ROT.length === ROT.length - 1 ? 3600 : 1100);
|
||
}
|
||
assert(game.run.heard.length === heard0, `乱按 156s 零进展(heard 不变=${heard0},无操作轨被手势持续重置不轰炸)`);
|
||
const seg = plays(audio, m0);
|
||
const tick45 = seg.entryOf('sfx/turn_tick');
|
||
assert(!!tick45 && tick45.t - t0 >= 45000 && tick45.t - t0 < 60000,
|
||
`无进展 45s 级 earcon(实测 +${tick45 ? Math.round((tick45.t - t0) / 1000) : '未触发'}s)`);
|
||
const hint90 = seg.entries().find((e) => e.id === 'voice/hint_a' || e.id === 'voice/hint_b');
|
||
assert(!!hint90 && hint90.t - t0 >= 90000 && hint90.t - t0 < 105000,
|
||
`无进展 90s 级短句(实测 +${hint90 ? Math.round((hint90.t - t0) / 1000) : '未触发'}s)`);
|
||
assert(hint90?.id === 'voice/hint_a', '90s 级选句真实:有未听条目播 hint_a');
|
||
const l3 = seg.entries().find((e) => /^voice\/(hint_c1|hint_c2)$/.test(e.id));
|
||
assert(!!l3 && l3.t - t0 >= 150000 && l3.t - t0 < 165000,
|
||
`无进展 150s 级保底矛盾对(${l3 ? l3.id : '未触发'},实测 +${l3 ? Math.round((l3.t - t0) / 1000) : '未触发'}s)`);
|
||
assert(game.run.hintsGiven.length >= 2, `保底提示入账 hintsGiven(${game.run.hintsGiven.join(',') || '无'})`);
|
||
// 保底 hint 给出的矛盾对可走通指证(失败即不通过项)
|
||
if (l3) {
|
||
const hintText = l3.id === 'voice/hint_c1' ? 'm1' : 'm4';
|
||
const contra = game._case.contradictions.find((c) => c.id === hintText);
|
||
const idxC = game._case.contradictions.indexOf(contra);
|
||
assert(await confront(game, game._case, contra.a, contra.b), '保底 hint 对走通指证');
|
||
assert(game.run.doubts[idxC] === true, `保底 hint 对命中(${hintText} 置疑点)`);
|
||
}
|
||
game.stop();
|
||
|
||
// ---- C. 有真实进展零提示 ----
|
||
storageMap.clear();
|
||
const c2 = newGame({ seed: 45 });
|
||
c2.game.start();
|
||
await tick(400);
|
||
g(c2.game, 'tap');
|
||
assert(await skipDrill(c2.game), '跳教学进板');
|
||
{
|
||
const mC = c2.audio.log.length;
|
||
for (const id of LISTEN_ITEMS) assert(await listenItem(c2.game, id), `真实进展:听取 ${id}`);
|
||
assert(!plays(c2.audio, mC).has('sfx/turn_tick') && !plays(c2.audio, mC).hasRe(/^voice\/hint_/),
|
||
'持续听完新条目(真实进展)零提示零 earcon(不误伤正常玩家)');
|
||
}
|
||
c2.game.stop();
|
||
|
||
// ---- D. 4 疑点齐后 150s 无进展 → hint_fin(去结案,计入保底账) ----
|
||
storageMap.clear();
|
||
const d2 = newGame({ seed: 46 });
|
||
d2.game.start();
|
||
await tick(400);
|
||
g(d2.game, 'tap');
|
||
assert(await skipDrill(d2.game), '跳教学进板');
|
||
d2.game.run.doubts = [true, true, true, true]; // 测试钩子:疑点已齐(真实路径由场景 5 覆盖)
|
||
const mD = d2.audio.log.length;
|
||
await tick(156000);
|
||
assert(plays(d2.audio, mD).has('voice/hint_fin'), '4 疑点齐 + 150s 无进展 → hint_fin(去结案)');
|
||
assert(d2.game.run.hintsGiven.includes('hint_fin'), '看门狗 hint_fin 计入保底提示账(与 4/4 庆祝播报区分)');
|
||
d2.game.stop();
|
||
}
|
||
|
||
// ================================ 场景 11:中断复述 + TITLE 右滑换新案(§10/§11.11) ================================
|
||
|
||
async function scenario11() {
|
||
origConsoleLog('\n=== 场景 11:存档重进 → 复述链(未听 ≤3 名直报)+ 上滑同链重放 ===');
|
||
storageMap.clear();
|
||
const a1 = newGame({ seed: 55 });
|
||
a1.game.start();
|
||
await tick(400);
|
||
g(a1.game, 'tap');
|
||
assert(await skipDrill(a1.game), '跳教学进板');
|
||
for (const id of ['w1', 'scene', 'fore']) assert(await listenItem(a1.game, id), `听取 ${id}`);
|
||
a1.game._save();
|
||
a1.game.stop();
|
||
|
||
const a2 = newGame({});
|
||
a2.game.start();
|
||
await tick(400);
|
||
assert(a2.game.state === 'TITLE' && plays(a2.audio).has('voice/sys_continue_ask'), '重进 TITLE 播 sys_continue_ask');
|
||
let mark = a2.audio.log.length;
|
||
g(a2.game, 'tap');
|
||
await waitState(() => a2.game.state === 'BOARD', 30000);
|
||
assert(a2.game.run.seed === 55 && a2.game.run.pack === 0, '读档还原 (seed,pack)');
|
||
assert(JSON.stringify(a2.game._case.layout) === JSON.stringify(a1.game._case.layout), '读档 layout 复用存档值(不重洗)');
|
||
const p = plays(a2.audio, mark);
|
||
await tick(100000); // 复述链 6 句(缺失时 15s/句兜底)整链走完
|
||
const pAfter = plays(a2.audio, mark);
|
||
assert(pAfter.orderOk('voice/st_asked_3', 'voice/item_w2', 'voice/item_w3', 'voice/item_w4', 'voice/st_doubt_0', 'voice/recap_go'),
|
||
'复述链序:st_asked_3 → 未听 3 名直报(item_w2/w3/w4)→ st_doubt_0 → recap_go(未听齐时尾句不变)');
|
||
assert(a2.game.run.heard.length === 3, 'heard 还原 3 份');
|
||
mark = a2.audio.log.length;
|
||
g(a2.game, 'up');
|
||
await tick(100000);
|
||
assert(plays(a2.audio, mark).orderOk('voice/st_asked_3', 'voice/item_w2', 'voice/st_doubt_0'),
|
||
'上滑同链可重放(中断复述链=状态链同构)');
|
||
a2.game.stop();
|
||
|
||
// TITLE 右滑 = 换新案(seed/pack 重掷,meta 保留)
|
||
const a3 = newGame({});
|
||
a3.game.start();
|
||
await tick(400);
|
||
assert(plays(a3.audio).has('voice/sys_continue_ask'), '实例 3 仍在 TITLE 询问继续');
|
||
const oldSeed = JSON.parse(storageMap.get('soundgame.progress.detective')).run.seed;
|
||
mark = a3.audio.log.length;
|
||
g(a3.game, 'right');
|
||
const fresh = await waitState(() => a3.game.state === 'BOARD' && a3.game.run && a3.game.run.seed !== oldSeed, 30000);
|
||
assert(fresh, 'TITLE 右滑 → 新案(seed 重掷)');
|
||
assert(plays(a3.audio, mark).has('voice/sys_new_case'), '新案播 sys_new_case');
|
||
assert(!a3.game.run.heard.includes('w1'), '新案 heard 清空(meta 保留 drillDone 不再教学)');
|
||
a3.game.stop();
|
||
}
|
||
|
||
// ================================ 场景 12:二周目变体 pack B(§11.12) ================================
|
||
|
||
async function scenario12() {
|
||
origConsoleLog('\n=== 场景 12:pack B 变体——真凶小顺 / acc_zhou / b_shun 链 / b_hint ===');
|
||
storageMap.clear();
|
||
const { game, audio } = newGame({ seed: 100, pack: 1 });
|
||
game.start();
|
||
await tick(400);
|
||
g(game, 'tap');
|
||
assert(await skipDrill(game), '跳教学进板');
|
||
const cs = game._case;
|
||
assert(cs.pack === 1 && cs.culprit === 'w2' && cs.culpritName === '小顺', 'pack B 真凶=小顺(culprit=w2)');
|
||
assert(cs.contradictions.map((c) => c.id).join(',') === 'm1b,m2b,m3b,m4b', '矛盾表 = m1b..m4b');
|
||
assert(cs.contradictions.every((c) => ['b_shun_1', 'b_shun_2', 'b_shun_3'].includes(c.a)), '矛盾真凶侧 = b_shun_*');
|
||
assert(cs.contradictions[1].a === 'b_shun_2' && cs.contradictions[1].b === 'b_zhou_2', 'M2b = b_shun_2 vs b_zhou_2');
|
||
assert(cs.verdict.accuseLine === 'fin_right_b' && cs.verdict.break[0] === 'b_shun_break_1', '认罪链 = fin_right_b + b_shun_break_*');
|
||
assert(cs.wrongAccuse.w3 === 'acc_zhou', '错指周先生走 acc_zhou');
|
||
assert(matrixInvariant(cs).ok, 'pack B 矩阵不变式复验');
|
||
|
||
// M1b 指证(b_shun_1 vs wen_2)→ b_shun_react_1
|
||
let mark = audio.log.length;
|
||
assert(await confront(game, cs, 'b_shun_1', 'wen_2'), 'M1b 指证完成');
|
||
assert(plays(audio, mark).has('voice/cf_hit') && plays(audio, mark).has('voice/b_shun_react_1'),
|
||
'M1b hit → b_shun_react_1');
|
||
|
||
// 错指周先生(optIdx 2)→ acc_zhou + fin_clear
|
||
await enterFinish(game);
|
||
await waitState(() => game._fin && game._fin.phase === 'who', 15000);
|
||
mark = audio.log.length;
|
||
g(game, 'right');
|
||
g(game, 'right');
|
||
await tick(650);
|
||
g(game, 'tap');
|
||
await tick(200);
|
||
g(game, 'tap');
|
||
assert(await waitState(() => game.state === 'BOARD', 60000), '错指周回 BOARD');
|
||
assert(plays(audio, mark).orderOk('voice/acc_zhou', 'voice/fin_clear', 'voice/st_wrong'),
|
||
'错指周先生 → acc_zhou(不在场证明)+ fin_clear + st_wrong 记账(R2 F9)');
|
||
|
||
// 保底提示 = b_hint_c1/c2(m1b 已发现 → 轮询到 m4b)
|
||
game.run.doubts = [true, false, false, false]; // 钩子:仅 m1b 已发现(真实路径同上)
|
||
const mHint = audio.log.length;
|
||
await tick(156000);
|
||
const bh = plays(audio, mHint).entries().find((e) => /^voice\/b_hint_c/.test(e.id));
|
||
assert(bh, `pack B 保底提示走 b_hint_c*(${bh ? bh.id : '无'})`);
|
||
|
||
// 正指小顺 → 凶器对 → 动机对 → VERDICT(b_shun_break 链)
|
||
await enterFinish(game);
|
||
await waitState(() => game._fin && game._fin.phase === 'who', 15000);
|
||
g(game, 'right'); // optIdx 1 = w2 小顺
|
||
await tick(650);
|
||
g(game, 'tap');
|
||
await tick(200);
|
||
g(game, 'tap');
|
||
assert(await waitState(() => game._fin && game._fin.phase === 'weapon', 45000), '指认小顺 → 凶器问');
|
||
await tick(17000); // 问句链 + busy 解除
|
||
const wpIdx = cs.layout.wpOpts.indexOf('wp_2');
|
||
for (let i = 0; i < wpIdx; i++) g(game, 'right');
|
||
await tick(300);
|
||
g(game, 'tap');
|
||
assert(await waitState(() => game._fin && game._fin.phase === 'motive', 45000), '凶器对 → 动机问');
|
||
await tick(17000); // 问句链 + busy 解除
|
||
const mvIdx = cs.layout.mvOpts.indexOf('mv_1');
|
||
for (let i = 0; i < mvIdx; i++) g(game, 'right');
|
||
await tick(300);
|
||
mark = audio.log.length;
|
||
g(game, 'tap');
|
||
assert(await waitState(() => game.state === 'VERDICT', 45000), '三问齐 → VERDICT');
|
||
assert(await waitState(() => game.state === 'SETTLE', 220000), '结算链完整(链锁)');
|
||
const p = plays(audio, mark);
|
||
assert(p.orderOk('voice/fin_right_b', 'voice/b_shun_break_1', 'voice/b_shun_break_2', 'voice/b_shun_break_3', 'sfx/gavel'),
|
||
'pack B 认罪链:fin_right_b → b_shun_break_1..3 → gavel');
|
||
assert(plays(audio, mark).has('voice/settle_hook'), 'settle_hook 落声');
|
||
g(game, 'tap');
|
||
assert(await waitState(() => game.state === 'BOARD' && game.run && game.run.pack === 0, 30000), '接新案 pack 1→0 翻转');
|
||
game.stop();
|
||
}
|
||
|
||
// ================================ 场景 13:键盘变体路由(V 组,§11.13) ================================
|
||
|
||
async function scenario13() {
|
||
origConsoleLog('\n=== 场景 13:settings.input 联动(游戏代码用普通 id,_k 变体引擎路由) ===');
|
||
const { inputMode } = await import('../../engine/gesture.js');
|
||
assert(inputMode() === 'touch', '手机 persona 默认 touch');
|
||
const { WebAudioEngine } = await import('../../engine/audio.js');
|
||
const eng = new WebAudioEngine('audio/');
|
||
{
|
||
settings.set({ input: 'key' });
|
||
assert(inputMode() === 'key', "settings.input='key' → inputMode()=key");
|
||
const cand = eng._candidates('voice/sys_help');
|
||
assert(cand[0].includes('/voice/female/key/sys_help.'), `key 模式候选链前置 key/ 变体(${cand[0]})`);
|
||
assert(cand.some((x) => x.includes('/voice/female/sys_help.')), 'key 变体 404 后自然落回主资产');
|
||
}
|
||
{
|
||
settings.set({ input: 'touch' });
|
||
assert(inputMode() === 'touch', "回 'touch' → 基础句");
|
||
const cand = eng._candidates('voice/settle_hook');
|
||
assert(cand[0].includes('/voice/female/settle_hook.'), `touch 模式直接主资产(${cand[0]})`);
|
||
assert(!cand.some((x) => x.includes('/key/')), 'touch 模式无 key/ 候选');
|
||
}
|
||
// 游戏代码零 _k 硬编码(平台约定:提示句 _k 变体引擎自动路由,游戏用普通 id)
|
||
const src = fs.readFileSync(path.join(DETECTIVE_DIR, 'game.js'), 'utf8');
|
||
assert(!/'[a-z0-9_]+_k'/.test(src), 'game.js 无 _k 后缀 id 硬编码(V 组由引擎路由)');
|
||
}
|
||
|
||
// ================================ 场景 14:菜单/帮助/存档(§11.14) ================================
|
||
|
||
async function scenario14() {
|
||
origConsoleLog('\n=== 场景 14:模态菜单循环-确认-关闭 / 去指证=长按同效 / 保存 PUT summary ===');
|
||
storageMap.clear();
|
||
storageMap.set('soundgame.auth', JSON.stringify({ token: 'test-token' }));
|
||
const { game, audio } = newGame({ seed: 42 });
|
||
game.start();
|
||
await tick(400);
|
||
g(game, 'tap');
|
||
assert(await skipDrill(game), '跳教学进板');
|
||
assert(await listenItem(game, 'w1'), '听取 w1(summary 素材)');
|
||
|
||
let mark = audio.log.length;
|
||
g(game, 'down');
|
||
await tick(32000); // sys_menu + 首项(15s/句兜底余量)
|
||
assert(game.menuOpen, '下滑呼出模态菜单');
|
||
const p0 = plays(audio, mark);
|
||
assert(p0.has('voice/sys_menu') && p0.has('voice/sys_menu_resume'), '菜单开场:sys_menu + 首项 resume');
|
||
// 帮助(上滑)
|
||
mark = audio.log.length;
|
||
g(game, 'up');
|
||
await tick(32000);
|
||
assert(plays(audio, mark).orderOk('voice/sys_help', 'voice/settings_hint'), '菜单上滑=帮助链(sys_help + settings_hint)');
|
||
// 循环切项:单击 resume→cf→save→help→restart→resume(5 项循环回绕)
|
||
mark = audio.log.length;
|
||
for (let i = 0; i < 5; i++) { g(game, 'tap'); await tick(300); }
|
||
const pC = plays(audio, mark);
|
||
assert(pC.has('voice/sys_menu_cf') && pC.has('voice/sys_menu_save') && pC.has('voice/sys_menu_restart')
|
||
&& pC.has('voice/sys_menu_help') && pC.count('voice/sys_menu_resume') === 1,
|
||
'单击循环切项(5 项回绕,每切播项名)');
|
||
// 「去指证」双击确认 = 长按同效(P4 冗余入口)——当前项恰回 resume,切一格到 cf
|
||
g(game, 'tap'); // → cf
|
||
await tick(300);
|
||
mark = audio.log.length;
|
||
g(game, 'doubletap');
|
||
assert(await waitState(() => game.state === 'CONFRONT', 25000), '菜单「去指证」进 CONFRONT');
|
||
assert(plays(audio, mark).has('voice/cf_start'), '去指证 = 长按同效(cf_start 同点)');
|
||
// 「返回证词板」退出
|
||
g(game, 'down');
|
||
await tick(32000);
|
||
mark = audio.log.length;
|
||
g(game, 'doubletap'); // resume(=返回证词板)
|
||
assert(await waitState(() => game.state === 'BOARD', 15000), '菜单「返回证词板」关闭回板');
|
||
|
||
// 保存并回大厅:PUT data.summary 命中生成规则
|
||
const fetch0 = fetchLog.length;
|
||
g(game, 'down');
|
||
await tick(32000); // 开菜单(idx=0 resume)
|
||
g(game, 'tap'); // → cf
|
||
await tick(300);
|
||
g(game, 'tap'); // → save
|
||
await tick(300);
|
||
mark = audio.log.length;
|
||
g(game, 'doubletap'); // 确认保存
|
||
assert(await waitState(() => plays(audio, mark).has('voice/sys_saved'), 25000), '确认保存播 sys_saved');
|
||
const put = fetchLog.slice(fetch0).find((f) => f.url === '/api/progress' && f.opts && f.opts.method === 'PUT');
|
||
assert(put, '保存触发 PUT /api/progress');
|
||
if (put) {
|
||
const body = JSON.parse(put.opts.body);
|
||
assert(body.game === 'detective' && body.data.schema === 1, 'PUT body game=detective schema=1');
|
||
assert(body.data.summary === '雨夜·聚宝斋 · 已问1/6 · 疑点0/4',
|
||
`summary 命中生成规则("${body.data.summary}")`);
|
||
}
|
||
assert(await waitState(() => window.location.href === '../', 15000), '保存并回大厅跳转');
|
||
game.stop();
|
||
}
|
||
|
||
// ================================ 资产引用报告(信息性,不判失败) ================================
|
||
|
||
function assetReport() {
|
||
origConsoleLog('\n=== 资产引用报告(并行生产期:缺失不判失败,齐套后应为 0) ===');
|
||
const missing = new Set();
|
||
const allIds = new Set();
|
||
for (const inst of audioRegistry) {
|
||
for (const e of inst.log) {
|
||
allIds.add(e.id);
|
||
if (e.missing) missing.add(e.id);
|
||
}
|
||
}
|
||
const femDir = path.join(DETECTIVE_DIR, 'audio', 'voice', 'female');
|
||
const maleDir = path.join(DETECTIVE_DIR, 'audio', 'voice', 'male');
|
||
if (fs.existsSync(femDir) && fs.existsSync(maleDir)) {
|
||
const idsOf = (d) => new Set(fs.readdirSync(d).filter((f) => f.endsWith('.ogg')).map((f) => f.replace(/\.ogg$/, '')));
|
||
const fem = idsOf(femDir);
|
||
const male = idsOf(maleDir);
|
||
const same = fem.size === male.size && [...fem].every((id) => male.has(id));
|
||
ok(`voice 双性别目录集合${same ? '一致' : '不一致'}(female ${fem.size} / male ${male.size})`);
|
||
} else {
|
||
ok('voice 双性别目录未落盘(资产 agent 并行生产中)');
|
||
}
|
||
ok(`运行期引用 id 共 ${allIds.size} 个,其中缺失 ${missing.size} 个${missing.size ? '(每句 15s 兜底全链推进,流程未卡死——§4.3 契约)' : ''}`);
|
||
}
|
||
|
||
// ================================ 主流程 ================================
|
||
|
||
try {
|
||
await scenario1();
|
||
await scenario2();
|
||
await scenario3();
|
||
await scenario4();
|
||
await scenario5();
|
||
await scenario6();
|
||
await scenario7();
|
||
await scenario9();
|
||
await scenario10();
|
||
await scenario11();
|
||
await scenario12();
|
||
await scenario13();
|
||
await scenario14();
|
||
} catch (e) {
|
||
failures.push(`未捕获异常: ${e.stack}`);
|
||
}
|
||
assetReport();
|
||
|
||
origConsoleLog('\n========================================');
|
||
origConsoleLog(`断言通过 ${passCount} 条,失败 ${failures.length} 条`);
|
||
if (failures.length) {
|
||
origConsoleLog('失败清单:');
|
||
for (const f of failures) origConsoleLog(` ✗ ${f}`);
|
||
}
|
||
process.exit(failures.length ? 1 : 0);
|