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
+208 -164
View File
@@ -1,56 +1,71 @@
/**
* 《暗码》新手会话剧本。
* 《暗码》新手会话剧本(26 号重构版:叙事+解码型,radio 范式)。
*
* 剧本:
* - `session`(主线):一位夜班报务员的完整弧线——听完开场白与规矩,逐封截获
* 四封密电(听摩尔斯 → 切候选听词义 → 双击重听/慢放 → 两段确认定词),
* 处置抉择(p2 报北风 / p4 呈报 / p6 留书 / p8 装傻 / p9 发最后一电),
* 听完尾声逐封点名,以完整 E1「最后一电」收尾。
* - `sessionStress`(乱按压力):好奇宝宝乱划乱点、菜单里乱逛、死命翻密码本,
* 一样要能走到 ENDING,全程零死锁、零静默死局。
* - `session`(主线):一位夜班报务员的完整弧线——开场第一句「点一下屏幕。」就动手 →
* 「轮到你了」状态教学 → 第一封电文摩尔斯首播(BUSY 不可跳)→ 浏览候选
* (左右滑自动预听对照档 → 静默一拍 → 词义句)→ 单击进确认(电文原速重播)→
* 再单击定下;四封电文各做一次处置抉择(p2 报北风 / p4 呈报 / p6 留书 /
* p8 装傻 / p9 发最后一电),中途主动重听一次选项(「再说一遍:」)、
* 上滑问一次状态(第几封 → 密码本余页 → 暴露档)、走神一次听三档提示、
* 结局后逛一圈菜单(阶段 2 下滑此时才真解锁),以完整 E1「最后一电」收尾。
* - `sessionStress`(乱按压力):好奇宝宝乱划乱点、确认态反复进出、菜单里乱逛、
* 阶段 3 双击把电文听到慢放,一样要能走到 ENDING,全程零死锁、零静默死局。
*
* 剧本失真红线(参照 radio/abyss 剧本先例):
* - 玩家不抢话:只在语音链播完(waitCond !voicePlaying)之后才出手;
* - 玩家不干晾自己:只在浏览/链末等待态偶尔走神触发一次看门狗;
* 剧本失真红线(参照 radio/storyline 剧本先例):
* - 玩家不抢话:只在话轮真正交出(floor.state === YOUR_TURN,链级判据)之后才
* 出手——BUSY 演出段(电文摩尔斯首播 / T4 danger 拍前置 2s / 确认电文重播)
* 真机里是 busy 回执,剧本同样等它走完;
* - 玩家不干晾自己:只在叙事等待态偶尔走神触发一次等待点提示;
* - RX/NIGHT 有自动链与定时器,禁止裸 waitIdle 空等,必须 waitCond 有界等待;
* - DECODE 里每步都是真输入:切候选、重听、慢放、确认,像真人一样先听后按;
* - RELAY 叙事链是「链末=继续」等待态:推进靠单击(像 tapUntil 一样有界循环),
* 抉择则等提示句播完武装后再滑(盲玩公平,不抢话)。
* - 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 === 'ASK') return '进游戏先听到「单击继续上次,右滑开始新的夜班」的询问';
if (game.phase === 'MENU') {
const names = ['回到电波', '翻密码本', '重新开始今晚', '保存并回大厅'];
return `菜单里,当前项「${names[game.menuIdx]}」:单击切换,双击确认,上滑帮助,下滑关闭`;
return `菜单里,当前项「${names[game.menuIdx]}」:左右滑换项,单击确认,上滑重听说明,下滑关闭`;
}
if (game.phase === 'INTRO') {
return game.voicePlaying ? '开场白正在播(今晚的目标和处长的夜令)' : '开场白播完了,等第一封电文';
if (game.floor.speaking) {
return game.runs <= 1 ? '开场指令正在播:「点一下屏幕。」' : '新夜班口播正在播';
}
return '等待声在响:点一下,接第一封电文';
}
if (game.phase === 'RX') {
return game._rxGuard
? `第 ${TEL_LABEL[game.tel - 1]} 封电文的摩尔斯正在首播,滴答声不可跳过`
? `第 ${TEL_LABEL[game.tel - 1]} 封电文的摩尔斯正在首播,滴答声不可跳(手势只有 busy 回执)`
: `第 ${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.confirming) return '进确认了:电文正在原速重播,再单击就定下来,左右滑可以取消';
return game.floor.speaking
? `第 ${TEL_LABEL[game.decodeTel - 1]} 封:正在听候选词(摩尔斯对照档 → 一拍 → 词义)`
: `第 ${TEL_LABEL[game.decodeTel - 1]} 封:浏览候选中,左右滑听对照档,单击进确认`;
}
if (game.choiceArmed) {
return '抉择念完了:向左或向右滑动做出你的决定';
if (atChoice(game)) {
return `抉择念完了(第 ${TEL_LABEL[game.tel - 1]} 封处置):向左或向右滑做出你的决定`;
}
if (game.phase === 'RELAY') {
return game.voicePlaying ? '处置叙事中,正在听' : '说完了,等我接话:单击继续';
return game.floor.speaking ? '处置叙事中,正在听' : '说完了,等我接话:单击继续';
}
if (game.phase === 'NIGHT') return '夜结算中,挂钟和雨声,马上来下一封';
if (game.phase === 'ENDING') {
return game.awaitingRestart
? '结局和尾声点名都听完了。单击再来一局,下滑开菜单'
return atSettle(game)
? '结局和尾声点名都听完了。单击开新夜班,下滑开菜单'
: '结局播报中';
}
return '……';
@@ -58,217 +73,246 @@ export function describe(game) {
// ---------------------------------------------------------------- 有界推进助手
/** 等当前语音链播完(有界等待,绝不裸空转)。 */
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 (atChoice(game)) 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.waitCond(() => atChoice(game) || atWait(game));
if (atChoice(game)) return;
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 等待段)。 */
/**
* 从任意叙事态推进到 DECODE 浏览武装(穿越 RX 首播摩尔斯 / NIGHT 自动链 /
* RELAY 继续等待段;T4 danger 拍前置 2s 的 BUSY 演出窗也在这里等过去)。
*/
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 遇未应答抉择');
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.voicePlaying) { await ctx.waitCond(() => !game.voicePlaying); continue; }
await ctx.gesture('tap');
await ctx.waitCond(() => game.voicePlaying || game.phase !== 'RX'); // 首播保护窗内 tap 被吞,等真实推进
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})`);
}
throw new Error(`toDecode 超步限(${game.phase}/${game.nodeId})`);
}
/** 从 p9 抉择后单击推进到 ENDING 结算态(d9 结果链含链末等待段)。 */
/** 从 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');
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');
}
throw new Error(`toEnding 超步限(${game.phase}/${game.nodeId})`);
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 小局:慢放听清 → 切候选听词义 → 选 idx → 两段确认。 */
/**
* DECODE 小局(26 号口径):听完当前候选预听 → 左右滑把候选对到 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;
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++) {
ctx.note('左右滑,听密码本上的候选词');
await ctx.gesture('right');
await ctx.waitCond(() => !game.voicePlaying);
await ctx.gesture('right'); // 切候选:候选摩尔斯对照档(620Hz)→ 一拍 → 词义句
await listenCand(ctx);
}
ctx.note('就这个词——单击选定');
await ctx.gesture('tap'); // 进确认
await waitVoice(ctx);
ctx.note('再单击,定下来');
await ctx.gesture('tap'); // 定下判定
await waitVoice(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) {
const { game } = ctx;
await untilChoice(ctx);
ctx.note(why);
await ctx.gesture(dir);
await waitVoice(ctx);
const { game } = ctx;
await ctx.waitCond(() => game.floor.speaking || !game.phase.startsWith('RELAY') || atWait(game));
}
// ---------------------------------------------------------------- 主线剧本
export async function session(ctx, mock) {
const { game } = ctx;
// 开场:INTRO 自动流(夜班开场 + 规矩 + 目标 + 处长夜令)→ RX(1) → DECODE
await ctx.waitCond(() => game.phase === 'RX');
ctx.status('开场白播完了,第一封电文进频道了');
ctx.note('电文滴答声进来了,这是摩尔斯,首播不可跳,听完它');
// 开场(§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('轮到我选词了:双击重听、左右滑听候选');
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('第一封处置完了,夜里结算');
// 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('第二封处置完了');
ctx.status('第二封处置完了,卷宗里落了笔');
// T3 交接(HAND)
// T3 交接(HAND):走神一次,听三档提示的第一档
await toDecode(ctx);
ctx.status('第三封电文:很短,手法有点抖');
await decodeWord(ctx, 0, '今夜交接,丑时老码头——H 和 S 只差一个滴答,幸好放慢了');
await relayChoice(ctx, 'right', '留书暗号,让雨替我跑这一趟');
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 · 高潮)
// 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);
await relayChoice(ctx, 'left', '开机,发最后一电——三年前我译错了一封,这次不能错');
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 waitVoice(ctx);
ctx.status('新的夜班开始了(再来一局)');
await ctx.waitCond(() => game.floor.speaking || atWait(game));
ctx.status('新的夜班开始了(再来一局,不再教「点一下屏幕」)');
}
// ---------------------------------------------------------------- 乱按压力剧本
// 目标:全程零死锁、零静默死局,一样能走到 ENDING。
// 剧本特征:不读提示、快速跳句、确认态反复进出、菜单里乱逛、错手势试探,
// 但仍保留「最终能通关」的底线(菜单里绝不单击确认,避免误触「重开/回大厅」)。
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);
let seed = 20260902;
const rng = () => {
seed = (seed * 1103515245 + 12345) & 0x7fffffff;
return seed / 0x7fffffff;
};
const pick = (arr) => arr[Math.floor(rng() * arr.length)];
// 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);
let menuOpenCount = 0;
let choiceCount = 0;
let decodeCount = 0;
let noiseCount = 0;
let browseIdle = 0; // 浏览态连续没进展的步数(防乱按卡死在某一封)
// 两段确认中途取消再重选
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('第一封处置完,压力测试过了第一道');
// 开场指令句是 critical:抢拍也只会拿到 busy 回执,等它播完再动手
await ctx.waitCond(() => game.floor.state === 'YOUR_TURN');
await ctx.gesture('tap');
// 剩下三封:正常打但保留一次乱按(每封浏览期乱滑 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);
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;
}
await decodeWord(ctx, picks[tel - 2], `第 ${tel} 封:慢放听清再定`);
await relayChoice(ctx, dirs[tel - 2], ['报北风/呈报/留书'][0] || '处置掉');
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']));
}
// 签收 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);
await ctx.waitCond(() => atSettle(game) || game.leaving);
ctx.status(`乱按剧本收尾:解码小局 ${decodeCount} 步、${choiceCount} 个抉择、乱试 ${noiseCount} 次、逛菜单 ${menuOpenCount} 次`);
ctx.note('全程没有任何一次手势被无声吞掉,也没有卡死在某个状态里——结局链照样走完');
}