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 置完成。
1667 lines
82 KiB
JavaScript
1667 lines
82 KiB
JavaScript
/**
|
||
* 《回声地窟》无头试玩测试(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);
|