Files
SoundGame/web/harness/compat-pipeline.test.mjs
T
xsl e943227136 feat(playbook): 26 号 P0 推广至全部 15 款游戏——兼容输入管道 + 可闻度调优
真人首测反馈「交互还是很迷惑」:大厅 15 款里只有 3 款新交互,其余 12 款
仍是旧版(无声操作、连点连跳),整体体验割裂。本次:

- engine/pipeline.js 新增 makeCompatPipeline(纯增量,游戏内部逻辑零改动):
  · 手势后 100ms 无任何音频调度 → invalid 回执(消灭无声操作,原则 4)
  · 语音中的手势 → 立即 ok 回执(§4.1 打断即收到)
  · 语音中 tap 400ms 防连点(§4.2,点两下不连跳两句)
  · longpressend/tapcancel 收尾噪声直通
- engine/audio.js 加 playCount 探针(回执启发式用)
- 12 款游戏 index.html 接入兼容管道(rhythm 保留 immediateTap;
  双击判定窗口不动——多款游戏双击是核心玩法/菜单确认,重映射会破坏)
- 可闻度调优(真人首测:笔记本外放偏轻,26 号 §3「起步值,真人测试后再调」):
  回执增益 ok 0.08→0.11 / invalid 0.10→0.13 / busy 0.04→0.06;
  等待声 0.03→0.055
- 大厅:三款深度重构游戏(声命线/暗室/音序回响)置顶 + 「新交互」徽标,
  卡片文案直接写玩法手势,测试不再误入旧交互游戏

测试:新增 compat-pipeline.test(9 断言);全部 15 款 playtest 回归通过
(共 3900+ 断言,0 失败);引擎 input/voice/guide 回归通过。
2026-10-01 21:09:26 +08:00

184 lines
7.4 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* compat-pipeline.test.mjs — 兼容输入管道单测(26 号 §4.2 / §5 P0 推广层)。
*
* 覆盖:
* - 手势后游戏有内容音 → 不叠回执(§4.2「已有明显内容音可省 ok」);
* - 手势后 100ms 内无任何音频调度 → invalid 回执(消灭无声操作,原则 4);
* - 语音播放中的手势 → 立即 ok 回执(§4.1 打断即收到);
* - 语音中 tap 的 400ms 防连点(§4.2);
* - longpressend / tapcancel 收尾噪声直通,不吃回执;
* - audio.playCount 探针计数。
*
* 运行:tools/node/bin/node web/harness/compat-pipeline.test.mjs
*/
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)), '..', 'simon');
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}`); }
}
console.error = () => {};
console.warn = () => {};
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); };
Object.defineProperty(globalThis, 'performance', { value: { now: () => now }, configurable: true });
async function flush(rounds = 6) {
for (let i = 0; i < rounds; i++) await new Promise((r) => realSetImmediate(r));
}
async function advance(cond = null) {
for (let n = 0; n < 10000; 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);
t.cb(...t.args);
}
throw new Error('虚拟时钟超上限');
}
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;
globalThis.fetch = async (u) => {
const p = path.join(GAME_DIR, decodeURIComponent(String(u)));
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 { makeCompatPipeline } = await import('../engine/pipeline.js');
const { Receipts } = await import('../engine/receipts.js');
settings.set({ guide: 'off', voice: 'female', speed: 1.0, bgm: 1.0 });
const audio = new WebAudioEngine('audio/');
await audio.unlock();
const receipts = new Receipts(audio);
const receiptLog = [];
const origPlay = receipts.play.bind(receipts);
receipts.play = (kind) => { receiptLog.push({ kind, t: now }); return origPlay(kind); };
// 假游戏:按手势决定是否有内容音
const fakeGame = {
lastGesture: null,
loudGestures: new Set(), // 这些手势会立即播 sfx
onGesture(g) {
this.lastGesture = g;
if (this.loudGestures.has(g)) audio.play('sfx/tone_tap');
return undefined; // 旧游戏通常无返回值
},
};
const pipe = makeCompatPipeline({ audio, receipts, game: fakeGame });
realLog('== 兼容管道(P0 推广层)==');
{
// 1. 内容音即反馈:不叠回执
fakeGame.loudGestures.add('left');
const n0 = receiptLog.length;
pipe('left'); await flush();
await advance(() => now >= 150);
assert(receiptLog.length === n0, '手势带内容音 → 不叠回执(内容音即反馈)');
// 2. 无声操作 → 100ms 时给 invalid
const n1 = receiptLog.length;
pipe('right'); await flush();
assert(receiptLog.length === n1, '手势后 100ms 内不抢先出声(等结果)');
await advance(() => receiptLog.length > n1);
const r = receiptLog[receiptLog.length - 1];
assert(r.kind === 'invalid' && r.t <= 100 + 500, `无声手势 → invalid 回执(实际 ${r && r.kind}@${r && r.t}ms)`);
// 3. playCount 探针
const c0 = audio.playCount;
audio.play('sfx/tone_tap');
assert(audio.playCount === c0 + 1, 'audio.playCount 每次 play 递增(回执启发式探针)');
// 4. 语音中手势 → 立即 ok
audio.play('voice/t0_new'); await flush();
assert(audio.isVoicePlaying(), '假游戏对白在播');
const n2 = receiptLog.length;
pipe('up'); await flush();
assert(receiptLog.length === n2 + 1 && receiptLog[receiptLog.length - 1].kind === 'ok',
'语音中的手势 → 立即 ok 回执(打断即收到)');
await advance(() => !audio.isVoicePlaying());
// 5. 语音中 tap 防连点:第二下只给 busy
audio.play('voice/t0_new'); await flush();
const n3 = receiptLog.length;
pipe('tap'); await flush();
assert(receiptLog[n3] && receiptLog[n3].kind === 'ok', '语音中 tap → ok');
pipe('tap'); await flush(); // 虚拟时钟未推进 = 400ms 内
assert(receiptLog[n3 + 1] && receiptLog[n3 + 1].kind === 'busy', '400ms 内第二下 tap → busy(不连跳)');
now += 500; // 跨过防连点窗口
await advance(() => !audio.isVoicePlaying());
// 6. 收尾噪声直通
const n4 = receiptLog.length;
let passed = false;
fakeGame.loudGestures.delete('left');
fakeGame.onGesture = (g) => { if (g === 'longpressend') passed = true; };
pipe('longpressend'); await flush();
await advance(() => now >= 150);
assert(passed && receiptLog.length === n4, 'longpressend 直通游戏、不吃回执');
}
realLog(`\n通过 ${passCount} 条断言,失败 ${failures.length} 条`);
if (failures.length > 0) {
realLog('失败清单:');
for (const f of failures) realLog(` - ${f}`);
process.exit(1);
}
realLog('全部断言通过。');