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 置完成。
This commit is contained in:
xsl
2026-10-02 12:33:20 +08:00
parent 9e4d67aa84
commit ee5d4d2544
6992 changed files with 30127 additions and 20451 deletions
+248 -222
View File
@@ -1,161 +1,191 @@
/**
* 《雨夜专案》新手会话剧本。
* 《雨夜专案》新手会话剧本(26 号重构版:叙事+听证词型,storyline/radio 范式)。
*
* 剧本:
* - `session`(主线):一位新手探长的完整破案弧线——标题开始 → DRILL 七步跟练
* (换卷宗/听取/重听/状态/长按指证/练习判定)→ 证词板翻听全部六份卷宗 →
* 逐对比对指证出全部四处矛盾(周先生四连谎)→ 结案陈词先错指小顺听不在场
* 证明,再正指周先生、答对凶器与动机 → 认罪链与法槌 → 评级结算 → 单击接
* 新案(pack 翻转,变体真凶小顺)短打一处指证收尾。
* - `sessionStress`(乱按压力):一位手忙脚乱的好奇宝宝——标题乱划、教学乱按、
* 证词板狂点乱滑(零进展被看门狗三级抢救)、菜单乱逛只下滑关闭、结案乱答
* 再修正——一样走到 VERDICT+SETTLE 与新案开局,全程零死锁。
* - `session`(主线):一位新手探长的完整破案弧线——开场第一句「点一下屏幕」就
* 动手,教学寄生于真实案卷(听老葛 → 教右滑+解锁上滑 → 听小顺 →「滑到指证,
* 点一下」→ 练习指证零账目),再把六份卷宗听过耳朵,逐对比对出全部四处矛盾
* (周先生四连谎),结案陈词先错指小顺听不在场证明,再正指周先生、答对凶器与
* 动机 → 认罪链与法槌 → 评级结算 → 下滑逛一圈菜单(阶段 2 此刻才真解锁)→
* 单击接新案(pack 翻转,变体真凶小顺)短打一处指证收尾。
* - `sessionStress`(乱按压力):一位手忙脚乱的好奇宝宝——标题乱划、证词板狂点
* 乱滑(零进展被无进展轨三级抢救:earcon → 短句 → 保底矛盾对)、菜单乱逛只
* 下滑关闭、结案乱答再修正——一样走到 VERDICT+SETTLE 与新案开局,全程零死锁。
*
* 剧本失真红线(参照 dungeon/radio 剧本先例):
* - 玩家不抢话:出手前等语音链/判定链收尾(waitStep 有界等待状态谓词);
* 剧本失真红线(参照 radio/rpg 剧本先例):
* - 玩家不抢话:只在话轮真正交出(floor.state === YOUR_TURN,链级判据)之后才
* 出手;听取(ACTION)是玩法态,可以随时点停/切条目——那本来就是玩家的事;
* - 玩家靠耳朵玩:指证前先翻听卷宗,导航只把「听到的内容」翻译成手势;
* - BOARD 常驻无进展轮询定时器:禁止裸 waitIdle 空等,必须 waitStep/waitVirtual;
* - 菜单里只「下滑关闭」,绝不双击执行(防误触「保存并回大厅」结束会话)。
* 上滑问一次状态(阶段 1 解锁后才用;双击留到阶段 3,未解锁前不碰);
* - BOARD 常驻无进展轮询定时器:禁止裸 waitIdle 空等,必须 waitStep 有界等待;
* - 菜单里只「下滑关闭」,绝不单击确认(防误触「保存并回大厅」结束会话)。
*/
const LISTEN_ITEMS = ['w1', 'w2', 'w3', 'w4', 'scene', 'fore'];
const ITEM_ORDER = [...LISTEN_ITEMS, 'case'];
const ITEM_ORDER = [...LISTEN_ITEMS, 'cf', 'case'];
const ITEM_LABEL = {
w1: '门卫老葛的证词', w2: '学徒小顺的证词', w3: '账房周先生的证词', w4: '房东温太太的证词',
scene: '现场勘验', fore: '法医报告', case: '结案陈词',
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) return '菜单里:单击切换,双击确认,上滑帮助,下滑关闭';
if (game._play) return `正在听${ITEM_LABEL[ITEM_ORDER[game._play.itemIdx]]}(单击停止,双击重新听当前句)`;
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 'DRILL': return game._drill
? `教学第 ${game._drill.step} 步,跟着旁白做(下滑可跳过)`
: '教学中';
? '检测到未结的案子:单击继续,右滑换一桩新案'
: '开场指令:「点一下屏幕。」——第一句就让我动手';
case 'BOARD': {
const label = ITEM_LABEL[ITEM_ORDER[game.itemIdx]];
return `证词板上是「${label}」:左右滑换卷宗,单击听取,按住进入指证,上滑状态,下滑菜单`;
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 '判定播报中,先听着';
const ph = { item1: '选第一份证词', line1: '第一份里逐句听', confirm1: '就这句?单击定下来',
item2: '选与它矛盾的那份', line2: '第二份里逐句听', confirm2: '就这句?单击定下来' }[cf.phase];
return `指证中:${ph}(左右滑换,单击确认,双击重听)`;
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: '指认真凶:四位证人选一个', whoConfirm: '就指认他?单击定下来',
weapon: '第二问:凶器是什么', motive: '第三问:动机是什么' }[fin.phase];
return `结案三问——${ph}(左右滑选,单击定)`;
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 翻转)';
case 'SETTLE': return '结算:评级与账目播完——单击接新案(pack 翻转),上滑重听评级,下滑菜单';
default: return '……';
}
}
// ---------------------------------------------------------------- 会话工具
/** 有界等待:条件满足或超时即返回。 */
async function waitStep(ctx, mock, cond, maxMs = 30000) {
/** 有界等待:条件满足或超时即返回(BOARD 常驻轮询定时器,禁裸 waitIdle)。 */
async function waitStep(ctx, mock, cond, maxMs = 60000) {
const t0 = mock.now;
let spins = 0;
const spinCap = Math.max(60, Math.floor(maxMs / 50));
while (!cond() && mock.now - t0 < maxMs && spins < spinCap) {
spins++;
await ctx.waitVirtual(100);
}
while (!cond() && mock.now - t0 < maxMs) await ctx.waitVirtual(150);
}
/** 滑到指定卷宗条目(最短环绕方向)。 */
async function navToItem(ctx, game, itemId) {
/** 等真话轮(链收+话轮已交,含指证/结案/菜单各等待点)。 */
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 ctx.waitVirtual(200);
await waitStep(ctx, mock, () => atTurn(game), 15000);
}
await ctx.waitVirtual(700); // 签名 earcon → 条目名落声
await turn(ctx, mock);
}
/** 完整听取当前卷宗(单击 → 等链播完回空闲)。 */
async function listenItem(ctx, mock, game, itemId) {
await navToItem(ctx, game, itemId);
ctx.note(`单击,听取${ITEM_LABEL[itemId]}`);
/** 完整听取当前卷宗(单击 → 等证词链播完回到可操作)。 */
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.state === 'BOARD', 85000);
await waitStep(ctx, mock, () => game._play === null && game.floor.state === 'YOUR_TURN', 150000);
}
/** 指证内选一句:滑到条目 → 单击 → 滑到句 → 单击确认 → 听完「就这句?」单击定下。 */
async function cfSelectLine(ctx, mock, game, caseData, lineId) {
/** 指证内选一句:滑到条目 → 单击进选句 → 滑到句 → 单击进确认 → 单击定下。 */
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 ctx.waitVirtual(200);
await waitStep(ctx, mock, () => atTurn(game), 15000);
}
await ctx.waitVirtual(700);
await turn(ctx, mock);
ctx.note(`选定「${ITEM_LABEL[itemId]}」——单击确认`);
await ctx.gesture('tap');
await ctx.waitVirtual(400);
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 ctx.waitVirtual(400); // 一句一听
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'), 25000);
await ctx.waitVirtual(17000); // 重播该句 + 「就这句?」听完
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');
}
/** 从证词板发起一次完整指证(按住进入 + 两侧选句),等判定链收尾回板。
* 长按带重试:E2 受理条件=无语音在播——播报尾音未排空时补一拍重试。 */
async function confront(ctx, mock, game, lineA, lineB, noteA, noteB) {
ctx.note(noteA);
for (let attempt = 0; attempt < 3 && game.state !== 'CONFRONT'; attempt++) {
await ctx.gesture('longpress');
if (game.state !== 'CONFRONT') await ctx.waitVirtual(1500);
/** 从证词板发起一次完整指证(滑到「指证」条目 + 单击进入 + 两侧选句),等判定链收尾回板。 */
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 waitStep(ctx, mock, () => game.state === 'CONFRONT' && game._cf && game._cf.phase === 'item1', 12000);
await ctx.waitVirtual(9000); // cf_start 听完
const cs = game._case;
await cfSelectLine(ctx, mock, game, cs, lineA);
await ctx.waitVirtual(17500); // 「再选与它矛盾的那份」+ 第一份自动重播
ctx.note(noteB);
await cfSelectLine(ctx, mock, game, cs, lineB);
await waitStep(ctx, mock, () => game.state === 'BOARD' && !game._play, 95000);
}
/** 从证词板走到结案陈词并单击进 FINISH(R2 F5:先过确认闸 fin_pre,闸内再单击才进三问)。 */
async function enterFinish(ctx, mock, game) {
await navToItem(ctx, game, 'case');
ctx.note('翻到结案陈词——单击过确认闸(「就此结案?」),再单击进三问');
await turn(ctx, mock);
await ctx.gesture('tap');
await ctx.waitVirtual(3000); // fin_pre 闸句落声
await ctx.gesture('tap');
await waitStep(ctx, mock, () => game.state === 'FINISH', 12000);
}
/** 等结案问句链播完(busy 解除 + 首选项报名)。 */
async function waitQuestion(ctx, mock, game, phase) {
await waitStep(ctx, mock, () => game._fin && game._fin.phase === phase && !game._fin.busy, 30000);
await ctx.waitVirtual(1000);
}
// ---------------------------------------------------------------- 主线剧本
@@ -163,190 +193,184 @@ async function waitQuestion(ctx, mock, game, phase) {
export async function session(ctx, mock) {
const { game } = ctx;
// —— 标题:单击开始(首局无存档) ——
await waitStep(ctx, mock, () => game.state === 'TITLE');
ctx.status('雨夜专案:一桩雨夜命案等着一双好耳朵');
ctx.note('单击,开始');
// —— 开场(§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('新案开卷:一句案情 → 签名 +「老葛的证词」→「单击,听取」→ 顺带教了「轮到你」这个状态声');
// —— DRILL 七步跟练 ——
await waitStep(ctx, mock, () => game.state === 'DRILL' && game._drill?.step === 0, 20000);
ctx.status('开场三句案情简介 + 「先熟悉证词板」');
await ctx.waitVirtual(37000);
ctx.note('右滑——换一份卷宗听听');
await ctx.gesture('right'); // D0 → D1
await waitStep(ctx, mock, () => game._drill?.step === 1, 8000);
await ctx.waitVirtual(10000); // 「单击,听取当前卷宗」+ 回正老葛报名
ctx.note('单击——听老葛的证词');
await ctx.gesture('tap'); // D1 真实证词链播
await waitStep(ctx, mock, () => game._drill?.step === 2, 80000);
ctx.status('一份证词听完了——「已听一份,还有五份」');
await ctx.waitVirtual(10000); // 「双击,重新听这一句」
ctx.note('双击——最后一句再过一遍耳朵');
await ctx.gesture('doubletap'); // D2 → D3
await waitStep(ctx, mock, () => game._drill?.step === 3, 15000);
await ctx.waitVirtual(10000); // 「上滑,随时听进度」
ctx.note('上滑——听听问了几份、疑点几处');
await ctx.gesture('up'); // D3 → D4
await waitStep(ctx, mock, () => game._drill?.step === 4, 45000);
await ctx.waitVirtual(10000); // 「按住屏幕,进入指证」
ctx.note('按住屏幕——进入指证');
await ctx.gesture('longpress'); // D4 → D5(CONFRONT 练习局)
ctx.status('练习一次指证:判定被教学截获,不算数');
await ctx.waitVirtual(18000); // cf_start + 练习指令
const cs1 = game._case;
await cfSelectLine(ctx, mock, game, cs1, cs1.layout.lines.w2[0]);
await ctx.waitVirtual(17500);
await cfSelectLine(ctx, mock, game, cs1, cs1.layout.lines.scene[0]);
await waitStep(ctx, mock, () => game.state === 'BOARD', 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 cs = game._case;
for (const id of LISTEN_ITEMS) {
await listenItem(ctx, mock, game, id);
// —— 首次指证 = 练习(判定截获,零账目污染) ——
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 ctx.waitVirtual(26000);
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 (let i = 0; i < pairs.length; i++) {
const [a, b, na, nb] = pairs[i];
await confront(ctx, mock, game, a, b, na, nb);
ctx.status(`疑点 ${game._doubtCount()}/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('四处疑点齐了——「疑点齐了,去结案陈词」');
ctx.status('四处疑点齐了——链尾直接给了一句「疑点齐了,去结案陈词」');
// —— 结案三问:先错指一次听不在场证明,再正指 ——
await enterFinish(ctx, mock, game);
await waitQuestion(ctx, mock, game, 'who');
ctx.note('先怀疑小顺——右滑选他,单击');
await ctx.gesture('right');
await ctx.waitVirtual(800);
await ctx.gesture('tap');
await ctx.waitVirtual(18000); // 「就指认他?」听完
ctx.note('单击,定下来');
await ctx.gesture('tap');
await waitStep(ctx, mock, () => game.state === 'BOARD', 35000);
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, game);
await waitQuestion(ctx, mock, game, 'who');
ctx.note('证据都指向周先生——右滑两下选他');
await ctx.gesture('right');
await ctx.gesture('right');
await ctx.waitVirtual(800);
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 ctx.waitVirtual(18000);
ctx.note('单击,正式指认');
await ctx.gesture('tap');
await waitQuestion(ctx, mock, game, 'weapon');
ctx.status('第二问:凶器是什么?四个选项一个个听');
{
const wpIdx = cs.layout.wpOpts.indexOf('wp_2'); // 「铜秤砣」——铜屑与不见了的秤砣都指向它
for (let i = 0; i < wpIdx; i++) { await ctx.gesture('right'); await ctx.waitVirtual(400); }
ctx.note('铜屑不答应别的答案——选「铜秤砣」,单击');
await ctx.gesture('tap');
}
await waitQuestion(ctx, mock, game, 'motive');
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('第三问:动机是什么?');
{
const mvIdx = cs.layout.mvOpts.indexOf('mv_1'); // 「监守自盗」——库里短的东西就是引线
for (let i = 0; i < mvIdx; i++) { await ctx.gesture('right'); await ctx.waitVirtual(400); }
ctx.note('账上短的东西才是引线——选「监守自盗」,单击');
await ctx.gesture('tap');
}
await answerOption(ctx, mock, cs.layout.mvOpts, cs.questions.motive.answer); // 监守自盗——账上短的东西才是引线
// —— 裁决:认罪链 + 法槌 + 结算(链锁,手势全吞) ——
await waitStep(ctx, mock, () => game.state === 'VERDICT', 30000);
// —— 裁决:认罪链 + 法槌 + 结算(critical 链锁,手势全吞) ——
await waitStep(ctx, mock, () => game.state === 'VERDICT', 40000);
ctx.status('证据链闭合——周先生认罪三段,雨声渐强又落下,法槌落定');
await waitStep(ctx, mock, () => game.state === 'SETTLE', 150000);
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?.pack === 1, 30000);
ctx.status('新案开卷:pack 翻转——这回的真凶换了人');
// —— 变体案短打:一处指证(小顺的谎)收尾 ——
await confront(ctx, mock, game, 'b_shun_1', 'wen_2',
'小顺说「我走的时候熄了灯」——变体案的第一句谎', '温太太还是那句话「灯还亮着」——对上了!');
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)];
// ① 标题页乱划,然后开始
for (let i = 0; i < 8 && game.state === 'TITLE'; i++) {
// ① 标题页乱划(含未解锁手势——拿到 invalid 回执/解释句也不卡流程;误开菜单
// 就顺手下滑关掉),最后收口把案子开起来(乱按的底线是流程照走)
for (let i = 0; i < 6 && game.state === 'TITLE'; i++) {
ctx.status('标题页乱划一气');
await ctx.gesture(rng() < 0.5 ? 'left' : 'doubletap');
await ctx.waitVirtual(450);
await ctx.gesture(pick(['left', 'right', 'up', 'down', 'doubletap']));
await ctx.waitVirtual(400);
if (game.menuOpen) { await ctx.gesture('down'); await ctx.waitVirtual(600); }
}
await ctx.gesture('tap');
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);
// ② 教学:乱按几下,然后下滑跳过(跟不下来也要能走完)
await waitStep(ctx, mock, () => game.state === 'DRILL' || game.state === 'BOARD', 25000);
let dg = 0;
while (game.state === 'DRILL' && dg++ < 30) {
if (rng() < 0.5) {
await ctx.gesture(['tap', 'doubletap', 'left', 'right', 'up'][Math.floor(rng() * 5)]);
await ctx.waitVirtual(450);
continue;
}
ctx.note('下滑,跳过教学');
await ctx.gesture('down');
// ② 教学期乱按几下(起停/乱滑/问状态;在指证、结案两项上绝不单击,不误入),
// 乱按走不完就把练习照实做掉(首次指证=练习,零账目,乱按也毕业)
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);
}
await waitStep(ctx, mock, () => game.state === 'BOARD', 25000);
ctx.status('总算从教学里滚出来了(可能一路错过来的)');
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('教学在乱按里也走完了(练习指证零账目,不会把自己教坏)');
// ③ 证词板乱按 185s(零进展——被看门狗三级抢救:earcon → 短句 → 保底矛盾对;
// 菜单乱逛的挂起时长会被无进展轨剔除,故墙钟跑满 185s 保证 L3 落窗)。
// 起停对=同拍双 tap(与语音时长解耦:一句都不完成=确定性零进展)。
// ③ 证词板乱按 160s(零进展——被无进展轨三级抢救:45s earcon → 90s 短句 →
// 150s 保底直接给可指证矛盾对)。起停对=跨过 400ms 防连点窗的双 tap
// (首句没听完就停=确定性零进展;heardLines 只记到完整句)。
const t0 = mock.now;
const ROT = ['pair', 'right', 'left', 'doubletap', 'left', 'right'];
const ROT = ['pair', 'right', 'left', 'right', 'left'];
let ri = 0;
while (game.state === 'BOARD' && mock.now - t0 < 185000) {
const gg = ROT[ri % ROT.length];
const atCase = game.itemIdx === ITEM_ORDER.length - 1;
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.gesture('tap'); // 立刻停止——一句都听不完
await ctx.waitVirtual(700); // 跨过防连点窗(第二下不再被 busy 吞)
await ctx.gesture('tap'); // 立刻停止——首句没听完
} else if (atCase) {
await ctx.gesture('left'); // 不误入结案陈词
await ctx.gesture('left');
} else {
await ctx.gesture(gg);
}
if (rng() < 0.12) {
ctx.note('手痒开菜单乱逛——只敢下滑关掉');
await ctx.gesture('down');
await ctx.waitVirtual(1500);
await ctx.waitVirtual(1200);
await ctx.gesture('down');
await ctx.waitVirtual(600);
await ctx.waitVirtual(500);
continue;
}
await ctx.waitVirtual(ri++ % ROT.length === ROT.length - 1 ? 3600 : 1100);
await ctx.waitVirtual(1100);
}
ctx.status('乱按也被抢救:听到了保底提示直接给出可指证的矛盾对');
// ④ 顺着保底提示真指证一处(零死锁底线:核心动词始终可达)
const cs = game._case;
if (game.state === 'BOARD' && game._doubtCount() < 4) {
if (game.state === 'BOARD') {
const contra = cs.contradictions.find((c) => c.hint) || cs.contradictions[0];
await confront(ctx, mock, game, contra.a, contra.b,
await confront(ctx, mock, cs, contra.a, contra.b,
`照着提示指证:${contra.a} 这句`, `对上 ${contra.b} 这句`);
ctx.status(`乱按局也能坐实疑点(${game._doubtCount()}/4)`);
}
@@ -354,31 +378,33 @@ export async function sessionStress(ctx, mock) {
// ⑤ 结案:乱答一次再答对(错指无限再机会 + 问题不重答只记账)
let fg = 0;
while (game.state !== 'SETTLE' && fg++ < 12) {
await enterFinish(ctx, mock, game);
await waitQuestion(ctx, mock, game, 'who');
const whoIdx = fg === 1 ? Math.floor(rng() * 4) : LISTEN_ITEMS.indexOf(cs.culprit);
for (let i = 0; i < whoIdx; i++) { await ctx.gesture('right'); await ctx.waitVirtual(300); }
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 ctx.waitVirtual(18000);
await waitStep(ctx, mock, () => game._fin && game._fin.phase === 'whoConfirm' && atTurn(game), 25000);
await ctx.gesture('tap');
if (game.state === 'FINISH' && game._fin?.phase !== 'who') {
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 waitQuestion(ctx, mock, game, phase);
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(300); }
for (let i = 0; i < idx; i++) { await ctx.gesture('right'); await ctx.waitVirtual(400); }
await turn(ctx, mock);
await ctx.gesture('tap');
await ctx.waitVirtual(1500);
}
}
await waitStep(ctx, mock, () => game.state !== 'FINISH', 40000);
// 等本轮回合彻底落定(错指=回板;答对=裁决链播完进 SETTLE)——裁决链播报期
// 手势全吞,绝不提前进下一轮在链上乱点(SETTLE 的单击=接新案,误触即翻包)
await waitStep(ctx, mock, () => game.state !== 'FINISH' && game.state !== 'VERDICT', 120000);
}
await waitStep(ctx, mock, () => game.state === 'SETTLE', 150000);
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, 30000);
await waitStep(ctx, mock, () => game.state === 'BOARD' && game.run && atTurn(game), 40000);
ctx.status('新案照样开局,压力测试收工');
}