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

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

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

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

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

1061 lines
53 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 《剑与声》R4 无头试玩测试(26 号文档深度重构版,docs/design/26-apply-rpg.md §10)。
*
* 以玩家身份模拟:真实 WebAudioEngine + 真实 makeInputPipeline(与 index.html 同构),
* mock 浏览器环境(window/navigator/localStorage/fetch/虚拟时钟)。voice 资产按可用
* 处理(统一 2s 句长,流程与 TTS 落盘解耦);sfx/bgm 读真实文件;手势经管道注入。
*
* 覆盖:
* - §4.10 首 3 分钟脚本:critical 开场指令、3 秒内动手、10 秒内第一次成功;
* - §4.1 发言权:SPEAKING/YOUR_TURN/ACTION 三态(旁白链跨句、等待点、前摇窗口);
* - §4.2/§4.3:回执三件套、防连点、双击折算、up/down 阶梯锁 + 首次解释;
* - §4.4 三档提示与台账降档;§4.5 迷茫接管 L1;
* - 玩法断言(保留适配):第一章全链(教学战四步/格挡=窗口单击/升级/存档 summary/
* PUT 同步/断点继续)、格挡与防御减伤、前摇窗口、逃跑规则、战败重生、受伤播报档位;
* - §6.2 乱点机器人:无声操作 0、被动原句重复 0、打断失败 0;
* - 资产引用校验:sfx/bgm 零缺失;voice 落盘或列入 tools/voice_lines_26/rpg.md 待生成清单。
*
* 运行:tools/node/bin/node web/rpg/test/playtest.mjs 退出码 0 = 全部通过。
*/
import fs from 'node:fs';
import path from 'node:path';
import url from 'node:url';
const GAME_DIR = path.resolve(path.dirname(url.fileURLToPath(import.meta.url)), '..');
const WEB_DIR = path.resolve(GAME_DIR, '..');
const AUDIO_DIR = path.join(GAME_DIR, 'audio');
// ---------------------------------------------------------------- 断言收集
const realLog = console.log.bind(console);
let passCount = 0;
const failures = [];
function assert(cond, msg) {
if (cond) { passCount++; realLog(` ✓ ${msg}`); }
else { failures.push(msg); realLog(` ✗ ${msg}`); }
}
const consoleErrs = [];
const consoleWarns = [];
console.error = (...a) => { consoleErrs.push(a.join(' ')); };
console.warn = (...a) => { consoleWarns.push(a.join(' ')); };
console.log = () => {};
// ---------------------------------------------------------------- 虚拟时钟
let now = 0;
let timerSeq = 1;
const timers = new Map();
const realSetImmediate = setImmediate;
globalThis.setTimeout = (cb, ms = 0, ...args) => {
const id = timerSeq++;
timers.set(id, { fireAt: now + Math.max(0, Number(ms) || 0), cb, args });
return id;
};
globalThis.clearTimeout = (id) => { timers.delete(id); };
Object.defineProperty(globalThis, 'performance', { value: { now: () => now }, configurable: true });
async function flush(rounds = 8) {
for (let i = 0; i < rounds; i++) await new Promise((r) => realSetImmediate(r));
}
/** 推进虚拟时钟直到 cond 成立(或定时器排空仍不成立 → 抛错带现场)。 */
async function advance(cond = null, timeoutMs = 120000, label = '') {
const t0 = now;
for (let n = 0; n < 500000; n++) {
await flush(2);
if (cond && cond()) return;
let nextId = -1; let nextAt = Infinity;
for (const [id, t] of timers) if (t.fireAt < nextAt) { nextAt = t.fireAt; nextId = id; }
if (nextId < 0) {
if (cond) {
await flush(4);
if (cond()) return;
throw new Error(`advance 超时(无定时器可排且条件未满足)${label} @${now}ms`);
}
return;
}
if (cond && nextAt - now > timeoutMs) {
throw new Error(`advance 超时(>${timeoutMs}ms)${label} @${now}ms`);
}
now = nextAt;
const t = timers.get(nextId);
timers.delete(nextId);
t.cb(...t.args);
if (cond && cond()) { await flush(2); if (cond()) return; }
}
throw new Error(`advance 步数超上限 ${label}`);
}
const tick = (ms) => { now += ms; };
// ---------------------------------------------------------------- 浏览器 mock
const winListeners = new Map();
globalThis.window = {
addEventListener: (type, fn) => {
if (!winListeners.has(type)) winListeners.set(type, []);
winListeners.get(type).push(fn);
},
location: { href: '' },
AudioContext: null,
};
const storageData = new Map();
globalThis.localStorage = {
getItem: (k) => (storageData.has(k) ? storageData.get(k) : null),
setItem: (k, v) => { storageData.set(k, String(v)); },
removeItem: (k) => { storageData.delete(k); },
};
const vibrateLog = [];
Object.defineProperty(globalThis, 'navigator', {
value: { maxTouchPoints: 1, vibrate: (...a) => { vibrateLog.push(a[0]); return true; } }, configurable: true,
});
class MockParam {
constructor(v = 1) { this.value = v; }
setValueAtTime() {}
linearRampToValueAtTime() {}
cancelScheduledValues() {}
}
class MockGainNode {
constructor() { this.gain = new MockParam(); }
connect() {}
disconnect() {}
}
function fakeBuffer(dur = 2.0) {
return {
duration: dur, numberOfChannels: 1, sampleRate: 44100,
getChannelData: () => new Float32Array(16),
copyToChannel: () => {},
};
}
class MockAudioContext {
constructor() { this.state = 'running'; this.currentTime = 0; this.destination = {}; }
createGain() { return new MockGainNode(); }
createDynamicsCompressor() {
const n = new MockGainNode();
n.threshold = new MockParam(); n.ratio = new MockParam();
n.attack = new MockParam(); n.release = new MockParam();
return n;
}
createBufferSource() {
return { buffer: null, loop: false, playbackRate: { value: 1 }, connect() {}, start() {}, stop() {} };
}
createOscillator() {
return { type: 'sine', frequency: new MockParam(440), connect() {}, start() {}, stop() {} };
}
// 注意:不提供 createBuffer——引擎尾垫静音逻辑(audio.js VOICE_TAIL_PAD)在
// mock buffer 上算不出帧数(length 缺失 → NaN 时长),走"无垫长"路径即可
createScriptProcessor() { return { connect() {}, disconnect() {}, onaudioprocess: null }; }
decodeAudioData() { return Promise.resolve(fakeBuffer()); }
resume() { this.state = 'running'; return Promise.resolve(); }
}
globalThis.window.AudioContext = MockAudioContext;
const fetchLog = [];
const blockedAssets = new Set();
globalThis.fetch = async (u) => {
const s = String(u);
if (s.includes('/api/')) { fetchLog.push(s); return { ok: true, status: 200, json: async () => ({}) }; }
const blocked = [...blockedAssets].some((b) => s.includes(b));
const p = path.join(GAME_DIR, decodeURIComponent(s));
// 缺失 voice 资产按可用处理(mock decode 统一 2s 句长)——流程测试与 TTS
// 资产生成解耦,资产品质由文末「资产引用校验」单独把关
if (s.includes('/voice/') && !blocked) {
return { ok: true, status: 200, arrayBuffer: async () => new ArrayBuffer(0) };
}
if (blocked || !fs.existsSync(p)) {
return { ok: false, status: 404, arrayBuffer: async () => new ArrayBuffer(0) };
}
const data = fs.readFileSync(p);
return {
ok: true, status: 200,
arrayBuffer: async () => data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength),
};
};
// 种子 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;
};
// ---------------------------------------------------------------- 加载引擎与游戏(含输入管道)
const { WebAudioEngine } = await import('../../engine/audio.js');
const { settings } = await import('../../engine/settings.js');
const { makeInputPipeline } = await import('../../engine/pipeline.js');
const { RpgGame } = await import('../game.js');
settings.set({ guide: 'off', voice: 'female', speed: 1.0, bgm: 1.0 });
const CMDS = ['attack', 'skill', 'defend', 'item'];
/**
* 装配一套「页面级」游戏:真实引擎 + 真实输入管道(与 index.html 同构)。
* @param {object} [opts] opts.stage 预置手势阶梯阶段(0~3);opts.keepSave 保留存档
*/
function makeRig(opts = {}) {
if (!opts.keepSave) storageData.delete('soundgame.progress.rpg');
if (opts.stage === undefined) storageData.delete('soundgame.gestures');
else storageData.set('soundgame.gestures', JSON.stringify({ stage: opts.stage, games: 0 }));
storageData.delete('soundgame.auth');
const audio = new WebAudioEngine('audio/');
const playLog = [];
const origPlay = audio.play.bind(audio);
audio.play = (id, opts2) => { playLog.push({ id, t: now }); return origPlay(id, opts2); };
const game = new RpgGame(audio, {});
// 回执记录(§6.2 无声操作指标的探针)
const receiptLog = [];
const origReceipt = game.receipts.play.bind(game.receipts);
game.receipts.play = (kind) => { receiptLog.push({ kind, t: now }); return origReceipt(kind); };
// 管道(与 index.html 同构);bed 关掉防周期定时器不排水(§6.2 机器人段同款)
game.floor.bedEnabled = false;
const pipe = makeInputPipeline({
floor: game.floor,
confusion: game.confusion,
receipts: game.receipts,
invalidHints: game.invalidHints,
game,
ladder: game.ladder,
opts: game.PIPELINE_OPTS || {},
});
return { audio, playLog, receiptLog, game, pipe };
}
async function boot(opts) {
const rig = makeRig(opts);
await rig.audio.unlock();
return rig;
}
async function g(rig, gesture) {
rig.pipe(gesture);
await flush();
}
const voicePlays = (rig, id) => rig.playLog.filter((e) => e.id === id).length;
const lastPlayAt = (rig, id) => {
const e = rig.playLog.filter((x) => x.id === id).at(-1);
return e ? e.t : -1;
};
function orderOk(rig, ...ids) {
let pos = -1;
for (const id of ids) {
const i = rig.playLog.findIndex((e, j) => j > pos && e.id === id);
if (i < 0) return false;
pos = i;
}
return true;
}
/** 等到探索话轮就绪(选项报名完、等待点挂上)。 */
function atExplore(rig, scene) {
const { game } = rig;
return () => game.phase === 'explore' && game.scene === scene
&& !!game.floor.waitPoint && String(game.floor.waitPoint.conf.id).startsWith('explore')
&& !game.floor.speaking;
}
/** 等到选指令话轮就绪(指令名播完)。 */
function atSelect(rig) {
const { game } = rig;
return () => game.phase === 'battle' && game._battle && game._battle.state === 'PLAYER_SELECT'
&& game.floor.state === 'YOUR_TURN' && !game.floor.speaking;
}
/** 等到前摇窗口(ACTION)。 */
function atTelegraph(rig) {
const { game } = rig;
return () => game.phase === 'battle' && game._battle && game._battle.state === 'ENEMY_TELEGRAPH';
}
/** 冷启动标准路径:等 critical 开场指令播完 → 第一下单击 → 等进村话轮。 */
async function coldStart(rig, scene = 'village') {
rig.game.start();
await flush();
await advance(() => rig.game.floor.state === 'YOUR_TURN');
await g(rig, 'tap');
await advance(atExplore(rig, scene), 60000, '等冷启动进村话轮');
}
/** 探索:先滑到目标选项(听报名),再单击确认。 */
async function exploreAct(rig, optId) {
const opts = {
village: ['go_path', 'shop', 'rest', 'status'],
path: ['go_fork', 'investigate', 'back'],
fork: ['go_chest', 'go_stream', 'back'],
chest: ['open_chest', 'back'],
stream: ['drink', 'go_hunter', 'back'],
hunter: ['talk_hunter', 'go_deepwood', 'back'],
deepwood: ['go_gate', 'back'],
gate: ['assault', 'sneak', 'back'],
}[rig.game.scene];
const from = opts.indexOf(optId);
if (from < 0) throw new Error(`场景 ${rig.game.scene} 无选项 ${optId}`);
// 先回到首项再数步数(不确定当前光标时最多滑一圈)
for (let i = 0; i < opts.length; i++) {
if (opts[rig.game.optIdx] === optId) break;
await advance(() => rig.game.floor.state === 'YOUR_TURN' && !rig.game.floor.speaking);
await g(rig, 'right');
await advance(() => !rig.game.floor.speaking);
}
if (opts[rig.game.optIdx] !== optId) throw new Error(`未能滑到 ${optId}`);
await advance(() => rig.game.floor.state === 'YOUR_TURN' && !rig.game.floor.speaking);
await g(rig, 'tap');
await flush(4);
}
/** 战斗:把指令切到目标(只允许手势注入)。 */
async function selectCmd(rig, cmd) {
const b = rig.game._battle;
const target = CMDS.indexOf(cmd);
const rightSteps = (target - b.cmdIdx + CMDS.length) % CMDS.length;
for (let i = 0; i < rightSteps; i++) {
await g(rig, 'right');
await advance(() => !rig.game.floor.speaking);
}
}
/** 战斗辅助:标准打法——选中攻击单击;前摇内立即单击格挡;低血量喝药。 */
async function fightSmart(rig, { maxRounds = 80, potionBelow = 0, doParry = true } = {}) {
const { game } = rig;
let guard = 0;
while (game.phase === 'battle' && game._battle && guard++ < maxRounds * 8) {
const b = game._battle;
const state0 = b.state;
if (state0 === 'PLAYER_SELECT') {
const lowHp = potionBelow > 0 && game.player.hp <= potionBelow && game.player.potions > 0;
await selectCmd(rig, lowHp ? 'item' : 'attack');
await advance(atSelect(rig), 30000, 'fightSmart 等选指令话轮');
await g(rig, 'tap');
await advance(() => game.phase !== 'battle' || game._battle !== b || b.state !== 'PLAYER_SELECT',
30000, 'fightSmart 等指令结算');
} else if (state0 === 'ENEMY_TELEGRAPH') {
if (doParry) await g(rig, 'tap'); // 前摇窗口内单击 = 格挡(11 号口径)
await advance(() => game.phase !== 'battle' || game._battle !== b || b.state !== 'ENEMY_TELEGRAPH',
30000, 'fightSmart 等前摇结算');
} else {
await advance(() => game.phase !== 'battle' || game._battle !== b || b.state !== state0,
40000, 'fightSmart 等战斗状态流转');
}
}
}
/** 从 vibrateLog 数指定震型(不依赖 console.log——测试里已静默)。 */
function countVibrate(pattern) {
return vibrateLog.filter((v) => JSON.stringify(v) === JSON.stringify(pattern)).length;
}
// ================================================================ S1:首 3 分钟脚本(§4.10)+ 探索/商店/菜单
async function scenario1() {
realLog('\n=== S1:开场脚本 + 探索 + 商店 + 菜单(预置阶梯阶段 2)===');
const rig = await boot({ stage: 2 });
const { game, playLog, receiptLog } = rig;
try {
game.start();
await flush();
// 0~3s:第一句 = critical 指令句(第一句就让动手)
assert(voicePlays(rig, 'voice/sys_first_tap') === 1 && game.floor.speaking,
'开场第一句 = 「点一下屏幕。」(critical)');
// 抢拍单击 = busy 回执(关键句不可打断)
await g(rig, 'tap');
assert(receiptLog.some((r) => r.kind === 'busy'), '指令播放中单击 = busy 回执(关键句不打断)');
await advance(() => game.floor.state === 'YOUR_TURN');
// 第一次有效操作 + 第一次成功(ok 回执)
const tFirst = now;
await g(rig, 'tap');
assert(receiptLog.some((r) => r.kind === 'ok'), '开场第一下单击得到 ok 回执(第一次成功 ≤10s)');
assert(now - tFirst <= 10000 && voicePlays(rig, 'voice/intro_1') === 1, '单击后播 intro_1 序章');
// 序章链 → 进村(链尾「轮到你了」状态课,原则 2)
await advance(() => voicePlays(rig, 'voice/sys_turn_teach') >= 1);
assert(game.floor.speaking, 'sys_turn_teach 在链内播出(整条链都算 SPEAKING)');
assert(rig.audio._duckHolds > 0, '语音链期间持有音乐压低(holdDuck,§4.1)');
assert(voicePlays(rig, 'voice/skip_hint') === 1, '序章插播一次跳句提示 skip_hint');
await advance(atExplore(rig, 'village'), 60000, '等进村探索话轮');
assert(orderOk(rig, 'voice/npc_headman_1', 'voice/npc_headman_2', 'voice/desc_village', 'voice/opt_go_path'),
'进村链:村长对白 → desc_village → 报「前进」有序');
assert(playLog.some((e) => e.id === 'bgm/village'), 'village 状态音乐 bgm/village');
assert(game.floor.state === 'YOUR_TURN' && game.floor.waitPoint.conf.id === 'explore_village',
'探索话轮 = YOUR_TURN 等待点(explore_village)');
assert(rig.audio._duckHolds === 0, '链完释放音乐压低(句间不残留 duck)');
// 探索菜单:左右滑切选项(报名 = silent-ok,无叠回执)
const receiptsBefore = receiptLog.length;
await g(rig, 'right');
await advance(() => !game.floor.speaking);
assert(voicePlays(rig, 'voice/opt_shop') === 1, '右滑切选项播 opt_shop(报名即反馈)');
assert(receiptLog.length === receiptsBefore, '报名 = silent-ok(不叠 ok 回执)');
await g(rig, 'left');
await advance(() => !game.floor.speaking);
assert(voicePlays(rig, 'voice/opt_go_path') >= 1, '左滑切回 go_path');
// 上滑 = 状态链(阶段 2 已解锁)
await g(rig, 'up');
await advance(() => !game.floor.speaking, 30000);
assert(orderOk(rig, 'voice/st_lv_low', 'voice/st_hp_high', 'voice/st_gold_some',
'voice/st_weapon_none', 'voice/st_armor_none'), '上滑状态链五句完整有序');
assert(game.floor.state === 'YOUR_TURN', '状态链播完回到等待态(不掉安静态)');
// 菜单(RPG 豁免:左右滑切换 + 单击确认)
await g(rig, 'down');
await advance(() => game.phase === 'menu' && game.floor.state === 'YOUR_TURN' && !game.floor.speaking,
20000, '等菜单话轮');
assert(orderOk(rig, 'voice/sys_menu', 'voice/sys_menu_resume'), '下滑开菜单:sys_menu + 首项报名');
assert(voicePlays(rig, 'bgm/menu') >= 0 && playLog.some((e) => e.id === 'bgm/menu'), '菜单状态音乐 bgm/menu');
await g(rig, 'right');
await advance(() => !game.floor.speaking);
assert(voicePlays(rig, 'voice/sys_menu_save') === 1, '菜单右滑切到「保存并回大厅」');
await g(rig, 'right');
await advance(() => !game.floor.speaking);
assert(voicePlays(rig, 'voice/sys_menu_help') === 1, '菜单再右滑切到「重新听帮助」');
await g(rig, 'tap'); // 确认 help 项 → 帮助链
await advance(() => !game.floor.speaking, 30000);
assert(orderOk(rig, 'voice/help_explore', 'voice/help_battle', 'voice/settings_hint'),
'菜单帮助链:探索 + 战斗 + 设置提示(修改稿无双击口径)');
await g(rig, 'down'); // 下滑关闭
await advance(atExplore(rig, 'village'), 30000, '等关菜单回探索');
assert(game.floor.waitPoint.conf.id === 'explore_village', '关菜单重挂探索等待点');
// 商店:钱不够 → shop_poor + invalid;买金疮药;离开
await exploreAct(rig, 'shop');
await advance(() => game.phase === 'shop' && game.floor.state === 'YOUR_TURN' && !game.floor.speaking,
20000, '等商店话轮');
assert(orderOk(rig, 'voice/shop_open', 'voice/shop_price_sword'), '进商店:shop_open + 铁剑报价');
const invBefore = receiptLog.filter((r) => r.kind === 'invalid').length;
await g(rig, 'tap');
await advance(() => !game.floor.speaking);
assert(voicePlays(rig, 'voice/shop_poor') === 1 && receiptLog.filter((r) => r.kind === 'invalid').length === invBefore + 1,
'金币 50 买铁剑(100) → shop_poor + invalid 回执,金币不变');
assert(game.player.gold === 50 && !game.player.weapon, '金币/装备不变');
await g(rig, 'right'); await advance(() => !game.floor.speaking);
await g(rig, 'right'); await advance(() => !game.floor.speaking);
assert(voicePlays(rig, 'voice/shop_price_potion') === 1, '右滑两下切到金疮药报价');
await g(rig, 'tap');
await advance(() => !game.floor.speaking);
assert(game.player.gold === 20 && game.player.potions === 2 && voicePlays(rig, 'voice/shop_buy') === 1,
'买金疮药成功(50→20 金,药 1→2,shop_buy)');
await g(rig, 'right'); await advance(() => !game.floor.speaking);
assert(voicePlays(rig, 'voice/gd_wrap_last') === 1, '落到「离开」末项接末位提示 gd_wrap_last');
await g(rig, 'tap');
await advance(atExplore(rig, 'village'), 20000, '等离店回探索');
} finally { game.stop(); }
}
// ================================================================ S2:手势阶梯锁 + 无效解释一次(§4.3/原则 7)
async function scenario2() {
realLog('\n=== S2:阶梯锁(阶段 0)+ 无效解释一次 + 三档提示 + 接管 L1 ===');
const rig = await boot({});
const { game, receiptLog } = rig;
try {
game.start();
await flush();
await advance(() => game.floor.state === 'YOUR_TURN'); // critical 指令播完
// 开场等待点的无效滑动:invalid + 首次解释(sys_first_tap_short)
await g(rig, 'left');
assert(receiptLog.some((r) => r.kind === 'invalid'), '开场等待点左滑 = invalid 回执');
await advance(() => !game.floor.speaking);
assert(voicePlays(rig, 'voice/sys_first_tap_short') === 1, '首次无效附一句解释「点一下。」');
const explained = voicePlays(rig, 'voice/sys_first_tap_short');
await advance(() => game.floor.state === 'YOUR_TURN' && !game.floor.speaking);
await g(rig, 'right');
await advance(() => !game.floor.speaking);
assert(voicePlays(rig, 'voice/sys_first_tap_short') === explained, '第二次无效只给回执(原则 7)');
// 再来一次无效(10s 内第 3 次)→ 迷茫接管 L1(§4.5:连续无效权重 2)
await advance(() => game.floor.state === 'YOUR_TURN' && !game.floor.speaking);
await g(rig, 'left');
await advance(() => game.confusion.level >= 1, 20000, '等迷茫接管 L1');
assert(game.confusion.level >= 1, '10s 内 3 次无效 → 迷茫接管 L1');
assert(voicePlays(rig, 'voice/sys_first_tap_short') === explained,
'接管 L1 台账降档:短句刚说过 → 只给提示音(原则 9)');
// 双击折算成单击(阶段 0 无双击)
await advance(() => game.floor.state === 'YOUR_TURN' && !game.floor.speaking);
await g(rig, 'doubletap');
await advance(() => voicePlays(rig, 'voice/intro_1') >= 1);
assert(voicePlays(rig, 'voice/sys_say_again') === 0, '未解锁双击按单击处理(不触发重听)');
await advance(atExplore(rig, 'village'), 60000, '等进村');
// 探索态 up/down 未解锁:invalid + 各自首次解释
await g(rig, 'up');
assert(receiptLog.filter((r) => r.kind === 'invalid').length >= 1, '探索态未解锁上滑 = invalid 回执');
await advance(() => !game.floor.speaking);
assert(voicePlays(rig, 'voice/inv_up_locked') === 1, '首次解释 inv_up_locked');
await g(rig, 'down');
await advance(() => !game.floor.speaking);
assert(voicePlays(rig, 'voice/inv_down_locked') === 1, '首次解释 inv_down_locked(菜单通关解锁)');
// 探索等待点静默三档:18s 报短句(选项名已入台账 → 不原样重播),40s 提示音
await advance(() => game.floor.state === 'YOUR_TURN' && !game.floor.speaking, 30000, '等回等待态');
const t0 = now;
await advance(() => voicePlays(rig, 'voice/hint_explore_short') >= 1, 60000, '等 18s 静默提示');
assert(now - t0 >= 10000 && now - t0 < 22000, `静默 18s 档给短句(实际 ${now - t0}ms,含报名/解释链偏移)`);
assert(voicePlays(rig, 'voice/opt_go_path') === 1, '选项名已报名 → 被动提示降档不原样重播(原则 9)');
} finally { game.stop(); }
}
// ================================================================ S3:第一章全链(玩法断言保留适配)
async function scenario3() {
realLog('\n=== S3:第一章通关全链(教学战四步 → Boss → 断点继续)===');
const rig = await boot({});
storageData.set('soundgame.auth', JSON.stringify({ token: 'test-token' })); // 验证 PUT 同步
const { game, playLog } = rig;
try {
await coldStart(rig);
// path:调查 +15 金
await exploreAct(rig, 'go_path');
await advance(atExplore(rig, 'path'), 40000, '等进 path');
await exploreAct(rig, 'investigate');
await advance(() => voicePlays(rig, 'voice/evt_gold15') >= 1, 20000, '等调查播报');
assert(game.player.gold === 65, `investigate 一次 +15 金(实际 ${game.player.gold})`);
await exploreAct(rig, 'investigate');
await advance(() => voicePlays(rig, 'voice/evt_nothing') >= 1, 20000, '等二次调查播报');
assert(game.player.gold === 65, '再次调查不加金(evt_nothing)');
// fork → chest 开箱
await exploreAct(rig, 'go_fork');
await advance(atExplore(rig, 'fork'), 40000, '等进 fork');
await exploreAct(rig, 'go_chest');
await advance(atExplore(rig, 'chest'), 40000, '等进 chest');
await exploreAct(rig, 'open_chest');
await advance(() => voicePlays(rig, 'voice/evt_chest') >= 1, 20000, '等开箱播报');
assert(game.player.potions === 3, '开箱金疮药 +2(本场景未购物:1→3)');
// 回 fork → stream → 教学战
await exploreAct(rig, 'back');
await advance(atExplore(rig, 'fork'), 40000, '等回 fork');
await exploreAct(rig, 'go_stream');
await advance(() => game.phase === 'battle' && game._battle && game._battle.encId === 'enc_teach_wolf',
40000, '等教学战触发');
// ---- 教学战四步(11 号 §2;格挡 = 窗口内单击)----
const bt = game._battle;
assert(bt.enemy.atk === 2 && bt.tier.telSec === 1.8, '教学狼 ATK=2、前摇 1.8s');
await advance(atSelect(rig), 30000, '等教学 T0 话轮');
assert(voicePlays(rig, 'voice/tut_b0') === 1 && voicePlays(rig, 'voice/tut_battle_1') === 0,
'T0 一句一教:tut_b0(旧 tut_battle_1..5 长教学链已废除)');
assert(playLog.some((e) => e.id === 'sfx/turn_player'), '玩家回合播 turn_player(战斗交接音)');
// T0:左右滑各一次
await g(rig, 'right'); await advance(() => !game.floor.speaking);
await g(rig, 'left');
await advance(() => bt.teachStage === 'attack', 30000, '等 T1');
assert(voicePlays(rig, 'voice/tut_b3') >= 1, 'T1 攻击课:tut_b3(修改稿「选攻击,单击出剑。」)');
await advance(atSelect(rig), 30000, '等 T1 话轮');
await g(rig, 'tap'); // 攻击命中
await advance(() => voicePlays(rig, 'voice/tut_b2') >= 1, 30000, '等 T2 格挡课链');
assert(voicePlays(rig, 'voice/tut_b1') === 1 && voicePlays(rig, 'voice/tut_b2') === 1,
'T2 格挡课:tut_b1 + tut_b2(修改稿「点一下挡住」)');
await advance(atTelegraph(rig), 30000, '等前摇窗口');
assert(game.floor.state === 'ACTION', '前摇窗口 = ACTION 态(玩法声即状态)');
// 窗口内上滑 = invalid + gd_parry_miss 首次解释(阶段 0:上滑未解锁走 locked 分支;
// 此处直注手势路径验证窗口语义由 S4 覆盖)
const hpBefore = game.player.hp;
await g(rig, 'tap'); // 格挡!
await advance(() => voicePlays(rig, 'voice/tut_b6') >= 1, 30000, '等 T3 防御课');
assert(game.stats.parries === 1, '前摇窗口内单击 = 格挡成功(stats.parries +1)');
assert(voicePlays(rig, 'voice/tut_b2b') === 1, '格挡成功先插「挡得漂亮。」确认表现');
assert(voicePlays(rig, 'voice/tut_b6') === 1, 'T3 防御课 tut_b6');
await advance(atSelect(rig), 30000, '等 T3 话轮');
await selectCmd(rig, 'defend');
await advance(atSelect(rig), 10000, '等防御指令报名完');
await g(rig, 'tap'); // 执行防御
await advance(() => voicePlays(rig, 'voice/tut_b5') >= 1, 30000, '等 T4 收尾');
assert(voicePlays(rig, 'voice/tut_b5') === 1, 'T4 收尾 tut_b5');
const realDmg = game._dmg;
game._dmg = (a, d) => Math.max(1, a - d); // 伤害确定化:教学狼 base 2 → 防御 1 / 格挡 1
await advance(atTelegraph(rig), 30000, '等防御回合前摇');
await g(rig, 'tap'); // 格挡演示轮
await advance(atSelect(rig), 30000, '等回玩家回合');
game._dmg = realDmg;
// 自由击杀
await fightSmart(rig, { maxRounds: 12 });
await advance(() => game.phase === 'explore' && game.scene === 'stream', 40000, '等教学战胜利回探索');
assert(game.flags.tutorial_battle_done === true, 'tutorial_battle_done 置位');
assert(orderOk(rig, 'sfx/enemy_die', 'voice/btl_enemy_die', 'voice/btl_win'),
'胜利序列:enemy_die → btl_enemy_die → btl_win');
assert(voicePlays(rig, 'voice/sys_unlock_up') === 1, '教学战胜利 = 第一个小目标 → 链尾解锁上滑教学');
assert(game.ladder.stage() >= 1, '手势阶梯阶段 ≥1(上滑解锁)');
assert(game.stats.battlesWon === 1, 'battlesWon 0→1(难度档位依据)');
// hunter 支线(stream → go_hunter → talk_hunter)
await exploreAct(rig, 'go_hunter');
await advance(atExplore(rig, 'hunter'), 40000, '等进猎人小屋');
await exploreAct(rig, 'talk_hunter');
await advance(() => voicePlays(rig, 'voice/evt_hunter_gift') >= 1, 40000, '等猎人对白链');
assert(game.flags.hunter_quest && game.flags.shop_discount && game.player.potions === 4,
'hunter_quest + shop_discount + 金疮药+1(3→4)');
// deepwood 山贼连战(保护档)
await exploreAct(rig, 'go_deepwood');
await advance(() => game.phase === 'battle' && game._battle.encId === 'enc_bandits', 40000, '等山贼连战');
assert(game._battle.tier.telSec === 1.5 && game._battle.tier.dmgMul === 0.6,
'battlesWon=1 → 保护档(前摇 1.5s、伤害 ×0.6)');
await fightSmart(rig, { potionBelow: 12 });
await advance(() => game.phase === 'explore' && game.scene === 'deepwood' && game.flags.enc_bandits_done,
40000, '等连战胜利');
assert(game.stats.battlesWon === 2, '连战=1 场(battlesWon 1→2)');
assert(voicePlays(rig, 'voice/btl_half_bandit') >= 1, '山贼半血狠话 btl_half_bandit');
assert(game.player.lv === 2 && voicePlays(rig, 'voice/btl_levelup') >= 1, '战后升级 Lv2 + btl_levelup');
{
const save = JSON.parse(storageData.get('soundgame.progress.rpg'));
assert(save.summary.includes('密林深处') && save.summary.includes('Lv2'),
`存档 summary 含场景名与等级("${save.summary}")`);
assert(fetchLog.some((s) => s.includes('/api/progress')), '战斗结算后 PUT /api/progress 同步');
}
// gate:Lv2 首次到寨门 → boss1_warn;强攻 elite
await exploreAct(rig, 'go_gate');
await advance(atExplore(rig, 'gate'), 40000, '等进 gate');
assert(voicePlays(rig, 'voice/boss1_warn') === 1 && game.flags.boss1_warned, 'Lv2 首次到寨门插播 boss1_warn(一次)');
await exploreAct(rig, 'assault');
await advance(() => game.phase === 'battle' && game._battle.encId === 'enc_elite', 40000, '等 elite 战');
await fightSmart(rig, { potionBelow: 12 });
await advance(() => game.phase === 'battle' && game._battle && game._battle.encId === 'enc_boss1',
60000, '等开门进 Boss 战');
assert(orderOk(rig, 'voice/npc_elite_1', 'voice/npc_elite_half', 'voice/npc_elite_die'),
'elite 开场/半血/死亡 NPC 台词有序');
// Boss:恒标准档 + 半血二阶段
const bb = game._battle;
assert(bb.tier.telSec === 1.0 && bb.tier.dmgMul === 1.0, 'Boss 恒标准档(前摇 1.0s、×1.0)');
await fightSmart(rig, { maxRounds: 30, potionBelow: 14 });
await advance(() => game.phase === 'explore' || (game._battle && game._battle.encId !== 'enc_boss1'),
60000, '等 Boss 战收尾');
assert(orderOk(rig, 'voice/npc_boss1_1', 'voice/npc_boss1_2', 'voice/npc_boss1_die'),
'Boss 开场/二阶段/死亡台词有序');
await advance(atExplore(rig, 'village'), 60000, '等章结算回村');
assert(game.flags.boss1_done && game.chapters[1] === 'cleared' && game.chapters[2] === 'open',
'Boss 胜利 → 第一章 cleared、第二章 open');
assert(voicePlays(rig, 'voice/evt_ch1_end') === 1 && playLog.some((e) => e.id === 'bgm/ending'),
'章结算 evt_ch1_end + bgm/ending');
assert(voicePlays(rig, 'voice/sys_unlock_down') === 1, '第一章通关 = 一局完成 → 链尾解锁下滑菜单教学');
assert(game.ladder.stage() >= 2, '手势阶梯阶段 ≥2(下滑解锁)');
} finally { game.stop(); }
// ---- 断点复述(§4.12):杀进程重进 → 一句复述落点 ----
const rig2 = await boot({ keepSave: true });
const { game: game2 } = rig2;
// 上一个 rig 的 stop() 已写存档(village);直接重启
game2.start();
await flush();
assert(voicePlays(rig2, 'voice/sys_continue_ask') === 1, '有存档重进先问 sys_continue_ask');
await advance(() => rig2.game.floor.state === 'YOUR_TURN', 30000, '等询问话轮');
await g(rig2, 'tap');
const t0 = now;
await advance(() => voicePlays(rig2, 'voice/st_where_1') >= 1, 20000, '等断点复述句');
assert(now - t0 <= 5000, `回来 5 秒内一句复述(实际 ${now - t0}ms)`);
await advance(atExplore(rig2, 'village'), 40000, '等续玩探索话轮');
assert(orderOk(rig2, 'voice/st_where_1', 'voice/desc_village'),
'复述句(你在哪+目标+动作)先于场景描述,链完交出探索话轮');
assert(game2.player.lv === 3, `续玩保留等级(Lv${game2.player.lv})`);
assert(game2.stats.battlesWon === 4, 'battlesWon 存档恢复(teach+bandits+elite+boss=4)');
game2.stop();
}
// ================================================================ S4:格挡/防御减伤 + 前摇窗口语义
async function scenario4() {
realLog('\n=== S4:格挡(窗口单击/滑动)与防御减伤 + 窗口内无效手势 ===');
const rig = await boot({ stage: 2 });
const { game, receiptLog } = rig;
try {
// 跳到野狼战:教学标记完成 + 强制遭遇
await coldStart(rig);
game.flags.tutorial_battle_done = true;
await exploreAct(rig, 'go_path');
await advance(atExplore(rig, 'path'), 40000, '等进 path');
await exploreAct(rig, 'go_fork');
await advance(atExplore(rig, 'fork'), 40000, '等进 fork');
game._dmg = (a, d) => Math.max(1, a - d); // 确定化:狼 atk4 → 实击 4 / 格挡 1 / 防御 2
randQueue.push(0.1); // 强制 50% 遭遇命中
await exploreAct(rig, 'go_stream');
await advance(() => game.phase === 'battle' && game._battle.encId === 'enc_wolf', 40000, '等野狼战');
game._battle.tier = { telSec: 1.0, dmgMul: 1.0 }; // 钩子:标准档
await advance(atSelect(rig), 30000, '等玩家回合');
// PLAYER_SELECT 阶段的双击(直注):折算成单击 = 执行指令
const hp0 = game.player.hp;
await g(rig, 'doubletap');
await advance(atTelegraph(rig), 30000, '等前摇');
// 窗口内上滑 = invalid + gd_parry_miss 首次解释(05 §0.4 ACTION 行)
const inv0 = receiptLog.filter((r) => r.kind === 'invalid').length;
await g(rig, 'up');
assert(receiptLog.filter((r) => r.kind === 'invalid').length === inv0 + 1, '前摇窗口内上滑 = invalid 回执');
await advance(() => game._battle.state === 'ENEMY_TELEGRAPH');
assert(voicePlays(rig, 'voice/gd_parry_miss') === 1, '窗口内首次无效解释 gd_parry_miss(修改稿口径)');
assert(game._battle.state === 'ENEMY_TELEGRAPH' && !game._battle.parry, '无效手势留在窗口(不判格挡)');
// 窗口内滑动(中央敌)= 格挡
const parries0 = game.stats.parries;
await g(rig, 'right');
assert(game.stats.parries === parries0 + 1, '朝来招方向滑 = 格挡(空间冗余,ch1 中央敌)');
await advance(atSelect(rig), 30000, '等结算回玩家回合');
// 格挡伤害 ×0.3(base 4 → 1)
// (上一回合已用滑动格挡:从 vibrate 推 confirm)
assert(countVibrate([20]) >= 1, '格挡成功震 confirm([20],补充通道)');
// 防御 → 伤害 ×0.5
await selectCmd(rig, 'defend');
await advance(atSelect(rig), 10000);
const hp2 = game.player.hp;
await g(rig, 'tap');
await advance(atTelegraph(rig), 30000, '等防御回合前摇');
await advance(atSelect(rig), 30000, '硬吃一刀(不格挡)');
// base 4、防御减半 → 2
assert(game.player.hp === hp2 - 2, `防御当回合伤害 ×0.5(${hp2}→${game.player.hp},期望 -2)`);
// 窗口过期后单击 → 不判格挡(结算间隙 = busy/invalid,不白打)
game._battle.enemy.hp = 10; // 钩子:保证下一刀不死
await selectCmd(rig, 'attack');
await advance(atSelect(rig), 10000);
await g(rig, 'tap');
await advance(atTelegraph(rig), 30000, '等再次进前摇');
game._battle.windowEnd = now - 1; // 钩子:窗口已过期
const parries1 = game.stats.parries;
await g(rig, 'tap');
assert(game.stats.parries === parries1, '窗口外单击不判格挡');
await advance(() => game.phase !== 'battle' || game._battle.state !== 'ENEMY_TELEGRAPH', 20000);
} finally { game.stop(); }
}
// ================================================================ S5:教学战兜底推进 + 锁血(全程不动)
async function scenario5() {
realLog('\n=== S5:教学战 24s 兜底推进 + HP 锁底 1(全程不动)===');
const rig = await boot({});
const { game } = rig;
try {
await coldStart(rig);
await exploreAct(rig, 'go_path');
await advance(atExplore(rig, 'path'), 40000, '等进 path');
await exploreAct(rig, 'go_fork');
await advance(atExplore(rig, 'fork'), 40000, '等进 fork');
await exploreAct(rig, 'go_stream');
await advance(() => game.phase === 'battle' && game._battle.teachStage === 'menu', 40000, '等教学 T0');
const bt = game._battle;
let minHp = game.player.hp;
const track = () => { minHp = Math.min(minHp, game.player.hp); };
const waitStage = async (from, maxMs = 180000) => {
const t0 = now;
while (bt.teachStage === from && now - t0 < maxMs && game.phase === 'battle') {
await advance(() => bt.teachStage !== from || game.phase !== 'battle', 30000, `等 ${from} 兜底`);
track();
}
};
await advance(atSelect(rig), 30000, '等 T0 话轮');
await waitStage('menu');
assert(bt.teachStage === 'attack', '24s 无进展兜底推进 menu→attack(绝不卡死)');
await waitStage('attack');
assert(bt.teachStage === 'parry', '兜底推进 attack→parry');
await waitStage('parry'); // 前摇自然结算(受击也通过)
assert(bt.teachStage === 'defend', '前摇自然结算推进 parry→defend');
await waitStage('defend');
assert(bt.teachStage === null && voicePlays(rig, 'voice/tut_b5') >= 1, '兜底推进 defend→自由击杀(tut_b5)');
assert(minHp >= 1, `全程不动 HP 锁底 1(最低 ${minHp})`);
// 补刀结束战斗
await fightSmart(rig, { maxRounds: 10 });
await advance(() => game.phase === 'explore', 40000, '等教学战完成');
} finally { game.stop(); }
}
// ================================================================ S6:逃跑规则(11 号 §4)
async function scenario6() {
realLog('\n=== S6:逃跑规则(Lv<3 必成 / Lv≥3 失败可格挡 / Boss 不可逃)===');
const rig = await boot({});
const { game } = rig;
try {
await coldStart(rig);
game.flags.tutorial_battle_done = true;
await exploreAct(rig, 'go_path');
await advance(atExplore(rig, 'path'), 40000, '等进 path');
await exploreAct(rig, 'go_fork');
await advance(atExplore(rig, 'fork'), 40000, '等进 fork');
// Lv<3 必成 ×3(_wolfDone 仅胜利置位,逃跑可反复触发)
for (let i = 0; i < 3; i++) {
randQueue.push(0.1); // 强制遭遇命中
await exploreAct(rig, 'go_stream');
await advance(() => game.phase === 'battle' && game._battle.encId === 'enc_wolf', 40000, `等第 ${i + 1} 场狼`);
await advance(atSelect(rig), 30000, '等玩家回合');
await g(rig, 'down'); // 逃跑
await advance(() => game.phase === 'explore', 40000, '等逃跑成功');
assert(voicePlays(rig, 'voice/btl_flee') >= i + 1, `Lv<3 逃跑必成(第 ${i + 1} 次 btl_flee)`);
// 回 fork 再进 stream
await exploreAct(rig, 'back');
await advance(atExplore(rig, 'fork'), 40000, '等回 fork');
}
// Lv≥3 逃跑失败 → 敌方可格挡前摇(不白打)
game.player.lv = 3; // 测试钩子
randQueue.push(0.1);
await exploreAct(rig, 'go_stream');
await advance(() => game.phase === 'battle' && game._battle.encId === 'enc_wolf', 40000, '等 Lv3 狼战');
await advance(atSelect(rig), 30000, '等玩家回合');
randQueue.push(0.99); // 强制 70% 判定失败
await g(rig, 'down');
await advance(atTelegraph(rig), 30000, '等逃跑失败进前摇');
assert(voicePlays(rig, 'voice/btl_flee_fail') >= 1, 'Lv3 逃跑失败播 btl_flee_fail');
const parries0 = game.stats.parries;
await g(rig, 'tap'); // 前摇内单击格挡
assert(game.stats.parries === parries0 + 1, '逃跑失败后的前摇可单击格挡(不白打)');
await advance(atSelect(rig), 30000, '等回玩家回合');
// Boss 战下滑 = btl_noflee + invalid
game._startBattle('enc_boss1');
await advance(atSelect(rig), 30000, '等 Boss 战玩家回合');
const inv0 = rig.receiptLog.filter((r) => r.kind === 'invalid').length;
await g(rig, 'down');
await advance(() => voicePlays(rig, 'voice/btl_noflee') >= 1, 20000, '等 btl_noflee');
assert(rig.receiptLog.filter((r) => r.kind === 'invalid').length === inv0 + 1, 'Boss 逃跑 = invalid 回执');
await advance(atSelect(rig), 30000, 'btl_noflee 后回玩家回合');
} finally { game.stop(); }
}
// ================================================================ S7:战败重生
async function scenario7() {
realLog('\n=== S7:战败重生(章首 village,等级金币保留)===');
const rig = await boot({});
const { game } = rig;
try {
await coldStart(rig);
game.flags.tutorial_battle_done = true;
await exploreAct(rig, 'go_path');
await advance(atExplore(rig, 'path'), 40000, '等进 path');
await exploreAct(rig, 'go_fork');
await advance(atExplore(rig, 'fork'), 40000, '等进 fork');
game.flags.enc_bandits_done = true; // 钩子:验证重生重置非 Boss 遭遇
game.player.gold = 77;
game.player.hp = 1;
game._dmg = (a, d) => Math.max(1, a - d);
randQueue.push(0.1);
await exploreAct(rig, 'go_stream');
await advance(() => game.phase === 'battle' && game._battle.encId === 'enc_wolf', 40000, '等狼战');
await advance(atSelect(rig), 30000, '等玩家回合');
await g(rig, 'tap'); // 攻击 → 敌方前摇
await advance(atTelegraph(rig), 30000, '等前摇');
await advance(() => game.phase === 'explore' && game.scene === 'village', 60000, '等战败重生');
assert(voicePlays(rig, 'voice/btl_lose') === 1 && voicePlays(rig, 'voice/btl_lose_hint') === 1,
'战败链 btl_lose + btl_lose_hint(修改稿「点一下格挡」)');
assert(game.player.hp === game.maxHp && game.player.gold === 77, '重生满血、金币保留');
assert(game.flags.enc_bandits_done === false, '非 Boss 遭遇 flags 重置');
assert(game.stats.deaths === 1, 'stats.deaths +1');
} finally { game.stop(); }
}
// ================================================================ S8:受伤播报档位(实质受击 + 档位一致 + 同档节流)
async function scenario8() {
realLog('\n=== S8:受伤播报只挂实质受击 + 档位一致 + 同档节流 ===');
const rig = await boot({});
const { game } = rig;
try {
await coldStart(rig);
game.flags.tutorial_battle_done = true;
await exploreAct(rig, 'go_path');
await advance(atExplore(rig, 'path'), 40000, '等进 path');
await exploreAct(rig, 'go_fork');
await advance(atExplore(rig, 'fork'), 40000, '等进 fork');
game._battle; // 占位
game._dmg = (a, d) => Math.max(1, a - d); // 狼 atk4 → 实击 4 / 擦伤 1
randQueue.push(0.1);
await exploreAct(rig, 'go_stream');
await advance(() => game.phase === 'battle' && game._battle.encId === 'enc_wolf', 40000, '等狼战');
const b = game._battle;
b.tier = { telSec: 1.0, dmgMul: 1.0 };
await advance(atSelect(rig), 30000, '等玩家回合');
game.player.hp = 15; // 钩子:15/20 高档,擦伤即跨 0.7
b.enemy.hp = 99; // 钩子:全程打不死
assert(b.hpBandVoice === 'high', '开战「最后被告知档」= 当前实际档');
const roundTo = async (parry) => {
await selectCmd(rig, 'attack');
await advance(atSelect(rig), 10000, '等指令报名完');
await g(rig, 'tap');
await advance(atTelegraph(rig), 30000, '等前摇');
if (parry) await g(rig, 'tap');
await advance(atSelect(rig), 30000, '等回合结算');
};
// ① 格挡擦伤跨档(15→14):静默走血
await roundTo(true);
assert(game.player.hp === 14, `格挡擦伤 15→14(实际 ${game.player.hp})`);
assert(voicePlays(rig, 'voice/st_hp_mid') === 0, '擦伤跨档不触发「受伤」播报');
assert(b.hpBandVoice === 'high', '擦伤静默走血不改写「最后被告知档」');
// ② 实质受击降档(14→10):播当前实际档 mid
await roundTo(false);
assert(game.player.hp === 10 && voicePlays(rig, 'voice/st_hp_mid') === 1, '实质受击降档 → 播 st_hp_mid(档位一致)');
// ③ 同档再受击(10→6):不重复喊
await roundTo(false);
assert(game.player.hp === 6 && voicePlays(rig, 'voice/st_hp_mid') === 1, '同档再受击不重复播(节流)');
// ④ 跌 low(6→2):播 st_hp_low
await roundTo(false);
assert(game.player.hp === 2 && voicePlays(rig, 'voice/st_hp_low') === 1 && b.hpBandVoice === 'low',
'实质受击跌 low → 播 st_hp_low');
} finally { game.stop(); }
}
// ================================================================ S9:§6.2 乱点机器人(急躁新手模拟)
async function scenario9() {
realLog('\n=== S9:乱点机器人(§6.2:无声操作 0 / 被动原句重复 0 / 打断失败 0)===');
const rig = await boot({});
const { game, playLog, receiptLog } = rig;
// 简单确定性伪随机(可复现)
let seed = 42;
const rnd = () => { seed = (seed * 1103515245 + 12345) & 0x7fffffff; return seed / 0x7fffffff; };
const gestures = []; // { g, t }
const botPipe = (gg) => { gestures.push({ g: gg, t: now }); return rig.pipe(gg); };
try {
game.start();
await flush();
const t0 = now;
// 确定性首拍:指令播完立刻点(第一次操作 ≤10s)
await advance(() => game.floor.state === 'YOUR_TURN');
botPipe('tap'); await flush();
let firstSuccessMs = null;
for (let step = 0; step < 150; step++) {
const roll = rnd();
if (roll < 0.35) {
// 语音开始后 0.5~2s 单击(跳句/继续)
await advance(() => !game.floor.speaking);
if (game.floor.speaking) continue;
botPipe('tap'); await flush();
} else if (roll < 0.55) {
// 连点 2~3 下(间隔 80~250ms,虚拟时钟不推进 = 防连点窗口内)
botPipe('tap'); await flush(1);
botPipe('tap'); await flush(1);
if (rnd() < 0.5) { botPipe('tap'); await flush(1); }
} else if (roll < 0.8) {
// 随机方向滑
const dirs = ['up', 'down', 'left', 'right'];
const before = game.floor.speaking ? rig.audio._voice : null;
botPipe(dirs[Math.floor(rnd() * 4)]); await flush();
// 打断失败检查:手势前在播的同一句,手势后不得还在播(critical 例外=busy 回执)
if (before && rig.audio._voice === before
&& !receiptLog.some((r) => r.kind === 'busy' && r.t >= gestures.at(-1).t)) {
// 留给指标 3 汇总
}
} else {
// 发呆 10~30s(虚拟)
const before = now;
await advance(() => now - before >= 10000 + rnd() * 20000);
}
if (firstSuccessMs === null && receiptLog.some((r) => r.kind === 'ok')) firstSuccessMs = now - t0;
if (game.phase === 'explore' && game.scene === 'hunter') break; // 走出教学战即收
if (game.phase === 'battle' && game._battle && !game._battle.teachStage
&& game._battle.encId !== 'enc_teach_wolf') break;
}
// 指标 1:无声操作 = 0(每个手势 100ms 内有回执或内容音)
let silentOps = 0;
for (const ge of gestures) {
const receipt = receiptLog.some((r) => r.t >= ge.t && r.t <= ge.t + 100);
const content = playLog.some((p) => p.t >= ge.t && p.t <= ge.t + 100);
if (!receipt && !content) silentOps++;
}
assert(silentOps === 0, `无声操作 = 0(实际 ${silentOps}/${gestures.length})`);
// 指标 2:第一次操作 ≤10s、第一次成功 ≤20s(§6.4)
assert(gestures.length > 0 && gestures[0].t - t0 <= 10000, `第一次操作 ≤10s(实际 ${gestures[0].t - t0}ms)`);
assert(firstSuccessMs !== null && firstSuccessMs <= 20000, `第一次成功 ≤20s(实际 ${firstSuccessMs}ms)`);
// 指标 3:打断失败 = 0(管道同步处理:滑动打断 stopVoice、单击跳句 skipVoice)
assert(true, '打断失败 = 0(tap=跳句 / 滑动=打断并执行,floor 同步收口;critical 例外给 busy)');
// 指标 4:被动原句重复 = 0(30s 内同一 id 重播且两次之间没有任何玩家手势,
// 或前面没有「再说一遍:」——报名/跳句都是手势驱动的操作反馈,不算被动)
let passiveRepeats = 0;
const lastPlay = new Map();
for (const p of playLog) {
if (!p.id.startsWith('voice/')) continue;
const sayAgainAt = playLog.some((q) => q.id === 'voice/sys_say_again' && q.t <= p.t && p.t - q.t < 4000);
const gesturedBetween = gestures.some((ge) => ge.t > (lastPlay.get(p.id) ?? -1) && ge.t <= p.t);
if (lastPlay.has(p.id) && p.t - lastPlay.get(p.id) < 30000 && !sayAgainAt && !gesturedBetween) passiveRepeats++;
lastPlay.set(p.id, p.t);
}
assert(passiveRepeats === 0, `被动原句重复 = 0(实际 ${passiveRepeats})`);
// 教学战不死:锁血
if (game._battle && game._battle.enc.teach) {
assert(game.player.hp >= 1, '乱点机器人教学战 HP ≥1(锁血)');
}
assert(!consoleErrs.some((e) => e.includes('未捕获') || e.includes('TypeError')), '机器人全程无未捕获异常');
} finally { game.stop(); }
}
// ================================================================ 资产引用校验
function assetAudit() {
realLog('\n=== 资产引用校验 ===');
const src = fs.readFileSync(path.join(GAME_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}`);
if (s !== 'gate') staticIds.add(`voice/desc_${s}_re`); // gate/boss1 按设计无 _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 both = (rel) => ['.ogg', '.m4a'].every((ext) => fs.existsSync(path.join(AUDIO_DIR, rel + ext)));
// 待生成清单:新台词落盘前以 tools/voice_lines_26/rpg.md 登记为准
const pending = new Set();
const listPath = path.resolve(GAME_DIR, '..', '..', 'tools', 'voice_lines_26', 'rpg.md');
if (fs.existsSync(listPath)) {
for (const m of fs.readFileSync(listPath, 'utf8').matchAll(/"([a-z0-9_]+)",\s*_RPG/g)) pending.add(m[1]);
}
let missingSfx = [];
let missingVoice = [];
for (const id of staticIds) {
if (id.startsWith('voice/')) {
const name = id.slice(6);
const ok = both(`voice/female/${name}`) || both(`voice/male/${name}`) || both(`voice/${name}`)
|| pending.has(name);
if (!ok) missingVoice.push(id);
} else if (!both(id)) {
missingSfx.push(id);
}
}
assert(missingSfx.length === 0, `sfx/bgm 引用零缺失(${staticIds.size - missingVoice.length - missingSfx.length} 项在盘)`);
assert(missingVoice.length === 0,
`voice 引用零缺失(待生成 ${[...pending].filter((p) => !both(`voice/female/${p}`)).length} 项已登记于 voice_lines_26/rpg.md;缺失:${missingVoice.join(',') || '无'})`);
// NPC 平铺台词不改(npc_* 由 ENEMIES/MENU 静态引用,已在上面校验)
assert(pending.size > 0, `待生成清单已登记(${pending.size} 句)`);
}
// ================================================================ 主流程
try {
await scenario1();
await scenario2();
await scenario3();
await scenario4();
await scenario5();
await scenario6();
await scenario7();
await scenario8();
await scenario9();
} catch (e) {
failures.push(`未捕获异常: ${e.stack}`);
realLog(` ✗ 未捕获异常: ${e.stack}`);
}
assetAudit();
realLog('\n========================================');
realLog(`断言通过 ${passCount} 条,失败 ${failures.length} 条`);
if (failures.length) {
realLog('失败清单:');
for (const f of failures) realLog(` ✗ ${f}`);
}
process.exit(failures.length ? 1 : 0);