/** * 《星环舰长》新手会话剧本(26 号重构版:叙事+玩法混合型,radio 范式)。 * * 剧本: * - `session`(主线):一位新舰长的首次巡逻全弧线——开场第一句「点一下屏幕」就 * 动手 → D0 上滑巡检(四系统音+账目档+解锁句)→ D1 听求救检测链 → D2 预览+下令 * 驰援(第一次成功 pa_chime+报酬)→ D3 再巡检 → 段末靠港启航 → S2 航线选择+ * 陨石迎战 → S3 海盗(大副支援)→ S4 辐射规避 → 结算链 → 单击返航;危机前 * 选项过完耳朵、犹豫时主动重听一次选项(阶段 3 口径由阶梯解锁状态决定)。 * - `sessionStress`(乱按压力):一位好奇宝宝乱划乱点、快速跳句、检测链乱滑 * (被解释并续完)、菜单里只换项不乱确认、危机挂到超时走默认结局——一路走到 * 结算(或弃舰),全程零死锁、零静默死局。 * * 剧本失真红线(参照 radio/dungeon 剧本先例): * - 玩家不抢话:出手前等真正等待态(floor.state === 'YOUR_TURN' 或玩法 ACTION 态 * 静默),检测链(纯聆听)绝不出手打断了事;voiceBusy 是句级信号不作门控; * - 玩家靠耳朵玩:危机先听检测链(签名→类型→方位),下令前左右滑把选项过完 * 耳朵,巡检靠上滑不靠背; * - 危机倒计时是真的:决策窗内动作紧凑,宁可超时也不抢检测链; * - 菜单里只「下滑关闭」,破坏项(放弃巡逻)绝不确认(防误触结束会话)。 */ const DIR_LABEL = { port: '左舷', star: '右舷', bow: '舰首', stern: '舰尾' }; const SYS_LABEL = { engine: '引擎', shield: '护盾', scan: '扫描', weapon: '武器' }; const OPT_LABEL = { evade: '规避机动', fight: '迎战', breach: '硬闯', aid: '驰援', ignore: '按兵不动' }; /** 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); export function describe(game) { if (game._goingHome) return '已确认保存并回大厅'; if (game._menu.open) { const names = ['回到舰桥', '保存并回大厅', '放弃本次巡逻']; return `菜单里,当前项「${names[game._menu.idx]}」:左右滑换项,单击确认,上滑重听说明,下滑关闭`; } if (game._tourState) return '巡检链播报中(四系统音+账目档):单击跳段,上滑重播全链'; if (game.state === 'CRISIS' && game._crisis) { const c = game._crisis; if (c.subphase === 'detect') return '检测链播报中:听签名音、类型、方位(先听完再动)'; if (c.subphase === 'decide') { const opt = { meteor: ['evade', 'fight', 'breach'], pirate: ['fight', 'evade', 'ignore'], distress: ['aid', 'ignore'], radiation: ['evade', 'breach'] }[c.type][c.optIdx]; return `决策窗(当前预览:${OPT_LABEL[opt]}):左右滑听选项,单击下令,倒计时要紧`; } return '危机结算链播报中,先听着'; } switch (game.state) { case 'TITLE': return game.run ? '检测到未完成的巡逻:单击继续,右滑进星港,上滑听舰船概况' : '标题页:单击开始新巡逻,右滑进星港,上滑听概况'; case 'PORT': return '星港里:左右滑看改装,单击购买,末项返回'; case 'ROUTE': return game._route.idx === 0 ? '选航线:稳妥线(采集平平)。右滑听矿富线,单击选定' : '选航线:矿富线(采得多磨损也多)。左滑回稳妥线,单击选定'; case 'CRUISE': return game._drill ? `教学第 ${game._drill.step} 步(${game._drill.step === 0 || game._drill.step === 3 ? '等上滑巡检' : '听/做'})` : '巡航中:上滑巡检全舰(我在哪),单击重播最近告警'; case 'DOCK': return `靠港整备:${game._dock.items.map((i) => (i.kind === 'go' ? '启航' : '修理' + SYS_LABEL[i.sys])).join(' / ')}——左右滑看项目,单击执行`; case 'REPORT': return game._report && !game._report.done ? '结算链播报中,手势全被吞(链锁)' : '结算完了:单击返航,上滑重听评级'; default: return '……'; } } // ---------------------------------------------------------------- 会话工具 /** 有界等待:条件满足或超时即返回。 */ async function waitStep(ctx, mock, cond, maxMs = 30000) { const t0 = mock.now; let spins = 0; const spinCap = Math.max(60, Math.floor(maxMs / 50)); while (!cond() && mock.now - t0 < maxMs && spins < spinCap) { spins++; await ctx.waitVirtual(100); } } /** 等链收/巡检收尾(有界)。 */ async function waitQuiet(ctx, mock, game, maxMs = 60000) { await waitStep(ctx, mock, () => !game.audio.isVoicePlaying() && !game._tourState, maxMs); } /** 等真正的等待态(话轮已交)或巡航 ACTION 静默。 */ async function waitTurn(ctx, mock, game, maxMs = 60000) { await waitStep(ctx, mock, () => atWait(game) || (game.state === 'CRUISE' && !game._tourState && !game.floor.speaking), maxMs); } /** 决策窗内把选项循环到目标下标(每步听一句报名)。 */ async function navOpt(ctx, game, targetIdx, nOpts) { let guard = 0; while (game._crisis && game._crisis.optIdx !== targetIdx && guard++ < 6) { const d = (targetIdx - game._crisis.optIdx + nOpts) % nOpts; await ctx.gesture(d * 2 <= nOpts ? 'right' : 'left'); await ctx.waitVirtual(1400); } } /** 危机决策:等检测链落决策窗 → 选项过耳朵 → 单击下令。 */ async function decide(ctx, mock, game, targetIdx, note) { const c = game._crisis; const nOpts = c.type === 'meteor' || c.type === 'pirate' ? 3 : 2; await waitStep(ctx, mock, () => game._crisis && game._crisis.subphase === 'decide', 40000); await ctx.waitVirtual(1500); // 首选项预览落声 await navOpt(ctx, game, targetIdx, nOpts); ctx.note(note); await ctx.gesture('tap'); } /** 一次完整危机:从巡航等检测链到结算链收尾。 */ async function rideCrisis(ctx, mock, game, targetIdx, plan) { await waitStep(ctx, mock, () => game.state === 'CRISIS', 150000); const c = game._crisis; ctx.status(`检测链:签名音→「${plan.type}」→方位${DIR_LABEL[c.dir]}${c.trap ? '——医师提醒:求救码后面有引擎声!' : ''}`); await waitStep(ctx, mock, () => game._crisis && game._crisis.subphase === 'decide', 40000); await decide(ctx, mock, game, targetIdx, plan.note); await waitStep(ctx, mock, () => game._crisis === null, 60000); await waitQuiet(ctx, mock, game, 40000); } /** 巡航到段末:途中巡检一次(我在哪),然后等危机。 */ async function cruiseAndCrisis(ctx, mock, game, tourNote, plan) { await waitStep(ctx, mock, () => game.state === 'CRUISE', 30000); await waitTurn(ctx, mock, game, 20000); ctx.note(tourNote); await ctx.gesture('up'); // 巡检(四系统音+舰体档+一个动作) await ctx.waitVirtual(20000); await rideCrisis(ctx, mock, game, plan.optIdx, plan); } /** DOCK 整备:修一遍能修的,然后启航。 */ async function dockAndGo(ctx, mock, game) { await waitStep(ctx, mock, () => game.state === 'DOCK', 60000); ctx.status('靠港整备:左右滑听听项目'); const items = game._dock.items; for (let i = 0; i < items.length; i++) { const it = game._dock.items[game._dock.idx]; if (it.kind !== 'go') { ctx.note(`修理${SYS_LABEL[it.sys]}(升一级,两资源)`); await ctx.gesture('tap'); await ctx.waitVirtual(4000); } await ctx.gesture('right'); await ctx.waitVirtual(1200); if (game._dock.items[game._dock.idx]?.kind === 'go') break; } ctx.note('启航,下一段巡航'); await ctx.gesture('tap'); await waitStep(ctx, mock, () => game.state === 'ROUTE' || game.state === 'CRUISE' || game.state === 'REPORT', 20000); } // ---------------------------------------------------------------- 主线剧本 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'); // —— D0:等教学链(轮到你了+巡检指令)→ 上滑巡检 —— await waitStep(ctx, mock, () => wpId(game) === 'inspect', 40000); ctx.note('上滑——听全舰状态(四个音都稳)'); await ctx.gesture('up'); await waitStep(ctx, mock, () => game._drill && game._drill.step === 1, 60000); ctx.status('巡检学会了:四个系统各 2 秒,顺序固定;链尾教了「想知道自己在哪,就向上滑」'); // —— D1/D2:求救检测链(纯聆听)→ 预览 → 下令驰援 —— await rideCrisis(ctx, mock, game, 0, { type: '求救信号', optIdx: 0, note: '驰援靠扫描——我们的扫描稳得很。单击,下令驰援!', }); await waitStep(ctx, mock, () => game._drill && game._drill.step === 3, 40000); ctx.status('驰援成功:资源+2,医师和大副记下了——听声辨型后下令,就是这样'); // —— D3:再巡检(听船员三档)→ D4 完成 —— await waitStep(ctx, mock, () => wpId(game) === 'inspect', 20000); ctx.note('上滑——再巡检一遍,听听船员档位'); await ctx.gesture('up'); await waitStep(ctx, mock, () => game.state === 'DOCK', 90000); ctx.status('教学完了,本段结束,靠港整备'); await dockAndGo(ctx, mock, game); // —— S2 陨石:听听矿富线再回稳妥,迎战轰开 —— await waitStep(ctx, mock, () => game.state === 'ROUTE', 20000); ctx.note('矿富航线听听——算了,稳妥线。单击'); await ctx.gesture('right'); await ctx.waitVirtual(1500); await ctx.gesture('left'); await ctx.waitVirtual(1200); await ctx.gesture('tap'); await cruiseAndCrisis(ctx, mock, game, '上滑巡检:磨损的扫描在左舷发失锁双哔', { type: '陨石群', optIdx: 1, note: '迎战,靠武器——单击,轰开它', }); await dockAndGo(ctx, mock, game); // —— S3 海盗:迎战(可能触发大副支援)—— await waitStep(ctx, mock, () => game.state === 'ROUTE', 20000); ctx.note('稳妥航线,太平——单击选定'); await ctx.gesture('tap'); await cruiseAndCrisis(ctx, mock, game, '上滑巡检:舰体带伤没有?资源还够用', { type: '海盗', optIdx: 0, note: '敌船引擎声,右舷来的——迎战!单击', }); await dockAndGo(ctx, mock, game); // —— S4 辐射:规避 → 结算 —— await waitStep(ctx, mock, () => game.state === 'ROUTE', 20000); ctx.note('末段了。矿富线搏一把——单击选定'); await ctx.gesture('right'); await ctx.waitVirtual(1500); await ctx.gesture('tap'); await cruiseAndCrisis(ctx, mock, game, '上滑巡检:倒计时最紧的一段,听清楚再动', { type: '辐射云', optIdx: 0, note: '计数急响——规避机动,靠引擎。单击!', }); // —— 结算链 → 单击返航 —— await waitStep(ctx, mock, () => game.state === 'REPORT', 90000); ctx.status('巡逻归来:评级与功勋在播,手势先别动(链锁=吞)'); await waitStep(ctx, mock, () => game._report && game._report.done, 60000); ctx.status(`评级 ${game._report.rate}——星港期待下一次巡逻`); ctx.note('单击,返航'); await ctx.gesture('tap'); await waitStep(ctx, mock, () => game.state === 'TITLE', 10000); ctx.status('回到标题页:首次巡逻闭环'); } // ---------------------------------------------------------------- 乱按压力剧本 // 目标:全程零死锁、零静默死局,一样能走到结算。 // 剧本特征:不读提示、快速跳句、检测链乱滑(被打断后续完)、菜单只换项下滑关、 // 危机挂到超时走默认结局——但保留「最终能通关」的底线。 export async function sessionStress(ctx, mock) { const { game } = ctx; let seed = 20261002; const rng = () => { seed = (seed * 1103515245 + 12345) & 0x7fffffff; return seed / 0x7fffffff; }; // ① 开场指令句是 critical:抢拍只会拿到 busy 回执,等它播完再动手 await ctx.waitCond(() => game.floor.state === 'YOUR_TURN'); await ctx.gesture('tap'); // ② D0 教学:乱按几下(tap/滑都是 invalid 回执+一次解释),最后照做上滑 await waitStep(ctx, mock, () => wpId(game) === 'inspect', 40000); for (let i = 0; i < 4; i++) { await ctx.gesture(['tap', 'left', 'right'][Math.floor(rng() * 3)]); await ctx.waitVirtual(450); } ctx.note('被咚了几下,还有一句「先向上滑」——照做'); await ctx.gesture('up'); await waitStep(ctx, mock, () => game._drill && game._drill.step === 1, 60000); // ③ S1 求救:检测链乱滑(invalid+解释+续完剩余句,保护链);决策窗不下令 → 超时 await waitStep(ctx, mock, () => game.state === 'CRISIS', 120000); ctx.status('检测链:乱滑也不掐链——它解释一句,把剩下的类型方位接着讲完'); await ctx.gesture('left'); await ctx.waitVirtual(600); await ctx.gesture('right'); await waitStep(ctx, mock, () => game._crisis && game._crisis.subphase === 'decide', 40000); ctx.status('求救来了也不下令——等它超时(信号消失,零损失)'); await waitStep(ctx, mock, () => game._crisis === null, 60000); await waitStep(ctx, mock, () => game.state === 'DOCK', 90000); // 教学收束(D3 兜底替播巡检) await dockAndGo(ctx, mock, game); // ④ S2 起:一半危机干等超时,一半随手点第一项;巡航乱按乱滑听回执 let seg = 2; let dockRounds = 0; let routeRounds = 0; while (game.state !== 'REPORT' && mock.now < 40 * 60000) { if (game.state === 'CRISIS' && game._crisis) { const c = game._crisis; if (c.subphase === 'detect') { await waitStep(ctx, mock, () => !game._crisis || game._crisis.subphase !== 'detect', 60000); continue; } if (c.subphase === 'decide') { if (rng() < 0.5) { ctx.note('这次不下令,等超时默认结局'); await waitStep(ctx, mock, () => game._crisis === null, 60000); } else { await ctx.gesture('tap'); // 预览到哪个就下哪令(鲁莽但合法) await waitStep(ctx, mock, () => game._crisis === null, 60000); } await waitQuiet(ctx, mock, game, 40000); continue; } await ctx.waitVirtual(1500); continue; } if (game.state === 'DOCK') { // 乱按修理/启航(无资源时 rep_poor 真话账);逛几轮仍出不去就走收敛整备 if (dockRounds++ < 3) { ctx.note('乱按修理/启航'); for (let i = 0; i < 3; i++) { await ctx.gesture('right'); await ctx.waitVirtual(800); } await ctx.gesture('tap'); await ctx.waitVirtual(2500); continue; } dockRounds = 0; ctx.note('瞎逛够了,按项目顺序整备一遍'); await dockAndGo(ctx, mock, game); continue; } if (game.state === 'ROUTE') { // 乱划航线(每次报名);逛两轮后在话轮上单击选定(报名播完 tap 才是选定) routeRounds++; if (routeRounds < 3) { await ctx.gesture(rng() < 0.5 ? 'right' : 'left'); await ctx.waitVirtual(1200); continue; } routeRounds = 0; await waitStep(ctx, mock, () => atWait(game), 30000); await ctx.gesture('tap'); await ctx.waitVirtual(1500); continue; } if (game.state === 'CRUISE') { if (game._menu.open) { await ctx.gesture('down'); await ctx.waitVirtual(1200); continue; } // 巡航乱按:单击重播告警、乱滑吃 invalid 回执、偶尔开菜单只敢下滑关 const roll = rng(); if (roll < 0.1 && !game._tourState) { ctx.note('手痒开菜单乱逛——只敢下滑关掉'); await ctx.gesture('down'); await ctx.waitVirtual(2000); if (game._menu.open) await ctx.gesture('down'); await ctx.waitVirtual(1200); continue; } await ctx.gesture(['tap', 'up', 'left', 'right'][Math.floor(rng() * 4)]); await ctx.waitVirtual(1500); continue; } if (game.state === 'REPORT') break; await ctx.waitVirtual(1500); } await waitStep(ctx, mock, () => game.state === 'REPORT' && game._report?.done, 180000); if (game._report) { ctx.status(`乱按局也走完了:评级 ${game._report.rate},全程零死锁`); await ctx.gesture('tap'); await waitStep(ctx, mock, () => game.state === 'TITLE', 10000); } else { ctx.status(`乱按局超出虚拟时长上限(${Math.round(mock.now / 60000)} 分钟),当前状态 ${game.state}——如实记录收尾`); } ctx.status('压力测试收工'); }