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 置完成。
1061 lines
53 KiB
JavaScript
1061 lines
53 KiB
JavaScript
/**
|
||
* 《剑与声》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);
|