Files
SoundGame/web/storyline/test/playtest.mjs
T

435 lines
18 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.
/**
* 《声命线》无头试玩仿真测试(docs/design/13-playtest-criteria.md §1 + §7)。
*
* 方案:
* - mock 浏览器环境:window/localStorage/navigator/fetch/AudioContext
* (decodeAudioData 返回固定 duration 的假 buffer;fetch 直接读真实资产目录,
* 缺失资产走引擎 console.error 静默路径);
* - 使用**真实** engine/audio.js + settings.js(voice 链靠引擎 endTimer +
* onComplete 推进,虚拟时钟驱动,不真实等待);
* - 手势直接注入 game.onGesture(与 attachGestures 回调同路径);
* - 虚拟时钟:patch setTimeout/clearTimeout/performance.now。
*
* 运行:tools/node/bin/node web/storyline/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}`); }
}
// console 捕获(引擎/游戏的 warn/error 进日志,测试输出用 realLog)
const consoleErrs = [];
const consoleWarns = [];
console.error = (...a) => { consoleErrs.push(a.join(' ')); };
console.warn = (...a) => { consoleWarns.push(a.join(' ')); };
console.log = () => {}; // 游戏逐手势 console.log,静默
// ---------------------------------------------------------------- 虚拟时钟
let now = 0; // 虚拟毫秒
let timerSeq = 1;
const timers = new Map(); // id -> { fireAt, cb, args }
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,
});
/** 排空微任务(fetch/decode 的 promise 链)。 */
async function flush(rounds = 8) {
for (let i = 0; i < rounds; i++) await new Promise((r) => realSetImmediate(r));
}
/**
* 推进虚拟时钟:逐个触发到期定时器,直到没有待触发定时器(= 游戏在等手势)
* 或 cond() 满足。返回 'cond' | 'idle'。
*/
async function advance(cond = null) {
for (let n = 0; n < 50000; 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('虚拟时钟定时器超上限(疑似死循环)');
}
const idle = () => advance(null);
// ---------------------------------------------------------------- 浏览器 mock
const winListeners = new Map();
globalThis.window = {
addEventListener: (type, fn) => {
if (!winListeners.has(type)) winListeners.set(type, []);
winListeners.get(type).push(fn);
},
AudioContext: null, // 下方赋值
};
function fireWindowEvent(type) {
for (const fn of winListeners.get(type) || []) fn();
}
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); },
};
const vibrateLog = [];
Object.defineProperty(globalThis, 'navigator', {
value: { vibrate: (...a) => { vibrateLog.push(a); return true; } }, configurable: true,
});
class MockParam {
constructor(v = 1) { this.value = v; }
setValueAtTime() {}
linearRampToValueAtTime() {}
cancelScheduledValues() {}
}
class MockGainNode {
constructor() { this.gain = new MockParam(); }
connect() {}
disconnect() {}
}
function fakeBuffer() {
return {
duration: 2.0, // 固定句长 2s(虚拟时钟推进语音链)
numberOfChannels: 1,
sampleRate: 44100,
getChannelData: () => new Float32Array(16),
};
}
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 }; }
decodeAudioData() { return Promise.resolve(fakeBuffer()); }
resume() { this.state = 'running'; return Promise.resolve(); }
}
globalThis.window.AudioContext = MockAudioContext;
// fetch:读真实资产目录;blockedAssets 中的片段强制 404(看门狗测试用)
const requestedUrls = [];
const blockedAssets = new Set();
globalThis.fetch = async (u) => {
const s = String(u);
requestedUrls.push(s);
const blocked = [...blockedAssets].some((b) => s.includes(b));
const p = path.join(GAME_DIR, decodeURIComponent(s));
if (blocked || !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 { StorylineGame } = await import('../game.js');
let gestureCount = 0;
function makeRig() {
const audio = new WebAudioEngine('audio/');
const playLog = [];
const origPlay = audio.play.bind(audio);
audio.play = (id, opts) => { playLog.push({ id, t: now }); return origPlay(id, opts); };
const game = new StorylineGame(audio, {});
return { audio, playLog, game };
}
async function g(game, gesture) {
gestureCount++;
game.onGesture(gesture);
await flush();
if (process.env.TRACE) {
realLog(` [gesture ${gesture}] node=${game.nodeId} mode=${game.mode} playing=${game.voicePlaying} cur=${game.currentVoiceId} armed=${game.choiceArmed}`);
}
}
const voicePlays = (playLog, id) => playLog.filter((e) => e.id === id).length;
const urlHits = (frag) => requestedUrls.filter((u) => u.includes(frag)).length;
// ================================================================ 主路径试玩
realLog('== 主路径试玩(关键路径 + 边界断言)==');
{
const { audio, playLog, game } = makeRig();
// §7.1 解锁前 play 为 no-op
audio.play('voice/n00_intro');
await flush();
assert(audio._voice === null && consoleWarns.some((w) => w.includes('尚未解锁')),
'启动遮罩点按前无音频播放(play 为 no-op)');
await audio.unlock();
// 1. start() → BGM + n00 旁白
game.start();
await flush();
assert(playLog.some((e) => e.id === 'bgm/dark_drone'), '开局播放 bgm/dark_drone');
assert(game.nodeId === 'n00' && game.voicePlaying && game.currentVoiceId === 'voice/n00_intro',
'n00 旁白开始(voice 层有播放记录)');
// 长按快进(引擎内置):临时 1.7× 不变调,松手恢复
fireWindowEvent('soundgame:longpress');
assert(audio._voice && audio._voice.st.tempo === 1.7, '长按 → 旁白临时 1.7 倍速');
fireWindowEvent('soundgame:longpressend');
assert(audio._voice && audio._voice.st.tempo === 1.0, '松开 → 恢复 settings.speed');
// ② 单击跳过本句:n00_intro → skip_hint(链继续,不断)
await g(game, 'tap');
assert(game.voicePlaying && game.currentVoiceId === 'voice/skip_hint',
'voice 播放中单击 = 跳过本句(skipVoice 续链到 skip_hint)');
await idle();
assert(!game.voicePlaying && game.nodeId === 'n00', 'n00 链播完,等待单击继续');
assert(voicePlays(playLog, 'voice/skip_hint') === 1, '③ 开场 skip_hint 提示播了一次');
// 切男声:下一句起用 male 目录
settings.set({ voice: 'male' });
await g(game, 'tap'); // → n01
await flush();
assert(urlHits('audio/voice/male/n01_wake.') > 0, 'settings.voice=male 后旁白走 male 目录');
// 双击重听当前节点
const beforeReplay = voicePlays(playLog, 'voice/n01_wake');
await g(game, 'doubletap');
assert(game.currentVoiceId === 'voice/n01_wake'
&& voicePlays(playLog, 'voice/n01_wake') === beforeReplay + 1,
'语音播放中双击 = 重听当前节点(同一 voice id 重播)');
// 菜单:BGM 切 menu pad,返回剧情节点不丢、BGM 淡回
await g(game, 'down');
assert(game.mode === 'MENU', '下滑呼出菜单');
await flush();
assert(audio._bgm && audio._bgm.id === 'bgm/menu', '② 进菜单 BGM 切 bgm/menu');
await idle(); // voice/menu 播完
await g(game, 'tap'); // 关菜单
assert(game.mode === 'STORY' && game.nodeId === 'n01', '关菜单返回剧情,节点不丢(不重头)');
await flush();
assert(audio._bgm && audio._bgm.id === 'bgm/dark_drone', '② 关菜单 BGM 淡回 dark_drone');
await idle(); // 重播 n01 播完
// 上滑:帮助 + 进度,随后自动重播当前节点
await g(game, 'up');
assert(game.currentVoiceId === 'voice/help', '上滑播放操作说明');
await idle();
assert(voicePlays(playLog, 'voice/status1') === 1, '帮助后播当前进度(第一幕 status1)');
// settings.bgm = 0 → 立即静默;调回 → 补播最后状态 BGM
settings.set({ bgm: 0 });
await flush();
assert(audio._bgm === null, 'settings.bgm=0 时无 bgm 层播放');
settings.set({ bgm: 1 });
await flush();
assert(audio._bgm && audio._bgm.id === 'bgm/dark_drone', 'bgm 调回后自动补播最后状态 BGM');
// → n02(抉择点)。n02_linwan 是角色音(林晚):male 目录无,平铺回退命中
settings.set({ speed: 1.7 });
requestedUrls.length = 0;
await g(game, 'tap');
await flush();
assert(game.nodeId === 'n02', '进入 n02 抉择节点');
assert(audio._voice && audio._voice.st.tempo === 1.7, 'settings.speed=1.7 下一句起生效');
assert(urlHits('audio/voice/male/n02_linwan.') > 0 && urlHits('audio/voice/n02_linwan.ogg') > 0,
'④ 角色音 n02_linwan 走平铺目录回退(不随性别设置变化)');
settings.set({ speed: 1.0 });
// 抉择提示 p1 播放中:单击不可跳;滑动缓存 pendingChoice
await advance(() => game.currentVoiceId === 'voice/p1');
assert(game.promptPlaying, '抉择提示 p1 播放中(promptPlaying=true)');
await g(game, 'tap');
assert(game.currentVoiceId === 'voice/p1', '① 抉择提示播放中单击不可跳');
await g(game, 'right');
assert(game.pendingChoice === 'right', '提示音播放中右滑缓存 pendingChoice');
await idle();
assert(game.nodeId === 'n04', '链尾 pendingChoice 生效 → n04');
assert(!game.companion && !game.knowTrap && !game.betrayed, '剧情标记初始为 false');
// n04 → n05 → n06 → n07(无语音时单击维持原义:继续剧情)
settings.set({ voice: 'female' }); // 切回女声,验证女性设置下角色音同样平铺
for (const next of ['n05', 'n06', 'n07']) {
await idle();
await g(game, 'tap');
assert(game.nodeId === next, `单击继续剧情 → ${next}`);
}
await idle();
assert(game.choiceArmed, 'n07 提示播完 choiceArmed=true(等待抉择)');
await g(game, 'right'); // 装死 → n09
assert(game.nodeId === 'n09' && game.knowTrap === true, 'n09 右滑 → knowTrap === true');
requestedUrls.length = 0;
await idle();
await g(game, 'tap'); // → n10
await idle();
await g(game, 'left'); // 闻声寻去 → n11
assert(game.nodeId === 'n11', 'n10 左滑 → n11');
await idle();
await g(game, 'left'); // 带上小满 → n12
assert(game.nodeId === 'n12' && game.companion === true, 'n11 左滑 → companion === true');
await idle();
await g(game, 'tap'); // → n15 断桥
await idle();
await g(game, 'left'); // 涵洞(带小满 → 虚拟节点解析 n16b)
assert(game.nodeId === 'n16b', '虚拟节点 resolve:带小满时 v_culvert → n16b');
await idle();
await g(game, 'tap'); // → n18
await idle();
assert(urlHits('audio/voice/n18_dog.ogg') > 0 || urlHits('audio/voice/female/n18_dog.') > 0,
'n18 旁白正常加载');
await g(game, 'left'); // 交出坐标 → n19
assert(game.nodeId === 'n19' && game.betrayed === true, 'n18 左滑 → betrayed === true');
await idle();
await g(game, 'tap'); // → n20
await idle();
await g(game, 'left'); // 赎罪 → n21
assert(game.nodeId === 'n21', 'n20 左滑 → n21');
await idle();
await g(game, 'tap'); // → e2 结局:代价
assert(game.nodeId === 'e2', '到达结局节点 e2(4 结局之一)');
await idle();
assert(voicePlays(playLog, 'voice/e2_price') === 1, '结局语音 e2_price 播出');
assert(game.awaitingRestart === true, '结局链播完进入结算态(awaitingRestart)');
assert(audio._bgm && audio._bgm.id === 'bgm/dark_drone',
'② 结局结算保持 dark_drone 不切(10 号文档 §2)');
// 结算态单击重开回 n00
await g(game, 'tap');
assert(game.nodeId === 'n00' && game.mode === 'STORY'
&& !game.companion && !game.knowTrap && !game.betrayed,
'结算态单击重开回 n00,剧情标记重置');
await idle();
assert(voicePlays(playLog, 'voice/skip_hint') === 1, '③ 重开后 skip_hint 不重播(每局一次)');
assert(gestureCount <= 40, `关键路径手势预算 ≤40(实际 ${gestureCount})`);
}
// ================================================================ 看门狗路径
realLog('== 看门狗路径(屏蔽 voice/n05_signal)==');
{
settings.set({ voice: 'female', speed: 1.0, bgm: 1.0 });
blockedAssets.add('n05_signal');
const errBefore = consoleErrs.length;
const { audio, game } = makeRig();
await audio.unlock();
game.start();
await idle();
await g(game, 'tap'); // n01
await idle();
await g(game, 'tap'); // n02
await idle(); // n02_linwan + p1 播完
await g(game, 'right'); // → n04
await idle();
await g(game, 'tap'); // → n05(第一句 n05_signal 被屏蔽)
await idle(); // 看门狗 30s 超时续链 → n05_bcast 播完
assert(consoleErrs.slice(errBefore).some((e) => e.includes('n05_signal')),
'屏蔽资产仅 console.error,不抛异常');
assert(consoleWarns.some((w) => w.includes('看门狗')), '语音看门狗超时自动续链');
assert(game.nodeId === 'n05' && !game.voicePlaying, '缺失 voice 不卡死:n05 链走完');
await g(game, 'tap'); // n06
await idle();
await g(game, 'tap'); // n07(非抉择点 n06 之后继续剧情)
await idle();
await g(game, 'right'); // n07 装死 → n09
await idle();
await g(game, 'tap'); // n10
await idle();
await g(game, 'right'); // n14
await idle();
await g(game, 'tap'); // n15
await idle();
await g(game, 'right'); // n17(无小满 → v_cable → n17)
assert(game.nodeId === 'n17', '虚拟节点 resolve:无小满时 v_cable → n17');
await idle();
await g(game, 'tap'); // n18
await idle();
await g(game, 'right'); // n22
await idle();
await g(game, 'tap'); // n23
await idle();
await g(game, 'left'); // n23 开闸 → n24
await idle();
await g(game, 'tap'); // n24 播完单击 → e4
assert(game.nodeId === 'e4', '屏蔽资产后仍可推进到结局 e4');
await idle();
assert(game.awaitingRestart === true, '看门狗路径到达结算态');
blockedAssets.clear();
}
// ================================================================ 资产引用校验
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(含性别目录/平铺回退),零缺失`);
// ③ skip_hint 双性别齐备
assert(both('voice/male/skip_hint') && both('voice/female/skip_hint'),
'voice/skip_hint 双性别 ogg+m4a 齐备');
// ④ 角色音只在平铺目录(不随性别设置变化,12 号文档 §4 回归)
const charVoices = ['n02_linwan', 'n03_msg', 'n05_bcast', 'n07_dog', 'e1_child'];
const flatOk = charVoices.every((n) => both(`voice/${n}`));
const notGendered = charVoices.every((n) => !fs.existsSync(path.join(AUDIO_DIR, `voice/male/${n}.ogg`))
&& !fs.existsSync(path.join(AUDIO_DIR, `voice/female/${n}.ogg`)));
assert(flatOk && notGendered, '④ 角色音(林晚/猎狗/小满等)仅存平铺目录,性别目录无副本');
}
// ================================================================ 汇总
realLog(`\n通过 ${passCount} 条断言,失败 ${failures.length} 条`);
if (failures.length > 0) {
realLog('失败清单:');
for (const f of failures) realLog(` - ${f}`);
process.exit(1);
}
realLog('全部断言通过。');