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

620 lines
27 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.
/**
* 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');