848 lines
36 KiB
JavaScript
848 lines
36 KiB
JavaScript
/**
|
||
* 《暗室》无头试玩测试 —— 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);
|