/** * 《雨夜专案》新手会话剧本(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('新案照样开局,压力测试收工'); }