Files
SoundGame/web/rpg/test/playtest.mjs
T
SoundGame Dev 79dffac01e guide层v1: 全局向导交互层落地(状态签名earcon/turn cue/静默看门狗/纠错) + 6游戏接线 + 8句向导语音 + 测试全绿
- engine/guide.js 运行时合成8个签名earcon(方法论L2硬参数),off档零定时器,插播queue不顶对白
- settings: guide三档(full/standard/off默认full) + 语速2.2视障档;audio: isVoicePlaying()/setRateBoostEnabled() 清两个已知问题
- 6游戏按schemes§2接线: storyline看门狗+无效手势纠错、simon免扣重听看门狗、escape turn cue+提前卡关提示、rhythm gd_answer/gd_miss+结算看门狗+快进豁免、space首撞指令提示+结算看门狗、rpg格挡纠错+60s催促+状态签名
- 大厅设置面板+向导文档(14-guide-layer.md)+00-global-ux §6;R5台词表补4条一致性
- 试玩转写含向导事件;6 playtest(guide=off断言零改动)+guide.test(34断言)+资产终检全绿
2026-08-30 22:46:05 +08:00

911 lines
41 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', {
value: { vibrate: (p) => { vibrateLog.push({ t: vnow, p }); return true; } },
configurable: true,
});
globalThis.window = {
addEventListener: () => {},
location: { href: '' },
};
globalThis.document = {
getElementById: () => null,
createElement: () => ({ style: { setProperty: () => {} }, className: '', offsetWidth: 0 }),
body: { appendChild: () => {} },
};
const origConsoleError = console.error.bind(console);
console.error = (...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 步
g(game, 'right'); g(game, 'left');
await tick(1500);
assert(bt.teachStage === 'attack' && plays(audio, mark).has('voice/tut_b3'), 'A8: T0 切换 2 次 → T1 attack(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(2500); // tut_b4+tut_b6 播完进 PLAYER_SELECT
assert(plays(audio, mark).orderOk('voice/tut_b4', 'voice/tut_b6'), 'A8: 格挡失败反馈 tut_b4 + 防御教学 tut_b6');
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(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');
}
// ================================ 资产引用校验 ================================
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();
} 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);