Files
SoundGame/web/harness/sessions/dungeon.mjs
T
xsl d870a75a26 feat(new8/batch1): 两款新 RPG《不眠频率》radio +《回声地窟》dungeon 全套(多agent流水线:策划→资产→开发→测试→玩家R1→修复→R2)
- 程序章程 research/new8/00-program.md(8款概念/回合协议/验收门/进度账)
- 设计:docs/design/15-radio.md+voice-ids(132句,6状态9抉择4结局,电话滤波资产侧带通)/16-dungeon.md+voice-ids(125句,脉冲回声/7层seed生成/三档战斗/元进度遗产)
- 资产:TTS 970文件(GPU flock串行)+ Mureka BGM 8首 $0.24(台账 mureka_log.md)+ 程序合成 sfx(含预pan侧墙/开口、phone_ring铃声、amb_cave呼吸层、turn_tick)+ 终检 check_all_assets 8游戏全绿
- radio:game.js+playtest 221断言(4结局矩阵/pendingChoice盲玩公平/RING两级看门狗/菜单循环/存档schema)
- dungeon:game.js+playtest 1610断言(seed全网格回声一致性/学期零死/格挡80%/死亡归因真话/元进度/菜单冻结/上滑5段状态链)
- 引擎修复:audio.js _playVoice 加载竞态补杀 token(R1真机71分钟语音风暴根因)+ 同id 3s窗口≥5次熔断保险丝 + gesture.js 长按boost事件门(+66行,9套件回归全绿)
- 大厅:新增 radio/dungeon 卡片 + GAMES 数组
- harness:sessions/radio.mjs+dungeon.mjs(主线+压力双剧本)+ transcribe 注册 + voice_text.json 刷新(8游戏704句)+ 13号文档 §20 登记
- R1 存档:4人设评审×2游戏 + 真机试玩报告×2(radio 5/5愿意玩;dungeon 0/4→确诊风暴)+ synthesis×2 + 转写8份
- R2 修复:radio F1-F5(看门狗补挂/rep_low改词重录/真铃声/ASK续玩语义/链末跳过保护)+ dungeon D0-1..D1-6(风暴根治/回声真实性/密度下限+呼吸层/导航最短路/菜单关闭反馈/金币档同账本)+ 实现核对登记
2026-09-02 18:52:37 +08:00

