配对码与认领(B): - POST /api/pair/start|join:6 位码 10 分钟单次有效、仅存内存零泄露面、5 连错 per-IP 封锁 10 分钟;join=新设备档案按桶 LWW 并入老档案+token 改绑(复用 claim-merge);日志脱敏 - 大厅:生成配对码(speechSynthesis 逐位朗读+aria-live+重听)/从其他设备转入(one-time-code 输入框)/从本机移除/401 过期自愈引导/aria 补全/玩家名可听化 - api-smoke 12/12(配对全流程/频控/封锁/过期自愈/移除/digitsToCn)+ CDP 真浏览器 E2E 22/22 键盘语音双套资产(A,路线 a): - 164 变体句(8 游戏 grep 实测,超出调研预估 153:含轻触/点两下/按住变体)×2 性别=328 生成 656 文件 36.9 分钟;触屏主资产零字节改动(PCM 逐样本比对后还原) - 引擎 _candidates 三跳路由 key→gender→平铺(inputMode 跟随);缓存键 mode|gender|id;engine-input 新增 K9 路由断言(72 条) - check_all_assets 增 key 列(两性别一致且 ⊆ 主集)8 游戏全绿;voice_text.json 增 __key__ 表;live control.mjs key 会话对照键盘措辞验收(集成) - voice-ids 文档:8 游戏键盘变体小节由 KEY_LINES 程序化生成(与台词表逐字断言一致);补齐 storyline/escape/simon 三个历史缺失文档;design README 增 new8 索引 - mock 适配:playtest persona 加 maxTouchPoints=1 保持触屏链断言语义零改动 回归:12 套件全绿(8 playtest + guide 122 + engine-voice 19 + engine-input 72 + api-smoke 12/12) 同批入库:批次 2/3 已完稿设计文档(17-abyss/18-detective/19-cipher 全套,20-22 在途)
1195 lines
57 KiB
JavaScript
1195 lines
57 KiB
JavaScript
/**
|
||
* 《剑与声》无头试玩测试(docs/design/13-playtest-criteria.md §6 + §7 全局断言)。
|
||
*
|
||
* 以玩家身份模拟:mock 浏览器环境(window/navigator/localStorage/fetch/虚拟时钟)
|
||
* 与 WebAudioEngine 接口(播放日志 + voice onComplete 按虚拟时钟推进语音链,
|
||
* 资产缺失 = 不触发 onComplete,对齐引擎静默跳过语义),加载真实 game.js,
|
||
* 手势直接注入 game.onGesture(引擎回调路径)。
|
||
*
|
||
* 运行:tools/node/bin/node web/rpg/test/playtest.mjs
|
||
* 退出码 0 = 全部断言通过。随机性由种子 PRNG(可强制排队值)保证可重复。
|
||
*/
|
||
import fs from 'node:fs';
|
||
import path from 'node:path';
|
||
import { fileURLToPath } from 'node:url';
|
||
|
||
const RPG_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||
|
||
// ================================ 虚拟时钟 ================================
|
||
|
||
let vnow = 0; // 虚拟毫秒
|
||
const timers = new Map();
|
||
let nextTimerId = 1;
|
||
|
||
function schedule(ms, fn) {
|
||
const id = nextTimerId++;
|
||
timers.set(id, { at: vnow + ms, fn });
|
||
return id;
|
||
}
|
||
function unschedule(id) { timers.delete(id); }
|
||
|
||
async function flushMicro() {
|
||
for (let i = 0; i < 5; i++) await Promise.resolve();
|
||
}
|
||
|
||
/** 推进虚拟时钟 ms 毫秒,期间到期的计时器按序触发(含语音 onComplete 链)。 */
|
||
async function tick(ms) {
|
||
const target = vnow + ms;
|
||
for (let guard = 0; guard < 100000; guard++) {
|
||
await flushMicro();
|
||
let best = null;
|
||
for (const [id, t] of timers) {
|
||
if (t.at <= target && (!best || t.at < best.t.at)) best = { id, t };
|
||
}
|
||
if (!best) break;
|
||
timers.delete(best.id);
|
||
vnow = best.t.at;
|
||
best.t.fn();
|
||
}
|
||
vnow = target;
|
||
await flushMicro();
|
||
}
|
||
|
||
// ================================ 浏览器环境 mock ================================
|
||
|
||
const storageMap = new Map();
|
||
const localStorageMock = {
|
||
getItem: (k) => (storageMap.has(k) ? storageMap.get(k) : null),
|
||
setItem: (k, v) => { storageMap.set(k, String(v)); },
|
||
removeItem: (k) => { storageMap.delete(k); },
|
||
clear: () => storageMap.clear(),
|
||
};
|
||
|
||
const vibrateLog = [];
|
||
const fetchLog = [];
|
||
const consoleErrors = [];
|
||
const consoleLogs = [];
|
||
|
||
Object.defineProperty(globalThis, 'performance', { value: { now: () => vnow }, configurable: true });
|
||
// 注意 setTimeout 参数序是 (fn, ms),内部 schedule 是 (ms, fn) —— 必须包一层
|
||
globalThis.setTimeout = (fn, ms = 0) => schedule(ms, fn);
|
||
globalThis.clearTimeout = unschedule;
|
||
globalThis.localStorage = localStorageMock;
|
||
globalThis.fetch = (url, opts) => { fetchLog.push({ url, opts }); return Promise.resolve({ ok: true, json: async () => ({}) }); };
|
||
Object.defineProperty(globalThis, 'navigator', {
|
||
// maxTouchPoints: 1 —— 手机 persona:inputMode() 判 touch,语音走触屏措辞主资产(键盘路由由 engine-input K9 专测)
|
||
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 = (...a) => { consoleErrors.push(a.join(' ')); origConsoleError(...a); };
|
||
const origConsoleLog = console.log.bind(console);
|
||
console.log = (...a) => { consoleLogs.push(a.join(' ')); origConsoleLog(...a); };
|
||
|
||
// 种子 PRNG(mulberry32)+ 强制值队列(遭遇/逃跑判定用)
|
||
let randState = 42;
|
||
const randQueue = [];
|
||
Math.random = () => {
|
||
if (randQueue.length) return randQueue.shift();
|
||
randState |= 0; randState = (randState + 0x6D2B79F5) | 0;
|
||
let t = Math.imul(randState ^ (randState >>> 15), 1 | randState);
|
||
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||
};
|
||
|
||
// ================================ 资产存在性 ================================
|
||
|
||
function hasPair(base) {
|
||
return fs.existsSync(base + '.ogg') && fs.existsSync(base + '.m4a');
|
||
}
|
||
|
||
/** voice:双性别目录齐全,或平铺(NPC 单音色)齐全;sfx/bgm:双格式。 */
|
||
function assetExists(id) {
|
||
const audioDir = path.join(RPG_DIR, 'audio');
|
||
if (id.startsWith('voice/')) {
|
||
const name = id.slice(6);
|
||
if (hasPair(path.join(audioDir, 'voice', 'female', name))
|
||
&& hasPair(path.join(audioDir, 'voice', 'male', name))) return true;
|
||
return hasPair(path.join(audioDir, 'voice', name)); // 平铺回退(npc_*)
|
||
}
|
||
return hasPair(path.join(audioDir, id));
|
||
}
|
||
|
||
// ================================ 音频引擎 mock ================================
|
||
|
||
class MockParam {
|
||
constructor() { this.value = 0; }
|
||
setValueAtTime() {}
|
||
linearRampToValueAtTime() {}
|
||
cancelScheduledValues() {}
|
||
}
|
||
|
||
class MockAudioContext {
|
||
get currentTime() { return vnow / 1000; }
|
||
createBufferSource() {
|
||
return { buffer: null, playbackRate: { value: 1 }, loop: false, onended: null,
|
||
connect() {}, start() {}, stop() {} };
|
||
}
|
||
createGain() { return { gain: new MockParam(), connect() {} }; }
|
||
}
|
||
|
||
class MockAudioEngine {
|
||
constructor() {
|
||
this.sfxVolume = 0.8; this.voiceVolume = 1; this.bgmVolume = 1;
|
||
this.log = []; // {t, layer, id, opts, missing}
|
||
this._voice = null; // 当前 voice token(game 侧 _voicePlaying 判定依据)
|
||
this.bgmNow = null;
|
||
this.skipCount = 0;
|
||
this.blocked = new Set(); // 被屏蔽的 voice id(看门狗测试:onComplete 永不触发)
|
||
this.ctx = new MockAudioContext();
|
||
this._sfxBus = { kind: 'sfxBus' };
|
||
}
|
||
isVoicePlaying() { return this._voice !== null; } // 对齐引擎公开 API(game 侧已改用它)
|
||
setRateBoostEnabled() {} // 对齐引擎公开 API(rpg 不调用,保持 mock 完整)
|
||
_dur(id) { return 1.0; } // 假 buffer 时长(秒):语音链按虚拟时钟推进
|
||
play(id, opts = {}) {
|
||
const layer = id.split('/')[0];
|
||
const missing = !assetExists(id);
|
||
this.log.push({ t: vnow, layer, id, opts, missing });
|
||
if (missing) { console.error(`[mock-audio] 资产缺失: ${id}`); return; } // 引擎静默跳过语义
|
||
if (layer === 'voice') {
|
||
this._voice = null; // 顶掉当前句(不触发其 onComplete,对齐引擎语义)
|
||
const token = { id, onComplete: opts.onComplete };
|
||
this._voice = token;
|
||
if (!this.blocked.has(id)) {
|
||
schedule(this._dur(id) * 1000, () => {
|
||
if (this._voice === token) {
|
||
this._voice = null;
|
||
if (token.onComplete) token.onComplete();
|
||
}
|
||
});
|
||
}
|
||
} else if (layer === 'bgm') {
|
||
this.bgmNow = id;
|
||
}
|
||
// sfx:即发即弃,无后续
|
||
}
|
||
skipVoice() {
|
||
this.skipCount++;
|
||
const cur = this._voice;
|
||
if (!cur) return;
|
||
this._voice = null;
|
||
if (cur.onComplete) cur.onComplete(); // 视为自然播完:链播下一句
|
||
}
|
||
stopVoice() { this._voice = null; }
|
||
stopBgm(fadeOutMs = 800) {
|
||
this.log.push({ t: vnow, layer: 'bgm', id: '(stop)', opts: { fadeOutMs } });
|
||
this.bgmNow = null;
|
||
}
|
||
stopAll() { this.stopBgm(200); this.stopVoice(); }
|
||
prewarm() {}
|
||
async _load(id) {
|
||
const missing = !assetExists(id);
|
||
this.log.push({ t: vnow, layer: 'load', id, missing });
|
||
if (missing) { console.error(`[mock-audio] 资产缺失: ${id}`); return null; }
|
||
return { duration: this._dur(id) };
|
||
}
|
||
}
|
||
|
||
// ================================ 断言设施 ================================
|
||
|
||
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,
|
||
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),
|
||
};
|
||
}
|
||
|
||
/** 最近一次敌方攻击 dmg(console.log '[RpgGame] 敌方攻击 dmg=N')。 */
|
||
function lastEnemyDmg() {
|
||
for (let i = consoleLogs.length - 1; i >= 0; i--) {
|
||
const m = consoleLogs[i].match(/敌方攻击 dmg=(\d+)/);
|
||
if (m) return Number(m[1]);
|
||
}
|
||
return null;
|
||
}
|
||
|
||
const ERROR_PATTERN = JSON.stringify([60, 40, 60]);
|
||
function vibratedError(fromIdx) {
|
||
return vibrateLog.slice(fromIdx).some((v) => JSON.stringify(v.p) === ERROR_PATTERN);
|
||
}
|
||
|
||
// ================================ 游戏装配 ================================
|
||
|
||
const { RpgGame } = await import('../game.js');
|
||
const { settings } = await import('../../engine/settings.js');
|
||
|
||
// 向导层关闭:回归测试只验证纯游戏逻辑(guide='full' 的行为由 harness/guide.test.mjs 覆盖)
|
||
settings.set({ guide: 'off', voice: 'female', speed: 1.0, bgm: 1.0 });
|
||
|
||
const audioRegistry = [];
|
||
function newGame() {
|
||
const audio = new MockAudioEngine();
|
||
audioRegistry.push(audio);
|
||
const game = new RpgGame(audio, {});
|
||
return { game, audio };
|
||
}
|
||
const g = (game, gesture) => game.onGesture(gesture);
|
||
|
||
/** 战斗辅助:把指令切到目标(只允许手势注入)。 */
|
||
function selectCmd(game, cmd) {
|
||
const CMDS = ['attack', 'skill', 'defend', 'item'];
|
||
const b = game._battle;
|
||
const target = CMDS.indexOf(cmd);
|
||
const rightSteps = (target - b.cmdIdx + CMDS.length) % CMDS.length;
|
||
const leftSteps = (b.cmdIdx - target + CMDS.length) % CMDS.length;
|
||
const [dir, n] = rightSteps <= leftSteps ? ['right', rightSteps] : ['left', leftSteps];
|
||
for (let i = 0; i < n; i++) g(game, dir);
|
||
}
|
||
|
||
/** 语音链快进(等链播完/看门狗兜底)。 */
|
||
async function battlePump(game, audio, ms = 30000) { await tick(ms); }
|
||
|
||
/** 前摇钩子:记录每次 _enemyTelegraph 开窗时长(ms)。 */
|
||
function hookTelegraph(game) {
|
||
const windows = [];
|
||
const orig = game._enemyTelegraph.bind(game);
|
||
game._enemyTelegraph = () => { orig(); windows.push(game._battle.windowEnd - vnow); };
|
||
return windows;
|
||
}
|
||
|
||
/** 战斗辅助:标准打法——选中攻击单击;前摇内立即双击格挡;低血量喝药。 */
|
||
async function fightSmart(game, audio, { maxRounds = 60, potionBelow = 0 } = {}) {
|
||
let gestures = 0;
|
||
while (game.phase === 'battle' && game._battle && gestures < maxRounds * 5) {
|
||
const b = game._battle;
|
||
if (b.state === 'PLAYER_SELECT') {
|
||
const lowHp = potionBelow > 0 && game.player.hp <= potionBelow && game.player.potions > 0;
|
||
selectCmd(game, lowHp ? 'item' : 'attack');
|
||
gestures += 4;
|
||
await tick(800);
|
||
g(game, 'tap'); // 战斗中单击=执行指令(语音不可跳,12 文档裁定)
|
||
gestures++;
|
||
await tick(200);
|
||
} else if (b.state === 'ENEMY_TELEGRAPH') {
|
||
g(game, 'doubletap'); // 即时双击格挡(窗口内)
|
||
gestures++;
|
||
await tick(200);
|
||
} else {
|
||
await tick(500);
|
||
}
|
||
}
|
||
return gestures;
|
||
}
|
||
|
||
// ================================ 场景 A:第一章通关关键路径 ================================
|
||
|
||
async function scenarioA() {
|
||
origConsoleLog('\n=== 场景 A:第一章通关关键路径 ===');
|
||
localStorageMock.clear();
|
||
storageMap.set('soundgame.auth', JSON.stringify({ token: 'test-token' })); // 验证 PUT 同步
|
||
const { game, audio } = newGame();
|
||
let mark = 0;
|
||
|
||
// ---- 1. 启动 → 序章 → village ----
|
||
game.start();
|
||
assert(game.phase === 'intro', 'A1: 启动进序章 intro');
|
||
await tick(60000); // 序章 5 句 + 进村 3 句
|
||
assert(game.phase === 'explore' && game.scene === 'village', 'A1: 序章后进 village 探索态');
|
||
let p = plays(audio, mark);
|
||
assert(p.orderOk('voice/intro_1', 'voice/intro_2', 'voice/intro_3', 'voice/skip_hint', 'voice/sys_new_game'),
|
||
'A1: 序章语音链 intro_1-3 + skip_hint 插播 + sys_new_game 有序');
|
||
assert(p.orderOk('voice/npc_headman_1', 'voice/npc_headman_2', 'voice/desc_village'),
|
||
'A1: 进村村长对白 + desc_village 有序');
|
||
assert(p.has('voice/opt_go_path'), 'A1: 进村播报首选项 opt_go_path');
|
||
assert(p.has('bgm/village'), 'A1: village 状态音乐 bgm/village');
|
||
assert(audio.log.some((e) => e.layer === 'load' && e.id === 'sfx/amb_village' && !e.missing),
|
||
'A1: village 环境音 amb_village 加载(sfx 层)');
|
||
|
||
// ---- 2. 选项切换 / 双击重听 / 状态链 ----
|
||
mark = audio.log.length;
|
||
g(game, 'right'); await tick(1200);
|
||
assert(game.optIdx === 1 && plays(audio, mark).has('voice/opt_shop'), 'A2: 右滑切选项播 opt_shop');
|
||
g(game, 'left'); await tick(1200);
|
||
assert(game.optIdx === 0 && plays(audio, mark).has('voice/opt_go_path'), 'A2: 左滑切回 go_path');
|
||
g(game, 'doubletap'); await tick(3000);
|
||
assert(plays(audio, mark).orderOk('voice/desc_village_re', 'voice/opt_go_path'), 'A2: 双击=重听场景描述+当前选项');
|
||
mark = audio.log.length;
|
||
g(game, 'up'); await tick(8000);
|
||
assert(plays(audio, mark).orderOk('voice/st_lv_low', 'voice/st_hp_high', 'voice/st_gold_some',
|
||
'voice/st_weapon_none', 'voice/st_armor_none'), 'A2: 上滑状态链 st_lv→st_hp→st_gold→st_weapon→st_armor 完整有序');
|
||
|
||
// ---- 3. 菜单(RPG 豁免:左右滑+单击确认)----
|
||
mark = audio.log.length;
|
||
g(game, 'down'); await tick(2500);
|
||
assert(game.phase === 'menu', 'A3: 探索态下滑=菜单');
|
||
assert(plays(audio, mark).has('bgm/menu'), 'A3: 菜单状态音乐 bgm/menu(amb 不停)');
|
||
assert(plays(audio, mark).orderOk('voice/sys_menu', 'voice/sys_menu_resume'), 'A3: 菜单语音 + 首项 resume');
|
||
g(game, 'right'); await tick(1200);
|
||
assert(game._menuIdx === 1 && plays(audio, mark).has('voice/sys_menu_save'), 'A3: 菜单右滑切到保存');
|
||
g(game, 'right'); await tick(1200);
|
||
g(game, 'tap'); await tick(4000); // help
|
||
assert(plays(audio, mark).orderOk('voice/help_explore', 'voice/help_battle', 'voice/settings_hint'), 'A3: 菜单帮助链完整');
|
||
g(game, 'down'); await tick(1500);
|
||
assert(game.phase === 'explore', 'A3: 菜单下滑关闭回探索态');
|
||
assert(plays(audio, mark).lastBgm('bgm/village'), 'A3: 关菜单淡回 bgm/village');
|
||
|
||
// ---- 4. 商店:铁剑 100 金不够 → shop_poor;买金疮药 ----
|
||
g(game, 'right'); await tick(1200); // opt_shop
|
||
g(game, 'tap'); await tick(3000); // 进商店(voice 播完再点)
|
||
assert(game.phase === 'shop', 'A4: 进商店');
|
||
assert(plays(audio).orderOk('voice/shop_open', 'voice/shop_price_sword'), 'A4: shop_open + 铁剑报价');
|
||
g(game, 'tap'); await tick(1500); // 买铁剑:50 < 100
|
||
assert(plays(audio).has('voice/shop_poor') && game.player.gold === 50 && !game.player.weapon,
|
||
'A4: 金币 50 买铁剑(100) → shop_poor,金币/装备不变');
|
||
g(game, 'right'); await tick(1200); // armor
|
||
g(game, 'right'); await tick(1200); // potion
|
||
g(game, 'tap'); await tick(1500); // 买金疮药 30 金
|
||
assert(game.player.gold === 20 && game.player.potions === 2, 'A4: 买金疮药成功(50→20 金,药 1→2)');
|
||
g(game, 'right'); await tick(1200); // leave
|
||
g(game, 'tap'); await tick(1200);
|
||
assert(game.phase === 'explore', 'A4: 离开商店回探索态');
|
||
|
||
// ---- 5. 单击跳过旁白(12 文档:探索态 voice 播放中单击=skipVoice)----
|
||
g(game, 'left'); await tick(1200); // 切到 go_path
|
||
const skipsBefore = audio.skipCount;
|
||
g(game, 'tap'); // 确认 go_path → _enterScene('path') 同步开播 desc_path
|
||
assert(game.scene === 'path' && audio._voice, 'A5: 进 path 后 desc 语音在播');
|
||
g(game, 'tap'); // desc 播放中单击 = 跳过本句(不执行选项)
|
||
assert(audio.skipCount === skipsBefore + 1, 'A5: desc 播放中单击触发 skipVoice');
|
||
assert(game.scene === 'path', 'A5: 跳过旁白不触发选项确认(场景不变)');
|
||
await tick(3000);
|
||
assert(plays(audio).has('voice/opt_go_fork'), 'A5: 跳过本句后语音链继续推进到选项播报');
|
||
|
||
// ---- 6. path 调查一次性 +15 金 ----
|
||
g(game, 'right'); await tick(1200); // investigate
|
||
g(game, 'tap'); await tick(1500);
|
||
assert(game.player.gold === 35 && plays(audio).has('voice/evt_gold15'), 'A6: investigate 一次 +15 金');
|
||
g(game, 'tap'); await tick(1500);
|
||
assert(game.player.gold === 35 && plays(audio).has('voice/evt_nothing'), 'A6: 再次调查播 evt_nothing 不加金');
|
||
|
||
// ---- 7. fork → chest 开箱 ----
|
||
g(game, 'left'); await tick(1200); // go_fork
|
||
g(game, 'tap'); await tick(3000);
|
||
assert(game.scene === 'fork', 'A7: 到岔路口');
|
||
g(game, 'tap'); await tick(3000); // idx0 go_chest
|
||
assert(game.scene === 'chest', 'A7: 到枯树洞');
|
||
g(game, 'tap'); await tick(1500); // open_chest
|
||
assert(game.player.potions === 4 && plays(audio).has('voice/evt_chest'), 'A7: 开箱金疮药 +2');
|
||
|
||
// ---- 8. stream 教学战(四步强制教学,11 文档 §2)----
|
||
g(game, 'right'); await tick(1200); // back
|
||
g(game, 'tap'); await tick(3000); // 回 fork
|
||
g(game, 'right'); await tick(1200); // go_stream
|
||
mark = audio.log.length;
|
||
g(game, 'tap'); // 进 stream → desc 后触发教学战
|
||
await tick(2000);
|
||
assert(game.phase === 'battle' && game._battle.encId === 'enc_teach_wolf', 'A8: stream 触发教学战');
|
||
const bt = game._battle;
|
||
assert(bt.enemy.atk === 2, 'A8: 教学狼 ATK=2(11 文档特例)');
|
||
assert(bt.teachStage === 'menu', 'A8: 教学 T0 stage=menu');
|
||
assert(bt.tier.telSec === 1.8, 'A8: 教学战前摇 1.8s');
|
||
const realDmg = game._dmg;
|
||
game._dmg = (a, d) => Math.max(1, a - d); // 测试钩子:伤害确定化(玩家 5、教学狼 2)
|
||
const teachWindows = hookTelegraph(game); // 前摇开窗时长记录(在 16s 看门狗兜底前手动推进)
|
||
await tick(8000); // 教学链 7 句播完(mock 1s/句)进 PLAYER_SELECT;看门狗 16s 兜底尚未触发
|
||
p = plays(audio, mark);
|
||
assert(p.orderOk('voice/btl_start_wolf', 'voice/tut_battle_1', 'voice/tut_battle_2',
|
||
'voice/tut_battle_3', 'voice/tut_battle_4', 'voice/tut_battle_5', 'voice/tut_b0'),
|
||
'A8: 首战教学链 tut_battle_1..5 + tut_b0 有序');
|
||
assert(p.lastBgm('bgm/battle') && p.lastBgm('bgm/battle').opts.fadeMs === 400,
|
||
'A8: 战斗 BGM 400ms 快切 bgm/battle');
|
||
assert(bt.state === 'PLAYER_SELECT', 'A8: 教学旁白后进 PLAYER_SELECT');
|
||
assert(bt.teachStage === 'menu', 'A8: 旁白播完仍在 T0(看门狗未误触发)');
|
||
assert(p.has('sfx/turn_player'), 'A8: 玩家回合播 turn_player(听觉标点)');
|
||
{
|
||
const L = audio.log.filter((e) => !e.missing);
|
||
const t1 = L.filter((e) => e.id === 'sfx/turn_player').at(-1).t;
|
||
const t2 = L.filter((e) => e.id === 'voice/cmd_attack').at(-1).t;
|
||
assert(t2 - t1 >= 400, 'A8: turn_player 与指令名之间留间隙不叠放(≥400ms)');
|
||
}
|
||
|
||
// T0:左右滑 2 次 → attack 步(round1-live P2-3:T1 改播 tut_battle_2,不再播 tut_b3)
|
||
g(game, 'right'); g(game, 'left');
|
||
await tick(1500);
|
||
assert(bt.teachStage === 'attack' && plays(audio, mark).count('voice/tut_battle_2') >= 2,
|
||
'A8: T0 切换 2 次 → T1 attack(重播 tut_battle_2,不再播 tut_b3)');
|
||
// T1:选中攻击单击命中 → parry 步
|
||
await tick(1200);
|
||
const wolfHpBefore = bt.enemy.hp;
|
||
g(game, 'tap');
|
||
assert(bt.enemy.hp < wolfHpBefore, 'A8: T1 攻击命中一次');
|
||
await tick(1500); // tut_b1(1s)→ 前摇开窗
|
||
assert(bt.teachStage === 'parry' && plays(audio, mark).has('voice/tut_b1'), 'A8: T1 命中 → T2 parry(tut_b1)');
|
||
assert(bt.state === 'ENEMY_TELEGRAPH', 'A8: T2 进入敌方前摇');
|
||
assert(Math.abs(teachWindows[0] - 1800) < 50, `A8: T2 前摇窗口 1.8s(实测 ${teachWindows[0]}ms)`);
|
||
assert(plays(audio, mark).has('voice/tut_b2'), 'A8: 前摇开窗播 tut_b2');
|
||
// T2:不格挡硬吃(受击也可通过)
|
||
const hpBeforeHit = game.player.hp;
|
||
await tick(1900);
|
||
assert(game.player.hp < hpBeforeHit && game.player.hp >= 1, 'A8: T2 受击掉血且 ≥1');
|
||
assert(bt.teachStage === 'defend', 'A8: T2 受击后 → T3 defend');
|
||
// T3:选防御单击 → tut_b5 收尾;防御减伤 50% 演示(狼 base 2 → 1)
|
||
await tick(4200); // tut_b4+tut_battle_4+tut_b6 三句播完进 PLAYER_SELECT(round1-live P1 链扩为 3 句)
|
||
assert(plays(audio, mark).orderOk('voice/tut_b4', 'voice/tut_battle_4', 'voice/tut_b6'),
|
||
'A8: 格挡失败反馈 tut_b4 + 减伤教学 tut_battle_4 + 防御教学 tut_b6(round1-live P1 明说减伤经济)');
|
||
selectCmd(game, 'defend');
|
||
await tick(1200);
|
||
g(game, 'tap');
|
||
await tick(2000);
|
||
assert(bt.teachStage === null && plays(audio, mark).has('voice/tut_b5'), 'A8: T3 执行防御 → tut_b5 收尾进自由击杀');
|
||
await tick(4000); // 敌方前摇 1.8s + 结算
|
||
assert(lastEnemyDmg() === 1, `A8: 防御当回合伤害减半(base 2 → ${lastEnemyDmg()},期望 1)`);
|
||
assert(bt.state === 'PLAYER_SELECT', 'A8: 防御后回到玩家回合');
|
||
// T4:自由击杀
|
||
selectCmd(game, 'attack');
|
||
await tick(1200);
|
||
g(game, 'tap');
|
||
await tick(4000);
|
||
game._dmg = realDmg;
|
||
assert(game.phase === 'explore' && game.scene === 'stream', 'A8: 教学狼击杀,回 stream 探索态');
|
||
p = plays(audio, mark);
|
||
assert(p.orderOk('sfx/enemy_die', 'voice/btl_enemy_die', 'sfx/victory', 'voice/btl_win', 'voice/btl_loot_small'),
|
||
'A8: 胜利序列 enemy_die → victory sfx → btl_win → loot 有序');
|
||
assert(game.flags.tutorial_battle_done === true, 'A8: tutorial_battle_done 置位');
|
||
assert(plays(audio, mark).count('voice/tut_b3') === 0,
|
||
'A8: 教学全程不播 tut_b3(「挡住了!」与未格挡实况矛盾,round1-live P2-3 退出教学链)');
|
||
assert(game.stats.battlesWon === 1, 'A8: battlesWon 0→1(难度档位依据)');
|
||
{
|
||
const stop = audio.log.filter((e) => e.id === '(stop)').at(-1);
|
||
assert(stop && stop.opts.fadeOutMs === 1500, 'A8: 胜利后战斗 BGM 淡出 1500ms');
|
||
const ex = plays(audio).lastBgm('bgm/explore');
|
||
assert(ex && ex.opts.fadeMs === 2000 && ex.opts.volume === 0.35,
|
||
'A8: 胜利→探索过渡:bgm/explore 淡入 2000ms、volume 0.35');
|
||
}
|
||
|
||
// ---- 9. hunter 支线 ----
|
||
await tick(1500); // 等选项 voice 播完
|
||
g(game, 'tap'); await tick(1500); // idx0 drink
|
||
assert(game.player.hp === game.maxHp && plays(audio).has('voice/sys_drink'), 'A9: 溪水回满血');
|
||
g(game, 'right'); await tick(1200); // go_hunter
|
||
g(game, 'tap'); await tick(3000);
|
||
assert(game.scene === 'hunter', 'A9: 到猎人小屋');
|
||
mark = audio.log.length;
|
||
g(game, 'tap'); await tick(4000); // talk_hunter
|
||
assert(game.flags.hunter_quest && game.flags.shop_discount && game.player.potions === 5,
|
||
'A9: hunter_quest + shop_discount + 金疮药+1');
|
||
assert(plays(audio, mark).orderOk('voice/npc_hunter_1', 'voice/npc_hunter_2', 'voice/evt_hunter_gift'),
|
||
'A9: 猎人对白链有序');
|
||
|
||
// ---- 10. deepwood 山贼连战(保护档验证)----
|
||
g(game, 'right'); await tick(1200); // go_deepwood
|
||
mark = audio.log.length;
|
||
g(game, 'tap');
|
||
await tick(2000); // desc 后触发遭遇
|
||
assert(game.phase === 'battle' && game._battle.encId === 'enc_bandits', 'A10: 密林触发山贼连战');
|
||
assert(game._battle.tier.telSec === 1.5 && game._battle.tier.dmgMul === 0.6,
|
||
'A10: battlesWon=1 → 保护档(前摇 1.5s、伤害 ×0.6)');
|
||
const banditWindows = hookTelegraph(game);
|
||
await fightSmart(game, audio);
|
||
assert(Math.abs(banditWindows[0] - 1500) < 50, `A10: 保护档前摇窗口实测 1.5s(${banditWindows[0]}ms)`);
|
||
assert(game.phase === 'explore' && game.flags.enc_bandits_done, 'A10: 山贼连战胜利,enc_bandits_done');
|
||
assert(game.stats.battlesWon === 2, 'A10: battlesWon 1→2(连战=1 场)');
|
||
assert(plays(audio, mark).has('voice/btl_half_bandit'), 'A10: 山贼半血狠话 btl_half_bandit');
|
||
assert(game.player.lv === 2, `A10: 战后升级 Lv2(实际 Lv${game.player.lv})`);
|
||
assert(plays(audio, mark).has('voice/btl_levelup'), 'A10: 升级播报 btl_levelup');
|
||
{
|
||
const save = JSON.parse(storageMap.get('soundgame.progress.rpg'));
|
||
assert(save.summary.includes('密林深处') && save.summary.includes('Lv2'),
|
||
`A10: 存档 summary 含场景名与等级("${save.summary}")`);
|
||
assert(fetchLog.some((f) => f.url === '/api/progress' && f.opts.method === 'PUT'),
|
||
'A10: 战斗结算后 PUT /api/progress 同步');
|
||
}
|
||
|
||
// ---- 11. gate:Lv2 首次到寨门 → boss1_warn;强攻 elite ----
|
||
await tick(1500); // 等选项 voice 播完
|
||
g(game, 'tap'); // idx0 go_gate
|
||
await tick(3500);
|
||
assert(game.scene === 'gate', 'A11: 到黑风寨门前');
|
||
assert(plays(audio).orderOk('voice/boss1_warn', 'voice/desc_gate'), 'A11: Lv2 首次到寨门插播 boss1_warn');
|
||
assert(game.flags.boss1_warned === true, 'A11: boss1_warned 置位(只播一次)');
|
||
mark = audio.log.length;
|
||
g(game, 'tap'); // idx0 assault → enc_elite
|
||
await tick(1000);
|
||
assert(game.phase === 'battle' && game._battle.encId === 'enc_elite', 'A11: 强攻触发 elite 战');
|
||
await fightSmart(game, audio, { potionBelow: 12 });
|
||
assert(game.flags.enc_elite_done, 'A11: elite 战胜利');
|
||
assert(plays(audio, mark).orderOk('voice/npc_elite_1', 'voice/npc_elite_half', 'voice/npc_elite_die'),
|
||
'A11: elite 开场/半血/死亡 NPC 台词有序');
|
||
await tick(4000); // 开门 → 聚义厅 → Boss 战
|
||
assert(game.phase === 'battle' && game._battle.encId === 'enc_boss1',
|
||
'A11: elite 胜利后开门进聚义厅 → Boss 战');
|
||
|
||
// ---- 12. Boss 战:恒标准档 + 半血二阶段 ----
|
||
{
|
||
const b = game._battle;
|
||
assert(b.tier.telSec === 1.0 && b.tier.dmgMul === 1.0,
|
||
'A12: battlesWon=3 时 Boss 仍恒标准档(前摇 1.0s、×1.0,毕业考不放宽)');
|
||
}
|
||
const bossWindows = hookTelegraph(game);
|
||
await fightSmart(game, audio, { maxRounds: 30, potionBelow: 14 });
|
||
assert(Math.abs(bossWindows[0] - 1000) < 50, `A12: Boss 前摇窗口实测 1.0s(${bossWindows[0]}ms)`);
|
||
assert(plays(audio).orderOk('voice/npc_boss1_1', 'voice/npc_boss1_2', 'voice/npc_boss1_die'),
|
||
'A12: Boss 开场/二阶段/死亡台词有序');
|
||
await tick(4000);
|
||
assert(game.flags.boss1_done && game.chapters[1] === 'cleared' && game.chapters[2] === 'open',
|
||
'A12: Boss 胜利 → 第一章 cleared、第二章 open');
|
||
assert(plays(audio).has('voice/evt_ch1_end'), 'A12: 章结算 evt_ch1_end');
|
||
assert(plays(audio).lastBgm('bgm/ending'), 'A12: 章结算 bgm/ending');
|
||
await tick(5000);
|
||
assert(game.phase === 'explore' && game.scene === 'village', 'A12: 结算后回村');
|
||
ok('场景 A 通关');
|
||
|
||
// ---- 13. 杀进程重进 → 继续旅程落点正确 ----
|
||
const { game: game2, audio: audio2 } = newGame();
|
||
game2.start();
|
||
await tick(1500);
|
||
assert(game2.phase === 'ask' && plays(audio2).has('voice/sys_continue_ask'), 'A13: 有存档重进播 sys_continue_ask');
|
||
g(game2, 'tap');
|
||
await tick(3000);
|
||
assert(game2.phase === 'explore' && game2.scene === 'village' && game2.player.lv === 3,
|
||
'A13: 单击继续落点 village、Lv3 保留');
|
||
assert(game2.stats.battlesWon === 4, 'A13: battlesWon 存档恢复(teach+bandits+elite+boss=4)');
|
||
}
|
||
|
||
// ================================ 场景 B:边界断言 ================================
|
||
|
||
/** 快速走到 stream 教学战(手势注入走真实路径)。 */
|
||
async function walkToTeachBattle(game) {
|
||
game.start();
|
||
await tick(60000); // 序章 + 进村
|
||
g(game, 'tap'); // village idx0 go_path
|
||
await tick(3000);
|
||
g(game, 'tap'); // path idx0 go_fork
|
||
await tick(3000);
|
||
g(game, 'right'); // fork → go_stream
|
||
await tick(1200);
|
||
g(game, 'tap'); // stream → 教学战
|
||
await tick(2000);
|
||
}
|
||
|
||
/** stream → fork → stream 重进(手势路径,触发 50% 野狼遭遇)。 */
|
||
async function reenterStream(game) {
|
||
g(game, 'right'); g(game, 'right'); await tick(2400); // stream: drink→go_hunter→back
|
||
g(game, 'tap'); await tick(3000); // fork
|
||
g(game, 'right'); await tick(1200); // go_stream
|
||
randQueue.push(0.1); // 强制遭遇命中
|
||
g(game, 'tap'); await tick(2000); // stream + 遭遇
|
||
}
|
||
|
||
/** 快速布置一场可逃跑的野狼战(钩子:tutorial_battle_done + 强制遭遇随机数)。 */
|
||
async function setupWolfBattle(game) {
|
||
game.start();
|
||
await tick(60000);
|
||
game.flags.tutorial_battle_done = true; // 测试钩子:跳过教学战触发
|
||
g(game, 'tap'); await tick(3000); // village → path
|
||
g(game, 'tap'); await tick(3000); // path → fork
|
||
g(game, 'right'); await tick(1200); // fork → go_stream
|
||
randQueue.push(0.1); // 强制 50% 遭遇命中
|
||
g(game, 'tap'); await tick(2000); // stream → enc_wolf
|
||
}
|
||
|
||
async function scenarioB1() {
|
||
origConsoleLog('\n=== 场景 B1:教学战看门狗兜底 + 锁血(全程不动) ===');
|
||
localStorageMock.clear();
|
||
const { game, audio } = newGame();
|
||
await walkToTeachBattle(game);
|
||
assert(game.phase === 'battle' && game._battle.teachStage === 'menu', 'B1: 进教学战 T0');
|
||
const bt = game._battle;
|
||
let minHp = game.player.hp;
|
||
const track = () => { minHp = Math.min(minHp, game.player.hp); };
|
||
|
||
// mock 语音 1s/句:教学链 7 句 → 进战约 +8s 进 PLAYER_SELECT 并 arm 看门狗。
|
||
// 用轮询等待教学步流转(对 ±1s 的链长误差不敏感)。
|
||
const waitStage = async (from, maxMs = 30000) => {
|
||
const t0 = vnow;
|
||
while (bt.teachStage === from && vnow - t0 < maxMs) { await tick(500); track(); }
|
||
};
|
||
await tick(9000); // 链播完、刚进 PLAYER_SELECT 不久
|
||
track();
|
||
assert(bt.teachStage === 'menu', 'B1: 看门狗计时内仍在 T0');
|
||
await waitStage('menu'); // 8s 重复提示 → 16s 兜底推进
|
||
assert(plays(audio).count('voice/tut_b0') >= 2, 'B1: 8s 无操作重复提示一次(tut_b0 重播)');
|
||
assert(bt.teachStage === 'attack', 'B1: 16s 无进展看门狗兜底 menu→attack');
|
||
await waitStage('attack');
|
||
assert(bt.teachStage === 'parry', 'B1: 看门狗兜底 attack→parry');
|
||
await waitStage('parry'); // 前摇计时器自然结算
|
||
assert(bt.teachStage === 'defend', 'B1: 前摇自然结算推进 parry→defend(受击不卡死)');
|
||
await waitStage('defend');
|
||
assert(bt.teachStage === null, 'B1: 看门狗兜底 defend→自由击杀');
|
||
assert(plays(audio).has('voice/tut_b5'), 'B1: 兜底路径也播 tut_b5 收尾');
|
||
assert(minHp >= 1, `B1: 全程不动 HP 锁底 1(最低 ${minHp})`);
|
||
// 补刀结束战斗
|
||
await battlePump(game, audio);
|
||
for (let i = 0; i < 8 && game.phase === 'battle'; i++) {
|
||
selectCmd(game, 'attack');
|
||
await tick(1200);
|
||
g(game, 'tap');
|
||
await tick(3000);
|
||
}
|
||
assert(game.phase === 'explore', 'B1: 看门狗路径下教学战可完成');
|
||
}
|
||
|
||
async function scenarioB1b() {
|
||
origConsoleLog('\n=== 场景 B1b:教学战乱按不死 ===');
|
||
localStorageMock.clear();
|
||
const { game } = newGame();
|
||
await walkToTeachBattle(game);
|
||
assert(game.phase === 'battle', 'B1b: 进教学战');
|
||
const GESTURES = ['tap', 'doubletap', 'left', 'right', 'up', 'down'];
|
||
let minHp = game.player.hp;
|
||
let n = 0;
|
||
while (game.phase === 'battle' && n < 120) {
|
||
g(game, GESTURES[Math.floor(Math.random() * GESTURES.length)]);
|
||
n++;
|
||
minHp = Math.min(minHp, game.player.hp);
|
||
await tick(700);
|
||
}
|
||
assert(minHp >= 1, `B1b: 乱按 ${n} 次 HP 锁底 1(最低 ${minHp})`);
|
||
ok(`B1b: 乱按 ${n} 次无异常,phase=${game.phase}`);
|
||
}
|
||
|
||
async function scenarioB2() {
|
||
origConsoleLog('\n=== 场景 B2:逃跑规则(Lv<3 必成 ×10 / Lv≥3 失败可格挡 / Boss 不可逃) ===');
|
||
localStorageMock.clear();
|
||
const { game, audio } = newGame();
|
||
await setupWolfBattle(game);
|
||
assert(game.phase === 'battle' && game._battle.encId === 'enc_wolf', 'B2: 野狼战布置完成');
|
||
|
||
// Lv<3 必成 ×10(_wolfDone 仅胜利置位,重进 stream 可再遇)
|
||
let fleeOk = 0;
|
||
for (let i = 0; i < 10; i++) {
|
||
if (game.phase !== 'battle') await reenterStream(game);
|
||
if (game.phase !== 'battle') break;
|
||
await battlePump(game, audio);
|
||
g(game, 'down'); // 逃跑
|
||
await tick(2500);
|
||
if (game.phase === 'explore') fleeOk++;
|
||
}
|
||
assert(fleeOk === 10, `B2: Lv<3 普通战逃跑 10/10 必成(实际 ${fleeOk}/10)`);
|
||
assert(plays(audio).count('voice/btl_flee') >= 10, 'B2: btl_flee 播报 10 次');
|
||
|
||
// Lv≥3 逃跑失败 → 敌方正常前摇回合(可格挡)
|
||
game.player.lv = 3; // 测试钩子
|
||
await reenterStream(game);
|
||
assert(game.phase === 'battle' && game._battle.encId === 'enc_wolf', 'B2: Lv3 野狼战布置完成');
|
||
await battlePump(game, audio);
|
||
const mark = audio.log.length;
|
||
randQueue.push(0.99); // 强制 70% 判定失败
|
||
g(game, 'down');
|
||
await tick(2000);
|
||
assert(plays(audio, mark).has('voice/btl_flee_fail'), 'B2: Lv3 逃跑失败播 btl_flee_fail');
|
||
assert(game._battle && game._battle.state === 'ENEMY_TELEGRAPH', 'B2: 逃跑失败 → 敌方正常前摇回合');
|
||
const parriesBefore = game.stats.parries;
|
||
g(game, 'doubletap'); // 前摇内双击 = 可格挡(不白打)
|
||
await tick(500);
|
||
assert(game.stats.parries === parriesBefore + 1, 'B2: 逃跑失败后的前摇可格挡(stats.parries +1)');
|
||
// 再逃:强制成功
|
||
await battlePump(game, audio);
|
||
randQueue.push(0.0);
|
||
g(game, 'down');
|
||
await tick(2500);
|
||
assert(game.phase === 'explore', 'B2: Lv3 逃跑 70% 判定可成功(rand=0)');
|
||
|
||
// Boss 战下滑 = btl_noflee
|
||
const { game: g2, audio: a2 } = newGame();
|
||
g2.start();
|
||
await tick(2000);
|
||
g2._startBattle('enc_boss1'); // 测试钩子:直接进 Boss 战
|
||
await battlePump(g2, a2);
|
||
const vibBefore = vibrateLog.length;
|
||
g(g2, 'down');
|
||
await tick(2000);
|
||
assert(plays(a2).has('voice/btl_noflee'), 'B2: Boss 战下滑 → btl_noflee');
|
||
assert(vibratedError(vibBefore), 'B2: 不可逃震 error');
|
||
assert(g2.phase === 'battle' && g2._battle.state === 'PLAYER_SELECT', 'B2: btl_noflee 后回玩家回合');
|
||
}
|
||
|
||
async function scenarioB3() {
|
||
origConsoleLog('\n=== 场景 B3:格挡/防御减伤与窗口外双击 ===');
|
||
localStorageMock.clear();
|
||
const { game, audio } = newGame();
|
||
await setupWolfBattle(game);
|
||
const b = game._battle;
|
||
game.stats.battlesWon = 5; // 钩子:标准档(前摇 1.0s、×1.0)
|
||
b.tier = { telSec: 1.0, dmgMul: 1.0 };
|
||
game._dmg = (a, d) => Math.max(1, a - d); // 伤害确定化:狼 atk4 → base 4
|
||
await battlePump(game, audio);
|
||
|
||
// 窗口外双击(PLAYER_SELECT 阶段):被忽略,不影响状态不震动
|
||
const vib0 = vibrateLog.length;
|
||
g(game, 'doubletap');
|
||
assert(b.state === 'PLAYER_SELECT' && vibrateLog.length === vib0, 'B3: PLAYER_SELECT 双击被忽略');
|
||
|
||
// 进攻一回合进前摇(_playerExecute → _checkEnemyDead → _enemyTelegraph 同步发生)
|
||
selectCmd(game, 'attack');
|
||
await tick(1200);
|
||
g(game, 'tap');
|
||
assert(b.state === 'ENEMY_TELEGRAPH', 'B3: 进入敌方前摇');
|
||
assert(Math.abs(b.windowEnd - vnow - 1000) < 50, 'B3: 标准档前摇 1.0s');
|
||
await tick(500);
|
||
|
||
// 前摇内做错手势 → error 震动留在窗口
|
||
const vib1 = vibrateLog.length;
|
||
g(game, 'tap');
|
||
assert(vibratedError(vib1), 'B3: 前摇内错手势震 error');
|
||
assert(b.state === 'ENEMY_TELEGRAPH' && !b.parry, 'B3: 错手势留在窗口');
|
||
|
||
// 窗口内双击格挡 → 伤害 ×0.3(base 4 → round(1.2)=1)
|
||
const hpBefore = game.player.hp;
|
||
g(game, 'doubletap');
|
||
await tick(300);
|
||
assert(lastEnemyDmg() === 1 && game.player.hp === hpBefore - 1,
|
||
`B3: 格挡成功伤害 ×0.3(base 4 → ${lastEnemyDmg()},期望 1)`);
|
||
|
||
// 下一回合:防御 → 伤害 ×0.5(base 4 → 2)
|
||
await battlePump(game, audio);
|
||
selectCmd(game, 'defend');
|
||
await tick(1200);
|
||
g(game, 'tap');
|
||
const hp2 = game.player.hp;
|
||
await tick(2500);
|
||
assert(lastEnemyDmg() === 2 && game.player.hp === hp2 - 2,
|
||
`B3: 防御当回合伤害 ×0.5(base 4 → ${lastEnemyDmg()},期望 2)`);
|
||
|
||
// 窗口过期后双击 → error 震动不生效(不判格挡)
|
||
await battlePump(game, audio);
|
||
b.enemy.hp = 10; // 钩子:回满狼血,保证这一刀不死(前两刀已打掉 10→5)
|
||
selectCmd(game, 'attack');
|
||
await tick(1200);
|
||
g(game, 'tap');
|
||
assert(b.state === 'ENEMY_TELEGRAPH', 'B3: 再次进入前摇');
|
||
b.windowEnd = vnow - 1; // 钩子:窗口已过期(结算计时器尚未触发)
|
||
const vib2 = vibrateLog.length;
|
||
g(game, 'doubletap');
|
||
assert(!b.parry && vibratedError(vib2), 'B3: 窗口外双击 → error 震动且不判格挡');
|
||
await tick(2500);
|
||
}
|
||
|
||
async function scenarioB4() {
|
||
origConsoleLog('\n=== 场景 B4:战败重生 ===');
|
||
localStorageMock.clear();
|
||
const { game, audio } = newGame();
|
||
await setupWolfBattle(game);
|
||
game.flags.enc_bandits_done = true; // 钩子:验证重生重置非 Boss 遭遇
|
||
game.player.gold = 77;
|
||
game.player.hp = 1;
|
||
game._dmg = (a, d) => Math.max(1, a - d);
|
||
await battlePump(game, audio);
|
||
const mark = audio.log.length;
|
||
selectCmd(game, 'attack');
|
||
await tick(1200);
|
||
g(game, 'tap');
|
||
await tick(2500); // 受击 dmg≥2 > hp1 → 战败
|
||
await tick(8000); // btl_lose + btl_lose_hint 链 → 重生
|
||
assert(plays(audio, mark).orderOk('voice/btl_lose', 'voice/btl_lose_hint'), 'B4: 战败语音链');
|
||
assert(game.phase === 'explore' && game.scene === 'village', 'B4: 战败回 village 重生');
|
||
assert(game.player.hp === game.maxHp && game.player.gold === 77, 'B4: 重生满血、金币保留');
|
||
assert(game.flags.enc_bandits_done === false, 'B4: 非 Boss 遭遇 flags 重置');
|
||
assert(game.stats.deaths === 1, 'B4: stats.deaths +1');
|
||
const stop = audio.log.filter((e) => e.id === '(stop)').at(-1);
|
||
assert(stop && stop.opts.fadeOutMs === 2000, 'B4: 战败战斗 BGM 2s 淡出');
|
||
}
|
||
|
||
async function scenarioB5() {
|
||
origConsoleLog('\n=== 场景 B5:语音屏蔽看门狗(资产缺失不卡死) ===');
|
||
localStorageMock.clear();
|
||
const { game, audio } = newGame();
|
||
game.start();
|
||
await tick(60000);
|
||
audio.blocked.add('voice/desc_path'); // 屏蔽资产:onComplete 永不触发
|
||
g(game, 'tap'); // village → path(desc_path 被屏蔽)
|
||
await tick(25000); // 看门狗 20s 兜底
|
||
assert(game.phase === 'explore' && game.scene === 'path', 'B5: desc 被屏蔽仍到达 path 探索态');
|
||
assert(plays(audio).has('voice/opt_go_fork'), 'B5: 看门狗兜底后选项播报照常(流程不卡死)');
|
||
}
|
||
|
||
async function scenarioB6() {
|
||
origConsoleLog('\n=== 场景 B6:状态链跳过 + Lv3 到寨门无 boss1_warn ===');
|
||
localStorageMock.clear();
|
||
const { game, audio } = newGame();
|
||
game.start();
|
||
await tick(60000);
|
||
// 状态链逐句跳过
|
||
g(game, 'up');
|
||
const skips0 = audio.skipCount;
|
||
g(game, 'tap'); await tick(300);
|
||
g(game, 'tap'); await tick(300);
|
||
assert(audio.skipCount >= skips0 + 2, 'B6: 状态播报链 st_* 可逐句跳过(skipVoice ×2)');
|
||
await tick(10000); // 链自然走完不卡死
|
||
assert(plays(audio).has('voice/st_armor_none'), 'B6: 跳过后状态链走完(st_armor_none 已播)');
|
||
// Lv3 到寨门不播 boss1_warn
|
||
game.player.lv = 3; // 钩子
|
||
game.scene = 'deepwood';
|
||
const mark = audio.log.length;
|
||
game._enterScene('gate'); // 钩子:直接进 gate(手势路径已在 A11 验证)
|
||
await tick(3000);
|
||
assert(!plays(audio, mark).has('voice/boss1_warn'), 'B6: Lv3 到寨门不播 boss1_warn');
|
||
}
|
||
|
||
async function scenarioB7() {
|
||
origConsoleLog('\n=== 场景 B7:Boss 战全格挡擦伤上限(round1-live P1) ===');
|
||
localStorageMock.clear();
|
||
const { game, audio } = newGame();
|
||
game.start();
|
||
await tick(2000);
|
||
// 设计标准出装(11 文档 §3 数值表):Lv3 + 铁剑 + 皮甲,HP36
|
||
game.player.lv = 3;
|
||
game.player.hp = game.maxHp;
|
||
game.player.weapon = 'iron_sword';
|
||
game.player.armor = 'leather';
|
||
game._startBattle('enc_boss1'); // 钩子:直接进 Boss 战
|
||
await battlePump(game, audio);
|
||
const mark = audio.log.length;
|
||
const logsBefore = consoleLogs.length;
|
||
await fightSmart(game, audio, { maxRounds: 30 }); // 全格挡打法(每次前摇内双击,不喝药)
|
||
assert(game.flags.boss1_done === true, 'B7: 全格挡可击杀 Boss(章节 flags 置位)');
|
||
const dmgs = consoleLogs.slice(logsBefore)
|
||
.map((l) => l.match(/敌方攻击 dmg=(\d+) 格挡=(true|false)/))
|
||
.filter(Boolean);
|
||
assert(dmgs.length >= 3, `B7: Boss 战发生多次敌方攻击(${dmgs.length} 次)`);
|
||
assert(dmgs.every((m) => m[2] === 'true'), 'B7: 全部敌方攻击均被格挡(fightSmart 全双击)');
|
||
assert(dmgs.every((m) => Number(m[1]) >= 1 && Number(m[1]) <= 3),
|
||
`B7: 格挡擦伤全部 ∈[1,3](11 文档 §3「格挡成功 2-3 伤」+ 钳顶,实测 ${dmgs.map((m) => m[1]).join(',')})`);
|
||
assert(!plays(audio, mark).has('voice/st_hp_low'),
|
||
'B7: 全格挡 Boss 战不播 st_hp_low(血量播报与实际战况匹配)');
|
||
assert(game.player.hp / game.maxHp >= 0.3,
|
||
`B7: 全格挡战后血量不掉 low 档(${game.player.hp}/${game.maxHp})`);
|
||
// 确定性探针:base 12 的一刀被格挡 → 擦伤恒为 3(原实现 round(12×0.3)=4 超设计上限)
|
||
await battlePump(game, audio); // 章结算链走完回村
|
||
game._dmg = () => 12; // 钩子:伤害确定化
|
||
game._startBattle('enc_boss1');
|
||
await battlePump(game, audio, 5000);
|
||
g(game, 'tap'); // 攻击(敌 45→33)
|
||
g(game, 'doubletap'); // 前摇内双击格挡
|
||
await tick(500);
|
||
assert(lastEnemyDmg() === 3, `B7: 擦伤钳顶确定性探针(base 12 格挡 → ${lastEnemyDmg()},期望 3)`);
|
||
}
|
||
|
||
async function scenarioB8() {
|
||
origConsoleLog('\n=== 场景 B8:闲置提示与结算语音不互掐 + 返回项末位提示(round1-live P2/P3,guide=full) ===');
|
||
localStorageMock.clear();
|
||
settings.set({ guide: 'full' });
|
||
try {
|
||
const { game, audio } = newGame();
|
||
const guideEvents = [];
|
||
game.guide.setEventHook((e) => guideEvents.push(e));
|
||
await setupWolfBattle(game);
|
||
await battlePump(game, audio, 5000);
|
||
const b = game._battle;
|
||
assert(b && b.state === 'PLAYER_SELECT', 'B8: 野狼战进玩家回合');
|
||
// 对照组:无闲置提示在播时击杀 → btl_enemy_die 不排队(默认顶句语义不变)
|
||
b.enemy.hp = 1; // 钩子:一刀死
|
||
g(game, 'tap');
|
||
await tick(500);
|
||
const diePlain = audio.log.filter((e) => e.id === 'voice/btl_enemy_die').at(-1);
|
||
assert(diePlain && !diePlain.opts.queue, 'B8: 无闲置提示在播时击杀语音不排队(对照)');
|
||
await battlePump(game, audio); // 胜利链走完回探索
|
||
assert(game.phase === 'explore', 'B8: 首狼击杀回探索态');
|
||
// 第二只狼:PLAYER_SELECT 闲置 30s → gd_stall 开播 → 立即出手
|
||
game._wolfDone = false; // 钩子:允许再次遇狼
|
||
await reenterStream(game);
|
||
await battlePump(game, audio, 5000);
|
||
const b2 = game._battle;
|
||
assert(b2 && b2.state === 'PLAYER_SELECT', 'B8: 第二只狼进玩家回合');
|
||
guideEvents.length = 0;
|
||
let waited = 0;
|
||
while (!guideEvents.some((e) => e.type === 'watchdog' && e.voiceId === 'gd_stall' && !e.skipped)
|
||
&& waited < 45000) { await tick(500); waited += 500; }
|
||
assert(waited < 45000, 'B8: 闲置 30s 看门狗播 gd_stall(未跳过)');
|
||
assert(audio._voice && audio._voice.id === 'voice/gd_stall', 'B8: gd_stall 正在播');
|
||
b2.enemy.hp = 1; // 钩子:一刀死
|
||
g(game, 'tap'); // 结算链首句应排到 gd_stall 之后播完,不拦腰顶断
|
||
await tick(300);
|
||
const dieQueued = audio.log.filter((e) => e.id === 'voice/btl_enemy_die').at(-1);
|
||
assert(dieQueued && dieQueued.opts.queue === true,
|
||
'B8: gd_stall 开播中结算语音排队等播完(queue:true,不顶断)');
|
||
await battlePump(game, audio); // 胜利回探索
|
||
// P3:探索态右滑落到"返回"项 → 报名后接末位提示(每场景一次)
|
||
g(game, 'right'); await tick(1200); // go_hunter
|
||
g(game, 'tap'); await tick(3000); // 猎人小屋
|
||
const m3 = audio.log.length;
|
||
g(game, 'right'); await tick(1200); // go_deepwood
|
||
g(game, 'right'); await tick(1200); // back(末项)
|
||
assert(plays(audio, m3).orderOk('voice/opt_back', 'voice/gd_wrap_last'),
|
||
'B8: 落到"返回"项 → 报名 opt_back 后接末位提示 gd_wrap_last(每场景一次)');
|
||
g(game, 'left'); await tick(1200);
|
||
g(game, 'right'); await tick(1200); // 再次落到 back
|
||
assert(plays(audio, m3).count('voice/gd_wrap_last') === 1,
|
||
'B8: 同一场景内末位提示不重复(_wrapShown 去重)');
|
||
} finally {
|
||
settings.set({ guide: 'off' }); // 复位:其余场景契约是 guide=off
|
||
}
|
||
}
|
||
|
||
async function scenarioB9() {
|
||
origConsoleLog('\n=== 场景 B9:指令名去重防口吃(round1-live P2「攻攻击。」) ===');
|
||
localStorageMock.clear();
|
||
const { game, audio } = newGame();
|
||
await setupWolfBattle(game);
|
||
await battlePump(game, audio, 5000);
|
||
const b = game._battle;
|
||
assert(b && b.state === 'PLAYER_SELECT', 'B9: 野狼战进玩家回合');
|
||
// 折返滑 + 滞留 650ms 定时器:指令名不双播
|
||
game._playerSelect(); // 钩子:回合重进(650ms 指令名定时器挂载中)
|
||
const m1 = audio.log.length;
|
||
g(game, 'right'); g(game, 'left'); // 攻击→破空斩→攻击(<650ms 折返)
|
||
await tick(900);
|
||
assert(plays(audio, m1).count('voice/cmd_attack') === 1,
|
||
'B9: 折返滑回攻击+滞留定时器 → cmd_attack 不双播(1.2s 去重跳过本次)');
|
||
// 相邻两回合连进:旧回合定时器作废
|
||
await tick(1500); // 让过去重窗口,隔离下一段
|
||
game._playerSelect();
|
||
const m2 = audio.log.length;
|
||
game._playerSelect(); // 钩子:相邻两回合连进(旧回合 650ms 定时器滞留)
|
||
await tick(900);
|
||
assert(plays(audio, m2).count('voice/cmd_attack') === 1,
|
||
'B9: 连进两回合 → 旧回合指令名定时器作废(cmd_attack 只播一次)');
|
||
}
|
||
|
||
async function scenarioB10() {
|
||
origConsoleLog('\n=== 场景 B10:受伤播报只挂实质受击 + 档位一致 + 同档节流(round2-live P1) ===');
|
||
localStorageMock.clear();
|
||
const { game, audio } = newGame();
|
||
await setupWolfBattle(game);
|
||
game.stats.battlesWon = 5; // 钩子:标准档(前摇 1.0s、×1.0)
|
||
game._battle.tier = { telSec: 1.0, dmgMul: 1.0 };
|
||
game._dmg = (a, d) => Math.max(1, a - d); // 伤害确定化:狼 atk4 → 实击 4、擦伤 1
|
||
await battlePump(game, audio, 5000);
|
||
const b = game._battle;
|
||
assert(b && b.state === 'PLAYER_SELECT', 'B10: 野狼战进玩家回合');
|
||
game.player.hp = 15; // 钩子:15/20=0.75 高档,一刀擦伤即跨 0.7 边界
|
||
b.enemy.hp = 99; // 钩子:全程打不死,专测受击播报
|
||
assert(b.hpBandVoice === 'high', 'B10: 开战"最后被告知档"=当前实际档(high)');
|
||
const mark = audio.log.length;
|
||
const attackRound = async (parry) => {
|
||
selectCmd(game, 'attack');
|
||
await tick(1200);
|
||
g(game, 'tap');
|
||
if (parry) g(game, 'doubletap'); // 前摇内即时双击格挡
|
||
await tick(2200); // 前摇 1.0s 结算 + 可能的降档句(1s)+ 回合交接
|
||
};
|
||
const itemRound = async () => {
|
||
selectCmd(game, 'item');
|
||
await tick(1200);
|
||
g(game, 'tap');
|
||
await tick(2200); // 喝药 + 敌方一个前摇回合(硬吃)
|
||
};
|
||
// ① 格挡擦伤跨档(15→14,0.70 跌进 mid):静默走血,不播任何受伤播报
|
||
await attackRound(true);
|
||
assert(game.player.hp === 14, `B10: 格挡擦伤 15→14(实际 ${game.player.hp})`);
|
||
assert(plays(audio, mark).count('voice/st_hp_mid') === 0 && plays(audio, mark).count('voice/st_hp_low') === 0,
|
||
'B10: 擦伤跨档不触发"受伤"播报(addendum §1:st_hp_* 是受击附属,非血量全局钩子)');
|
||
assert(b.hpBandVoice === 'high', 'B10: 擦伤静默走血不改写"最后被告知档"');
|
||
// ② 实质受击降档(14→10,mid):播当前实际档 st_hp_mid(不多不少)
|
||
await attackRound(false);
|
||
assert(game.player.hp === 10 && plays(audio, mark).count('voice/st_hp_mid') === 1,
|
||
'B10: 实质受击降档 → 播 st_hp_mid(与实际档位一致)');
|
||
assert(plays(audio, mark).count('voice/st_hp_low') === 0 && b.hpBandVoice === 'mid',
|
||
'B10: 播报档不高估(mid 不喊 low),"最后被告知档"同步为 mid');
|
||
// ③ 同档再受击(10→6,0.30 仍 mid):不重复喊
|
||
await attackRound(false);
|
||
assert(game.player.hp === 6 && plays(audio, mark).count('voice/st_hp_mid') === 1,
|
||
'B10: 同档再受击不重复播(节流:mid 仍 1 次)');
|
||
// ④ 再受击跌 low(6→2,0.10):播 st_hp_low
|
||
await attackRound(false);
|
||
assert(game.player.hp === 2 && plays(audio, mark).count('voice/st_hp_low') === 1 && b.hpBandVoice === 'low',
|
||
'B10: 实质受击跌 low → 播 st_hp_low(命悬一线与实际档位一致)');
|
||
// ⑤ 同档再受击(钩子回血到 5 → 1,仍 low):不重复喊
|
||
game.player.hp = 5;
|
||
await attackRound(false);
|
||
assert(game.player.hp === 1 && plays(audio, mark).count('voice/st_hp_low') === 1,
|
||
'B10: low 档内再受击不重复播(节流:low 仍 1 次)');
|
||
// ⑥ 喝药回血(1→9)后硬吃一刀(9→5,回 low):与"最后被告知档"同档 → 不重播
|
||
game.player.potions = 3; // 钩子:药管够
|
||
await itemRound();
|
||
assert(game.player.hp === 5 && plays(audio, mark).count('voice/st_hp_low') === 1,
|
||
'B10: 回血后一刀直接跌回 low(无中间档):同档不重播(low 仍 1 次)');
|
||
// ⑦ 再喝药(5→13)硬吃一刀(13→9,mid):升档静默同步;再受击(9→5)跌 low = 新降档 → 重播一次
|
||
await itemRound();
|
||
assert(game.player.hp === 9 && plays(audio, mark).count('voice/st_hp_mid') === 1 && b.hpBandVoice === 'mid',
|
||
'B10: 回血升档静默(mid 不重播),"最后被告知档"经实击同步回 mid');
|
||
await attackRound(false);
|
||
assert(game.player.hp === 5 && plays(audio, mark).count('voice/st_hp_low') === 2 && b.hpBandVoice === 'low',
|
||
'B10: 经中间档再次跌破 low = 新降档,重播一次 st_hp_low(不属"狼来了")');
|
||
}
|
||
|
||
async function scenarioB11() {
|
||
origConsoleLog('\n=== 场景 B11:探索菜单语音播放中单击=跳句+补发确认(round2-live P1) ===');
|
||
localStorageMock.clear();
|
||
const { game, audio } = newGame();
|
||
game.start();
|
||
await tick(60000); // 序章+进村,选项报名已播完
|
||
// ① 选项报名在播中单击 → 跳句 + 执行当前选项(live 1264.6s 证据场景)
|
||
g(game, 'right'); // 切到 shop:opt_shop 开播(mock 1s/句)
|
||
assert(audio._voice && audio._voice.id === 'voice/opt_shop', 'B11: 选项报名在播(opt_shop)');
|
||
const skips0 = audio.skipCount;
|
||
g(game, 'tap');
|
||
assert(audio.skipCount === skips0 + 1, 'B11: 报名在播中单击触发跳句(skipVoice)');
|
||
assert(game.phase === 'shop', 'B11: 跳句同时补发确认语义:进商店(动作不石沉大海)');
|
||
// ② desc 链在播中单击 → 只跳句不确认(12 文档 §1 跳句惯例不回退)
|
||
g(game, 'down'); await tick(2000); // 离店回探索(选项报名播完)
|
||
g(game, 'left'); await tick(1200); // 切回 go_path
|
||
g(game, 'tap'); // 确认 go_path → desc_path 开播
|
||
assert(game.scene === 'path' && audio._voice, 'B11: 确认 go_path 进 path(desc 在播)');
|
||
const skips1 = audio.skipCount;
|
||
g(game, 'tap');
|
||
assert(audio.skipCount === skips1 + 1 && game.scene === 'path',
|
||
'B11: desc 在播中单击=只跳句不确认(场景不变,A5 契约不回退)');
|
||
await tick(3000); // 链走完 → 选项报名
|
||
// ③ 边界提示在播中单击"返回" → 跳句+执行返回(live 1370.1s 证据场景,guide=full)
|
||
settings.set({ guide: 'full' });
|
||
try {
|
||
g(game, 'left'); await tick(1200); // path 首项左滑反绕到末项 back → opt_back 播完接 gd_wrap_last
|
||
assert(audio._voice && audio._voice.id === 'voice/gd_wrap_last', 'B11: 边界提示 gd_wrap_last 在播');
|
||
const skips2 = audio.skipCount;
|
||
g(game, 'tap');
|
||
assert(audio.skipCount === skips2 + 1 && game.scene === 'village',
|
||
'B11: 边界提示在播中单击"返回"=跳句+执行返回(回 village,动作不丢)');
|
||
} finally {
|
||
settings.set({ guide: 'off' }); // 复位:其余场景契约是 guide=off
|
||
}
|
||
}
|
||
|
||
async function scenarioB12() {
|
||
origConsoleLog('\n=== 场景 B12:教学自动轮播"播完再上"不互掐(round2-live P2) ===');
|
||
localStorageMock.clear();
|
||
const { game, audio } = newGame();
|
||
await walkToTeachBattle(game);
|
||
const b = game._battle;
|
||
// 等教学链播完进 PLAYER_SELECT(T0 menu,看门狗挂载点)
|
||
let waited = 0;
|
||
while (!(b.state === 'PLAYER_SELECT' && b.teachStage === 'menu') && waited < 30000) { await tick(500); waited += 500; }
|
||
assert(b.state === 'PLAYER_SELECT' && b.teachStage === 'menu', 'B12: 教学 T0 进玩家回合(看门狗已挂载)');
|
||
await tick(2500); // 让 650ms 指令名定时器播完(隔离声道,避免顶掉占用钩子)
|
||
// 声道占用钩子:blocked 的 voice 永不触发 onComplete(模拟"上一句仍在播")
|
||
audio.blocked.add('voice/tut_b5');
|
||
audio.play('voice/tut_b5');
|
||
await tick(9000); // 越过 8s 重复提示触发点
|
||
assert(plays(audio).count('voice/tut_b0') === 1,
|
||
'B12: 语音在播时 8s 重复提示跳过(自动轮播不顶句,tut_b0 仍 1 次)');
|
||
await tick(8000); // 越过 16s 兜底推进点
|
||
assert(b.teachStage === 'menu' && plays(audio).count('voice/tut_battle_2') === 1,
|
||
'B12: 语音在播时 16s 兜底延后推进(不拦腰斩句,live 333.09s 互掐场景)');
|
||
const tFree = vnow;
|
||
audio.stopVoice(); // 模拟在播句播完(声道空出)
|
||
await tick(2500); // 1.5s 重试间隔内应推进
|
||
assert(b.teachStage === 'attack', 'B12: 声道空出后兜底推进 menu→attack(延后不死锁)');
|
||
const tAdv = audio.log.filter((e) => e.id === 'voice/tut_battle_2').at(-1).t;
|
||
assert(tAdv >= tFree, `B12: 推进语音等声道空出才开播("播完再上",${tAdv} ≥ ${tFree})`);
|
||
}
|
||
|
||
async function scenarioB13() {
|
||
origConsoleLog('\n=== 场景 B13:杀敌结算语音不腰斩刚开播的指令名(round2-live P2) ===');
|
||
localStorageMock.clear();
|
||
const { game, audio } = newGame();
|
||
await setupWolfBattle(game);
|
||
await battlePump(game, audio, 5000);
|
||
const b = game._battle;
|
||
assert(b && b.state === 'PLAYER_SELECT', 'B13: 野狼战进玩家回合');
|
||
game._playCmdVoice(); // 钩子:指令名刚开播(复现 live 843.9s:「攻击。」播到一半出手)
|
||
assert(audio._voice && audio._voice.id === 'voice/cmd_attack', 'B13: 指令名在播');
|
||
b.enemy.hp = 1; // 钩子:一刀死
|
||
g(game, 'tap');
|
||
await tick(300);
|
||
const die = audio.log.filter((e) => e.id === 'voice/btl_enemy_die').at(-1);
|
||
assert(die && die.opts.queue === true,
|
||
'B13: 指令名开播 1.6s 内击杀语音 queue 排队等播完(不拦腰斩句)');
|
||
await battlePump(game, audio);
|
||
assert(game.phase === 'explore', 'B13: 排队后胜利链正常走完回探索(不死锁)');
|
||
}
|
||
|
||
// ================================ 资产引用校验 ================================
|
||
|
||
function assetAudit() {
|
||
origConsoleLog('\n=== 资产引用校验 ===');
|
||
// 1) 运行期播放/加载过的全部 id 必须存在
|
||
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(',') || '无'}(${audioRegistry.reduce((n, a) => n + a.log.length, 0)} 条播放日志全绿)`);
|
||
|
||
// 2) game.js 静态引用的 id 全量展开校验(模板 id 按数据表展开)
|
||
const src = fs.readFileSync(path.join(RPG_DIR, 'game.js'), 'utf8');
|
||
const staticIds = new Set();
|
||
for (const m of src.matchAll(/'((?:voice|sfx|bgm)\/[^'`${]+)'/g)) staticIds.add(m[1]);
|
||
// 模板 id 展开(与 game.js 数据表一一对应)
|
||
const OPTIONS = ['go_path', 'shop', 'rest', 'status', 'go_fork', 'investigate', 'back',
|
||
'go_chest', 'go_stream', 'drink', 'go_hunter', 'talk_hunter', 'go_deepwood', 'go_gate',
|
||
'assault', 'sneak', 'open_chest', 'leave'];
|
||
for (const o of OPTIONS) staticIds.add(`voice/opt_${o}`);
|
||
for (const s of ['village', 'path', 'fork', 'chest', 'stream', 'hunter', 'deepwood', 'gate']) {
|
||
staticIds.add(`voice/desc_${s}`);
|
||
staticIds.add(`sfx/amb_${s}`);
|
||
// gate/boss1 按设计无 _re 回访句(game.js 回退全句 desc_gate),不列入校验
|
||
if (s !== 'gate') staticIds.add(`voice/desc_${s}_re`);
|
||
}
|
||
staticIds.add('voice/desc_hall');
|
||
staticIds.add('sfx/amb_hall');
|
||
for (const band of ['high', 'mid', 'low']) {
|
||
staticIds.add(`voice/st_hp_${band}`);
|
||
staticIds.add(`voice/foe_hp_${band}`);
|
||
}
|
||
for (const c of ['attack', 'skill', 'defend', 'item']) staticIds.add(`voice/cmd_${c}`);
|
||
for (const m of ['sys_menu_resume', 'sys_menu_save', 'sys_menu_help']) staticIds.add(`voice/${m}`);
|
||
for (const w of ['none', 'iron', 'steel', 'wind']) staticIds.add(`voice/st_weapon_${w}`);
|
||
for (const a of ['none', 'leather', 'iron']) staticIds.add(`voice/st_armor_${a}`);
|
||
for (const l of ['low', 'mid', 'high']) staticIds.add(`voice/st_lv_${l}`);
|
||
for (const gd of ['poor', 'some', 'rich']) staticIds.add(`voice/st_gold_${gd}`);
|
||
const badStatic = [...staticIds].filter((id) => !assetExists(id));
|
||
assert(badStatic.length === 0, `资产校验: 静态引用缺失 ${badStatic.join(',') || '无'}`);
|
||
ok(`资产校验: 静态 ${staticIds.size} id + 运行期日志全量双格式(ogg+m4a)齐全`);
|
||
}
|
||
|
||
// ================================ 主流程 ================================
|
||
|
||
try {
|
||
await scenarioA();
|
||
await scenarioB1();
|
||
await scenarioB1b();
|
||
await scenarioB2();
|
||
await scenarioB3();
|
||
await scenarioB4();
|
||
await scenarioB5();
|
||
await scenarioB6();
|
||
await scenarioB7();
|
||
await scenarioB8();
|
||
await scenarioB9();
|
||
await scenarioB10();
|
||
await scenarioB11();
|
||
await scenarioB12();
|
||
await scenarioB13();
|
||
} 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);
|