- 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
2060 lines
98 KiB
JavaScript
2060 lines
98 KiB
JavaScript
/**
|
||
* 《回声地窟》无头试玩测试(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);
|