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

611 lines
27 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 }; }
// 向导层 earcon 运行时合成所需(guide=standard/full 时 guide.state/nudge 会创建)
createOscillator() {
return { type: 'sine', frequency: new MockParam(440), connect() {}, start() {}, stop() {} };
}
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');
// 向导层关闭:回归测试只验证纯游戏逻辑(guide='full' 的行为由 harness/guide.test.mjs 覆盖)
settings.set({ guide: 'off', voice: 'female', speed: 1.0, bgm: 1.0 });
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();
}
// ================================================================ live round2 N1
// (docs/design/13-playtest-criteria.md §16:真实在播的长语音不被 30s 看门狗掐断)
realLog('== live round2 N1:长语音看门狗续约不掐(§16)==');
{
settings.set({ guide: 'off', voice: 'female', speed: 1.0, bgm: 1.0 });
const { audio, playLog, game } = makeRig();
await audio.unlock();
// 预植 40s 长语音缓存(模拟真实 intro 71.7s 超 30s 阈值的情形;虚拟时钟不真实等待)
audio._cache.set('female|voice/n00_intro', Promise.resolve({
duration: 40.0, numberOfChannels: 1, sampleRate: 44100,
getChannelData: () => new Float32Array(16),
}));
const warnBefore = consoleWarns.length;
const t0 = now;
game.start();
await flush();
assert(game.currentVoiceId === 'voice/n00_intro', 'N1:40s 长 intro 开播');
// 越过 30s 看门狗阈值:voice 真在播 → 不掐、不续链、无告警
await advance(() => now - t0 >= 30000);
assert(game.currentVoiceId === 'voice/n00_intro' && audio.isVoicePlaying()
&& game.nodeId === 'n00',
'N1:30s 看门狗触发时 voice 真在播 → 不续链(仍停在 intro)');
assert(!consoleWarns.slice(warnBefore).some((w) => w.includes('看门狗')),
'N1:在播长语音不触发看门狗告警');
// 自然播完才续链下一句(skip_hint 不再顶掉 intro 末句——冷启动末句被吃的根因)
await advance(() => game.currentVoiceId === 'voice/skip_hint');
assert(now - t0 >= 40000, `N1:intro 自然播完才续链 skip_hint(实际 ${now - t0}ms)`);
assert(voicePlays(playLog, 'voice/skip_hint') === 1, 'N1:skip_hint 在 intro 播完后播出');
await idle();
assert(!game.voicePlaying && game.nodeId === 'n00', 'N1:长链正常走完进入等待态');
}
// ================================================================ live round1 修复断言
// (docs/design/13-playtest-criteria.md §10:结局双击重听 / 结局状态播报;
// §10 分支台词口径经 §16 N2 翻案——钢索路径恢复播 n18_dog)
realLog('== live round1 修复断言(§10+§16:结局重听 / n18 两路径 / 结局状态)==');
{
settings.set({ guide: 'off', voice: 'female', speed: 1.0, bgm: 1.0 });
const { audio, playLog, game } = makeRig();
await audio.unlock();
game.start();
await idle(); // n00 链播完
// 钢索路径推进到 n18:n01→n02(right→n04)→n05→n06→n07(right→n09)→n10(right→n14)→n15
await g(game, 'tap'); await idle(); // → n01
await g(game, 'tap'); await idle(); // → n02(抉择)
await g(game, 'right'); await idle(); // → n04
await g(game, 'tap'); await idle(); // → n05
await g(game, 'tap'); await idle(); // → n06
await g(game, 'tap'); await idle(); // → n07(抉择)
await g(game, 'right'); await idle(); // → n09
await g(game, 'tap'); await idle(); // → n10(抉择)
await g(game, 'right'); await idle(); // → n14
await g(game, 'tap'); await idle(); // → n15(抉择)
await g(game, 'right'); await idle(); // 钢索 → n17(无涵洞经历)
assert(game.nodeId === 'n17' && game.viaCulvert === false,
'n15 右滑 → 钢索 n17(viaCulvert === false)');
// round2 N2(§10 P2-2 矫枉过正翻案):钢索路径恢复播 n18_dog 对峙戏 + p6
await g(game, 'tap'); await idle(); // → n18
assert(game.nodeId === 'n18' && voicePlays(playLog, 'voice/n18_dog') === 1
&& voicePlays(playLog, 'voice/p6') === 1,
'N2:钢索路径 n18 恢复播 n18_dog(对峙戏)+ 抉择提示 p6(known-issue:涵洞句轻微穿帮待 TTS 分支句)');
// 推进到结局 e2:n19→n20(left→n21)→e2
await g(game, 'left'); await idle(); // n18 交坐标 → n19
await g(game, 'tap'); await idle(); // → n20(抉择)
await g(game, 'left'); await idle(); // → n21
await g(game, 'tap'); await idle(); // → e2
assert(game.nodeId === 'e2' && game.awaitingRestart === true, '到达结局 e2 进入结算态');
// P2-1 结局双击重听:结算态双击 → 结局语音重播;重播中单击 = 跳句不重开
const e2before = voicePlays(playLog, 'voice/e2_price');
await g(game, 'doubletap');
assert(game.voicePlaying && game.currentVoiceId === 'voice/e2_price'
&& voicePlays(playLog, 'voice/e2_price') === e2before + 1,
'P2-1:结局结算态双击 = 重播结局语音');
assert(game.awaitingRestart === false, 'P2-1:结局重播期间离开结算态(单击回到跳句语义)');
await g(game, 'tap'); // 重播中单击 = 跳过本句
assert(game.nodeId === 'e2', 'P2-1:结局重播中单击跳过本句,不触发重开');
await idle();
assert(game.awaitingRestart === true && !game.voicePlaying, 'P2-1:结局重播链播完回到结算态');
// P3-2 结局后状态播报:help 后播当前结局语音(不再报 status3),播完回结算态
const e2again = voicePlays(playLog, 'voice/e2_price');
await g(game, 'up');
assert(game.currentVoiceId === 'voice/help', 'P3-2:结局后上滑先播 help');
await idle();
assert(voicePlays(playLog, 'voice/e2_price') === e2again + 1
&& voicePlays(playLog, 'voice/status3') === 0,
'P3-2:结局后状态播报 = 当前结局语音(不再报 status3)');
assert(game.awaitingRestart === true && !game.voicePlaying,
'P3-2:状态播完直接回结算态(不二次重播节点)');
}
// ================================================================ 静默兜底(standard 档)
// (docs/design/13-playtest-criteria.md §10:13s gd_continue / 30s gd_again / 手势重置)
realLog('== 静默兜底(guide=standard,§10 P2-3)==');
{
settings.set({ guide: 'standard', voice: 'female', speed: 1.0, bgm: 1.0 });
const { audio, playLog, game } = makeRig();
await audio.unlock();
game.start();
await advance(() => !game.voicePlaying); // n00 链播完(精确停链尾,不越过看门狗)
assert(game.nodeId === 'n00' && !game.voicePlaying, 'standard:n00 链播完进入等待态');
const tArm = now;
await advance(() => voicePlays(playLog, 'voice/gd_continue') >= 1);
assert(now - tArm === 13000, `standard:13s 无操作播 gd_continue(实际 ${now - tArm}ms)`);
await advance(() => voicePlays(playLog, 'voice/gd_again') >= 1);
assert(now - tArm === 30000, `standard:30s 升级播 gd_again(实际 ${now - tArm}ms)`);
await idle(); // 两级用尽彻底静默(无更多定时器)
// 玩家手势后重置:新节点播完重新挂载,13s 再次提示
await g(game, 'tap'); // → n01
await advance(() => !game.voicePlaying);
assert(game.nodeId === 'n01' && !game.voicePlaying, 'standard:手势后进入 n01 等待态');
const gdBefore = voicePlays(playLog, 'voice/gd_continue');
const tWait = now;
await advance(() => voicePlays(playLog, 'voice/gd_continue') > gdBefore);
assert(now - tWait === 13000, `standard:手势重置后新等待点 13s 再提示(实际 ${now - tWait}ms)`);
settings.set({ guide: 'off' });
}
// ================================================================ 抉择节点静默兜底
// (docs/design/13-playtest-criteria.md §16 N3:25s/50s 重播 p* 提示、手势复位)
realLog('== 抉择节点静默兜底(guide=standard,§16 N3)==');
{
settings.set({ guide: 'standard', voice: 'female', speed: 1.0, bgm: 1.0 });
const { audio, playLog, game } = makeRig();
await audio.unlock();
game.start();
await advance(() => !game.voicePlaying); // n00 播完
await g(game, 'tap'); // → n01
await advance(() => !game.voicePlaying);
await g(game, 'tap'); // → n02(抉择:n02_linwan + p1)
await advance(() => !game.voicePlaying);
assert(game.nodeId === 'n02' && game.choiceArmed, 'N3:n02 抉择链播完,等待抉择');
const p1first = voicePlays(playLog, 'voice/p1');
const tArm = now;
await advance(() => voicePlays(playLog, 'voice/p1') > p1first);
assert(now - tArm === 25000, `N3:抉择播完 25s 无操作重播 p1(实际 ${now - tArm}ms)`);
await advance(() => voicePlays(playLog, 'voice/p1') > p1first + 1);
assert(now - tArm === 50000, `N3:50s 再复述一次 p1(实际 ${now - tArm}ms)`);
await idle(); // 两级用尽彻底静默
assert(game.nodeId === 'n02' && game.choiceArmed, 'N3:复述期间剧情不推进、仍待抉择');
// 空点复位:抉择等待态单击(无进展手势)重置计时,再 25s 复述
await g(game, 'tap');
const p1again = voicePlays(playLog, 'voice/p1');
const tTap = now;
await advance(() => voicePlays(playLog, 'voice/p1') > p1again);
assert(now - tTap === 25000, `N3:空点后计时重置,25s 再复述(实际 ${now - tTap}ms)`);
// 玩家抉择后兜底终止:右滑 → n04,此后不再复述 p1
await g(game, 'right');
await advance(() => !game.voicePlaying);
assert(game.nodeId === 'n04', 'N3:右滑抉择 → n04 剧情推进');
const p1final = voicePlays(playLog, 'voice/p1');
await idle();
assert(voicePlays(playLog, 'voice/p1') === p1final, 'N3:抉择后兜底终止(不再复述)');
settings.set({ guide: 'off' });
}
// ================================================================ 资产引用校验
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('全部断言通过。');