/** * 《暗码》新手会话剧本(26 号重构版:叙事+解码型,radio 范式)。 * * 剧本: * - `session`(主线):一位夜班报务员的完整弧线——开场第一句「点一下屏幕。」就动手 → * 「轮到你了」状态教学 → 第一封电文摩尔斯首播(BUSY 不可跳)→ 浏览候选 * (左右滑自动预听对照档 → 静默一拍 → 词义句)→ 单击进确认(电文原速重播)→ * 再单击定下;四封电文各做一次处置抉择(p2 报北风 / p4 呈报 / p6 留书 / * p8 装傻 / p9 发最后一电),中途主动重听一次选项(「再说一遍:」)、 * 上滑问一次状态(第几封 → 密码本余页 → 暴露档)、走神一次听三档提示、 * 结局后逛一圈菜单(阶段 2 下滑此时才真解锁),以完整 E1「最后一电」收尾。 * - `sessionStress`(乱按压力):好奇宝宝乱划乱点、确认态反复进出、菜单里乱逛、 * 阶段 3 双击把电文听到慢放,一样要能走到 ENDING,全程零死锁、零静默死局。 * * 剧本失真红线(参照 radio/storyline 剧本先例): * - 玩家不抢话:只在话轮真正交出(floor.state === YOUR_TURN,链级判据)之后才 * 出手——BUSY 演出段(电文摩尔斯首播 / T4 danger 拍前置 2s / 确认电文重播) * 真机里是 busy 回执,剧本同样等它走完; * - 玩家不干晾自己:只在叙事等待态偶尔走神触发一次等待点提示; * - RX/NIGHT 有自动链与定时器,禁止裸 waitIdle 空等,必须 waitCond 有界等待; * - DECODE 里每步都是真输入:切候选听对照、进确认、定下,像真人一样先听后按 * (旧「双击重听/慢放、长按翻密码本」口径已废止:双击=阶段 3,翻本走菜单项); * - RELAY 叙事链是「链末=继续」等待态:推进靠单击(有界循环),抉择则等提示句 * 播完武装后再滑(盲玩公平,不抢话)。 */ const TEL_LABEL = ['一', '二', '三', '四']; /** 26 号 API:真等待态(链收+话轮已交)与当前等待点 id。 */ const atWait = (game) => !game.floor.speaking && game.floor.state === 'YOUR_TURN'; const wpId = (game) => (game.floor.waitPoint ? game.floor.waitPoint.conf.id : null); const atChoice = (game) => atWait(game) && wpId(game) === 'choice'; const atSettle = (game) => game.phase === 'ENDING' && game.awaitingRestart; 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') { if (game.floor.speaking) { return game.runs <= 1 ? '开场指令正在播:「点一下屏幕。」' : '新夜班口播正在播'; } return '等待声在响:点一下,接第一封电文'; } if (game.phase === 'RX') { return game._rxGuard ? `第 ${TEL_LABEL[game.tel - 1]} 封电文的摩尔斯正在首播,滴答声不可跳(手势只有 busy 回执)` : `第 ${TEL_LABEL[game.tel - 1]} 封电文截获中`; } if (game.phase === 'DECODE') { if (game.confirming) return '进确认了:电文正在原速重播,再单击就定下来,左右滑可以取消'; return game.floor.speaking ? `第 ${TEL_LABEL[game.decodeTel - 1]} 封:正在听候选词(摩尔斯对照档 → 一拍 → 词义)` : `第 ${TEL_LABEL[game.decodeTel - 1]} 封:浏览候选中,左右滑听对照档,单击进确认`; } if (atChoice(game)) { return `抉择念完了(第 ${TEL_LABEL[game.tel - 1]} 封处置):向左或向右滑做出你的决定`; } if (game.phase === 'RELAY') { return game.floor.speaking ? '处置叙事中,正在听' : '说完了,等我接话:单击继续'; } if (game.phase === 'NIGHT') return '夜结算中,挂钟和雨声,马上来下一封'; if (game.phase === 'ENDING') { return atSettle(game) ? '结局和尾声点名都听完了。单击开新夜班,下滑开菜单' : '结局播报中'; } return '……'; } // ---------------------------------------------------------------- 有界推进助手 /** RELAY 链推进到抉择武装:语音在播等它播完,链末等待态单击继续(不抢话)。 */ async function untilChoice(ctx, maxSteps = 40) { const { game } = ctx; for (let i = 0; i < maxSteps; i++) { if (atChoice(game)) return; if (game.phase === 'DECODE') throw new Error('untilChoice 遇 DECODE(需先解码)'); if (game.phase === 'MENU' || game.phase === 'ASK') throw new Error('untilChoice 遇菜单/询问'); await ctx.waitCond(() => atChoice(game) || atWait(game)); if (atChoice(game)) return; await ctx.gesture('tap'); // 链末 = 继续 } throw new Error(`untilChoice 超步限(${game.phase}/${game.nodeId})`); } /** * 从任意叙事态推进到 DECODE 浏览武装(穿越 RX 首播摩尔斯 / NIGHT 自动链 / * RELAY 继续等待段;T4 danger 拍前置 2s 的 BUSY 演出窗也在这里等过去)。 */ async function toDecode(ctx, maxSteps = 60) { const { game } = ctx; for (let i = 0; i < maxSteps && !(game.phase === 'DECODE' && game.browseArmed); i++) { if (game.phase === 'MENU' || game.phase === 'ASK') throw new Error('toDecode 遇菜单/询问'); if (game.phase === 'ENDING') throw new Error('toDecode 遇 ENDING'); if (game.phase === 'DECODE') { await ctx.waitCond(() => game.browseArmed); continue; } if (atWait(game)) { await ctx.gesture('tap'); continue; } // 自动流(摩尔斯首播/夜链/danger 前置):话轮不在手上,有界等推进或回到等待态 await ctx.waitCond(() => (game.phase === 'DECODE' && game.browseArmed) || atWait(game)); } if (!(game.phase === 'DECODE' && game.browseArmed)) { 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 && !atSettle(game); i++) { if (game.phase === 'ENDING') { await ctx.waitCond(() => atSettle(game)); continue; } if (atWait(game)) { await ctx.gesture('tap'); continue; } await ctx.waitCond(() => atSettle(game) || atWait(game) || game.phase === 'ENDING'); } if (!atSettle(game)) throw new Error(`toEnding 超步限(${game.phase}/${game.nodeId})`); } /** 等候选词义句听完(自动预听链:候选摩尔斯 → 静默一拍 → 词义句;被让路则自然跳过)。 */ async function listenCand(ctx) { const { game } = ctx; await ctx.waitCond(() => game.floor.speaking || atWait(game)); await ctx.waitCond(() => atWait(game)); } /** * DECODE 小局(26 号口径):听完当前候选预听 → 左右滑把候选对到 idx(每切自动 * 预听对照档)→ 单击进确认(电文原速重播)→ 再单击定下。 */ async function decodeWord(ctx, idx, why) { const { game } = ctx; await ctx.waitCond(() => game.phase === 'DECODE' && game.browseArmed && atWait(game)); ctx.note(`${why}——先把候选一个一个对耳朵`); await listenCand(ctx); const delta = (idx - game.candIdx + 3) % 3; for (let i = 0; i < delta; i++) { await ctx.gesture('right'); // 切候选:候选摩尔斯对照档(620Hz)→ 一拍 → 词义句 await listenCand(ctx); } ctx.note('就这个词——单击,进确认(电文会再拍一遍原速)'); await ctx.gesture('tap'); await ctx.waitCond(() => wpId(game) === 'confirm' && atWait(game)); ctx.note('对上了。再单击,定下来'); await ctx.gesture('tap'); } /** 等抉择武装并选择 dir(盲玩公平:提示句播完才滑)。 */ async function relayChoice(ctx, dir, why) { await untilChoice(ctx); ctx.note(why); await ctx.gesture(dir); const { game } = ctx; await ctx.waitCond(() => game.floor.speaking || !game.phase.startsWith('RELAY') || atWait(game)); } // ---------------------------------------------------------------- 主线剧本 export async function session(ctx, mock) { const { game } = ctx; // 开场(§4.10):第一句 = critical 指令「点一下屏幕。」→ 风铃 + 等待声 → 第一下单击 await ctx.waitCond(() => game.floor.state === 'YOUR_TURN'); ctx.status('开场第一句就是指令「点一下屏幕。」,没有规则念白'); ctx.note('等待声就是「轮到你了」——点一下,接第一封'); await ctx.gesture('tap'); ctx.status('「听到这个声音,就是轮到你了。」说完,调谐声里进来了第一封电文'); await toDecode(ctx); ctx.status('第一封摩尔斯拍完了,教程只教一句:向右滑,听下一个词'); // T1 船期(教学封:BAY;译错=即时归因+无限重选零惩罚,本线一次译对) await decodeWord(ctx, 0, '教学封 BAY:货船。B,一长三短'); ctx.status('「货船,译对了。」链尾还教了「想知道自己在哪,就向上滑」'); await untilChoice(ctx); ctx.note('药品船丑时过江——先点一下,让它把选项再说一遍'); await ctx.gesture('tap'); // 抉择点空点 = 主动重听(「再说一遍:」+ 完整句) await ctx.waitCond(() => atWait(game)); ctx.status('「再说一遍:」之后又听了完整选项,话轮还在我手上'); await ctx.waitCond(() => atChoice(game)); ctx.note('报给北风,江上有人等这船药'); await ctx.gesture('left'); ctx.status('第一封处置完了,夜结算把开场没讲的规矩补在链首'); // T2 名单(TOP) await toDecode(ctx); ctx.status('第二封电文:内部通报,手法很急'); ctx.note('上滑问一句「我在哪」——第几封、密码本还剩几页、今晚危不危险'); await ctx.gesture('up'); await ctx.waitCond(() => atWait(game) || game.phase !== 'DECODE'); await ctx.waitCond(() => atWait(game)); 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 untilChoice(ctx); ctx.silence(15, '犹豫要不要亲自跑一趟,在想值班室空岗的后果'); await ctx.waitVirtual(15600); // continue 等待点 13s 档:「单击,继续。」 ctx.status('听到一声「单击,继续」,马上点'); await ctx.gesture('tap'); ctx.note('留书暗号,让雨替我跑这一趟'); await ctx.gesture('right'); ctx.status('第三封处置完了,交接赶上了'); // T4 处置令(SPY · 高潮:danger 拍前置 2s,脚步声渐强,手势全是 busy) await toDecode(ctx); ctx.status('第四封电文:快手七的手法——门外的脚步声都近了'); await decodeWord(ctx, 0, '内线确认,今夜三时收网,代号耳机——收网的是我'); ctx.note('揭晓前的两秒,只有渐强的脚步声:这是危机拍,什么手势都打断不了'); await relayChoice(ctx, 'right', '手离开放键器。我只是个值机的——什么都不做'); ctx.status('处长的试探应付过去了,频道要收了'); // 签收:g9_in → g9_knot → p9 await untilChoice(ctx); ctx.note('开机,发最后一电——三年前我译错了一封,这次不能错'); await ctx.gesture('left'); await toEnding(ctx); ctx.status('结局播完了:尾声把今晚每一封都点了名'); // 第一局打完:下滑菜单此刻才真解锁(教学句挂在结局链尾),进去逛一圈 ctx.note('听完结局链尾那句「向下滑,可以打开菜单」——去看看菜单'); await ctx.gesture('down'); await ctx.waitCond(() => game.phase === 'MENU' && atWait(game)); ctx.status('菜单名和第一项「回到电波」报完了'); await ctx.gesture('right'); // 下一项报名:「翻密码本」(长按捷径废止后的正门) await ctx.waitCond(() => atWait(game)); ctx.status('听完「翻密码本」的报名,不动它'); await ctx.gesture('down'); // 下滑关闭(不确认,绝不误触「重开/回大厅」) await ctx.waitCond(() => atSettle(game)); ctx.status('菜单关了,结局链复述了一遍局面,回到结算'); ctx.note('单击,收工——但下一班夜班,还想再来'); await ctx.gesture('tap'); await ctx.waitCond(() => game.floor.speaking || atWait(game)); ctx.status('新的夜班开始了(再来一局,不再教「点一下屏幕」)'); } // ---------------------------------------------------------------- 乱按压力剧本 // 目标:全程零死锁、零静默死局,一样能走到 ENDING。 // 剧本特征:不读提示、快速跳句、确认态反复进出、菜单里乱逛、错手势试探, // 但仍保留「最终能通关」的底线(菜单里绝不单击确认,避免误触「重开/回大厅」)。 export async function sessionStress(ctx, mock) { const { game } = ctx; let seed = 20260902; const rng = () => { seed = (seed * 1103515245 + 12345) & 0x7fffffff; return seed / 0x7fffffff; }; const pick = (arr) => arr[Math.floor(rng() * arr.length)]; let menuOpenCount = 0; let choiceCount = 0; let decodeCount = 0; let noiseCount = 0; let browseIdle = 0; // 浏览态连续没进展的步数(防乱按卡死在某一封) // 开场指令句是 critical:抢拍也只会拿到 busy 回执,等它播完再动手 await ctx.waitCond(() => game.floor.state === 'YOUR_TURN'); await ctx.gesture('tap'); for (let i = 0; i < 400 && !game.leaving; i++) { // 话轮不在手上(摩尔斯首播/danger 前置/自动链):有界等,绝不空转 await ctx.waitCond(() => atWait(game) || game.phase === 'ENDING'); if (game.leaving) break; if (game.phase === 'MENU') { menuOpenCount++; await ctx.gesture(pick(['left', 'right'])); // 乱逛换项(每项报名) await ctx.waitCond(() => atWait(game)); await ctx.gesture('down'); // 下滑关闭(不单击确认,绝不误触「重开/回大厅」) continue; } if (atSettle(game)) break; if (game.phase === 'ENDING') { await ctx.waitCond(() => atSettle(game)); continue; } if (atChoice(game)) { choiceCount++; if (rng() < 0.35) { // 好奇宝宝:犹豫时乱试一个不推进剧情的手势(重听选项/问状态/无效) noiseCount++; await ctx.gesture(pick(['up', 'tap', 'doubletap'])); await ctx.waitCond(() => atWait(game)); } await ctx.gesture(pick(['left', 'right'])); // 乱选也要选,剧情照走 continue; } if (game.phase === 'DECODE') { // 浏览:切候选/问状态/阶段 3 双击重听电文原速→慢放,都可乱来; // 确认态:单击=定下(乱按也会收敛——T1 译错即时归因无限重选) decodeCount++; if (game.confirming || browseIdle >= 4 || rng() < 0.4) { browseIdle = 0; await ctx.gesture('tap'); } else { browseIdle++; await ctx.gesture(pick(['left', 'right', 'up', 'doubletap', 'tap'])); } // 电文重播/预听链是有界自动流:等它落回等待态或进下一阶段 await ctx.waitCond(() => atWait(game) || game.phase === 'RELAY' || game.phase === 'NIGHT'); continue; } // 叙事等待态 / RX 外的静默帧:乱划一下(无效手势也要有反馈,零静默) noiseCount++; await ctx.gesture(pick(['tap', 'left', 'right', 'up', 'down', 'doubletap'])); } await ctx.waitCond(() => atSettle(game) || game.leaving); ctx.status(`乱按剧本收尾:解码小局 ${decodeCount} 步、${choiceCount} 个抉择、乱试 ${noiseCount} 次、逛菜单 ${menuOpenCount} 次`); ctx.note('全程没有任何一次手势被无声吞掉,也没有卡死在某个状态里——结局链照样走完'); }