import fs from 'node:fs'; import path from 'node:path'; import url from 'node:url'; const GAME_DIR = path.resolve(path.dirname(url.fileURLToPath(import.meta.url)), '..'); const AUDIO_DIR = path.join(GAME_DIR, 'audio'); const realLog = console.log.bind(console); let passCount = 0; const failures = []; function assert(cond, msg) { if (cond) { passCount++; realLog(` ✓ ${msg}`); } else { failures.push(msg); realLog(` ✗ ${msg}`); } } const consoleErrs = []; const consoleWarns = []; console.error = (...a) => { consoleErrs.push(a.join(' ')); }; console.warn = (...a) => { consoleWarns.push(a.join(' ')); }; console.log = () => {}; let now = 0; let timerSeq = 1; const timers = new Map(); const realSetImmediate = setImmediate; globalThis.setTimeout = (cb, ms = 0, ...args) => { const id = timerSeq++; timers.set(id, { fireAt: now + Math.max(0, Number(ms) || 0), cb, args }); return id; }; globalThis.clearTimeout = (id) => { timers.delete(id); }; globalThis.setInterval = (cb, ms = 0) => { const id = timerSeq++; timers.set(id, { fireAt: now + Math.max(0, Number(ms) || 0), cb, args: [], interval: Number(ms) || 0 }); return id; }; globalThis.clearInterval = (id) => { timers.delete(id); }; const realDateNow = Date.now; Date.now = () => now; Object.defineProperty(globalThis, 'performance', { value: { now: () => now }, configurable: true }); async function flush(rounds = 8) { for (let i = 0; i < rounds; i++) await new Promise((r) => realSetImmediate(r)); } async function advance(cond = null) { for (let n = 0; n < 200000; n++) { await flush(2); if (cond && cond()) return 'cond'; let nextId = -1; let nextAt = Infinity; for (const [id, t] of timers) if (t.fireAt < nextAt) { nextAt = t.fireAt; nextId = id; } if (nextId < 0) return 'idle'; now = nextAt; const t = timers.get(nextId); timers.delete(nextId); if (t.interval) timers.set(nextId, { fireAt: now + t.interval, cb: t.cb, args: [], interval: t.interval }); t.cb(...t.args); } throw new Error('虚拟时钟定时器超上限(疑似死循环)'); } const idle = () => advance(null); const tick = (ms) => { now += ms; }; globalThis.window = { addEventListener: () => {}, AudioContext: null }; const storageData = new Map(); globalThis.localStorage = { getItem: (k) => (storageData.has(k) ? storageData.get(k) : null), setItem: (k, v) => { storageData.set(k, String(v)); }, removeItem: (k) => { storageData.delete(k); }, }; Object.defineProperty(globalThis, 'navigator', { value: { maxTouchPoints: 1, vibrate: () => true }, configurable: true, }); class MockParam { constructor(v = 1) { this.value = v; } setValueAtTime() {} linearRampToValueAtTime() {} cancelScheduledValues() {} } class MockGainNode { constructor() { this.gain = new MockParam(); } connect() {} disconnect() {} } class MockAudioContext { constructor() { this.state = 'running'; this.currentTime = 0; this.destination = {}; } createGain() { return new MockGainNode(); } createDynamicsCompressor() { const n = new MockGainNode(); n.threshold = new MockParam(); n.ratio = new MockParam(); n.attack = new MockParam(); n.release = new MockParam(); return n; } createBufferSource() { return { buffer: null, loop: false, connect() {}, start() {}, stop() {} }; } createScriptProcessor() { return { connect() {}, disconnect() {}, onaudioprocess: null }; } createOscillator() { return { type: 'sine', frequency: new MockParam(440), connect() {}, start() {}, stop() {} }; } decodeAudioData() { return Promise.resolve({ duration: 2.0, numberOfChannels: 1, sampleRate: 44100, getChannelData: () => new Float32Array(16) }); } resume() { this.state = 'running'; return Promise.resolve(); } } globalThis.window.AudioContext = MockAudioContext; const requestedUrls = []; globalThis.fetch = async (u) => { const s = String(u); requestedUrls.push(s); if (s.includes('/voice/')) return { ok: true, status: 200, arrayBuffer: async () => new ArrayBuffer(0) }; const p = path.join(GAME_DIR, decodeURIComponent(s)); if (!fs.existsSync(p)) return { ok: false, status: 404, arrayBuffer: async () => new ArrayBuffer(0) }; const data = fs.readFileSync(p); return { ok: true, status: 200, arrayBuffer: async () => data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength) }; }; const { WebAudioEngine } = await import('../../engine/audio.js'); const { settings } = await import('../../engine/settings.js'); const { makeInputPipeline } = await import('../../engine/pipeline.js'); const { SimonGame } = await import('../game.js'); settings.set({ guide: 'off', voice: 'female', speed: 1.0, bgm: 1.0 }); function makeRig() { const audio = new WebAudioEngine('audio/'); const playLog = []; const origPlay = WebAudioEngine.prototype.play; audio.play = (id, opts) => { playLog.push({ id, t: now }); return origPlay.call(audio, id, opts); }; const game = new SimonGame(audio, {}); game.floor.bedEnabled = false; const receiptLog = []; const origR = game.receipts.play.bind(game.receipts); game.receipts.play = (kind) => { receiptLog.push({ kind, t: now }); return origR(kind); }; const chimeLog = []; const origChime = game.floor.chime.bind(game.floor); game.floor.chime = () => { chimeLog.push(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, playLog, receiptLog, chimeLog, game, pipe }; } const voicePlays = (playLog, id) => playLog.filter((e) => e.id === id).length; let randSeed = 12345; Math.random = () => { randSeed = (randSeed * 1103515245 + 12345) & 0x7fffffff; return randSeed / 0x7fffffff; }; /** 教学快进:三音色各答对一次,进对局。 */ async function skipTutorial(rig) { const { playLog, game, pipe } = rig; await advance(() => game.floor.state === 'YOUR_TURN'); // t0 指令播完 await pipe('tap'); await flush(); // 第一击(tap 音色) await advance(() => voicePlays(playLog, 'voice/t3_left') >= 1); await advance(() => game.floor.state === 'YOUR_TURN'); await pipe('left'); await flush(); // 教左滑 await advance(() => voicePlays(playLog, 'voice/t4_right') >= 1); await advance(() => game.floor.state === 'YOUR_TURN'); await pipe('right'); await flush(); // 教右滑 await advance(() => voicePlays(playLog, 'voice/t5_tap') >= 1); await advance(() => game.floor.state === 'YOUR_TURN'); await pipe('tap'); await flush(); // 教单击音色 } // ================================================================ 教学(§4.10 首 3 分钟) realLog('== 教学:3 秒内动手、教一个音色马上用 =='); { storageData.clear(); // 必须在 makeRig 之前:构造器就读历史最佳 const rig = makeRig(); const { audio, playLog, receiptLog, game, pipe } = rig; await audio.unlock(); game.start(); await flush(); assert(voicePlays(playLog, 'voice/t0_new') === 1 && game.floor.speaking, '0~3 秒:第一句 = 「音序回响。点一下屏幕。」'); await advance(() => game.floor.state === 'YOUR_TURN'); await pipe('tap'); await flush(); // 第一击 assert(voicePlays(playLog, 'sfx/tone_tap') >= 1, '第一击 100ms 内出声(音色回响即反馈,微循环)'); await advance(() => voicePlays(playLog, 'voice/t3_left') >= 1); await advance(() => game.floor.state === 'YOUR_TURN'); await pipe('left'); await flush(); assert(voicePlays(playLog, 'sfx/tone_left') >= 1 && voicePlays(playLog, 'voice/t_ok') >= 1, '按指令左滑 → 音色回响 + 「对了。」(≤4 字肯定,原则 11)'); await advance(() => voicePlays(playLog, 'voice/t4_right') >= 1); await advance(() => game.floor.state === 'YOUR_TURN'); await pipe('right'); await flush(); await advance(() => voicePlays(playLog, 'voice/t5_tap') >= 1); await advance(() => game.floor.state === 'YOUR_TURN'); await pipe('tap'); await flush(); await advance(() => voicePlays(playLog, 'voice/t7_new') >= 1); assert(true, '三音色教完 → 收尾句'); await advance(() => game.phase === 'playback' || game.phase === 'roundend'); assert(game.round >= 1, '进入第 1 轮'); game.stop(); } // ================================================================ 对局:失败表 realLog('== 对局:新手三音色、失败归因与免费重听 =='); { storageData.clear(); // 必须在 makeRig 之前:构造器就读历史最佳 const rig = makeRig(); const { audio, playLog, receiptLog, game, pipe } = rig; await audio.unlock(); game.start(); await flush(); await skipTutorial(rig); await advance(() => game.phase === 'input'); assert(game.sequence.length === 2, `第 1 轮序列长度 = 2(实际 ${game.sequence.length},round=${game.round})`); assert(game.sequence.every((g) => ['tap', 'left', 'right'].includes(g)), '新手序列只含三个已教音色(F3:选项 ≤3)'); assert(game.floor.state === 'YOUR_TURN', '答题等待 = YOUR_TURN(交接提示音 + 等待声)'); for (const g of [...game.sequence]) { await pipe(g); await flush(); } await advance(() => voicePlays(playLog, 'voice/correct') + voicePlays(playLog, 'voice/gd_clear') >= 1); assert(true, '整轮答对 → 表扬句(微循环回报)'); // 第 2 轮:答错 → 失败表(归因 + 免费重听) await advance(() => game.phase === 'input'); const expected = game.sequence[0]; const wrong = expected === 'tap' ? 'left' : 'tap'; await pipe(wrong); await flush(); assert(game.failCount === 1, '答错 → 失败计数 1(新手期零消耗)'); const wrId = 'voice/wr_' + (expected === 'doubletap' ? 'dt' : expected); await advance(() => voicePlays(playLog, wrId) >= 1); assert(true, `失败归因 2 秒内播出(「错了。是${expected}。」,§4.9)`); await advance(() => voicePlays(playLog, 'voice/relisten') >= 1); assert(true, '归因后免费重听整串(原则 15)'); await advance(() => game.phase === 'playback'); assert(true, '重听后重新播放序列'); game.stop(); } // ================================================================ 答题静默三档 realLog('== 答题静默三档 =='); { storageData.clear(); // 必须在 makeRig 之前:构造器就读历史最佳 const rig = makeRig(); const { audio, playLog, chimeLog, game, pipe } = rig; await audio.unlock(); game.start(); await flush(); await skipTutorial(rig); if (process.env.DBG) realLog(` [dbg] skipTutorial done t=${now} phase=${game.phase} round=${game.round} fails=${game.failCount}`); const r1 = await advance(() => game.phase === 'input'); if (process.env.DBG) realLog(` [dbg] advance(input) -> ${r1} t=${now} phase=${game.phase}`); const yt = voicePlays(playLog, 'voice/your_turn'); const t0 = now; if (process.env.DBG) realLog(` [dbg] input reached t=${now} yt=${yt} round=${game.round} phaseseen=${game.phase}`); await advance(() => voicePlays(playLog, 'voice/your_turn') > yt); assert(now - t0 === 10000, `答题静默 10s 复述「轮到你了」(实际 ${now - t0}ms)`); const chim = chimeLog.length; await advance(() => chimeLog.length > chim); assert(true, '第二档只给交接提示音(原则 9 渐短)'); game.stop(); } // ================================================================ 结算 realLog('== 结算:承诺句 + 单击重开 =='); { const rig = makeRig(); const { audio, playLog, game, pipe } = rig; await audio.unlock(); storageData.clear(); game.start(); await flush(); await skipTutorial(rig); // 直推 gameover:failCount=3 后再错一次 await advance(() => game.phase === 'input'); game.failCount = 3; game.sequence = ['left', 'tap']; game.inputIdx = 0; await pipe('right'); await flush(); await advance(() => game.phase === 'gameover'); await advance(() => voicePlays(playLog, 'voice/replay') >= 1); await advance(() => game.floor.state === 'YOUR_TURN'); assert(game.floor.waitPoint && game.floor.waitPoint.conf.id === 'settle', '结算链播完交出结算等待点(不留安静态)'); await pipe('tap'); await flush(); assert(game.phase !== 'gameover', '结算态单击 = 言行一致直接重开'); game.stop(); } // ================================================================ 乱点机器人(§6.2) realLog('== 乱点机器人(§6.2 指标)=='); { storageData.clear(); // 必须在 makeRig 之前:构造器就读历史最佳 const rig = makeRig(); const { audio, playLog, receiptLog, game, pipe } = rig; await audio.unlock(); let seed = 99; const rnd = () => { seed = (seed * 1103515245 + 12345) & 0x7fffffff; return seed / 0x7fffffff; }; const gestures = []; const botPipe = (gg) => { gestures.push({ g: gg, t: now }); return pipe(gg); }; game.start(); await flush(); const t0 = now; await advance(() => game.floor.state === 'YOUR_TURN'); botPipe('tap'); await flush(); // 确定性第一击 let firstSuccessMs = null; for (let step = 0; step < 200; step++) { const roll = rnd(); if (roll < 0.35) { botPipe('tap'); await flush(); } else if (roll < 0.5) { botPipe('tap'); await flush(1); botPipe('tap'); await flush(1); } else if (roll < 0.85) { const dirs = ['up', 'down', 'left', 'right']; botPipe(dirs[Math.floor(rnd() * 4)]); await flush(); } else { const before = now; await advance(() => now - before >= 5000 + rnd() * 10000); } if (firstSuccessMs === null && receiptLog.some((r) => r.kind === 'ok')) firstSuccessMs = now - t0; if (game.phase === 'gameover') break; } let silentOps = 0; for (const ge of gestures) { const receipt = receiptLog.some((r) => r.t >= ge.t && r.t <= ge.t + 100); const content = playLog.some((p) => p.t >= ge.t && p.t <= ge.t + 100); if (!receipt && !content) silentOps++; } assert(silentOps === 0, `无声操作 = 0(实际 ${silentOps}/${gestures.length})——播放期也有 busy 回执`); assert(firstSuccessMs !== null && firstSuccessMs <= 20000, `第一次成功 ≤ 20s(实际 ${firstSuccessMs}ms)`); // 玩家动作触发的反馈类(失败归因 / 表扬 / 教学纠错 / 重听引导)不计被动重复: // 它们是对玩家刚才那一下的回应(原则 15 要求每次失败都归因),非游戏主动复读 const FEEDBACK = /^(voice\/(wr_|t_wrong_|guide_blunt|relisten|slow_down|t_ok|correct|gd_clear|t_retry|chance))/; let passiveRepeats = 0; const repeatIds = new Map(); const lastPlay = new Map(); for (const p of playLog) { if (!p.id.startsWith('voice/') || FEEDBACK.test(p.id)) continue; const sayAgainAt = playLog.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 (process.env.DBG) { for (const p of playLog) { if (['voice/t3_left', 'voice/t4_right', 'voice/t_try'].includes(p.id)) { const near = playLog.filter((q) => Math.abs(q.t - p.t) <= 3000 && q.id.startsWith('voice/')).map((q) => `${q.id.replace('voice/', '')}@${q.t}`); realLog(` [dbg] ${p.id} @${p.t} 近旁: ${near.join(' ')}`); } } } assert(passiveRepeats === 0, `被动原句重复 = 0(实际 ${passiveRepeats})`); game.stop(); } // ================================================================ 资产引用校验 realLog('== 资产引用校验 =='); { const src = fs.readFileSync(path.join(GAME_DIR, 'game.js'), 'utf8'); const ids = [...new Set([...src.matchAll(/'((?:voice|sfx|bgm)\/[A-Za-z0-9_.-]+)'/g)].map((m) => m[1]))]; assert(ids.length > 0, `game.js 提取到 ${ids.length} 个资产 id`); const both = (rel) => ['.ogg', '.m4a'].every((ext) => fs.existsSync(path.join(AUDIO_DIR, rel + ext))); let missing = 0; for (const id of ids) { if (id.startsWith('voice/')) { const name = id.slice(6); const ok = both(`voice/male/${name}`) || both(`voice/female/${name}`) || both(`voice/${name}`); if (!ok) { missing++; realLog(` ✗ 资产缺失: ${id}`); } } else if (!both(id)) { missing++; realLog(` ✗ 资产缺失: ${id}`); } } assert(missing === 0, `全部 ${ids.length} 个引用 id 均有 ogg+m4a,零缺失`); } realLog(`\n通过 ${passCount} 条断言,失败 ${failures.length} 条`); if (failures.length > 0) { realLog('失败清单:'); for (const f of failures) realLog(` - ${f}`); process.exit(1); } realLog('全部断言通过。');