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

587 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.
/**
* 盲航(space)无头试玩测试 —— 覆盖 docs/design/13-playtest-criteria.md §5(space 关键路径
* + 边界断言)与 §7(全局通用断言),以及本轮改动(状态 BGM / skipVoice / skip_hint)。
*
* 用法:tools/node/bin/node web/space/test/playtest.mjs
*
* 设计:
* - 真实加载 engine/{audio,settings,haptics,game}.js 与 vendor/soundtouch.js(纯 JS),
* 只 mock 浏览器环境:window/document/navigator/localStorage/fetch/AudioContext。
* - fetch 映射到 web/space/audio/ 真实文件系统:资产缺失 → ok:false(顺带做资产校验)。
* - decodeAudioData 返回带 duration 的假 buffer(voice 1.0s / bgm 60s / sfx 0.5s);
* ScriptProcessorNode 永不触发 onaudioprocess,voice 自然结束由引擎内部定时器驱动。
* - 虚拟时钟:setTimeout/clearTimeout/performance.now 全部替换,测试零真实等待。
* - 手势直接注入 game.onGesture(引擎判定路径之外的稳定注入点,13 号文档允许);
* 刷怪方向用 Math.random 队列控制,陨石投放/快进用游戏实例钩子(文档明示允许)。
*/
import { existsSync, readFileSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const GAME_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
// ---------------- 虚拟时钟 ----------------
const clock = {
now: 0,
_timers: new Map(),
_nextId: 1,
setTimeout(fn, ms = 0) {
const id = this._nextId++;
this._timers.set(id, { at: this.now + Math.max(0, ms), fn });
return id;
},
clearTimeout(id) { this._timers.delete(id); },
/** 推进虚拟时钟,按到期顺序触发所有 ≤ target 的定时器。 */
advance(ms) {
const target = this.now + ms;
for (;;) {
let bestId = -1, bestAt = Infinity;
for (const [id, t] of this._timers) {
if (t.at < bestAt || (t.at === bestAt && id < bestId)) { bestAt = t.at; bestId = id; }
}
if (bestId < 0 || bestAt > target) break;
const { fn } = this._timers.get(bestId);
this._timers.delete(bestId);
this.now = bestAt;
fn();
}
this.now = target;
},
};
// ---------------- 浏览器环境 mock(必须先于引擎 import) ----------------
globalThis.setTimeout = (fn, ms) => clock.setTimeout(fn, ms);
globalThis.clearTimeout = (id) => clock.clearTimeout(id);
Object.defineProperty(globalThis, 'performance', {
value: { now: () => clock.now }, configurable: true,
});
Object.defineProperty(globalThis, 'navigator', {
value: { vibrate: (p) => { vibrations.push(p); return true; } }, configurable: true,
});
const vibrations = [];
function makeLocalStorage() {
const m = new Map();
return {
getItem: (k) => (m.has(k) ? m.get(k) : null),
setItem: (k, v) => m.set(k, String(v)),
removeItem: (k) => m.delete(k),
clear: () => m.clear(),
};
}
Object.defineProperty(globalThis, 'localStorage', {
value: makeLocalStorage(), writable: true, configurable: true,
});
globalThis.window = globalThis;
window.addEventListener = () => {};
window.location = { href: '' };
globalThis.document = {
createElement: () => ({ style: { setProperty() {} }, appendChild() {}, addEventListener() {}, remove() {} }),
getElementById: () => null,
body: { appendChild() {} },
};
/** 置为缺失的资产 id(看门狗测试用),如 'voice/drill_good'。 */
const missingAssets = new Set();
const fetchLog = [];
globalThis.fetch = async (url, opts) => {
const u = String(url);
if (u.startsWith('audio/')) {
// url 形如 audio/voice/female/x.ogg → 归一化 id(voice/x)用于缺失注入
const noExt = u.replace(/\.[a-z0-9]+$/i, '');
const id = noExt.replace(/^audio\//, '').replace(/^voice\/(male|female)\//, 'voice/');
if (missingAssets.has(id) || !existsSync(path.join(GAME_DIR, u))) {
fetchLog.push({ url: u, opts, t: clock.now, ok: false });
return { ok: false, status: 404, arrayBuffer: async () => new ArrayBuffer(0) };
}
fetchLog.push({ url: u, opts, t: clock.now, ok: true });
return { ok: true, status: 200, arrayBuffer: async () => ({ __url: u }) };
}
fetchLog.push({ url: u, opts, t: clock.now, ok: true });
return { ok: true, status: 200, arrayBuffer: async () => new ArrayBuffer(0), json: async () => ({}) };
};
class MockParam {
constructor(v = 0) { this.value = v; }
cancelScheduledValues() {}
setValueAtTime() {}
linearRampToValueAtTime() {}
}
class MockAudioContext {
constructor() { this.state = 'suspended'; this.destination = {}; }
get currentTime() { return clock.now / 1000; }
async resume() { this.state = 'running'; }
createGain() { return { gain: new MockParam(1), connect() {}, disconnect() {} }; }
createDynamicsCompressor() {
return {
threshold: new MockParam(), ratio: new MockParam(),
attack: new MockParam(), release: new MockParam(),
connect() {}, disconnect() {},
};
}
createBufferSource() {
return { buffer: null, loop: false, connect() {}, disconnect() {}, start() {}, stop() {} };
}
createScriptProcessor() { return { connect() {}, disconnect() {}, onaudioprocess: null }; }
async decodeAudioData(raw) {
const u = (raw && raw.__url) || '';
let duration = 0.5;
if (u.includes('/voice/')) duration = 1.0;
else if (u.includes('/bgm/')) duration = 60;
return {
duration, numberOfChannels: 1, sampleRate: 44100,
getChannelData: () => new Float32Array(16),
};
}
}
globalThis.AudioContext = MockAudioContext;
// ---------------- 受控随机 ----------------
const randQueue = [];
Math.random = () => (randQueue.length ? randQueue.shift() : 0); // 默认 0 = left
// ---------------- 加载引擎与游戏(globals 就绪后动态 import) ----------------
const { WebAudioEngine } = await import('../../engine/audio.js');
const { settings } = await import('../../engine/settings.js');
const { SpaceGame } = await import('../game.js');
// 向导层关闭:回归测试只验证纯游戏逻辑(guide='full' 的行为由 harness/guide.test.mjs 覆盖)
settings.set({ guide: 'off', voice: 'female', speed: 1.0, bgm: 1.0 });
// ---------------- 断言与工具 ----------------
let passed = 0, failed = 0;
function assert(cond, msg) {
if (cond) { passed++; console.log(' ✓', msg); }
else { failed++; console.error(' ✗ FAIL:', msg); }
}
function section(name) { console.log('\n== ' + name); }
/** 冲刷微任务(fetch/decode/play 链是 async 的)。 */
const tick = async (n = 10) => { for (let i = 0; i < n; i++) await new Promise((r) => setImmediate(r)); };
async function advance(ms) { clock.advance(ms); await tick(); }
function makeHarness() {
const audio = new WebAudioEngine('audio/');
const plays = [], skips = [];
const origPlay = audio.play.bind(audio);
audio.play = (id, opts) => { plays.push({ id, opts: opts || {}, t: clock.now }); return origPlay(id, opts); };
const origSkip = audio.skipVoice.bind(audio);
audio.skipVoice = () => { skips.push(clock.now); return origSkip(); };
const game = new SpaceGame(audio, {});
return { audio, game, plays, skips };
}
const voices = (plays) => plays.filter((p) => p.id.startsWith('voice/')).map((p) => p.id);
const lastVoice = (plays) => voices(plays).at(-1);
const vibrated = (pat) => vibrations.some((v) => Array.isArray(v) && v.join() === pat);
try {
// ======================================================================
section('S1 关键路径:演练 → 保护期 → 分批引入 → 坠毁结算 → 重开');
// ======================================================================
const h = makeHarness();
const { game, audio, plays, skips } = h;
// -- §7.1 解锁前无音频播放 --
audio.play('sfx/dodge');
assert(fetchLog.length === 0, '音频解锁前 play 不加载不播放(§7.1)');
await audio.unlock();
game.start();
await tick();
assert(game.phase === 'drill', '首局进入演练 phase');
assert(lastVoice(plays) === 'voice/drill_welcome', 'D0 drill_welcome 开播');
assert(!!audio._voice, 'voice 层播放中');
// -- ② 演练旁白单击跳过 + ③ skip_hint 插播 --
game.onGesture('tap'); await tick();
assert(skips.length === 1, '演练旁白中单击 = skipVoice()');
assert(lastVoice(plays) === 'voice/skip_hint', 'skip_hint 紧随 drill_welcome 插播(③)');
game.onGesture('tap'); await tick();
assert(lastVoice(plays) === 'voice/drill_rule', '再单击跳到 D0 下一句 drill_rule');
await advance(1300); // drill_rule 播完 → D1 旁白
assert(game.drillIdx === 0 && lastVoice(plays) === 'voice/drill_left', 'D1 旁白 drill_left');
assert(!game.drillOb, '旁白播完前不出现演练陨石(语音先、音效后)');
game.onGesture('tap'); await tick(); // 跳过 drill_left → 立即 spawn
assert(game.drillOb && game.drillOb.dir === 'left', '跳句后 D1 演练陨石(左)出现');
assert(plays.some((p) => p.id === 'sfx/whoosh_left'), 'whoosh_left 方位音');
// -- 边界:演练做错重教 + 失败 2 次带过 --
game.onGesture('right'); await tick();
assert(game.drillFails === 1 && game.drillIdx === 0, 'D1 右滑(应左)→ 同一步重试');
assert(lastVoice(plays) === 'voice/drill_retry_left', 'drill_retry_left 当场重教');
assert(vibrated('60,40,60'), '演练做错 ERROR 震动');
await advance(1300);
assert(game.drillOb && game.drillOb.dir === 'left', '重教后同一颗重来');
game.onGesture('up'); await tick();
assert(game.drillFails === 2, 'D1 连错第 2 次');
assert(lastVoice(plays) === 'voice/drill_moveon', '失败 2 次 drill_moveon 带过');
await advance(1300);
assert(game.drillIdx === 1 && lastVoice(plays) === 'voice/drill_right', '自动进入 D2');
// -- 边界回归:演练等待输入时单击维持原义(算做错) --
await advance(1300); // drill_right 播完 → spawn
assert(game.drillOb && game.drillOb.dir === 'right', 'D2 演练陨石(右)出现');
game.onGesture('tap'); await tick();
assert(game.drillFails === 1 && lastVoice(plays) === 'voice/drill_retry_right',
'演练等待输入时单击维持原义(判错重教),不被跳过逻辑劫持');
await advance(1300);
// D2 做对(good=false → 直接下一步)
game.onGesture('right'); await tick();
assert(game.drillIdx === 2 && lastVoice(plays) === 'voice/drill_up', 'D2 做对进 D3');
// D3 做对(good=true → drill_good 短肯定)
await advance(1300);
assert(game.drillOb && game.drillOb.dir === 'up', 'D3 演练陨石(上)出现');
game.onGesture('up'); await tick();
assert(lastVoice(plays) === 'voice/drill_good', 'D3 做对播 drill_good');
await advance(1300);
assert(game.drillIdx === 3 && lastVoice(plays) === 'voice/drill_down', '进 D4');
// D4 做对 → D5 收尾
await advance(1300);
assert(game.drillOb && game.drillOb.dir === 'down', 'D4 演练陨石(下)出现');
game.onGesture('down'); await tick();
assert(lastVoice(plays) === 'voice/drill_done', 'D5 收尾句');
await advance(1300);
assert(localStorage.getItem('soundgame.space.drillDone') === '1', 'drillDone 落盘(§5.1)');
assert(game.phase === 'playing', 'D5 后进入 playing');
assert(voices(plays).filter((id) => id === 'voice/skip_hint').length === 1,
'skip_hint 全程仅插播一次(③)');
await tick();
assert(audio._bgm && audio._bgm.id === 'bgm/engine', '对局 BGM = bgm/engine');
// -- §5.2 保护期:任其撞不掉血 + 方位归因 --
const t0 = game.runStart;
await advance(1500); // 首颗(左,Math.random 默认 0)
assert(game.obstacles.size >= 1, '保护期首颗陨石刷出');
await advance(4100); // 到撞击点
assert(game.hits === 0, '保护期撞击不掉血(训练护盾吸收)');
assert(lastVoice(plays) === 'voice/protect_left', 'protect_<dir> 与来袭方向一致(左)');
assert(vibrated('30,50,30,50,30'), '保护期吸收 WARNING 震动');
// -- §5.3 第 20s 单击引入(feat_boost 可跳) --
await advance(t0 + 20000 - clock.now);
assert(game.phase === 'feature' && game.featureKind === 'boost', '第 20s 暂停刷怪教单击');
assert(lastVoice(plays) === 'voice/feat_boost', 'feat_boost 引入句');
const skipsBefore = skips.length;
game.onGesture('tap'); await tick();
assert(skips.length === skipsBefore + 1 && game.phase === 'feature',
'feat_boost 在播时单击 = 跳过本句(不直接触发加速)');
game.onGesture('tap'); await tick();
assert(game.boostUntil > clock.now, '旁白已跳后单击 → boost 生效(§5.3)');
assert(game.phase === 'playing', '分批引入结束恢复 playing');
assert(lastVoice(plays) === 'voice/feat_boost_done', 'feat_boost_done 确认句');
// 加速增益期间的撞击算冲过空隙
game.obstacles.clear();
const obBoost = { dir: 'right', impactAt: clock.now + 100 };
game.obstacles.add(obBoost);
game._after(100, () => game._resolve(obBoost));
await advance(150);
assert(game.dodges === 1 && game.hits === 0, '加速期间撞击 = 冲过空隙(dodge)');
// -- §5.3 第 45s 双击引入 --
await advance(t0 + 45000 - clock.now);
assert(game.phase === 'feature' && game.featureKind === 'shield', '第 45s 教双击护盾');
game.onGesture('doubletap'); await tick();
assert(game.shieldUpUntil > clock.now, '双击 → 护盾生效(§5.3)');
assert(game.shieldReadyAt === clock.now + 30000, '护盾充能 30s 锁定');
assert(game.phase === 'playing', '护盾引入结束恢复 playing');
// 护盾格挡一颗(消耗护盾,不掉血)
game.obstacles.clear();
const obShield = { dir: 'left', impactAt: clock.now + 100 };
game.obstacles.add(obShield);
game._after(100, () => game._resolve(obShield));
await advance(150);
assert(game.hits === 0 && game.shieldUpUntil === 0, '护盾挡下一颗后消耗');
assert(plays.some((p) => p.id === 'sfx/shield_block'), 'shield_block 格挡音');
await advance(1400); // feat_shield_done 播完 → 排队的 shield_block 语音
assert(voices(plays).includes('voice/shield_block'), 'shield_block 排队语音播出');
// -- 切断自然刷怪(测试钩子),后续手动投放,避免难度曲线干扰 --
const naturalSpawn = game._spawnObstacle.bind(game);
game._spawnObstacle = () => {};
// -- §5.4 保护解除播报 --
await advance(t0 + 60050 - clock.now);
assert(game._protectAnnounced && voices(plays).includes('voice/protect_end'), '60s 保护解除播报');
// -- 边界:护盾充能完毕播报,且只播一次(回归:曾双重调度播两次) --
await advance(game.shieldReadyAt - clock.now + 50);
assert(voices(plays).filter((id) => id === 'voice/shield_ready').length === 1,
'shield_ready 充能完毕播报恰好一次');
assert(vibrated('15'), '护盾就绪 PICKUP 震动');
// -- §5.4 正常闪避 3 颗(同向滑,须在撞击前 1.5s 判定窗口内出手) --
let expectDodges = game.dodges;
for (const rv of [0, 0.25, 0.5]) { // left / right / up
randQueue.push(rv);
game.obstacles.clear(); game.swipes = [];
naturalSpawn();
const ob = [...game.obstacles].at(-1);
await advance(ob.impactAt - clock.now - 800); // 逼近到撞击前 800ms
game.onGesture(ob.dir); // 判定窗口内同向滑
await advance(900); // 过撞击点
expectDodges++;
assert(game.dodges === expectDodges, `同向滑闪避成功(${ob.dir},dodges=${expectDodges})`);
assert(game.hits === 0, '同向闪避不掉血');
}
// -- 边界:反向滑 ≠ dodge(顺便作为第 1 次实打实撞击) --
randQueue.push(0); // left
game.obstacles.clear(); game.swipes = [];
naturalSpawn();
const obH1 = [...game.obstacles].at(-1);
game.onGesture('right'); // 反向
await advance(obH1.impactAt - clock.now + 50);
assert(game.hits === 1, '反向滑不判闪避,撞击 hits=1');
assert(lastVoice(plays) === 'voice/hit_left', 'hit_<dir> 归因方向与来袭一致');
assert(vibrated('80'), '受击 HIT 震动');
// -- ② 对局归因语音可跳 --
const skipsB2 = skips.length;
game.onGesture('tap'); await tick();
assert(skips.length === skipsB2 + 1, '归因语音在播时单击 = skipVoice()');
// -- 边界:第 2 次被撞追加 hit_last --
randQueue.push(0.25); // right
game.obstacles.clear(); game.swipes = [];
naturalSpawn();
const obH2 = [...game.obstacles].at(-1);
await advance(obH2.impactAt - clock.now + 50);
assert(game.hits === 2 && game.phase === 'playing', 'hits===2 不坠毁');
await advance(1300); // hit_right 播完
await advance(1300); // hit_last 播出并播完
const vi = voices(plays);
const h2i = vi.lastIndexOf('voice/hit_right');
assert(h2i >= 0 && vi[h2i + 1] === 'voice/hit_last', '第 2 次被撞追加 hit_last');
// -- 边界:里程碑 dodges=10 播 ms_10 --
game.dodges = 9; // 13 号文档允许的测试钩子
randQueue.push(0.5); // up
game.obstacles.clear(); game.swipes = [];
naturalSpawn();
const obM = [...game.obstacles].at(-1);
await advance(obM.impactAt - clock.now - 800);
game.onGesture('up');
await advance(900);
assert(game.dodges === 10, 'dodges=10');
assert(voices(plays).includes('voice/ms_10'), '里程碑 ms_10 播报');
assert(vibrated('20'), '里程碑 CONFIRM 震动');
await advance(1300);
// -- §5.4 第 3 次撞击坠毁 --
randQueue.push(0.75); // down
game.obstacles.clear(); game.swipes = [];
naturalSpawn();
const obH3 = [...game.obstacles].at(-1);
const crashAt = obH3.impactAt;
await advance(obH3.impactAt - clock.now + 50);
assert(game.hits === 3 && game.phase === 'over', 'hits===3 才坠毁 gameover');
assert(plays.some((p) => p.id === 'sfx/crash'), '坠毁音 crash');
assert(audio._bgm === null, '坠毁即 stopBgm(结算前先静)');
assert(vibrated('60,40,60'), '坠毁 ERROR 震动');
await advance(350);
assert(vibrations.filter((v) => Array.isArray(v) && v.join() === '80').length >= 2, '坠毁追加 HIT 震动');
// -- §5.5 结算分档 + 状态 BGM(10 号文档 §6) --
const aliveSec = Math.floor((crashAt - t0) / 1000);
const expectTime = aliveSec < 30 ? 'voice/time_1' : aliveSec < 60 ? 'voice/time_2'
: aliveSec < 120 ? 'voice/time_3' : 'voice/time_4';
const expectDodge = game.dodges < 5 ? 'voice/dodge_1' : game.dodges < 15 ? 'voice/dodge_2'
: game.dodges < 50 ? 'voice/dodge_3' : 'voice/dodge_4';
await advance(1300); // 爆炸声 1.2s 后开口
assert(lastVoice(plays) === expectTime, `结算时间段句 ${expectTime}(存活 ${aliveSec}s)`);
game.onGesture('tap'); await tick(); // 跳过时间段句
assert(lastVoice(plays) === 'voice/new_record', '结算句可跳;首局必破纪录 new_record');
await advance(1300);
assert(lastVoice(plays) === expectDodge, `结算躲避段句 ${expectDodge}(dodges=${game.dodges})`);
await advance(1300);
assert(lastVoice(plays) === 'voice/restart_hint', 'restart_hint 收尾');
await advance(1300); // 链播完
assert(plays.some((p) => p.id === 'bgm/menu' && p.opts.fadeMs === 800),
'结算语音链播完淡入 bgm/menu 800ms(10 号文档 §6)');
await tick();
assert(audio._bgm && audio._bgm.id === 'bgm/menu', '结算态 BGM = bgm/menu');
assert(vibrated('100'), '破纪录 SUCCESS 震动');
// -- §5.5 单击重开 --
game.onGesture('tap'); await tick();
assert(game.phase === 'playing' && game.hits === 0 && game.dodges === 0, '结算态单击重开新局');
assert(audio._bgm && audio._bgm.id === 'bgm/engine', '重开淡回 bgm/engine');
// ======================================================================
section('S2 菜单:状态 BGM 切换 + 下滑折中 + 全球约定(§7.5)');
// ======================================================================
game.obstacles.clear();
game.onGesture('down'); await tick();
assert(game.phase === 'menu', '无下方陨石时下滑 = 菜单(下滑折中)');
assert(plays.some((p) => p.id === 'bgm/menu' && p.opts.fadeMs === 300),
'进菜单 crossfade 300ms 切 bgm/menu');
await tick();
assert(audio._bgm && audio._bgm.id === 'bgm/menu', '菜单态 BGM = bgm/menu');
game.onGesture('tap'); await tick();
assert(lastVoice(plays) === 'voice/menu_lobby', '菜单单击循环 → menu_lobby');
game.onGesture('tap'); await tick();
assert(lastVoice(plays) === 'voice/menu_help', '菜单单击循环 → menu_help');
game.onGesture('tap'); await tick();
assert(lastVoice(plays) === 'voice/menu_resume', '菜单单击循环回卷 → menu_resume');
game.onGesture('up'); await tick();
assert(lastVoice(plays) === 'voice/help_recap', '菜单上滑 = 帮助');
game.onGesture('doubletap'); await tick(); // _menuIdx=0 → 恢复
assert(game.phase === 'playing', '菜单双击确认 menu_resume = 回对局');
assert(plays.some((p) => p.id === 'bgm/engine' && p.opts.fadeMs === 500),
'回对局淡回 bgm/engine 500ms');
await tick();
assert(audio._bgm && audio._bgm.id === 'bgm/engine', '对局态 BGM 恢复 engine');
// 下滑折中另一侧:有下方陨石时下滑 = 闪避
randQueue.push(0.75); // down
game.obstacles.clear(); game.swipes = [];
naturalSpawn();
const obD = [...game.obstacles].at(-1);
assert(obD.dir === 'down', '投放下方陨石');
await advance(obD.impactAt - clock.now - 800);
game.onGesture('down');
assert(game.phase === 'playing', '有下方陨石时下滑 = 闪避,不开菜单');
await advance(900);
assert(game.dodges === 1, '下滑闪避生效 dodges=1');
// 菜单"回大厅"
game.obstacles.clear();
game.onGesture('down'); await tick();
game.onGesture('tap'); await tick(); // → menu_lobby
game.onGesture('doubletap'); await tick();
assert(window.location.href === '../', '菜单双击确认 menu_lobby = 回大厅');
window.location.href = '';
// ======================================================================
section('S3 二周目:ask 流程 + skip_hint 不重复插播');
// ======================================================================
const h2 = makeHarness();
await h2.audio.unlock();
h2.game.start(); await tick();
assert(h2.game.phase === 'ask' && lastVoice(h2.plays) === 'voice/second_run_ask',
'二周目 second_run_ask 询问');
h2.game.onGesture('up'); await tick(); // 上滑重听演练
assert(h2.game.phase === 'drill' && lastVoice(h2.plays) === 'voice/drill_welcome',
'上滑重听演练');
await advance(1300);
assert(lastVoice(h2.plays) === 'voice/drill_rule', '重听演练 D0 无 skip_hint(仅首局一次)');
assert(!voices(h2.plays).includes('voice/skip_hint'), 'skip_hint 未重复插播');
h2.game.stop();
const h2b = makeHarness();
await h2b.audio.unlock();
h2b.game.start(); await tick();
h2b.game.onGesture('tap'); await tick(); // 单击直接出发
assert(h2b.game.phase === 'playing', '二周目单击直接出发');
h2b.game.stop();
// ======================================================================
section('S4 看门狗:屏蔽 voice 资产不卡死(§7.2 / §5 说明)');
// ======================================================================
globalThis.localStorage = makeLocalStorage(); // 全新存档 → 走演练
missingAssets.add('voice/drill_good');
const h3 = makeHarness();
await h3.audio.unlock();
h3.game.start(); await tick();
for (let i = 0; i < 3; i++) await advance(1300); // D0 三句逐句播完(welcome/skip_hint/rule)
assert(lastVoice(h3.plays) === 'voice/drill_left', '看门狗局:D1 旁白');
await advance(1300); // spawn D1
h3.game.onGesture('left'); await tick(); // 做对 → drill_good(缺失)
assert(fetchLog.some((f) => f.url.includes('drill_good') && !f.ok) && !h3.audio._voice,
'缺失资产 404 且未起播(静默降级)');
await advance(10100); // 看门狗 10s
assert(h3.game.drillIdx === 1 && lastVoice(h3.plays) === 'voice/drill_right',
'语音缺失时看门狗推进到 D2');
await advance(1300);
h3.game.onGesture('right'); await tick(); // D2 做对(无 drill_good)
assert(h3.game.drillIdx === 2, 'D2 无短肯定直接进 D3');
await advance(1300);
h3.game.onGesture('up'); await tick(); // D3 做对 → drill_good(缺失)→ 看门狗
await advance(10100);
assert(h3.game.drillIdx === 3, '看门狗再次推进到 D4');
await advance(1300);
h3.game.onGesture('down'); await tick(); // D4 做对 → drill_done
await advance(1400);
assert(h3.game.phase === 'playing', '屏蔽资产全流程不卡死,进 playing');
h3.game.stop();
missingAssets.clear();
// ======================================================================
section('S5 设置边界(§7.3)');
// ======================================================================
const h4 = makeHarness();
await h4.audio.unlock();
globalThis.localStorage = makeLocalStorage(); // 全新存档 → 走演练(S4 已写过 drillDone)
settings.set({ speed: 1.7 });
h4.game.start(); await tick();
assert(h4.game.phase === 'drill', 'S5 首局进演练');
assert(h4.audio._voice && h4.audio._voice.st.tempo === 1.7, 'speed=1.7 时 voice tempo 生效');
settings.set({ voice: 'male' });
const f0 = fetchLog.length;
h4.game.onGesture('tap'); await tick(); // 跳过 welcome → 下一句走 male 目录
const maleFetches = fetchLog.slice(f0).filter((f) => f.url.includes('/voice/'));
assert(maleFetches.length > 0 && maleFetches.every((f) => f.url.includes('/voice/male/')),
'settings.voice=male 后下一句起用 male 目录');
h4.game.stop();
settings.set({ bgm: 0 });
localStorage.setItem('soundgame.space.drillDone', '1');
const f1 = fetchLog.length;
h4.game.start(); await tick(); // ask
h4.game.onGesture('tap'); await tick(); // → _startRun → play('bgm/engine')
assert(h4.game.phase === 'playing', 'bgm=0 局进入 playing');
assert(!fetchLog.slice(f1).some((f) => f.url.startsWith('audio/bgm/')),
'bgm=0 时不加载 BGM 资产');
assert(!h4.audio._bgm && h4.audio._bgmWanted && h4.audio._bgmWanted.id === 'bgm/engine',
'bgm=0 时只记"想播"意图');
settings.set({ bgm: 1 });
await tick();
assert(h4.audio._bgm && h4.audio._bgm.id === 'bgm/engine', 'bgm 调回非零自动补播最后状态 BGM');
h4.game.stop();
settings.set({ voice: 'female', speed: 1.0 });
// ======================================================================
section('S6 资产引用校验(game.js 引用 id × ogg+m4a × 双性别)');
// ======================================================================
const src = readFileSync(path.join(GAME_DIR, 'game.js'), 'utf8')
.replace(/\/\*[\s\S]*?\*\//g, '') // 去块注释
.replace(/\/\/[^\n]*/g, ''); // 去行注释
const ids = new Set();
for (const m of src.matchAll(/['"`]((?:bgm|sfx|voice)\/[A-Za-z0-9_]+)['"`]/g)) {
if (!m[1].endsWith('_')) ids.add(m[1]); // 滤掉模板前缀(voice/protect_ 等)
}
// 动态拼接件展开
for (const d of ['left', 'right', 'up', 'down']) {
ids.add(`voice/protect_${d}`);
ids.add(`voice/hit_${d}`);
}
const missing = [];
for (const id of ids) {
if (id.startsWith('voice/')) {
for (const g of ['female', 'male']) {
for (const ext of ['ogg', 'm4a']) {
const rel = `audio/voice/${g}/${id.slice(6)}.${ext}`;
if (!existsSync(path.join(GAME_DIR, rel))) missing.push(rel);
}
}
} else {
for (const ext of ['ogg', 'm4a']) {
const rel = `audio/${id}.${ext}`;
if (!existsSync(path.join(GAME_DIR, rel))) missing.push(rel);
}
}
}
console.log(` 引用音频 id ${ids.size} 个`);
assert(missing.length === 0, '资产零缺失' + (missing.length ? `(缺:${missing.join(', ')})` : ''));
} catch (e) {
failed++;
console.error('\n未捕获异常(§7.2 失败):', e);
}
console.log(`\n========== 结果: ${passed} 通过, ${failed} 失败 ==========`);
process.exit(failed ? 1 : 0);