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

647 lines
26 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 《暗室》无头试玩测试 —— mock 浏览器环境 + 虚拟时钟,以玩家手势跑通
* docs/design/13-playtest-criteria.md §2 的关键路径与边界断言。
*
* 运行:tools/node/bin/node web/escape/test/playtest.mjs
*
* 设计:
* - 真实引擎(engine/audio.js + settings.js + vendor/soundtouch.js),
* mock 的是浏览器 API 层(AudioContext/fetch/localStorage/navigator/window)。
* - decodeAudioData 返回带 duration 的假 buffer(fetch 把时长编进 ArrayBuffer
* 头 8 字节);voice 自然结束由引擎内部定时器驱动(duration ÷ tempo),
* 虚拟时钟推进即触发 onComplete,语音链随之推进。
* - 手势直接在 game 实例上注入 onGesture(不过 gesture.js 的触摸判定)。
* - 全程虚拟时钟:setTimeout/setInterval/Date.now/performance.now 都走
* 手动推进的逻辑时钟,测试毫秒级跑完、完全可重复(本游戏无随机)。
*/
import { readFileSync, existsSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
const HERE = dirname(fileURLToPath(import.meta.url)); // web/escape/test
const GAME_DIR = join(HERE, '..'); // web/escape
// ---------- 虚拟时钟(必须先于引擎模块加载安装) ----------
let now = 0;
let timerSeq = 1;
const timers = new Map(); // id -> { at, fn, interval|null }
globalThis.setTimeout = (fn, ms = 0) => {
const id = timerSeq++;
timers.set(id, { at: now + ms, fn, interval: null });
return id;
};
globalThis.clearTimeout = (id) => { timers.delete(id); };
globalThis.setInterval = (fn, ms = 0) => {
const id = timerSeq++;
timers.set(id, { at: now + ms, fn, interval: ms });
return id;
};
globalThis.clearInterval = (id) => { timers.delete(id); };
Date.now = () => now;
Object.defineProperty(globalThis, 'performance', { value: { now: () => now }, configurable: true });
/** 推进虚拟时钟 ms,按序触发到期定时器(interval 自动重排)。 */
function advance(ms) {
const end = now + ms;
for (;;) {
let nextId = null;
let nextAt = Infinity;
for (const [id, t] of timers) {
if (t.at <= end && t.at < nextAt) { nextAt = t.at; nextId = id; }
}
if (nextId === null) break;
const t = timers.get(nextId);
now = t.at;
if (t.interval !== null) t.at = now + t.interval;
else timers.delete(nextId);
t.fn();
}
now = end;
}
/** 刷新微任务队列(fetch/decode/语音链接续都是 promise 驱动)。 */
async function flush() {
for (let i = 0; i < 30; i++) await new Promise((r) => setImmediate(r));
}
// ---------- 浏览器环境 mock ----------
const vibrateLog = [];
const fetchLog = [];
let blockedAssets = []; // 命中即 404(模拟资产缺失)
Object.defineProperty(globalThis, 'navigator', {
value: { vibrate: (p) => vibrateLog.push(p) }, configurable: true,
});
const store = new Map();
Object.defineProperty(globalThis, 'localStorage', {
value: {
getItem: (k) => (store.has(k) ? store.get(k) : null),
setItem: (k, v) => store.set(k, String(v)),
removeItem: (k) => store.delete(k),
clear: () => store.clear(),
},
configurable: true,
});
Object.defineProperty(globalThis, 'window', {
value: {
addEventListener: () => {},
removeEventListener: () => {},
AudioContext: null, // 下方填入
location: { href: '' },
},
configurable: true,
});
Object.defineProperty(globalThis, 'document', {
value: {
createElement: () => ({
style: { setProperty: () => {} },
classList: { add: () => {}, remove: () => {} },
appendChild: () => {}, remove: () => {}, addEventListener: () => {},
}),
body: { appendChild: () => {} },
getElementById: () => null,
},
configurable: true,
});
const LAYER_DURATION = { voice: 1.0, sfx: 0.3, bgm: 60 };
Object.defineProperty(globalThis, 'fetch', {
value: async (url) => {
fetchLog.push(String(url));
if (blockedAssets.some((b) => String(url).includes(b))) {
return { ok: false, status: 404, arrayBuffer: async () => new ArrayBuffer(0) };
}
const u = String(url);
const layer = u.includes('/voice/') ? 'voice' : u.includes('/bgm/') ? 'bgm' : 'sfx';
const buf = new ArrayBuffer(8);
new Float64Array(buf)[0] = LAYER_DURATION[layer];
return { ok: true, arrayBuffer: async () => buf };
},
configurable: true,
});
class MockAudioParam {
constructor(v = 0) { this.value = v; }
cancelScheduledValues() {}
setValueAtTime() {}
linearRampToValueAtTime() {}
}
class MockNode {
constructor() { this.gain = new MockAudioParam(1); }
connect() {}
disconnect() {}
}
class MockAudioContext {
constructor() {
this.state = 'running';
this.currentTime = 0;
this.destination = {};
this.sampleRate = 44100;
}
createGain() { return new MockNode(); }
createDynamicsCompressor() {
const n = new MockNode();
for (const k of ['threshold', 'ratio', 'attack', 'release', 'knee', 'reduction']) n[k] = new MockAudioParam(0);
return n;
}
createBufferSource() {
const n = new MockNode();
n.buffer = null; n.loop = false;
n.start = () => {}; n.stop = () => {};
return n;
}
createScriptProcessor() { const n = new MockNode(); n.onaudioprocess = null; return n; }
decodeAudioData(raw) {
const duration = new Float64Array(raw)[0] || 1;
return Promise.resolve({
duration,
numberOfChannels: 1,
sampleRate: 44100,
length: Math.ceil(duration * 44100),
getChannelData: () => new Float32Array(Math.ceil(duration * 44100)),
});
}
resume() { this.state = 'running'; return Promise.resolve(); }
}
window.AudioContext = MockAudioContext;
// ---------- 加载引擎与游戏(globals 就绪后动态 import) ----------
const { WebAudioEngine } = await import('../../engine/audio.js');
const { settings } = await import('../../engine/settings.js');
const { EscapeGame } = await import('../game.js');
// 向导层关闭:回归测试只验证纯游戏逻辑(guide='full' 的行为由 harness/guide.test.mjs 覆盖)
settings.set({ guide: 'off', voice: 'female', speed: 1.0, bgm: 1.0 });
// ---------- 断言与辅助 ----------
let failures = 0;
let passes = 0;
function assert(cond, msg) {
if (cond) { passes++; console.log(' ✓', msg); }
else { failures++; console.error(' ✗ FAIL:', msg); }
}
function section(title) { console.log('\n== ' + title); }
/** play 调用记录 + skipVoice 计数。 */
function instrument(audio) {
const log = [];
const origPlay = audio.play.bind(audio);
audio.play = (id, opts = {}) => { log.push({ t: now, id, opts }); return origPlay(id, opts); };
const origSkip = audio.skipVoice.bind(audio);
let skips = 0;
audio.skipVoice = () => { skips++; return origSkip(); };
return {
log,
skips: () => skips,
count: (id) => log.filter((e) => e.id === id).length,
last: (id) => [...log].reverse().find((e) => e.id === id),
};
}
async function newGame() {
const audio = new WebAudioEngine('audio/');
await audio.unlock();
const game = new EscapeGame(audio, {});
return { audio, game, rec: instrument(audio) };
}
async function gs(game, g) { game.onGesture(g); await flush(); }
async function tick(ms) { advance(ms); await flush(); }
/** 播完 n 句 voice(每句 1s + 120ms 余量)。 */
async function finishVoices(n = 1) { for (let i = 0; i < n; i++) await tick(1300); }
const vibrateCount = (pattern) => vibrateLog.filter((p) => JSON.stringify(p) === JSON.stringify(pattern)).length;
const ERR_PAT = [60, 40, 60];
const WARN_PAT = [30, 50, 30, 50, 30];
const VOICE_END_SLACK = 1300; // 一句 voice 的虚拟时长
// ================================================================
// 场景 A:关键路径 —— 首局引导 G0-G7 → PLAY 解谜链 → 逃脱结算 → 菜单 → 重开
// ================================================================
section('A. 关键路径:引导 → 解谜 → 逃脱 → 结算 → 菜单 → 重开');
{
store.clear();
const { audio, game, rec } = await newGame();
game.start();
await flush();
assert(game.phase === 'GUIDE', '启动进入 GUIDE');
assert(rec.count('bgm/drone') === 1, '开局播 bgm/drone');
assert(rec.count('voice/g0_intro') === 1, 'g0_intro 播放');
await tick(VOICE_END_SLACK); // g0 播完
// G0:单击 → G1(链播 g1_move + skip_hint 首局插播)
await gs(game, 'tap');
assert(rec.count('voice/g1_move') === 1, 'G0 单击后播 g1_move');
await tick(VOICE_END_SLACK);
assert(rec.count('voice/skip_hint') === 1, 'skip_hint 在 G0/G1 附近插播一次');
await tick(VOICE_END_SLACK);
assert(game.guideStep === 1 && !game._guideBusy, '进入 G1,输入解锁');
// G1:任意滑动 → 脚步声
const stepsBefore = rec.count('sfx/step');
await gs(game, 'up');
assert(rec.count('sfx/step') === stepsBefore + 1, 'G1 滑动播脚步声');
await finishVoices(2); // g1_done + g2_wall
assert(game.guideStep === 2, '进入 G2');
// G2:滑动 ×2 撞墙反馈(避开下滑——引导期任何步骤下滑 = 跳过引导)
const wallsBefore = rec.count('sfx/wall');
const errBefore = vibrateCount(ERR_PAT);
await gs(game, 'up');
await gs(game, 'right');
assert(rec.count('sfx/wall') === wallsBefore + 2, 'G2 两滑各播 sfx/wall');
assert(vibrateCount(ERR_PAT) === errBefore + 2, 'G2 两滑各发 error 震动');
await finishVoices(2); // wall + g3_left
assert(game.guideStep === 3, '进入 G3');
// G3:左滑 → (0,1)
await gs(game, 'left');
assert(game.x === 0 && game.y === 1, 'G3 左滑移到 (0,1)');
await finishVoices(1); // g4_down
assert(game.guideStep === 4, '进入 G4');
// G4:下滑 → (0,2) 储物间
await gs(game, 'down');
assert(game.x === 0 && game.y === 2, 'G4 下滑移到 (0,2)');
assert(rec.count('sfx/amb_storage') >= 1, '储物间环境音');
await finishVoices(1); // g5_inspect
assert(game.guideStep === 5, '进入 G5');
// G5:单击拾钥匙
await gs(game, 'tap');
assert(game.hasKey === true, 'G5 拾得钥匙 hasKey === true');
await finishVoices(2); // g5_key + g6_doubletap
assert(game.guideStep === 6, '进入 G6');
// G6:双击 → G7 → 引导完成进 PLAY
await gs(game, 'doubletap');
await finishVoices(3); // inv_key + g6_done + g7_goal → onDone → _finishGuide
assert(game.phase === 'PLAY', '引导完成进入 PLAY');
assert(store.get('soundgame.escape.guideDone') === '1', "localStorage guideDone === '1'");
// ---- PLAY 解谜链:(0,2) → 铁柜房 (2,1) → 电箱房 (0,0) → 大门 (2,2) ----
await gs(game, 'right');
assert(game.x === 1 && game.y === 2, '右滑到 (1,2)');
await gs(game, 'right');
assert(game.x === 2 && game.y === 2, '右滑到 (2,2)');
assert(rec.count('voice/room_gate') === 1, '首到大门播 room_gate');
await finishVoices(1);
await gs(game, 'up');
assert(game.x === 2 && game.y === 1, '上滑到铁柜房 (2,1)');
assert(rec.count('voice/room_cabinet') === 1, '首到铁柜房播 room_cabinet');
await finishVoices(1);
// 双击开柜 → 保险丝;unlock_cabinet 为关键获得不可跳
await gs(game, 'doubletap');
assert(game.cabinetOpen === true && game.hasFuse === true, '开柜得保险丝 cabinetOpen && hasFuse');
assert(game.hasKey === false, '钥匙被消耗');
await flush();
assert(!!audio._voice, 'unlock_cabinet 播放中');
const skipsAtUnlock = rec.skips();
const ambAtUnlock = rec.count('sfx/amb_cabinet');
await gs(game, 'tap'); // 不可跳句播放中单击 = 忽略
assert(rec.skips() === skipsAtUnlock, 'unlock_cabinet 不可单击跳过');
assert(rec.count('sfx/amb_cabinet') === ambAtUnlock, '不可跳句播放中单击不触发检查');
await finishVoices(1);
// 左滑×2 + 上滑 → 电箱房 (0,0)
await gs(game, 'left');
await gs(game, 'left');
await gs(game, 'up');
assert(game.x === 0 && game.y === 0, '到达电箱房 (0,0)');
assert(rec.count('voice/room_power') === 1, '首到电箱房播 room_power');
await finishVoices(1);
// 双击修电箱 → 通电
await gs(game, 'doubletap');
assert(game.powerOn === true, '修复电箱 powerOn === true');
assert(rec.count('sfx/spark') === 1 && rec.count('sfx/power_on') === 1, '修电箱音效 spark + power_on');
await finishVoices(1); // fix_box
// 右滑×2 + 下滑×2 → 大门 (2,2),通电后环境音切 amb_gate_live
await gs(game, 'right');
await gs(game, 'right');
await gs(game, 'down');
await gs(game, 'down');
assert(game.x === 2 && game.y === 2, '到达大门 (2,2)');
assert(rec.count('sfx/amb_gate_live') >= 1, '通电后大门播 amb_gate_live');
// 双击逃脱 → ENDING
const playSecAtEscape = Math.floor((now - game.startElapsedMs) / 1000);
await gs(game, 'doubletap');
assert(game.phase === 'ENDING', '双击大门逃脱,进入 ENDING');
assert(rec.count('sfx/gate_open') === 1 && rec.count('sfx/win_chime') === 1, '逃脱音效 gate_open + win_chime');
assert(rec.count('voice/escape') === 1, 'escape 语音播出');
assert(playSecAtEscape < 120, `本次用时 ${playSecAtEscape}s < 120s`);
await finishVoices(1);
assert(rec.count('voice/time_band1') === 1, 'time_band1 语音播出(<120s 档)');
await finishVoices(2); // time_band1 + again → 链完 → 结算 pad
const menuPad = rec.last('bgm/menu');
assert(!!menuPad && menuPad.opts.volume === 0.5 && menuPad.opts.fadeMs === 1500,
'逃脱语音链播完淡入 bgm/menu(volume 0.5 / fadeMs 1500)');
// ENDING 下滑 → 菜单:切 bgm/menu(400ms)
await gs(game, 'down');
assert(game.phase === 'MENU', 'ENDING 下滑打开菜单');
const menuOpen = rec.last('bgm/menu');
assert(menuOpen && menuOpen.opts.fadeMs === 400, '进菜单 crossfade 400ms 播 bgm/menu');
await finishVoices(2); // menu_open + menu_restart
// 菜单约定:单击循环 / 上滑帮助 / 双击确认(选"返回")
await gs(game, 'tap');
assert(rec.count('voice/menu_home') === 1, '菜单单击循环到 menu_home');
await gs(game, 'up');
assert(rec.count('voice/help_ops') === 1, '菜单上滑播操作说明');
await finishVoices(1);
await gs(game, 'tap');
assert(rec.count('voice/menu_back') === 1, '菜单单击循环到 menu_back');
await finishVoices(1);
await gs(game, 'doubletap'); // idx=2 返回
assert(game.phase === 'ENDING', '菜单双击"返回"回到结算');
// 菜单约定:下滑 = 关闭菜单
await gs(game, 'down');
assert(game.phase === 'MENU', '再次下滑打开菜单');
await gs(game, 'down');
assert(game.phase === 'ENDING', '菜单下滑关闭回到结算');
// 结算单击重开 → PLAY + bgm/drone
const droneBefore = rec.count('bgm/drone');
await gs(game, 'tap');
assert(game.phase === 'PLAY', '结算单击重开回 PLAY');
assert(rec.count('bgm/drone') === droneBefore + 1, '重开切回 bgm/drone');
assert(rec.count('voice/replay') === 1, '重开播 replay');
assert(game.x === 1 && game.y === 1 && game.hasKey === false, '重开状态复位');
await finishVoices(1);
// ---- 边界:单击检查 = 环境音先播、描述 800ms 后播 ----
const ambDripBefore = rec.count('sfx/amb_drip');
await gs(game, 'tap');
assert(rec.count('sfx/amb_drip') === ambDripBefore + 1, '检查先播环境音 amb_drip');
assert(rec.count('voice/start_room') === 0, '描述 800ms 内未播');
await tick(800);
assert(rec.last('voice/start_room') && rec.last('voice/start_room').t - rec.last('sfx/amb_drip').t === 800,
'描述在环境音 800ms 后播');
// start_room 是检查描述(可跳):voice 播放中单击 = 跳过本句
const skipsBefore = rec.skips();
const ambBefore = rec.count('sfx/amb_drip');
await gs(game, 'tap');
assert(rec.skips() === skipsBefore + 1, '检查描述播放中单击 = skipVoice');
assert(rec.count('sfx/amb_drip') === ambBefore, '跳过时不触发检查');
await tick(200);
// ---- 边界:撞墙 (0,0) 左滑 → wall + error 震动 + 位置不变 + 无脚步声 ----
await gs(game, 'left'); // (0,1)
await gs(game, 'up'); // (0,0)
const stepCnt = rec.count('sfx/step');
const wallCnt = rec.count('sfx/wall');
const errCnt = vibrateCount(ERR_PAT);
await gs(game, 'left'); // 撞墙
assert(game.x === 0 && game.y === 0, '撞墙位置不变');
assert(rec.count('sfx/wall') === wallCnt + 1, '撞墙播 sfx/wall');
assert(vibrateCount(ERR_PAT) === errCnt + 1, '撞墙发 error 震动');
assert(rec.count('sfx/step') === stepCnt, '撞墙不播脚步声');
assert(rec.count('voice/wall') >= 1, '撞墙播 wall 语音');
await finishVoices(1);
// ---- 边界:双击无目标兜底 = 背包/use_fail + L2 明示,不崩 ----
const errCnt2 = vibrateCount(ERR_PAT);
await gs(game, 'doubletap'); // 电箱房无保险丝
assert(vibrateCount(ERR_PAT) === errCnt2 + 1, '双击无目标发 error 震动');
assert(rec.count('voice/use_fail') === 1, '兜底播 use_fail(关键房间)');
await finishVoices(1);
assert(rec.count('voice/stuck_key_2') === 1, '兜底追加 L2 明示 stuck_key_2');
await finishVoices(1);
game.stop();
}
// ================================================================
// 场景 B:引导错手势纠错 + 连错 3 次直白重述 + 下滑跳过引导
// ================================================================
section('B. 引导纠错与跳过');
{
store.clear();
vibrateLog.length = 0;
const { game, rec } = await newGame();
game.start();
await flush();
await tick(VOICE_END_SLACK); // g0
await gs(game, 'tap'); // G0 → G1
await finishVoices(2); // g1_move + skip_hint
await gs(game, 'up'); // G1
await finishVoices(2); // g1_done + g2_wall
await gs(game, 'up'); // G2 ×2
await gs(game, 'up');
await finishVoices(2); // wall + g3_left
assert(game.guideStep === 3, '到达 G3');
// G3 右滑(应为左)→ guide_wrong_left;连错 3 次 → guide_blunt_left + warning 震动
const warnBefore = vibrateCount(WARN_PAT);
await gs(game, 'right');
assert(rec.count('voice/guide_wrong_left') === 1, 'G3 右滑播 guide_wrong_left');
await gs(game, 'right');
await gs(game, 'right');
assert(rec.count('voice/guide_blunt_left') === 1, '连错 3 次播 guide_blunt_left');
assert(vibrateCount(WARN_PAT) === warnBefore + 1, '连错 3 次发 warning 震动');
assert(game.guideStep === 3, '纠错不离步(仍 G3)');
await gs(game, 'left');
assert(game.guideStep === 3 && game._guideBusy, '左滑后正常推进(G4 语音中)');
game.stop();
// 下滑跳过引导
store.clear();
const g2 = await newGame();
g2.game.start();
await flush();
await gs(g2.game, 'down');
assert(g2.rec.count('voice/guide_skip') === 1, '引导期下滑播 guide_skip');
await tick(VOICE_END_SLACK);
assert(g2.game.phase === 'PLAY', '下滑跳过引导直达 PLAY');
assert(store.get('soundgame.escape.guideDone') === '1', '跳过引导也写 guideDone');
assert(g2.rec.count('voice/start_room') === 1, '跳过引导播 start_room');
g2.game.stop();
}
// ================================================================
// 场景 C:卡关三级提示(60s/120s/180s + nav 带路 + 进展重置 + 跳过裁定)
// ================================================================
section('C. 卡关提示计时器');
{
store.clear();
store.set('soundgame.escape.guideDone', '1'); // 二周目直达
vibrateLog.length = 0;
const { game, rec } = await newGame();
game.start();
await flush();
assert(game.phase === 'REOPEN', '二周目进 REOPEN');
assert(rec.count('voice/reopen_prompt') === 1, '播 reopen_prompt');
await gs(game, 'tap');
assert(game.phase === 'PLAY', 'REOPEN 单击直接进 PLAY');
await finishVoices(1); // start_room
// 60s 无进展 → L1(stuck_ask + stuck_key_1)
const warn0 = vibrateCount(WARN_PAT);
await tick(60000);
assert(rec.count('voice/stuck_ask') === 1, '60s 播 stuck_ask');
// L1 可跳:stuck_ask 播放中单击 → 链进 stuck_key_1
const skips0 = rec.skips();
await gs(game, 'tap');
assert(rec.skips() === skips0 + 1, 'L1 提示可单击跳过');
await flush();
assert(rec.count('voice/stuck_key_1') === 1, '跳过后链播 stuck_key_1');
assert(vibrateCount(WARN_PAT) === warn0 + 1, 'L1 发 warning 震动');
await finishVoices(1);
// 120s → L2;180s → L3 带路(stuck_key_3 + nav_left + nav_down)
await tick(60000);
assert(rec.count('voice/stuck_key_2') === 1, '120s 播 stuck_key_2');
await finishVoices(1);
await tick(60000);
assert(rec.count('voice/stuck_key_3') === 1, '180s 播 stuck_key_3');
await finishVoices(1); // stuck_key_3 播完 → nav_left 起
assert(rec.count('voice/nav_left') === 1, 'L3 带路算路:(1,1)→(0,2) 先播 nav_left');
await finishVoices(1); // nav_left 播完 → nav_down 起
assert(rec.count('voice/nav_down') === 1, 'L3 带路算路:再播 nav_down');
// L3 带路不可跳:播放中单击 = 忽略
const skips1 = rec.skips();
const ambC = rec.count('sfx/amb_drip');
await gs(game, 'tap');
assert(rec.skips() === skips1, 'L3 带路语音不可单击跳过');
assert(rec.count('sfx/amb_drip') === ambC, 'L3 播放中单击不触发检查');
await finishVoices(1); // nav_down 播完
// L3 之后每 60s 重复带路
await tick(60000);
assert(rec.count('voice/stuck_key_3') === 2, 'L3 每 60s 重复带路');
await finishVoices(3);
// 进展事件重置:移动到关键房间 → _stuckShown 清空 → 再无进展约 60s 重新从 L1 起
// (卡关检查每 5s 一次,相位对齐需略多于 60s)
await gs(game, 'right'); // (2,1) 铁柜房,首到 = 进展
assert(game._stuckShown.size === 0, '进展事件后 _stuckShown 清空');
await finishVoices(1); // room_cabinet
await tick(66000);
assert(rec.count('voice/stuck_ask') === 2, '进展重置后 60s 重新播 L1');
game.stop();
}
// ================================================================
// 场景 D:语音资产缺失 → 20s 看门狗兜底推进,不卡死
// ================================================================
section('D. 资产缺失看门狗(屏蔽 g4_down)');
{
store.clear();
blockedAssets = ['g4_down'];
const { game } = await newGame();
game.start();
await flush();
await tick(VOICE_END_SLACK);
await gs(game, 'tap'); // G0
await finishVoices(2);
await gs(game, 'up'); // G1
await finishVoices(2);
await gs(game, 'up'); // G2 ×2
await gs(game, 'up');
await finishVoices(2);
await gs(game, 'left'); // G3 → _advance(4, ['voice/g4_down']) 资产缺失
await tick(2000);
assert(game.guideStep === 3 && game._guideBusy, 'g4_down 缺失:链不推进,停在 G3 等待');
await tick(20000); // 看门狗
assert(game.guideStep === 4 && !game._guideBusy, '20s 看门狗兜底推进到 G4');
// 继续走完引导,确认不卡死
await gs(game, 'down'); // G4
await finishVoices(1);
await gs(game, 'tap'); // G5
await finishVoices(2);
await gs(game, 'doubletap'); // G6
await finishVoices(3);
assert(game.phase === 'PLAY', '资产缺失下仍走完引导进 PLAY');
blockedAssets = [];
game.stop();
}
// ================================================================
// 场景 E:设置边界(bgm=0 静默/恢复、voice 性别切换、speed 1.7)
// ================================================================
section('E. 设置边界');
{
store.clear();
store.set('soundgame.escape.guideDone', '1');
const { audio, game } = await newGame();
game.start();
await flush();
await gs(game, 'tap'); // REOPEN → PLAY
await finishVoices(1);
assert(audio._bgm && audio._bgm.id === 'bgm/drone', 'PLAY 中 BGM 为 drone');
settings.set({ bgm: 0 });
await tick(300);
assert(audio._bgm === null, 'bgm=0:进行中的 BGM 停止');
assert(audio._bgmWanted && audio._bgmWanted.id === 'bgm/drone', 'bgm=0:保留"想播"意图');
settings.set({ bgm: 1.0 });
await flush();
assert(audio._bgm && audio._bgm.id === 'bgm/drone', 'bgm 调回自动补播 drone');
settings.set({ voice: 'male' });
const fetchBase = fetchLog.length;
await gs(game, 'tap'); // 检查 → 描述用男声目录
await tick(800);
await flush();
assert(fetchLog.slice(fetchBase).some((u) => u.includes('/voice/male/')), 'voice=male 后从 male 目录加载');
settings.set({ speed: 1.7 });
await tick(VOICE_END_SLACK); // 让当前句播完
await gs(game, 'tap'); // 再触发一句检查描述
await tick(800);
await flush();
assert(audio._voice && audio._voice.st.tempo === 1.7, 'speed=1.7 下一句 tempo 生效');
settings.set({ voice: 'female', speed: 1.0, bgm: 1.0 }); // 复位共享单例
game.stop();
}
// ================================================================
// 资产引用校验:game.js 引用的 id 在音频目录有 ogg + m4a(voice 双性别),零缺失
// ================================================================
section('F. 资产引用校验');
{
const src = readFileSync(join(GAME_DIR, 'game.js'), 'utf8');
const ids = new Set();
for (const m of src.matchAll(/(voice|sfx|bgm)\/[a-z0-9_]+/g)) {
if (!m[0].endsWith('_')) ids.add(m[0]); // 模板前缀(stuck_ 等)单独展开
}
// 模板字符串族展开
for (const d of ['up', 'down', 'left', 'right']) {
ids.add(`voice/guide_wrong_${d}`);
ids.add(`voice/guide_blunt_${d}`);
}
for (const st of ['key', 'cabinet', 'box', 'gate']) {
for (const lv of [1, 2, 3]) ids.add(`voice/stuck_${st}_${lv}`);
}
let missing = 0;
for (const id of [...ids].sort()) {
const [layer, name] = id.split('/');
const bases = layer === 'voice'
? [`audio/voice/male/${name}`, `audio/voice/female/${name}`]
: [`audio/${layer}/${name}`];
for (const base of bases) {
for (const ext of ['.ogg', '.m4a']) {
if (!existsSync(join(GAME_DIR, base + ext))) {
missing++;
console.error(' ✗ 缺失资产:', base + ext);
}
}
}
}
assert(missing === 0, `资产引用校验:${ids.size} 个 id 全部存在(ogg+m4a${''},voice 双性别),零缺失`);
}
// ================================================================
console.log(`\n========== 结果:${passes} 通过 / ${failures} 失败 ==========`);
process.exit(failures === 0 ? 0 : 1);