/** * 《不眠频率》新手会话剧本(26 号重构版:叙事型,storyline 范式)。 * * 剧本: * - `session`(主线):一位夜间主播的完整弧线——开场第一句「点一下屏幕」就动手 → * 听热线灯教学接第一通 → 五通电话每个抉择都做选择(C1 走心 / C2 劝上车 / * C3 问她+真话 / C4 带话+留在直播 / C5 陪聊 / p9 直面),中途主动重听一次选项、 * 走神一次听三档提示、结局后逛一圈菜单(阶段 2 下滑此时才真解锁), * 以完整 E1「天亮之前」收尾。 * - `sessionStress`(乱按压力):一位好奇宝宝乱划乱点、快速跳句、菜单里乱逛, * 一样要能走到 ENDING,全程零死锁、零静默死局。 * * 剧本失真红线(参照 storyline/rpg 剧本先例): * - 玩家不抢话:只在话轮真正交出(floor.state === YOUR_TURN,链级判据)之后才 * 出手——voiceBusy 是句级信号、链内句间会闪断,不能当门控;BUSY 演出段 * (危机拍前置窗/幕间)真机里是 busy 回执,剧本同样等它走完; * - 玩家不干晾自己:只在 ONAIR 叙事等待态偶尔走神触发一次等待点提示; * - RING/BRIDGE 有铃声循环/喘息定时器,禁止裸 waitIdle 空等,必须 waitCond 有界等待; * - 通话里每句之间是「链末=继续」的等待态——真人听完一句就点一下,不机械、不抢跑。 */ const CALL_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' && wpId(game) === 'settle'; 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.floor.speaking ? '开场指令正在播:「点一下屏幕。」' : '等待声在响:点一下,进第一通'; } if (game.phase === 'RING') return `第 ${CALL_LABEL[game.callN - 1]} 通电话打进来:铃声就是提示,点一下接听`; if (atChoice(game)) { return `选项念完了(第 ${CALL_LABEL[game.callN - 1]} 通):向左或向右滑做出你的决定`; } if (game.floor.speaking) return `第 ${CALL_LABEL[game.callN - 1]} 通通话中,正在听对方说话`; if (game.phase === 'ONAIR') return `对方说完了,等我接话(第 ${CALL_LABEL[game.callN - 1]} 通):单击继续`; if (game.phase === 'BRIDGE') return '幕间喘息,垫乐播放中,马上接下一通'; if (game.phase === 'ENDING') { return atSettle(game) ? '结局和五条回响都听完了。单击开新的夜班,下滑开菜单' : '结局播报中'; } return '……'; } // ---------------------------------------------------------------- 主线剧本 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'); await ctx.waitCond(() => game.phase === 'RING'); ctx.status('「热线灯亮了。点一下,接听。」听完,铃声就是等待声'); await ctx.gesture('tap'); await ctx.waitCond(() => game.floor.state === 'YOUR_TURN'); ctx.status('线路接通了,导播报完线,老人开口了'); let relistened = false; let walkedAway = false; let menuVisited = false; let choiceIdx = 0; // 抉择路径:C1 走心、C2 劝上车、C3 问她+真话、C4 带话+留在直播、C5 陪聊、p9 直面 const choices = [ { dir: 'left', why: '老人在发抖,我陪他哼那首老歌' }, { dir: 'left', why: '留在原地陪他等巡逻队' }, { dir: 'left', why: '小鹿妈妈等的是那声「妈」,劝她上车' }, { dir: 'right', why: '糖糖像是憋着事,我轻轻问她怎么了' }, { dir: 'left', why: '告诉她这不是她的错——大人的事不算在孩子头上' }, { dir: 'left', why: '陈姐听见孩子问话了,我把话带回去' }, { dir: 'left', why: '留在直播里,让全城等这声门铃' }, { dir: 'left', why: '老韩眼皮在打架,我陪他聊聊闺女' }, { dir: 'left', why: '留言机红灯在闪——直面它,替老主播把晚安播出去' }, ]; for (let i = 0; i < 200 && !game.leaving; i++) { // 只等当前语音链播完或抉择武装;RING/BRIDGE 有定时器,绝不用裸 waitIdle await ctx.waitCond(() => game.floor.state === 'YOUR_TURN'); if (game.leaving) break; if (game.phase === 'RING') { ctx.status(`第 ${CALL_LABEL[game.callN - 1]} 通电话响了,接`); await ctx.gesture('tap'); await ctx.waitCond(() => game.floor.state === 'YOUR_TURN'); continue; } if (atChoice(game)) { const pick = choices[choiceIdx]; if (!pick) break; // 抉择走完(理论上到 p9 后应已进 ENDING) // p2 起上滑已解锁(阶段 1):犹豫时点一下重听选项(原则 9 主动重听) if (!relistened && choiceIdx >= 1) { relistened = true; ctx.note(pick.why + '——没听清,先点一下,让它「再说一遍」'); await ctx.gesture('tap'); await ctx.waitCond(() => game.floor.state === 'YOUR_TURN'); ctx.status('「再说一遍:」之后又听了完整选项,话轮还在我手上'); await ctx.waitCond(() => atChoice(game)); } ctx.status('选项念完了,做决定'); ctx.note(pick.why); await ctx.gesture(pick.dir); // 念完即选,不干晾(犹豫会在等待态空烧提示三档) if (choiceIdx === 0) { ctx.status('链头一声「对。」,链尾教了「想知道自己在哪,就向上滑」'); ctx.note('做完第一件事才教下一件事,一句话只教一个'); } choiceIdx++; continue; } if (game.phase === 'ENDING') { if (!menuVisited && atSettle(game)) { // 第一局打完,下滑菜单此刻才真正解锁(教学句挂在结局链尾) menuVisited = true; ctx.note('听完结局链尾那句「向下滑,可以打开菜单」——去看看菜单'); await ctx.gesture('down'); await ctx.waitCond(() => game.floor.state === 'YOUR_TURN'); ctx.status('菜单名和第一项「回到直播」报完了'); await ctx.gesture('right'); // 下一项报名 await ctx.waitCond(() => game.floor.state === 'YOUR_TURN'); ctx.status('听完「重新开始今晚」的报名,不动它'); await ctx.gesture('down'); // 下滑关闭(不确认,绝不误触「重开/回大厅」) await ctx.waitCond(() => atSettle(game)); ctx.status('菜单关了,结局链复述了一遍局面,回到结算'); continue; } await ctx.waitCond(() => atSettle(game)); continue; } if (game.phase === 'BRIDGE') { ctx.status('幕间,口碑播报和垫乐,等下一通'); await ctx.waitVirtual(2600); continue; } // ONAIR 叙事等待态(链末=继续):真人听完一句就接话,偶尔走神一次。 // 注意不要在这里 waitVirtual 小憩——等待态挂着提示定时器,waitVirtual // 会一口气跳到下一个定时器 tick(18s 提示档),剧本就变成「每句干晾 18s」。 if (!walkedAway && game.phase === 'ONAIR') { walkedAway = true; ctx.silence(25, '听到一半走了神,在想老人的老花镜'); await ctx.waitVirtual(25600); // 触发等待点提示 full 档(18s):「单击,继续。」 ctx.status('听到一声「单击,继续」,马上点'); await ctx.gesture('tap'); continue; } await ctx.gesture('tap'); // 链末单击 = 继续 } await ctx.waitCond(() => game.phase === 'ENDING' && atSettle(game)); ctx.status('结局链走完,五条回响一句句点到我今晚的每一次选择'); ctx.note('听完尾声那笔账——今晚的人都接住了,会话结束(E1 天亮之前)'); } // ---------------------------------------------------------------- 乱按压力剧本 // 目标:全程零死锁、零静默死局,一样能走到 ENDING。 // 剧本特征:不读提示、快速跳句、菜单里乱逛、错手势试探,但仍保留「最终能通关」的底线。 export async function sessionStress(ctx, mock) { const { game } = ctx; let seed = 20260901; const rng = () => { seed = (seed * 1103515245 + 12345) & 0x7fffffff; return seed / 0x7fffffff; }; const gestures = ['tap', 'left', 'right', 'up', 'down', 'doubletap']; const pick = (arr) => arr[Math.floor(rng() * arr.length)]; let menuOpenCount = 0; let choiceCount = 0; let answered = 0; let noiseCount = 0; // 开场指令句是 critical:抢拍也只会拿到 busy 回执,等它播完再动手 await ctx.waitCond(() => game.floor.state === 'YOUR_TURN'); await ctx.gesture('tap'); for (let i = 0; i < 300; i++) { await ctx.waitCond(() => game.floor.state === 'YOUR_TURN'); if (game.leaving) break; if (game.phase === 'RING') { answered++; await ctx.gesture('tap'); // 不接会一直响,乱按也照接 continue; } if (game.phase === 'MENU') { menuOpenCount++; await ctx.gesture(pick(['left', 'right'])); // 乱逛换项(每项报名) await ctx.waitCond(() => game.floor.state === 'YOUR_TURN'); await ctx.gesture('down'); // 下滑关闭(不单击确认,避免误触「重开/回大厅」) continue; } if (atChoice(game)) { choiceCount++; if (rng() < 0.35) { // 好奇宝宝:犹豫时乱试一个不推进剧情的手势(重听/问状态/无效) noiseCount++; await ctx.gesture(pick(['up', 'tap', 'doubletap'])); await ctx.waitCond(() => game.floor.state === 'YOUR_TURN'); } await ctx.gesture(pick(['left', 'right'])); continue; } if (game.phase === 'BRIDGE') { await ctx.waitVirtual(2600); continue; } if (game.phase === 'ENDING') { await ctx.waitCond(() => atSettle(game)); break; } // ONAIR 叙事等待态 / 其他静默帧:乱划一下(无效手势也要有反馈,零静默) noiseCount++; await ctx.gesture(pick(gestures)); } await ctx.waitCond(() => game.phase === 'ENDING' && atSettle(game)); ctx.status(`乱按剧本收尾:接听 ${answered} 次、${choiceCount} 个抉择、乱试 ${noiseCount} 次、逛菜单 ${menuOpenCount} 次`); ctx.note('全程没有任何一次手势被无声吞掉,也没有卡死在某个状态里——结局链照样走完'); }