571 lines
25 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.
/**
* 《回声地窟》新手会话剧本。
*
* 规则:
* - 主线剧本 `session`:一位盲眼新手玩家的完整弧线——标题开始 → DRILL 九步逐项跟练
* (脉冲/前进/转身/重发/状态/菜单/教学战/下潜)→ EXPLORE 脉冲探路、明雷战斗 →
* 逐层下潜到窟主面前 → 力战而亡听死亡归因与元进度入账 → 第二周目(遗产生效、
* 不再进教学)短程收尾,完整展示死亡-重开循环。
* - 乱按压力剧本 `sessionStress`:一位手忙脚乱的好奇宝宝——标题乱划、教学乱按、
* 狂点脉冲、战斗乱滑乱逃、菜单乱逛后关掉——一样要走到死亡-重开循环,全程零死锁。
*
* 剧本失真红线(参照 radio/storyline 剧本先例):
* - 玩家不抢话:出手前等语音链播完(waitCond !voiceBusy);
* - 玩家靠耳朵玩游戏:走位前先脉冲听回声,导航只把「听到的方向」翻译成手势;
* - 绝不用 waitIdle 空等怪物/前摇(有定时器在跑,必须 waitCond/waitVirtual 有界等待);
* - 菜单里只按「下滑关闭」,绝不双击执行(防止误触「保存并回大厅」结束会话)。
*/
const ORDER = ['N', 'E', 'S', 'W'];
const DIRS = { N: { dx: 0, dy: -1 }, E: { dx: 1, dy: 0 }, S: { dx: 0, dy: 1 }, W: { dx: -1, dy: 0 } };
const HP_BAND = { 1: '毫发无损', 2: '受了点伤', 3: '伤势不轻', 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
? `教学演练第 ${game._drill.step} 步,跟着旁白做`
: '教学演练中';
case 'EXPLORE': return game.run
? `第 ${game.run.floor} 层黑暗里:单击发脉冲用回声看路,按住前进,左右滑转身,上滑状态,下滑菜单`
: '……';
case 'DESCEND': return '顺着楼梯,往下潜……';
case 'SETTLE': return '结算:死亡归因与元进度入账,单击再探一次';
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);
}
}
/** 转身面向 dir(左右滑,听转身声)。 */
async function faceDir(ctx, game, dir) {
const d = (ORDER.indexOf(dir) - ORDER.indexOf(game.run.face) + 4) % 4;
const gs = d === 3 ? 'left' : 'right';
for (let i = 0; i < (d === 3 ? 1 : d); i++) {
await ctx.gesture(gs);
await ctx.waitVirtual(150);
}
}
/** 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]];
while (q.length) {
const [x, y] = q.shift();
for (const [dir, d] of Object.entries(DIRS)) {
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));
/** 认真打法:前摇声响就双击格挡,轮到自己就单击挥击,直到战斗结束。 */
async function fight(ctx, game) {
let guard = 0;
while (game.state === 'BATTLE' && game._battle && guard < 120) {
guard++;
const b = game._battle;
if (game.menuOpen) { await ctx.gesture('down'); await ctx.waitVirtual(600); continue; }
if (b.state === 'telegraph') {
ctx.note('前摇声响——双击,格挡');
await ctx.gesture('doubletap');
await ctx.waitVirtual(300);
continue;
}
if (b.state === 'player') {
ctx.note('单击,挥击');
await ctx.gesture('tap');
await ctx.waitVirtual(500);
continue;
}
await ctx.waitVirtual(400);
}
await ctx.waitVirtual(3000); // 胜利链播完
}
/** 力战而亡:不逃跑、顾不上格挡,喘着气隔回合挥拳——窟主的刀比你的拳快(死亡归因路线)。 */
async function fightToDeath(ctx, game) {
let guard = 0;
let turn = 0;
while (game.state === 'BATTLE' && guard < 160) {
guard++;
const b = game._battle;
if (!b) break;
if (b.state === 'player') {
if (turn % 2 === 0) {
ctx.note('咬牙单击——挡不住了也要打');
await ctx.gesture('tap');
} else {
ctx.note('喘一口气,再挥');
}
turn++;
await ctx.waitVirtual(500);
continue;
}
await ctx.waitVirtual(400);
}
}
/** 探索导航:脉冲听路 → 一步步走向目标格;路上撞进小怪就打完再走,
* 撞进窟主(deathOnBoss)就转为力战而亡。
* R2 伪影修正:until='stairs' 时以「踏上楼梯格」为到站条件(R1 剧本按坐标
* 判到站,长按多走/被怪打断时提前收工,导致「单击下潜」点在半路变成脉冲——
* 全程零下楼演出、165 脉冲 0 次铁器回声的转写伪影根源);D9 起脉冲常开
* (玩家靠耳朵玩游戏红线:走位前先听回声)。 */
async function navigate(ctx, mock, game, target, { pulse = true, deathOnBoss = false, until = 'cell' } = {}) {
for (let guard = 0; guard < 220; guard++) {
if (game.state === 'BATTLE') {
if (deathOnBoss && game._battle?.boss) { await fightToDeath(ctx, game); continue; }
await fight(ctx, game);
continue;
}
if (game.state !== 'EXPLORE' && game.state !== 'DRILL') return;
if (!game.run) return;
if (until === 'stairs' ? !!game._onStairs
: (game.run.pos.x === target.x && game.run.pos.y === target.y)) return;
if (pulse) {
ctx.note('单击发脉冲,听回声辨路');
await ctx.gesture('tap');
await ctx.waitVirtual(2000); // 听完回声组再动身
}
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) {
if (process.env.DUNGEON_SESS_DEBUG) mock.realLog('[sess-debug] !step @', JSON.stringify(game.run.pos), 'target=', JSON.stringify(target),
'onStairs=', game._onStairs, 'floor=', game.run.floor, 'mobs=', JSON.stringify(game._floor.mobs.map((m) => [m.type, m.x, m.y])));
await ctx.waitVirtual(800);
continue;
} // 被游走的怪堵住了:等它走开
if (process.env.DUNGEON_SESS_DEBUG && game.run.floor >= 2) {
mock.realLog('[sess-debug] iter', JSON.stringify(game.run.pos), game.run.face,
'target=', JSON.stringify(target), 'state=', game.state, 'walk=', !!game._walk,
'step=', JSON.stringify(step || null), 'menu=', game.menuOpen, 't=', mock.now);
}
await faceDir(ctx, game, step.dir);
if (game.state === 'BATTLE') continue; // 走位途中被扑进来的怪打断:先打完再走(防长按变逃跑)
await ctx.gesture('longpress');
// 立即松开,走「恰 1 格」。R2 修正:原来的 waitVirtual(120) 在虚拟时钟里会
// 越过 +500ms 的下一步计时器(advance 至少推进到下一个到期定时器),每按一次
// 多走 1+ 格——到站判断永远踩不准楼梯格,剧本在楼梯两侧来回震荡 20 分钟
//(R1 转写「165 脉冲 0 铁器、两次下楼零演出」的另一根源)。
await ctx.gesture('longpressend');
await ctx.waitVirtual(200);
}
}
/** 到站下潜:只有真踩在楼梯格上才单击(R2 伪影修正:半路的单击只是浪费的脉冲)。
* DRILL(D9) 也接受——教学最后一击 = 踏楼梯单击下潜,是设计好的出教学路径。 */
async function tapDescend(ctx, mock, game) {
if (!game._onStairs || (game.state !== 'EXPLORE' && game.state !== 'DRILL')) return false;
ctx.note('铁器就在脚下——单击,下潜');
await ctx.gesture('tap');
await waitStep(ctx, mock, () => game.state !== 'EXPLORE' && game.state !== 'DRILL', 4000);
return true;
}
// ---------------------------------------------------------------- 主线剧本
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 九步逐项跟练 ——
await waitStep(ctx, mock, () => game.state === 'DRILL' && game._drill?.step === 0);
ctx.status('教学开始:三句开场白');
await waitStep(ctx, mock, () => !ctx.voiceBusy(), 12000);
ctx.note('单击回应「先学会用耳朵看」');
await ctx.gesture('tap'); // D0 → D1
await waitStep(ctx, mock, () => game._drill?.step === 1);
ctx.note('单击——人生第一声脉冲');
await ctx.gesture('tap'); // D1 脉冲
await waitStep(ctx, mock, () => game._drill?.step === 2);
ctx.status('回声回来了,听墙的距离');
await ctx.waitVirtual(2000);
await waitStep(ctx, mock, () => !ctx.voiceBusy(), 8000);
ctx.note('单击继续');
await ctx.gesture('tap'); // D2 → D3
await waitStep(ctx, mock, () => game._drill?.step === 3);
await waitStep(ctx, mock, () => !ctx.voiceBusy(), 8000);
ctx.note('单击继续(侧向规则听完了)');
await ctx.gesture('tap'); // D3 → D4
await waitStep(ctx, mock, () => game._drill?.step === 4);
ctx.note('按住屏幕不放——脚步声起,往前走');
await ctx.gesture('longpress');
await ctx.waitVirtual(1400); // 走满 2 格
ctx.note('松开,停下');
await ctx.gesture('longpressend');
await waitStep(ctx, mock, () => game._drill?.step === 5);
ctx.note('左滑,转身');
await ctx.gesture('left');
await ctx.waitVirtual(400);
ctx.note('右滑,转回来');
await ctx.gesture('right');
await waitStep(ctx, mock, () => game._drill?.step === 6);
ctx.note('双击——再发一次脉冲,不要钱');
await ctx.gesture('doubletap'); // D6 重发
await waitStep(ctx, mock, () => game._drill?.step === 7);
await ctx.waitVirtual(2000);
ctx.note('上滑,听自己的状态');
await ctx.gesture('up');
await ctx.waitVirtual(5800); // 五段状态链
ctx.note('下滑,打开菜单');
await ctx.gesture('down');
await ctx.waitVirtual(1600);
ctx.note('再下滑,关上菜单');
await ctx.gesture('down');
await waitStep(ctx, mock, () => game._drill?.step === 8);
// —— D8 教学战:盲眼蝠 ATK=0,伤不到你 ——
ctx.status('「前面有东西!放心,它伤不到你」——等它扑上来');
await waitStep(ctx, mock, () => game.state === 'BATTLE' && game._battle, 25000);
ctx.status('教学怪扑到了面前');
await waitStep(ctx, mock, () => game._battle?.state === 'telegraph', 15000);
ctx.note('听到它的动静——双击,格挡');
await ctx.gesture('doubletap');
await waitStep(ctx, mock, () => game._battle?.state === 'player', 15000);
ctx.note('单击挥拳,两拳放倒它');
for (let i = 0; i < 6 && game.state === 'BATTLE'; i++) {
if (game._battle?.state === 'telegraph') { await ctx.gesture('doubletap'); await ctx.waitVirtual(300); }
else if (game._battle?.state === 'player') { await ctx.gesture('tap'); await ctx.waitVirtual(400); }
else await ctx.waitVirtual(400);
}
await waitStep(ctx, mock, () => game._drill?.step === 9, 20000);
ctx.status('教学战获胜——「打中了,就是这样」');
// —— D9:听铁器的回声,自己走到楼梯(脉冲常开:靠耳朵找到铁器再落脚) ——
ctx.note('单击,听听楼梯的铁器回声');
await ctx.gesture('tap');
await ctx.waitVirtual(2200);
await navigate(ctx, mock, game, game._floor.stairs, { until: 'stairs' });
ctx.note('踏上楼梯格——「楼梯就在脚下」');
if (!(await tapDescend(ctx, mock, game))) {
ctx.note('(还没到楼梯,继续朝铁器回声摸过去)');
await navigate(ctx, mock, game, game._floor.stairs, { until: 'stairs' });
await tapDescend(ctx, mock, game);
}
await waitStep(ctx, mock, () => game.state === 'EXPLORE' && game.run?.floor === 2, 20000);
ctx.status(game.run?.floor === 2
? '教学完了。第二层,真正的地窟从这里开始'
: '下潜尚未完成——先把楼梯找到');
// —— EXPLORE:脉冲探路 → 明雷战斗 → 逐层下潜到第 3 层(每层走一步验一步,
// 被怪打断/没踩准则再摸一轮——R2 修正:单发 navigate+tapDescend 任何一环
// 被打断就整段脱轨,这是 R1 转写下潜零演出的第三个根源) ——
while (game.run && game.run.floor < 3 && game.state !== 'SETTLE') {
const from = game.run.floor;
ctx.note(`单击脉冲,探一探第 ${from} 层`);
await ctx.gesture('tap'); // 若正踩在楼梯上(D9 收尾),这一击就是下潜
await ctx.waitVirtual(2200);
await navigate(ctx, mock, game, game._floor.stairs, { until: 'stairs' });
if (!(await tapDescend(ctx, mock, game))) {
ctx.note('(还没踩准楼梯,朝铁器回声再摸一轮)');
await navigate(ctx, mock, game, game._floor.stairs, { until: 'stairs' });
if (!(await tapDescend(ctx, mock, game)) && game._onStairs) {
ctx.note('铁器就在脚下——单击,下潜');
await ctx.gesture('tap');
}
}
await waitStep(ctx, mock, () => !game.run || game.run.floor > from || game.state === 'SETTLE', 20000);
const done = game.run && game.run.floor === from + 1;
ctx.status(!done
? `下潜尚未完成(还在第 ${game.run?.floor ?? '?'} 层)`
: game.run.floor === 3 ? '第三层——窟主的地界' : `下潜成功,这是第 ${game.run.floor} 层`);
if (!done) break; // 两轮都没下去:不空转,如实记「没下去」
}
// —— L3:走向楼梯,窟主石甲蟹就守在上面 → 力战而亡 ——
for (let guard = 0; guard < 3 && game.run && game.run.floor === 3 && game.state !== 'SETTLE'; guard++) {
ctx.note('脉冲听路,朝楼梯摸过去');
await ctx.gesture('tap');
await ctx.waitVirtual(2200);
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); continue; }
if (game._onStairs && game.state === 'EXPLORE') { await tapDescend(ctx, mock, game); continue; }
break;
}
// —— SETTLE:死亡归因 + 元进度入账 ——
await waitStep(ctx, mock, () => game.state === 'SETTLE', 60000);
if (game.state === 'SETTLE') {
ctx.status('倒下了。死亡归因链:死于谁、哪里没做好、带走了多少金币');
ctx.note('听完归因三问和「下个周目,你会更强」');
await ctx.waitVirtual(10000);
ctx.note('单击——再探一次');
await ctx.gesture('tap'); // → TITLE(元进度入账后)
await waitStep(ctx, mock, () => game.state === 'TITLE', 10000);
} else {
ctx.note('这一把竟然没死——那就继续往下走');
}
// —— 第二周目:遗产继承、不再进教学(只有真回到 TITLE 才单击开局) ——
if (game.state === 'TITLE') {
ctx.note('单击,开始新周目');
await ctx.gesture('tap');
await waitStep(ctx, mock, () => game.state === 'EXPLORE' && game.run, 30000);
ctx.status('新周目直落探索:遗产生效,不用再上课');
ctx.note('上滑,听新周目的局面复述');
await ctx.gesture('up');
await ctx.waitVirtual(6500);
ctx.note('单击脉冲,听听熟悉的回声');
await ctx.gesture('tap');
await ctx.waitVirtual(2500);
}
ctx.status('第二周目短程收尾——死亡-重开循环跑通,收工');
}
// ---------------------------------------------------------------- 乱按压力剧本
export async function sessionStress(ctx, mock) {
const { game } = ctx;
let seed = 20260901;
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;
}
if (rng() < 0.15) { // 偶尔长按(探索=前进,战斗=尝试逃跑,Boss 会拒绝)
await ctx.gesture('longpress');
await ctx.waitVirtual(300);
await ctx.gesture('longpressend');
await ctx.waitVirtual(200);
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');
// ② 教学期:一半乱按,但每步最后一定做对(跟不下来也要能走完)
await waitStep(ctx, mock, () => game.state === 'DRILL' || game.state === 'EXPLORE', 20000);
ctx.status('教学里乱按一气,旁白说到哪按到哪');
const RIGHT = { 0: 'tap', 1: 'tap', 2: 'tap', 3: 'tap', 4: 'longpress', 5: 'left', 6: 'doubletap', 9: 'tap' };
let stepGuard = 0;
while (game.state === 'DRILL' && game._drill && stepGuard < 320) {
stepGuard++;
const step = game._drill.step;
if (game.menuOpen) { await ctx.gesture('down'); await ctx.waitVirtual(500); continue; }
if (game.state === 'BATTLE') { await chaosFight(ctx, game, rng, pick); continue; }
if (rng() < 0.45) {
await noise(); // 乱按:会吃 error 震、会走错路,但不死锁
continue;
}
if (step === 7) { // 上滑 + 开菜单再关
if (!game._drill.upDone) { await ctx.gesture('up'); await ctx.waitVirtual(5800); }
else { await ctx.gesture('down'); await ctx.waitVirtual(1400); await ctx.gesture('down'); await ctx.waitVirtual(600); }
continue;
}
if (step === 8) { // 等教学怪扑上来,交给乱打
await waitStep(ctx, mock, () => game.state === 'BATTLE' || game._drill?.step === 9, 25000);
if (game.state === 'BATTLE') await chaosFight(ctx, game, rng, pick);
continue;
}
if (step === 9) { // D9:乱按到这一步也得真走到楼梯,否则会卡死在聆听里
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 stp = (bfsPath(game._floor, game.run.pos, st, mobCells(game._floor))[0]
|| bfsPath(game._floor, game.run.pos, st, new Set())[0]);
if (!stp) { await ctx.gesture('tap'); await ctx.waitVirtual(1500); continue; }
const d = (ORDER.indexOf(stp.dir) - ORDER.indexOf(game.run.face) + 4) % 4;
await ctx.gesture(d === 3 ? 'left' : 'right');
await ctx.waitVirtual(200);
await ctx.gesture('longpress');
await ctx.waitVirtual(120);
await ctx.gesture('longpressend');
await ctx.waitVirtual(250);
continue;
}
const gs = RIGHT[step];
if (!gs) { await ctx.waitVirtual(300); continue; }
await ctx.gesture(gs);
if (step === 4) { await ctx.waitVirtual(1400); await ctx.gesture('longpressend'); }
await ctx.waitVirtual(500);
}
await waitStep(ctx, mock, () => game.state === 'EXPLORE' || game.state === 'BATTLE' || game.state === 'SETTLE', 25000);
ctx.status('总算从教学里滚出来了(可能跳了课,可能一路错过来的)');
// ③+④ 探索/下潜/送死:一路狂点脉冲、乱走乱打,直到死一次为止(零死锁底线=SETTLE)
let chaosGuard = 0;
while (game.run && game.state !== 'SETTLE' && chaosGuard < 500) {
chaosGuard++;
if (game.state === 'DRILL') { // 乱按按出「跳过教学」(下滑在 DRILL 的原义),绝不在教学里空转
await ctx.gesture('down');
await ctx.waitVirtual(2500);
continue;
}
if (game.state === 'BATTLE') {
const b = game._battle;
if (b?.boss || game.run.floor >= 3) {
ctx.status('乱打:不格挡、逃不掉(Boss),一直挥拳');
await fightToDeath(ctx, game);
continue;
}
await chaosFight(ctx, game, rng, pick);
continue;
}
if (game.menuOpen) { await ctx.gesture('down'); await ctx.waitVirtual(400); continue; }
if (game.state !== 'EXPLORE') { await ctx.waitVirtual(400); continue; }
if (game._onStairs && !game._floor.mobs.some((m) => m.x === game.run.pos.x && m.y === game.run.pos.y)) {
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) { // 狂点脉冲:冷却期就是一串 error 震,游戏不炸
ctx.note('手痒连点三下脉冲');
await ctx.gesture('tap');
await ctx.gesture('tap');
await ctx.gesture('tap');
await ctx.waitVirtual(1600);
continue;
}
if (r < 0.45) { await noise(); continue; }
// 正经走两步:脉冲听路 → 朝楼梯/王座挪
await ctx.gesture('tap');
await ctx.waitVirtual(1800);
const st = game._floor.stairs || game._floor.throne;
const step = (bfsPath(game._floor, game.run.pos, st, mobCells(game._floor))[0]
|| bfsPath(game._floor, game.run.pos, st, new Set())[0]);
if (!step) { await ctx.waitVirtual(800); continue; }
const d = (ORDER.indexOf(step.dir) - ORDER.indexOf(game.run.face) + 4) % 4;
await ctx.gesture(d === 3 ? 'left' : 'right');
await ctx.waitVirtual(200);
await ctx.gesture('longpress');
await ctx.waitVirtual(120);
await ctx.gesture('longpressend');
await ctx.waitVirtual(250);
}
// ⑤ SETTLE → TITLE → 新周目,短程收尾
await waitStep(ctx, mock, () => game.state === 'SETTLE', 20000);
if (game.state === 'SETTLE') {
ctx.status('乱打也被归因得明明白白,元进度照单全收');
await ctx.waitVirtual(4000);
await ctx.gesture('tap'); // → TITLE
await waitStep(ctx, mock, () => game.state === 'TITLE', 10000);
await noise(); // 标题页再乱划一下
await ctx.gesture('tap'); // 新周目
await waitStep(ctx, mock, () => game.state === 'EXPLORE' && game.run, 25000);
ctx.status('第二周目照样开局,压力测试全程零死锁,收工');
} else {
ctx.note('这一把居然没死——压力有限,收工');
}
}
/** 乱打:大多数时候出手,偶尔乱滑/上滑/试图逃跑;前摇里手忙脚乱。 */
async function chaosFight(ctx, 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(500); continue; }
if (b.state === 'telegraph') {
if (rng() < 0.25) {
ctx.note('手忙脚乱中碰巧双击了一下');
await ctx.gesture('doubletap');
await ctx.waitVirtual(300);
} else {
await ctx.waitVirtual(400);
}
continue;
}
if (b.state === 'player') {
const r = rng();
if (r < 0.62) {
ctx.note('单击,抡空也打');
await ctx.gesture('tap');
} else if (r < 0.72) {
ctx.note('慌了,按住屏幕想跑');
await ctx.gesture('longpress');
await ctx.waitVirtual(300);
await ctx.gesture('longpressend');
} else if (r < 0.88) {
await ctx.gesture(pick(['left', 'right']));
} else {
await ctx.gesture('up');
}
await ctx.waitVirtual(500);
continue;
}
await ctx.waitVirtual(400);
}
await ctx.waitVirtual(3000);
}