Files
SoundGame/web/dungeon/test/playtest.mjs
T
xsl f7509ad5d8 feat(new8/R3): 双游戏收口修复轮 + 验证档案(radio G1-G6 / dungeon E0-1..E1-2)
- radio:帮助链去重(同等待点二次=beat震+earcon,全量口令38→20)/rep_low警告震/空点轻震/状态链重排(状态在前)/「首播必完整、复播可即掐」显式规则登记/G3核查为设计使然转断言;221→246断言
- dungeon:看门狗双轨制(无操作轨+无进展轨,30/60/90s三级升级到导航接管)/跳教学90s自动滚出(2403s→95s内)/踩楼梯到达即报贴身铁器/band1新词「带回来一点,不多」全管线重录/逃跑长按900ms+提示句;1610→1642断言
- R3 验证(进行中已入库部分):全量回归 9/9 全绿;R3 转写×4;P2/P3/P4 终审×6份(radio 满分档首评/三审,dungeon 4/4.5/3-4 全票愿意玩,无P0);两处独立定址同一残余=漫游型迷路刷不响无进展轨(R4 第一条)
- 8套件+guide全绿;Mureka 累计 $0.87/$2.00
2026-09-02 23:08:35 +08:00

2060 lines
98 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 《回声地窟》无头试玩测试(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();
}
// ================================ 场景 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 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);