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

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

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

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

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

411 lines
23 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 《雨夜专案》新手会话剧本(26 号重构版:叙事+听证词型,storyline/radio 范式)。
*
* 剧本:
* - `session`(主线):一位新手探长的完整破案弧线——开场第一句「点一下屏幕」就
* 动手,教学寄生于真实案卷(听老葛 → 教右滑+解锁上滑 → 听小顺 →「滑到指证,
* 点一下」→ 练习指证零账目),再把六份卷宗听过耳朵,逐对比对出全部四处矛盾
* (周先生四连谎),结案陈词先错指小顺听不在场证明,再正指周先生、答对凶器与
* 动机 → 认罪链与法槌 → 评级结算 → 下滑逛一圈菜单(阶段 2 此刻才真解锁)→
* 单击接新案(pack 翻转,变体真凶小顺)短打一处指证收尾。
* - `sessionStress`(乱按压力):一位手忙脚乱的好奇宝宝——标题乱划、证词板狂点
* 乱滑(零进展被无进展轨三级抢救:earcon → 短句 → 保底矛盾对)、菜单乱逛只
* 下滑关闭、结案乱答再修正——一样走到 VERDICT+SETTLE 与新案开局,全程零死锁。
*
* 剧本失真红线(参照 radio/rpg 剧本先例):
* - 玩家不抢话:只在话轮真正交出(floor.state === YOUR_TURN,链级判据)之后才
* 出手;听取(ACTION)是玩法态,可以随时点停/切条目——那本来就是玩家的事;
* - 玩家靠耳朵玩:指证前先翻听卷宗,导航只把「听到的内容」翻译成手势;
* 上滑问一次状态(阶段 1 解锁后才用;双击留到阶段 3,未解锁前不碰);
* - BOARD 常驻无进展轮询定时器:禁止裸 waitIdle 空等,必须 waitStep 有界等待;
* - 菜单里只「下滑关闭」,绝不单击确认(防误触「保存并回大厅」结束会话)。
*/
const LISTEN_ITEMS = ['w1', 'w2', 'w3', 'w4', 'scene', 'fore'];
const ITEM_ORDER = [...LISTEN_ITEMS, 'cf', 'case'];
const ITEM_LABEL = {
w1: '门卫老葛的证词', w2: '学徒小顺的证词', w3: '账房周先生的证词', w4: '房东温太太的证词',
scene: '现场勘验', fore: '法医报告', cf: '指证', case: '结案陈词',
};
const MENU_LABEL = {
sys_menu_resume: '回到证词板', sys_menu_save: '保存并回大厅',
sys_menu_restart: '重新查这桩案子', sys_menu_help: '重新听帮助',
};
const WHO_LABEL = { w1: '老葛', w2: '小顺', w3: '周先生', w4: '温太太' };
/** 26 号 API:真等待态(链收+话轮已交)与当前等待点 id。 */
const atTurn = (game) => !game.floor.speaking && game.floor.state === 'YOUR_TURN';
const wpId = (game) => (game.floor.waitPoint ? game.floor.waitPoint.conf.id : null);
export function describe(game) {
if (game.menuOpen) {
const names = game._menuItems.map((id) => MENU_LABEL[id] || id).join('、');
return `菜单里(${names}):当前项「${MENU_LABEL[game._menuItems[game._menuIdx]] || '?'}」——左右滑换项,单击确认,上滑重听说明,下滑关闭`;
}
if (game._play) return `正在听${ITEM_LABEL[ITEM_ORDER[game._play.itemIdx]]}(点一下停止;左右滑换卷宗;上滑问状态)`;
switch (game.state) {
case 'TITLE': return game.run
? '检测到未结的案子:单击继续,右滑换一桩新案'
: '开场指令:「点一下屏幕。」——第一句就让我动手';
case 'BOARD': {
const label = ITEM_LABEL[ITEM_ORDER[game.itemIdx]];
const gate = game._caseGate ? '(确认闸已挂:再单击才进三问,滑走撤闸)' : '';
return `证词板上是「${label}」:左右滑换卷宗,单击听取${game.itemIdx === 6 ? '(在指证项上,点一下进入指证)' : ''}${game.itemIdx === 7 ? '(在结案陈词上)' : ''}${gate};上滑问状态,下滑菜单`;
}
case 'CONFRONT': {
const cf = game._cf;
if (!cf) return '指证中';
if (cf.phase === 'judged') return '判定播报中,先听着(链锁,手势只给 busy)';
const ph = {
item1: '选第一份证词(左右滑换,单击确认)', line1: '第一份里逐句听(左右滑换句,单击进确认)',
confirm1: '就这句?点一下定下来(左右滑=取消回预览)',
item2: '再选与它矛盾的那份', line2: '第二份里逐句听', confirm2: '就这句?点一下定下来',
}[cf.phase];
return `指证中:${ph}`;
}
case 'FINISH': {
const fin = game._fin;
if (!fin) return '结案中';
const ph = {
who: `第一问指认真凶(当前 ${WHO_LABEL[['w1', 'w2', 'w3', 'w4'][fin.optIdx]]}):左右滑换人,单击进确认`,
whoConfirm: '就指认这位?单击定下来(左右滑取消)',
weapon: '第二问:凶器是什么(左右滑听选项,单击定)',
motive: '第三问:动机是什么(左右滑听选项,单击定)',
}[fin.phase];
return `结案三问——${ph}`;
}
case 'VERDICT': return '裁决链播报中:认罪三段与法槌,手势全部被吞(链锁)';
case 'SETTLE': return '结算:评级与账目播完——单击接新案(pack 翻转),上滑重听评级,下滑菜单';
default: return '……';
}
}
// ---------------------------------------------------------------- 会话工具
/** 有界等待:条件满足或超时即返回(BOARD 常驻轮询定时器,禁裸 waitIdle)。 */
async function waitStep(ctx, mock, cond, maxMs = 60000) {
const t0 = mock.now;
while (!cond() && mock.now - t0 < maxMs) await ctx.waitVirtual(150);
}
/** 等真话轮(链收+话轮已交,含指证/结案/菜单各等待点)。 */
async function turn(ctx, mock, maxMs = 60000) {
await waitStep(ctx, mock, () => atTurn(ctx.game), maxMs);
}
/** 滑到指定卷宗条目(最短环绕方向;签名 earcon → 条目名 → 交话轮)。 */
async function navToItem(ctx, mock, itemId) {
const game = ctx.game;
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;
await ctx.gesture(d * 2 <= ITEM_ORDER.length ? 'right' : 'left');
await waitStep(ctx, mock, () => atTurn(game), 15000);
}
await turn(ctx, mock);
}
/** 完整听取当前卷宗(单击 → 等证词链播完回到可操作)。 */
async function listenItem(ctx, mock, itemId) {
const game = ctx.game;
await navToItem(ctx, mock, itemId);
ctx.note(`点一下,听取${ITEM_LABEL[itemId]}`);
await ctx.gesture('tap');
await waitStep(ctx, mock, () => game._play === null && game.floor.state === 'YOUR_TURN', 150000);
}
/** 指证内选一句:滑到条目 → 单击进选句 → 滑到句 → 单击进确认 → 单击定下。 */
async function cfSelectLine(ctx, mock, caseData, lineId) {
const game = ctx.game;
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 ctx.gesture(d * 2 <= LISTEN_ITEMS.length ? 'right' : 'left');
await waitStep(ctx, mock, () => atTurn(game), 15000);
}
await turn(ctx, mock);
ctx.note(`选定「${ITEM_LABEL[itemId]}」——单击确认`);
await ctx.gesture('tap'); // 确认条目 → 进选句(首选项句自动播)
await waitStep(ctx, mock, () => game._cf && (game._cf.phase === 'line1' || game._cf.phase === 'line2') && atTurn(game), 20000);
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 ctx.gesture(d * 2 <= ids.length ? 'right' : 'left');
await waitStep(ctx, mock, () => atTurn(game), 15000);
}
await turn(ctx, mock);
ctx.note('听到那句对不上的了——单击,进入确认');
await ctx.gesture('tap'); // 进确认(重播该句 + 「就这句?」)
await waitStep(ctx, mock, () => game._cf && (game._cf.phase === 'confirm1' || game._cf.phase === 'confirm2') && atTurn(game), 20000);
await turn(ctx, mock);
ctx.note('单击,定下这句');
await ctx.gesture('tap');
}
/** 从证词板发起一次完整指证(滑到「指证」条目 + 单击进入 + 两侧选句),等判定链收尾回板。 */
async function confront(ctx, mock, caseData, lineA, lineB, noteA, noteB) {
const game = ctx.game;
await navToItem(ctx, mock, 'cf');
ctx.note(noteA || '滑到「指证」——点一下,进入指证');
await ctx.gesture('tap');
await waitStep(ctx, mock, () => game.state === 'CONFRONT' && game._cf && game._cf.phase === 'item1', 15000);
await turn(ctx, mock); // cf_start(+练习句)→ 条目报名
await cfSelectLine(ctx, mock, caseData, lineA);
await waitStep(ctx, mock, () => game._cf && game._cf.phase === 'item2', 25000);
await turn(ctx, mock); // 「再选与它矛盾的那份」+ 第一份自动重播(记忆契约)
ctx.note(noteB || '换那份对不上的——同样两句定下来');
await cfSelectLine(ctx, mock, caseData, lineB);
await waitStep(ctx, mock, () => game.state === 'BOARD' && !game._play && atTurn(game), 120000);
}
/** 从证词板走到结案三问(滑到结案陈词 → 单击过确认闸 → 闸内再单击进三问)。 */
async function enterFinish(ctx, mock) {
const game = ctx.game;
await navToItem(ctx, mock, 'case');
ctx.note('翻到结案陈词——单击过确认闸(「就此结案?」)');
await ctx.gesture('tap');
await turn(ctx, mock, 20000); // fin_pre 闸句
ctx.note('闸内再单击,进三问');
await ctx.gesture('tap');
await waitStep(ctx, mock, () => game.state === 'FINISH' && atTurn(game), 30000); // fin_start + 首人名
}
/** 结案某问里滑到指定选项并单击定下(who 问多一段确认)。 */
async function answerOption(ctx, mock, opts, want) {
const game = ctx.game;
const idx = opts.indexOf(want);
for (let i = 0; i < idx; i++) {
await ctx.gesture('right');
await waitStep(ctx, mock, () => atTurn(game), 15000);
}
await turn(ctx, mock);
await ctx.gesture('tap');
}
// ---------------------------------------------------------------- 主线剧本
export async function session(ctx, mock) {
const { game } = ctx;
// —— 开场(§4.10):第一句 =「点一下屏幕。」(critical),第一下单击开新案 ——
await waitStep(ctx, mock, () => atTurn(game), 30000);
ctx.status('雨夜专案开场:一句指令就让我动手,没有规则念白');
ctx.note('点一下屏幕');
await ctx.gesture('tap');
await waitStep(ctx, mock, () => game.run && atTurn(game), 40000);
ctx.status('新案开卷:一句案情 → 签名 +「老葛的证词」→「单击,听取」→ 顺带教了「轮到你」这个状态声');
// —— 教学寄生在真实案卷:听第一份(8~10 秒第一次成功=听到真证词) ——
ctx.note('点一下,听老葛怎么说');
await ctx.gesture('tap');
await waitStep(ctx, mock, () => game._play === null && atTurn(game), 150000);
ctx.status('一份真证词过完耳朵——「向右滑,下一份。」还教了「想知道自己在哪,就向上滑」');
ctx.note('右滑——换小顺的证词');
await ctx.gesture('right');
await turn(ctx, mock);
ctx.note('再点一下,听小顺的');
await ctx.gesture('tap');
await waitStep(ctx, mock, () => game._play === null && atTurn(game), 150000);
ctx.status('两份听完——「滑到指证,点一下。」指证是案卷列表里的一项,不用再记什么长按');
// —— 首次指证 = 练习(判定截获,零账目污染) ——
const cs0 = game._case;
await confront(ctx, mock, cs0, cs0.layout.lines.fore[0], cs0.layout.lines.w1[0],
'滑到「指证」——点一下,进去练一次', '练习:再任选一份的任意一句(不算数)');
ctx.status('「指证就是这样。练习不算数,真矛盾要靠耳朵找。」——教学收官,回板自由查案');
// —— 自由调查:翻听其余四份卷宗(上滑问一次状态) ——
for (const id of ['w3', 'w4', 'scene', 'fore']) {
await listenItem(ctx, mock, id);
ctx.status(`已听 ${game.run.heard.length}/6 份——${ITEM_LABEL[id]}过完耳朵`);
}
ctx.note('上滑核对进度:六份都听完了,疑点还没坐实');
await ctx.gesture('up');
await turn(ctx, mock, 40000);
ctx.status('状态链:已问六份 → 疑点零 →「滑到指证,去比对」');
// —— 指证:四处矛盾逐对比对(周先生四连谎) ——
const cs = game._case;
const pairs = [
['z_2', 'wen_2', '周先生说「顺手熄了灯」——先定下这句', '温太太说「十点二十灯还亮着」——对上了!'],
['z_3', 'w2_3', '周先生说「钥匙贴身带着」', '小顺说「钥匙平日挂柴房铜钩」——对不上!'],
['z_4', 'fo_3', '周先生说「伤是松木劈柴砸的」', '法医说「创口嵌有铜屑,凶器是铜器」——对不上!'],
['z_4', 'sc_4', '还是周先生那句「铜秤卖给收废品的了」', '现场勘验说「铜秤立在架上」——对不上!'],
];
for (const [a, b, na, nb] of pairs) {
await confront(ctx, mock, cs, a, b, na, nb);
ctx.status(`疑点 ${game._doubtCount()}/4 坐实——真凶的反应慌在刚戳中的那件事上`);
}
ctx.status('四处疑点齐了——链尾直接给了一句「疑点齐了,去结案陈词」');
// —— 结案三问:先错指一次听不在场证明,再正指 ——
await enterFinish(ctx, mock);
ctx.note('先怀疑小顺——右滑选他,单击,再单击确认');
await answerOption(ctx, mock, ['w1', 'w2', 'w3', 'w4'], 'w2');
await waitStep(ctx, mock, () => game._fin && game._fin.phase === 'whoConfirm' && atTurn(game), 20000);
await ctx.gesture('tap'); // 正式指认(错)
await waitStep(ctx, mock, () => game.state === 'BOARD' && atTurn(game), 60000);
ctx.status('错怪了——小顺宿在城南刘掌柜家,不在场证明成立,回到证词板(无限再机会,错指记档)');
await enterFinish(ctx, mock);
ctx.note('证据都指向周先生——右滑两下选他,单击,再单击确认');
await answerOption(ctx, mock, ['w1', 'w2', 'w3', 'w4'], 'w3');
await waitStep(ctx, mock, () => game._fin && game._fin.phase === 'whoConfirm' && atTurn(game), 20000);
await ctx.gesture('tap');
await waitStep(ctx, mock, () => game._fin && game._fin.phase === 'weapon' && atTurn(game), 40000);
ctx.status('第二问:凶器是什么?左右滑一个个听');
await answerOption(ctx, mock, cs.layout.wpOpts, cs.questions.weapon.answer); // 铜秤砣——铜屑不答应别的答案
await waitStep(ctx, mock, () => game._fin && game._fin.phase === 'motive' && atTurn(game), 40000);
ctx.status('第三问:动机是什么?');
await answerOption(ctx, mock, cs.layout.mvOpts, cs.questions.motive.answer); // 监守自盗——账上短的东西才是引线
// —— 裁决:认罪链 + 法槌 + 结算(critical 链锁,手势全吞) ——
await waitStep(ctx, mock, () => game.state === 'VERDICT', 40000);
ctx.status('证据链闭合——周先生认罪三段,雨声渐强又落下,法槌落定');
await waitStep(ctx, mock, () => game.state === 'SETTLE' && atTurn(game), 300000);
const gi = game._gradeInfo;
ctx.status(`案件归档,雨停了——评级 ${gi ? gi.grade : '?'}:疑点 ${gi ? gi.outcome.doubts : '?'}/4,错指 ${gi ? gi.outcome.wrongWho : '?'} 次`);
ctx.note('链尾教了「向下滑,可以打开菜单」——去逛一圈');
// —— 菜单(阶段 2 此刻才真解锁):左右滑换项,只下滑关闭 ——
await ctx.gesture('down');
await waitStep(ctx, mock, () => game.menuOpen && atTurn(game), 20000);
ctx.status('菜单名和第一项「回到证词板」报完了');
await ctx.gesture('right'); // 下一项报名
await turn(ctx, mock);
ctx.status('听完「保存并回大厅」的报名——不动它');
await ctx.gesture('down'); // 下滑关闭(不单击确认)
await waitStep(ctx, mock, () => !game.menuOpen && atTurn(game), 20000);
ctx.status('菜单关了,回到结算');
// —— 接新案(pack 翻转)+ 变体案短打一处指证收尾 ——
ctx.note('单击——接新案(同一场雨里,还有别的案子)');
await ctx.gesture('tap');
await waitStep(ctx, mock, () => game.state === 'BOARD' && game.run && game.run.pack === 1 && atTurn(game), 40000);
ctx.status('新案开卷:pack 翻转——这回的真凶换了人(不再教学,做对过的事不教第二遍)');
await confront(ctx, mock, game._case, 'b_shun_1', 'wen_2',
'小顺说「我走的时候熄了灯」——变体案的第一句谎', '温太太还是那句「灯还亮着」——对上了!');
ctx.status(`变体案疑点 ${game._doubtCount()}/4——真凶另有其人,收工`);
}
// ---------------------------------------------------------------- 乱按压力剧本
// 目标:全程零死锁、零静默死局,一样能走到 SETTLE。
// 剧本特征:不读提示、起停乱点、乱滑换卷宗、菜单乱逛只下滑关闭、结案乱答再修正。
export async function sessionStress(ctx, mock) {
const { game } = ctx;
let seed = 20260909;
const rng = () => { seed = (seed * 1103515245 + 12345) & 0x7fffffff; return seed / 0x7fffffff; };
const pick = (arr) => arr[Math.floor(rng() * arr.length)];
// ① 标题页乱划(含未解锁手势——拿到 invalid 回执/解释句也不卡流程;误开菜单
// 就顺手下滑关掉),最后收口把案子开起来(乱按的底线是流程照走)
for (let i = 0; i < 6 && game.state === 'TITLE'; i++) {
ctx.status('标题页乱划一气');
await ctx.gesture(pick(['left', 'right', 'up', 'down', 'doubletap']));
await ctx.waitVirtual(400);
if (game.menuOpen) { await ctx.gesture('down'); await ctx.waitVirtual(600); }
}
let sg = 0;
while (game.state === 'TITLE' && sg++ < 8) {
await ctx.gesture('tap'); // 单击 = 继续存档 / 开新案(误进菜单就再关掉重试)
await waitStep(ctx, mock, () => game.state !== 'TITLE' || game.menuOpen, 8000);
if (game.menuOpen) { await ctx.gesture('down'); await ctx.waitVirtual(600); }
}
await waitStep(ctx, mock, () => game.state === 'BOARD' && game.run, 60000);
// ② 教学期乱按几下(起停/乱滑/问状态;在指证、结案两项上绝不单击,不误入),
// 乱按走不完就把练习照实做掉(首次指证=练习,零账目,乱按也毕业)
let tg = 0;
while (game.state === 'BOARD' && !game.meta.drillDone && tg++ < 8) {
const atSpecial = game.itemIdx >= ITEM_ORDER.length - 2;
await ctx.gesture(atSpecial ? 'left' : pick(['tap', 'left', 'right', 'up']));
await ctx.waitVirtual(1200);
}
if (!game.meta.drillDone && game.state === 'BOARD' && game._case) {
await waitStep(ctx, mock, () => game.state === 'BOARD' && !game._play && atTurn(game), 60000);
const csP = game._case;
await confront(ctx, mock, csP, csP.layout.lines.fore[0], csP.layout.lines.w1[0],
'照着提示把练习做掉:任选一份证词', '再任选一份任意一句——练习不算数');
}
ctx.status('教学在乱按里也走完了(练习指证零账目,不会把自己教坏)');
// ③ 证词板乱按 160s(零进展——被无进展轨三级抢救:45s earcon → 90s 短句 →
// 150s 保底直接给可指证矛盾对)。起停对=跨过 400ms 防连点窗的双 tap
// (首句没听完就停=确定性零进展;heardLines 只记到完整句)。
const t0 = mock.now;
const ROT = ['pair', 'right', 'left', 'right', 'left'];
let ri = 0;
while (game.state === 'BOARD' && mock.now - t0 < 160000) {
const gg = ROT[ri++ % ROT.length];
const atCase = game.itemIdx >= ITEM_ORDER.length - 2; // 不误入指证/结案陈词
if (gg === 'pair' && !atCase) {
await ctx.gesture('tap'); // 起播
await ctx.waitVirtual(700); // 跨过防连点窗(第二下不再被 busy 吞)
await ctx.gesture('tap'); // 立刻停止——首句没听完
} else if (atCase) {
await ctx.gesture('left');
} else {
await ctx.gesture(gg);
}
if (rng() < 0.12) {
ctx.note('手痒开菜单乱逛——只敢下滑关掉');
await ctx.gesture('down');
await ctx.waitVirtual(1200);
await ctx.gesture('down');
await ctx.waitVirtual(500);
continue;
}
await ctx.waitVirtual(1100);
}
ctx.status('乱按也被抢救:听到了保底提示直接给出可指证的矛盾对');
// ④ 顺着保底提示真指证一处(零死锁底线:核心动词始终可达)
const cs = game._case;
if (game.state === 'BOARD') {
const contra = cs.contradictions.find((c) => c.hint) || cs.contradictions[0];
await confront(ctx, mock, cs, contra.a, contra.b,
`照着提示指证:${contra.a} 这句`, `对上 ${contra.b} 这句`);
ctx.status(`乱按局也能坐实疑点(${game._doubtCount()}/4)`);
}
// ⑤ 结案:乱答一次再答对(错指无限再机会 + 问题不重答只记账)
let fg = 0;
while (game.state !== 'SETTLE' && fg++ < 12) {
await enterFinish(ctx, mock);
const whoIdx = fg === 1 ? Math.floor(rng() * 4) : ['w1', 'w2', 'w3', 'w4'].indexOf(cs.culprit);
for (let i = 0; i < whoIdx; i++) { await ctx.gesture('right'); await ctx.waitVirtual(400); }
await turn(ctx, mock);
ctx.note(fg === 1 ? '先胡乱指一个听听反应' : '这回指着正主');
await ctx.gesture('tap');
await waitStep(ctx, mock, () => game._fin && game._fin.phase === 'whoConfirm' && atTurn(game), 25000);
await ctx.gesture('tap');
if (game.state === 'FINISH' && game._fin && game._fin.phase !== 'who') {
for (const [phase, want] of [['weapon', cs.questions.weapon.answer], ['motive', cs.questions.motive.answer]]) {
await waitStep(ctx, mock, () => game._fin && game._fin.phase === phase && atTurn(game), 40000);
const opts = phase === 'weapon' ? cs.layout.wpOpts : cs.layout.mvOpts;
const idx = opts.indexOf(want);
for (let i = 0; i < idx; i++) { await ctx.gesture('right'); await ctx.waitVirtual(400); }
await turn(ctx, mock);
await ctx.gesture('tap');
}
}
// 等本轮回合彻底落定(错指=回板;答对=裁决链播完进 SETTLE)——裁决链播报期
// 手势全吞,绝不提前进下一轮在链上乱点(SETTLE 的单击=接新案,误触即翻包)
await waitStep(ctx, mock, () => game.state !== 'FINISH' && game.state !== 'VERDICT', 120000);
}
await waitStep(ctx, mock, () => game.state === 'SETTLE' && atTurn(game), 300000);
ctx.status('乱按一路也把案子结了:裁决链完整、评级如实记账');
// ⑥ 接新案短程收尾(压力全程零死锁)
await ctx.gesture('tap');
await waitStep(ctx, mock, () => game.state === 'BOARD' && game.run && atTurn(game), 40000);
ctx.status('新案照样开局,压力测试收工');
}