Files
SoundGame/web/harness/sessions/dungeon.mjs
T
xsl ee5d4d2544 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 置完成。
2026-10-02 12:33:20 +08:00

681 lines
35 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 《回声地窟》新手会话剧本(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);
}