Files
SoundGame/web/simon/test/playtest.mjs
T
xsl ee9d7efe57 feat(playbook): 26 号交互手册首批落地——storyline/escape/simon 全面重构 + 引擎六模块
按 docs/design/26-audio-interaction-playbook.md 重构三款游戏(对白重写 + 双性别语音重生成 580 文件)。

引擎层(web/engine/,其余 12 款游戏零改动):
- floor.js 发言权管理器:SPEAKING/YOUR_TURN/BUSY/ACTION 四态、交接提示音、
  等待声(220/330Hz 嘟-嘟,20s 后放缓)、跨链音乐压低(audio.js holdDuck)
- receipts.js 输入回执三件套(ok/invalid/busy,100ms 内,原则 4)
- confusion.js 迷茫检测(乱点/无效/静默信号 → 三级接管,§4.5)
- hints.js 等待点 + 三档提示(full→short→提示音,30s 台账跨点去重,原则 9)
- novice.js 手势解锁阶梯(up/down/doubletap 跨局持久,§4.3)
- pipeline.js 输入管道(回执收口、防连点 400ms、双击折算、explainOnce)

游戏层:
- storyline R3:开场 71.7s 规则念白 → 3 秒内动手;双击=重播(反模式5)废除;
  抉择点三档提示+主动重听;断点存档复述(§4.12)
- escape R3:单击=检查+使用合并(双击用法废除);长按=我在哪(四向玩法裁定);
  撞墙归因每方向一次(原则 7);迷茫 L2 缩小/L3 演示;坐标存档续玩
- simon R3:新手三音色,按实力渐进解锁(+up/down/dt);失败表替代机会账
  (归因 2s 内→免费重听→放慢→结算,原则 15);播放期手势=busy 回执

测试:三款 playtest 重写(含 §6.2 乱点机器人:无声操作/被动重复/打断失败全 0,
首操作 ~2s);guide.test 改为 Guide 层单测+rhythm/space 段。全部通过
(38+33+22+72+19+35 断言)。设计单见 docs/design/26-apply-*.md。
2026-10-01 08:11:12 +08:00

375 lines
16 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.
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('全部断言通过。');