/** * 《回声地窟》无头试玩测试(26 号文档 playbook 深度重构版,设计单 26-apply-dungeon.md)。 * * 覆盖:地窟生成不变式(seed 复现/连通/楼梯=最远房/安全区/密度)、回声===grid 全扫描、 * 新教学链 D0~D7(首 3 分钟逐秒脚本)、四向相对移动矩阵(前进/侧移转向/后退/撞墙归因)、 * 长按=我在哪、脉冲冷却 invalid、战斗(单击格挡 ×0.2 / 挥空 / 背刺 / 缠斗纠错 / 下滑逃跑 / * Boss 拒绝与半血)、死亡归因真话 + 遗产经济 + 结算 critical 链与手势、断点复述、 * 迷茫接管三级(探索态)、流程兜底(屏蔽资产不卡死)、菜单(左右滑+单击确认+二次确认)、 * §6.2 乱点机器人指标(无声操作 0 / 被动原句重复 0 / 打断失败 0 / 首次操作 ≤10s / * 首次成功 ≤20s)、资产引用校验(新句以 PENDING 虚拟资产模拟,落地后自动转实物断言)。 * * 运行:tools/node/bin/node web/dungeon/test/playtest.mjs * 退出码 0 = 全部断言通过。随机性由种子 PRNG 保证可重复;地窟生成由 opts.seed 注入复现。 */ import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; const DUNGEON_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); // ================================ 虚拟时钟 ================================ let vnow = 0; // 虚拟毫秒 const timers = new Map(); let nextTimerId = 1; function schedule(ms, fn) { const id = nextTimerId++; timers.set(id, { at: vnow + ms, fn }); return id; } function unschedule(id) { timers.delete(id); } async function flushMicro() { for (let i = 0; i < 5; i++) await Promise.resolve(); } /** 推进虚拟时钟 ms 毫秒,期间到期的计时器按序触发(含语音 onComplete 链)。 */ async function tick(ms) { const target = vnow + ms; for (let guard = 0; guard < 200000; guard++) { await flushMicro(); let best = null; for (const [id, t] of timers) { if (t.at <= target && (!best || t.at < best.t.at)) best = { id, t }; } if (!best) break; timers.delete(best.id); vnow = best.t.at; best.t.fn(); } vnow = target; await flushMicro(); } /** 有界条件等待(虚拟时钟下绝不忙等挂死)。 */ async function waitCond(cond, maxMs = 30000) { const t0 = vnow; let spins = 0; const spinCap = Math.max(80, Math.floor(maxMs / 25)); while (!cond() && vnow - t0 < maxMs && spins < spinCap) { spins++; await tick(25); } } // ================================ 浏览器环境 mock ================================ const storageMap = new Map(); const localStorageMock = { getItem: (k) => (storageMap.has(k) ? storageMap.get(k) : null), setItem: (k, v) => { storageMap.set(k, String(v)); }, removeItem: (k) => { storageMap.delete(k); }, clear: () => storageMap.clear(), }; const vibrateLog = []; const fetchLog = []; Object.defineProperty(globalThis, 'performance', { value: { now: () => vnow }, configurable: true }); globalThis.setTimeout = (fn, ms = 0) => schedule(ms, fn); globalThis.clearTimeout = unschedule; globalThis.localStorage = localStorageMock; globalThis.fetch = (url, opts) => { fetchLog.push({ url, opts }); return Promise.resolve({ ok: true, json: async () => ({}) }); }; Object.defineProperty(globalThis, 'navigator', { value: { maxTouchPoints: 1, vibrate: (p) => { vibrateLog.push({ t: vnow, p }); return true; } }, configurable: true, }); globalThis.window = { addEventListener: () => {}, location: { href: '' }, }; globalThis.document = { getElementById: () => null, createElement: () => ({ style: { setProperty: () => {} }, className: '', offsetWidth: 0 }), body: { appendChild: () => {} }, }; const origConsoleError = console.error.bind(console); console.error = () => {}; // 资产缺失走审计断言,不刷屏 const origConsoleLog = console.log.bind(console); console.log = () => {}; console.warn = () => {}; // 种子 PRNG(mulberry32):怪物游走间隔/游走方向/新周目 seed 可重复 let randState = 42; Math.random = () => { randState |= 0; randState = (randState + 0x6D2B79F5) | 0; let t = Math.imul(randState ^ (randState >>> 15), 1 | randState); t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; return ((t ^ (t >>> 14)) >>> 0) / 4294967296; }; // ================================ 资产存在性 ================================ /** * 26 号重构新增台词(tools/voice_lines_26/dungeon.md「新增」节):资产由管线统一 * 生成后落地;落地前测试以「虚拟资产」模拟(照常播放、可断言),生成后 PENDING * 清空即自动转为实物断言(见文末资产审计)。 */ const PENDING_VOICE = new Set([ 'd0_new', 'sys_first_tap', 'sys_first_tap_short', 'sys_turn_teach', 'tut_praise', 'tut_move', 'tut_side_move', 'tut_coin', 'tut_help_hold', 'tut_parry_tap', 'hint_swipe', 'btl_turn_short', 'wall', 'wall_dir_front', 'wall_dir_left', 'wall_dir_right', 'wall_dir_back', 'inv_pulse', 'inv_title', 'inv_settle', 'inv_drill', 'inv_tel', 'inv_menu_locked', 'menu_open', 'menu_help', 'menu_restart_confirm', 'sys_only_up', 'sys_only_down', 'sys_only_left', 'sys_only_right', 'sys_demo', 'sys_now_you', 'sys_say_again', 'sys_unlock_down', 'sys_unlock_dt', ]); function hasPair(base) { return fs.existsSync(base + '.ogg') && fs.existsSync(base + '.m4a'); } /** voice:双性别目录齐全(或 PENDING 虚拟资产);sfx/bgm:双格式。 */ function assetExists(id) { const audioDir = path.join(DUNGEON_DIR, 'audio'); if (id.startsWith('voice/')) { const name = id.slice(6); if (PENDING_VOICE.has(name)) return true; return hasPair(path.join(audioDir, 'voice', 'female', name)) && hasPair(path.join(audioDir, 'voice', 'male', name)); } return hasPair(path.join(audioDir, id)); } // ================================ 音频引擎 mock ================================ let voiceUidSeq = 1; class MockAudioEngine { constructor() { this.log = []; // {t, layer, id, opts, missing, uid} this._voice = null; // 当前 voice token(isVoicePlaying 判定依据) this.voiceStops = []; // {uid, t, how}(自然完/顶掉/skip/stopVoice —— 打断指标用) this.bgmNow = null; this.blocked = new Set(); // 被屏蔽的 voice id(onComplete 永不触发 → 流程兜底) this.rateLog = []; } isVoicePlaying() { return this._voice !== null; } setRateBoostEnabled(on) { this.rateLog.push({ t: vnow, on }); } holdDuck() {} // BGM 压低跨链:无 BGM 实体,no-op _dur() { return 1.0; } // 假 buffer 时长(秒):语音链按虚拟时钟推进 _clearVoice(how) { const t = this._voice; if (!t) return; this._voice = null; this.voiceStops.push({ uid: t.uid, t: vnow, how }); } play(id, opts = {}) { const layer = id.split('/')[0]; const missing = !assetExists(id); const uid = voiceUidSeq++; this.log.push({ t: vnow, layer, id, opts, missing, uid }); if (missing) return; // 引擎静默跳过语义(审计单独断言) if (layer === 'voice') { this._clearVoice('top'); // 顶掉当前句(不触发其 onComplete,对齐引擎语义) const token = { id, uid, onComplete: opts.onComplete }; this._voice = token; if (!this.blocked.has(id)) { schedule(this._dur(id) * 1000, () => { if (this._voice === token) { this._clearVoice('end'); if (token.onComplete) token.onComplete(); } }); } } else if (layer === 'bgm') { this.bgmNow = id; } // sfx:即发即弃,无后续 } skipVoice() { // 视为这句自然播完:onComplete → 链继续(floor tap 跳句语义) const t = this._voice; if (!t) return; this._clearVoice('skip'); if (t.onComplete) t.onComplete(); } stopVoice() { this._clearVoice('stop'); } stopBgm() { this.log.push({ t: vnow, layer: 'bgm', id: '(stop)', opts: {} }); this.bgmNow = null; } stopAll() { this.stopBgm(); this.stopVoice(); } prewarm() {} } // ================================ 断言设施 ================================ let passCount = 0; const failures = []; function assert(cond, msg) { if (cond) { passCount++; } else { failures.push(msg); origConsoleError(` ✗ ${msg}`); } } function ok(msg) { passCount++; origConsoleLog(` ✓ ${msg}`); } function plays(audio, from = 0) { const L = audio.log.slice(from); return { has: (id) => L.some((e) => e.id === id && !e.missing), count: (id) => L.filter((e) => e.id === id && !e.missing).length, hasRe: (re) => L.some((e) => re.test(e.id) && !e.missing), countRe: (re) => L.filter((e) => re.test(e.id) && !e.missing).length, orderOk: (...ids) => { let pos = -1; for (const id of ids) { const i = L.findIndex((e, j) => j > pos && e.id === id && !e.missing); if (i < 0) return false; pos = i; } return true; }, lastBgm: (id) => L.filter((e) => e.id === id && !e.missing).at(-1), }; } // ================================ 游戏装配(输入管道) ================================ const { DungeonGame } = await import('../game.js'); const { settings } = await import('../../engine/settings.js'); const { makeInputPipeline } = await import('../../engine/pipeline.js'); settings.set({ guide: 'off', voice: 'female', speed: 1.0, bgm: 1.0 }); const audioRegistry = []; function newGame(opts = {}) { const audio = new MockAudioEngine(); audioRegistry.push(audio); const game = new DungeonGame(audio, {}, opts); game.floor.bedEnabled = false; // 虚拟时钟测试关掉等待声(周期定时器排水慢) const receiptLog = []; const origR = game.receipts.play.bind(game.receipts); game.receipts.play = (kind) => { receiptLog.push({ kind, t: vnow }); return origR(kind); }; const chimeLog = []; const origChime = game.floor.chime.bind(game.floor); game.floor.chime = () => { chimeLog.push(vnow); return origChime(); }; const pipe = makeInputPipeline({ floor: game.floor, confusion: game.confusion, receipts: game.receipts, invalidHints: game.invalidHints, game, ladder: game.ladder, opts: game.PIPELINE_OPTS, }); return { game, audio, pipe, receiptLog, chimeLog }; } const g = (rig, gesture) => rig.pipe(gesture); // ================================ 网格真值工具(测试侧独立重算) ================================ const DIR_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 ECHO_DELAY = [0, 250, 500, 800, 1100]; const ECHO_VOL = [0, 0.9, 0.7, 0.5, 0.35]; function truthScan(floor, x, y, face) { const S = floor.size; const d = DIRS[face]; if (floor.stairs && x === floor.stairs.x && y === floor.stairs.y) { return { band: 1, dist: 1, mat: 'stair', left: sideScan(floor, x, y, face, -1), right: sideScan(floor, x, y, face, 1) }; } let dist = S * 2, mat = 'stone'; for (let i = 1; i <= S * 2; i++) { const cx = x + d.dx * i, cy = y + d.dy * i; if (cx < 0 || cy < 0 || cx >= S || cy >= S || floor.grid[cy][cx] === 0) { dist = i; mat = 'stone'; break; } if (floor.stairs && cx === floor.stairs.x && cy === floor.stairs.y) { dist = i; mat = 'stair'; break; } } const band = dist <= 1 ? 1 : dist === 2 ? 2 : dist <= 4 ? 3 : 4; return { band, dist, mat, left: sideScan(floor, x, y, face, -1), right: sideScan(floor, x, y, face, 1) }; } function sideScan(floor, x, y, face, s) { const S = floor.size; const fi = DIR_ORDER.indexOf(face); const dd = DIRS[DIR_ORDER[(fi + s + 4) % 4]]; const sx = x + dd.dx, sy = y + dd.dy; if (sx < 0 || sy < 0 || sx >= S || sy >= S || floor.grid[sy][sx] === 0) return { kind: 'wall', dist: 1 }; return { kind: 'open', dist: 1 }; } function floorSig(f) { const items = [...f.items.entries()].sort((a, b) => (a[0] < b[0] ? -1 : 1)) .map(([k, v]) => k + ':' + JSON.stringify(v)).join('|'); return JSON.stringify({ grid: f.grid, rooms: f.rooms, start: f.start, stairs: f.stairs, throne: f.throne, mobs: f.mobs.map((m) => ({ type: m.type, x: m.x, y: m.y, hp: m.hp, maxHp: m.maxHp, drill: !!m.drill })), items, }); } function bfsDist(floor) { const S = floor.size; const dist = Array.from({ length: S }, () => new Array(S).fill(-1)); const q = [[floor.start.x, floor.start.y]]; dist[floor.start.y][floor.start.x] = 0; while (q.length) { const [x, y] = q.shift(); for (const d of Object.values(DIRS)) { const nx = x + d.dx, ny = y + d.dy; if (nx < 0 || ny < 0 || nx >= S || ny >= S || floor.grid[ny][nx] !== 1 || dist[ny][nx] >= 0) continue; dist[ny][nx] = dist[y][x] + 1; q.push([nx, ny]); } } return dist; } function bfsPath(floor, from, to, blocked = new Set()) { 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; } /** 相对手势 → 朝绝对方向 dir 移动一格(四向滑动 = 面朝相对移动)。 */ function gestureToward(game, dir) { const rel = (DIR_ORDER.indexOf(dir) - DIR_ORDER.indexOf(game.run.face) + 4) % 4; return ['up', 'right', 'down', 'left'][rel]; } async function moveAbs(rig, dir) { await g(rig, gestureToward(rig.game, dir)); await tick(30); } const mobCells = (floor) => new Set(floor.mobs.map((m) => m.x + ',' + m.y)); const lastBgmEntry = (audio) => audio.log.filter((e) => e.layer === 'bgm').at(-1); /** 通用战斗打法:前摇窗内单击格挡,玩家回合单击挥击(≥400ms 间隔防连点窗)。 */ async function fightUntilWin(rig, maxMs = 60000) { const { game } = rig; const t0 = vnow; while (game.state === 'BATTLE' && vnow - t0 < maxMs) { const b = game._battle; if (!b) break; if (b.state === 'telegraph') { await g(rig, 'tap'); await tick(200); continue; } if (b.state === 'player') { await g(rig, 'tap'); await tick(450); continue; } await tick(400); } await tick(6000); // 胜利链走完 } /** 沿寻路序列移动(每步按当前面朝换算相对手势;途中遭遇就地打完)。 */ async function walkPath(rig, path) { const trace = process.env.DUNGEON_TRACE ? [] : null; for (const step of path) { if (rig.game.state === 'BATTLE') { await fightUntilWin(rig); continue; } await moveAbs(rig, step.dir); await tick(560); if (trace) trace.push(`${step.dir}@${rig.game.run.pos.x},${rig.game.run.pos.y}:${rig.game.state}`); } if (rig.game.state === 'BATTLE') await fightUntilWin(rig); if (trace) origConsoleLog(' [walkPath] ' + trace.join(' ')); } /** 从 TITLE 快进到 EXPLORE(预置 drillDone 存档 → 继续 → 复述链播完)。 */ async function reachExplore(rig) { const { game } = rig; if (!storageMap.has('soundgame.progress.dungeon')) seedSave(55); game._mobStep = () => {}; // 静场:快进路径不受游走怪干扰 game.start(); await waitCond(() => game.floor.state === 'YOUR_TURN'); await g(rig, 'tap'); // 继续上周目 await waitCond(() => game.state === 'EXPLORE'); await tick(6500); // 复述链播完 } /** 预置一份 drillDone 的进行中存档(seed 可指定)。 */ function seedSave(seed = 55) { storageMap.set('soundgame.progress.dungeon', JSON.stringify({ schema: 1, game: 'dungeon', updatedAt: '2026-01-01T00:00:00Z', summary: '第1层 · 毫发无损 · 金币0 · 遗产0/5', meta: { gold: 0, unlocks: [], bestFloor: 0, runs: 0, kills: 0, drillDone: true, pending: [] }, run: { seed, floor: 1, pos: [3, 3], face: 'S', hp: 20, atkLv: 0, weapon: 0, armor: 0, potion: 0, veilUsedFloor: false, killsRun: 0, looted: [], cleared: [], parries: 0, flees: 0, gold: 0, }, })); } // ================================ 场景 1:seed 复现 + 网格不变式 ================================ const SPEC = { 1: { size: 7, mobs: { bat: 2 }, items: ['coin'] }, 2: { size: 7, mobs: { rat: 3 }, items: ['coin', 'potion'] }, 3: { size: 9, mobs: { mush: 3, crab: 1 }, items: ['coin', 'weapon1'], boss: 'crab' }, 4: { size: 9, mobs: { crawler: 2, spider: 2 }, items: ['coin', 'armor1'] }, 5: { size: 11, mobs: { spider: 3, crawler: 2 }, items: ['coin', 'potion'] }, 6: { size: 11, mobs: { spider: 2, crawler: 2, leech: 1 }, items: ['coin', 'weapon2'], boss: 'leech' }, 7: { size: 11, mobs: { guard: 3, king: 1 }, items: ['coin'], final: true }, }; async function scenario1() { origConsoleLog('\n=== 场景 1:seed=42 两次构造逐格相等 + 网格不变式(玩法核心不动) ==='); storageMap.clear(); const a = newGame({ seed: 42 }); const b = newGame({}); for (let f = 1; f <= 7; f++) { const f1 = a.game._generateFloor(42, f); const f2 = a.game._generateFloor(42, f); const f3 = b.game._generateFloor(42, f); assert(JSON.stringify(f1.grid) === JSON.stringify(f2.grid), `L${f}: 同 seed 两次构造 grid 逐格相等`); assert(floorSig(f1) === floorSig(f3), `L${f}: 跨实例构造 grid/怪/宝/楼梯逐格相等`); assert(f1.size === SPEC[f].size, `L${f}: 尺寸 ${SPEC[f].size}(实际 ${f1.size})`); const dist = bfsDist(f1); let okConn = true; for (let y = 0; y < f1.size; y++) for (let x = 0; x < f1.size; x++) { if (f1.grid[y][x] === 1 && dist[y][x] < 0) okConn = false; } assert(okConn, `L${f}: 连通性不变式(起点 BFS 覆盖全地板)`); if (!SPEC[f].final) { assert(f1.stairs && f1.grid[f1.stairs.y][f1.stairs.x] === 1, `L${f}: 楼梯存在且为地板格`); let far = null, farD = -1; for (const r of f1.rooms) { const c = { x: r.x + (r.w >> 1), y: r.y + (r.h >> 1) }; if (dist[c.y][c.x] > farD) { farD = dist[c.y][c.x]; far = c; } } assert(f1.stairs.x === far.x && f1.stairs.y === far.y, `L${f}: 楼梯 = BFS 最远房间中心`); } else { assert(!f1.stairs && f1.throne && f1.grid[f1.throne.y][f1.throne.x] === 1, 'L7: 终局层无楼梯、王座落格'); } const got = {}; for (const m of f1.mobs) got[m.type] = (got[m.type] || 0) + 1; const specTotal = Object.values(SPEC[f].mobs).reduce((x, y) => x + y, 0); for (const [t, n] of Object.entries(SPEC[f].mobs)) assert((got[t] || 0) >= n, `L${f}: 怪物构成 ⊇ ${JSON.stringify(SPEC[f].mobs)}(${t} ${got[t] || 0}/${n})`); assert(Object.keys(got).every((t) => SPEC[f].mobs[t]), `L${f}: 密度补怪全部来自本层怪种`); assert(f1.mobs.length - specTotal <= 8, `L${f}: 密度补怪 ≤8 只(实际 +${f1.mobs.length - specTotal})`); const target = SPEC[f].final ? f1.throne : f1.stairs; for (const m of f1.mobs) { const isBoss = m.type === SPEC[f].boss; if (isBoss) assert(m.x === target.x && m.y === target.y, `L${f}: 窟主守楼梯/王座格`); else { const minD = SPEC[f].mobs.bat && f === 1 ? (m.drill ? 0 : 4) : 3; assert(dist[m.y][m.x] >= minD, `L${f}: 普通怪 BFS ≥${minD} 安全区(${m.type}@${m.x},${m.y} d=${dist[m.y][m.x]})`); } assert(f1.grid[m.y][m.x] === 1, `L${f}: 怪落地板格`); } const gotItems = [...f1.items.values()].map((v) => v.type).sort(); assert(JSON.stringify(gotItems) === JSON.stringify([...SPEC[f].items].sort()), `L${f}: 宝物表 ${SPEC[f].items}`); { const S = f1.size; const content = new Set([f1.start.x + ',' + f1.start.y, target.x + ',' + target.y, ...f1.items.keys(), ...f1.mobs.map((m) => m.x + ',' + m.y)]); const path2 = [target]; let cur2 = target; while (cur2.x !== f1.start.x || cur2.y !== f1.start.y) { for (const d of Object.values(DIRS)) { const nx = cur2.x + d.dx, ny = cur2.y + d.dy; if (nx < 0 || ny < 0 || nx >= S || ny >= S || f1.grid[ny][nx] !== 1 || dist[ny][nx] !== dist[cur2.y][cur2.x] - 1) continue; cur2 = { x: nx, y: ny }; break; } path2.push(cur2); } let maxGap = 0, run = 0; for (const c of path2) { if (content.has(c.x + ',' + c.y)) { maxGap = Math.max(maxGap, run); run = 0; } else run++; } maxGap = Math.max(maxGap, run); assert(maxGap <= 12, `L${f}: 主路径内容密度(最长真空 ${maxGap} 格 ≤12,路径 ${path2.length} 格)`); } } // L1 教学硬约束 const L1 = a.game._generateFloor(42, 1); assert(L1.start.x === 3 && L1.start.y === 3, 'L1: 教学起点恒 (3,3)'); assert(L1.grid[1][3] === 0, 'L1: 约束①起点正前 2 格内有墙(D0 回声演示)'); assert(L1.grid[2][3] === 1 && L1.grid[4][3] === 1, 'L1: 教学房前后格为地板'); const doorBat = L1.mobs.find((m) => m.x === 3 && m.y === 5); assert(doorBat && doorBat.type === 'bat' && doorBat.drill, 'L1: 约束②门口固定教学盲眼蝠 (3,5)'); const coinEntry = [...L1.items.entries()][0]; assert(coinEntry[1].type === 'coin' && /^[234],[234]$/.test(coinEntry[0]) && coinEntry[0] !== '3,3', 'L1: 约束③金币在起点房内(拾取体验)'); ok('七层生成全部复现且不变式成立'); // 实流复现:TITLE → 单击新周目(首句 = sys_first_tap 让玩家动手) a.game.start(); await tick(1200); assert(plays(a.audio).has('voice/sys_first_tap'), 'TITLE 无档首句 = sys_first_tap「点一下屏幕。」(§4.10)'); await waitCond(() => a.game.floor.state === 'YOUR_TURN'); await g(a, 'tap'); await tick(3000); assert(a.game.run && a.game.run.seed === 42, 'opts.seed=42 注入新周目'); assert(JSON.stringify(a.game._floor.grid) === JSON.stringify(L1.grid), '实流 L1 grid 与纯函数生成一致'); await waitCond(() => a.game.state === 'DRILL'); assert(a.game.state === 'DRILL', '首周目进教学'); a.game.stop(); } // ================================ 场景 2:新教学链 D0~D7 + 回声全扫描 ================================ /** D7 窗口内做回声调度全扫描:全地板格 × 4 面向(教学期怪静止,无游走干扰)。 */ async function echoFullScan(rig) { const { game, audio } = rig; const floor = game._floor; const st = floor.stairs; let n = 0; for (let y = 0; y < floor.size; y++) { for (let x = 0; x < floor.size; x++) { if (floor.grid[y][x] !== 1) continue; for (const face of DIR_ORDER) { const truth = truthScan(floor, x, y, face); game.run.pos = { x, y }; game.run.face = face; // 测试钩子:扫描不依赖走位 const mark = audio.log.length; const t0 = vnow; const le = game._pulse(); // 直呼脉冲(楼梯格上 tap=下潜,绕开输入通道) assert(le && le.band === truth.band && le.dist === truth.dist && le.mat === truth.mat, `回声档/距/材质与 grid 真值一致 @(${x},${y},${face})`); assert(le.left === truth.left.kind && le.right === truth.right.kind, `左右侧墙/开口与 grid 真值一致 @(${x},${y},${face})`); await tick(1300); // 冷却 1.2s + band4 回声/尾音全落(调度先行,断言后置) const L = audio.log.slice(mark); assert(L.some((e) => e.id === 'sfx/echo_' + truth.band && !e.missing), `echo_${truth.band} 播出 @(${x},${y},${face})`); assert(L.some((e) => e.id === 'sfx/mat_' + truth.mat && !e.missing), `材质尾音 mat_${truth.mat} 播出 @(${x},${y},${face})`); assert(L.some((e) => e.id === 'sfx/side_' + truth.left.kind + '_l' && !e.missing), `左侧 side_${truth.left.kind}_l @(${x},${y},${face})`); assert(L.some((e) => e.id === 'sfx/side_' + truth.right.kind + '_r' && !e.missing), `右侧 side_${truth.right.kind}_r @(${x},${y},${face})`); assert(L.filter((e) => e.id.startsWith('sfx/side_')).length === 2, '每次脉冲恰两路侧向回声(身后不报)'); if (n % 5 === 0) { const delay = ECHO_DELAY[truth.band], vol = ECHO_VOL[truth.band]; const echo = L.find((e) => e.id === 'sfx/echo_' + truth.band); assert(Math.abs(echo.t - (t0 + delay)) <= 1, `echo_${truth.band} 延迟 ${delay}ms(实测 ${echo.t - t0})`); assert(Math.abs(echo.opts.volume - vol) < 1e-9, `echo_${truth.band} 音量 ${vol}`); const mat = L.find((e) => e.id === 'sfx/mat_' + truth.mat); assert(Math.abs(mat.t - (t0 + delay + 120)) <= 1, `材质尾音 = 回声到达 +120ms(实测 ${mat.t - t0})`); const side = L.find((e) => e.id === 'sfx/side_' + truth.left.kind + '_l'); assert(Math.abs(side.t - (t0 + 350)) <= 1 && Math.abs(side.opts.volume - 0.6) < 1e-9, `侧向回声 +350ms / 音量 0.6(实测 ${side.t - t0} / ${side.opts.volume})`); } n++; assert(game.state === 'DRILL', `扫描期间停留教学态 @(${x},${y},${face})`); } } } assert(n >= 30, `回声全扫描组合 ≥30(实际 ${n})`); return n; } async function scenario2() { origConsoleLog('\n=== 场景 2:新教学链 D0~D7(首 3 分钟脚本)+ 回声全扫描 + 下潜复现 ==='); storageMap.clear(); const rig = newGame({ seed: 42 }); const { game, audio, receiptLog } = rig; game.start(); await tick(1200); await waitCond(() => game.floor.state === 'YOUR_TURN'); let mark = audio.log.length; await g(rig, 'tap'); // 新周目 await waitCond(() => game.state === 'DRILL' && game._drill.step === 0); assert(plays(audio, mark).has('voice/sys_new_run'), '新周目播 sys_new_run(intro_1/intro_2 规则念白已砍)'); await waitCond(() => game.floor.state === 'YOUR_TURN'); assert(plays(audio, mark).has('voice/d0_new'), 'D0 开场第一句 = d0_new「黑暗。点一下,听回声。」(critical)'); // D0:单击脉冲 → 回声组 →「对。」+ 规则句 mark = audio.log.length; const t1 = vnow; await g(rig, 'tap'); const d0Truth = truthScan(game._floor, 3, 3, 'N'); assert(d0Truth.band === 2, 'D0: 起点面北 dist=2 → echo_2(约束①兑现)'); await tick(1700); let p = plays(audio, mark); assert(p.has('sfx/pulse') && p.has('sfx/echo_2'), 'D0: 脉冲 + echo_2 播出'); await tick(3300); p = plays(audio, mark); assert(p.orderOk('voice/tut_praise', 'voice/tut_front_rule'), 'D0: 回声落地后「对。」+ 规则句串行(不叠声)'); await waitCond(() => game._drill.step === 1); assert(game._drill.step === 1, 'D0 通过 → D1'); // D1 错手势:invalid 回执 + inv_drill 解释一次(原则 7) mark = audio.log.length; let r0 = receiptLog.length; await g(rig, 'left'); await tick(200); assert(receiptLog.slice(r0).some((x) => x.kind === 'invalid'), 'D1 错手势 → invalid 回执'); assert(plays(audio, mark).has('voice/inv_drill'), 'D1 错手势 → inv_drill 解释一次'); const invDrillCount = plays(audio).count('voice/inv_drill'); r0 = receiptLog.length; await g(rig, 'down'); await tick(200); assert(plays(audio).count('voice/inv_drill') === invDrillCount, 'inv_drill 同键只解释一次(之后仅回执)'); assert(receiptLog.slice(r0).some((x) => x.kind === 'invalid'), '再错只给回执'); // D1:再点一次(巩固)= 第一次成功(奖励音) mark = audio.log.length; await g(rig, 'tap'); await tick(2500); p = plays(audio, mark); assert(p.has('sfx/levelup'), 'D1: 第二次脉冲 → 奖励音(第一次成功)'); await waitCond(() => game._drill.step === 2); await tick(3000); assert(plays(audio, mark).orderOk('voice/sys_turn_teach', 'voice/tut_move'), 'D1 → D2:先教「轮到你」状态(sys_turn_teach),再教前进 tut_move'); assert(plays(audio, mark).has('sfx/levelup'), 'D1: 奖励音(第一次成功)'); // D2:上滑前进(脚步声;面朝不变) const posBefore = { ...game.run.pos }; const faceBefore = game.run.face; mark = audio.log.length; await g(rig, 'up'); await tick(2200); assert(game.run.pos.y === posBefore.y - 1 && game.run.pos.x === posBefore.x, 'D2: 上滑 = 前进一格'); assert(game.run.face === faceBefore, 'D2: 前进后面朝不变'); assert(plays(audio, mark).has('sfx/step_own'), 'D2: 脚步声(内容音即回执)'); await waitCond(() => game._drill.step === 3); await tick(1000); assert(plays(audio, mark).has('voice/tut_praise'), 'D2 做对:肯定句「对。」(原则 11)'); // D3:左滑 = 侧移 + 转向(转身声 + 脚步声) mark = audio.log.length; const pre3 = { x: game.run.pos.x, y: game.run.pos.y, face: game.run.face }; await g(rig, 'left'); await tick(2200); const relLeft = (DIR_ORDER.indexOf('W') - DIR_ORDER.indexOf(pre3.face) + 4) % 4; // 西在左侧 const leftDir = DIR_ORDER[(DIR_ORDER.indexOf(pre3.face) + 3) % 4]; const leftVec = DIRS[leftDir]; assert(game.run.face === leftDir, `D3: 左滑 = 转向左侧(${pre3.face} → ${game.run.face})`); assert(game.run.pos.x === pre3.x + leftVec.dx && game.run.pos.y === pre3.y + leftVec.dy, 'D3: 左滑 = 向左移动一格'); assert(plays(audio, mark).has('sfx/step_turn') && plays(audio, mark).has('sfx/step_own'), 'D3: 转身声 + 脚步声'); await waitCond(() => game._drill.step === 4); await tick(2500); assert(plays(audio, mark).has('voice/tut_coin'), 'D4: tut_coin「房里有金币,走过去捡。」'); // D4:走到金币格 → 拾取 = 第一个小目标(阶梯阶段 1) game._mobStep = () => {}; // 静场 const ladderStage0 = game.ladder.stage(); const coinEntry = [...game._floor.items.entries()].find(([, v]) => v.type === 'coin'); const [ck, citem] = coinEntry; const [cx, cy] = ck.split(',').map(Number); mark = audio.log.length; const coinPath = bfsPath(game._floor, game.run.pos, { x: cx, y: cy }, mobCells(game._floor)); await walkPath(rig, coinPath); await tick(1500); assert(game.run.pos.x === cx && game.run.pos.y === cy, 'D4: 走上金币格'); assert(plays(audio, mark).has('sfx/coin') && plays(audio, mark).hasRe(/^voice\/evt_coin_/), 'D4: coin 音 + 拾取播报'); assert(game.ladder.stage() === Math.max(ladderStage0, 1), 'D4: 拾金币 = 第一个小目标 → ladder.onGoal()(阶段 1)'); await waitCond(() => game._drill.step === 5); await tick(1500); assert(plays(audio, mark).has('voice/tut_help_hold'), 'D5: tut_help_hold「迷路了,按住屏幕。」'); // D5:长按 = 我在哪(帮助链即答案;位置不变) mark = audio.log.length; const pos5 = { ...game.run.pos }; await waitCond(() => game.floor.state === 'YOUR_TURN'); await g(rig, 'longpress'); await tick(4500); p = plays(audio, mark); assert(p.has('voice/st_floor_1') && p.countRe(/^voice\/st_face_/) === 1, 'D5: 帮助链 = 层 + 面朝'); assert(p.hasRe(/^voice\/(stair_dir_|stair_hint_b)/), 'D5: 帮助链含目标方位句'); assert(!p.hasRe(/^voice\/st_hp_/), 'D5: 满血不报血量(语言经济)'); assert(game.run.pos.x === pos5.x && game.run.pos.y === pos5.y, 'D5: 长按不位移(旧「按住前进」废除)'); await waitCond(() => game._drill.step === 6); await tick(1500); assert(plays(audio, mark).has('voice/tut_mob'), 'D6: tut_mob(真话:ATK0 伤不到你)'); // D6:教学怪逼近 → 遭遇 → 单击格挡 → 单击挥击至击杀 await waitCond(() => game.state === 'BATTLE' && !!game._battle, 25000); const bat = game._battle; assert(bat.type === 'bat' && bat.atk === 0, '教学怪 = 盲眼蝠 ATK0(学期零惩罚,读表断言)'); assert(plays(audio).has('voice/btl_bat'), '遭遇播报 btl_bat'); await waitCond(() => bat.state === 'telegraph', 15000); assert(plays(audio).orderOk('voice/btl_bat', 'voice/tut_parry_tap'), '遭遇链:btl_bat → tut_parry_tap(单击格挡)'); mark = audio.log.length; await g(rig, 'tap'); // 前摇窗口内单击 = 格挡 await tick(3500); p = plays(audio, mark); assert(p.has('sfx/parry'), '前摇内单击 → sfx/parry(旧「双击格挡」废除)'); assert(p.has('voice/tut_parry_ok') && p.has('voice/tut_attack'), '格挡成功句 → 攻击教学句'); assert(game.run.hp === 20, '教学怪零伤害(HP 不降)'); const hpGuard = game.run.hp; while (game.state === 'BATTLE') { const b2 = game._battle; if (b2?.state === 'telegraph') { await g(rig, 'tap'); await tick(250); } else if (b2?.state === 'player') { await g(rig, 'tap'); await tick(460); } else await tick(400); } await tick(4000); assert(game.state === 'DRILL' && game._drill.step === 7, '击杀教学怪 → D7'); assert(game.run.hp === hpGuard, 'D6 全程 HP 不降'); assert(game.meta.gold === citem.gold + 3, `击杀教学怪金币入账(金币堆 ${citem.gold} + 蝠 3 = ${game.meta.gold})`); assert(plays(audio).has('voice/tut_kill'), '击杀播 tut_kill'); await tick(1500); assert(plays(audio).has('voice/tut_stairs'), 'D7: tut_stairs「铁器的回声,就是下潜的楼梯。」'); // D7:回声调度全扫描(静场)+ 长按帮助不位移 origConsoleLog(' --- D7 回声调度全扫描(全地板格 × 4 面向) ---'); game._floor.mobs.length = 0; // 清残余游走怪(含逃跑回归者),路径确定 const scanned = await echoFullScan(rig); ok(`回声全扫描 ${scanned} 组合全部与 grid 真值一致`); mark = audio.log.length; const pos7 = { ...game.run.pos }; await g(rig, 'longpress'); await tick(4500); assert(game.run.pos.x === pos7.x && game.run.pos.y === pos7.y, 'D7: 长按帮助不位移'); assert(plays(audio, mark).countRe(/^voice\/st_floor_/) === 1, 'D7: 帮助链可重放(层 + 方位)'); // D7 收尾:走到楼梯 → 到达即报 → 单击下潜 game.run.pos = { x: 3, y: 3 }; game.run.face = 'N'; game._onStairs = false; const path = bfsPath(game._floor, game.run.pos, game._floor.stairs, mobCells(game._floor)); assert(path.length > 0, 'D7: 起点到楼梯存在通路'); mark = audio.log.length; await walkPath(rig, path); assert(game._onStairs === true, '踏上楼梯格'); assert(plays(audio, mark).has('voice/tut_stairs_on'), '首次踏上楼梯格播 tut_stairs_on'); assert(plays(audio, mark).has('sfx/echo_1') && plays(audio, mark).has('sfx/mat_stair'), '到达即报:踩上楼梯格瞬间自动播贴身铁器回声(echo_1 + mat_stair,零玩家脉冲)'); mark = audio.log.length; await tick(2500); // 让 tut_stairs_on 链播完(期间 tap=跳句不执行下潜) mark = audio.log.length; await g(rig, 'tap'); // 单击下潜 if (process.env.DUNGEON_TRACE) origConsoleLog(` [descend] tap@${game.state} stairs=${game._onStairs} floorState=${game.floor.state}`); await waitCond(() => game.state === 'EXPLORE' && game.run.floor === 2, 15000); if (process.env.DUNGEON_TRACE) origConsoleLog(` [descend] after=${game.state} floor=${game.run?.floor} voiceTail=${audio.log.filter((e) => e.layer === 'voice').slice(-4).map((e) => e.id).join(',')}`); assert(game.run.floor === 2 && game.meta.drillDone === true && game._drill === null, 'DESCEND → 第 2 层探索态,教学收尾'); p = plays(audio, mark); assert(p.has('sfx/stairs_down') && p.orderOk('voice/evt_descend', 'voice/tut_done', 'voice/st_floor_2'), '下潜演出:stairs_down → evt_descend → tut_done → st_floor_2'); assert(game.floor.state === 'ACTION', '探索态 = floor.ACTION(玩法声音即状态)'); const f2 = game._generateFloor(42, 2); assert(JSON.stringify(game._floor.grid) === JSON.stringify(f2.grid), '第 2 层 grid 由 floorSeed 重生成且复现'); game.stop(); } // ================================ 场景 3:探索边界(移动矩阵/撞墙归因/冷却/帮助/呼吸层) ================================ async function scenario3() { origConsoleLog('\n=== 场景 3:探索边界(四向相对移动 / 撞墙归因 / 脉冲冷却 / 长按帮助 / 呼吸层) ==='); storageMap.clear(); storageMap.set('soundgame.gestures', JSON.stringify({ stage: 3, games: 9 })); // 双击不折算(专测) const rig = newGame({}); await reachExplore(rig); const { game, audio, receiptLog } = rig; game._mobStep = () => {}; // 静场 let mark, p; // 上滑=前进 / 下滑=后退(面朝不变);下滑不开菜单(00 §5.3:对局中下滑=后退) // 锚点:找一格,其某向邻格与对向邻格都是地板(前进后可原路后退) const st3 = game._floor.start; let anchor = null, anchorFace = null; outer3: for (let y = 0; y < game._floor.size; y++) for (let x = 0; x < game._floor.size; x++) { if (game._floor.grid[y][x] !== 1) continue; for (const [face, d] of Object.entries(DIRS)) { const fx = x + d.dx, fy = y + d.dy, bx = x - d.dx, by = y - d.dy; const S3 = game._floor.size; if (fx < 0 || fy < 0 || fx >= S3 || fy >= S3 || bx < 0 || by < 0 || bx >= S3 || by >= S3) continue; if (game._floor.grid[fy][fx] === 1 && game._floor.grid[by][bx] === 1) { anchor = { x, y }; anchorFace = face; break outer3; } } } assert(anchor && anchorFace, '场景3: 找到前进/后退双向可通的锚点格'); game.run.pos = { ...anchor }; game.run.face = anchorFace; const fv = DIRS[anchorFace]; mark = audio.log.length; await g(rig, 'up'); await tick(60); assert(game.run.pos.x === anchor.x + fv.dx && game.run.pos.y === anchor.y + fv.dy && game.run.face === anchorFace, '上滑 = 前进一格(面朝不变)'); await g(rig, 'down'); await tick(60); assert(game.run.pos.x === anchor.x && game.run.pos.y === anchor.y && game.run.face === anchorFace, '下滑 = 后退一格(面朝不变)'); assert(!game.menuOpen, '探索下滑不开菜单(玩法占用后退)'); assert(plays(audio, mark).count('sfx/step_own') === 2, '两次移动各一脚步声(silent-ok,内容音即回执)'); // 右滑 = 侧移 + 转向(锚点右侧为地板) let sideAnchor = null; outer3b: for (let y = 0; y < game._floor.size; y++) for (let x = 0; x < game._floor.size - 1; x++) { if (game._floor.grid[y][x] === 1 && game._floor.grid[y][x + 1] === 1) { sideAnchor = { x, y }; break outer3b; } } game.run.pos = { ...sideAnchor }; game.run.face = 'N'; mark = audio.log.length; await g(rig, 'right'); await tick(60); assert(game.run.face === 'E' && game.run.pos.x === sideAnchor.x + 1 && game.run.pos.y === sideAnchor.y, '右滑 = 向右一格并转向东'); assert(plays(audio, mark).has('sfx/step_turn') && plays(audio, mark).has('sfx/step_own'), '侧移 = 转身声 + 脚步声'); // 撞墙:bump + invalid + 归因句 + 方位真话(每方向一次) let bumpSpot = null; outer: for (let y = 0; y < game._floor.size; y++) for (let x = 0; x < game._floor.size; x++) { if (game._floor.grid[y][x] !== 1) continue; for (const [face, d] of Object.entries(DIRS)) { const nx = x + d.dx, ny = y + d.dy; if (nx < 0 || ny < 0 || nx >= game._floor.size || ny >= game._floor.size || game._floor.grid[ny][nx] === 0) { bumpSpot = { x, y, face }; break outer; } } } game.run.pos = { x: bumpSpot.x, y: bumpSpot.y }; game.run.face = bumpSpot.face; const hpBefore = game.run.hp; mark = audio.log.length; let r0 = receiptLog.length; await g(rig, gestureToward(game, bumpSpot.face)); // 朝墙移动(相对手势) await tick(2200); // 归因链两句话播完(wall → wall_dir_front 串行,各 1s) p = plays(audio, mark); assert(p.has('sfx/bump'), '撞墙播 bump 闷响'); assert(p.has('voice/wall') && p.has('voice/wall_dir_front'), '首次撞墙 = wall 归因 + wall_dir_front 方位真话'); assert(receiptLog.slice(r0).some((x) => x.kind === 'invalid'), '撞墙 = invalid 回执(失败表:方向错)'); assert(game.run.pos.x === bumpSpot.x && game.run.pos.y === bumpSpot.y && game.run.hp === hpBefore, '撞墙原地无伤'); const wallCount = plays(audio).count('voice/wall'); await g(rig, gestureToward(game, bumpSpot.face)); // 同方向再撞:只闷响 + 回执 await tick(300); assert(plays(audio).count('voice/wall') === wallCount, '同方向再撞不重播归因句(原则 7)'); assert(plays(audio, mark).count('sfx/bump') >= 2, '再撞仍有闷响(不是无声)'); // 换方向撞:只给方位句(不同 id,渐短)——找 bumpSpot 的另一个临墙方向 let face2 = null; for (const [face, d] of Object.entries(DIRS)) { if (face === bumpSpot.face) continue; const nx = bumpSpot.x + d.dx, ny = bumpSpot.y + d.dy; if (nx < 0 || ny < 0 || nx >= game._floor.size || ny >= game._floor.size || game._floor.grid[ny][nx] === 0) { face2 = face; break; } } if (face2) { const rel2 = (DIR_ORDER.indexOf(face2) - DIR_ORDER.indexOf(bumpSpot.face) + 4) % 4; const relName = ['front', 'right', 'back', 'left'][rel2]; mark = audio.log.length; await g(rig, gestureToward(game, face2)); await tick(1300); // 方位句一句播完(链结束后回 ACTION,冷却段 tap 才是真脉冲) assert(plays(audio, mark).has('voice/wall_dir_' + relName) && !plays(audio, mark).has('voice/wall'), `新方向撞墙只播方位句 wall_dir_${relName}(渐短,原则 9)`); } // 脉冲冷却 1.2s:冷却期单击 → invalid 回执 + inv_pulse 解释一次;无第二次回声组 mark = audio.log.length; r0 = receiptLog.length; await g(rig, 'tap'); // 脉冲 1 await tick(100); await g(rig, 'tap'); // 冷却期内 await tick(1700); assert(plays(audio, mark).count('sfx/pulse') === 1, '冷却期单击不重复脉冲'); assert(receiptLog.slice(r0).some((x) => x.kind === 'invalid'), '冷却期单击 = invalid 回执'); assert(plays(audio, mark).has('voice/inv_pulse'), '冷却解释 inv_pulse「回声还没回来,稍等。」'); assert(plays(audio, mark).countRe(/^sfx\/echo_/) === 1, '冷却期无第二次回声组'); r0 = receiptLog.length; mark = audio.log.length; await tick(1300); // 出冷却 await g(rig, 'tap'); await g(rig, 'tap'); // 再进冷却 await tick(300); assert(!plays(audio, mark).has('voice/inv_pulse'), 'inv_pulse 同键只解释一次'); // 双击(阶段 3)= 冷却外再脉冲(不绑免冷却重播) await tick(1300); mark = audio.log.length; await g(rig, 'doubletap'); await tick(300); assert(plays(audio, mark).has('sfx/pulse'), '双击(阶段 3)= 再发脉冲(冷却外合法「再听」)'); await tick(800); // 仍在 1.2s 冷却内(1300-800 < 1200) mark = audio.log.length; await g(rig, 'doubletap'); // 冷却内 await tick(300); assert(plays(audio, mark).count('sfx/pulse') === 0 && receiptLog.some((x) => x.kind === 'invalid'), '冷却内双击不重发(禁止「双击=免冷却重播」绑死连点)'); // 长按帮助(探索,不在楼梯):层[+血 if 伤]+面朝+目标方位 game.run.hp = 10; // 钩子:受伤档 3 mark = audio.log.length; await g(rig, 'longpress'); await tick(4500); p = plays(audio, mark); assert(p.orderOk('voice/st_floor_1', 'voice/st_hp_3') || p.has('voice/st_hp_3'), '受伤后帮助链含血量档'); assert(p.countRe(/^voice\/st_face_/) === 1, '帮助链含面朝句'); assert(p.hasRe(/^voice\/(stair_dir_|stair_hint_b|btl_king)/), '帮助链含目标方位句'); game.run.hp = 20; // 玩家主动重听(§4.4)永远给完整链:链内不叠句、血量按当前伤势(钩子已回满血 → 无血句) mark = audio.log.length; await g(rig, 'longpress'); await tick(4500); assert(plays(audio, mark).count('voice/st_floor_1') === 1 && !plays(audio, mark).has('voice/st_hp_3') && plays(audio, mark).countRe(/^voice\/stair_dir_/) <= 1, '重复帮助不叠句(主动重听=完整链,链内各句恰一次)'); // 探索呼吸层:静默 20s 播 amb_cave;操作重置(探测循环 5s 粒度,窗口放宽到 26s) game._lastAmbAt = vnow; // 对齐计时 mark = audio.log.length; await tick(26000); assert(plays(audio, mark).count('sfx/amb_cave') === 1, '呼吸层:静默 ≥20s 播 amb_cave 恰 1 次'); await tick(26000); assert(plays(audio, mark).count('sfx/amb_cave') === 2, '呼吸层:20s 间隔再播(不连播)'); await g(rig, 'up'); mark = audio.log.length; await tick(12000); assert(plays(audio, mark).count('sfx/amb_cave') === 0, '操作后 12s 内不播(静默计时重置)'); game.stop(); } // ================================ 场景 4:战斗(单击格挡 / 挥空 / 背刺 / 缠斗 / 逃跑 / Boss) ================================ async function scenario4() { origConsoleLog('\n=== 场景 4:战斗(单击格挡 ×0.2 / 缠斗纠错 / 下滑逃跑 / Boss 拒绝 / 绕位背刺 / 半血) ==='); storageMap.clear(); const rig = newGame({}); await reachExplore(rig); const { game, audio, receiptLog } = rig; game._mobStep = () => {}; let mark, p, r0; // ---- Test A:L2 窟鼠(缠斗,ATK=1) ---- game._encounter({ type: 'rat', x: game.run.pos.x, y: game.run.pos.y, hp: 6, maxHp: 6 }); await waitCond(() => game._battle?.state === 'player', 12000); const b = game._battle; assert(b && b.type === 'rat' && b.atk === 1, 'L2 窟鼠 ATK=1(读表断言:学期 ≤1)'); assert(plays(audio).lastBgm('bgm/battle'), '普通怪战 bgm/battle'); assert(game.floor.state === 'YOUR_TURN', '战斗回合 = YOUR_TURN(交接音 + 等待声挂点)'); // 缠斗怪转身纠错(每场 1 次) mark = audio.log.length; await g(rig, 'left'); await tick(1600); assert(plays(audio, mark).has('voice/btl_no_turn'), '缠斗怪左滑 → btl_no_turn'); r0 = receiptLog.length; await g(rig, 'right'); await tick(900); assert(plays(audio, mark).count('voice/btl_no_turn') === 1, 'btl_no_turn 每场只纠错 1 次'); assert(receiptLog.slice(r0).some((x) => x.kind === 'invalid'), '之后转身只给 invalid 回执'); // 单击命中面前怪 mark = audio.log.length; await g(rig, 'tap'); await tick(300); assert(b.hp === 4 && plays(audio, mark).has('sfx/atk_hit'), '面前怪单击命中(6→4,atk=2)'); assert(b.state === 'telegraph' && game.floor.state === 'ACTION', '挥击后进敌方前摇(ACTION:单击=格挡)'); // 前摇窗内长按 = invalid + inv_tel 一次(在首个前摇窗 1.2s 内做) mark = audio.log.length; r0 = receiptLog.length; await g(rig, 'longpress'); await tick(300); assert(receiptLog.slice(r0).some((x) => x.kind === 'invalid'), '前摇窗长按 = invalid 回执'); assert(plays(audio, mark).has('voice/inv_tel'), '前摇窗无效解释 inv_tel(一次)'); // 不格挡 → 全额伤(300+300+900=1500 > 前摇窗 1200,窗口外结算) await tick(900); assert(game.run.hp === 19, `窗口外(未格挡)挨全伤(20→${game.run.hp})`); // 自动喝药:HP≤25% 且腰带有药 mark = audio.log.length; game.run.hp = 2; game.run.potion = 1; // 钩子 await tick(1200); // 到玩家回合 await g(rig, 'tap'); await tick(1400); // 敌 dmg1 → HP1(≤25%)→ 自动喝 +8 assert(game.run.hp === 9 && game.run.potion === 0, `自动喝药回 40% maxHp(2→1→${game.run.hp}),腰带清空`); assert(plays(audio, mark).has('sfx/potion') && plays(audio, mark).has('voice/st_hp_3'), 'potion 音 + 喝后血量档播报'); // 格挡 80% 减伤(钩子 atk=10 使减伤可观察) b.hp = 99; b.atk = 10; await tick(1200); mark = audio.log.length; const hpP = game.run.hp; await g(rig, 'tap'); // 命中(99→97) await tick(300); await g(rig, 'tap'); // 前摇窗口内单击格挡 await tick(2600); assert(game.run.hp === hpP - 2, `单击格挡 ×0.2(atk10 → 伤 ${hpP - game.run.hp},期望 2)`); assert(plays(audio, mark).has('voice/btl_parried'), '格挡成功播 btl_parried(挡下了大半)'); assert(game.run.parries === 1, 'parries 只记「面对且挡下」的格挡(1 次)'); // 下滑 = 尝试逃跑:必成 + 撤退挨一下(真实账目) b.atk = 1; // 还原 await tick(1200); mark = audio.log.length; await g(rig, 'down'); await tick(3000); assert(game.state === 'EXPLORE', '下滑逃跑必成(无 RNG)→ 探索态'); assert(game.run.hp === hpP - 3, `撤退挨一下真实掉血(${hpP}→${game.run.hp})`); assert(plays(audio, mark).has('voice/btl_flee') && plays(audio, mark).has('sfx/hurt'), 'btl_flee(真话:被抓了一下)+ hurt'); assert(lastBgmEntry(audio)?.id === 'bgm/explore', '逃跑脱战切回 bgm/explore'); assert(game._floor.mobs.some((m) => m.type === 'rat' && m.x === game.run.pos.x && m.y === game.run.pos.y), '怪留原格(遭遇格=玩家格)'); // ---- Test B:绕行者(绕位怪):换位脚步 / 背刺归因 / 挥空每场一次 ---- game._encounter({ type: 'crawler', x: game.run.pos.x, y: game.run.pos.y, hp: 10, maxHp: 10 }); await waitCond(() => game._battle?.state === 'player', 12000); const c = game._battle; assert(c.mover === true && !c.boss, '绕行者 = 非窟主绕位怪'); mark = audio.log.length; await g(rig, 'tap'); await tick(300); assert(c.hp === 8, '绕行者命中(10→8)'); await g(rig, 'tap'); // 前摇窗口内单击格挡 await tick(2600); assert(game.run.hp === hpP - 3, `面对格挡 ×0.2(atk2 → 0 伤,HP 不变 ${game.run.hp})`); assert(game.run.parries === 2, 'parries 2(鼠 1 + 绕行者 1)'); // 回合 2:命中 → 敌每 2 回合移动 → 换位右侧(脚步报位) mark = audio.log.length; await tick(1200); await g(rig, 'tap'); await tick(300); assert(c.hp === 6, '再命中(8→6)'); assert(plays(audio, mark).has('sfx/mob_step_bug_r'), '换位脚步 mob_step_bug_r(右侧)'); await tick(1400); // 不格挡 → 右侧全额 2 伤 assert(game.run.hp === hpP - 5, `右侧未格挡全额(→${game.run.hp})`); // 回合 3:它在右 → 挥空(归因真话,每场一次) mark = audio.log.length; await tick(1200); await g(rig, 'tap'); await tick(300); assert(plays(audio, mark).has('voice/btl_swish'), '侧位挥空播 btl_swish(挥空归因真话)'); await tick(2200); // swish(1s) → 敌回合 → 前摇 → resolve 全额 assert(game.run.hp === hpP - 7, `挥空回合挨刀(→${game.run.hp})`); // 回合 4:转身追击命中 → 敌绕到背后(中央资产音量减半 + btl_behind 每场一次) mark = audio.log.length; await tick(1200); await g(rig, 'right'); // 战斗内左右滑 = 转身(绕位怪合法) await tick(120); assert(c.bearing === DIR_ORDER.indexOf(game.run.face), '转身后绕位怪回到面前(追击达成)'); await g(rig, 'tap'); await tick(300); assert(c.hp === 4, '转身追击命中(6→4)'); { const mv = audio.log.slice(mark).find((e) => e.id === 'sfx/mob_step_bug_c'); assert(mv && Math.abs(mv.opts.volume - 0.45) < 1e-9, '敌绕到背后:中央脚步资产音量减半 0.45'); } game.run.hp = 20; // 钩子:防死,专测背刺归因 await tick(2400); assert(plays(audio, mark).has('voice/btl_behind'), '背后挨刀播 btl_behind(归因真话)'); assert(game.run.hp === 18, `背刺全额 2 伤(20→${game.run.hp})`); // 收尾击杀(它在背后 = face+2,转两次身才回正面) mark = audio.log.length; await tick(1200); await g(rig, 'tap'); await tick(300); await tick(1600); await g(rig, 'left'); await tick(120); await g(rig, 'left'); await tick(120); assert(game._battle && game._battleRel() === 0, '背后绕位怪:两次转身回到正面'); game._battle.hp = 2; // 钩子:一刀收尾(换位几何已由上文转身/背刺断言覆盖) await g(rig, 'tap'); await tick(5000); assert(game.state === 'EXPLORE', '绕行者击杀回探索态'); assert(plays(audio, mark).has('voice/btl_kill') && plays(audio, mark).has('voice/btl_loot_2'), '胜利链 btl_kill + btl_loot_2'); assert(game.meta.gold === 10, '击杀金币入元进度(10 金)'); // ---- Test C:窟主石甲蟹(Boss 绕位):下滑逃跑拒绝 + 半血 ---- game._encounter({ type: 'crab', x: game.run.pos.x, y: game.run.pos.y, hp: 20, maxHp: 20 }); await waitCond(() => game._battle?.state === 'player', 12000); const cb = game._battle; assert(cb.boss === true && cb.mover === true, '窟主 = Boss + 绕位'); assert(plays(audio).lastBgm('bgm/boss'), 'Boss 战 bgm/boss'); mark = audio.log.length; r0 = receiptLog.length; await g(rig, 'down'); // Boss 战下滑 = 逃跑被拒 await tick(1600); assert(plays(audio, mark).has('voice/btl_flee_boss'), 'Boss 战下滑 → btl_flee_boss 拒绝'); assert(game.state === 'BATTLE' && cb.state === 'player', '拒绝后回合不消耗,仍在玩家回合'); // 半血:ATK+1 + btl_half_crab cb.hp = 10; // 钩子:一击跨半 mark = audio.log.length; await g(rig, 'tap'); await tick(300); assert(cb.hp === 8 && cb.atk === 4, '半血阶段 ATK 3→4'); assert(plays(audio, mark).has('voice/btl_half_crab'), '半血播 btl_half_crab'); await tick(3000); // half 句 → 敌前摇 → 全额 4 伤 assert(game.run.hp === 12, `窟主一击 4 伤(16→${game.run.hp})`); cb.hp = 1; // 钩子:收尾 mark = audio.log.length; await tick(1200); await g(rig, 'tap'); await tick(5000); assert(game.state === 'EXPLORE', '击杀窟主回探索态'); assert(game.run.cleared.includes(game.run.floor), '窟主已清(楼梯解锁)'); assert(game.meta.gold === 60 && plays(audio, mark).has('voice/btl_loot_3'), '窟主 50 金 → loot 档 3'); game.stop(); } // ================================ 场景 5:死亡归因 + 遗产 + 结算(critical 链 + 手势 + 菜单) ================================ async function scenario5() { origConsoleLog('\n=== 场景 5:死亡归因真话 + 遗产 + 结算 critical 链 + 菜单(左右滑+单击确认) ==='); storageMap.clear(); storageMap.set('soundgame.auth', JSON.stringify({ token: 'test-token' })); const rig = newGame({ seed: 7 }); const { game, audio, receiptLog } = rig; game.start(); await tick(1200); await waitCond(() => game.floor.state === 'YOUR_TURN'); // TITLE 上滑 = 元进度链(空档 → meta_gold_1 + st_best_1 + unlock_none) let mark = audio.log.length; await g(rig, 'up'); await tick(4500); assert(plays(audio, mark).orderOk('voice/meta_gold_1', 'voice/st_best_1', 'voice/unlock_none'), 'TITLE 元进度链(空档 → unlock_none)'); await waitCond(() => game.floor.state === 'YOUR_TURN'); await g(rig, 'tap'); // 新周目 → 教学 await waitCond(() => game.state === 'DRILL'); game._mobStep = () => {}; game.meta.gold = 500; // 钩子:遗产阈值(90+150+220=460 ≤ 500 < 760) game.run.hp = 3; // 钩子:Boss 一刀致命 game._encounter({ type: 'crab', x: game.run.pos.x, y: game.run.pos.y, hp: 20, maxHp: 20 }); await waitCond(() => game._battle?.state === 'player', 12000); game._battle.bearing = (DIR_ORDER.indexOf(game.run.face) + 2) % 4; // 钩子:绕到背后 → 背刺 if (process.env.DUNGEON_TRACE) origConsoleLog(` [pre-tap] state=${game.state} bstate=${game._battle?.state} hp=${game.run.hp} pos=${game.run.pos.x},${game.run.pos.y}`); mark = audio.log.length; await g(rig, 'tap'); // 挥空(它在背后) await tick(2600); // swish → 敌前摇 → 背刺 dmg3 → HP0 → 死 assert(game.state === 'SETTLE', '背刺致死 → SETTLE'); // 结算链 = critical:手势给 busy,不截杀链(旧 H1 根治) const r0 = receiptLog.length; await g(rig, 'tap'); await tick(100); await g(rig, 'left'); await tick(100); assert(receiptLog.slice(r0).every((x) => x.kind === 'busy'), '结算链播报中 tap/滑动 = busy 回执(critical 不打断)'); await tick(14000); // 结算链 7 句 + 解锁教学句播完(mark 覆盖致命一击起的全段) if (process.env.DUNGEON_TRACE) origConsoleLog(` [die] state=${game.state} markCount=${plays(audio, mark).count('voice/die_intro')} allCount=${plays(audio).count('voice/die_intro')} chain=${JSON.stringify(game._settleChain)} fromMark=${audio.log.slice(mark).filter((e) => e.layer === 'voice').map((e) => e.id.replace('voice/', '') + (e.missing ? '!' : '')).join(',')}`); let p = plays(audio, mark); assert(p.orderOk('voice/die_intro', 'voice/die_cause_3', 'voice/die_adv_3'), '死亡链:die_intro → die_cause_3(背刺)→ die_adv_3(身后有动静先转身)'); assert(p.count('voice/die_intro') === 1, 'die_intro 恰播一次(在途手势未截杀结算链)'); assert(game._deathInfo.lastHit.behind === true && game._deathInfo.cause === 3, '归因指认真凶:背刺 → die_cause_3'); assert(game.meta.gold === 40, `遗产自动购置 90+150+220(500→${game.meta.gold})`); assert(JSON.stringify(game.meta.unlocks) === JSON.stringify(['potion', 'vigor', 'veil']), '解锁 potion/vigor/veil'); assert(game.meta.bestFloor === 1 && game.meta.runs === 1, 'bestFloor/runs 更新'); assert(game.run === null, '死亡清 run 不清 meta'); assert(game.ladder.stage() >= 2, '一局结束 → ladder.onGameDone()(阶段 ≥2:下滑菜单解锁)'); { const save = JSON.parse(storageMap.get('soundgame.progress.dungeon')); assert(save.summary.startsWith('深处归来'), `死亡 summary 规则("${save.summary}")`); assert(save.meta.gold === 40 && save.run === null, '存档 run=null、meta 保留'); assert(fetchLog.some((f) => f.url === '/api/progress' && f.opts.method === 'PUT'), '死亡结算 PUT /api/progress'); } assert(plays(audio).lastBgm('bgm/menu') && plays(audio).lastBgm('bgm/menu').opts.volume === 0.5, '结算 bgm/menu volume 0.5'); assert(game.floor.state === 'YOUR_TURN' && game.floor.waitPoint?.conf.id === 'settle', '结算链播完交出结算等待点(不留安静态)'); // 结算等待点手势:长按 = 重听结算一句 mark = audio.log.length; await g(rig, 'longpress'); await tick(2000); assert(plays(audio, mark).has('voice/settle_again'), '结算长按 = 重听结算一句'); // 上滑 = 元进度段(钩子:金=0 → band0 空手档) game.meta.gold = 0; mark = audio.log.length; await g(rig, 'up'); await tick(4500); assert(plays(audio, mark).has('voice/meta_gold_1'), '结算上滑 = 重听元进度段(band0 口径保留)'); // 金币 band1 真话句(gold_some) game.meta.gold = 30; mark = audio.log.length; await waitCond(() => game.floor.state === 'YOUR_TURN'); await g(rig, 'up'); await tick(4500); assert(plays(audio, mark).has('voice/gold_some'), '0<金<50 → gold_some 真话句(不再「口袋空空」谎报)'); game.meta.gold = 40; // 菜单(阶段 2 已解锁):下滑呼出 → 左右滑换项(报名)→ 单击确认 mark = audio.log.length; await waitCond(() => game.floor.state === 'YOUR_TURN'); await g(rig, 'down'); await tick(2000); assert(game.menuOpen, '结算下滑 = 菜单(阶段 2 解锁后)'); assert(plays(audio, mark).has('voice/menu_open'), '菜单开场 menu_open(旧「单击切换,双击确认」废止)'); assert(plays(audio, mark).hasRe(/^voice\/sys_menu_restart$/), '菜单首项报名'); await g(rig, 'right'); await tick(1600); assert(plays(audio, mark).has('voice/sys_menu_save'), '右滑 = 下一项(保存并回大厅)'); await g(rig, 'left'); await tick(1600); assert(plays(audio, mark).count('voice/sys_menu_restart') >= 2, '左滑 = 上一项(重新开始本周目)'); // 重新开始 = 破坏性 → 二次确认 mark = audio.log.length; await g(rig, 'tap'); await tick(1600); assert(plays(audio, mark).has('voice/menu_restart_confirm'), '单击确认重开 → 二次确认句'); assert(game.menuOpen, '二次确认前不执行(仍在菜单)'); mark = audio.log.length; await g(rig, 'tap'); // 再点一下 = 确认 await tick(8000); assert(!game.menuOpen && (game.state === 'EXPLORE' || game.state === 'DRILL') && game.run, '确认后开新周目(meta 保留)'); assert(game._maxHp() === 26 && game.run.hp === 26, '体魄遗产:起始 maxHp 20→26'); assert(game.run.potion === 1, '行囊药遗产:开局自带 1 瓶'); p = plays(audio, mark); assert(p.orderOk('voice/sys_new_run', 'voice/unlock_lead', 'voice/unlock_potion', 'voice/unlock_vigor', 'voice/unlock_veil'), '新周目开场遗产播报(unlock_lead + 3 项)'); assert(game.meta.pending.length === 0, '播报后清 pending'); assert(game.state === 'DRILL' || game.state === 'EXPLORE', '新周目按 drillDone 口径进入教学/探索'); game.stop(); } // ================================ 场景 6:断点复述(§4.12) + TITLE 右滑新周目 ================================ async function scenario6() { origConsoleLog('\n=== 场景 6:断点复述 + 存档 summary + TITLE 右滑重掷新周目 ==='); storageMap.clear(); seedSave(55); const a1 = newGame({}); a1.game.start(); await tick(1500); assert(a1.game.state === 'TITLE' && plays(a1.audio).has('voice/sys_continue_ask'), '有档重进播 sys_continue_ask'); await waitCond(() => a1.game.floor.state === 'YOUR_TURN'); // 挪一格再退出(自动存档) a1.game._mobStep = () => {}; await g(a1, 'up'); await tick(200); const saved = JSON.parse(storageMap.get('soundgame.progress.dungeon')); const savedPos = saved.run.pos; assert(Array.isArray(savedPos) && saved.summary.startsWith('第1层'), `存档 summary/pos 字段保留("${saved.summary}" @${savedPos})`); a1.game.stop(); // stop() 自动存 // 实例 2:杀进程重进 → continue_ask → 单击继续 → 复述链(层/血/面朝/楼梯方位) const a2 = newGame({}); a2.game._mobStep = () => {}; // 静场:复述链不受游走怪打断 a2.game.start(); await tick(1500); assert(plays(a2.audio).has('voice/sys_continue_ask'), '实例 2 仍在 TITLE 询问继续'); let mark = a2.audio.log.length; await waitCond(() => a2.game.floor.state === 'YOUR_TURN'); await g(a2, 'tap'); await waitCond(() => a2.game.state === 'EXPLORE'); await tick(6500); const p2 = plays(a2.audio, mark); assert(p2.orderOk('voice/st_floor_1', 'voice/st_hp_1', 'voice/st_face_s'), '局面复述链 st_floor → st_hp → st_face 有序'); assert(p2.countRe(/^voice\/(stair_dir_|stair_hint_b)/) === 1, '复述链含楼梯方位句(§4.12:位置+目标+动作)'); assert(a2.game.run.pos.x === savedPos[0] && a2.game.run.pos.y === savedPos[1] && a2.game.run.face === 'S', '落点还原(pos/face)'); assert(a2.audio.rateLog.at(-1).on === false, '对局期旁白快进关闭(长按=帮助)'); // 长按 = 帮助(同链可重放,位置不变) mark = a2.audio.log.length; const pos = { ...a2.game.run.pos }; await g(a2, 'longpress'); await tick(4500); assert(plays(a2.audio, mark).has('voice/st_floor_1') && a2.game.run.pos.x === pos.x && a2.game.run.pos.y === pos.y, '长按帮助可重放且不位移'); a2.game.stop(); // 实例 3:TITLE 右滑 = 重掷新周目(元进度保留) const a3 = newGame({}); a3.game._mobStep = () => {}; // 静场:开局播报不受游走怪打断 a3.game.start(); await tick(1500); await waitCond(() => a3.game.floor.state === 'YOUR_TURN'); const oldSeed = JSON.parse(storageMap.get('soundgame.progress.dungeon')).run.seed; const oldKills = JSON.parse(storageMap.get('soundgame.progress.dungeon')).meta.kills; mark = a3.audio.log.length; await g(a3, 'right'); await waitCond(() => a3.game.state === 'EXPLORE' || a3.game.state === 'DRILL', 25000); assert(a3.game.run && a3.game.run.seed !== oldSeed, `重掷 seed(${oldSeed} → ${a3.game.run.seed})`); assert(a3.game.meta.kills === oldKills, '元进度保留'); assert(plays(a3.audio, mark).orderOk('voice/sys_new_run', 'voice/st_floor_1'), '新周目播报 + 局面复述'); a3.game.stop(); } /** 找「死胡同」格(开放邻格最少)+ 一面朝向的墙:乱按零位移的保障。 */ function deadEndCell(floor) { let best = null; for (let y = 0; y < floor.size; y++) for (let x = 0; x < floor.size; x++) { if (floor.grid[y][x] !== 1) continue; let open = 0, wallDir = null; for (const [dir, d] of Object.entries(DIRS)) { const nx = x + d.dx, ny = y + d.dy; if (nx < 0 || ny < 0 || nx >= floor.size || ny >= floor.size || floor.grid[ny][nx] === 0) { wallDir = dir; continue; } open++; } if (!best || open < best.open) best = { x, y, open, wallDir }; } return best; } // ================================ 场景 7:迷茫接管三级(探索态 §4.5) ================================ async function scenario7() { origConsoleLog('\n=== 场景 7:迷茫接管三级(静默 40s 起 / 乱点 → L1 目标句 → L2 缩小 → L3 演示) ==='); storageMap.clear(); seedSave(11); const rig = newGame({}); await reachExplore(rig); const { game, audio, receiptLog } = rig; game._mobStep = () => {}; // 静场 // L1:静默 40s 起 → 目标句(首段静默提示,台账去重) let mark = audio.log.length; await tick(49000); assert(plays(audio, mark).has('voice/stair_hint_a'), 'L1(静默 40s 起):stair_hint_a「找找铁器的回声,那是楼梯。」'); await tick(2000); assert(game.floor.state === 'ACTION' || !game.floor.speaking, 'L1 提示播完回 ACTION'); // 死胡同钉死朝墙:撞墙失败信号(recordFail)驱动接管升级 const de = deadEndCell(game._floor); assert(de && de.wallDir, '场景7: 找到临墙格(钉死朝墙 → 撞墙信号保障)'); game.run.pos = { x: de.x, y: de.y }; game.run.face = de.wallDir; const bumpG = gestureToward(game, de.wallDir); // L2:过冷却 → 2 次撞墙(10s 内)→ 接管 L2(只放行导航方向 sys_only_*) await tick(16000); // 冷却 15s mark = audio.log.length; for (let i = 0; i < 2; i++) { await g(rig, bumpG); await tick(1000); } await waitCond(() => game.confusion.level >= 1, 12000); await tick(16000); // 再过冷却 mark = audio.log.length; for (let i = 0; i < 2; i++) { await g(rig, bumpG); await tick(1000); } await waitCond(() => game.confusion.level >= 2 || game._restrictDir, 12000); await tick(2000); assert(game.confusion.level >= 2, '乱点信号(同步失败 ×2)→ 接管 L2'); assert(plays(audio, mark).hasRe(/^voice\/sys_only_/) || plays(audio).hasRe(/^voice\/sys_only_/), 'L2:sys_only_*「现在,只要向X滑。」'); const restrict = game._restrictDir; assert(restrict, 'L2:游戏侧只放行一个移动方向'); // 非放行方向 = 无效(不移动;同时是 L3 的 invalid 信号源) const other = ['up', 'down', 'left', 'right'].find((d) => d !== restrict); const posBefore = { ...game.run.pos }; mark = audio.log.length; await g(rig, other); await tick(200); assert(game.run.pos.x === posBefore.x && game.run.pos.y === posBefore.y, 'L2 期间非放行方向不产生移动'); assert(receiptLog.some((x) => x.kind === 'invalid'), 'L2 非放行方向 = invalid 回执'); // L3:过冷却 → 3 次无效(受限方向乱按)→ 听我做一次(游戏自己朝楼梯走一步) await tick(16000); mark = audio.log.length; const posL3 = { ...game.run.pos }; for (let i = 0; i < 3; i++) { await g(rig, other); await tick(800); } await waitCond(() => game.confusion.level >= 3, 12000); await tick(4000); assert(game.confusion.level >= 3, '连续无效 ×3 → 接管 L3'); assert(plays(audio, mark).has('voice/sys_demo') || plays(audio).has('voice/sys_now_you'), 'L3:sys_demo「听我做一次。」'); assert(plays(audio).has('voice/sys_now_you'), 'L3:演示后 sys_now_you「现在,你来。」'); assert(plays(audio, mark).has('sfx/step_own') || game.run.pos.x !== posL3.x || game.run.pos.y !== posL3.y, 'L3:游戏自己走一步(脚步声演示/真实位移)'); assert(!game._restrictDir, 'L3 后解除方向限制'); // 进展事件清零:下一段静默从 L1 重新分级 game.confusion.resetForWaitPoint(); game._restrictDir = null; game._idleHintGiven = false; mark = audio.log.length; await tick(49000); await tick(1500); assert(plays(audio, mark).hasRe(/^voice\/(stair_hint_|sys_only_|sys_demo)/), '分级重置后从 L1 重新触发'); game.stop(); } // ================================ 场景 8:屏蔽 voice 不卡死(流程兜底) ================================ async function scenario8() { origConsoleLog('\n=== 场景 8:屏蔽任意 voice 资产 → 流程不卡死(_sayFlow/_drillChain 兜底) ==='); storageMap.clear(); seedSave(3); const rig = newGame({}); rig.audio.blocked.add('voice/evt_descend'); rig.audio.blocked.add('voice/btl_rat'); await reachExplore(rig); const { game } = rig; game._mobStep = () => {}; // 屏蔽遭遇报名 → 兜底照样进玩家回合 game._encounter({ type: 'rat', x: game.run.pos.x, y: game.run.pos.y, hp: 6, maxHp: 6 }); await waitCond(() => game._battle?.state === 'player', 15000); assert(game._battle && game._battle.state === 'player', 'btl_rat 被屏蔽:兜底进玩家回合(不卡死)'); game._battle.hp = 0; // 钩子:一刀结束 await g(rig, 'tap'); await tick(5000); assert(game.state === 'EXPLORE', '胜利链走完回探索'); // 屏蔽下潜旁白 → 兜底照样完成下潜 const path = bfsPath(game._floor, game.run.pos, game._floor.stairs, mobCells(game._floor)); await walkPath(rig, path); assert(game._onStairs === true, '踏上楼梯'); await tick(2500); // 让 tut_stairs_on 播完(期间 tap=跳句) await g(rig, 'tap'); await tick(13000); assert(game.run.floor === 2, 'evt_descend 被屏蔽:兜底完成下潜(不卡死)'); game.stop(); } // ================================ 场景 9:全网格回声一致性 + 贴身即接战 ================================ async function scenario9() { origConsoleLog('\n=== 场景 9:全网格回声一致性(L1..L7)+ 贴身即接战 ==='); storageMap.clear(); seedSave(42); const rig = newGame({}); const { game } = rig; await reachExplore(rig); game._mobStep = () => {}; // 静场(扫描会换挂 _floor,游走怪不干扰) let combos = 0; let sideOpens = 0; for (let f = 1; f <= 7; f++) { const fl = game._generateFloor(42, f); game._floor = fl; for (let y = 0; y < fl.size; y++) for (let x = 0; x < fl.size; x++) { if (fl.grid[y][x] !== 1) continue; const per = []; for (const face of DIR_ORDER) { game.run.pos = { x, y }; game.run.face = face; const truth = truthScan(fl, x, y, face); const got = game._echoScan(x, y, face); const L = game._sideScan(-1); const R = game._sideScan(1); if (L.kind === 'open') sideOpens++; const okCell = got.dist === truth.dist && got.mat === truth.mat && L.kind === truth.left.kind && R.kind === truth.right.kind; per.push(`${face}:${okCell ? 'ok' : `got(d${got.dist},${got.mat},${L.kind}/${R.kind}) want(d${truth.dist},${truth.mat},${truth.left.kind}/${truth.right.kind})`}`); } assert(per.every((s) => s.endsWith('ok')), `回声===grid @L${f}(${x},${y}) ${per.join(' ')}`); combos++; } } assert(combos >= 200, `全网格回声扫描覆盖 ${combos} 格 × 4 面`); assert(sideOpens > 0, `全网格存在侧向开口(side_open 真话,${sideOpens} 格次)`); for (let f = 1; f <= 6; f++) { const fl = game._generateFloor(42, f); game._floor = fl; const st = fl.stairs; game.run.pos = { x: st.x, y: st.y }; game.run.face = 'N'; const got = game._echoScan(st.x, st.y, 'N'); assert(got.dist === 1 && got.mat === 'stair', `L${f}: 站楼梯上脉冲=贴身铁器回声(dist1/mat_stair)`); } // 贴身即接战:相邻怪必然扑进玩家格开战(软封锁消除) const fl = game._floor; let pair = null; outer9: for (let y = 0; y < fl.size; y++) for (let x = 0; x < fl.size - 1; x++) { if (fl.grid[y][x] === 1 && fl.grid[y][x + 1] === 1) { pair = { x, y }; break outer9; } } game.run.pos = { x: pair.x, y: pair.y }; game.run.face = 'E'; const mob = { type: 'mush', x: pair.x + 1, y: pair.y, hp: 8, maxHp: 8, drill: false }; game._floor.mobs.push(mob); DungeonGame.prototype._mobStep.call(game, mob); // 直呼真实步进(绕开静场 stub) assert(game.state === 'BATTLE' && game._battle?.type === 'mush', '贴身即接战:相邻怪必然扑进玩家格开战(软封锁消除)'); game.stop(); } // ================================ 场景 10:管道契约(stateName / invalidHint / PIPELINE_OPTS) ================================ async function scenario10() { origConsoleLog('\n=== 场景 10:管道契约(stateName / invalidHint / PIPELINE_OPTS) ==='); storageMap.clear(); seedSave(1); const rig = newGame({}); const { game } = rig; game.start(); await tick(1500); const po = game.PIPELINE_OPTS; assert(po && po.swipeCore === true && po.longpressCore === true, 'PIPELINE_OPTS = { swipeCore, longpressCore }(四向 + 长按均为核心手势)'); assert(game.stateName() === 'title', 'stateName(TITLE) = title'); assert(game.invalidHint('explore', 'tap') === 'voice/inv_pulse', 'invalidHint(explore,tap) = inv_pulse'); assert(game.invalidHint('title', 'left') === 'voice/inv_title', 'invalidHint(title,left) = inv_title'); assert(game.invalidHint('settle', 'left') === 'voice/inv_settle', 'invalidHint(settle,left) = inv_settle'); assert(game.invalidHint('drill', 'up') === 'voice/inv_drill', 'invalidHint(drill,*) = inv_drill'); assert(game.invalidHint('tel', 'longpress') === 'voice/inv_tel', 'invalidHint(tel,*) = inv_tel'); assert(game.invalidHint('menu', 'up') === 'voice/menu_help', 'invalidHint(menu,*) = menu_help'); assert(game.invalidHint('locked', 'up') === null, 'invalidHint(locked,*) = null(核心手势无锁)'); game.stop(); } // ================================ 场景 11:乱点机器人(§6.2 指标) ================================ async function scenario11() { origConsoleLog('\n=== 场景 11:乱点机器人(§6.2:无声操作 0 / 被动原句重复 0 / 打断失败 0 / 首次操作 ≤10s / 首次成功 ≤20s) ==='); storageMap.clear(); const rig = newGame({ seed: 2026 }); const { game, audio, receiptLog } = rig; let seed = 7; const rnd = () => { seed = (seed * 1103515245 + 12345) & 0x7fffffff; return seed / 0x7fffffff; }; const gestures = []; const botPipe = (gg) => { gestures.push({ g: gg, t: vnow, crit: !!(game.floor._chain && game.floor._chain.critical), vUid: audio._voice ? audio._voice.uid : null, vId: audio._voice ? audio._voice.id : null, }); return rig.pipe(gg); }; const t0 = vnow; game.start(); await tick(50); // 确定性首步:等到轮到玩家就点(首次操作) await waitCond(() => game.floor.state === 'YOUR_TURN', 12000); botPipe('tap'); // 再等教学 D0 交话轮,点出第一声脉冲(首次成功) await waitCond(() => game.state === 'DRILL' && game.floor.state === 'YOUR_TURN', 20000); botPipe('tap'); const firstOpMs = gestures[0].t - t0; const firstPulse = audio.log.find((e) => e.id === 'sfx/pulse'); const firstSuccessMs = firstPulse ? firstPulse.t - t0 : null; assert(firstOpMs !== null && firstOpMs <= 10000, `第一次操作 ≤ 10s(实际 ${firstOpMs}ms)`); assert(firstSuccessMs !== null && firstSuccessMs <= 20000, `第一次成功(第一声脉冲)≤ 20s(实际 ${firstSuccessMs}ms)`); // 急躁新手循环:40% 语音中单击 / 20% 连点两三下 / 30% 随机滑动 / 10% 发呆 8~23s for (let step = 0; step < 150; step++) { const roll = rnd(); if (roll < 0.4) { botPipe('tap'); await tick(600); } else if (roll < 0.6) { botPipe('tap'); await tick(120); botPipe('tap'); await tick(600); } else if (roll < 0.9) { const dirs = ['up', 'down', 'left', 'right']; botPipe(dirs[Math.floor(rnd() * 4)]); await tick(600); } else { await tick(8000 + Math.floor(rnd() * 15000)); } } // 指标 1:无声操作(操作后 100ms 内无回执且无内容音) let silentOps = 0; for (const ge of gestures) { const receipt = receiptLog.some((r) => r.t >= ge.t && r.t <= ge.t + 100); const content = audio.log.some((x) => x.t >= ge.t && x.t <= ge.t + 100); if (!receipt && !content) silentOps++; } assert(silentOps === 0, `无声操作 = 0(实际 ${silentOps}/${gestures.length})`); // 指标 2:打断失败(操作后同一句语音还在播超过 150ms;critical 句除外)。 // 防连点 busy 回执的手势不算:那是 400ms 窗的刻意抑制(点两下不连跳两句, // §4.2),且 busy 回执本身已是可听反馈——不算「打断失败」。 let interruptFails = 0; const failDetail = []; for (const ge of gestures) { if (ge.vUid == null || ge.crit) continue; const busyReceipt = receiptLog.some((r) => r.kind === 'busy' && r.t >= ge.t && r.t <= ge.t + 100); if (busyReceipt) continue; const stop = audio.voiceStops.find((s) => s.uid === ge.vUid); if (!stop || stop.t - ge.t > 150) { interruptFails++; failDetail.push(`${ge.g}:${ge.vId}@+${ge.t}`); } } assert(interruptFails === 0, `打断失败 = 0(实际 ${interruptFails}${failDetail.length ? ':' + failDetail.slice(0, 5).join(' ') : ''})`); // 指标 3:被动原句重复(同一 voice id 30s 内播两次,且前 2.5s 无「再说一遍:」) let passiveRepeats = 0; const repeatIds = new Map(); const lastPlay = new Map(); for (const e of audio.log) { if (e.layer !== 'voice' || e.missing) continue; const sayAgainAt = audio.log.some((q) => q.id === 'voice/sys_say_again' && !q.missing && q.t <= e.t && e.t - q.t < 2500); if (lastPlay.has(e.id) && e.t - lastPlay.get(e.id) < 30000 && !sayAgainAt) { passiveRepeats++; repeatIds.set(e.id, (repeatIds.get(e.id) || 0) + 1); } lastPlay.set(e.id, e.t); } if (passiveRepeats) origConsoleLog(` 重复明细: ${[...repeatIds.entries()].map(([k, v]) => `${k}×${v}`).join(' ')}`); assert(passiveRepeats === 0, `被动原句重复 = 0(实际 ${passiveRepeats})`); game.stop(); } // ================================ 资产引用校验 ================================ function assetAudit() { origConsoleLog('\n=== 资产引用校验 ==='); // 1) 运行期播放/加载过的全部 id 必须存在(blocked 屏蔽除外——那是被测项) const missing = new Set(); for (const audio of audioRegistry) { for (const e of audio.log) if (e.missing) missing.add(e.id); } assert(missing.size === 0, `资产校验: 运行期缺失 ${[...missing].join(',') || '无'}`); // 2) voice 双性别目录 id 集合一致(新增句落地前 = 127;落地后按台词表增长) const vdir = path.join(DUNGEON_DIR, 'audio', 'voice'); const idsOf = (gender) => new Set(fs.readdirSync(path.join(vdir, gender)) .filter((f) => f.endsWith('.ogg')).map((f) => f.replace(/\.ogg$/, ''))); const fem = idsOf('female'); const male = idsOf('male'); assert(fem.size === male.size && [...fem].every((id) => male.has(id)), `资产校验: voice 双性别目录集合一致(female ${fem.size} = male ${male.size})`); assert(fem.size === 137, `资产校验: 台词表 26 号合并后 137 句齐全(实际 ${fem.size})`); // 3) game.js 静态引用 id 全量展开校验(模板 id 按数据表展开) const src = fs.readFileSync(path.join(DUNGEON_DIR, 'game.js'), 'utf8'); const staticIds = new Set(); for (const m of src.matchAll(/'((?:voice|sfx|bgm)\/[^'`]+)'/g)) { if (/[/_]$/.test(m[1])) continue; // 模板前缀,由下表展开 staticIds.add(m[1]); } for (const f of ['1', '2', '3', '4', '5', '6', '7']) staticIds.add(`voice/st_floor_${f}`); for (const b of ['1', '2', '3', '4']) staticIds.add(`voice/st_hp_${b}`); for (const d of ['n', 'e', 's', 'w']) { staticIds.add(`voice/st_face_${d}`); staticIds.add(`voice/stair_dir_${d}`); } for (const t of ['none', 'left', 'right', 'both']) staticIds.add(`voice/st_threat_${t}`); for (const i of ['1', '2', '3']) { for (const pre of ['voice/st_best_', 'voice/meta_gold_', 'voice/die_cause_', 'voice/die_adv_', 'voice/btl_loot_', 'voice/evt_coin_']) { staticIds.add(pre + i); } } for (const u of ['lead', 'potion', 'vigor', 'veil', 'echo', 'blade', 'none']) staticIds.add(`voice/unlock_${u}`); for (const w of ['front', 'left', 'right', 'back']) staticIds.add(`voice/wall_dir_${w}`); for (const o of ['up', 'down', 'left', 'right']) staticIds.add(`voice/sys_only_${o}`); for (const t of ['sys_unlock_down', 'sys_unlock_dt']) staticIds.add(`voice/${t}`); // ladder.teachLine for (const c of ['small', 'bug', 'heavy']) for (const s of ['l', 'c', 'r']) staticIds.add(`sfx/mob_step_${c}_${s}`); for (const c of ['small', 'bug', 'heavy']) for (const s of ['', '_l', '_r']) staticIds.add(`sfx/tel_${c}${s}`); for (const k of ['wall', 'open']) for (const s of ['l', 'r']) staticIds.add(`sfx/side_${k}_${s}`); for (const m of ['bat', 'rat', 'mush', 'crab', 'crawler', 'spider', 'leech', 'guard', 'king']) staticIds.add(`voice/btl_${m}`); for (const m of ['crab', 'leech', 'king']) staticIds.add(`voice/btl_half_${m}`); for (const e of ['1', '2', '3', '4']) staticIds.add(`sfx/echo_${e}`); for (const m of ['stone', 'door', 'stair', 'water']) staticIds.add(`sfx/mat_${m}`); for (const i of ['sys_menu_save', 'sys_menu_help', 'sys_menu_restart']) staticIds.add(`voice/${i}`); for (const i of ['sys_continue_ask', 'sys_new_run', 'sys_saved', 'evt_potion', 'evt_potion_full', 'evt_weapon', 'evt_armor', 'evt_stairs_lock', 'evt_descend', 'btl_behind', 'btl_swish', 'btl_no_turn', 'btl_kill', 'btl_lvup', 'btl_flee', 'btl_flee_boss', 'btl_parried', 'die_intro', 'settle_win_1', 'settle_win_2', 'settle_stronger', 'settle_again', 'help_explore', 'help_battle', 'settings_hint', 'stair_hint_a', 'stair_hint_b', 'gold_some', 'gd_turn', 'tut_front_rule', 'tut_parry_ok', 'tut_parry_miss', 'tut_kill', 'tut_stairs', 'tut_stairs_on', 'tut_done', 'tut_skip', 'tut_mob']) staticIds.add('voice/' + i); const badStatic = [...staticIds].filter((id) => !assetExists(id)); assert(badStatic.length === 0, `资产校验: 静态引用缺失 ${badStatic.join(',') || '无'}`); // PENDING 清单必须精确对应代码引用(生成落地后清空本清单即转实物断言) const pendingUsed = [...PENDING_VOICE].filter((id) => staticIds.has('voice/' + id)); assert(pendingUsed.length === PENDING_VOICE.size, `资产校验: PENDING ${PENDING_VOICE.size} 条全部被代码引用(实际 ${pendingUsed.length};落地后清空 PENDING_VOICE)`); ok(`资产校验: 静态 ${staticIds.size} id(含 ${PENDING_VOICE.size} 条虚拟资产)+ 运行期日志全量双格式(ogg+m4a)齐全`); } // ================================ 主流程 ================================ const ONLY = process.env.DUNGEON_ONLY ? process.env.DUNGEON_ONLY.split(',') : null; const ALL = [scenario1, scenario2, scenario3, scenario4, scenario5, scenario6, scenario7, scenario8, scenario9, scenario10, scenario11]; try { for (const sc of ALL) if (!ONLY || ONLY.includes(sc.name)) await sc(); } catch (e) { failures.push(`未捕获异常: ${e.stack}`); } assetAudit(); origConsoleLog('\n========================================'); origConsoleLog(`断言通过 ${passCount} 条,失败 ${failures.length} 条`); if (failures.length) { origConsoleLog('失败清单:'); for (const f of failures) origConsoleLog(` ✗ ${f}`); } process.exit(failures.length ? 1 : 0);