/** * 盲航(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', { // maxTouchPoints: 1 —— 手机 persona:inputMode() 判 touch,语音走触屏措辞主资产(键盘路由由 engine-input K9 专测) value: { maxTouchPoints: 1, 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);