/** * 盲航(space)无头试玩测试 —— 26 号 playbook 深度重构版(docs/design/26-apply-space.md)。 * * 覆盖: * - §4.10 首 3 分钟:演练第一句 = D1 指令(critical,手势一律 busy 回执)、 * 第一次成功 ≤10s、做错当场重教、没出手说「没拨到」(禁说慢了)、2 次后演示通过; * - 26 号交互层:对局 = Floor ACTION(玩法声即状态)、演练指令 critical、 * 结算链播完 yieldTurn settle 等待点、长按=我在哪/重听、新手期无双击(管道折算 tap)、 * 未解锁单击 = invalid + 首次「现在只要滑。」、挥空 = whiff + invalid + 首次解释; * - 04 号玩法核心保留:同向判定窗(呼啸全程 + 250ms 宽限,早滑不吞)、whoosh 方位、 * 60s 保护期(吸收 + protect_ 归因 + R5 去重)、断崖过渡、归因分方位 + hit_last、 * R5-2 呼喊顶断登记重排、里程碑、警报两档去重; * - 原则 15:首局前两次失败锁血(归因照说真话)、第 3 次失败起自动降刷怪; * - 阶段 4 高级手势:第一局结束解锁加速(一句教学,不暂停刷怪);护盾仅双击已识别 * 的会话(DT_CORE 快照,新手期双击折算);冷却拒绝必须出声(boost_not_ready); * - §4.12 断点复述:stop() 自动存档 → 回来问继续/重开 → 复述句 + count-in 慢速陨石; * - 菜单(00 §5.4):左右滑切换 + 单击确认(阶段 2 结算后下滑呼出);回大厅收尾态; * - §6.2 乱点机器人:无声操作 = 0、被动原句重复 = 0(dir 呼喊是玩法内容音,不计)、 * 打断失败 = 0(同一 voice token 在手势 150ms 后仍在播;critical 与呼喊豁免); * - 资产引用校验(26 号改造新增 id 白名单 = 待生成资产,见 tools/voice_lines_26/space.md)。 * * 用法:tools/node/bin/node web/space/test/playtest.mjs * * 设计: * - 真实加载 engine/{audio,settings,pipeline}.js 与 game.js,只 mock 浏览器环境; * 手势经 makeInputPipeline 注入(与 index.html 同款接线),回执可观测。 * - fetch 映射到 web/space/audio/ 真实文件:sfx/bgm 缺失 → 404;voice id 属 * PENDING_NEW(26 号新增、资产待统一生成)→ 模拟存在(duration 1.0s), * 资产校验段单独断言白名单与实际文件一致。 * - 虚拟时钟:setTimeout/clearTimeout/performance.now 全部替换,测试零真实等待。 * - 刷怪方向用 Math.random 队列控制;陨石投放用 naturalSpawn(游戏实例方法注入)。 */ 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)), '..'); // ---------------- 26 号改造新增 voice id(资产待统一生成,先按存在模拟) ---------------- const PENDING_NEW = new Set([ 'hint_tap', 'd_short_left', 'd_short_right', 'd_short_up', 'd_short_down', 'hint_swipe', 'inv_tap', 'inv_whiff', 'miss', 'lp_run', 'lp_teach', 'sys_turn_teach', 'sys_say_again', 'sys_unlock_down', 'sys_demo', 'sys_now_you', 'resume_ask', 'resume_recap', ]); // ---------------- 虚拟时钟 ---------------- 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,语音走触屏措辞主资产 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(), }; } const storage = makeLocalStorage(); Object.defineProperty(globalThis, 'localStorage', { value: storage, 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_left'。 */ 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/'); const name = id.replace(/^voice\//, ''); let ok; if (missingAssets.has(id)) ok = false; else if (id.startsWith('voice/')) ok = PENDING_NEW.has(name) || fileExists(u); else ok = fileExists(u); fetchLog.push({ url: u, opts, t: clock.now, ok }); if (!ok) return { ok: false, status: 404, arrayBuffer: async () => new ArrayBuffer(0) }; 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 () => ({}) }; }; /** 引擎按 ogg→m4a 逐个探测;键盘变体目录缺失会回退主资产(key/ → 同性别主目录)。 */ function fileExists(u) { const base = u.replace(/\.[a-z0-9]+$/i, ''); const cands = [base, base.includes('/key/') ? base.replace('/key/', '/') : null]; return cands.some((c) => c && ['.ogg', '.m4a'].some((ext) => existsSync(path.join(GAME_DIR, c + ext)))); } 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 }; } createOscillator() { return { type: 'sine', frequency: new MockParam(440), connect() {}, start() {}, stop() {} }; } 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 { makeInputPipeline } = await import('../../engine/pipeline.js'); const { SpaceGame } = await import('../game.js'); // 向导层关闭:回归测试只验证纯游戏逻辑(guide 设置只影响等待点提示档位) 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 链与 voice 播放安全门是 async 的)。 */ const tick = async (n = 10) => { for (let i = 0; i < n; i++) await new Promise((r) => setImmediate(r)); }; /** 分段推进:每 ≤400ms 让出主线程冲微任务(voice 安全门/decode 不被饿死)。 */ async function advance(ms) { let left = ms; while (left > 0) { const step = Math.min(400, left); clock.advance(step); await tick(2); left -= step; } } /** 轮询推进直到条件成立(虚拟时长上限内)。 */ async function waitFor(cond, maxMs = 30000) { const target = clock.now + maxMs; while (!cond() && clock.now < target) await advance(300); return cond(); } function makeRig() { const audio = new WebAudioEngine('audio/'); const plays = [], skips = [], voiceStarts = [], receiptLog = [], chimeLog = []; const origPlay = audio.play.bind(audio); audio.play = (id, opts) => { plays.push({ id, opts: opts || {}, t: clock.now }); return origPlay(id, opts); }; const origPV = audio._playVoice.bind(audio); audio._playVoice = (id, opts) => { voiceStarts.push({ id, t: clock.now }); return origPV(id, opts); }; const origSkip = audio.skipVoice.bind(audio); audio.skipVoice = () => { skips.push(clock.now); return origSkip(); }; const game = new SpaceGame(audio, {}); game.floor.bedEnabled = false; // 虚拟时钟测试关等待声(周期定时器不排水) const origR = game.receipts.play.bind(game.receipts); game.receipts.play = (kind) => { receiptLog.push({ kind, t: clock.now }); return origR(kind); }; const origChime = game.floor.chime.bind(game.floor); game.floor.chime = () => { chimeLog.push(clock.now); return origChime(); }; const pipe = makeInputPipeline({ floor: game.floor, confusion: game.confusion, receipts: game.receipts, invalidHints: game.invalidHints, game, ladder: game.ladder, opts: game.PIPELINE_OPTS, }); return { audio, game, plays, skips, voiceStarts, receiptLog, chimeLog, pipe }; } const voices = (plays) => plays.filter((p) => p.id.startsWith('voice/')).map((p) => p.id); const lastVoice = (plays) => voices(plays).at(-1); const voicePlays = (plays, id) => plays.filter((p) => p.id === id).length; const vibrated = (pat) => vibrations.some((v) => Array.isArray(v) && v.join() === pat); const confirmCount = () => vibrations.filter((v) => Array.isArray(v) && v.join() === '20').length; const invalidCount = (rig) => rig.receiptLog.filter((r) => r.kind === 'invalid').length; const busyCount = (rig) => rig.receiptLog.filter((r) => r.kind === 'busy').length; const freshStorage = () => storage.clear(); try { // ====================================================================== section('S1 演练(§4.10 首 3 分钟):第一句就让玩家拨一颗'); // ====================================================================== freshStorage(); const h = makeRig(); const { game, audio, plays, skips, receiptLog } = h; assert(fetchLog.length === 0, '音频解锁前 play 不加载不播放(§7.1)'); await audio.unlock(); game.start(); await tick(); assert(game.phase === 'drill', '首局直接进演练(删 D0 零操作欢迎)'); assert(lastVoice(plays) === 'voice/drill_left', '第一句 = D1 指令「听,左边有陨石。向左滑。」'); assert(game.floor.speaking && game.floor._chain && game.floor._chain.critical === true, '演练指令 = critical 关键句(§4.6)'); // critical 关键句:手势一律 busy 回执,不跳句、不打断 const busy0 = busyCount(h); await h.pipe('tap'); await tick(); await h.pipe('right'); await tick(); await h.pipe('longpress'); await tick(); assert(busyCount(h) === busy0 + 3, '关键句在播:单击/滑动/长按全部 busy 回执'); assert(skips.length === 0, '关键句不可跳过(12 号旧「单击跳旁白」对 critical 失效)'); // 指令播完 → 演练陨石 spawn(语音先、音效后)→ 交出等待点 await waitFor(() => game.drillOb); assert(game.drillOb.dir === 'left', 'D1 演练陨石(左)出现'); assert(plays.some((p) => p.id === 'sfx/whoosh_left'), 'whoosh_left 方位音'); assert(game.floor.state === 'YOUR_TURN' && game.floor.waitPoint && game.floor.waitPoint.conf.id === 'd1_left', '演练陨石 = 等待点 d1_left(04 §0.5)'); // 等待点单击 = invalid + 首次「向声音那边滑。」(矩阵行 2) const inv0 = invalidCount(h); await h.pipe('tap'); await tick(); assert(invalidCount(h) === inv0 + 1, '演练等待点单击 = invalid 回执'); await waitFor(() => voicePlays(plays, 'voice/hint_swipe') === 1); assert(true, '首次解释「向声音那边滑。」'); await h.pipe('tap'); await tick(); await advance(1500); assert(voicePlays(plays, 'voice/hint_swipe') === 1, '同格解释只说一次(原则 7)'); // 做对:dodge 音 + drill_good + 马上教长按帮助(04 §0.3 阶段 1) await waitFor(() => game.floor.state === 'YOUR_TURN'); await h.pipe('left'); await tick(); assert(plays.some((p) => p.id === 'sfx/dodge'), '做对 → dodge 音(内容音即反馈)'); await waitFor(() => voicePlays(plays, 'voice/drill_good') === 1); await waitFor(() => voicePlays(plays, 'voice/lp_teach') === 1); assert(storage.getItem('soundgame.space.lpTaught') === '1', '长按教学落盘(只教一次)'); assert(game.ladder.stage() === 1, '第一次成功闪避 → 手势阶梯阶段 1'); assert(game.drillIdx === 0 && game.floor.speaking, '第一次成功:肯定句后接 D2'); // D2:做对 → 教「轮到你」状态(10~30s 窗口,§4.10 模板) await waitFor(() => game.drillIdx === 1 && !!game.drillOb && game.drillOb.dir === 'right'); await h.pipe('right'); await tick(); await waitFor(() => voicePlays(plays, 'voice/sys_turn_teach') === 1); assert(true, '10~30s 窗口:教「听到这个声音,就是轮到你了」'); // D3:没出手(超时)→ 真话归因「没拨到」(禁说慢了)+ 同一颗重来 await waitFor(() => game.drillIdx === 2 && !!game.drillOb && game.drillOb.dir === 'up'); const impact3 = game.drillOb.impactAt; await advance(impact3 - clock.now + 100); assert(game.drillFails === 1, '演练陨石到点未拨开 = 失败 1 次'); await waitFor(() => voicePlays(plays, 'voice/miss') === 1); assert(true, '没出手 → 「没拨到。声音一响就滑。」(不说「慢了」)'); await waitFor(() => voicePlays(plays, 'voice/drill_retry_up') === 1); await waitFor(() => game.drillOb && game.drillOb.dir === 'up'); await h.pipe('up'); await tick(); assert(voicePlays(plays, 'voice/drill_good') === 1, '语音肯定仅 D1(D2-D4 只内容音,04 §2)'); // D4:连错两次 → 演示通过(04 §0.6「2 次后演示通过」) await waitFor(() => game.drillIdx === 3 && !!game.drillOb && game.drillOb.dir === 'down'); await h.pipe('left'); await tick(); // 错向 1 assert(game.drillFails === 1, '错向 → 失败计数 1'); await waitFor(() => voicePlays(plays, 'voice/drill_retry_down') === 1); await waitFor(() => game.drillOb && game.drillOb.dir === 'down'); await h.pipe('right'); await tick(); // 错向 2 assert(game.drillFails === 2, '同一步失败 2 次'); await waitFor(() => voicePlays(plays, 'voice/drill_moveon') === 1); assert(plays.filter((p) => p.id === 'sfx/dodge').length >= 1, '演示拨开有 dodge 音'); await waitFor(() => voicePlays(plays, 'voice/drill_done') === 1); await waitFor(() => game.phase === 'playing'); assert(storage.getItem('soundgame.space.drillDone') === '1', 'drillDone 落盘'); assert(game.floor.state === 'ACTION', '对局 = Floor ACTION 态(玩法声即状态,不起等待声)'); await tick(); assert(audio._bgm && audio._bgm.id === 'bgm/engine', '对局 BGM = bgm/engine'); // ====================================================================== section('S2 保护期:吸收归因 + R5 去重 + 新手单击/挥空拒绝 + 播报'); // ====================================================================== const t0 = game.runStart; await advance(1600); // 首颗(左,Math.random 默认 0) assert(game.obstacles.size >= 1, '保护期首颗陨石刷出'); assert(!plays.some((p) => p.t >= t0 && p.id.startsWith('voice/dir_')), '撞击点落在保护期内的陨石 spawn 不播 dir 呼喊(protect 长句教学优先)'); await advance(4100); // 到撞击点 assert(game.hits === 0, '保护期撞击不掉血(训练护盾吸收)'); await waitFor(() => voicePlays(plays, 'voice/protect_left') === 1); assert(true, 'protect_ 与来袭方向一致(左)'); assert(vibrated('30,50,30,50,30'), '保护期吸收 WARNING 震动'); assert(plays.some((p) => p.id === 'sfx/shield_block'), '吸收有 shield_block 音'); // R5 去重:同方位吸收 30s 内不重播教学句(只有音+震) game.obstacles.clear(); const obP2 = { dir: 'left', impactAt: clock.now + 100 }; game.obstacles.add(obP2); game._after(100, () => game._resolve(obP2)); await advance(150); assert(game.hits === 0 && voicePlays(plays, 'voice/protect_left') === 1, '同方位吸收 30s 台账去重(被动原句重复 = 0,原则 9)'); // 新手期对局单击 = invalid + 首次「现在只要滑。」(加速未解锁) //(先等教学句播完回 ACTION,避免这下被当成「跳过这句」) await waitFor(() => !audio.isVoicePlaying() && game.floor.state === 'ACTION'); const inv1 = invalidCount(h); await h.pipe('tap'); await tick(); assert(invalidCount(h) === inv1 + 1, '未解锁加速:对局单击 = invalid 回执'); await waitFor(() => voicePlays(plays, 'voice/inv_tap') === 1); await h.pipe('tap'); await tick(); await advance(1500); assert(voicePlays(plays, 'voice/inv_tap') === 1, '「现在只要滑。」只说一次(原则 7)'); // 挥空 = whiff 音 + invalid + 首次解释(R5 保留 + 04 §0.7) const inv2 = invalidCount(h); await h.pipe('up'); await tick(); assert(invalidCount(h) === inv2 + 1 && plays.some((p) => p.id === 'sfx/whiff'), '挥空 → whiff 音 + invalid 回执'); await waitFor(() => voicePlays(plays, 'voice/inv_whiff') === 1); assert(true, '挥空首次解释「现在没有这边的陨石。」'); // 新手期双击折算成单击(管道处理,护盾不存在) const shieldBefore = game.shieldUpUntil; await h.pipe('doubletap'); await tick(); assert(game.shieldUpUntil === shieldBefore, '新手期双击不展开护盾(折算 tap,无双击)'); // 50s 预告 / 60s 保护结束 await advance(t0 + 50500 - clock.now); assert(voicePlays(plays, 'voice/protect_soon') === 1, '50s 预告 protect_soon(R5)'); await advance(t0 + 60500 - clock.now); assert(game._protectAnnounced && voicePlays(plays, 'voice/protect_end') === 1, '60s 保护解除播报 protect_end'); // ====================================================================== section('S3 对局核心:同向判定窗 + 呼喊 + 新手锁血 + 归因接力 + 坠毁'); // ====================================================================== // 切断自然刷怪(测试钩子),后续手动投放,避免难度曲线干扰 const naturalSpawn = game._spawnObstacle.bind(game); game._spawnObstacle = () => {}; game._bareCount = 0; // 测试钩子:裸飞计数清零(断崖过渡从第 1 颗起验) // 断崖过渡 + 呼喊同窗 + 早滑不吞 randQueue.push(0.75); // down game.obstacles.clear(); game.swipes = []; const tS = clock.now; naturalSpawn(); const obD = [...game.obstacles].at(-1); await tick(); assert(obD.impactAt - tS === 4000, '保护期后裸飞第 1 颗逼近 4.0s 不收缩(断崖过渡)'); assert(plays.some((p) => p.id === 'voice/dir_down' && p.t === tS), '方向呼喊 dir_ 与呼啸同窗(spawn 当刻)'); await advance(200); // 呼啸响起 0.2s 内立刻同向滑(旧 1.5s 窗口之外的位置) await h.pipe('down'); await tick(); await advance(obD.impactAt - clock.now + 100); assert(game.dodges === 1 && game.hits === 0, '早滑(spawn+0.2s)判定躲开(窗口 = 呼啸全程)'); // 新手锁血:保护期后前两次失败不扣资源(原则 15),归因照说真话 //(S2 保护期吸收已累计 _failCount,这里看增量) const fc0 = game._failCount; randQueue.push(0); // left game.obstacles.clear(); game.swipes = []; naturalSpawn(); const obA = [...game.obstacles].at(-1); await h.pipe('right'); await tick(); // 反向滑 = 挥空 await advance(obA.impactAt - clock.now + 100); assert(game.hits === 0 && game._failCount === fc0 + 1, '首局第 1 次失败:锁血吸收不掉血'); await waitFor(() => voicePlays(plays, 'voice/hit_left') === 1); assert(plays.some((p) => p.id === 'sfx/shield_block'), '锁血吸收有 shield_block 音'); randQueue.push(0.25); // right game.obstacles.clear(); game.swipes = []; naturalSpawn(); const obB = [...game.obstacles].at(-1); await advance(obB.impactAt - clock.now + 100); assert(game.hits === 0 && game._failCount === fc0 + 2, '首局第 2 次失败:仍锁血'); await waitFor(() => voicePlays(plays, 'voice/hit_right') === 1); // 第 3 次失败:实打实掉血 + 自动降难度(原则 15) randQueue.push(0.5); // up game.obstacles.clear(); game.swipes = []; naturalSpawn(); const obC = [...game.obstacles].at(-1); await advance(obC.impactAt - clock.now + 100); assert(game.hits === 1 && game._failCount === fc0 + 3, '第 3 次失败起扣血'); assert(game._eased === true, '第 3 次失败自动降难度(刷怪放缓)'); randQueue.push(0.5); // up game.obstacles.clear(); game.swipes = []; naturalSpawn(); const obE = [...game.obstacles].at(-1); await tick(); assert(obE.impactAt - obE.spawnAt > 2800, '降难度后逼近时长加宽(原则 15)'); await advance(obE.impactAt - clock.now + 100); assert(game.hits === 2, '第 4 次失败 hits=2'); await waitFor(() => voicePlays(plays, 'voice/hit_up') === 2); await waitFor(() => voicePlays(plays, 'voice/hit_last') === 1); assert(vibrated('80'), '受击 HIT 震动'); // R5-2:呼喊顶断归因链 → 登记重排(先因后果整链重播一次) randQueue.push(0); // left:新陨石 spawn 呼喊顶断在播的 [hit_up, hit_last] 链 game.obstacles.clear(); game.swipes = []; naturalSpawn(); const obNext = [...game.obstacles].at(-1); await waitFor(() => h.voiceStarts.some((v) => v.id === 'voice/dir_left')); assert(true, '下一颗 spawn 呼喊顶断归因(生存优先级保留)'); await h.pipe('left'); await tick(); // 滑动打断并闪避(矩阵行 4) await advance(obNext.impactAt - clock.now + 100); assert(game.dodges === 2 && game.hits === 2, '打断呼喊的滑动照样拨开陨石'); await waitFor(() => voicePlays(plays, 'voice/hit_up') === 3); assert(true, '归因被顶断后整链重排一次(R5-2 补播,先因后果)'); game.obstacles.clear(); game.swipes = []; // 第 3 次实撞 → 坠毁结算 randQueue.push(0.75); // down naturalSpawn(); const obH3 = [...game.obstacles].at(-1); const crashAt = obH3.impactAt; await advance(obH3.impactAt - clock.now + 100); 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 震动'); // 结算链:time → new_record → dodge → restart_hint(critical)→ settle 等待点 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'; await advance(1300); // 爆炸声 1.2s 后开口 await waitFor(() => voicePlays(plays, 'voice/restart_hint') === 1); assert(voicePlays(plays, expectTime) === 1, `结算时间段句 ${expectTime}(存活 ${aliveSec}s)`); assert(voicePlays(plays, 'voice/new_record') === 1, '首局必破纪录 new_record'); await waitFor(() => game.floor.state === 'YOUR_TURN'); assert(game.floor.waitPoint && game.floor.waitPoint.conf.id === 'settle', '结算链播完 yieldTurn 结算等待点(不留安静态,§4.1)'); assert(voicePlays(plays, 'voice/sys_unlock_down') === 1, '第一局结束教「向下滑,可以打开菜单」(阶段 2)'); assert(game.ladder.stage() === 2, 'onGameDone → 阶梯阶段 2'); // 结算滑动 = invalid + 解释(invalidHint over → restart_hint) const inv3 = invalidCount(h); await h.pipe('left'); await tick(); assert(invalidCount(h) === inv3 + 1, '结算态滑动 = invalid 回执'); // 长按 = 重听结算(再说一遍: + 成绩链) await waitFor(() => game.floor.state === 'YOUR_TURN'); await h.pipe('longpress'); await tick(); await waitFor(() => voicePlays(plays, 'voice/sys_say_again') === 1); assert(true, '长按重听结算(前缀「再说一遍:」)'); // ====================================================================== section('S4 菜单(00 §5.4):左右滑切换 + 单击确认;回大厅收尾'); // ====================================================================== await waitFor(() => game.floor.state === 'YOUR_TURN' && game.phase === 'over'); await h.pipe('down'); await tick(); assert(game.phase === 'menu', '结算态下滑 = 菜单(阶段 2 解锁;对局中下滑永远是闪避)'); await waitFor(() => voicePlays(plays, 'voice/menu_resume') === 1); assert(voicePlays(plays, 'voice/menu_main') === 1, '菜单打开句 + 首项报名'); await waitFor(() => game.floor.state === 'YOUR_TURN' && game.floor.waitPoint.conf.id === 'menu'); await h.pipe('right'); await tick(); await waitFor(() => voicePlays(plays, 'voice/menu_lobby') === 1); assert(true, '右滑 → 下一项报名(menu_lobby)'); await waitFor(() => game.floor.state === 'YOUR_TURN'); await h.pipe('left'); await tick(); await waitFor(() => voicePlays(plays, 'voice/menu_resume') === 2); assert(true, '左滑 → 上一项报名(回卷 menu_resume)'); // 长按 = 重听菜单说明 await waitFor(() => game.floor.state === 'YOUR_TURN'); await h.pipe('longpress'); await tick(); await waitFor(() => voicePlays(plays, 'voice/menu_main') === 2); assert(true, '长按重听菜单说明'); // 教学要点项:右滑两次 → menu_help → 单击确认播 help_recap await waitFor(() => game.floor.state === 'YOUR_TURN'); await h.pipe('right'); await tick(); await h.pipe('right'); await tick(); await waitFor(() => game.floor.state === 'YOUR_TURN'); await h.pipe('tap'); await tick(); await waitFor(() => voicePlays(plays, 'voice/help_recap') === 1); assert(true, '确认「教学要点」→ help_recap(改版口径:无单击加速/双击护盾)'); // 回大厅收尾(round2 R2:先播确认句再跳转,不落无声死界面) await waitFor(() => game.floor.state === 'YOUR_TURN'); await h.pipe('left'); await tick(); // → menu_lobby await waitFor(() => game.floor.state === 'YOUR_TURN'); const lobbyBefore = voicePlays(plays, 'voice/menu_lobby'); await h.pipe('tap'); await tick(); assert(game.phase === 'leaving', '确认「回大厅」→ leaving 收尾态'); await waitFor(() => voicePlays(plays, 'voice/menu_lobby') === lobbyBefore + 1); assert(true, '先播回大厅确认句'); assert(window.location.href !== '../', '确认句播完前不跳转'); await advance(2600); // 确认句播完 / 看门狗兜底跳转 assert(window.location.href === '../', '确认句播完(或看门狗)后跳转大厅'); window.location.href = ''; // 测试钩子:复位跳转 game._goingHome = false; // 测试钩子:解除收尾标记 // 单击 = 确认当前项(menu_resume 继续飞行 → 新一局;走真实菜单路径重开) game._openMenu(); // 测试钩子:重新呼出菜单(结算态语义) await waitFor(() => game.floor.state === 'YOUR_TURN' && game.floor.waitPoint.conf.id === 'menu'); await h.pipe('tap'); await tick(); assert(game.phase === 'playing', '单击确认 menu_resume = 再飞一次'); assert(audio._bgm && audio._bgm.id === 'bgm/engine', '新局 bgm/engine'); // ====================================================================== section('S5 第二局:加速解锁教学 + 冷却拒绝出声 + 断点复述'); // ====================================================================== const t2 = game.runStart; await waitFor(() => voicePlays(plays, 'voice/feat_boost') === 1, 12000); assert(voicePlays(plays, 'voice/feat_boost') === 1, '第一局结束 → 第二局 6s 教一句加速(不暂停刷怪)'); assert(game.phase === 'playing', '教学不进独立 phase(04 §3 废止保护期插课)'); // 单击 = 加速(已解锁;boost 音即反馈)。先等教学句播完回 ACTION—— // 语音在播时单击 = 跳过这句(consumed),不会触发加速 await waitFor(() => !audio.isVoicePlaying() && game.floor.state === 'ACTION'); const boostPlays0 = plays.filter((p) => p.id === 'sfx/boost').length; const conf0 = confirmCount(); await h.pipe('tap'); await tick(); assert(game.boostUntil > clock.now, '单击 → 加速生效(runs>=1 解锁)'); assert(plays.filter((p) => p.id === 'sfx/boost').length === boostPlays0 + 1, '加速内容音落位'); assert(confirmCount() === conf0 + 1, '加速激活 CONFIRM 震(04 §5)'); // 冷却拒绝必须出声(R5-4) await h.pipe('tap'); await tick(); await waitFor(() => voicePlays(plays, 'voice/boost_not_ready') === 1); assert(vibrated('40,100,40'), '冷却拒绝 HEARTBEAT 震(禁止静默)'); await h.pipe('tap'); await tick(); await advance(1500); assert(voicePlays(plays, 'voice/boost_not_ready') === 1, '冷却拒绝 4s 去抖'); // 护盾未解锁(本会话双击折算)→ 双击走单击路径 await h.pipe('doubletap'); await tick(); assert(game.shieldUpUntil === 0, '双击未识别的会话:双击仍按单击处理(无护盾)'); // 断点复述(§4.12):对局中 stop() 自动存档 → 新 rig 问继续/重开 game.dodges = 7; // 测试钩子(13 号文档允许) game._saveRun(); game.stop(); const h2 = makeRig(); await h2.audio.unlock(); h2.game.start(); await tick(); assert(voicePlays(h2.plays, 'voice/resume_ask') === 1, '回来先问:继续上次还是重开(§4.12)'); assert(h2.game.phase === 'reopen', 'reopen 态'); await waitFor(() => h2.game.floor.state === 'YOUR_TURN'); await h2.pipe('tap'); await tick(); assert(h2.game.phase === 'playing', '单击 = 继续上次航程'); await waitFor(() => voicePlays(h2.plays, 'voice/resume_recap') === 1); assert(true, '续玩先播复述句(你在航道上。躲开呼啸。向声音那边滑。)'); assert(h2.game.dodges === 7, '断点成绩恢复(dodges=7)'); // count-in:恢复后前 3 颗裸飞陨石保持 4.0s 慢速(再瞄准,不暴毙) const spawn2 = h2.game._spawnObstacle.bind(h2.game); h2.game._spawnObstacle = () => {}; h2.game.protectionUntil = clock.now - 1000; // 测试钩子:强制视为保护期已过 h2.game._protectAnnounced = true; for (let i = 1; i <= 3; i++) { randQueue.push(0); h2.game.obstacles.clear(); h2.game.swipes = []; const tR = clock.now; spawn2(); const obR = [...h2.game.obstacles].at(-1); assert(obR.impactAt - tR === 4000, `恢复 count-in:第 ${i} 颗慢速 4.0s(round2 R2 机制复用)`); } h2.game.stop(); // ====================================================================== section('S6 看门狗:屏蔽 voice 资产不卡死(Floor 哨兵推进链)'); // ====================================================================== freshStorage(); missingAssets.add('voice/drill_left'); const h3 = makeRig(); await h3.audio.unlock(); h3.game.start(); await tick(); assert(voicePlays(h3.plays, 'voice/drill_left') === 1, '指令尝试起播(记录 play 调用)'); await advance(3600); // Floor 资产缺失哨兵 3s 宽限后推进链 assert(h3.game.drillOb && h3.game.drillOb.dir === 'left', '资产缺失不卡死:哨兵推进到 spawn'); h3.game.stop(); missingAssets.clear(); // ====================================================================== section('S7 乱点机器人(§6.2 指标)'); // ====================================================================== freshStorage(); const rb = makeRig(); const { game: rg, audio: raudio } = rb; await raudio.unlock(); let seed = 42; const rnd = () => { seed = (seed * 1103515245 + 12345) & 0x7fffffff; return seed / 0x7fffffff; }; const gestures = []; const bot = async (g) => { const tokBefore = raudio._voice; const playingId = rb.voiceStarts.filter((v) => v.t <= clock.now).at(-1)?.id || null; const critical = !!(rg.floor._chain && rg.floor._chain.critical); const ge = { g, t: clock.now, tokBefore, playingId, critical }; gestures.push(ge); rb.pipe(g); await tick(); await advance(150); // 打断失败:非 critical、非 dir 呼喊的句子在手势 150ms 后仍是同一 token if (ge.tokBefore && raudio._voice === ge.tokBefore && !ge.critical && !(ge.playingId && ge.playingId.startsWith('voice/dir_'))) { ge.interruptFail = true; } }; const wt = (ms) => advance(ms); rg.start(); await tick(); const t0b = clock.now; // 确定性首拍:等第一颗演练陨石(等待点)→ 同向滑开 = 第一次成功(≤10s) await waitFor(() => rg.drillOb, 15000); assert(rg.drillOb && clock.now - t0b <= 10000, `第一次成功 ≤10s(实际 ${clock.now - t0b}ms)`); await bot(rg.drillOb.dir); // 乱点直到进入对局(急躁新手:跳句、连点、乱滑) let guard = 0; while (rg.phase !== 'playing' && guard++ < 260) { const roll = rnd(); if (roll < 0.35) { await bot('tap'); await wt(900 + rnd() * 1200); } else if (roll < 0.55) { await bot('tap'); await wt(150); await bot('tap'); await wt(150); if (rnd() < 0.5) { await bot('tap'); await wt(500); } } else { await bot(['up', 'down', 'left', 'right'][Math.floor(rnd() * 4)]); await wt(700 + rnd() * 1300); } } assert(rg.phase === 'playing', `乱点也能走完演练进对局(${gestures.length} 次手势)`); // 对局乱点 28s(保护期内:乱滑全挥空、乱点全 invalid,都有声) const tPlay = clock.now; guard = 0; while (clock.now - tPlay < 28000 && rg.phase === 'playing' && guard++ < 200) { const roll = rnd(); if (roll < 0.3) { await bot('tap'); await wt(800 + rnd() * 1500); } else if (roll < 0.5) { await bot('tap'); await wt(120); await bot('tap'); await wt(600); } else { await bot(['up', 'down', 'left', 'right'][Math.floor(rnd() * 4)]); await wt(600 + rnd() * 1200); } } rg.stop(); // 指标 1:无声操作 = 0(每个手势 100ms 内有回执或内容音) let silentOps = 0; for (const ge of gestures) { const receipt = rb.receiptLog.some((r) => r.t >= ge.t && r.t <= ge.t + 100); const content = rb.plays.some((p) => p.t >= ge.t && p.t <= ge.t + 100); if (!receipt && !content) silentOps++; } assert(silentOps === 0, `无声操作 = 0(实际 ${silentOps}/${gestures.length})`); // 指标 2:打断失败 = 0(手势 150ms 后同一 voice token 不应仍在播;critical/呼喊豁免) const interruptFails = gestures.filter((ge) => ge.interruptFail).length; assert(interruptFails === 0, `打断失败 = 0(实际 ${interruptFails})`); // 指标 3:被动原句重复 = 0(30s 内同一 id 原样重播且无「再说一遍:」前缀; // dir 呼喊是玩法内容音(同 alarm),不属被动提示句,不计) let passiveRepeats = 0; const repeatIds = new Map(); const lastPlay = new Map(); for (const p of rb.plays) { if (!p.id.startsWith('voice/') || p.id.startsWith('voice/dir_')) continue; const sayAgainAt = rb.plays.some((q) => q.id === 'voice/sys_say_again' && q.t <= p.t && p.t - q.t < 2500); if (lastPlay.has(p.id) && p.t - lastPlay.get(p.id) < 30000 && !sayAgainAt) { passiveRepeats++; repeatIds.set(p.id, (repeatIds.get(p.id) || 0) + 1); } lastPlay.set(p.id, p.t); } if (passiveRepeats) { console.log(' 重复明细:', [...repeatIds.entries()].map(([k, v]) => `${k}×${v}`).join(' ')); } assert(passiveRepeats === 0, `被动原句重复 = 0(实际 ${passiveRepeats})`); // ====================================================================== section('S8 资产引用校验(26 号改造新增 id 白名单 = 待生成资产)'); // ====================================================================== 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}`); ids.add(`voice/d_short_${d}`); } const missing = []; const pendingSeen = new Set(); for (const id of ids) { const name = id.slice(6); if (id.startsWith('voice/')) { if (PENDING_NEW.has(name)) { pendingSeen.add(name); continue; } for (const g of ['female', 'male']) { for (const ext of ['ogg', 'm4a']) { const rel = `audio/voice/${g}/${name}.${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} 个(其中待生成新增 ${pendingSeen.size} 个)`); assert(missing.length === 0, '存量资产零缺失' + (missing.length ? `(缺:${missing.join(', ')})` : '')); assert([...PENDING_NEW].every((n) => pendingSeen.has(n)), `白名单与 game.js 引用一致(多出:${[...PENDING_NEW].filter((n) => !pendingSeen.has(n)).join(', ') || '无'})`); } catch (e) { failed++; console.error('\n未捕获异常(§7.2 失败):', e); } console.log(`\n========== 结果: ${passed} 通过, ${failed} 失败 ==========`); process.exit(failed ? 1 : 0);