Files
SoundGame/web/escape/test/playtest.mjs
T

848 lines
36 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') === 0 && rec.count('sfx/win_chime') === 0,
'逃脱音效后移:人声头部 2.6s 内无 gate_open/win_chime 对撞(round2-live P2)');
assert(rec.count('voice/escape') === 1, 'escape 语音播出');
assert(playSecAtEscape < 120, `本次用时 ${playSecAtEscape}s < 120s`);
await tick(2600);
assert(rec.count('sfx/gate_open') === 1 && rec.count('sfx/win_chime') === 1,
'逃脱音效 2.6s 后播出(人声头部避让,轰鸣落在「电机轰鸣」附近)');
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 播放中单击 = 跳过本句 + 补发检查
// (round1-live:跳过后检查意图不丢——环境音即时重播,描述 800ms 后补播)
const skipsBefore = rec.skips();
const ambBefore = rec.count('sfx/amb_drip');
const startRoomBefore = rec.count('voice/start_room');
await gs(game, 'tap');
assert(rec.skips() === skipsBefore + 1, '检查描述播放中单击 = skipVoice');
assert(rec.count('sfx/amb_drip') === ambBefore + 1, '跳过后补发检查:环境音即时重播');
assert(rec.count('voice/start_room') === startRoomBefore, '补发的检查描述 800ms 内未播');
await tick(800);
assert(rec.count('voice/start_room') === startRoomBefore + 1, '跳过后补发检查:描述 800ms 后补播');
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();
}
// ================================================================
// 场景 A2:冷启动引导链回归(round2-live P1)——intro 后不点不滑,
// g1_move 必须自动播出;全程零单击(G0 不点)也能逐步走完 8 步引导
// ================================================================
section('A2. 冷启动引导链:intro 后 g1_move 自动播出,零单击走通(round2-live 回归)');
{
store.clear();
const { game, rec } = await newGame();
game.start();
await flush();
assert(rec.count('voice/g0_intro') === 1, 'g0_intro 播放');
await tick(VOICE_END_SLACK); // intro 播完(虚拟 1s 句)
assert(rec.count('voice/g1_move') === 0, 'intro 刚播完:g1_move 尚未播(1.2s 自动推进未到点)');
await tick(1200); // 自动推进到点
assert(rec.count('voice/g1_move') === 1, '无任何手势:intro 后 1.2s 自动播 g1_move(P1 回归断言)');
await finishVoices(2); // g1_move + skip_hint
assert(game.guideStep === 1 && !game._guideBusy, '自动进入 G1,输入解锁');
// intro 播放中滑动不顶开场白(对照:原"先跟我走"会 kill intro 且不再自动推进)
store.clear();
const g1b = await newGame();
g1b.game.start();
await flush();
await tick(400); // intro 播放中
await gs(g1b.game, 'right'); // 抢跑滑动:应被忽略,不顶 intro
assert(g1b.rec.count('voice/guide_offpath') === 0, 'intro 播放中滑动:不播 guide_offpath 顶开场白');
await tick(VOICE_END_SLACK + 1200);
assert(g1b.rec.count('voice/g1_move') === 1, 'intro 播放中滑动后:g1_move 仍自动播出');
g1b.game.stop();
// G1 下滑 = 作答(g1_move 明说"任意方向"),不再是跳过引导
await gs(game, 'down');
assert(game.phase === 'GUIDE' && game.guideStep !== 0, 'G1 下滑不跳过引导(仍在 GUIDE)');
assert(rec.count('voice/guide_skip') === 0, 'G1 下滑不播 guide_skip');
await finishVoices(2); // g1_done + g2_wall
assert(game.guideStep === 2, 'G1 下滑作答进入 G2');
// G2 下滑也计入撞墙滑动(再补一滑凑满 2 次推进)
await gs(game, 'down');
await gs(game, 'up');
await finishVoices(2); // wall + g3_left
assert(game.guideStep === 3, 'G2 下滑作答计入,两滑后进入 G3');
// 零单击走完全链:G3 左 → G4 下 → G5 单击(答题单击,非 G0 继续单击)
await gs(game, 'left');
await finishVoices(1); // g4_down
await gs(game, 'down'); // G4 下滑 = 作答
await finishVoices(1); // g5_inspect
assert(game.guideStep === 5, 'G4 下滑作答进入 G5');
await gs(game, 'tap'); // G5 检查拾钥匙
await finishVoices(2); // g5_key + g6_doubletap
await gs(game, 'doubletap'); // G6
await finishVoices(3); // inv_key + g6_done + g7_goal
assert(game.phase === 'PLAY', '冷启动零 G0 单击:教学链逐步走通进 PLAY');
game.stop();
// 下滑跳过在答案不含滑动的步骤仍生效(G3)
store.clear();
const g2b = await newGame();
g2b.game.start();
await flush();
await gs(g2b.game, 'tap'); // G0 → G1
await finishVoices(2);
await gs(g2b.game, 'up'); // G1
await finishVoices(2);
await gs(g2b.game, 'up'); // G2 ×2
await gs(g2b.game, 'up');
await finishVoices(2); // → G3
await gs(g2b.game, 'down'); // G3 答案 = 左滑,下滑 = 跳过
assert(g2b.rec.count('voice/guide_skip') === 1, 'G3 下滑仍跳过引导');
await tick(VOICE_END_SLACK);
assert(g2b.game.phase === 'PLAY', 'G3 下滑跳过直达 PLAY');
g2b.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');
assert(vibrateCount(WARN_PAT) === warn0 + 1, 'L1 发 warning 震动');
// L1 链可跳:播放中单击 = 跳过本句(链播 stuck_key_1)+ 补发检查(round1-live)
const skips0 = rec.skips();
const ambDrip0 = rec.count('sfx/amb_drip');
await gs(game, 'tap');
assert(rec.skips() === skips0 + 1, 'L1 提示链播放中单击 = 跳过本句');
await flush();
assert(rec.count('voice/stuck_key_1') === 1, '跳过后链播 stuck_key_1');
assert(rec.count('sfx/amb_drip') === ambDrip0 + 1, '跳过后补发检查:环境音即时重播');
await tick(800); // 补发描述到点:stuck_key_1 仍在播 → 进待播队列
assert(rec.count('voice/start_room') === 1, '链播未完,补发描述排队等待(不顶句)');
await finishVoices(1); // stuck_key_1 播完 → 补发描述开播
assert(rec.count('voice/start_room') === 2, '链播播完后补发检查描述');
await finishVoices(1); // 补发描述播完
// 自上次操作起连续无操作 120s → L2 明示;180s → L3 带路(stuck_key_3 + nav_left + nav_down)
await tick(125000);
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');
await finishVoices(1); // nav_down 播完
// L3 之后每 60s 无操作重复带路
await tick(60000);
assert(rec.count('voice/stuck_key_3') === 2, 'L3 每 60s 无操作重复带路');
await finishVoices(3);
// L3 带路不可跳:播放中单击 = 忽略(新一轮带路起播后验证)
await tick(60000);
assert(rec.count('voice/stuck_key_3') === 3, '持续无操作 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(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();
}
// ================================================================
// 场景 C2:活跃操作不触发卡关提示(round1-live:按"无操作时长"计时)
// ================================================================
section('C2. 活跃操作时不触发卡关提示');
{
store.clear();
store.set('soundgame.escape.guideDone', '1');
const { game, rec } = await newGame();
game.start();
await flush();
await gs(game, 'tap'); // REOPEN → PLAY
await finishVoices(1); // start_room
// 每 ~25s 一个普通移动手势(无进展事件),持续 ~250s:任何卡关提示都不应出现
for (let i = 0; i < 10; i++) {
await tick(25000);
await gs(game, i % 2 === 0 ? 'left' : 'right');
}
assert(game.x === 1 && game.y === 1, '往返移动 10 次回到 (1,1)');
assert(rec.count('voice/stuck_ask') === 0, '活跃操作 250s:无 L1(stuck_ask)');
assert(
rec.count('voice/stuck_key_1') === 0 && rec.count('voice/stuck_key_2') === 0 && rec.count('voice/stuck_key_3') === 0,
'活跃操作 250s:无 L2/L3 提示(机关枪回归断言)',
);
// 停止操作 60s 后 L1 如期而至:计时器仍武装
await tick(65000);
assert(rec.count('voice/stuck_ask') === 1, '停止操作 60s 后播 L1');
game.stop();
}
// ================================================================
// 场景 C3:提示排队礼仪——不顶掉进行中的语音;待播期间玩家操作即作废
// ================================================================
section('C3. 卡关提示排队:不顶语音、操作作废');
{
store.clear();
store.set('soundgame.escape.guideDone', '1');
const { audio, game, rec } = await newGame();
game.start();
await flush();
await gs(game, 'tap'); // REOPEN → PLAY
await finishVoices(1); // start_room
// —— 语音播放中到点:提示入队,等当前句自然播完 ——
await gs(game, 'tap'); // 单击检查(无语音 → 直接检查)
await tick(800); // 检查描述开播(start_room,虚拟时长 1s)
assert(audio.isVoicePlaying(), '前提:检查描述播放中');
game._lastActiveAt = now - 61000; // 测试钩子:回拨无操作计时(模拟已 61s 无操作)
game._checkStuck(); // 直调:等效卡关轮询到点
assert(rec.count('voice/stuck_ask') === 0, '语音播放中 L1 只入队不开播');
assert(audio.isVoicePlaying(), '进行中的语音未被顶掉');
await finishVoices(1); // 描述自然播完
assert(rec.count('voice/stuck_ask') === 1, '语音播完后 L1 补播 stuck_ask');
const desc = rec.last('voice/start_room');
const hint = rec.last('voice/stuck_ask');
assert(hint.t >= desc.t + 1000, 'stuck_ask 在描述自然播完之后才开播(未 kill)');
await finishVoices(2); // stuck_ask + stuck_key_1 播完
// —— 待播期间玩家操作:提示作废,且不按已播计入 ——
await gs(game, 'tap'); // 再次检查(无语音 → 直接检查)
await tick(800); // 描述开播
game._lastActiveAt = now - 121000; // 钩子:121s 无操作 → L2 到点
game._checkStuck();
assert(rec.count('voice/stuck_key_2') === 0, '语音播放中 L2 只入队不开播');
await gs(game, 'tap'); // 玩家操作(跳过描述 + 补发检查)→ 待播 L2 作废
await finishVoices(2);
assert(rec.count('voice/stuck_key_2') === 0, '玩家操作后待播 L2 不再播出');
assert(!game._stuckShown.has('L2'), '作废的 L2 不计入已播级别');
game.stop();
}
// ================================================================
// 场景 C4:重开后高频操作零卡关提示(round2-live:复测 stuck_key_2 实为
// 双击兜底主动求提示,非计时器——本场景冻结计时器语义:重开重置无操作计时)
// ================================================================
section('C4. 重开后高频操作 90s 零卡关提示(round2-live 回归)');
{
store.clear();
store.set('soundgame.escape.guideDone', '1');
const { game, rec } = await newGame();
game.start();
await flush();
await gs(game, 'tap'); // REOPEN → PLAY
await finishVoices(1);
game._restart(); // 结算/菜单重开路径:计时必须重置
await flush();
assert(rec.count('voice/replay') === 1, '重开播 replay');
await finishVoices(1);
// 每秒一个操作(滑动/单击交替,不用双击——双击无目标是设计内主动求提示),
// 持续 90s:任何卡关提示都不应出现
for (let i = 0; i < 90; i++) {
await tick(1000);
await gs(game, i % 2 === 0 ? 'left' : 'tap');
if (i % 2 === 1) await tick(900); // 单击补发描述到点播出,下一句前消化
}
assert(rec.count('voice/stuck_ask') === 0, '重开后高频操作 90s:无 L1(stuck_ask)');
assert(
rec.count('voice/stuck_key_1') === 0 && rec.count('voice/stuck_key_2') === 0 && rec.count('voice/stuck_key_3') === 0,
'重开后高频操作 90s:无 L2/L3(复测 stuck_key_2 = 双击兜底,非计时器)',
);
// 停手 60s 后 L1 如期而至:重开后计时器仍武装
await tick(66000);
assert(rec.count('voice/stuck_ask') === 1, '重开后停手 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);