435 lines
18 KiB
JavaScript
435 lines
18 KiB
JavaScript
/**
|
||
* 《声命线》无头试玩仿真测试(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('全部断言通过。');
|