Files
xsl ee5d4d2544 feat(playbook): 26 号交互手册全量落地——剩余 12 款深度重构 + 语音全量重制
storyline/escape/simon 首批之后的推广收官:15 款全部按 26 号模式运行
(发言权/回执/三档提示/迷茫接管/手势阶梯/断点复述),旧「双击重听/格挡/
菜单确认」等反模式口径全线废除,开场零规则念白(首句即动手)。

游戏层(12 款):rpg(格挡=前摇窗口内再单击)/ rhythm / space / radio /
dungeon / abyss / detective / cipher / teahouse / keeper / bridge /
hunt(联机切片):
- 每款 game.js 接引擎六模块 + makeInputPipeline(doubleTapWindow:0);
  四向玩法类 swipeCore + 长按帮助;双击是玩法的(space 护盾 / hunt
  旋风斩)按手势阶梯阶段 3 分档恢复双击窗口(doubletapCore)
- 每款 playtest 重写:玩法断言 + §6.2 乱点机器人(无声操作 / 被动
  原句重复 / 打断失败 = 0);15 款 3900+ 断言全绿
- 12 份 docs/design/26-apply-*.md 施工单 + tools/voice_lines_26/
  台词清单 + voice-ids 文档同步 + 转写会话剧本按新口径重写

引擎层(web/engine/,6 处修复):pipeline 增 doubletapCore;novice
teachLine 带 voice/ 前缀(此前教学句被静默丢弃);confusion.record
前置到新手门(锁定手势的乱点也计信号);InvalidHints 播完恢复解释前
状态(根治孤儿 BUSY);WaitPoint takeover(2) 台账去重;台账只在
speak 真正播出时登记。遗留引擎债登记 roadmap §3.1。

语音资产:台词表合并(新增 ~250 系统句 / 改 ~50 旧口径 / 删 ~110
废止句;合并器 tools/merge_voice_lines_26.py + 引用校验器
tools/validate_voice_refs.py),GPU 重录 1536 wav → 4204 ogg/m4a
落地,check_all_assets + validate_voice_refs 双绿;键盘变体同步扩表。

大厅/文档:15 款统一新交互口径(「新交互」徽标退役);README /
HANDOVER / rpg README 旧口径修正;roadmap P0-P3 置完成。
2026-10-02 12:33:20 +08:00

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