Files
SoundGame/web/rpg/test/playtest.mjs
T
xsl 566241e05d feat(platform/P1): 配对码跨设备 + 认领打磨 + 键盘语音双套资产(两 agent 并行 + 集成收口)
配对码与认领(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 在途)
2026-09-10 00:43:23 +08:00

1195 lines
57 KiB
JavaScript
Raw Blame History

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