/** * 《雨夜专案》新手会话剧本。 * * 剧本: * - `session`(主线):一位新手探长的完整破案弧线——标题开始 → DRILL 七步跟练 * (换卷宗/听取/重听/状态/长按指证/练习判定)→ 证词板翻听全部六份卷宗 → * 逐对比对指证出全部四处矛盾(周先生四连谎)→ 结案陈词先错指小顺听不在场 * 证明,再正指周先生、答对凶器与动机 → 认罪链与法槌 → 评级结算 → 单击接 * 新案(pack 翻转,变体真凶小顺)短打一处指证收尾。 * - `sessionStress`(乱按压力):一位手忙脚乱的好奇宝宝——标题乱划、教学乱按、 * 证词板狂点乱滑(零进展被看门狗三级抢救)、菜单乱逛只下滑关闭、结案乱答 * 再修正——一样走到 VERDICT+SETTLE 与新案开局,全程零死锁。 * * 剧本失真红线(参照 dungeon/radio 剧本先例): * - 玩家不抢话:出手前等语音链/判定链收尾(waitStep 有界等待状态谓词); * - 玩家靠耳朵玩:指证前先翻听卷宗,导航只把「听到的内容」翻译成手势; * - BOARD 常驻无进展轮询定时器:禁止裸 waitIdle 空等,必须 waitStep/waitVirtual; * - 菜单里只「下滑关闭」,绝不双击执行(防误触「保存并回大厅」结束会话)。 */ const LISTEN_ITEMS = ['w1', 'w2', 'w3', 'w4', 'scene', 'fore']; const ITEM_ORDER = [...LISTEN_ITEMS, 'case']; const ITEM_LABEL = { w1: '门卫老葛的证词', w2: '学徒小顺的证词', w3: '账房周先生的证词', w4: '房东温太太的证词', scene: '现场勘验', fore: '法医报告', case: '结案陈词', }; export function describe(game) { if (game.menuOpen) return '菜单里:单击切换,双击确认,上滑帮助,下滑关闭'; 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}」:左右滑换卷宗,单击听取,按住进入指证,上滑状态,下滑菜单`; } 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}(左右滑换,单击确认,双击重听)`; } case 'FINISH': { const fin = game._fin; if (!fin) return '结案中'; const ph = { who: '指认真凶:四位证人选一个', whoConfirm: '就指认他?单击定下来', weapon: '第二问:凶器是什么', motive: '第三问:动机是什么' }[fin.phase]; return `结案三问——${ph}(左右滑选,单击定)`; } case 'VERDICT': return '裁决链播报中:认罪三段与法槌,手势全部被吞(链锁)'; case 'SETTLE': return '结算:评级与账目播完,单击接新案(pack 翻转)'; default: return '……'; } } // ---------------------------------------------------------------- 会话工具 /** 有界等待:条件满足或超时即返回。 */ async function waitStep(ctx, mock, cond, maxMs = 30000) { 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); } } /** 滑到指定卷宗条目(最短环绕方向)。 */ async function navToItem(ctx, 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; await ctx.gesture(d * 2 <= ITEM_ORDER.length ? 'right' : 'left'); await ctx.waitVirtual(200); } await ctx.waitVirtual(700); // 签名 earcon → 条目名落声 } /** 完整听取当前卷宗(单击 → 等链播完回空闲)。 */ async function listenItem(ctx, mock, game, itemId) { await navToItem(ctx, game, itemId); ctx.note(`单击,听取${ITEM_LABEL[itemId]}`); await ctx.gesture('tap'); await waitStep(ctx, mock, () => game._play === null && game.state === 'BOARD', 85000); } /** 指证内选一句:滑到条目 → 单击 → 滑到句 → 单击确认 → 听完「就这句?」单击定下。 */ async function cfSelectLine(ctx, mock, game, caseData, lineId) { 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 ctx.waitVirtual(700); ctx.note(`选定「${ITEM_LABEL[itemId]}」——单击确认`); await ctx.gesture('tap'); await ctx.waitVirtual(400); 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); // 一句一听 } 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); // 重播该句 + 「就这句?」听完 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); } 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。 */ async function enterFinish(ctx, mock, game) { await navToItem(ctx, game, 'case'); ctx.note('翻到结案陈词——单击,进入结案三问'); 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); } // ---------------------------------------------------------------- 主线剧本 export async function session(ctx, mock) { const { game } = ctx; // —— 标题:单击开始(首局无存档) —— await waitStep(ctx, mock, () => game.state === 'TITLE'); ctx.status('雨夜专案:一桩雨夜命案等着一双好耳朵'); ctx.note('单击,开始'); await ctx.gesture('tap'); // —— 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('教学完了——「指证就是这样,真矛盾要靠耳朵找」'); // —— 自由调查:翻听全部六份卷宗 —— const cs = game._case; for (const id of LISTEN_ITEMS) { await listenItem(ctx, mock, game, id); ctx.status(`已听 ${game.run.heard.length}/6 份——${ITEM_LABEL[id]}过完耳朵`); } ctx.note('上滑核对进度:六份都听完了,疑点还没坐实'); await ctx.gesture('up'); await ctx.waitVirtual(26000); // —— 指证穿插:四处矛盾逐对比对(周先生四连谎) —— 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 坐实——真凶的反应一次比一次慌`); } 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, game); await waitQuestion(ctx, mock, game, 'who'); ctx.note('证据都指向周先生——右滑两下选他'); await ctx.gesture('right'); await ctx.gesture('right'); await ctx.waitVirtual(800); 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'); 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 waitStep(ctx, mock, () => game.state === 'VERDICT', 30000); ctx.status('证据链闭合——周先生认罪三段,雨声渐强又落下,法槌落定'); await waitStep(ctx, mock, () => game.state === 'SETTLE', 150000); const gi = game._gradeInfo; ctx.status(`案件归档,雨停了——评级 ${gi ? gi.grade : '?'}:疑点 ${gi ? gi.outcome.doubts : '?'}/4,错指 ${gi ? gi.outcome.wrongWho : '?'} 次`); 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', '小顺说「我走的时候熄了灯」——变体案的第一句谎', '温太太还是那句话「灯还亮着」——对上了!'); ctx.status(`变体案疑点 ${game._doubtCount()}/4——真凶另有其人,收工`); } // ---------------------------------------------------------------- 乱按压力剧本 export async function sessionStress(ctx, mock) { const { game } = ctx; let seed = 20260909; const rng = () => { seed = (seed * 1103515245 + 12345) & 0x7fffffff; return seed / 0x7fffffff; }; // ① 标题页乱划,然后开始 for (let i = 0; i < 8 && game.state === 'TITLE'; i++) { ctx.status('标题页乱划一气'); await ctx.gesture(rng() < 0.5 ? 'left' : 'doubletap'); await ctx.waitVirtual(450); } await ctx.gesture('tap'); // ② 教学:乱按几下,然后下滑跳过(跟不下来也要能走完) 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'); await ctx.waitVirtual(1200); } await waitStep(ctx, mock, () => game.state === 'BOARD', 25000); ctx.status('总算从教学里滚出来了(可能一路错过来的)'); // ③ 证词板乱按 185s(零进展——被看门狗三级抢救:earcon → 短句 → 保底矛盾对; // 菜单乱逛的挂起时长会被无进展轨剔除,故墙钟跑满 185s 保证 L3 落窗)。 // 起停对=同拍双 tap(与语音时长解耦:一句都不完成=确定性零进展)。 const t0 = mock.now; const ROT = ['pair', 'right', 'left', 'doubletap', 'left', 'right']; 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; if (gg === 'pair' && !atCase) { await ctx.gesture('tap'); // 起播 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(1500); await ctx.gesture('down'); await ctx.waitVirtual(600); continue; } await ctx.waitVirtual(ri++ % ROT.length === ROT.length - 1 ? 3600 : 1100); } ctx.status('乱按也被抢救:听到了保底提示直接给出可指证的矛盾对'); // ④ 顺着保底提示真指证一处(零死锁底线:核心动词始终可达) const cs = game._case; if (game.state === 'BOARD' && game._doubtCount() < 4) { const contra = cs.contradictions.find((c) => c.hint) || cs.contradictions[0]; await confront(ctx, mock, game, 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, 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); } ctx.note(fg === 1 ? '先胡乱指一个听听反应' : '这回指着正主'); await ctx.gesture('tap'); await ctx.waitVirtual(18000); await ctx.gesture('tap'); if (game.state === 'FINISH' && 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); 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); } await ctx.gesture('tap'); await ctx.waitVirtual(1500); } } await waitStep(ctx, mock, () => game.state !== 'FINISH', 40000); } await waitStep(ctx, mock, () => game.state === 'SETTLE', 150000); ctx.status('乱按一路也把案子结了:裁决链完整、评级如实记账'); // ⑥ 接新案短程收尾(压力全程零死锁) await ctx.gesture('tap'); await waitStep(ctx, mock, () => game.state === 'BOARD' && game.run, 30000); ctx.status('新案照样开局,压力测试收工'); }