Files
SoundGame/web/escape/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

421 lines
19 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.
/**
* 《暗室》R3 无头试玩测试(26 号文档 playbook 重构版)。
*
* 覆盖:§4.10 引导首 3 分钟(3 秒内动手)、单击=检查+使用合并、撞墙失败归因、
* 长按=我在哪、迷茫三级接管(含缩小与演示)、断点复述、回执与无声操作指标(§6.2)、
* 资产引用校验。
*
* 运行:tools/node/bin/node web/escape/test/playtest.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)), '..');
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 { EscapeGame } = 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 EscapeGame(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;
// ================================================================ 引导(§4.10 首 3 分钟)
realLog('== 引导:3 秒内动手、教一个用一个 ==');
{
const rig = makeRig();
const { audio, playLog, receiptLog, game, pipe } = rig;
await audio.unlock();
game.start();
await flush();
assert(voicePlays(playLog, 'voice/g0_new') === 1 && game.floor.speaking,
'0~3 秒:开场第一句 = 「朝任意方向,滑一下。」');
await advance(() => game.floor.state === 'YOUR_TURN');
await pipe('tap'); await flush(); // 错误手势 → 纠错句
assert(voicePlays(playLog, 'voice/g_wrong_gesture') === 1, '引导期点按 → 纠错句(做错当场重教)');
assert(receiptLog.some((r) => r.kind === 'invalid'), '纠错同时给 invalid 回执');
await advance(() => game.floor.state === 'YOUR_TURN');
await pipe('right'); await flush(); // 任意滑动 → 第一步
assert(playLog.some((e) => e.id === 'sfx/step'), '滑动 → 脚步声(内容音即反馈)');
await advance(() => game.floor.state === 'YOUR_TURN');
// 撞墙两次教「无效操作长什么样」
await pipe('up'); await flush(); // (1,1) 向上 = 撞墙
assert(voicePlays(playLog, 'voice/wall') >= 1, '撞墙 → 「那边是墙,换个方向。」');
assert(receiptLog.some((r) => r.kind === 'invalid'), '撞墙 = invalid 回执(失败表:方向错)');
await advance(() => game.floor.state === 'YOUR_TURN' || game.phase === 'GUIDE');
await pipe('up'); await flush(); // 第二次撞墙 → g3
await advance(() => voicePlays(playLog, 'voice/g3_new') >= 1);
assert(true, '两次撞墙后教向左滑');
await advance(() => game.floor.state === 'YOUR_TURN');
await pipe('left'); await flush(); // g3 答案
assert(game.x === 0 && game.y === 1, '向左滑 → 移动到 (0,1)');
await advance(() => game.floor.state === 'YOUR_TURN');
await pipe('down'); await flush(); // g4 答案 → 储物间
assert(game.x === 0 && game.y === 2, '向下滑 → 储物间 (0,2)');
await advance(() => game.floor.state === 'YOUR_TURN');
await pipe('tap'); await flush(); // 检查 → 拾钥匙
assert(game.hasKey === true && playLog.some((e) => e.id === 'sfx/pickup'), '单击检查 → 拾得钥匙');
await advance(() => game.phase === 'PLAY');
assert(game.phase === 'PLAY', '引导完成进入自由探索(ACTION 态)');
game.stop();
}
// ================================================================ 探索:检查+使用合并、长按帮助
realLog('== 探索:单击=检查+使用,长按=我在哪 ==');
{
const rig = makeRig();
const { audio, playLog, receiptLog, game, pipe } = rig;
await audio.unlock();
storageData.clear();
game.start(); await flush();
// 快进引导
await advance(() => game.floor.state === 'YOUR_TURN');
await pipe('right'); await flush();
await advance(() => game.floor.state === 'YOUR_TURN');
await pipe('up'); await flush(); await advance(() => game.floor.state === 'YOUR_TURN' || game.guideStep >= 2);
await pipe('up'); await flush(); await advance(() => voicePlays(playLog, 'voice/g3_new') >= 1);
await advance(() => game.floor.state === 'YOUR_TURN');
await pipe('left'); await flush(); await advance(() => game.floor.state === 'YOUR_TURN');
await pipe('down'); await flush(); await advance(() => game.floor.state === 'YOUR_TURN');
await pipe('tap'); await flush(); await advance(() => game.phase === 'PLAY');
assert(game.phase === 'PLAY' && game.hasKey, '到达 PLAY,手上有钥匙');
// 长按 = 我在哪(目标 + 方位)
await pipe('longpress'); await flush();
assert(voicePlays(playLog, 'voice/lp_cabinet') === 1, '长按 → 目标句(找铁柜)');
await advance(() => voicePlays(playLog, 'voice/nav_right') >= 1);
assert(true, '长按 → 方位句(向右滑)');
await advance(() => game.floor.state === 'ACTION' || !game.floor.speaking);
// 去铁柜房 (2,1):右右上
for (const [g2] of [['right'], ['right'], ['up']]) {
await pipe(g2); await flush();
await advance(() => !game.floor.speaking || game.floor.state === 'ACTION');
}
assert(game.x === 2 && game.y === 1, '走到铁柜房 (2,1)');
// 单击 = 用钥匙开柜(旧双击用法已合并进单击)
await pipe('tap'); await flush();
await advance(() => voicePlays(playLog, 'voice/unlock_cabinet') >= 1);
assert(game.cabinetOpen === true && game.hasFuse === true && !game.hasKey,
'单击 = 钥匙开铁柜 → 得保险丝(无需双击)');
await advance(() => !game.floor.speaking);
// 修电箱 (0,0):左左上
for (const g2 of ['left', 'left', 'up']) {
await pipe(g2); await flush();
await advance(() => !game.floor.speaking || game.floor.state === 'ACTION');
}
assert(game.x === 0 && game.y === 0, '走到电箱房 (0,0)');
await pipe('tap'); await flush();
await advance(() => voicePlays(playLog, 'voice/fix_box') >= 1);
assert(game.powerOn === true, '单击 = 保险丝修电箱 → 通电');
await advance(() => !game.floor.speaking);
// 大门 (2,2):右右下下
for (const g2 of ['right', 'right', 'down', 'down']) {
await pipe(g2); await flush();
await advance(() => !game.floor.speaking || game.floor.state === 'ACTION');
}
assert(game.x === 2 && game.y === 2, '走到大门 (2,2)');
await pipe('tap'); await flush();
await advance(() => voicePlays(playLog, 'voice/escape') >= 1);
assert(game.phase === 'ENDING', '单击 = 开门逃脱 → 结算');
await advance(() => game.floor.state === 'YOUR_TURN');
assert(game.floor.waitPoint && game.floor.waitPoint.conf.id === 'settle',
'结算链播完交出结算等待点(不留安静态)');
game.stop();
}
// ================================================================ 迷茫接管(三级)
realLog('== 迷茫接管:L1 方位 → L2 缩小 → L3 演示 ==');
{
const rig = makeRig();
const { audio, playLog, receiptLog, chimeLog, game, pipe } = rig;
await audio.unlock();
storageData.clear();
game.start(); await flush();
await advance(() => game.floor.state === 'YOUR_TURN');
await pipe('right'); await flush();
await advance(() => game.floor.state === 'YOUR_TURN');
await pipe('up'); await flush(); await advance(() => game.floor.state === 'YOUR_TURN' || game.guideStep >= 2);
await pipe('up'); await flush(); await advance(() => voicePlays(playLog, 'voice/g3_new') >= 1);
await advance(() => game.floor.state === 'YOUR_TURN');
await pipe('left'); await flush(); await advance(() => game.floor.state === 'YOUR_TURN');
await pipe('down'); await flush(); await advance(() => game.floor.state === 'YOUR_TURN');
await pipe('tap'); await flush(); await advance(() => game.phase === 'PLAY');
// 乱撞墙 3 次(10s 内)→ L1 接管:说目标方位
const navBefore = playLog.filter((e) => e.id.startsWith('voice/nav_')).length;
const t0 = now;
for (let i = 0; i < 3; i++) { await pipe('down'); await flush(); tick(1000); } // (0,2) 向下 = 墙
assert(game.confusion.level >= 1, '10s 内 3 次撞墙 → 迷茫接管 L1');
// L1 反馈 = 方位句(30s 内已说过则降为交接提示音——原则 9 台账)
const chimeBefore = chimeLog.length;
await advance(() => playLog.filter((e) => e.id.startsWith('voice/nav_')).length > navBefore
|| chimeLog.length > chimeBefore);
assert(playLog.filter((e) => e.id.startsWith('voice/nav_')).length > navBefore
|| chimeLog.length > chimeBefore, 'L1:给方位句或提示音(台账降档)');
await advance(() => !game.floor.speaking && game.floor.state === 'ACTION');
// 过冷却再撞 → L2:只放行一个方向
const tL1 = now;
await advance(() => now - tL1 >= 16000);
await advance(() => !game.floor.speaking);
for (let i = 0; i < 3; i++) { await pipe('down'); await flush(); tick(1000); }
await advance(() => game.confusion.level >= 2 || game._restrictDir);
assert(game._restrictDir, 'L2:接管指定唯一放行方向(缩小选择)');
// 非放行方向 = 无效(不移动)
const yBefore = game.y;
await pipe('up'); await flush(); // 目标在北边 → 放行 up;down 应被拦
assert(game.y === yBefore, 'L2 期间非放行方向不产生移动(只给回执)');
game.stop();
}
// ================================================================ 断点复述(§4.12)
realLog('== 断点复述 ==');
{
const rig1 = makeRig();
await rig1.audio.unlock();
storageData.clear();
rig1.game.start(); await flush();
await advance(() => rig1.game.floor.state === 'YOUR_TURN');
await rig1.pipe('right'); await flush();
await advance(() => rig1.game.floor.state === 'YOUR_TURN');
await rig1.pipe('up'); await flush(); await advance(() => rig1.game.floor.state === 'YOUR_TURN' || rig1.game.guideStep >= 2);
await rig1.pipe('up'); await flush(); await advance(() => voicePlays(rig1.playLog, 'voice/g3_new') >= 1);
await advance(() => rig1.game.floor.state === 'YOUR_TURN');
await rig1.pipe('left'); await flush(); await advance(() => rig1.game.floor.state === 'YOUR_TURN');
await rig1.pipe('down'); await flush(); await advance(() => rig1.game.floor.state === 'YOUR_TURN');
await rig1.pipe('tap'); await flush(); await advance(() => rig1.game.phase === 'PLAY');
// 走一步存档再退出
await rig1.pipe('right'); await flush();
rig1.game.stop();
const rig2 = makeRig();
const { audio, playLog, game, pipe } = rig2;
await audio.unlock();
game.start(); await flush();
assert(voicePlays(playLog, 'voice/sys_continue_ask') === 1, '回来先问:继续上次还是重新开始');
await advance(() => game.floor.state === 'YOUR_TURN');
await pipe('tap'); await flush();
assert(game.phase === 'PLAY' && game.x === 1 && game.y === 2, '单击 = 从存档坐标继续探索');
await advance(() => voicePlays(playLog, 'voice/lp_key') >= 1 || voicePlays(playLog, 'voice/lp_cabinet') >= 1);
assert(true, '续玩先播复述句(目标 + 方位)');
game.stop();
}
// ================================================================ 乱点机器人(§6.2)
realLog('== 乱点机器人(§6.2 指标)==');
{
const rig = makeRig();
const { audio, playLog, receiptLog, chimeLog, game, pipe } = rig;
await audio.unlock();
storageData.clear();
let seed = 7;
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('right'); await flush(); // 确定性第一步
let firstSuccessMs = null;
for (let step = 0; step < 150; step++) {
const roll = rnd();
if (roll < 0.35) {
botPipe('tap'); await flush();
} else if (roll < 0.55) {
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 >= 8000 + rnd() * 15000);
}
if (firstSuccessMs === null && receiptLog.some((r) => r.kind === 'ok')) firstSuccessMs = now - t0;
if (game.phase === 'ENDING') 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})`);
assert(firstSuccessMs !== null && firstSuccessMs <= 20000, `第一次成功 ≤ 20s(实际 ${firstSuccessMs}ms)`);
let passiveRepeats = 0;
const repeatIds = new Map();
const lastPlay = new Map();
for (const p of playLog) {
if (!p.id.startsWith('voice/')) 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 (passiveRepeats) realLog(` 重复明细: ${[...repeatIds.entries()].map(([k, v]) => `${k}×${v}`).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('全部断言通过。');