/** * 《星环舰长》新手会话剧本。 * * 剧本: * - `session`(主线):一位新舰长的首次巡逻全弧线——标题开始 → DRILL D0..D4 跟练 * (巡检/听检测链/预览+驰援下令/再巡检)→ S1 收尾进 DOCK → S2 航线选择+陨石迎战 * → DOCK 修理磨损 → S3 海盗迎战(支援演出)→ S4 辐射规避 → 结算链 → 单击返航。 * - `sessionStress`(乱按压力):标题乱划、教学跳过、巡航乱按乱滑(被看门狗抢救)、 * 菜单乱逛只敢下滑关、危机全部超时走默认结局、DOCK 挂着不操作吃无进展三级提示—— * 一路走到结算(或弃舰),全程零死锁。 * * 剧本失真红线(参照 detective/dungeon 剧本先例): * - 玩家不抢话:出手前等语音链/判定链收尾(waitStep 有界等待状态谓词); * - 玩家靠耳朵玩:危机前先听检测链,下令前左右滑把选项过完耳朵; * - 危机倒计时是真的:决策窗内动作紧凑,宁可超时也不抢检测链; * - 菜单里只「下滑关闭」,绝不双击执行(防误触「保存并回大厅」结束会话)。 */ const DIR_LABEL = { port: '左舷', star: '右舷', bow: '舰首', stern: '舰尾' }; const SYS_LABEL = { engine: '引擎', shield: '护盾', scan: '扫描', weapon: '武器' }; export function describe(game) { if (game._menu.open) return '菜单里:单击切换,双击确认,上滑帮助,下滑关闭'; if (game._tourState) 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} 步,跟着旁白做(下滑可跳过)` : '巡航中:上滑巡检全舰,单击重播最近告警,下滑菜单'; case 'CRISIS': { const c = game._crisis; if (!c) return '危机结算中,先听着'; if (c.subphase === 'detect') return '检测链播报中:听签名音、类型、方位(手势静默保护)'; const opt = { evade: '规避机动', fight: '迎战', breach: '硬闯', aid: '驰援', ignore: '按兵不动' }[c.type === 'meteor' ? ['evade', 'fight', 'breach'][c.optIdx] : c.type === 'pirate' ? ['fight', 'evade', 'ignore'][c.optIdx] : c.type === 'distress' ? ['aid', 'ignore'][c.optIdx] : ['evade', 'breach'][c.optIdx]]; return `决策窗(当前预览:${opt}):左右滑听选项,单击下令,上滑简链,倒计时要紧`; } 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); } /** 在决策窗内把选项循环到目标下标(每步听一句)。 */ 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' ? 3 : c.type === 'pirate' ? 3 : c.type === 'distress' ? 2 : 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); 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; // —— 标题:单击开始首次巡逻 —— await waitStep(ctx, mock, () => game.state === 'TITLE'); ctx.status('星环舰长:一条巡逻航线,四个系统各守一方'); ctx.note('单击,新巡逻启航'); await ctx.gesture('tap'); // —— DRILL D0:等开场链,上滑巡检 —— await waitStep(ctx, mock, () => game._drill && game._drill.armed, 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, () => game._drill?.armed, 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('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('right'); await ctx.waitVirtual(1500); await ctx.gesture('left'); await ctx.waitVirtual(1200); 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 routeN = 0; let seed = 20260914; const rng = () => { seed = (seed * 1103515245 + 12345) & 0x7fffffff; return seed / 0x7fffffff; }; // ① 标题页乱划,然后开始 for (let i = 0; i < 8 && game.state === 'TITLE'; i++) { ctx.status('标题页乱划一气'); await ctx.gesture(rng() < 0.5 ? 'left' : 'doubletap'); await ctx.waitVirtual(450); } await ctx.gesture('tap'); // ② 教学:乱按几下(绝不带 doubletap——菜单开着时双击会真执行),然后下滑跳过 await waitStep(ctx, mock, () => game.state === 'CRUISE' && game._drill?.armed, 40000); let dg = 0; while (game._drill && dg++ < 30) { if (game._menu.open) { // 兜底:菜单开着只敢下滑关 await ctx.gesture('down'); await ctx.waitVirtual(1200); continue; } if (rng() < 0.5) { await ctx.gesture(['tap', 'left', 'right'][Math.floor(rng() * 3)]); await ctx.waitVirtual(450); continue; } ctx.note('下滑,跳过教学'); await ctx.gesture('down'); await ctx.waitVirtual(1500); } await waitStep(ctx, mock, () => !game._drill, 25000); if (game._menu.open) { await ctx.gesture('down'); await ctx.waitVirtual(1200); } ctx.status('教学被跳过了(tut_skip 已播)'); // ③ S1:巡航乱按 40s 虚拟(看门狗 L1 该来催了),S1 危机不理会 → 超时默认结局 const t0 = mock.now; while (game.state === 'CRUISE' && game.run?.seg === 1 && mock.now - t0 < 40000) { if (game._menu.open) { await ctx.gesture('down'); await ctx.waitVirtual(1200); continue; } await ctx.gesture(['tap', 'left', 'right'][Math.floor(rng() * 3)]); if (rng() < 0.12) { ctx.note('手痒开菜单乱逛——只敢下滑关掉'); if (!game._menu.open) await ctx.gesture('down'); await ctx.waitVirtual(2000); if (game._menu.open) await ctx.gesture('down'); await ctx.waitVirtual(1200); continue; } await ctx.waitVirtual(1200); } ctx.status('S1 求救来了也不下令——等它超时(信号消失,零损失)'); await waitStep(ctx, mock, () => game.state !== 'CRISIS', 60000); await waitStep(ctx, mock, () => game.state === 'DOCK', 60000); // DOCK 挂着不动 → 无进展三级提示(45s turn_tick / 150s 自动预览启航) ctx.status('靠港了偏不整备——听它三级提示'); await ctx.waitVirtual(160000); await ctx.gesture('tap'); // 自动预览已选启航项 → 单击走你 await waitStep(ctx, mock, () => game.state === 'ROUTE' || game.state === 'CRUISE', 20000); // ④ S2 起:每个危机都干等超时,乱滑几下测试静默保护 while (game.state !== 'REPORT' && mock.now < 40 * 60000) { if (game.state === 'CRISIS' && game._crisis && game._crisis.subphase === 'detect') { ctx.status('检测链:左滑右滑都不理我(纯聆听保护)'); await ctx.gesture('left'); await ctx.waitVirtual(600); await ctx.gesture('right'); await waitStep(ctx, mock, () => !game._crisis, 60000); await waitStep(ctx, mock, () => ['DOCK', 'REPORT', 'CRUISE', 'ROUTE', 'CRISIS'].includes(game.state), 30000); continue; } if (game.state === 'DOCK') { 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; } if (game.state === 'ROUTE') { // 确定性交替:一半机率选定(rng 序列可能长期不出 tap,烧光虚拟预算) routeN = (routeN + 1) % 2; await ctx.gesture(routeN === 0 ? 'tap' : 'swiperight'); await ctx.waitVirtual(1200); continue; } if (game.state === 'CRUISE') { if (game._menu.open) { await ctx.gesture('down'); await ctx.waitVirtual(1200); continue; } await ctx.gesture(['tap', 'up', 'left'][Math.floor(rng() * 3)]); 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('压力测试收工'); }