/** * 《回声地窟》无头试玩测试(docs/design/16-dungeon.md §12 断言清单 + §2 豁免 E1-E4)。 * * 以玩家身份模拟:mock 浏览器环境(window/navigator/localStorage/fetch/虚拟时钟) * 与音频引擎接口(播放日志 + voice onComplete 按虚拟时钟推进语音链,资产缺失 = * 不触发 onComplete,对齐引擎静默跳过语义),加载真实 game.js,手势直接注入 * game.onGesture(引擎回调路径)。mock 层机制对齐 web/rpg/test/playtest.mjs。 * * 运行:tools/node/bin/node web/dungeon/test/playtest.mjs * 退出码 0 = 全部断言通过。随机性由种子 PRNG 保证可重复;地窟生成由 * opts.seed=42 注入复现(§6.1 floorSeed 派生公式)。 */ 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 < 100000; 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(); } // ================================ 浏览器环境 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 }); // 注意 setTimeout 参数序是 (fn, ms),内部 schedule 是 (ms, fn) —— 必须包一层 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: { 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); // 种子 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; }; // ================================ 资产存在性 ================================ function hasPair(base) { return fs.existsSync(base + '.ogg') && fs.existsSync(base + '.m4a'); } /** voice:双性别目录齐全;sfx/bgm:双格式。 */ function assetExists(id) { const audioDir = path.join(DUNGEON_DIR, 'audio'); if (id.startsWith('voice/')) { const name = id.slice(6); return hasPair(path.join(audioDir, 'voice', 'female', name)) && hasPair(path.join(audioDir, 'voice', 'male', name)); } return hasPair(path.join(audioDir, id)); } // ================================ 音频引擎 mock ================================ class MockAudioEngine { constructor() { this.log = []; // {t, layer, id, opts, missing} this._voice = null; // 当前 voice token(isVoicePlaying 判定依据) this.bgmNow = null; this.blocked = new Set(); // 被屏蔽的 voice id(onComplete 永不触发 → 看门狗兜底) this.rateLog = []; // setRateBoostEnabled 调用记录(E3 豁免断言) } isVoicePlaying() { return this._voice !== null; } setRateBoostEnabled(on) { this.rateLog.push({ t: vnow, on }); } _dur() { return 1.0; } // 假 buffer 时长(秒):语音链按虚拟时钟推进 play(id, opts = {}) { const layer = id.split('/')[0]; const missing = !assetExists(id); this.log.push({ t: vnow, layer, id, opts, missing }); if (missing) return; // 引擎静默跳过语义(审计单独断言) if (layer === 'voice') { this._voice = null; // 顶掉当前句(不触发其 onComplete,对齐引擎语义) const token = { id, onComplete: opts.onComplete }; this._voice = token; if (!this.blocked.has(id)) { schedule(this._dur(id) * 1000, () => { if (this._voice === token) { this._voice = null; if (token.onComplete) token.onComplete(); } }); } } else if (layer === 'bgm') { this.bgmNow = id; } // sfx:即发即弃,无后续 } stopVoice() { this._voice = null; } 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 ERROR_PATTERN = JSON.stringify([60, 40, 60]); function vibratedError(fromIdx) { return vibrateLog.slice(fromIdx).some((v) => JSON.stringify(v.p) === ERROR_PATTERN); } // ================================ 游戏装配 ================================ const { DungeonGame } = await import('../game.js'); const { settings } = await import('../../engine/settings.js'); // 向导层关闭:回归测试只验证纯游戏逻辑(guide='full' 行为见场景 10) 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); return { game, audio }; } const g = (game, gesture) => game.onGesture(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 } }; // §3.1 回声编码表(规格原文) const ECHO_DELAY = [0, 250, 500, 800, 1100]; const ECHO_VOL = [0, 0.9, 0.7, 0.5, 0.35]; /** 正前扫描真值:最近反射体 → {dist, mat};侧向 → {kind, dist}(身后不报)。 * R2 D0-2 口径:①站在楼梯格=贴身铁器(mat_stair);②侧向第一格是地板=开口 * (能走进去就是开口,§3.1「墙=闷 / 开口=空旷」),否则沿射线报墙距。 */ 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) }; } /** 侧向真值(R2 D0-2):第一格地板=开口;否则闷墙(含到墙距离)。 */ 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]]; let 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 }; } /** floor 可比较签名(grid/房间/起点/楼梯/宝物/怪 逐格)。 */ 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, }); } /** 网格不变式:连通性(BFS 距起点全覆盖)/ 楼梯存在且=最远房间心 / 怪/宝落格合法。 */ 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; } /** BFS 寻路(避开 blocked 格),返回 [{x,y,dir}] 步序列。 */ 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。 */ async function faceDir(game, dir) { const from = DIR_ORDER.indexOf(game.run.face); const to = DIR_ORDER.indexOf(dir); const d = (to - from + 4) % 4; const gs = d === 3 ? 'left' : 'right'; for (let i = 0; i < (d === 3 ? 1 : d); i++) { g(game, gs); await tick(30); } } /** 通用战斗打法:前摇内双击、玩家回合单击,直到战斗结束。 */ async function fightUntilWin(game, maxMs = 60000) { const t0 = vnow; while (game.state === 'BATTLE' && vnow - t0 < maxMs) { const b = game._battle; if (!b) break; if (game.menuOpen) { g(game, 'down'); await tick(300); continue; } if (b.state === 'telegraph') { g(game, 'doubletap'); await tick(200); continue; } if (b.state === 'player') { g(game, 'tap'); await tick(400); continue; } await tick(400); } await tick(3000); // 胜利链走完 } /** 沿寻路序列长按走(每步 1 格;途中遭遇就地打完)。 * 每步走完再空烧 >500ms,让上一发的步进计时器在 _walk=null 下空转,杜绝多走。 */ async function walkPath(game, path) { const trace = process.env.DUNGEON_TRACE ? [] : null; for (const step of path) { if (game.state === 'BATTLE') { await fightUntilWin(game); continue; } await faceDir(game, step.dir); g(game, 'longpress'); const t0 = vnow; while (game.state !== 'BATTLE' && vnow - t0 < 2000 && (game.run.pos.x !== step.x || game.run.pos.y !== step.y)) await tick(50); g(game, 'longpressend'); await tick(560); if (trace) trace.push(`${step.dir}@${game.run.pos.x},${game.run.pos.y}:${game.state}`); } if (game.state === 'BATTLE') await fightUntilWin(game); if (trace) origConsoleLog(' [walkPath] ' + trace.join(' ')); } const mobCells = (floor) => new Set(floor.mobs.map((m) => m.x + ',' + m.y)); /** 最近一次 BGM 请求(当前状态曲判定用)。 */ const lastBgmEntry = (audio) => audio.log.filter((e) => e.layer === 'bgm').at(-1); // ================================ 场景 1:seed 复现 + 网格不变式(§6/§12.2) ================================ 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 两次构造逐格相等 + 网格不变式(§6/§12.2) ==='); 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})`); // 不变式:全地板格连通(BFS 覆盖) 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 覆盖全地板)`); // 楼梯:非终局层存在、落格、= 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: 终局层无楼梯、王座落格'); } // 怪物:窟主守楼梯/王座、普通怪 BFS 安全区;构成 ⊇ 规格表(R2 D0-3① 允许 // 主路径密度补怪,但补的必须是本层已有普通怪型、数量有界) const got = {}; for (const m of f1.mobs) got[m.type] = (got[m.type] || 0) + 1; const specTotal = Object.values(SPEC[f].mobs).reduce((a, b) => a + b, 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}`); // R2 D0-3① 主路径密度下限:start→goal 最短路上每 ≤12 格必有内容遭遇 { 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 教学硬约束(§6.1 拒绝采样) 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 格内有墙(D1 回声演示)'); 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('七层生成全部复现且不变式成立'); // 实流复现:真实 start → 新周目 _floor 与纯函数一致 a.game.start(); await tick(400); g(a.game, 'tap'); await tick(400); 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 tick(2000); // sys_new_run 链播完 → _enterDrill assert(a.game.state === 'DRILL', '首周目进教学'); a.game.stop(); } // ================================ 场景 2:DRILL 全链 + 回声全扫描 + 下潜复现(§5/§3/§12) ================================ /** D9 窗口内做回声调度全扫描:全地板格 × 4 面向(教学期怪静止,无游走干扰)。 */ async function echoFullScan(game, audio) { 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; if (st && x === st.x && y === st.y) game._pulse(); // 楼梯格上 tap=下潜,直呼脉冲 else g(game, 'tap'); const le = game._lastEcho; 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(1700); // 冷却 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, '每次脉冲恰两路侧向回声(身后不报,§3.1)'); // 代表性组合:精确延迟/音量断言(§12.3 ±50ms 口径内,此处虚拟时钟可精确到 0) 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:DRILL 教学链逐步骤 + 回声全扫描 + 下潜复现(§5/§3.1/§12.1) ==='); storageMap.clear(); const { game, audio } = newGame({ seed: 42 }); game.start(); await tick(400); assert(game.state === 'TITLE' && !game.run, '启动进 TITLE(无存档)'); assert(plays(audio).has('bgm/menu'), 'TITLE bgm/menu'); assert(audio.rateLog.at(-1).on === true, 'E3:非对局长按快进开启'); let mark = audio.log.length; g(game, 'tap'); await tick(6000); // sys_new_run(1s) → DRILL D0(intro_1+intro_2+tut_d0,3s) assert(game.state === 'DRILL' && game._drill.step === 0, '单击开始 → 进 DRILL D0'); let p = plays(audio, mark); assert(p.orderOk('voice/sys_new_run', 'voice/intro_1', 'voice/intro_2', 'voice/tut_d0'), 'D0 序:sys_new_run → intro_1/2 → tut_d0'); assert(p.has('bgm/explore'), 'DRILL bgm/explore'); assert(audio.rateLog.at(-1).on === false, 'E3:对局(教学)期长按快进关闭'); // D0 → D1 g(game, 'tap'); await tick(200); assert(game._drill.step === 1, 'D0 单击通过 → D1'); // D1 首次脉冲(FTUE 首输入点):脉冲 + 回声组 + tut_echo_come mark = audio.log.length; const t1 = vnow; g(game, 'tap'); const d1Truth = truthScan(game._floor, 3, 3, 'N'); await tick(1700); p = plays(audio, mark); assert(p.has('sfx/pulse'), 'D1: 脉冲 sfx/pulse 播出'); assert(p.has('sfx/echo_' + d1Truth.band) && d1Truth.band === 2, 'D1: 起点面北 dist=2 → echo_2'); { const echo = audio.log.slice(mark).find((e) => e.id === 'sfx/echo_2'); assert(Math.abs(echo.t - (t1 + 500)) <= 50, `D1: echo_2 于 +500ms(±50ms 口径,实测 ${echo.t - t1})`); assert(Math.abs(echo.opts.volume - 0.7) < 1e-9, 'D1: echo_2 音量 0.7'); } assert(p.has('voice/tut_echo_come'), 'D1: tut_echo_come 播报'); assert(p.orderOk('voice/tut_echo_come', 'voice/tut_front_rule'), 'D1: 反馈句与下一步指令句串行(R2 D0-1:不再同 tick 双 play 互顶)'); assert(game._drill.step === 2, 'D1 通过 → D2'); // D2:做错重教(新短句不逐字重播),再做对 mark = audio.log.length; let vib0 = vibrateLog.length; g(game, 'left'); await tick(150); assert(vibratedError(vib0), 'D2 错手势 error 震'); assert(game._drill.step === 2 && game._drill.fails === 1, 'D2 错 → 同步重教不跳步'); g(game, 'tap'); await tick(1300); assert(game._drill.step === 3, 'D2 单击通过 → D3'); // D3:连错 2 次 → 自动演示通过,不卡流程(§5) g(game, 'right'); g(game, 'right'); await tick(1300); assert(game._drill.step === 4, 'D3 连错 2 次 → 自动通过 → D4'); // D4:长按前进 ≥2 格(自动面向东走道) assert(game.run.face === 'E', 'D4 自动面向东侧走道'); assert(plays(audio).has('voice/tut_walk'), 'D4 tut_walk 旁白'); const posBefore = { ...game.run.pos }; g(game, 'longpress'); await tick(650); g(game, 'longpressend'); await tick(150); assert(game.run.pos.x - posBefore.x >= 2, `D4: 长按前进 ≥2 格(${posBefore.x}→${game.run.pos.x})`); assert(game._drill.step === 5, 'D4 走满 2 格 → D5'); // D5:左滑 + 右滑各 1 assert(plays(audio).has('voice/tut_turn'), 'D5 tut_turn 旁白'); g(game, 'left'); await tick(120); g(game, 'right'); await tick(1300); assert(game._drill.step === 6, 'D5 左右滑各 1 → D6'); // D6:双击免费重发脉冲 await tick(1200); // 与 D1 脉冲拉开冷却 mark = audio.log.length; vib0 = vibrateLog.length; g(game, 'doubletap'); await tick(1700); p = plays(audio, mark); assert(p.has('sfx/pulse'), 'D6: 双击重发脉冲'); const d6Truth = truthScan(game._floor, game.run.pos.x, game.run.pos.y, game.run.face); assert(p.has('sfx/echo_' + d6Truth.band), 'D6: 回声组按 grid 重发'); assert(!vibratedError(vib0), 'D6: 重发不受冷却惩罚(未撞冷却)'); assert(game._drill.step === 7, 'D6 → D7'); // D7:上滑状态链 + 下滑开菜单再关 await tick(2500); // R2 D0-1 串行化后 D6 反馈句先播完(echo_come 1s)→ D7 两句旁白到齐 assert(plays(audio).orderOk('voice/tut_status', 'voice/tut_menu'), 'D7 旁白 tut_status + tut_menu'); mark = audio.log.length; g(game, 'up'); await tick(5300); // 状态链 5 句播完 assert(plays(audio, mark).orderOk('voice/st_floor_1', 'voice/st_hp_1', 'voice/st_gold_1', 'voice/st_face_e'), 'D7 上滑状态链(层/血/金/面朝)'); assert(plays(audio, mark).countRe(/^voice\/st_threat_/) === 1, 'D7 状态链第 5 段威胁句'); g(game, 'down'); // 开菜单 await tick(1600); assert(game.menuOpen && game.menuFrom === 'DRILL', 'D7 下滑开菜单(挂起 overlay)'); p = plays(audio, mark); assert(p.orderOk('voice/st_floor_1', 'voice/st_hp_1', 'voice/st_gold_1', 'voice/st_face_e'), 'D7 上滑状态链(层/血/金/面朝)'); assert(p.has('voice/sys_menu') && p.has('voice/sys_menu_resume'), '菜单开场:sys_menu + 首项'); assert(audio.rateLog.at(-1).on === true, 'E3:菜单(非对局)恢复快进'); g(game, 'down'); await tick(1300); assert(!game.menuOpen && game._drill.step === 8, 'D7 关菜单 → D8'); assert(audio.rateLog.at(-1).on === false, 'E3:回教学恢复豁免'); // D8:教学怪逼近 → 遭遇 → 格挡教学 → 击杀 assert(game.run.face === 'W', 'D8 自动面对教学怪(dominant 方向)'); assert(plays(audio).has('voice/tut_mob'), 'D8 tut_mob(真话:ATK0 伤不到你)'); let t0 = vnow; while (game.state !== 'BATTLE' && vnow - t0 < 20000) await tick(200); assert(game.state === 'BATTLE' && game._battle.type === 'bat', '教学怪走进玩家格 → 遭遇'); assert(plays(audio).has('sfx/mob_step_small_c') || plays(audio).has('sfx/mob_step_small_l') || plays(audio).has('sfx/mob_step_small_r'), '逼近脚步 mob_step_small_* 可闻(明雷)'); const bat = game._battle; assert(bat.atk === 0, 'L1 盲眼蝠 ATK=0(学习期零惩罚,读表断言)'); assert(plays(audio).lastBgm('bgm/battle'), '普通怪战 bgm/battle'); t0 = vnow; while (bat.state !== 'telegraph' && vnow - t0 < 15000) await tick(200); assert(plays(audio).orderOk('voice/btl_bat', 'voice/tut_parry'), '遭遇链:btl_bat → tut_parry'); mark = audio.log.length; g(game, 'doubletap'); // 前摇窗口内格挡 await tick(2600); // tut_parry_ok(1s) + tut_attack(1s) 链播完 p = plays(audio, mark); assert(p.has('voice/tut_parry_ok'), 'D8 格挡成功 → tut_parry_ok → tut_attack'); assert(p.has('voice/tut_attack'), 'D8 进入攻击教学'); assert(game.run.hp === 20, 'D8: 教学怪零伤害(HP 不降)'); // 乱按(有界,菜单守卫):ATK0 怪全程打不死人 const R = ['tap', 'doubletap', 'left', 'right', 'up']; let nGest = 0; while (game.state === 'BATTLE' && nGest < 80) { if (game.menuOpen) g(game, 'down'); else g(game, R[Math.floor(Math.random() * R.length)]); nGest++; await tick(500); } assert(game.state === 'DRILL' && game._drill.step === 9, `D8 乱按 ${nGest} 手势后战斗收束 → D9`); assert(game.run.hp === 20, 'D8 乱按全程 HP 不降(§12 边界:学习期零惩罚)'); assert(game.meta.gold === 3, '击杀教学怪金币入账(蝠 3 金)'); assert(plays(audio).has('voice/tut_kill'), 'D8 击杀播 tut_kill'); // D9:回声调度全扫描(§12.3)。静场:扫描是纯地形断言,怪游走/贴身扑击不干扰 origConsoleLog(' --- D9 回声调度全扫描(全地板格 × 4 面向) ---'); game._mobStep = () => {}; const scanned = await echoFullScan(game, audio); ok(`回声全扫描 ${scanned} 组合全部与 grid 真值一致`); // 双击重发一致性(§12.3:未移动时回声 id 与上次一致、资源零消耗) game.run.pos = { x: 3, y: 3 }; game.run.face = 'N'; g(game, 'tap'); await tick(1700); const e1 = JSON.stringify(game._lastEcho); const goldBefore = game.meta.gold; mark = audio.log.length; g(game, 'doubletap'); await tick(1700); assert(JSON.stringify(game._lastEcho) === e1, '双击重发:未移动时回声编码与上次一致'); assert(!vibratedError(mark), '双击重发不受额外惩罚'); assert(game.meta.gold === goldBefore && game.run.hp === 20, '双击重发资源零消耗'); // D9 收尾:走到楼梯 → 踏格提示 → 单击下潜(静场收尾:残余游走怪清场,路径确定) game._floor.mobs.length = 0; const path = bfsPath(game._floor, game.run.pos, game._floor.stairs, mobCells(game._floor)); assert(path.length > 0, 'D9: 起点到楼梯存在通路'); mark = audio.log.length; await walkPath(game, 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'), 'R3 E1-1 到达即报:踩上楼梯格瞬间自动播贴身铁器回声(echo_1+mat_stair,全程零玩家脉冲)'); mark = audio.log.length; g(game, 'tap'); // 单击下潜 await tick(6000); assert(game.state === 'EXPLORE' && game.run.floor === 2, 'DESCEND → 第 2 层探索态'); assert(game.meta.drillDone === true && game._drill === null, '教学标志入元进度,DRILL 收尾'); 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(p.has('sfx/stairs_down') && game.run.floor === 2, 'R3 E1-1: 到达即报不占输入通道——tap 仍是下潜确认(照常下潜)'); const f2 = game._generateFloor(42, 2); assert(JSON.stringify(game._floor.grid) === JSON.stringify(f2.grid), '§12.6: 第 2 层 grid 由 floorSeed 重生成且复现'); return { game, audio }; } // ================================ 场景 4:探索边界(§2.1/§3.1/§7.1/§12 边界) ================================ async function scenario4({ game, audio }) { origConsoleLog('\n=== 场景 4:探索边界(撞墙/冷却/状态链/菜单冻结/拾取/脚步编码/零定时器) ==='); let p; // 上滑 5 段状态链 let mark = audio.log.length; g(game, 'up'); await tick(6000); const faceId = 'voice/st_face_' + game.run.face.toLowerCase(); p = plays(audio, mark); // R3 E1-2:此刻 meta.gold=3(教学蝠掉落,0<金<50)→ 档 1 播 gold_some 真话句(不再「口袋空空」) assert(p.orderOk('voice/st_floor_2', 'voice/st_hp_1', 'voice/gold_some', faceId), '上滑链前 4 段有序(层/血/金/gold_some band1 真话/面朝)'); assert(p.countRe(/^voice\/st_threat_/) === 1, '上滑链第 5 段威胁句恰 1 句'); // 探索菜单冻结:菜单打开期间怪物游走停摆,关闭恢复。 // 安全步进器:走真实游走/脚步编码,但不扑击不进玩家格(冻结断言不被接战打断; // 贴身扑击本身由场景 12C 专项断言) const safeStep = (m) => { const S = game._floor.size; const opts = []; for (const d of Object.values(DIRS)) { const nx = m.x + d.dx, ny = m.y + d.dy; if (nx >= 0 && ny >= 0 && nx < S && ny < S && game._floor.grid[ny][nx] === 1 && !(nx === game.run.pos.x && ny === game.run.pos.y)) opts.push({ x: nx, y: ny }); } if (!opts.length) return; const c = opts[Math.floor(Math.random() * opts.length)]; m.x = c.x; m.y = c.y; game._mobStepSound(m.type, m.x, m.y); }; game._mobStep = safeStep; mark = audio.log.length; g(game, 'down'); await tick(1500); assert(game.menuOpen && game.menuFrom === 'EXPLORE', '探索下滑=菜单'); assert(plays(audio, mark).has('bgm/menu'), '菜单 bgm/menu'); await tick(5000); const stepsInMenu = audio.log.slice(mark).filter((e) => e.id.startsWith('sfx/mob_step_')).length; assert(stepsInMenu === 0, '菜单打开:怪物游走冻结(0 步声)'); g(game, 'down'); await tick(6000); const stepsAfter = audio.log.slice(mark).filter((e) => e.id.startsWith('sfx/mob_step_')).length; assert(stepsAfter > 0, '关菜单后游走恢复(步声恢复)'); // 静场钩子:后续纯地形测试不受游走干扰 game._mobStep = () => {}; // 金币拾取:meta.gold 实时入账 + 状态链金币档跳档(§12.4) const coinEntry = [...game._floor.items.entries()].find(([, v]) => v.type === 'coin'); { const [k, item] = coinEntry; const [cx, cy] = k.split(',').map(Number); const nb = [[1, 0], [-1, 0], [0, 1], [0, -1]].map(([dx, dy]) => ({ x: cx + dx, y: cy + dy })) .find((c) => game._floor.grid[c.y]?.[c.x] === 1 && !game._floor.items.has(c.x + ',' + c.y) && !game._floor.mobs.some((m) => m.x === c.x && m.y === c.y)); assert(nb, '金币格存在可站邻格'); game.run.pos = { ...nb }; await faceDir(game, cx > nb.x ? 'E' : cx < nb.x ? 'W' : cy > nb.y ? 'S' : 'N'); game.meta.gold = 45; // 钩子:45 + 堆(10-30) 必跨 50 → 金币档 1→2 mark = audio.log.length; g(game, 'longpress'); await tick(80); g(game, 'longpressend'); await tick(300); assert(game.run.pos.x === cx && game.run.pos.y === cy, '走上金币格'); assert(plays(audio, mark).has('sfx/coin') && plays(audio, mark).hasRe(/^voice\/evt_coin_/), 'coin 音 + 拾取播报'); assert(game.meta.gold >= 55 && game.run.gold >= 10, `金币入元进度(meta=${game.meta.gold})`); assert(!game._floor.items.has(k), '金币堆消失'); mark = audio.log.length; g(game, 'up'); await tick(6000); assert(plays(audio, mark).has('voice/st_gold_2'), '状态链金币档跳档 → st_gold_2'); } // 药水满腰带:evt_potion_full 且药留在原地(§12.4) const potEntry = [...game._floor.items.entries()].find(([, v]) => v.type === 'potion'); if (potEntry) { const [k] = potEntry; const [cx, cy] = k.split(',').map(Number); const nb = [[1, 0], [-1, 0], [0, 1], [0, -1]].map(([dx, dy]) => ({ x: cx + dx, y: cy + dy })) .find((c) => game._floor.grid[c.y]?.[c.x] === 1 && !game._floor.items.has(c.x + ',' + c.y)); game.run.pos = { ...nb }; game.run.potion = 1; // 钩子:腰带已满 await faceDir(game, cx > nb.x ? 'E' : cx < nb.x ? 'W' : cy > nb.y ? 'S' : 'N'); mark = audio.log.length; g(game, 'longpress'); await tick(80); g(game, 'longpressend'); await tick(300); assert(plays(audio, mark).has('voice/evt_potion_full'), '腰带满播 evt_potion_full'); assert(game._floor.items.has(k) && game.run.potion === 1, '药留在原地、腰带不变'); } else { ok('本层无药水堆(L2 规格含药水,跳过时视为生成异常)'); assert(!!potEntry, 'L2 应有药水堆'); } // 撞墙:bump + 位置不变 + 无伤(§2.1) 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; g(game, 'longpress'); await tick(120); g(game, 'longpressend'); await tick(150); assert(plays(audio, mark).has('sfx/bump'), '撞墙播 bump'); assert(game.run.pos.x === bumpSpot.x && game.run.pos.y === bumpSpot.y, '撞墙位置不变'); assert(game.run.hp === hpBefore, '撞墙无伤'); // 脉冲冷却 1.2s:冷却期单击 → 仅 error 震、无第二次回声(§3.1) mark = audio.log.length; const vib0 = vibrateLog.length; g(game, 'tap'); // 脉冲 1 g(game, 'tap'); // 冷却期内 await tick(1700); assert(plays(audio, mark).count('sfx/pulse') === 1, '冷却期单击不重复脉冲'); assert(vibrateLog.length > vib0 && vibratedError(vib0), '冷却期单击仅 error 震'); assert(plays(audio, mark).countRe(/^sfx\/echo_/) === 1, '冷却期无第二次回声组'); // 方位自洽:转身后重脉冲,左右 side 与 grid 随面朝交换(§12 边界) { const pos = game.run.pos; game.run.face = 'N'; g(game, 'tap'); const tN = truthScan(game._floor, pos.x, pos.y, 'N'); await tick(1700); assert(game._lastEcho.left === tN.left.kind && game._lastEcho.right === tN.right.kind, '面北侧向与真值一致'); g(game, 'right'); // 手势转身 → 面 E await tick(120); assert(game.run.face === 'E', '右滑转身 E(N→E 顺时针)'); g(game, 'tap'); const tE = truthScan(game._floor, pos.x, pos.y, 'E'); await tick(1700); assert(game._lastEcho.left === tE.left.kind && game._lastEcho.right === tE.right.kind, '转身后侧向随面朝交换(grid 一致)'); } // 怪物脚步编码(§7.1):pan/音量档/背后中央减半/heartbeat game.run.face = 'N'; const px = game.run.pos.x, py = game.run.pos.y; const stepCases = [ { dx: 0, dy: -1, id: 'sfx/mob_step_small_c', vol: 0.9, beat: true, name: '正前 1 格' }, { dx: -1, dy: 0, id: 'sfx/mob_step_small_l', vol: 0.9, beat: true, name: '左 1 格' }, { dx: 1, dy: 0, id: 'sfx/mob_step_small_r', vol: 0.9, beat: true, name: '右 1 格' }, { dx: 0, dy: 1, id: 'sfx/mob_step_small_c', vol: 0.45, beat: true, name: '背后 1 格(中央减半)' }, { dx: 0, dy: -3, id: 'sfx/mob_step_small_c', vol: 0.6, beat: false, name: '正前 3 格' }, { dx: 0, dy: 6, id: 'sfx/mob_step_small_c', vol: 0.175, beat: false, name: '背后 6 格(0.35 再减半)' }, ]; for (const c of stepCases) { const v0 = vibrateLog.length; game._mobStepSound('rat', px + c.dx, py + c.dy); const e = audio.log.filter((e2) => e2.id === c.id).at(-1); assert(e && Math.abs(e.opts.volume - c.vol) < 1e-9, `脚步 ${c.name} → ${c.id} vol ${c.vol}`); const beat = vibrateLog.slice(v0).some((v) => JSON.stringify(v.p) === JSON.stringify([40, 100, 40])); assert(beat === c.beat, `脚步 ${c.name} heartbeat ${c.beat ? '有' : '无'}(≤2 格预警窗)`); } { const cnt = audio.log.filter((e) => e.id.startsWith('sfx/mob_step_')).length; game._mobStepSound('rat', px, py + 9); // 曼哈顿 9 > 8:更远不播 assert(audio.log.filter((e) => e.id.startsWith('sfx/mob_step_')).length === cnt, '距离 >8 格不播脚步'); } // guide=off 全静契约:30s 空闲零向导/内容播放——唯环境垫除外(R2 D0-3② // 呼吸层是内容层不是向导层:20s 静默播 amb_cave,guide=off 也该有"洞还活着") mark = audio.log.length; await tick(30000); { const seg = audio.log.slice(mark); assert(seg.every((e) => !e.id.startsWith('voice/') && !e.missing), 'guide=off:30s 空闲零语音零向导定时器'); const amb = seg.filter((e) => e.id === 'sfx/amb_cave'); assert(amb.length === 1 && amb[0].t - mark >= 20000 - 5500 && amb[0].opts.volume === 0.35, `呼吸层:静默 ≥20s 播 amb_cave 恰 1 次且 volume 0.35(实际 ${amb.length} 次)`); } // 呼吸层节奏:再静默 20s 才播下一段;玩家操作重置静默(不算"静默期") mark = audio.log.length; await tick(21000); assert(audio.log.slice(mark).filter((e) => e.id === 'sfx/amb_cave').length === 1, '呼吸层:20s 间隔再播一次(不连播不轰炸)'); g(game, 'up'); // 状态链播报(有人声)+ 操作重置静默 await tick(6000); mark = audio.log.length; await tick(12000); assert(audio.log.slice(mark).filter((e) => e.id === 'sfx/amb_cave').length === 0, '呼吸层:操作后 12s 内不播(静默计时被重置)'); game.stop(); } // ================================ 场景 5:战斗三档(§7/§12.5) ================================ async function scenario5() { origConsoleLog('\n=== 场景 5:战斗(命中/缠斗纠错/格挡 80%/逃跑/Boss 拒绝/绕位背刺/半血) ==='); let mark; storageMap.clear(); const { game, audio } = newGame({}); game.start(); await tick(400); g(game, 'tap'); await tick(6000); // → DRILL D0 g(game, 'down'); await tick(2000); // 跳过教学 → EXPLORE assert(game.state === 'EXPLORE', '下滑跳过教学 → 探索态(tut_skip)'); assert(plays(audio).has('voice/tut_skip'), '跳过播 tut_skip'); game._mobStep = () => {}; // 静场:战斗断言不受游走干扰 // ---- Test A:L2 窟鼠(缠斗,ATK=1) ---- game._encounter({ type: 'rat', x: game.run.pos.x, y: game.run.pos.y, hp: 6, maxHp: 6 }); await tick(2500); // btl_rat(1s) → 玩家回合 const b = game._battle; assert(b && b.type === 'rat' && b.atk === 1, 'L2 窟鼠 ATK=1(读表断言:学期 ≤1)'); assert(b.state === 'player', '遭遇播报后进玩家回合'); assert(plays(audio).lastBgm('bgm/battle'), '普通怪战 bgm/battle'); // 缠斗怪转身纠错(每场 1 次) mark = audio.log.length; let vibA = vibrateLog.length; g(game, 'left'); await tick(1600); assert(plays(audio, mark).has('voice/btl_no_turn') && vibratedError(vibA), '缠斗怪左滑 → btl_no_turn + error 震'); g(game, 'right'); await tick(900); assert(plays(audio, mark).count('voice/btl_no_turn') === 1, 'btl_no_turn 每场只纠错 1 次(之后仅 error 震)'); // 玩家回合窗口外双击 = error 不生效 mark = audio.log.length; vibA = vibrateLog.length; g(game, 'doubletap'); await tick(300); assert(vibratedError(vibA) && b.state === 'player', '玩家回合双击(窗口外)→ error 震不生效'); // 单击命中面前怪 mark = audio.log.length; g(game, 'tap'); await tick(300); assert(b.hp === 4 && plays(audio, mark).has('sfx/atk_hit'), '面前怪单击命中(6→4,atk=2)'); assert(b.state === 'telegraph', '挥击后进敌方前摇(tel 窗口)'); // 不格挡 → 全额伤 await tick(1400); assert(game.run.hp === 19, `窗口外(未格挡)挨全伤(20→${game.run.hp})`); // 自动喝药:HP≤25% 且腰带有药 mark = audio.log.length; game.run.hp = 2; game.run.potion = 1; // 钩子 g(game, 'tap'); await tick(300); 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(1500); // 到玩家回合 mark = audio.log.length; const hpP = game.run.hp; g(game, 'tap'); // 命中(99→97) await tick(300); g(game, 'doubletap'); // 前摇窗口内格挡 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 次)'); // R3 打磨:战斗长按未满 900ms 松手 = 取消逃跑(防走位肌肉记忆误逃)+ 区分提示一句 mark = audio.log.length; vibA = vibrateLog.length; g(game, 'longpress'); await tick(200); g(game, 'longpressend'); await tick(600); assert(game.state === 'BATTLE' && game._battle === b && b.state === 'player', '战斗长按 ~650ms(<900ms)松手 = 不逃(R3 打磨)'); assert(vibratedError(vibA), '短长按取消伴随 error 震'); assert(plays(audio, mark).has('voice/tut_flee_hold'), '取消播区分提示 tut_flee_hold(每场 1 次)'); // 长按逃跑:必成 + 撤退挨一下(真实账目) b.atk = 1; // 还原 await tick(1500); mark = audio.log.length; g(game, 'longpress'); await tick(2500); 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', 'R2 D1-4:逃跑脱战切回 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 tick(2500); const c = game._battle; assert(c.mover === true && !c.boss, '绕行者 = 非窟主绕位怪'); // 回合 1:命中 + 面对格挡 mark = audio.log.length; g(game, 'tap'); await tick(300); assert(c.hp === 8, '绕行者命中(10→8)'); g(game, 'doubletap'); // 前摇窗口内 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; g(game, 'tap'); await tick(300); assert(c.hp === 6, '再命中(8→6)'); assert(plays(audio, mark).has('sfx/mob_step_bug_r'), '换位脚步 mob_step_bug_r(右侧)'); assert(c.bearing === (DIR_ORDER.indexOf(game.run.face) + 1) % 4, '绕位怪换位到右侧'); await tick(1400); // 不格挡 → 右侧全额 2 伤 assert(game.run.hp === hpP - 5, `右侧未格挡全额(→${game.run.hp})`); // 回合 3:它在右 → 挥空(归因真话)→ 转身追击 g(game, 'tap'); await tick(300); assert(plays(audio, mark).has('voice/btl_swish'), '侧位挥空播 btl_swish(挥空归因真话)'); await tick(2200); // swish(1s) → 敌回合(不动)→ 前摇 1.2s → resolve 全额 2 伤 assert(game.run.hp === hpP - 7, `挥空回合挨刀(→${game.run.hp})`); // 回合 4:转身追击命中 → 敌绕到背后(中央资产音量减半) g(game, 'right'); await tick(120); assert(c.bearing === DIR_ORDER.indexOf(game.run.face), '转身后绕位怪回到面前(追击达成)'); mark = audio.log.length; g(game, '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); // resolve(+1.2s) → btl_behind 链(1s) → 玩家回合 assert(plays(audio, mark).has('voice/btl_behind'), '背后挨刀播 btl_behind(归因真话)'); assert(game.run.hp === 18, `背刺全额 2 伤(20→${game.run.hp})`); // 回合 5:转身两格重新面对 → 前摇内格挡 ×0.2 g(game, 'right'); g(game, 'right'); await tick(120); assert(c.bearing === DIR_ORDER.indexOf(game.run.face), '两转追击重新面对'); c.hp = 6; // 钩子:留血做转身格挡 g(game, 'tap'); await tick(300); g(game, 'doubletap'); await tick(2600); assert(game.run.hp === 18, `转身后格挡 ×0.2(0 伤,${game.run.hp})`); // 收尾击杀:敌回合 6 换位到左 → 转身对准 → 击杀(loot 档 2 + meta.gold +10) g(game, 'tap'); await tick(300); await tick(1600); // 敌回合 6 → 换位左 → 全额 2 g(game, 'left'); // 追击转身对准 await tick(120); assert(c.bearing === DIR_ORDER.indexOf(game.run.face), '换位后追击对准'); g(game, 'tap'); await tick(4500); // 击杀 → 胜利链 → EXPLORE assert(game.state === 'EXPLORE', '绕行者击杀回探索态'); assert(lastBgmEntry(audio)?.id === 'bgm/explore', 'R2 D1-4:胜利回探索态切回 bgm/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 tick(2500); 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; const vibC = vibrateLog.length; g(game, 'longpress'); // 尝试逃跑 await tick(1600); assert(plays(audio, mark).has('voice/btl_flee_boss'), 'Boss 战长按 → btl_flee_boss 拒绝'); assert(vibratedError(vibC), '拒绝伴随 error 震'); assert(game.state === 'BATTLE' && cb.state === 'player', '拒绝后回合不消耗,仍在玩家回合'); // 半血:ATK+1 + btl_half_crab cb.hp = 10; // 钩子:一击跨半 mark = audio.log.length; g(game, '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; // 钩子:收尾 g(game, 'tap'); await tick(4500); 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(); } // ================================ 场景 6:死亡归因真话 + 元进度遗产(§7.4/§11/§12.7) ================================ async function scenario6a() { origConsoleLog('\n=== 场景 6a:死于窟主 → 归因真话 + 遗产购置 + 新周目继承 ==='); let p; storageMap.clear(); storageMap.set('soundgame.auth', JSON.stringify({ token: 'test-token' })); const { game, audio } = newGame({ seed: 7 }); game.start(); await tick(400); // TITLE 上滑:空档案 → meta_gold_1 + st_best_1 + unlock_none let mark = audio.log.length; g(game, 'up'); await tick(4000); assert(plays(audio, mark).orderOk('voice/meta_gold_1', 'voice/st_best_1', 'voice/unlock_none'), 'TITLE 元进度链(空档 → unlock_none)'); g(game, 'tap'); await tick(6000); // → DRILL g(game, 'down'); await tick(2000); // skip → EXPLORE 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 tick(2500); mark = audio.log.length; g(game, 'tap'); // 挥击(20→18) await tick(300); game.run.parries = 2; // 钩子:挡过但没跑过 → die_adv_2(打不过就逃) game.run.flees = 0; await tick(1400); // 敌前摇 → 未格挡 → dmg3 → HP0 → 死 assert(game.state === 'SETTLE', 'HP≤0 → 死亡结算 SETTLE'); await tick(8000); // 死亡链 7 句播完 p = plays(audio, mark); // R3 E1-2:settle 金币 40(0<金<50)→ 档 1 播新真话句 gold_some(不再是「没带回什么」) assert(p.orderOk('voice/die_intro', 'voice/die_cause_2', 'voice/die_adv_2', 'voice/st_floor_1', 'voice/gold_some', 'voice/settle_stronger', 'voice/settle_again'), '死亡链序:die_intro → die_cause_2(Boss 重击未格挡)→ die_adv_2 → st_floor → gold_some(E1-2 band1)→ settle_stronger → settle_again'); assert(game._deathInfo.lastHit.type === 'crab' && game._deathInfo.cause === 2, `归因指认真凶:lastHit=${game._deathInfo.lastHit.type} → die_cause_2`); assert(game.meta.gold === 40, `遗产自动购置 90+150+220(500→${game.meta.gold})`); { // R3 E1-2:settle 档位与 TITLE 同函数同账本;band1(0<金<50)= gold_some 真话句 const band = game._goldBand(game.meta.gold); const want = band === 1 ? 'voice/gold_some' : 'voice/meta_gold_' + band; assert(plays(audio, mark).has(want), `R2 D1-5/R3 E1-2:SETTLE 金币档 = TITLE 同分档出口(_goldBand=${band} → ${want})`); } 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'); { 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(audio.rateLog.at(-1).on === true, 'E3:结算恢复快进'); // SETTLE 双击 = 重听结算链(整链) mark = audio.log.length; g(game, 'doubletap'); await tick(8000); assert(plays(audio, mark).orderOk('voice/die_intro', 'voice/die_cause_2', 'voice/settle_again'), 'SETTLE 双击重听整条结算链'); g(game, 'tap'); // → TITLE(元进度入账后) await tick(1500); assert(game.state === 'TITLE' && !game.run, 'SETTLE 单击 → TITLE'); // 新周目:seed 重掷 + 遗产生效播报 mark = audio.log.length; g(game, 'tap'); await tick(14000); assert(game.state === 'EXPLORE' && game.run.seed !== 7, '新周目 seed ≠ 旧 seed'); 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.meta.drillDone === true, '二周目不再进 DRILL'); assert(p.orderOk('voice/st_floor_1', 'voice/st_hp_1', 'voice/st_face_n'), '新周目局面复述(层/血/面朝)'); assert(p.countRe(/^voice\/st_threat_/) === 1, '局面复述含威胁段'); return { game, audio }; } async function scenario6b() { origConsoleLog('\n=== 场景 6b:死于小怪 → die_cause_1(普通怪连续失血) ==='); let p; storageMap.clear(); const { game, audio } = newGame({}); game.start(); await tick(400); g(game, 'tap'); await tick(6000); g(game, 'down'); await tick(2000); game._mobStep = () => {}; game.run.hp = 1; // 钩子:鼠刀致命 game._encounter({ type: 'rat', x: game.run.pos.x, y: game.run.pos.y, hp: 6, maxHp: 6 }); await tick(2500); let mark = audio.log.length; g(game, 'tap'); // 挥击 await tick(1700); // 敌 dmg1 → HP0 → 死 assert(game.state === 'SETTLE', '小怪磨死 → SETTLE'); await tick(8000); p = plays(audio, mark); assert(p.orderOk('voice/die_intro', 'voice/die_cause_1', 'voice/die_adv_1', 'voice/st_floor_1'), '死亡链:die_cause_1 + die_adv_1(全程零格挡)'); assert(game._deathInfo.lastHit.type === 'rat' && game._deathInfo.cause === 1, `归因指认真凶:lastHit=${game._deathInfo.lastHit.type} → die_cause_1`); game.stop(); } async function scenario6c() { origConsoleLog('\n=== 场景 6c:死于背刺 → die_cause_3 + btl_behind 真话 ==='); let p; storageMap.clear(); const { game, audio } = newGame({}); game.start(); await tick(400); g(game, 'tap'); await tick(6000); g(game, 'down'); await tick(2000); game._mobStep = () => {}; game._encounter({ type: 'crawler', x: game.run.pos.x, y: game.run.pos.y, hp: 10, maxHp: 10 }); await tick(2500); game._battle.bearing = (DIR_ORDER.indexOf(game.run.face) + 2) % 4; // 钩子:它已绕到背后 game.run.hp = 1; // 钩子:背刺刀致命 let mark = audio.log.length; g(game, 'tap'); // 挥空(它在背后) await tick(300); assert(plays(audio, mark).has('voice/btl_swish'), '背后挥空播 btl_swish'); await tick(2600); // swish(1s) → 敌前摇 1.2s → 背后挨刀 dmg2 → 死 assert(game.state === 'SETTLE', '背刺致死 → SETTLE'); await tick(8000); p = plays(audio, mark); assert(p.orderOk('voice/die_intro', 'voice/die_cause_3', 'voice/die_adv_3', 'voice/st_floor_1'), '死亡链:die_cause_3 + die_adv_3(身后有动静先转身)'); assert(game._deathInfo.lastHit.behind === true && game._deathInfo.cause === 3, '归因指认真凶:背刺 → die_cause_3'); game.stop(); } // ================================ 场景 6d:H1——死亡瞬间在途手势不得截杀结算链 ================================ // R3 live A 局实锤(dungeon-20260902150633 events.jsonl):致命 tel_heavy 落地 // (t=2786.78 resolve → _die)时玩家上一记 tap 还压在引擎双击窗(300ms)里没派发, // 2787.057 才回调——此时 state 已是 SETTLE,tap=「离开结算」→ _enterTitle() 的 // stopVoice() 把 die_intro 掐死在 0.18s(vend kill),后 6 句零 fetch 零 vstart, // 结算屏静默 76s(下一次 tap 在 TITLE 上开出新周目 sys_new_run)。 // 回归断言:死亡结算后死窗(game SETTLE_GRACE_MS=800ms)内在途 tap 被吞, // 整链 7 句完整播出(结算屏有内容);死窗后 tap 正常离开结算(设计出口不受影响)。 // 旧代码在本场景必挂:首个在途 tap 即跳 TITLE,die_cause_* 起零播放。 async function scenario6d() { origConsoleLog('\n=== 场景 6d(H1):死亡瞬间在途 tap → 结算链不被截杀,7 句完整播出 ==='); storageMap.clear(); const { game, audio } = newGame({}); game.start(); await tick(400); g(game, 'tap'); await tick(6000); g(game, 'down'); await tick(2000); game._mobStep = () => {}; game.run.hp = 1; // 钩子:窟主反一刀致命(对齐 live:crab 重击收尾) game._encounter({ type: 'crab', x: game.run.pos.x, y: game.run.pos.y, hp: 20, maxHp: 20 }); await tick(2500); game._battle.bearing = (DIR_ORDER.indexOf(game.run.face) + 2) % 4; // 钩子:绕到背后(live 尾段同款) const mark = audio.log.length; g(game, 'tap'); // 挥空 → btl_swish 战斗收尾链 → 敌前摇 → 反一刀 → 死(live 同款序列) await tick(2300); // swish(1s) 播完 → enemyTurn → 前摇 1.2s → resolve 死亡(tap 后 +2.2s) assert(game.state === 'SETTLE', 'H1: 致命一击 → SETTLE'); // 死亡瞬间仍在途的 tap #1:与死亡同拍(旧代码此处即 _enterTitle 截杀结算链) g(game, 'tap'); assert(game.state === 'SETTLE', 'H1: 死后同拍在途 tap 被死窗吞掉(仍在 SETTLE,不离开结算)'); // tap #2:死亡 +300ms 派发(对齐引擎双击窗回调延迟,live 2787.057-2786.78≈277ms) await tick(200); g(game, 'tap'); assert(game.state === 'SETTLE', 'H1: 死后 +300ms 在途 tap 同样被吞(仍在 SETTLE)'); await tick(8000); // 结算链 7 句应完整播出(旧代码在 die_intro 半句即断) const p = plays(audio, mark); assert(p.orderOk('voice/die_intro', 'voice/die_cause_3', 'voice/die_adv_3', 'voice/st_floor_1', 'voice/meta_gold_1', 'voice/settle_stronger', 'voice/settle_again'), 'H1: 结算链 7 句完整播出(die_intro → die_cause_3 → die_adv_3 → st_floor → 金币档 → settle_stronger → settle_again)——结算屏有内容'); assert(p.count('voice/die_intro') === 1, 'H1: die_intro 恰播一次(在途 tap 未触发 _enterTitle/stopVoice 截杀)'); assert(game.state === 'SETTLE' && game.run === null, 'H1: 链播完仍安居 SETTLE(run 已清、meta 保留)'); // 死窗后(>800ms)设计出口照常:tap → TITLE await tick(2000); g(game, 'tap'); await tick(1500); assert(game.state === 'TITLE' && !game.run, 'H1: 死窗后 tap 正常离开结算 → TITLE(设计出口不受死窗影响)'); game.stop(); } // ================================ 场景 7:菜单暂停/恢复(E4,§2.4/§12.8) ================================ async function scenario7({ game, audio }) { origConsoleLog('\n=== 场景 7:菜单=暂停(探索游走冻结 / 战斗前摇冻结、恢复无重复回合) ==='); // 探索菜单冻结(第二周目 L1,真怪游走中)。安全步进器:真游走+真脚步,不扑击 // 不进玩家格(贴身扑击由场景 12C 专项负责,此处断言不被随机接战打断) game._mobStep = (m) => { const S = game._floor.size; const opts = []; for (const d of Object.values(DIRS)) { const nx = m.x + d.dx, ny = m.y + d.dy; if (nx >= 0 && ny >= 0 && nx < S && ny < S && game._floor.grid[ny][nx] === 1 && !(nx === game.run.pos.x && ny === game.run.pos.y)) opts.push({ x: nx, y: ny }); } if (!opts.length) return; const c = opts[Math.floor(Math.random() * opts.length)]; m.x = c.x; m.y = c.y; game._mobStepSound(m.type, m.x, m.y); }; let mark = audio.log.length; g(game, 'down'); await tick(1500); assert(game.menuOpen && game.menuFrom === 'EXPLORE', '探索下滑=菜单'); await tick(5000); const inMenu = audio.log.slice(mark).filter((e) => e.id.startsWith('sfx/mob_step_')).length; assert(inMenu === 0, '菜单打开期间怪物游走冻结'); g(game, 'down'); await tick(6000); const resumed = audio.log.slice(mark).filter((e) => e.id.startsWith('sfx/mob_step_')).length; assert(resumed > 0, '关菜单后游走恢复'); // 战斗前摇暂停:窗口剩余量冻结,恢复后恰结算一次 game._mobStep = () => {}; game.run.veilUsedFloor = true; // 钩子:护符本层已耗(6a 已验证减半),此处验证账目 game._encounter({ type: 'rat', x: game.run.pos.x, y: game.run.pos.y, hp: 6, maxHp: 6 }); await tick(2500); const b = game._battle; g(game, 'tap'); // 命中(6→4)→ 敌前摇 await tick(300); assert(b.state === 'telegraph', '进入敌方前摇'); const hp0 = game.run.hp; const tel0 = plays(audio).count('sfx/tel_small'); g(game, 'down'); // 前摇中开菜单(E4:战斗菜单=暂停) await tick(120); assert(game.menuOpen && game._menuPause != null, '前摇剩余窗口记入暂停账'); await tick(5000); // 远超 1.2s 窗口 assert(game.run.hp === hp0 && b.state === 'telegraph', '前摇冻结:超时不结算、不推进'); assert(audio.rateLog.at(-1).on === true, 'E3:菜单恢复快进'); g(game, 'down'); // 关菜单 await tick(1800); assert(!game.menuOpen, '菜单关闭'); assert(game.run.hp === hp0 - 1, `恢复后恰结算一次(${hp0}→${game.run.hp})`); assert(plays(audio).count('sfx/tel_small') === tel0, '前摇音不重播(无重复回合)'); await tick(300); assert(b.state === 'player', '回到玩家回合(敌方仅行动一次)'); // 菜单帮助 + 保存并回大厅 mark = audio.log.length; g(game, 'down'); await tick(1500); g(game, 'up'); await tick(4000); assert(plays(audio, mark).orderOk('voice/help_explore', 'voice/help_battle', 'voice/settings_hint'), '菜单上滑=帮助链'); g(game, 'tap'); // resume → save await tick(1300); assert(plays(audio, mark).has('voice/sys_menu_save'), '单击循环切到「保存并回大厅」'); g(game, 'doubletap'); await tick(3000); assert(plays(audio, mark).has('voice/sys_saved'), '确认保存播 sys_saved'); assert(window.location.href === '../', '保存并回大厅 → 跳转大厅'); } // ================================ 场景 8:中断复述 + TITLE 右滑重掷新周目(§11/§12.9) ================================ async function scenario8() { origConsoleLog('\n=== 场景 8:中断复述链 + TITLE 右滑重掷新周目 ==='); let p; storageMap.clear(); const a1 = newGame({ seed: 55 }); a1.game.start(); await tick(400); g(a1.game, 'tap'); await tick(6000); g(a1.game, 'down'); await tick(2000); a1.game._mobStep = () => {}; // 挪到「南边有地板」的格子并转身朝南、走 1 格 const fl = a1.game._floor; let spot = null; outer: for (let y = 0; y < fl.size - 1; y++) for (let x = 0; x < fl.size; x++) { if (fl.grid[y][x] === 1 && fl.grid[y + 1][x] === 1) { spot = { x, y }; break outer; } } a1.game.run.pos = { ...spot }; g(a1.game, 'right'); g(a1.game, 'right'); await tick(200); assert(a1.game.run.face === 'S', '面朝南'); g(a1.game, 'longpress'); await tick(80); g(a1.game, 'longpressend'); await tick(200); const savedPos = { ...a1.game.run.pos }; a1.game._save(); // 钩子:等同菜单"保存并回大厅"(走位本身不触发存档,§11 保存时机) a1.game.stop(); // 实例 2:杀进程重进 → sys_continue_ask → 单击继续 → 局面复述链 const a2 = newGame({}); a2.game.start(); await tick(1500); assert(a2.game.state === 'TITLE' && plays(a2.audio).has('voice/sys_continue_ask'), '有档重进播 sys_continue_ask'); let mark = a2.audio.log.length; g(a2.game, 'tap'); await tick(8000); assert(a2.game.state === 'EXPLORE', '读档回探索态'); assert(a2.game.run.face === 'S' && a2.game.run.pos.x === savedPos.x && a2.game.run.pos.y === savedPos.y, '落点还原(pos/face)'); p = plays(a2.audio, mark); assert(p.orderOk('voice/st_floor_1', 'voice/st_hp_1', 'voice/st_face_s'), '局面复述链 st_floor → st_hp → st_face 有序'); assert(p.countRe(/^voice\/st_threat_/) === 1, '局面复述链第 4 段威胁句'); assert(a2.audio.rateLog.at(-1).on === false, 'E3:对局期快进关闭'); // 上滑同链可重放 mark = a2.audio.log.length; g(a2.game, 'up'); await tick(6000); assert(plays(a2.audio, mark).orderOk('voice/st_floor_1', 'voice/st_hp_1', 'voice/st_face_s'), '上滑同链可重放'); a2.game.stop(); // 实例 3:TITLE 右滑 = 重掷新周目(元进度保留) const a3 = newGame({}); a3.game.start(); await tick(1500); assert(plays(a3.audio).has('voice/sys_continue_ask'), '实例 3 仍在 TITLE 询问继续'); 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; g(a3.game, 'right'); for (let i = 0; i < 120 && a3.game.state !== 'EXPLORE'; i++) { await tick(100); if (a3.game.run) a3.game._mobStep = () => {}; // 静场:新周目 L1 游走怪不干扰状态断言 } assert(a3.game.state === 'EXPLORE' && a3.game.run, 'TITLE 右滑 → 直接进新周目探索(已学过教学)'); assert(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(); } // ================================ 场景 9:卡关三级提示(§10.3,不报答案) ================================ async function scenario9() { origConsoleLog('\n=== 场景 9:卡关三级 60/120/180s(暗示→明示→带路)+ 队列礼仪 ==='); storageMap.clear(); const { game, audio } = newGame({ seed: 11 }); game.start(); await tick(400); g(game, 'tap'); await tick(6000); g(game, 'down'); await tick(2000); assert(game.state === 'EXPLORE', '进探索态'); game._mobStep = () => {}; // 静场 const stuck = game._stuck; // L1 暗示(60s 无进展) stuck.lastAct = vnow - 60000; let mark = audio.log.length; const vibS9 = vibrateLog.length; await tick(6000); // 5s 轮询 assert(plays(audio, mark).has('voice/stair_hint_a'), '60s → stair_hint_a(L1 无窟主 → 铁器暗示,不报答案)'); assert(stuck.shown.has('L1'), 'L1 级已记'); { const warn = vibrateLog.slice(vibS9).some((v) => JSON.stringify(v.p) === JSON.stringify([30, 50, 30, 50, 30])); assert(warn && !vibratedError(vibS9), 'L1 提示附 warning 震(非 error)'); } // L2 明示(无进展满 120s) stuck.lastAct = vnow - 120000; mark = audio.log.length; await tick(6000); assert(plays(audio, mark).has('voice/stair_dir_' + game._stuckStairDir()), `120s → stair_dir_*(明示绝对方位=${game._stuckStairDir()})`); assert(stuck.shown.has('L2'), 'L2 级已记'); // L3 带路(无进展满 180s):进入导航模式;R2 D1-3 起带路=最短路逐步校正 //(不再直线差值指墙),同向短窗不重复播(保险丝友好) stuck.lastAct = vnow - 180000; mark = audio.log.length; await tick(6000); assert(stuck.nav === true, '180s → 进入导航模式'); assert(plays(audio, mark).countRe(/^voice\/nav_[eswn]$/) === 1, '带路首报=最短路第一步方向(恰 1 句)'); { const st2 = game._floor.stairs; const dir1 = game._navFirstStepDir(st2); assert(dir1 && plays(audio, mark).has('voice/nav_' + dir1.toLowerCase()), `带路方向与 grid 最短路一致(第一步朝 ${dir1})`); const nb = DIRS[dir1]; assert(game._floor.grid[game.run.pos.y + nb.dy]?.[game.run.pos.x + nb.dx] === 1, 'R2 D1-3:带路方向第一步必可通行(不再指墙)'); } const path = bfsPath(game._floor, game.run.pos, game._floor.stairs, mobCells(game._floor)); assert(path.length > 0, '带路可达楼梯'); mark = audio.log.length; await walkPath(game, path); { // 每步校正口径:改向必播(首报已在触发句计入节流)、同向 ≥1.5s 提醒、末步 nav_done let turns = 0; for (let i = 1; i < path.length; i++) if (path[i].dir !== path[i - 1].dir) turns++; const navN = plays(audio, mark).countRe(/^voice\/nav_[eswn]$/); assert(navN >= turns && navN <= turns + path.length, `导航逐步校正:每个改向都有报(转向 ${turns} 次,实播 ${navN} 句,有界不刷屏)`); } assert(plays(audio, mark).has('voice/nav_done'), '抵达楼梯播 nav_done'); { // 节流单元断言:同向 1.5s 内重复调用只播一次 game.run.pos = { ...game._floor.start }; game._stuck.nav = true; game._navLastDir = null; game._navLastAt = 0; const m0 = audio.log.length; game._navSpeakStep(); game._navSpeakStep(); game._navSpeakStep(); assert(plays(audio, m0).countRe(/^voice\/nav_[eswn]$/) === 1, '导航节流:同向连呼 3 次只播 1 句'); await tick(1600); game._navSpeakStep(); assert(plays(audio, m0).countRe(/^voice\/nav_[eswn]$/) === 2, '导航节流:超过 1.5s 同向提醒播第 2 句'); game._stuck.nav = false; } assert(!game._stuck.nav && stuck.shown.size === 0, '抵达=进展事件 → 导航退出、分级重置'); // 进展事件重置分级:重置后再静默 60s → 从 L1 重新开始(而非直接 L2) stuck.lastAct = vnow - 60000; mark = audio.log.length; await tick(6000); assert(plays(audio, mark).has('voice/stair_hint_a') && !stuck.shown.has('L2'), '进展事件后重置分级:下段静默期从 L1 重新触发'); // 队列礼仪:语音在播时入队不顶句;玩家操作作废未播级别;未播级别可重新触发 stuck.shown.clear(); // 钩子:清分级,专测 L1 的入队路径 g(game, 'up'); // 状态链在播(5 句) await tick(100); stuck.lastAct = vnow - 60000; const qMark = audio.log.length; game._stuckCheck(); // 钩子直呼检查(13 文档允许钩子模拟"语音播放中到点") assert(stuck.pending.length >= 1, '语音在播 → 提示入待播队列(不顶句)'); assert(plays(audio, qMark).count('voice/stair_hint_a') === 0, '待播期间未抢播'); g(game, 'tap'); // 玩家操作 assert(stuck.pending.length === 0, '玩家操作作废未播提示'); assert(!stuck.shown.has('L1'), '未播级别不计入 shown(可重新触发)'); await tick(62000); assert(plays(audio).has('voice/stair_hint_a'), '作废级别在下段静默期重新触发(60s 如期)'); game.stop(); } // ================================ 场景 10:向导看门狗(guide=full,§10.2) ================================ async function scenario10() { origConsoleLog('\n=== 场景 10:向导看门狗(guide=full:探索 10s / 战斗 15s / 教学 8s 纯 nudge) ==='); storageMap.clear(); settings.set({ guide: 'full' }); try { const { game, audio } = newGame({}); const events = []; game.guide.setEventHook((e) => events.push(e)); game.start(); await tick(400); g(game, 'tap'); await tick(5500); // sys_new_run + D0 三句 // DRILL 每步等待 [8s]:纯 nudge 无语音(教学句不重播) let mark = audio.log.length; const ev0 = events.length; await tick(9000); assert(events.slice(ev0).some((e) => e.type === 'watchdog'), 'DRILL 看门狗到点触发(nudge 事件)'); assert(audio.log.slice(mark).every((e) => !e.id.startsWith('voice/gd_')), '教学看门狗纯 nudge:无 gd_* 语音'); g(game, 'down'); await tick(2000); // skip → EXPLORE game._mobStep = () => {}; // 探索静止 [10s, 25s]:第 1 级播 gd_pulse mark = audio.log.length; await tick(11000); assert(audio.log.slice(mark).some((e) => e.id === 'voice/gd_pulse'), '探索 10s 看门狗播 gd_pulse'); // R2 D1-1:菜单关闭回执=状态签名(开=menu 签名,关=回到探索的 explore 签名) { const stEv = () => events.filter((e) => e.type === 'state').map((e) => e.name); g(game, 'down'); // 开菜单 await tick(1500); g(game, 'down'); // 关菜单 await tick(1500); const names = stEv(); assert(names[names.length - 2] === 'menu' && names[names.length - 1] === 'explore', '菜单关闭播回程状态签名(menu → explore,盲手可感知开关)'); } // R2 D1-2:D9 看门狗兜底(8s nudge、30s 升级播楼梯暗示——自寻路步必须有救援) { const realState = game.state; const realDrill = game._drill; game.state = 'DRILL'; game._drill = { step: 9 }; game._drillWatchdog9(); const ev0 = events.length; mark = audio.log.length; await tick(31000); assert(events.slice(ev0).filter((e) => e.type === 'watchdog').length === 2, 'D9 看门狗两级都触发(8s nudge + 30s 升级)'); assert(audio.log.slice(mark).some((e) => e.id === 'voice/stair_hint_a'), 'D9 30s 升级播楼梯暗示(兜底救援)'); game.state = realState; game._drill = realDrill; } // 战斗玩家回合 [15s, 30s]:第 1 级播 gd_turn game._encounter({ type: 'rat', x: game.run.pos.x, y: game.run.pos.y, hp: 6, maxHp: 6 }); await tick(2500); assert(game._battle.state === 'player', '战斗玩家回合'); mark = audio.log.length; await tick(16000); assert(audio.log.slice(mark).some((e) => e.id === 'voice/gd_turn'), '战斗回合 15s 看门狗播 gd_turn'); assert(audio.log.slice(mark).some((e) => e.id === 'sfx/turn_tick'), 'R2 D1-6:回合 15s 播合成回执音 turn_tick(standard 档无语音也有确认)'); assert(game.state === 'BATTLE', '看门狗只提示不接管(仍在战斗)'); } finally { settings.set({ guide: 'off' }); // 复位:其余场景契约 guide=off } } // ================================ 场景 11:屏蔽 voice 不卡死(§12 边界) ================================ async function scenario11() { origConsoleLog('\n=== 场景 11:屏蔽任意 voice 资产 → 流程不卡死(看门狗/兜底推进) ==='); storageMap.clear(); const { game, audio } = newGame({ seed: 3 }); audio.blocked.add('voice/evt_descend'); audio.blocked.add('voice/btl_rat'); game.start(); await tick(400); g(game, 'tap'); await tick(6000); g(game, 'down'); await tick(2000); game._mobStep = () => {}; // 屏蔽遭遇报名 → _speakGuarded 10s 兜底照样进玩家回合 game._encounter({ type: 'rat', x: game.run.pos.x, y: game.run.pos.y, hp: 6, maxHp: 6 }); let t0 = vnow; while (!(game._battle && game._battle.state === 'player') && vnow - t0 < 15000) await tick(500); assert(game._battle && game._battle.state === 'player', 'btl_rat 被屏蔽:10s 兜底进玩家回合(不卡死)'); game._battle.hp = 0; // 钩子:一刀结束 g(game, 'tap'); await tick(4000); assert(game.state === 'EXPLORE', '胜利链走完回探索'); // 屏蔽下潜旁白 → 8s 兜底照样完成下潜 const path = bfsPath(game._floor, game.run.pos, game._floor.stairs, mobCells(game._floor)); await walkPath(game, path); assert(game._onStairs === true, '踏上楼梯'); g(game, 'tap'); await tick(13000); assert(game.run.floor === 2, 'evt_descend 被屏蔽:8s 兜底完成下潜(不卡死)'); audio.blocked.clear(); game.stop(); } // ================================ 场景 12:R2 结构修复回归(D0-1 保险丝/全网格回声/密度/贴身接战) ================================ async function scenario12() { origConsoleLog('\n=== 场景 12:R2 结构修复回归(风暴保险丝 + 全网格回声一致性 + 贴身接战 + 60s 调度上界) ==='); // ---- A. 引擎风暴保险丝单元断言(真实 WebAudioEngine,D0-1②) ---- { const { WebAudioEngine } = await import('../../engine/audio.js'); const eng = new WebAudioEngine('audio/'); eng.unlocked = true; const calls = []; eng._playVoice = (id) => calls.push('v:' + id); eng._playBgm = (id) => calls.push('b:' + id); eng._playSfx = (id) => calls.push('s:' + id); const t0 = vnow; for (let i = 0; i < 4; i++) eng.play('voice/spam_id'); assert(calls.length === 4, '保险丝:同 id 3s 窗口内前 4 次调度放行'); eng.play('voice/spam_id'); eng.play('voice/spam_id'); assert(calls.length === 4, '保险丝:第 5 次起同 id 调度被丢弃(不进播放层)'); eng.play('voice/other_id'); assert(calls.length === 5, '保险丝:只熔断超阈 id,其他 id 不受累'); await tick(3100); assert(vnow - t0 >= 3100, '虚拟时钟推进过熔断窗口'); eng.play('voice/spam_id'); assert(calls.length === 6, '保险丝:窗口滑出后恢复放行'); eng.play('bgm/whatever'); eng.play('sfx/whatever'); assert(calls.length === 8, '保险丝:只作用于 voice 层(bgm/sfx 不计)'); } // ---- B. 全网格回声一致性(seed=42 全图 L1..L7,D0-2) ---- { storageMap.clear(); const { game } = newGame({ seed: 42 }); game.start(); await tick(400); g(game, 'tap'); await tick(400); assert(game.run, '场景12B: 新周目就绪'); 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 真话,R1 全程 0 次 → 现扫到 ${sideOpens} 格次)`); // 楼梯脚下=贴身铁器(mat_stair 教学承诺兑现) 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)`); } game.stop(); } // ---- C. 贴身即接战(D1-3 蘑菇软封锁修复)+ 纯探索 60s 调度上界 + 卡关自然触发 ---- storageMap.clear(); { const { game, audio } = newGame({ seed: 5 }); game.start(); await tick(400); g(game, 'tap'); await tick(6000); g(game, 'down'); await tick(2000); assert(game.state === 'EXPLORE', '场景12C: 跳过教学进探索'); game._mobStep = () => {}; // 静场 // 贴身即接战:怪与玩家正交相邻 → 它的下一次移动必然扑进玩家格 const fl = game._floor; let pair = null; outer12: 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 outer12; } } 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._mobStep = () => {}; { const t0b = vnow; while (game._battle?.state !== 'player' && vnow - t0b < 12000) await tick(200); game._battle.hp = 0; // 钩子:一刀结束,继续后续断言 g(game, 'tap'); // 玩家回合挥击 → 胜利 await tick(4500); } assert(game.state === 'EXPLORE', '贴身战胜后回探索'); // 纯探索 60s 零操作:语音调度总量有上界(D0-1 回归断言:无互抢风暴), // 且卡关 L1 暗示在真实计时器下自然触发(D0-3④:不 hook lastAct) game._floor.mobs.length = 0; const m0 = audio.log.length; const t0 = vnow; await tick(62000); const seg = audio.log.slice(m0); const voiceN = seg.filter((e) => e.layer === 'voice').length; assert(vnow - t0 >= 62000, '场景12C: 60s 虚拟空闲推进'); assert(voiceN <= 3, `纯探索 60s 零操作语音调度有上界(${voiceN} 句 ≤3;R1 风暴同期 >2000 句)`); assert(seg.some((e) => e.id === 'voice/stair_hint_a'), '卡关 L1 暗示 60s 自然触发(纯探索、无 hook)'); assert(seg.filter((e) => e.id === 'sfx/amb_cave').length >= 2, '探索呼吸层 60s 内 ≥2 段环境垫(20s 节奏)'); game.stop(); } } // ================================ 场景 13:R3 修复回归(E0-1 看门狗双轨 / E0-2 跳教学 / E1-1 / E1-2 / 打磨) ================================ /** 找「死胡同」格(开放邻格最少)+ 一面朝向的墙:乱按零位移的保障。 */ 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; } /** 连续乱按:每 ~1s 一个随机手势;reface 非空时每拍先把脸按回墙(长按只会 bump)。 */ async function mash(game, set, ms, reface = null) { const t0 = vnow; while (vnow - t0 < ms) { if (reface) game.run.face = reface; // 测试钩子:钉死朝墙,保证零位移(零进展) g(game, set[Math.floor(Math.random() * set.length)]); await tick(900 + Math.floor(Math.random() * 500)); } } async function scenario13() { origConsoleLog('\n=== 场景 13:R3 修复回归(E0-1 双轨看门狗 / E0-2 跳教学 / E1-2 金币档 / E1-1 锁格到达即报) ==='); // ---- A. E0-1:连续乱按 96s 零进展 → earcon → 短句 → 导航接管(无操作轨被喂饱也升级) ---- { storageMap.clear(); const { game, audio } = newGame({ seed: 11 }); game.start(); await tick(400); g(game, 'tap'); await tick(6000); g(game, 'down'); await tick(2000); assert(game.state === 'EXPLORE', '场景13A: 跳过教学进探索'); game._mobStep = () => {}; // 静场:乱按不被接战打断 const de = deadEndCell(game._floor); assert(de && de.wallDir, '场景13A: 找到临墙格(mash 每拍钉死朝墙 → 长按只 bump,零位移保障)'); game.run.pos = { x: de.x, y: de.y }; const m0 = audio.log.length; await mash(game, ['tap', 'doubletap', 'left', 'right', 'longpress'], 96000, de.wallDir); const seg = plays(audio, m0); assert(game.state === 'EXPLORE' && game.run.pos.x === de.x && game.run.pos.y === de.y, 'E0-1: 乱按 96s 全程零位移(无操作轨持续被手势重置——R2 压力局正是被这条饿死)'); assert(seg.has('sfx/turn_tick'), 'E0-1: 无进展 30s 级 → earcon(复用合成回执音,乱按饿不死)'); assert(seg.has('voice/stair_hint_a'), 'E0-1: 无进展 60s 级 → 短句 stair_hint_a(抢救落到人话)'); assert(seg.hasRe(/^voice\/nav_[eswn]$/), 'E0-1: 无进展 90s 级 → 导航接管(播最短路首步方向)'); assert(game._stuck.nav === true, 'E0-1: 接管后进入导航模式(每步校正 + 60s 重报直至进展)'); { const dir = game._navFirstStepDir(game._floor.stairs); const nb = DIRS[dir]; assert(dir && game._floor.grid[game.run.pos.y + nb.dy]?.[game.run.pos.x + nb.dx] === 1, 'E0-1: 接管首步方向必可通行(楼梯指向,不指墙)'); } const vn = seg.countRe(/^voice\//); assert(vn <= 4, `E0-1: 乱按 96s 语音总量有上界(${vn} 句 ≤4;R1 风暴/2223s 空窗两极都不复现)`); game.stop(); } // ---- B. E0-1:有真实进展(格位置持续推进)则全程不触发 ---- { storageMap.clear(); const { game, audio } = newGame({ seed: 23 }); game.start(); await tick(400); g(game, 'tap'); await tick(6000); g(game, 'down'); await tick(2000); assert(game.state === 'EXPLORE', '场景13B: 跳过教学进探索'); game._mobStep = () => {}; const fl = game._floor; const from = { ...fl.start }; const nb = Object.entries(DIRS).find(([, d]) => { const nx = from.x + d.dx, ny = from.y + d.dy; return fl.grid[ny]?.[nx] === 1 && !fl.items.has(nx + ',' + ny) && !(fl.stairs && nx === fl.stairs.x && ny === fl.stairs.y); }); assert(nb, '场景13B: 起点有无宝无楼梯的可走邻格'); const d0 = nb[1]; const m0 = audio.log.length; const t0 = vnow; let legs = 0; while (vnow - t0 < 100000) { const back = legs % 2 === 1; const dx = back ? -d0.dx : d0.dx, dy = back ? -d0.dy : d0.dy; game.run.face = Object.entries(DIRS).find(([, d]) => d.dx === dx && d.dy === dy)[0]; g(game, 'longpress'); // 起步即走 1 格(真实进展:格位置变化重置无进展轨) await tick(80); g(game, 'longpressend'); await tick(560); legs++; } const seg = plays(audio, m0); assert(!seg.hasRe(/^voice\//) && !seg.has('sfx/turn_tick'), `E0-1: 有真实进展(${legs} 次格移动 / 100s)零卡关语音零 earcon(不误伤正常玩家)`); game.stop(); } // ---- C. E0-2:跳教学种子局主路径密度不变式 + 120s 内必有指向语音(或遭遇) ---- { storageMap.clear(); const { game, audio } = newGame({ seed: 77 }); game.start(); await tick(400); g(game, 'tap'); await tick(6000); g(game, 'down'); // D0 即下滑:跳教学路径 await tick(2000); assert(game.state === 'EXPLORE' && game.run, '场景13C: 跳教学直达探索(drillDone)'); { // 主路径密度不变式:对实测 _floor 由测试侧独立重算(生成器对跳教学局一视同仁) const fl = game._floor; const dist = bfsDist(fl); const target = fl.stairs; const content = new Set([fl.start.x + ',' + fl.start.y, target.x + ',' + target.y, ...fl.items.keys(), ...fl.mobs.map((m) => m.x + ',' + m.y)]); const path2 = [target]; let cur = { ...target }; while (cur.x !== fl.start.x || cur.y !== fl.start.y) { for (const d of Object.values(DIRS)) { const nx = cur.x + d.dx, ny = cur.y + d.dy; if (nx < 0 || ny < 0 || nx >= fl.size || ny >= fl.size || fl.grid[ny][nx] !== 1 || dist[ny][nx] !== dist[cur.y][cur.x] - 1) continue; cur = { x: nx, y: ny }; break; } path2.push(cur); } 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, `E0-2: 跳教学种子局主路径密度不变式(最长真空 ${maxGap} 格 ≤12)`); } game._mobStep = () => {}; // 静场:专验指向语音路径(遭遇路径由场景 12C 覆盖) const de = deadEndCell(game._floor); game.run.pos = { x: de.x, y: de.y }; const m0 = audio.log.length; const t0 = vnow; await mash(game, ['tap', 'doubletap', 'left', 'right', 'longpress'], 96000, de.wallDir); const hit = audio.log.slice(m0).find((e) => e.id === 'voice/stair_hint_a' || /^voice\/nav_[eswn]$/.test(e.id)); assert(hit && hit.t - t0 <= 120000, `E0-2: 跳教学后 ${Math.round((hit ? hit.t - t0 : 1e9) / 1000)}s 内必有指向语音(≤120s;R2 压力局 skip 后 79 分钟零指向)`); game.stop(); } // ---- D. E0-2:教学态乱按零进展 90s 级 → 自动滚出教学(R1 乱按局 ~75s 口径恢复) ---- { storageMap.clear(); const { game, audio } = newGame({ seed: 11 }); game.start(); await tick(400); g(game, 'tap'); await tick(6000); assert(game.state === 'DRILL', '场景13D: 进教学'); game._drill.step = 9; // 测试钩子:直落 D9(脉冲等待步——压力局 39 分钟锁点;tap=脉冲零进展) const m0 = audio.log.length; const t0 = vnow; await mash(game, ['tap', 'doubletap'], 98000); const seg = plays(audio, m0); assert(game.state === 'EXPLORE' && game.meta.drillDone === true, `E0-2: 教学乱按零进展 ${Math.round((vnow - t0) / 1000)}s 内自动滚出教学(R2 压力局 2403s → ≤95s)`); assert(seg.has('sfx/turn_tick'), 'E0-2: 教学乱按 30s 级 earcon 先到'); assert(seg.has('voice/stair_hint_a'), 'E0-2: D9 乱按 60s 级给方向话 stair_hint_a(看门狗长嘴)'); assert(seg.has('voice/tut_skip'), 'E0-2: 接管播 tut_skip(已跳过教学 → 探索态由导航链接管)'); game.stop(); } // ---- E. E1-2 金币档真话(上滑 + SETTLE + =0 保留空手)+ E1-1 锁格到达即报 ---- { storageMap.clear(); const { game, audio } = newGame({ seed: 11 }); game.start(); await tick(400); g(game, 'tap'); await tick(6000); g(game, 'down'); await tick(2000); game._mobStep = () => {}; game.meta.gold = 13; // 钩子:0 < 金 < 50(教学拾取后的真实档位) let mark = audio.log.length; g(game, 'up'); await tick(6000); let p = plays(audio, mark); assert(p.has('voice/gold_some') && !p.has('voice/st_gold_1'), 'E1-2: 上滑金币档 0<金<50 播 gold_some(带回来一点,不多;不再谎报「口袋空空」)'); // E1-1 锁路径:窟主未清的楼梯格,到达即报铁器 + 锁句(死亡前做,run 仍在) const seed13 = game.run.seed; game._floor = game._generateFloor(seed13, 3); // 测试钩子:L3(窟主守楼梯) game.run.floor = 3; const st3 = game._floor.stairs; game._onStairs = false; game._stairsSpokenFloor = 0; const e0 = audio.log.length; game._onStairCell(st3.x, st3.y); await tick(300); // echo_1 即刻、mat_stair = 到达 +120ms 材质尾音 { const seg = plays(audio, e0); assert(seg.has('sfx/echo_1') && seg.has('sfx/mat_stair'), 'E1-1: 踏上(被锁的)楼梯格自动播贴身铁器回声(echo_1 + mat_stair)'); assert(game._onStairs === true, 'E1-1: 到达置位 _onStairs(tap 下潜逻辑不受影响)'); } assert(plays(audio, e0).has('voice/evt_stairs_lock'), 'E1-1: 窟主未清 → 到达即报铁器 + evt_stairs_lock'); // 还原 L1 后死于窟鼠:SETTLE band1 同步 gold_some game._floor = game._generateFloor(seed13, 1); game.run.floor = 1; game.run.hp = 1; game._encounter({ type: 'rat', x: game.run.pos.x, y: game.run.pos.y, hp: 6, maxHp: 6 }); await tick(2500); mark = audio.log.length; g(game, 'tap'); // 挥击 → 敌前摇 → 反一刀致命 await tick(2200); assert(game.state === 'SETTLE', '场景13E: 死亡进结算'); await tick(9000); p = plays(audio, mark); assert(p.orderOk('voice/die_intro', 'voice/die_cause_1', 'voice/st_floor_1', 'voice/gold_some'), 'E1-2: SETTLE band1(0<金<50)同步播 gold_some(真话:这趟带回了一点)'); // =0 保留空手措辞(SETTLE 上滑重听元进度段走 _titleUpChain 同一出口) game.meta.gold = 0; g(game, 'up'); await tick(4000); assert(plays(audio, mark).has('voice/meta_gold_1'), 'E1-2: 金=0 保留 meta_gold_1 空手措辞(band0 口径不变)'); 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 集合一致 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 === 127, `资产校验: 台词表 127 句齐全(125 + R3 gold_some/tut_flee_hold,实际 ${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; // 模板前缀('sfx/echo_' + band),由下表展开 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}`); staticIds.add(`voice/st_gold_${b}`); } for (const d of ['n', 'e', 's', 'w']) { staticIds.add(`voice/st_face_${d}`); staticIds.add(`voice/nav_${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 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', 'sys_menu_resume', 'sys_menu_save', 'sys_menu_help', 'sys_menu_restart']) staticIds.add(`voice/${i}`); for (const i of ['intro_1', 'intro_2', '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', 'nav_done', 'tut_d0', 'tut_echo_come', 'tut_front_rule', 'tut_side_rule', 'tut_walk', 'tut_turn', 'tut_replay', 'tut_status', 'tut_menu', 'tut_mob', 'tut_parry', 'tut_parry_ok', 'tut_parry_miss', 'tut_attack', 'tut_kill', 'tut_stairs', 'tut_stairs_on', 'tut_done', 'tut_skip', 'gd_invalid', 'gd_pulse', 'gd_turn']) staticIds.add('voice/' + i); for (const s of ['pulse', 'step_own', 'step_turn', 'bump', 'coin', 'potion', 'gear', 'stairs_down', 'atk_swing', 'atk_hit', 'parry', 'hurt', 'enemy_die', 'levelup']) staticIds.add('sfx/' + s); for (const b2 of ['menu', 'explore', 'battle', 'boss']) staticIds.add('bgm/' + b2); const badStatic = [...staticIds].filter((id) => !assetExists(id)); assert(badStatic.length === 0, `资产校验: 静态引用缺失 ${badStatic.join(',') || '无'}`); ok(`资产校验: 静态 ${staticIds.size} id + 运行期日志全量双格式(ogg+m4a)齐全`); } // ================================ 主流程 ================================ let s2Ctx = null; try { await scenario1(); s2Ctx = await scenario2(); await scenario4(s2Ctx); await scenario5(); const s6 = await scenario6a(); await scenario7(s6); await scenario6b(); await scenario6c(); await scenario6d(); // H1:死亡瞬间在途 tap 不截杀结算链 await scenario8(); await scenario9(); await scenario10(); await scenario11(); await scenario12(); await scenario13(); } 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);