/** * 《暗码》新手会话剧本。 * * 剧本: * - `session`(主线):一位夜班报务员的完整弧线——听完开场白与规矩,逐封截获 * 四封密电(听摩尔斯 → 切候选听词义 → 双击重听/慢放 → 两段确认定词), * 处置抉择(p2 报北风 / p4 呈报 / p6 留书 / p8 装傻 / p9 发最后一电), * 听完尾声逐封点名,以完整 E1「最后一电」收尾。 * - `sessionStress`(乱按压力):好奇宝宝乱划乱点、菜单里乱逛、死命翻密码本, * 一样要能走到 ENDING,全程零死锁、零静默死局。 * * 剧本失真红线(参照 radio/abyss 剧本先例): * - 玩家不抢话:只在语音链播完(waitCond !voicePlaying)之后才出手; * - 玩家不干晾自己:只在浏览/链末等待态偶尔走神触发一次看门狗; * - RX/NIGHT 有自动链与定时器,禁止裸 waitIdle 空等,必须 waitCond 有界等待; * - DECODE 里每步都是真输入:切候选、重听、慢放、确认,像真人一样先听后按; * - RELAY 叙事链是「链末=继续」等待态:推进靠单击(像 tapUntil 一样有界循环), * 抉择则等提示句播完武装后再滑(盲玩公平,不抢话)。 */ const TEL_LABEL = ['一', '二', '三', '四']; export function describe(game) { if (game.leaving) return '已确认保存并回大厅'; if (game.phase === 'ASK') return '进游戏先听到「单击继续上次,右滑新夜班」的询问'; if (game.phase === 'MENU') { const names = ['回到电波', '翻密码本', '重新开始今晚', '保存并回大厅']; return `菜单里,当前项「${names[game.menuIdx]}」:单击切换,双击确认,上滑帮助,下滑关闭`; } if (game.phase === 'INTRO') { return game.voicePlaying ? '开场白正在播(今晚的目标和处长的夜令)' : '开场白播完了,等第一封电文'; } if (game.phase === 'RX') { return game._rxGuard ? `第 ${TEL_LABEL[game.tel - 1]} 封电文的摩尔斯正在首播,滴答声不可跳过` : `第 ${TEL_LABEL[game.tel - 1]} 封电文截获中`; } if (game.phase === 'DECODE') { if (game.confirming) return '进确认了:再单击就定下来,左右滑可以取消'; return game.voicePlaying ? `第 ${TEL_LABEL[game.decodeTel - 1]} 封:正在听候选词/电文` : `第 ${TEL_LABEL[game.decodeTel - 1]} 封:浏览候选中,左右滑听候选词,双击重听电文,长按翻密码本`; } if (game.choiceArmed) { return '抉择念完了:向左或向右滑动做出你的决定'; } if (game.phase === 'RELAY') { return game.voicePlaying ? '处置叙事中,正在听' : '说完了,等我接话:单击继续'; } if (game.phase === 'NIGHT') return '夜结算中,挂钟和雨声,马上来下一封'; if (game.phase === 'ENDING') { return game.awaitingRestart ? '结局和尾声点名都听完了。单击再来一局,下滑开菜单' : '结局播报中'; } return '……'; } // ---------------------------------------------------------------- 有界推进助手 /** 等当前语音链播完(有界等待,绝不裸空转)。 */ async function waitVoice(ctx) { await ctx.waitCond(() => !ctx.game.voicePlaying); } /** RELAY 链推进到抉择武装:语音在播等它播完,链末等待态单击继续(不抢话)。 */ async function untilChoice(ctx, maxSteps = 40) { const { game } = ctx; for (let i = 0; i < maxSteps; i++) { if (game.choiceArmed) return; if (game.phase === 'DECODE') throw new Error('untilChoice 遇 DECODE(需先解码)'); if (game.phase === 'MENU' || game.phase === 'ASK') throw new Error('untilChoice 遇菜单/询问'); if (game.voicePlaying) { await ctx.waitCond(() => game.choiceArmed || !game.voicePlaying); continue; } await ctx.gesture('tap'); // 链末 = 继续 await ctx.waitCond(() => game.choiceArmed || game.voicePlaying || game.phase !== 'RELAY'); } throw new Error(`untilChoice 超步限(${game.phase}/${game.nodeId})`); } /** 从任意叙事态单击推进到 DECODE 浏览武装(穿越 RX/NIGHT 自动链与 RELAY 等待段)。 */ async function toDecode(ctx, maxSteps = 60) { const { game } = ctx; for (let i = 0; i < maxSteps; i++) { if (game.phase === 'DECODE' && game.browseArmed) return; if (game.choiceArmed) throw new Error('toDecode 遇未应答抉择'); if (game.phase === 'MENU' || game.phase === 'ASK') throw new Error('toDecode 遇菜单/询问'); if (game.voicePlaying) { await ctx.waitCond(() => !game.voicePlaying); continue; } await ctx.gesture('tap'); await ctx.waitCond(() => game.voicePlaying || game.phase !== 'RX'); // 首播保护窗内 tap 被吞,等真实推进 } throw new Error(`toDecode 超步限(${game.phase}/${game.nodeId})`); } /** 从 p9 抉择后单击推进到 ENDING 结算态(d9 结果链含链末等待段)。 */ async function toEnding(ctx, maxSteps = 40) { const { game } = ctx; for (let i = 0; i < maxSteps; i++) { if (game.phase === 'ENDING' && game.awaitingRestart) return; if (game.voicePlaying) { await ctx.waitCond(() => !game.voicePlaying); continue; } if (game.phase !== 'RELAY' && game.phase !== 'ENDING') { await ctx.waitCond(() => game.phase === 'ENDING' || game.voicePlaying); continue; } await ctx.gesture('tap'); await ctx.waitCond(() => game.voicePlaying || game.phase === 'ENDING'); } throw new Error(`toEnding 超步限(${game.phase}/${game.nodeId})`); } // ---------------------------------------------------------------- 主线剧本 /** DECODE 小局:慢放听清 → 切候选听词义 → 选 idx → 两段确认。 */ async function decodeWord(ctx, idx, why) { const { game } = ctx; await ctx.waitCond(() => game.phase === 'DECODE' && game.browseArmed && !game.voicePlaying); ctx.note(`${why}——先双击听电文,再双击放慢逐字母听`); await ctx.gesture('doubletap'); await waitVoice(ctx); await ctx.gesture('doubletap'); // 慢放 0.5× await ctx.waitVirtual(400); // 慢放播一会(摩尔斯走合成不占语音时钟) // 逐候选听词义(从当前候选循环到目标) const cur = game.candIdx; const delta = (idx - cur + 3) % 3; for (let i = 0; i < delta; i++) { ctx.note('左右滑,听密码本上的候选词'); await ctx.gesture('right'); await ctx.waitCond(() => !game.voicePlaying); } ctx.note('就这个词——单击选定'); await ctx.gesture('tap'); // 进确认 await waitVoice(ctx); ctx.note('再单击,定下来'); await ctx.gesture('tap'); // 定下判定 await waitVoice(ctx); } /** 等抉择武装并选择 dir(盲玩公平:提示句播完才滑)。 */ async function relayChoice(ctx, dir, why) { const { game } = ctx; await untilChoice(ctx); ctx.note(why); await ctx.gesture(dir); await waitVoice(ctx); } export async function session(ctx, mock) { const { game } = ctx; // 开场:INTRO 自动流(夜班开场 + 规矩 + 目标 + 处长夜令)→ RX(1) → DECODE await ctx.waitCond(() => game.phase === 'RX'); ctx.status('开场白播完了,第一封电文进频道了'); ctx.note('电文滴答声进来了,这是摩尔斯,首播不可跳,听完它'); await toDecode(ctx); ctx.status('轮到我选词了:双击重听、左右滑听候选'); // T1 船期(教学封:BAY) await decodeWord(ctx, 0, '教学封:先学规矩——听不清就再听一遍'); // 教学封若误选会回浏览(剧本内不会,保险循环) for (let i = 0; i < 6 && game.phase === 'DECODE'; i++) await decodeWord(ctx, 0, '重新选'); await relayChoice(ctx, 'left', '药品船丑时过江——报给北风,江上有人等这船药'); ctx.status('第一封处置完了,夜里结算'); // T2 名单(TOP) await toDecode(ctx); ctx.status('第二封电文:内部通报,手法很急'); await decodeWord(ctx, 0, '黑名单。头一个代号是码头——我的交通员'); await relayChoice(ctx, 'right', '名单按流程呈报处长——码头,自己多保重'); ctx.status('第二封处置完了'); // T3 交接(HAND) await toDecode(ctx); ctx.status('第三封电文:很短,手法有点抖'); await decodeWord(ctx, 0, '今夜交接,丑时老码头——H 和 S 只差一个滴答,幸好放慢了'); await relayChoice(ctx, 'right', '留书暗号,让雨替我跑这一趟'); ctx.status('第三封处置完了'); // T4 处置令(SPY · 高潮) await toDecode(ctx); ctx.status('第四封电文:快手七的手法——门外的脚步声都近了'); await decodeWord(ctx, 0, '内线确认,今夜三时收网,代号耳机——收网的是我'); await relayChoice(ctx, 'right', '手离开放键器。我只是个值机的——什么都不做'); ctx.status('处长的试探应付过去了,频道要收了'); // 签收:g9_in → g9_knot → p9 await untilChoice(ctx); await relayChoice(ctx, 'left', '开机,发最后一电——三年前我译错了一封,这次不能错'); await toEnding(ctx); ctx.status('结局播完了:尾声把今晚每一封都点了名'); ctx.note('单击,收工——但下一班夜班,还想再来'); await ctx.gesture('tap'); await waitVoice(ctx); ctx.status('新的夜班开始了(再来一局)'); } // ---------------------------------------------------------------- 乱按压力剧本 export async function sessionStress(ctx, mock) { const { game } = ctx; // 开场就乱按:tap/doubletap/方向全来一遍(RX 首播保护窗内也应零死锁) await ctx.waitCond(() => game.phase === 'RX' && game._rxGuard); ctx.note('好奇宝宝:首播中乱点乱滑——都应该是轻震,不判无效'); for (const gg of ['tap', 'left', 'right', 'doubletap', 'tap']) { await ctx.gesture(gg); await ctx.waitVirtual(300); } await toDecode(ctx); // T1:翻密码本翻到手软(免费页 + 3 页 + 翻空),再译对 ctx.note('先把密码本翻烂:长按翻页,翻到一页不剩'); for (let i = 0; i < 5; i++) { await ctx.gesture('longpress'); await waitVoice(ctx); await ctx.waitVirtual(200); } ctx.note('乱切候选二十次'); for (let i = 0; i < 20; i++) { await ctx.gesture(i % 3 === 0 ? 'left' : 'right'); await ctx.waitVirtual(250); } ctx.note('双击重听连打到慢放'); for (let i = 0; i < 4; i++) { await ctx.gesture('doubletap'); await ctx.waitVirtual(300); } // 菜单乱逛 ctx.note('下滑开菜单,乱切几项再关掉'); await ctx.gesture('down'); await ctx.waitCond(() => game.phase === 'MENU'); for (let i = 0; i < 6; i++) { await ctx.gesture('tap'); await ctx.waitVirtual(250); } await ctx.gesture('up'); // 菜单内帮助 await ctx.waitCond(() => !game.voicePlaying); await ctx.gesture('down'); // 关闭(不管停在哪个项,下滑永远安全) await ctx.waitCond(() => game.phase === 'DECODE' && game.browseArmed); // 两段确认中途取消再重选 ctx.note('单击进确认,再左滑取消,折腾两回'); for (let i = 0; i < 2; i++) { await ctx.gesture('tap'); await waitVoice(ctx); await ctx.gesture('left'); await ctx.waitCond(() => !game.voicePlaying); } // 译对(免费提示早用过了,凭耳朵+慢放兜底) await decodeWord(ctx, 0, '翻到提示也用完了,凭慢放听出来的:货船'); for (let i = 0; i < 6 && game.phase === 'DECODE'; i++) await decodeWord(ctx, 0, '重选'); await relayChoice(ctx, 'right', '压进值机日志——这船的事得看清楚再说'); ctx.status('第一封处置完,压力测试过了第一道'); // 剩下三封:正常打但保留一次乱按(每封浏览期乱滑 10 次) const picks = [0, 0, 0]; const dirs = ['left', 'right', 'right']; for (let tel = 2; tel <= 4; tel++) { await toDecode(ctx); for (let i = 0; i < 10; i++) { await ctx.gesture('right'); await ctx.waitVirtual(200); } await decodeWord(ctx, picks[tel - 2], `第 ${tel} 封:慢放听清再定`); await relayChoice(ctx, dirs[tel - 2], ['报北风/呈报/留书'][0] || '处置掉'); } // 签收 p9(stress 也发最后一电) await untilChoice(ctx); ctx.note('开机,把今晚发出去——乱按一晚,正经事不含糊'); await ctx.gesture('left'); await waitVoice(ctx); await toEnding(ctx); ctx.status('乱按一整晚也走到了结局,零死锁零静默死局'); await ctx.gesture('tap'); // 再来一局 await waitVoice(ctx); }