/** * 《回声地窟》新手会话剧本(26 号重构版)。 * * 交互口径(web/dungeon/game.js 26 号状态×手势表,旧剧本的旧口径全废): * - 四向滑动 = 面朝相对移动:up=前进一格;left/right=向侧走一格并转向;down=后退一格 * (旧「长按前进」废除);swipeCore,不按新手阶梯锁。 * - 单击 = 声波脉冲(探索,冷却 1.2s)/ 下潜确认(楼梯格)/ 挥击(战斗我的回合)/ * 格挡(敌方前摇 1.2s 窗内)——旧「双击格挡」「双击免冷却重发」废除。 * - 长按 = 「我在哪」(层+血 if 伤+面朝+目标方位,≤20 字)——旧「按住前进」废除。 * - 教学链 D0-D7(不再是九步):D0 点一下听回声 → D1 巩固 → D2 上滑前进 → * D3 左滑侧移 → D4 自由捡金币 → D5 长按问路 → D6 教学战(单击格挡+单击挥击)→ * D7 自由寻楼梯单击下潜(教学出口)。 * * 剧本: * - `session`(主线):一位盲眼新手玩家的完整弧线——标题页第一句就是指令 → * D0-D7 教学逐项跟练(含教学战)→ 下潜第二层真正探窟:脉冲听路、明雷遭遇战 * (前摇单击格挡、回合单击挥击)→ 第三层窟主守梯,力战而亡听死亡归因与 * 元进度入账 → 结算走神一次听三档提示 → 逛一圈菜单(死亡后下滑才解锁)→ * 单击再探一次:第二周目遗产继承、不再上课,短程收尾。 * - `sessionStress`(乱按压力):一位手忙脚乱的好奇宝宝——标题乱划、教学里乱按、 * 狂点脉冲、战斗乱滑乱逃、探索乱撞墙,一样要走到死亡-重开循环,全程零死锁。 * * 剧本失真红线(参照 radio/escape/abyss 剧本先例): * - 玩家不抢话:教学作答步(D0-D3/D5)与战斗回合是 floor.state === 'YOUR_TURN' * 的链级判据(voiceBusy 是句级信号、链内句间会闪断,不能当门控);自由行动步 * (D4/D6/D7/探索)= ACTION,等内容链播完(!floor.speaking)再出手; * - 玩家靠耳朵玩游戏:走位前单击脉冲听回声,导航只是把「听到的方向」翻译成 * 一次四向滑动(侧移自带转向,一步一个手势); * - 怪物游走/教学怪逼近/前摇窗(1.2s)都有定时器,禁止裸 waitIdle 空等,一律 * waitCond/waitVirtual 有界等待;前摇声一响(telegraph)立刻单击格挡; * - 菜单里绝不单击确认(重开=破坏性二次确认、保存回大厅=结束会话):只左右滑 * 换项听报名,下滑关闭。 */ const DIR_ORDER = ['N', 'E', 'S', 'W']; /** 相对方位(0 前/1 右/2 后/3 左)→ 手势(四向滑动 = 面朝相对移动)。 */ const REL_GESTURE = ['up', 'right', 'down', 'left']; const HP_BAND = { 1: '毫发无损', 2: '受了点伤', 3: '伤势不轻', 4: '命悬一线' }; const DRILL_TEXT = { 0: '教学第一步:「黑暗。点一下,听回声。」', 1: '「对。」——巩固:再点一次脉冲', 2: '「朝前,滑一下。」——上滑,前进一格', 3: '「向左滑,转身走。」——左滑,侧移并转向', 4: '「房里有金币,走过去捡。」——自由行动(四向滑动走路,单击脉冲探路)', 5: '「迷路了,按住屏幕。」——长按,听「我在哪」', 6: '「前面有东西!放心,它伤不到你。」——等教学怪扑来', 7: '「铁器的回声,就是下潜的楼梯。」——自己走到楼梯格,单击下潜', }; const atTurn = (game) => !game.floor.speaking && game.floor.state === 'YOUR_TURN'; const wpId = (game) => (game.floor.waitPoint ? game.floor.waitPoint.conf.id : null); /** * 教学作答步(D0-D3/D5)的真话轮判据:本步自己的等待点(id='drillN')已挂上。 * 只看 floor.state === YOUR_TURN 不够——作答后旧等待点不会立刻被摘(管道未接线), * 旧步的等待点会「闪真」,按 id 区分才能证明新步的指令链真的播完交了话轮。 */ const atDrillTurn = (game, step) => game._drill?.step === step && atTurn(game) && wpId(game) === 'drill' + step; /** 绝对方位 → 此刻该滑哪个方向(相对面朝)。 */ const relGesture = (game, dir) => REL_GESTURE[(DIR_ORDER.indexOf(dir) - DIR_ORDER.indexOf(game.run.face) + 4) % 4]; export function describe(game) { if (game.menuOpen) return '菜单里:左右滑换项,单击确认(绝不碰),上滑重听说明,下滑关闭'; if (game.state === 'BATTLE' && game._battle) { const b = game._battle; if (b.state === 'telegraph') return '敌方前摇声响起了——单击,格挡!'; if (b.state === 'player') { return `战斗中:血量${HP_BAND[game._hpBand(game.run.hp, game._maxHp())]},单击挥击,左右滑转身,下滑逃跑,长按听战况`; } return '战斗播报中,先听着'; } switch (game.state) { case 'TITLE': return game.run ? '检测到探窟记录:单击继续,右滑重探单周目,上滑听元进度' : '标题页第一句就是指令:「点一下屏幕。」'; case 'DRILL': return game._drill ? `${DRILL_TEXT[game._drill.step] ?? '教学演练中'}${atTurn(game) ? '' : '(旁白在说,先听)'}` : '教学演练中'; case 'EXPLORE': { if (!game.run) return '……'; if (game._onStairs) return `第 ${game.run.floor} 层:铁器就在脚下——单击,下潜`; return `第 ${game.run.floor} 层黑暗里:单击脉冲听回声,四向滑动走路,长按问路`; } case 'DESCEND': return '顺着楼梯,往下潜……'; case 'SETTLE': return atTurn(game) ? '结算:死亡归因与元进度入账听完了——单击再探一次,下滑开菜单(刚解锁)' : '结算链/提示句在播,先听完'; 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); } } /** BFS 寻路(避开怪格,目标格例外):把「听来的方位感」翻译成一步步手势。 */ function bfsPath(floor, from, to, blocked) { const S = floor.size; const startK = from.x + ',' + from.y; if (from.x === to.x && from.y === to.y) return []; const prev = new Map(); const seen = new Set([startK]); const q = [[from.x, from.y]]; const VEC = { N: { dx: 0, dy: -1 }, E: { dx: 1, dy: 0 }, S: { dx: 0, dy: 1 }, W: { dx: -1, dy: 0 } }; while (q.length) { const [x, y] = q.shift(); for (const [dir, d] of Object.entries(VEC)) { const nx = x + d.dx, ny = y + d.dy, k = nx + ',' + ny; if (nx < 0 || ny < 0 || nx >= S || ny >= S) continue; // 目标格即使被怪占着也要能走到(明雷:踏上窟主的格子触发遭遇) if (floor.grid[ny][nx] !== 1 || seen.has(k) || (blocked.has(k) && !(nx === to.x && ny === to.y))) continue; seen.add(k); prev.set(k, { x, y, dir }); if (nx === to.x && ny === to.y) { q.length = 0; break; } q.push([nx, ny]); } } const steps = []; let cur = to.x + ',' + to.y; if (!prev.has(cur)) return steps; while (cur !== startK) { const p = prev.get(cur); const [sx, sy] = cur.split(',').map(Number); steps.unshift({ x: sx, y: sy, dir: p.dir }); cur = p.x + ',' + p.y; } return steps; } const mobCells = (floor) => new Set(floor.mobs.map((m) => m.x + ',' + m.y)); const stairsCell = (game) => (game._floor ? (game._floor.stairs || game._floor.throne) : null); /** 「到站」判据:_onStairs 旗标,或坐标本就在楼梯/王座格(R4 修正:怪占楼梯格时 * 遭遇先于报梯、旗标没置上——只看旗标会在楼梯上干磨几百次脉冲)。 */ const onStairsCell = (game) => { const st = stairsCell(game); return !!game._onStairs || (!!st && !!game.run && game.run.pos.x === st.x && game.run.pos.y === st.y); }; const OPP_DIR = { N: 'S', S: 'N', E: 'W', W: 'E' }; /** 人味修正:踩在楼梯格却点不动(到达播报被楼梯上的遭遇战吞了)—— * 挪一步再踩回来,让游戏重新报「楼梯就在脚下」。返回是否已真正站上(旗标亮)。 */ async function retriggerStairs(ctx, mock, game) { const st = stairsCell(game); if (!st || !game.run) return !!game._onStairs; if (game.run.pos.x !== st.x || game.run.pos.y !== st.y) return !!game._onStairs; if (game._onStairs) return true; if (game.floor.speaking) { await ctx.waitVirtual(600); return !!game._onStairs; } const S = game._floor.size; const VEC = { N: { dx: 0, dy: -1 }, E: { dx: 1, dy: 0 }, S: { dx: 0, dy: 1 }, W: { dx: -1, dy: 0 } }; for (const [dir, d] of Object.entries(VEC)) { const nx = game.run.pos.x + d.dx, ny = game.run.pos.y + d.dy; if (nx < 0 || ny < 0 || nx >= S || ny >= S || game._floor.grid[ny][nx] !== 1) continue; if (game._floor.mobs.some((m) => m.x === nx && m.y === ny)) continue; // 旁边有怪:换一边下脚 ctx.note('铁器就在脚下却点不动——挪一步,重新踩上楼梯'); await ctx.gesture(relGesture(game, dir)); await ctx.waitVirtual(300); if (game.state === 'BATTLE') return false; // 挪步惊动了怪:先打,下轮再试 if (!game.run) return false; await ctx.gesture(relGesture(game, OPP_DIR[dir])); await ctx.waitVirtual(400); return !!game._onStairs; } return !!game._onStairs; // 四周都下不了脚:等游荡的怪让位 } /** * 探索导航(26 号):隔步脉冲听回声 → 朝目标一格一格滑。一次四向滑动 = 一步 * (侧移自带转向),不再有「转身+长按」两段式。撞进怪 = 明雷遭遇,打完再走; * deathOnBoss 时撞进窟主转为力战而亡。 * 三重防磨死(R3 修正:个别随机布局里教学链/等待点会让导航手势全部落空, * 原地干磨数小时——转写伪影):① 教学作答步(floor=YOUR_TURN)手势全是 * invalid,立即撤;② 连续 12 次迭代坐标没动(链卡播/被怪堵/无效循环)撤; * ③ 单次导航虚拟时长超 90s 撤。撤回后交给上层等待点/75s 教学兜底推进。 */ async function navigate(ctx, mock, game, target, { pulse = true, deathOnBoss = false, until = 'cell' } = {}) { const t0 = mock.now; let stuck = 0; let lastPos = game.run ? game.run.pos.x + ',' + game.run.pos.y : ''; for (let guard = 0; guard < 260; guard++) { if (game.state === 'BATTLE') { if (deathOnBoss && game._battle?.boss) { await fightToDeath(ctx, game); return; } await fightWell(ctx, mock, game); continue; } if (game.state !== 'EXPLORE' && game.state !== 'DRILL') return; if (!game.run || !game._floor) return; if (game.state === 'DRILL' && atTurn(game)) return; // 作答步等话轮:导航手势在这里全是无效 if (until === 'stairs' ? onStairsCell(game) : (game.run.pos.x === target.x && game.run.pos.y === target.y)) return; const posKey = game.run.pos.x + ',' + game.run.pos.y; stuck = posKey === lastPos ? stuck + 1 : 0; lastPos = posKey; if (stuck > 12 || mock.now - t0 > 90000) return; // 原地打转/超时:交给上层,绝不干磨 if (game.floor.speaking) { await ctx.waitVirtual(400); continue; } // 不抢话:内容链播完再动 if (pulse && guard % 2 === 0) { ctx.note('单击发脉冲,听回声辨路'); await ctx.gesture('tap'); await ctx.waitVirtual(1700); // 听完回声组(正前延迟≤1.1s+材质尾音,侧向预 pan) } const path = bfsPath(game._floor, game.run.pos, target, mobCells(game._floor)); const step = (path[0] || bfsPath(game._floor, game.run.pos, target, new Set())[0]); // 无避怪路就硬走 if (!step) { await ctx.waitVirtual(800); continue; } // 被游走的怪堵住了:等它走开 await ctx.gesture(relGesture(game, step.dir)); // 四向滑动一步(脚步声即回执) await ctx.waitVirtual(320); } } /** * 教学脱困:某一步迟迟不推进时,安静等一小段(不出手 = 活跃死线不再后推), * 让游戏自己的 75s 教学兜底自动过步(教学绝不卡死的有界保证)。只等不打。 */ async function drillUnstick(ctx, mock, game, cond, maxMs = 90000) { const t0 = mock.now; while (!cond() && mock.now - t0 < maxMs) { await ctx.waitVirtual(1000); // 空手等待:不脉冲、不挪步,把死线留给兜底 } } /** 认真打法(26 号):前摇声一响就单击格挡,轮到自己就单击挥击,直到战斗结束。 */ async function fightWell(ctx, mock, game) { let guard = 0; while (game.state === 'BATTLE' && guard < 200) { guard++; const b = game._battle; if (!b) break; if (b.state === 'telegraph') { ctx.note('前摇声起了——单击,格挡'); await ctx.gesture('tap'); await waitStep(ctx, mock, () => game._battle !== b || b.state !== 'telegraph', 4000); // 等窗口结算,不连点 continue; } if (b.state === 'player' && atTurn(game)) { ctx.note('轮到我了——单击,挥击'); await ctx.gesture('tap'); await ctx.waitVirtual(450); continue; } await ctx.waitVirtual(350); } await ctx.waitVirtual(2600); // 胜利链播完再走 } /** 力战而亡:不格挡、不逃跑,喘着气隔回合挥拳——窟主的刀比你的拳快(死亡归因路线)。 */ async function fightToDeath(ctx, game) { let turn = 0; while (game.state === 'BATTLE' && game._battle && turn < 200) { turn++; const b = game._battle; if (b.state === 'player' && atTurn(game)) { if (turn % 2 === 0) ctx.note('咬牙又挥了一拳——挡不住了也要打'); else ctx.note('顾不上格挡,抡拳'); await ctx.gesture('tap'); await ctx.waitVirtual(500); continue; } await ctx.waitVirtual(400); } } // ---------------------------------------------------------------- 主线剧本 export async function session(ctx, mock) { const { game } = ctx; // —— 标题页:第一句就是 critical 指令「点一下屏幕。」(§4.10),没档 → 单击开始 —— await waitStep(ctx, mock, () => game.state === 'TITLE'); await ctx.waitCond(() => atTurn(game)); ctx.status('标题页第一句就是指令,不是规则念白'); ctx.note('黑暗里一句「点一下屏幕」——点'); await ctx.gesture('tap'); // → sys_new_run → 教学 D0 // —— D0:黑暗。点一下,听回声 —— await waitStep(ctx, mock, () => atDrillTurn(game, 0), 20000); ctx.status('教学第一步就是动手听,不念规则'); ctx.note('点一下——人生第一声脉冲'); await ctx.gesture('tap'); // 回声组(正前延迟+材质尾音+侧向预 pan)就是回执 // —— D1:对。回声回来越快,墙就越近(巩固:再点一次) —— await waitStep(ctx, mock, () => atDrillTurn(game, 1), 20000); ctx.status('「对。」+ 一句正前规则——回声教我看墙'); ctx.note('再点一次,巩固'); await ctx.gesture('tap'); await waitStep(ctx, mock, () => game._drill?.step === 2, 15000); // 奖励音 + 前进教学链 // —— D2:朝前,滑一下(上滑=前进一格) —— await waitStep(ctx, mock, () => atDrillTurn(game, 2), 20000); ctx.status('「听到这个声音,就是轮到你了」——等待声就是话轮'); ctx.note('上滑,前进一格'); await ctx.gesture('up'); // 脚步声即回执 // —— D3:向左滑,转身走(侧移=移动+转向一步到位) —— await waitStep(ctx, mock, () => atDrillTurn(game, 3), 20000); ctx.status('侧移自带转向——不再是「转身+按住」两段式'); ctx.note('左滑,转身走'); await ctx.gesture('left'); // —— D4:房里有金币,走过去捡(自由行动:ACTION,没有等待声) —— await waitStep(ctx, mock, () => game._drill?.step === 4, 15000); ctx.status('自由行动步:玩法声音就是状态,脉冲探路自己走'); const coin = [...game._floor.items.entries()].find(([, v]) => v.type === 'coin'); if (coin) { const [cx, cy] = coin[0].split(',').map(Number); await navigate(ctx, mock, game, { x: cx, y: cy }); } if (game._drill?.step === 4) { // 链/等待点打架没走成:安静等 75s 兜底过步(绝不干磨) await drillUnstick(ctx, mock, game, () => game._drill?.step !== 4); } await waitStep(ctx, mock, () => game._drill?.step === 5, 15000); // 拾取 → 第一个小目标 → D5 ctx.status('金币入袋(实时进元进度)——做完第一件事,才教下一件事'); // —— D5:迷路了,按住屏幕(长按 = 我在哪) —— await waitStep(ctx, mock, () => atDrillTurn(game, 5), 20000); ctx.note('长按,问一次路'); await ctx.gesture('longpress'); // 层+面朝+楼梯方位,一句话 await waitStep(ctx, mock, () => game._drill?.step === 6, 15000); ctx.status('「我在哪」一句话说完——长按从前进键变成了问路键'); // —— D6:教学战(盲眼蝠 ATK0 把门):单击格挡 + 单击挥击 —— await waitStep(ctx, mock, () => game.state === 'BATTLE', 25000); if (game.state !== 'BATTLE' && game._drill?.step === 6) { // 教学怪迟迟不扑来:等兜底过步 await drillUnstick(ctx, mock, game, () => game.state === 'BATTLE' || game._drill?.step !== 6); } ctx.status('「前面有东西!放心,它伤不到你」——它扑过来了'); await fightWell(ctx, mock, game); if (game.state === 'BATTLE' && game._drill?.step === 6) { // 乱局里没打完:接着打完 await fightWell(ctx, mock, game); } if (game._drill?.step === 6) { // 战斗脱轨(怪没了/逃掉了):等兜底进 D7 await drillUnstick(ctx, mock, game, () => game._drill?.step !== 6 || game.state === 'BATTLE'); if (game.state === 'BATTLE') await fightWell(ctx, mock, game); } await waitStep(ctx, mock, () => game._drill?.step === 7 || game.state === 'EXPLORE', 25000); ctx.status('「打中了,就是这样。」格挡和挥击都是单击——一个手势按状态分流'); // —— D7:铁器的回声就是楼梯,自己走过去(自由寻路 + 单击下潜出口) —— await waitStep(ctx, mock, () => game._drill?.step === 7, 15000); for (let round = 0; round < 3 && game.state === 'DRILL' && game._drill?.step === 7 && !onStairsCell(game); round++) { await navigate(ctx, mock, game, game._floor.stairs, { until: 'stairs' }); if (!onStairsCell(game)) await drillUnstick(ctx, mock, game, () => onStairsCell(game) || game._drill?.step !== 7 || game.state !== 'DRILL', 30000); } await waitStep(ctx, mock, () => !game.floor.speaking, 8000); if (!game._onStairs && onStairsCell(game) && game.state === 'DRILL' && game._drill?.step === 7) { await retriggerStairs(ctx, mock, game); // 教学房的门怪若占了楼梯格:挪一步重新踩上 } await waitStep(ctx, mock, () => !game.floor.speaking, 8000); if (game._onStairs && (game.state === 'DRILL' || game.state === 'EXPLORE')) { ctx.status('铁器贴着脚底响——「楼梯就在脚下。单击,下潜」'); await ctx.gesture('tap'); // D7 出口:楼梯格单击 = 下潜(教学完成) } await waitStep(ctx, mock, () => game.state === 'EXPLORE' && game.run?.floor === 2, 30000); ctx.status('「教学完了。地窟很深,小心走。」第二层,真正的地窟从这里开始'); // —— 第二层:脉冲探路 → 明雷遭遇战 → 找到楼梯下潜 —— ctx.silence(3, '在楼梯口定了定神,听这一层的动静'); await ctx.waitVirtual(3200); for (let guard = 0; guard < 40 && game.run && game.run.floor < 3 && game.state !== 'SETTLE'; guard++) { await navigate(ctx, mock, game, game._floor.stairs, { until: 'stairs' }); if (game.state === 'SETTLE' || !game.run || game.run.floor >= 3) break; if (onStairsCell(game)) { if (!game._onStairs) await retriggerStairs(ctx, mock, game); // 到达播报被楼梯上的怪吞了 if (game._onStairs && game.state !== 'BATTLE') { ctx.note('踏上楼梯格——单击,下潜'); await ctx.gesture('tap'); await waitStep(ctx, mock, () => !game.run || game.run.floor >= 3 || game.state === 'SETTLE', 20000); continue; } } await ctx.waitVirtual(600); // 被打断后重整旗鼓再摸一轮 } ctx.status(game.run?.floor === 3 ? '第三层——窟主的地界' : `还在第 ${game.run?.floor ?? '?'} 层摸路`); // —— 第三层:长按问路 →「楼梯有大东西守着,先赢它」→ 走过去,力战而亡 —— if (game.run && game.run.floor === 3) { ctx.note('长按,问一次路——听说有大家伙守着楼梯'); await ctx.gesture('longpress'); await waitStep(ctx, mock, () => !game.floor.speaking, 8000); await navigate(ctx, mock, game, game._floor.stairs || game._floor.throne, { deathOnBoss: true, until: 'stairs' }); if (game.state === 'BATTLE' && game._battle?.boss) await fightToDeath(ctx, game); } // —— 保底:万一第三层没走到/没死成(随机布局的意外),正经找窟主送掉这条命, // 保证弧线以「死亡 → 结算 → 重开」收尾 —— if (game.run && game.state !== 'SETTLE') { for (let guard = 0; guard < 8 && game.run && game.state !== 'SETTLE'; guard++) { if (game.state === 'BATTLE') { if (game._battle?.boss) { await fightToDeath(ctx, game); continue; } await fightWell(ctx, mock, game); continue; } if (game.menuOpen) { await ctx.gesture('down'); await ctx.waitVirtual(400); continue; } if (game.state === 'DRILL') { // 教学脱轨残留:安静等兜底过步再走 const st = game._drill?.step; await drillUnstick(ctx, mock, game, () => game.state !== 'DRILL' || game._drill?.step !== st, 90000); continue; } if (game.state !== 'EXPLORE') { await ctx.waitVirtual(600); continue; } if (onStairsCell(game)) { if (!game._onStairs) await retriggerStairs(ctx, mock, game); if (game._onStairs && game.state !== 'BATTLE') { ctx.note('踏上楼梯格——单击,下潜'); await ctx.gesture('tap'); await waitStep(ctx, mock, () => !game.run || game.run.floor >= 3 || game.state === 'SETTLE', 20000); continue; } } await navigate(ctx, mock, game, game._floor.stairs || game._floor.throne, { deathOnBoss: true, until: 'stairs' }); } } // —— SETTLE:死亡归因真话 + 元进度入账(critical 结局链) —— await waitStep(ctx, mock, () => game.state === 'SETTLE', 60000); if (game.state !== 'SETTLE') { // 理论到不了这里:如实收尾,不演假结局 ctx.status(`没能走到结算(停在 ${game.state})——这一局的地窟赢了,收工`); return; } await ctx.waitCond(() => atTurn(game)); ctx.status('倒下了。归因链一句句把死法说清楚:死于谁、哪里没做好、带回了多少金币'); // 走神一次:结算等待点第一档提示(20s)「单击,再探一次。」 ctx.silence(20, '瘫在黑暗里喘气,结算听完后发了一会儿呆'); await ctx.waitVirtual(20500); ctx.status('一句「单击,再探一次」把我拉回来——先不急'); await ctx.waitCond(() => atTurn(game)); // 逛一圈菜单(第一局打完,下滑此刻才解锁;绝不单击确认,下滑关闭) ctx.note('结算链尾教了「向下滑,可以打开菜单」——去看看'); await ctx.gesture('down'); await ctx.waitCond(() => atTurn(game)); ctx.status('菜单第一项「重新开始本周目」'); await ctx.gesture('right'); await ctx.waitCond(() => atTurn(game)); ctx.status('第二项「保存并回大厅」——不去,关掉'); await ctx.gesture('down'); await ctx.waitCond(() => atTurn(game)); // 菜单关了,回到结算等待 ctx.status('菜单关了,回结算——全程没碰破坏性确认'); // —— 再探一次:第二周目(遗产生效、不再上课),短程收尾 —— ctx.note('单击——再探一次'); await ctx.gesture('tap'); await waitStep(ctx, mock, () => game.state === 'EXPLORE' && game.run?.floor === 1, 25000); for (let tail = 0; tail < 4 && game.run; tail++) { if (game.state === 'BATTLE') { // 复述没听完就被游荡的蝙蝠扑了:打完再收尾 await fightWell(ctx, mock, game); continue; } if (game.floor.speaking) { await ctx.waitVirtual(600); continue; } // 局面复述(层+血+面朝+楼梯方位) if (tail === 0) ctx.status('新周目直落探索:遗产生效,不用再上课'); ctx.note(tail < 2 ? '长按,再问一次路——新周目的楼梯换方向了' : '单击脉冲,听听熟悉的回声'); await ctx.gesture(tail < 2 ? 'longpress' : 'tap'); await ctx.waitVirtual(1800); } ctx.status('死亡-重开循环跑通,第二周目短程收工'); } // ---------------------------------------------------------------- 乱按压力剧本 // 目标:全程零死锁、零静默死局,一样要走到死亡-重开循环。 // 剧本特征:不读提示、乱划乱点、狂点脉冲吃冷却 invalid、战斗乱滑乱逃、撞墙听闷响, // 但仍保留「最终能走到 SETTLE」的底线。 export async function sessionStress(ctx, mock) { const { game } = ctx; let seed = 20261002; const rng = () => { seed = (seed * 1103515245 + 12345) & 0x7fffffff; return seed / 0x7fffffff; }; const pick = (arr) => arr[Math.floor(rng() * arr.length)]; const NOISE = ['tap', 'tap', 'doubletap', 'left', 'right', 'up', 'down', 'longpress']; /** 乱按的安全阀:菜单里只「下滑关闭」,绝不单击确认(重开/回大厅都是坑)。 */ const noise = async () => { if (game.menuOpen) { ctx.note('菜单乱逛完了,下滑关掉'); await ctx.gesture('down'); await ctx.waitVirtual(500); return; } await ctx.gesture(pick(NOISE)); await ctx.waitVirtual(450); }; // ① 标题页乱划一通,然后开始 for (let i = 0; i < 10 && game.state === 'TITLE'; i++) { ctx.status('标题页乱划'); await noise(); } await ctx.gesture('tap'); // ② 教学期:一半乱按,但每步最后一定做对(跟不下来也要能走完)。 // 教学期的遭遇战(乱走撞怪/教学怪扑来)也留在这个循环里打——出了循环 // 就再没有 D4/D7 的自由寻路逻辑,会永远卡在教学里。 await waitStep(ctx, mock, () => game.state === 'DRILL' || game.state === 'EXPLORE' || game.state === 'BATTLE', 20000); ctx.status('教学里乱按一气,旁白说到哪按到哪'); const RIGHT = { 0: 'tap', 1: 'tap', 2: 'up', 3: 'left', 5: 'longpress' }; for (let guard = 0; guard < 500 && (game.state === 'DRILL' || (game.state === 'BATTLE' && game._drill)); guard++) { if (game.menuOpen) { await ctx.gesture('down'); await ctx.waitVirtual(400); continue; } if (game.state === 'BATTLE') { await chaosFight(ctx, mock, game, rng, pick); continue; } const step = game._drill?.step; if (step === 4) { // 自由捡金币:乱走两下也得真走到(活跃动作会后推兜底死线,别指望超时) const coin = [...game._floor.items.entries()].find(([, v]) => v.type === 'coin'); if (!coin) { await ctx.waitVirtual(300); continue; } const [cx, cy] = coin[0].split(',').map(Number); if (game.run.pos.x === cx && game.run.pos.y === cy) { await ctx.waitVirtual(600); continue; } if (rng() < 0.3) { await noise(); continue; } const path = bfsPath(game._floor, game.run.pos, { x: cx, y: cy }, mobCells(game._floor)); const stp = (path[0] || bfsPath(game._floor, game.run.pos, { x: cx, y: cy }, new Set())[0]); if (stp) { await ctx.gesture(relGesture(game, stp.dir)); await ctx.waitVirtual(300); } continue; } if (step === 6) { // 等教学怪扑来,交给乱打 await waitStep(ctx, mock, () => game.state === 'BATTLE' || game._drill?.step === 7, 25000); continue; } if (step === 7) { // 自由寻楼梯:乱按按到这一步也得真走过去,否则卡死在聆听里 if (game._onStairs) { await ctx.gesture('tap'); // 单击下潜(教学出口) await waitStep(ctx, mock, () => game.state !== 'DRILL', 15000); continue; } const st = game._floor.stairs || game._floor.throne; const path = bfsPath(game._floor, game.run.pos, st, mobCells(game._floor)); const stp = (path[0] || bfsPath(game._floor, game.run.pos, st, new Set())[0]); if (!stp) { await ctx.gesture('tap'); await ctx.waitVirtual(1500); continue; } if (rng() < 0.25) { await noise(); continue; } await ctx.gesture(relGesture(game, stp.dir)); await ctx.waitVirtual(300); continue; } const gs = RIGHT[step]; if (!gs) { await ctx.waitVirtual(300); continue; } await waitStep(ctx, mock, () => atDrillTurn(game, step) || game._drill?.step !== step, 15000); if (game._drill?.step !== step) continue; // 乱按碰巧把本步做对了:换下一步 if (rng() < 0.4) { await noise(); continue; } // 乱按:吃 error 震/冷却 invalid,不死锁 await ctx.gesture(gs); await ctx.waitVirtual(450); } await waitStep(ctx, mock, () => game.state === 'EXPLORE' || game.state === 'SETTLE' || (game.state === 'BATTLE' && !game._drill), 25000); ctx.status('总算从教学里滚出来了'); // ③ 探索乱局:狂点脉冲、乱滑撞墙、乱打乱逃,直到死一次为止(零死锁底线 = SETTLE) let chaosGuard = 0; while (game.run && game.state !== 'SETTLE' && chaosGuard < 600) { chaosGuard++; if (game.menuOpen) { await ctx.gesture('down'); await ctx.waitVirtual(400); continue; } if (game.state === 'DRILL') { // 兜底:乱按又滚回教学未完的步子(真走完它,别空转) if (game._drill?.step === 7 && onStairsCell(game)) { await ctx.gesture('tap'); await ctx.waitVirtual(1500); continue; } await navigate(ctx, mock, game, game._floor.stairs || game._floor.throne, { until: 'stairs' }); continue; } if (game.state === 'BATTLE') { if (game._battle?.boss || game.run.floor >= 3) { ctx.status('乱打:不格挡、逃不掉(窟主),一直挥拳'); await fightToDeath(ctx, game); continue; } await chaosFight(ctx, mock, game, rng, pick); continue; } if (game.state !== 'EXPLORE') { await ctx.waitVirtual(400); continue; } if (onStairsCell(game) && !game._floor.mobs.some((m) => m.x === game.run.pos.x && m.y === game.run.pos.y)) { if (!game._onStairs) await retriggerStairs(ctx, mock, game); if (game._onStairs && game.state === 'EXPLORE') { ctx.note('正好站在楼梯上——单击下潜'); await ctx.gesture('tap'); await waitStep(ctx, mock, () => game.state === 'EXPLORE' && game.run.floor >= 2, 15000); continue; } } const r = rng(); if (r < 0.3) { // 狂点脉冲:冷却期就是一串 invalid,游戏不炸 ctx.note('手痒连点三下脉冲'); await ctx.gesture('tap'); await ctx.gesture('tap'); await ctx.gesture('tap'); await ctx.waitVirtual(1600); continue; } if (r < 0.5) { await noise(); continue; } // 乱中带正:朝楼梯挪一步 const st = game._floor.stairs || game._floor.throne; const path = bfsPath(game._floor, game.run.pos, st, mobCells(game._floor)); const stp = (path[0] || bfsPath(game._floor, game.run.pos, st, new Set())[0]); if (!stp) { await ctx.waitVirtual(800); continue; } await ctx.gesture(relGesture(game, stp.dir)); await ctx.waitVirtual(300); } // ④ 乱按还没死透(乱逃乱挡命硬):正经找窟主送掉这条命,保证走到 SETTLE if (game.run && game.state !== 'SETTLE') { ctx.note('命硬——那就自己走到窟主面前了结'); for (let guard = 0; guard < 400 && game.run && game.state !== 'SETTLE'; guard++) { if (game.state === 'BATTLE') { if (game._battle?.boss) { await fightToDeath(ctx, game); continue; } await fightWell(ctx, mock, game); continue; } if (game.menuOpen) { await ctx.gesture('down'); await ctx.waitVirtual(400); continue; } if (game.state === 'DRILL') { // 兜底同上:先把教学走完 await navigate(ctx, mock, game, game._floor.stairs || game._floor.throne, { until: 'stairs' }); if (onStairsCell(game) && game.state === 'DRILL') { if (!game._onStairs) await retriggerStairs(ctx, mock, game); if (game._onStairs) { await ctx.gesture('tap'); await ctx.waitVirtual(1500); } } continue; } if (game.state !== 'EXPLORE') { await ctx.waitVirtual(400); continue; } if (onStairsCell(game)) { if (!game._onStairs) await retriggerStairs(ctx, mock, game); if (game._onStairs) { await ctx.gesture('tap'); await ctx.waitVirtual(1500); continue; } } await navigate(ctx, mock, game, game._floor.stairs || game._floor.throne, { deathOnBoss: true, until: 'stairs' }); } } // ⑤ SETTLE → 再探一次(直接新周目),短程收尾 await waitStep(ctx, mock, () => game.state === 'SETTLE', 30000); await waitStep(ctx, mock, () => atTurn(game), 30000); if (game.state === 'SETTLE') { ctx.status('乱打也被归因得明明白白,元进度照单全收'); await ctx.waitVirtual(2500); await ctx.gesture('tap'); // 再探一次 → 新周目(直落探索) await waitStep(ctx, mock, () => game.state === 'EXPLORE' && game.run, 25000); ctx.status('第二周目照样开局(不再上课),乱划两下收工'); await noise(); await noise(); } else { ctx.note('这一把居然没死——压力有限,收工'); } } /** 乱打:大多数时候出手,偶尔乱滑/上滑听战况/试图逃跑;前摇窗手忙脚乱。 */ async function chaosFight(ctx, mock, game, rng, pick) { let guard = 0; while (game.state === 'BATTLE' && game._battle && guard < 160) { guard++; const b = game._battle; if (game.menuOpen) { await ctx.gesture('down'); await ctx.waitVirtual(400); continue; } if (b.state === 'telegraph') { if (rng() < 0.3) { ctx.note('手忙脚乱中碰巧点了一下——格挡'); await ctx.gesture('tap'); } await waitStep(ctx, mock, () => game._battle !== b || b.state !== 'telegraph', 4000); continue; } if (b.state === 'player' && atTurn(game)) { const r = rng(); if (r < 0.6) { ctx.note('单击,抡空也打'); await ctx.gesture('tap'); } else if (r < 0.7) { ctx.note('慌了,下滑想跑'); await ctx.gesture('down'); } else if (r < 0.85) { await ctx.gesture(pick(['left', 'right'])); } else { await ctx.gesture(pick(['up', 'longpress'])); } await ctx.waitVirtual(450); continue; } await ctx.waitVirtual(350); } await ctx.waitVirtual(2500); }