/**
* 盲航(space)无头试玩测试 —— 覆盖 docs/design/13-playtest-criteria.md §5(space 关键路径
* + 边界断言)与 §7(全局通用断言),以及本轮改动(状态 BGM / skipVoice / skip_hint)、
* §11 round4-live 修复(方向呼喊提前到 spawn / 闪避窗口=呼啸全程 / 护盾 15s+不可用应答 /
* 保护期断崖过渡 / hit_last 呼喊接力 / feat 教学仅首局),以及 round2 R2 修复
* (下滑开菜单冷静期防误暂停 / 暂停恢复缓冲 / 回大厅收尾流程 / 警告链呼喊抑制+坠毁掐断)。
*
* 用法: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 = [], voiceStarts = [];
const origPlay = audio.play.bind(audio);
audio.play = (id, opts) => { plays.push({ id, opts: opts || {}, t: clock.now }); return origPlay(id, opts); };
// _playVoice 记录 voice 实际起播(queue 排队件由引擎内部接力,不经 play() 调用日志)
const origPlayVoice = audio._playVoice.bind(audio);
audio._playVoice = (id, opts) => { voiceStarts.push({ id, t: clock.now }); return origPlayVoice(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, voiceStarts };
}
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_
与来袭方向一致(左)');
assert(vibrated('30,50,30,50,30'), '保护期吸收 WARNING 震动');
assert(!plays.some((p) => p.t >= t0 && p.id.startsWith('voice/dir_')),
'撞击点落在保护期内的陨石 spawn 不播 dir_(§11:protect 长句教学优先)');
// -- §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 + 15000, '护盾充能 15s 锁定(§11 改口,原 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 排队语音播出');
// -- §11 护盾充能中双击:即时应答 + 4s 去抖(P1 panic 双击零反馈) --
const featDoneCount = () => voices(plays).filter((id) => id === 'voice/feat_shield_done').length;
const featDoneBefore = featDoneCount();
game.onGesture('doubletap'); await tick();
assert(game.shieldUpUntil === 0, '充能中双击不展开护盾');
assert(featDoneCount() === featDoneBefore + 1,
'护盾不可用即时应答:播 feat_shield_done 现成句(§11,不新增语音 id)');
assert(vibrated('40,100,40'), '护盾不可用 HEARTBEAT 震动(与撞击/警报可区分)');
game.onGesture('doubletap'); await tick();
assert(featDoneCount() === featDoneBefore + 1, '护盾不可用应答 4s 去抖(连刷不重播)');
// -- 切断自然刷怪(测试钩子),后续手动投放,避免难度曲线干扰 --
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/§11 正常闪避 3 颗(同向滑;§11 起窗口=呼啸全程,此处仍在撞击前 800ms 出手) --
// 同循环验证 §11 断崖过渡(裸飞第 1~3 颗逼近固定 4.0s)与方向呼喊时机(spawn 同窗)
let expectDodges = game.dodges;
let bareIdx = 0;
for (const rv of [0, 0.25, 0.5]) { // left / right / up
bareIdx++;
randQueue.push(rv);
game.obstacles.clear(); game.swipes = [];
const tSpawn = clock.now;
naturalSpawn();
const ob = [...game.obstacles].at(-1);
await tick(); // voice 播放安全门:播放经 microtask 落位(虚拟时钟不走,t 仍 = tSpawn)
assert(ob.impactAt - tSpawn === 4000,
`断崖过渡:裸飞第 ${bareIdx} 颗逼近 4.0s 不收缩(§11)`);
assert(plays.some((p) => p.id === `voice/dir_${ob.dir}` && p.t === tSpawn),
`方向呼喊 dir_${ob.dir} 与呼啸同窗(spawn 当刻,§11)`);
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, '同向闪避不掉血');
assert(plays.filter((p) => p.id === `voice/dir_${ob.dir}` && p.t >= tSpawn).length === 1,
'撞击前 500ms loud 警报档不再重复方向呼喊(§11)');
}
// -- §11 早滑不再被吞:呼啸响起立刻滑(距撞击 >1.5s)也判躲开(P1 核心回归) --
randQueue.push(0.75); // down
game.obstacles.clear(); game.swipes = [];
const tEarly = clock.now;
naturalSpawn();
const obEarly = [...game.obstacles].at(-1);
assert(obEarly.impactAt - tEarly < 4000, '断崖过渡:裸飞第 4 颗起接入难度曲线(<4.0s,§11)');
await advance(200); // 呼啸响起 0.2s 内立刻同向滑(旧版 1.5s 窗口之外,必被吞的位置)
game.onGesture('down');
await advance(obEarly.impactAt - clock.now + 100); // 过撞击点
expectDodges++;
assert(game.dodges === expectDodges && game.hits === 0,
'早滑(spawn+0.2s,距撞击 >1.5s)判定躲开(§11 窗口=呼啸全程)');
// -- 边界:反向滑 ≠ 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_ 归因方向与来袭一致');
assert(vibrated('80'), '受击 HIT 震动');
// -- ② 对局归因语音可跳 --
const skipsB2 = skips.length;
game.onGesture('tap'); await tick();
assert(skips.length === skipsB2 + 1, '归因语音在播时单击 = skipVoice()');
// -- 边界/§11+round2 R2:第 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 不坠毁');
assert(h.voiceStarts.at(-1).id === 'voice/hit_right', '归因 hit_right 开播');
assert(game._hitLastPending === true, 'hit_last 挂起待接播(§11 呼喊接力)');
randQueue.push(0); // left:刷下一颗裸飞陨石,其 spawn 呼喊顶断归因句(生存优先级保留)
naturalSpawn();
const obNext = [...game.obstacles].at(-1);
await tick();
assert(h.voiceStarts.at(-1).id === 'voice/dir_left', '下一颗 spawn 呼喊顶断归因(生存优先级)');
await advance(1300); // 呼喊播完 → onComplete 接力 → hit_last 自动接播
assert(h.voiceStarts.at(-1).id === 'voice/hit_last',
'归因被呼喊顶断后 hit_last 仍接播(§11:最后一命不再被静默吞掉)');
assert(game._hitLastPending === false, 'hit_last 接播后挂起标记清除');
assert(game._hitLastPlaying === true, 'hit_last 在播标记置位(round2 R2)');
// round2 R2:hit_last 在播期间新刷陨石的 spawn 呼喊被抑制(警告播完前不被顶断)
const shoutMark2 = h.voiceStarts.length;
randQueue.push(0.5); // up
naturalSpawn();
const obNext2 = [...game.obstacles].at(-1);
await tick();
assert(!h.voiceStarts.slice(shoutMark2).some((v) => v.id === 'voice/dir_up'),
'hit_last 在播期间 spawn 不播方向呼喊(round2 R2:警告播完前不被顶断)');
await advance(1300); // hit_last 播完 → 呼喊抑制解除
assert(game._hitLastPlaying === false, 'hit_last 播完在播标记清除');
randQueue.push(0.5); // up:链后再刷陨石,方向呼喊恢复
naturalSpawn();
await tick();
assert(h.voiceStarts.at(-1).id === 'voice/dir_up', '警告链结束后方向呼喊恢复(round2 R2)');
game.obstacles.delete(obNext);
game.obstacles.delete(obNext2);
game.obstacles.clear(); game.swipes = [];
// -- 边界:里程碑 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 次撞击坠毁(round2 R2 B4:警告链在播时坠毁即掐断,结算不叠播) --
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 - 500); // 撞击前 500ms:重挂"最后一命"警告链
game._hitLastPending = true; // 测试钩子:模拟第 2 撞后归因刚播完、警告待接播
game._drainHitLast(); await tick();
assert(h.voiceStarts.at(-1).id === 'voice/hit_last' && !!audio._voice, '坠毁前 hit_last 在播');
await advance(600); // 过撞击点 → hits===3 → 坠毁
assert(game.hits === 3 && game.phase === 'over', 'hits===3 才坠毁 gameover');
assert(!audio._voice, '坠毁即掐断警告链:hit_last 不再与结算叠播(round2 R2 B4)');
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');
// -- §11 feat 教学仅首局:第 2 局过 20s/45s 不进 feature、不重播,机制开局即用 --
const featMark = plays.length;
await advance(20050); // 过单击引入时刻(保护期内陨石不伤血)
assert(game.phase === 'playing', '第 2 局过 20s 不进 feature phase(§11)');
game.onGesture('tap'); await tick(); // 无旁白在播 → 加速直接生效
assert(game.boostUntil > clock.now, '第 2 局单击加速开局即用(§11)');
game.onGesture('doubletap'); await tick();
assert(game.shieldUpUntil > clock.now, '第 2 局双击护盾开局即用(§11)');
await advance(25050); // 过双击引入时刻
assert(game.phase === 'playing', '第 2 局过 45s 仍不进 feature phase(§11)');
assert(!plays.slice(featMark).some((p) => p.id === 'voice/feat_boost' || p.id === 'voice/feat_shield'),
'第 2 局 feat_boost/feat_shield 不再重播(§11)');
// ======================================================================
section('S2 菜单:状态 BGM 切换 + 下滑折中 + 全球约定(§7.5)');
// ======================================================================
game.obstacles.clear(); game.swipes = [];
game._lastResolveAt = -Infinity; // 测试钩子:跳过开菜单冷静期(round2 R2)
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');
// -- round2 R2 恢复缓冲:恢复后前 3 颗裸飞陨石回 4.0s 慢节奏(软重启,不再 3 秒暴毙) --
game.protectionUntil = clock.now - 1000; // 测试钩子:强制视为保护期已过
game._protectAnnounced = true; // 不再补播 protect_end
for (let i = 1; i <= 3; i++) {
randQueue.push(0); // left
game.obstacles.clear(); game.swipes = [];
const tR = clock.now;
naturalSpawn();
const obR = [...game.obstacles].at(-1);
assert(obR.impactAt - tR === 4000,
`恢复缓冲:暂停恢复后第 ${i} 颗裸飞陨石逼近 4.0s 不收缩(round2 R2)`);
}
randQueue.push(0);
game.obstacles.clear(); game.swipes = [];
const tR4 = clock.now;
naturalSpawn();
const obR4 = [...game.obstacles].at(-1);
assert(obR4.impactAt - tR4 < 4000, '恢复缓冲:第 4 颗起回到难度曲线(round2 R2)');
game.obstacles.clear(); game.swipes = [];
// -- round2 R2 误暂停守卫:判定后冷静期内的下滑绝不开菜单;过冷静期保留旧折中 --
game._lastResolveAt = clock.now; // 测试钩子:模拟"刚判定完(撞/躲)"
game.onGesture('down'); await tick();
assert(game.phase === 'playing', '判定后 800ms 冷静期内下滑不开菜单(round2 R2 误暂停修复)');
game._lastResolveAt = -Infinity;
randQueue.push(0); // left:非下方陨石悬置中(过冷静期保留旧折中,仍可开菜单)
naturalSpawn();
await tick();
game.onGesture('down'); await tick();
assert(game.phase === 'menu', '非下方陨石悬置且过冷静期下滑 = 菜单(旧折中保留,round2 R2)');
game._closeMenu(); await tick(); // 恢复对局继续后续用例
game.obstacles.clear(); game.swipes = [];
// 下滑折中另一侧:有下方陨石时下滑 = 闪避
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');
// 菜单"回大厅"(round2 R2 收尾流程:leaving 态 + 确认句 → 跳转,不再落无声死界面)
game.obstacles.clear(); game.swipes = [];
game._lastResolveAt = -Infinity; // 测试钩子:跳过开菜单冷静期
game.onGesture('down'); await tick();
assert(game.phase === 'menu', '过冷静期后下滑再开菜单(回大厅前置)');
game.onGesture('tap'); await tick(); // → menu_lobby
game.onGesture('doubletap'); await tick();
assert(game.phase === 'leaving', '回大厅确认中:leaving 收尾态(round2 R2)');
assert(lastVoice(plays) === 'voice/menu_lobby', '先播回大厅确认句再跳转(round2 R2)');
assert(window.location.href !== '../', '确认句播完前不跳转(round2 R2)');
game.onGesture('left'); await tick(); // leaving 中滑动不再受理
assert(game.phase === 'leaving', 'leaving 中滑动手势被忽略(round2 R2)');
game.onGesture('tap'); await tick(); // leaving 中单击 = 跳过确认句 → 立即跳转
assert(window.location.href === '../', '确认句跳过后跳转大厅(round2 R2)');
window.location.href = '';
game.stop();
// ======================================================================
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}`);
ids.add(`voice/dir_${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);