620 lines
27 KiB
JavaScript
620 lines
27 KiB
JavaScript
/**
|
||
* playtest.mjs — 《音序回响》无头试玩仿真测试(docs/design/13-playtest-criteria.md §3 + §7)。
|
||
*
|
||
* 思路:mock 浏览器环境(window/document/navigator/localStorage/fetch/AudioContext),
|
||
* 加载**真实** engine/audio.js(拿到真实 skipVoice/onComplete/设置语义)与 game.js,
|
||
* 用虚拟时钟推进所有 setTimeout(语音链结束定时器也在其列),手势直接注入
|
||
* game.onGesture(即引擎回调路径)。Math.random 注入种子,全流程可重复。
|
||
*
|
||
* 运行:tools/node/bin/node web/simon/test/playtest.mjs
|
||
*/
|
||
import fs from 'node:fs';
|
||
import path from 'node:path';
|
||
import { fileURLToPath } from 'node:url';
|
||
|
||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); // web/simon
|
||
|
||
// ---------------------------------------------------------------- 虚拟时钟
|
||
let nowMs = 0;
|
||
const timers = new Map();
|
||
let nextTimerId = 1;
|
||
|
||
function vSetTimeout(fn, ms) {
|
||
const id = nextTimerId++;
|
||
timers.set(id, { at: nowMs + (ms || 0), fn });
|
||
return id;
|
||
}
|
||
function vClearTimeout(id) { timers.delete(id); }
|
||
|
||
async function flush(rounds = 50) {
|
||
for (let i = 0; i < rounds; i++) await Promise.resolve();
|
||
}
|
||
|
||
/** 推进虚拟时钟 ms,沿途触发到期定时器(含级联)。 */
|
||
async function advance(ms) {
|
||
const target = nowMs + ms;
|
||
for (;;) {
|
||
let bestId = null;
|
||
let bestAt = Infinity;
|
||
for (const [id, t] of timers) {
|
||
if (t.at <= target && t.at < bestAt) { bestAt = t.at; bestId = id; }
|
||
}
|
||
if (bestId === null) break;
|
||
const t = timers.get(bestId);
|
||
timers.delete(bestId);
|
||
nowMs = t.at;
|
||
t.fn();
|
||
await flush();
|
||
}
|
||
nowMs = target;
|
||
await flush();
|
||
}
|
||
|
||
async function advanceUntil(cond, { step = 25, max = 60000, desc = '' } = {}) {
|
||
let t = 0;
|
||
while (!cond()) {
|
||
if (t >= max) {
|
||
(globalThis.__origError || console.error)(`[debug] phase=${game?.phase} teachIdx=${game?.teachIdx} teachAwait=${game?.teachAwait} teachErrs=${game?.teachErrs} round=${game?.round} voice=${lastVoice?.() ?? '?'} voicePlaying=${!!audio?._voice} timers=${timers.size}`);
|
||
throw new Error(`advanceUntil 超时(${max}ms):${desc}`);
|
||
}
|
||
await advance(step);
|
||
t += step;
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------- 浏览器 mock
|
||
const vibLog = [];
|
||
const fetchLog = [];
|
||
const warnLog = [];
|
||
const errorLog = [];
|
||
const blockedAssets = new Set(); // 子串匹配,命中的资产 fetch 返回 !ok
|
||
const bufDuration = new Map(); // ArrayBuffer -> 秒
|
||
|
||
function durFor(url) {
|
||
if (url.includes('voice/')) return 1.0;
|
||
if (url.includes('sfx/gameover')) return 0.95;
|
||
if (url.includes('sfx/tone')) return 0.3;
|
||
if (url.includes('bgm/')) return 90;
|
||
return 1.0;
|
||
}
|
||
|
||
function fakeEl() {
|
||
return {
|
||
id: '', className: '', textContent: '', src: '', alt: '', offsetWidth: 0,
|
||
style: { setProperty() {} },
|
||
children: [],
|
||
appendChild(c) { this.children.push(c); },
|
||
remove() {},
|
||
addEventListener() {},
|
||
};
|
||
}
|
||
|
||
const winListeners = new Map();
|
||
const windowMock = {
|
||
addEventListener(type, fn) {
|
||
if (!winListeners.has(type)) winListeners.set(type, []);
|
||
winListeners.get(type).push(fn);
|
||
},
|
||
removeEventListener() {},
|
||
dispatch(type, event = {}) {
|
||
for (const fn of winListeners.get(type) || []) fn(event);
|
||
},
|
||
location: { href: '' },
|
||
};
|
||
|
||
const lsStore = new Map();
|
||
const localStorageMock = {
|
||
getItem: (k) => (lsStore.has(k) ? lsStore.get(k) : null),
|
||
setItem: (k, v) => lsStore.set(k, String(v)),
|
||
removeItem: (k) => lsStore.delete(k),
|
||
};
|
||
|
||
class FakeAudioParam {
|
||
constructor(v = 0) { this.value = v; }
|
||
setValueAtTime() {}
|
||
cancelScheduledValues() {}
|
||
linearRampToValueAtTime() {}
|
||
}
|
||
|
||
class FakeAudioContext {
|
||
constructor() {
|
||
this.state = 'suspended';
|
||
this.currentTime = 0;
|
||
this.sampleRate = 44100;
|
||
this.destination = {};
|
||
}
|
||
async resume() { this.state = 'running'; }
|
||
createGain() { return { gain: new FakeAudioParam(1), connect() {}, disconnect() {} }; }
|
||
createDynamicsCompressor() {
|
||
return {
|
||
threshold: new FakeAudioParam(), ratio: new FakeAudioParam(),
|
||
attack: new FakeAudioParam(), release: new FakeAudioParam(),
|
||
connect() {}, disconnect() {},
|
||
};
|
||
}
|
||
createBufferSource() {
|
||
return { buffer: null, loop: false, onended: null, connect() {}, disconnect() {}, start() {}, stop() {} };
|
||
}
|
||
createScriptProcessor() { return { connect() {}, disconnect() {}, onaudioprocess: null }; }
|
||
async decodeAudioData(ab) {
|
||
const duration = bufDuration.get(ab) ?? 1;
|
||
return {
|
||
duration, numberOfChannels: 1, sampleRate: 44100,
|
||
getChannelData: () => new Float32Array(8),
|
||
};
|
||
}
|
||
}
|
||
|
||
async function fetchMock(url) {
|
||
fetchLog.push(url);
|
||
const blocked = [...blockedAssets].some((b) => url.includes(b));
|
||
const fsPath = path.join(ROOT, decodeURIComponent(url));
|
||
const ok = !blocked && fs.existsSync(fsPath);
|
||
return {
|
||
ok,
|
||
async arrayBuffer() {
|
||
const ab = new ArrayBuffer(8);
|
||
bufDuration.set(ab, durFor(url));
|
||
return ab;
|
||
},
|
||
};
|
||
}
|
||
|
||
// 种子随机(LCG),保证序列可重复
|
||
let seed = 20260830;
|
||
function seededRandom() {
|
||
seed = (seed * 1103515245 + 12345) & 0x7fffffff;
|
||
return seed / 0x7fffffff;
|
||
}
|
||
|
||
// 全局安装(defineProperty:Node 24 自带 fetch/performance/navigator 只读 getter)
|
||
for (const [k, v] of Object.entries({
|
||
window: windowMock,
|
||
document: {
|
||
body: fakeEl(),
|
||
createElement: () => fakeEl(),
|
||
getElementById: () => null,
|
||
},
|
||
navigator: { vibrate: (p) => vibLog.push(p) },
|
||
localStorage: localStorageMock,
|
||
fetch: fetchMock,
|
||
performance: { now: () => nowMs },
|
||
setTimeout: vSetTimeout,
|
||
clearTimeout: vClearTimeout,
|
||
})) {
|
||
Object.defineProperty(globalThis, k, { value: v, configurable: true, writable: true });
|
||
}
|
||
windowMock.AudioContext = FakeAudioContext;
|
||
Math.random = seededRandom;
|
||
|
||
const origWarn = console.warn;
|
||
const origError = console.error;
|
||
globalThis.__origError = origError;
|
||
console.warn = (...a) => { warnLog.push(a.join(' ')); };
|
||
console.error = (...a) => { errorLog.push(a.join(' ')); };
|
||
|
||
// ---------------------------------------------------------------- 断言框架
|
||
let passed = 0;
|
||
let failed = 0;
|
||
function assert(cond, msg) {
|
||
if (cond) { passed++; console.log(` ✓ ${msg}`); }
|
||
else { failed++; origError(` ✗ ${msg}`); } // 失败行走原始 console(console.error 被 mock 捕获)
|
||
}
|
||
function section(name) { console.log(`\n== ${name}`); }
|
||
|
||
process.on('unhandledRejection', (e) => {
|
||
failed++;
|
||
origError(' ✗ 未处理 Promise 拒绝:', e);
|
||
});
|
||
|
||
// ---------------------------------------------------------------- 加载引擎与游戏
|
||
const { WebAudioEngine } = await import('../../engine/audio.js');
|
||
const { settings } = await import('../../engine/settings.js');
|
||
const { SimonGame } = await import('../game.js');
|
||
|
||
// 向导层关闭:回归测试只验证纯游戏逻辑(guide='full' 的行为由 harness/guide.test.mjs 覆盖)
|
||
settings.set({ guide: 'off', voice: 'female', speed: 1.0, bgm: 1.0 });
|
||
|
||
const audio = new WebAudioEngine('audio/');
|
||
const game = new SimonGame(audio, fakeEl());
|
||
let gameRef = game; // 手势注入目标(第二场景换新实例时切换)
|
||
|
||
// 间谍:记录播放/跳过/停 BGM
|
||
const playLog = [];
|
||
const skipLog = [];
|
||
const stopBgmLog = [];
|
||
const origPlay = audio.play.bind(audio);
|
||
audio.play = (id, opts) => { playLog.push({ id, opts: opts || {}, t: nowMs }); return origPlay(id, opts); };
|
||
const origSkip = audio.skipVoice.bind(audio);
|
||
audio.skipVoice = () => { skipLog.push(nowMs); return origSkip(); };
|
||
const origStopBgm = audio.stopBgm.bind(audio);
|
||
audio.stopBgm = (ms) => { stopBgmLog.push(ms); return origStopBgm(ms); };
|
||
|
||
const played = (id) => playLog.some((e) => e.id === id);
|
||
const playCount = (id) => playLog.filter((e) => e.id === id).length;
|
||
const lastVoice = () => {
|
||
for (let i = playLog.length - 1; i >= 0; i--) {
|
||
if (playLog[i].id.startsWith('voice/')) return playLog[i].id;
|
||
}
|
||
return null;
|
||
};
|
||
|
||
let gestureCount = 0;
|
||
// 真实手势回调是独立宏任务;每次注入后 flush 微任务,模拟事件循环间隔
|
||
//(否则同一 tick 内连续两个 voice play 会撞上引擎 _playVoice 的异步加载窗口)
|
||
async function gesture(g) { gestureCount++; gameRef.onGesture(g); await flush(); }
|
||
|
||
const GESTURES = ['up', 'down', 'left', 'right', 'tap', 'doubletap'];
|
||
|
||
// ---------------------------------------------------------------- 全局断言 1:解锁前无音频
|
||
section('启动遮罩前(引擎解锁语义)');
|
||
audio.play('voice/t0_welcome');
|
||
await flush();
|
||
assert(warnLog.some((w) => w.includes('尚未解锁')), '解锁前 play 仅 console.warn');
|
||
assert(fetchLog.length === 0, '解锁前无任何资产加载');
|
||
await audio.unlock();
|
||
|
||
// ---------------------------------------------------------------- 启动 + 设置边界(全局断言 3)
|
||
section('启动 / 设置生效(voice 性别、语速)');
|
||
settings.set({ voice: 'male', speed: 1.7 });
|
||
game.start();
|
||
await advanceUntil(() => !!audio._voice, { desc: 't0_welcome 开播' });
|
||
assert(game.phase === 'tutorial', '0.5s 后进入教学(T0)');
|
||
assert(lastVoice() === 'voice/t0_welcome', 't0_welcome 播放');
|
||
assert(fetchLog.some((u) => u.includes('audio/voice/male/t0_welcome.ogg')), 'voice=male 时从 male 目录取材');
|
||
assert(audio._voice.st.tempo === 1.7, 'speed=1.7 时播放实例 tempo=1.7(不变调变速)');
|
||
settings.set({ voice: 'female', speed: 1.0 });
|
||
|
||
// skip_hint 插播 + 长按加速(引擎内置行为过一遍真实代码路径)
|
||
await advanceUntil(() => lastVoice() === 'voice/skip_hint' && !!audio._voice, { desc: 'skip_hint 开播' });
|
||
assert(fetchLog.some((u) => u.includes('audio/voice/female/skip_hint.ogg')), '切回 female 后下一句从 female 目录取材');
|
||
assert(playCount('voice/skip_hint') === 1, 'skip_hint 在 t0 附近插播一次');
|
||
windowMock.dispatch('soundgame:longpress');
|
||
assert(audio._voice && audio._voice.st.tempo === 1.7, '长按 → 旁白临时 1.7 倍速');
|
||
windowMock.dispatch('soundgame:longpressend');
|
||
assert(audio._voice && audio._voice.st.tempo === 1.0, '松开 → 恢复 settings.speed');
|
||
|
||
// ---------------------------------------------------------------- 教学 T1-T6
|
||
section('交互式教学(T0-T7)');
|
||
// T1 讲解句播放中单击 = 跳过本句(12 号文档 + 本轮裁定)
|
||
// 注意:teachIdx 构造即 0,必须以"当前在播 t1_up"为准
|
||
await advanceUntil(() => lastVoice() === 'voice/t1_up' && !!audio._voice, { desc: 't1_up 开播' });
|
||
assert(game.phase === 'tutorial' && game.teachIdx === 0, 'T1 教学句播放');
|
||
{
|
||
const skips = skipLog.length;
|
||
await gesture('tap');
|
||
assert(skipLog.length === skips + 1, '教学讲解播放中单击 = skipVoice()');
|
||
assert(game.phase === 'tutorial' && game.teachIdx === 0, '跳过后教学状态机不破坏');
|
||
}
|
||
await advanceUntil(() => game.teachAwait, { desc: 'T1 等待输入' });
|
||
|
||
for (let i = 0; i < GESTURES.length; i++) {
|
||
await advanceUntil(() => game.teachAwait, { desc: `T${i + 1} 等待输入` });
|
||
assert(game.teachIdx === i, `teachIdx 递进到 ${i}`);
|
||
const expected = GESTURES[i];
|
||
if (i === 1) {
|
||
// 错手势:播 t_wrong_* + error 震动,不推进
|
||
const wrong = GESTURES.find((g) => g !== expected && g !== 'down'); // down=跳教学,避开
|
||
await gesture(wrong);
|
||
assert(played('voice/t_wrong_down'), '教学错手势播 t_wrong_down');
|
||
assert(vibLog.some((p) => Array.isArray(p) && p[0] === 60), '教学错手势发 error 震动');
|
||
assert(game.teachIdx === 1 && game.teachAwait, '错手势后仍在原步骤等待');
|
||
}
|
||
await gesture(expected); // i=4 时 expected='tap':等待输入阶段单击=答题(不跳过)
|
||
if (i < GESTURES.length - 1) {
|
||
await advanceUntil(() => game.teachIdx === i + 1, { desc: `teachIdx → ${i + 1}` });
|
||
}
|
||
}
|
||
await advanceUntil(() => game.phase === 'announce', { desc: 'T7 收尾后进第 1 轮' });
|
||
assert(game.round === 1 && played('voice/round_01'), '教学完成直达第 1 轮播报');
|
||
|
||
// ---------------------------------------------------------------- 对局辅助
|
||
function checkSeqConstraints() {
|
||
const s = game.sequence;
|
||
if (game.round <= 5) {
|
||
for (let i = 2; i < s.length; i++) {
|
||
assert(!(s[i] === s[i - 1] && s[i - 1] === s[i - 2]), `第 ${game.round} 轮序列前三连同手势(位置 ${i})`);
|
||
}
|
||
}
|
||
for (let i = 3; i < s.length; i++) {
|
||
assert(!(s[i] === s[i - 1] && s[i - 1] === s[i - 2] && s[i - 2] === s[i - 3]), `全程无四连同手势(第 ${game.round} 轮位置 ${i})`);
|
||
}
|
||
}
|
||
|
||
/** announce →(单击跳过轮次播报)→ playback(手势静默忽略)→ input。 */
|
||
async function toInputViaSkip(round) {
|
||
await advanceUntil(() => game.phase === 'announce' && !!audio._voice, { desc: `第 ${round} 轮播报` });
|
||
const skips = skipLog.length;
|
||
await gesture('tap');
|
||
assert(skipLog.length === skips + 1, `第 ${round} 轮 round_* 播报单击可跳`);
|
||
assert(game.phase === 'playback', '跳过后直接进入序列播放');
|
||
const seqSnapshot = game.sequence.join(',');
|
||
await gesture('left'); // playback 阶段任意手势静默忽略(含 voice 也不跳)
|
||
assert(game.phase === 'playback' && game.sequence.join(',') === seqSnapshot, 'playback 阶段手势静默忽略、序列不受影响');
|
||
await advanceUntil(() => game.phase === 'input', { desc: `第 ${round} 轮进入输入` });
|
||
assert(game.inputIdx === 0, `第 ${round} 轮 inputIdx 归零`);
|
||
checkSeqConstraints();
|
||
}
|
||
|
||
/** 语音缺失(round_03 被屏蔽)→ 2.5s 看门狗直接进 playback。 */
|
||
async function toInputViaWatchdog(round) {
|
||
await advanceUntil(() => game.phase === 'announce', { desc: `第 ${round} 轮 announce` });
|
||
await advance(300);
|
||
assert(!audio._voice, '被屏蔽的语音确实没有播起来');
|
||
await advanceUntil(() => game.phase === 'playback', { desc: '看门狗兜底进 playback', max: 10000 });
|
||
assert(errorLog.some((e) => e.includes('资产缺失')), '资产缺失仅 console.error');
|
||
const seqSnapshot = game.sequence.join(',');
|
||
await gesture('left');
|
||
assert(game.phase === 'playback' && game.sequence.join(',') === seqSnapshot, '看门狗路径 playback 手势同样忽略');
|
||
await advanceUntil(() => game.phase === 'input', { desc: `第 ${round} 轮进入输入` });
|
||
}
|
||
|
||
let dtAnswerTested = false; // 裁定①:期望恰为 doubletap 时双击=答题
|
||
/** 输入阶段按序列原样复现;途中遇到期望 doubletap 时验证"双击=答题不触发重听"。 */
|
||
async function answerAll() {
|
||
while (game.phase === 'input' && game.inputIdx < game.sequence.length) {
|
||
const exp = game.sequence[game.inputIdx];
|
||
if (exp === 'doubletap' && !dtAnswerTested) {
|
||
const ch = game.chances;
|
||
const idx = game.inputIdx;
|
||
const relistenCount = playCount('voice/relisten');
|
||
await gesture('doubletap');
|
||
assert(game.inputIdx === idx + 1 && game.chances === ch && playCount('voice/relisten') === relistenCount,
|
||
'裁定①:期望输入恰为 doubletap 时双击=答题(inputIdx 前进、不触发重听)');
|
||
dtAnswerTested = true;
|
||
} else {
|
||
await gesture(exp);
|
||
}
|
||
}
|
||
}
|
||
|
||
/** 把前缀里连续的 doubletap 答掉,直到期望输入非 doubletap。 */
|
||
async function answerPastDoubletaps() {
|
||
while (game.phase === 'input' && game.sequence[game.inputIdx] === 'doubletap') {
|
||
await gesture('doubletap'); // 期望即 doubletap → 答题
|
||
}
|
||
}
|
||
|
||
/** 把前缀里连续的 up 答掉,直到期望输入非 up(round3 G5:期望恰为 up 时上滑=作答)。 */
|
||
async function answerPastUps() {
|
||
while (game.phase === 'input' && game.sequence[game.inputIdx] === 'up') {
|
||
await gesture('up'); // 期望即 up → 答题
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------- 第 1-3 轮
|
||
section('第 1-3 轮(含单击跳播报、playback 忽略、看门狗)');
|
||
assert(game._interval() === 700, '第 1 轮播放间隔 700ms');
|
||
await toInputViaSkip(1);
|
||
await answerAll();
|
||
await advanceUntil(() => game.phase === 'announce' && game.round === 2, { desc: '进第 2 轮' });
|
||
|
||
await toInputViaSkip(2);
|
||
await answerAll();
|
||
blockedAssets.add('round_03'); // 看门狗被测项:在第 3 轮播报发起前屏蔽该语音资产
|
||
await advanceUntil(() => game.phase === 'announce' && game.round === 3, { desc: '进第 3 轮' });
|
||
|
||
await toInputViaWatchdog(3);
|
||
await answerAll();
|
||
await advanceUntil(() => game.phase === 'announce' && game.round === 4, { desc: '进第 4 轮' });
|
||
|
||
// ---------------------------------------------------------------- 第 4 轮:答错触发复活
|
||
section('第 4 轮:答错消耗复活、序列不变重播');
|
||
await toInputViaSkip(4);
|
||
assert(game.chances === 1, '初始 1 次重听机会');
|
||
{
|
||
const prevSeq = game.sequence;
|
||
const exp = game.sequence[0];
|
||
// round3 G5:up=重听键,注入错手势时避开(否则触发重听而非判错)
|
||
const wrong = GESTURES.find((g) => g !== exp && g !== 'doubletap' && g !== 'up');
|
||
await gesture(wrong);
|
||
assert(game.chances === 0, '答错自动消耗复活:chances 1→0');
|
||
assert(game.phase === 'roundend' && played('voice/retry_use'), '播 retry_use');
|
||
await advanceUntil(() => played('voice/chances_out'), { desc: 'chances_out 链播' });
|
||
assert(played('voice/chances_out'), '机会归 0 追加播 chances_out');
|
||
assert(vibLog.some((p) => Array.isArray(p) && p[0] === 60), '答错发 error 震动');
|
||
await advanceUntil(() => game.phase === 'input', { desc: '重播后回输入' });
|
||
assert(game.sequence === prevSeq, '复活重播序列不变(引用相等)');
|
||
assert(game.inputIdx === 0, '复活后输入进度清零');
|
||
}
|
||
await answerAll();
|
||
await advanceUntil(() => game.phase === 'announce' && game.round === 5, { desc: '进第 5 轮' });
|
||
assert(game.chances === 1, '每完成 4 轮 chances +1(回到 1)');
|
||
assert(played('voice/chance_plus'), '播 chance_plus');
|
||
|
||
// ---------------------------------------------------------------- 第 5 轮:主动重听 + 边界
|
||
section('第 5 轮:主动重听(上滑)/ chances=0 上滑 / 双击=作答(round3 G5,13 号文档 §8 变更记录)');
|
||
await toInputViaSkip(5);
|
||
{
|
||
// round3 G5:input 阶段双击永远=作答,主动重听改绑上滑(期望恰为 up 时上滑=作答)
|
||
await answerPastDoubletaps();
|
||
await answerPastUps(); // 先把期望恰为 up 的音答掉
|
||
const relistenCount = playCount('voice/relisten');
|
||
await gesture('up'); // 期望非 up → 主动重听
|
||
assert(game.chances === 0, '主动重听耗机会:chances 1→0');
|
||
assert(playCount('voice/relisten') === relistenCount + 1 && game.phase === 'roundend', '播 relisten 并重播');
|
||
await advanceUntil(() => game.phase === 'input', { desc: '重听重播后回输入' });
|
||
assert(game.inputIdx === 0, '重听后输入进度清零');
|
||
}
|
||
{
|
||
// chances=0 时上滑非期望手势:不判错、播 chances_out
|
||
await answerPastDoubletaps();
|
||
await answerPastUps();
|
||
const round = game.round;
|
||
const idx = game.inputIdx;
|
||
const coCount = playCount('voice/chances_out');
|
||
await gesture('up');
|
||
assert(game.phase === 'input' && game.round === round && game.inputIdx === idx, 'chances=0 上滑非期望:不判错(round/inputIdx 不变)');
|
||
assert(playCount('voice/chances_out') === coCount + 1, 'chances=0 上滑播 chances_out');
|
||
assert(game.chances === 0, 'chances 不变(不为负)');
|
||
}
|
||
// 答完第 5 轮(途中验证双击=答题);若本轮没遇到 doubletap 则继续往下轮找
|
||
await answerAll();
|
||
while (!dtAnswerTested && game.round < 10) {
|
||
await advanceUntil(() => game.phase === 'announce', { desc: '继续找 doubletap 回合' });
|
||
const r = game.round;
|
||
await advanceUntil(() => !!audio._voice, { desc: `第 ${r} 轮播报` });
|
||
await gesture('tap'); // 跳播报
|
||
await advanceUntil(() => game.phase === 'input', { desc: `第 ${r} 轮输入` });
|
||
await answerAll();
|
||
}
|
||
assert(dtAnswerTested, '双击冲突裁定①已覆盖(序列中出现 doubletap 并验证)');
|
||
|
||
// ---------------------------------------------------------------- 耗尽后答错 → gameover
|
||
section('复活耗尽 → gameover → 结算');
|
||
await advanceUntil(() => game.phase === 'input' || game.phase === 'roundend', { desc: '最后一轮输入' });
|
||
if (game.phase === 'roundend') {
|
||
await advanceUntil(() => game.phase === 'input', { desc: '进入输入阶段' });
|
||
}
|
||
assert(game.chances === 0, '复活已耗尽');
|
||
const finalRound = game.round;
|
||
{
|
||
const exp = game.sequence[game.inputIdx];
|
||
// round3 G5:up=重听键,注入错手势时避开
|
||
const wrong = GESTURES.find((g) => g !== exp && g !== 'doubletap' && g !== 'up');
|
||
await gesture(wrong);
|
||
}
|
||
assert(game.phase === 'gameover', 'chances=0 答错 → gameover');
|
||
const gameoverT = nowMs;
|
||
const score = finalRound - 1;
|
||
const expectedBand = score <= 0 ? 'voice/score_band1' : score <= 3 ? 'voice/score_band2'
|
||
: score <= 6 ? 'voice/score_band3' : score <= 9 ? 'voice/score_band4'
|
||
: score <= 14 ? 'voice/score_band5' : 'voice/score_band6';
|
||
assert(!played('bgm/menu'), 'gameover 瞬间尚无 BGM');
|
||
// 结算语音链播放中单击 = 跳过本句(不是重开)
|
||
await advanceUntil(() => !!audio._voice, { desc: '结算语音链开播' });
|
||
for (let i = 0; i < 8 && audio._voice; i++) {
|
||
const ph = game.phase;
|
||
await gesture('tap');
|
||
assert(game.phase === ph && game.phase === 'gameover', '结算语音播放中单击=跳过本句(不重开)');
|
||
await advance(50);
|
||
}
|
||
await advanceUntil(() => !audio._voice, { desc: '结算语音链播完' });
|
||
assert(played('voice/gameover') && played(expectedBand), `score_band 语音与 round-1=${score} 匹配(${expectedBand})`);
|
||
assert(played('voice/new_record'), '破纪录播 new_record');
|
||
assert(lsStore.get('soundgame.simon.best') === String(score), 'best 写入 localStorage');
|
||
assert(played('sfx/gameover'), 'sfx/gameover 播出');
|
||
// 状态 BGM:sfx/gameover 播完 1s 后淡入 menu pad(gameover 起 +2650ms,volume 0.3)
|
||
await advanceUntil(() => played('bgm/menu'), { desc: '结算 menu pad 淡入' });
|
||
{
|
||
const e = playLog.find((x) => x.id === 'bgm/menu');
|
||
assert(e.opts.volume === 0.3, '结算 bgm/menu 以 volume 0.3 播放');
|
||
assert(e.t - gameoverT >= 2500 && e.t - gameoverT <= 2800, `menu pad 在 sfx/gameover 播完 1s 后淡入(实际 +${e.t - gameoverT}ms)`);
|
||
}
|
||
|
||
// 全程无 BGM(教学/对局):gameover 之前无任何 bgm 层播放调用
|
||
assert(playLog.filter((e) => e.id.startsWith('bgm/') && e.t < gameoverT).length === 0,
|
||
'教学/对局/序列播放全程无 BGM(用户硬性要求)');
|
||
|
||
// ---------------------------------------------------------------- 速度下限(单测 _interval)
|
||
{
|
||
const r = game.round;
|
||
game.round = 1; assert(game._interval() === 700, '第 1 轮 interval=700ms');
|
||
game.round = 11; assert(game._interval() === 440, '第 11 轮 interval=440ms(未到下限)');
|
||
game.round = 12; assert(game._interval() === 420, '第 12 轮起 interval=420ms 下限');
|
||
game.round = 30; assert(game._interval() === 420, '第 30 轮仍为 420ms(不穿了下限)');
|
||
game.round = r;
|
||
}
|
||
|
||
// ---------------------------------------------------------------- 菜单约定 + 重开
|
||
section('菜单(下滑呼出)与单击重开');
|
||
await gesture('down');
|
||
assert(game.phase === 'menu' && played('voice/menu_open'), '下滑呼出菜单');
|
||
assert(playCount('bgm/menu') >= 1, '菜单状态挂 bgm/menu(与结算同一首)');
|
||
await gesture('tap');
|
||
assert(game._menuIdx === 1 && played('voice/menu_home'), '菜单单击循环到"回大厅"');
|
||
await gesture('tap');
|
||
assert(game._menuIdx === 2 && played('voice/menu_back'), '菜单单击循环到"返回"');
|
||
await gesture('up');
|
||
assert(played('voice/rules'), '菜单上滑=帮助(规则播报)');
|
||
await gesture('doubletap'); // 选中"返回"
|
||
assert(game.phase === 'gameover', '菜单双击确认"返回"→ 回结算页');
|
||
await advanceUntil(() => !audio._voice, { desc: 'rules 链播完' });
|
||
const stopsBefore = stopBgmLog.length;
|
||
await gesture('tap'); // 结算页无语音时单击 = 重开
|
||
assert(stopBgmLog.length === stopsBefore + 1 && stopBgmLog[stopBgmLog.length - 1] === 300, '单击重开 stopBgm(300) 回静默');
|
||
await advanceUntil(() => game.phase === 'announce' && game.round === 1, { desc: '重开进第 1 轮' });
|
||
assert(game.phase !== 'tutorial' && playCount('voice/skip_hint') === 1, '单击重开跳过教学直达第 1 轮(skip_hint 不重播)');
|
||
await advanceUntil(() => game.phase === 'input', { desc: '重开后第 1 轮输入' });
|
||
await answerAll();
|
||
assert(game.round === 1 && (game.phase === 'roundend' || game.phase === 'announce'), '重开后对局功能正常');
|
||
|
||
// ---------------------------------------------------------------- settings.bgm 边界(全局断言 3)
|
||
section('settings.bgm 边界');
|
||
settings.set({ bgm: 0 });
|
||
await flush();
|
||
assert(audio._bgm === null, 'bgm=0 立即停播进行中 BGM');
|
||
{
|
||
const fetches = fetchLog.filter((u) => u.includes('bgm/menu')).length;
|
||
audio.play('bgm/menu', { volume: 0.3 });
|
||
await flush();
|
||
assert(fetchLog.filter((u) => u.includes('bgm/menu')).length === fetches && audio._bgm === null,
|
||
'bgm=0 时 play(bgm) 只记意图不加载不播放');
|
||
}
|
||
settings.set({ bgm: 1.0 });
|
||
await advanceUntil(() => !!audio._bgm, { desc: 'bgm 恢复自动补播' });
|
||
assert(audio._bgm.id === 'bgm/menu', 'bgm 调回非零自动补播最后的状态 BGM');
|
||
audio.stopBgm();
|
||
settings.set({ bgm: 1.0 });
|
||
|
||
// ---------------------------------------------------------------- 场景二:教学期下滑=跳教学(现有裁定回归)
|
||
section('场景二:教学期下滑=跳过教学');
|
||
game.stop(); // 主场景实例收尾(作废旧会话计时器/语音链)
|
||
await advance(500);
|
||
{
|
||
const game2 = new SimonGame(audio, fakeEl());
|
||
gameRef = game2;
|
||
game2.start();
|
||
await advanceUntil(() => game2.phase === 'tutorial' && lastVoice() === 'voice/t1_up' && !!audio._voice,
|
||
{ desc: '场景二 T1 讲解开播' });
|
||
await gesture('down'); // 非"等待练下滑"时刻:下滑=跳整个教学
|
||
assert(played('voice/t_skip'), '教学讲解期下滑播 t_skip 确认句');
|
||
await advanceUntil(() => game2.phase === 'announce' && game2.round === 1, { desc: '跳教学直达第 1 轮' });
|
||
assert(game2.phase !== 'tutorial', '下滑跳过教学直达第 1 轮');
|
||
game2.stop();
|
||
gameRef = game;
|
||
await advance(500);
|
||
}
|
||
|
||
// ---------------------------------------------------------------- 资产引用校验
|
||
section('资产引用校验(ogg+m4a 双格式,voice 双性别)');
|
||
{
|
||
const src = fs.readFileSync(path.join(ROOT, 'game.js'), 'utf8');
|
||
const ids = new Set();
|
||
for (const m of src.matchAll(/'(voice|sfx|bgm)\/[a-z0-9_]+'/gi)) ids.add(m[0].slice(1, -1));
|
||
for (let i = 1; i <= 20; i++) ids.add(`voice/round_${String(i).padStart(2, '0')}`); // 动态 id
|
||
ids.add('voice/round_more');
|
||
const missing = [];
|
||
for (const id of ids) {
|
||
const [layer, name] = id.split('/');
|
||
if (layer === 'voice') {
|
||
for (const gender of ['male', 'female']) {
|
||
for (const ext of ['ogg', 'm4a']) {
|
||
if (!fs.existsSync(path.join(ROOT, 'audio/voice', gender, `${name}.${ext}`))) {
|
||
missing.push(`audio/voice/${gender}/${name}.${ext}`);
|
||
}
|
||
}
|
||
}
|
||
} else {
|
||
for (const ext of ['ogg', 'm4a']) {
|
||
if (!fs.existsSync(path.join(ROOT, 'audio', layer, `${name}.${ext}`))) {
|
||
missing.push(`audio/${layer}/${name}.${ext}`);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
assert(missing.length === 0, `game.js 引用 ${ids.size} 个资产 id,双格式双性别零缺失${missing.length ? ':缺 ' + missing.join(', ') : ''}`);
|
||
}
|
||
|
||
// ---------------------------------------------------------------- 汇总
|
||
section('汇总');
|
||
assert(gestureCount <= 120, `手势预算达标(${gestureCount} ≤ 120)`);
|
||
console.warn = origWarn;
|
||
console.error = origError;
|
||
console.log(`\n通过 ${passed} 条断言,失败 ${failed} 条,注入手势 ${gestureCount} 次,虚拟时长 ${(nowMs / 1000).toFixed(1)}s`);
|
||
if (failed > 0) {
|
||
console.error('PLAYTEST FAILED');
|
||
process.exit(1);
|
||
}
|
||
console.log('PLAYTEST PASSED');
|