storyline/escape/simon 首批之后的推广收官:15 款全部按 26 号模式运行 (发言权/回执/三档提示/迷茫接管/手势阶梯/断点复述),旧「双击重听/格挡/ 菜单确认」等反模式口径全线废除,开场零规则念白(首句即动手)。 游戏层(12 款):rpg(格挡=前摇窗口内再单击)/ rhythm / space / radio / dungeon / abyss / detective / cipher / teahouse / keeper / bridge / hunt(联机切片): - 每款 game.js 接引擎六模块 + makeInputPipeline(doubleTapWindow:0); 四向玩法类 swipeCore + 长按帮助;双击是玩法的(space 护盾 / hunt 旋风斩)按手势阶梯阶段 3 分档恢复双击窗口(doubletapCore) - 每款 playtest 重写:玩法断言 + §6.2 乱点机器人(无声操作 / 被动 原句重复 / 打断失败 = 0);15 款 3900+ 断言全绿 - 12 份 docs/design/26-apply-*.md 施工单 + tools/voice_lines_26/ 台词清单 + voice-ids 文档同步 + 转写会话剧本按新口径重写 引擎层(web/engine/,6 处修复):pipeline 增 doubletapCore;novice teachLine 带 voice/ 前缀(此前教学句被静默丢弃);confusion.record 前置到新手门(锁定手势的乱点也计信号);InvalidHints 播完恢复解释前 状态(根治孤儿 BUSY);WaitPoint takeover(2) 台账去重;台账只在 speak 真正播出时登记。遗留引擎债登记 roadmap §3.1。 语音资产:台词表合并(新增 ~250 系统句 / 改 ~50 旧口径 / 删 ~110 废止句;合并器 tools/merge_voice_lines_26.py + 引用校验器 tools/validate_voice_refs.py),GPU 重录 1536 wav → 4204 ogg/m4a 落地,check_all_assets + validate_voice_refs 双绿;键盘变体同步扩表。 大厅/文档:15 款统一新交互口径(「新交互」徽标退役);README / HANDOVER / rpg README 旧口径修正;roadmap P0-P3 置完成。
294 lines
14 KiB
JavaScript
294 lines
14 KiB
JavaScript
/**
|
||
* 向导层无头测试(web/harness/guide.test.mjs)。
|
||
*
|
||
* settings.guide='full' 下,用 storyline + simon 两个代表游戏 + Guide 直连断言:
|
||
* ① 看门狗礼仪(round2 F1):storyline 进入等待态 25s → nudge + gd_continue,
|
||
* 50s 升级只重申手势(gd_again),**不再重播 help/进度/当前节点**,两级用尽
|
||
* 彻底静默;simon input 10s → gd_relisten,25s 升级 = confirm 震 + turn
|
||
* earcon 纯标记(**取消自动重播序列**);
|
||
* ② turn cue:simon 首轮序列播完 → turn earcon 事件 + confirm 震 + your_turn;
|
||
* ③ error 纠错:storyline 无效手势 → error 震 + gd_invalid(onceKey 同局只播一次);
|
||
* ④ F1.4 idle-only:对白在播时 error/watchdog 语音跳过(事件带 skipped:true、
|
||
* 不入队),空闲时正常播;turn 语音保持 queue 不受影响;
|
||
* ⑤ 等级衰减:guide='off' 时以上全部无事件、无 gd_* 播放、无定时器注册。
|
||
* 事件经 guide.setEventHook 收集;mock 层与各 playtest 同款(虚拟时钟)。
|
||
*
|
||
* 运行:tools/node/bin/node web/harness/guide.test.mjs
|
||
*/
|
||
import fs from 'node:fs';
|
||
import path from 'node:path';
|
||
import url from 'node:url';
|
||
|
||
const ROOT = path.resolve(path.dirname(url.fileURLToPath(import.meta.url)), '..', '..');
|
||
const GAME_DIRS = {
|
||
StorylineGame: path.join(ROOT, 'web', 'storyline'),
|
||
SimonGame: path.join(ROOT, 'web', 'simon'),
|
||
RhythmGame: path.join(ROOT, 'web', 'rhythm'),
|
||
SpaceGame: path.join(ROOT, 'web', 'space'),
|
||
EscapeGame: path.join(ROOT, 'web', 'escape'),
|
||
};
|
||
let currentGameDir = GAME_DIRS.StorylineGame;
|
||
|
||
// ---------------------------------------------------------------- 断言收集
|
||
|
||
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.error = () => {};
|
||
console.warn = () => {};
|
||
console.log = () => {};
|
||
|
||
// ---------------------------------------------------------------- 虚拟时钟
|
||
|
||
let now = 0; // 虚拟毫秒
|
||
let timerSeq = 1;
|
||
const timers = new Map(); // id -> { fireAt, cb }
|
||
const realSetImmediate = setImmediate;
|
||
|
||
globalThis.setTimeout = (cb, ms = 0) => {
|
||
const id = timerSeq++;
|
||
timers.set(id, { fireAt: now + Math.max(0, Number(ms) || 0), cb });
|
||
return id;
|
||
};
|
||
globalThis.clearTimeout = (id) => { timers.delete(id); };
|
||
// rhythm 的 40ms 调度泵 / space 的局内定时链依赖 interval(round3 G1/G7 用例需要)
|
||
const intervals = new Set();
|
||
globalThis.setInterval = (cb, ms = 0) => {
|
||
const id = timerSeq++;
|
||
const step = () => {
|
||
if (!intervals.has(id)) return; // 已被 clearInterval
|
||
cb();
|
||
if (intervals.has(id)) timers.set(id, { fireAt: now + Math.max(0, Number(ms) || 0), cb: step });
|
||
};
|
||
intervals.add(id);
|
||
timers.set(id, { fireAt: now + Math.max(0, Number(ms) || 0), cb: step });
|
||
return id;
|
||
};
|
||
globalThis.clearInterval = (id) => { intervals.delete(id); timers.delete(id); };
|
||
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 < 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();
|
||
}
|
||
throw new Error('虚拟时钟定时器超上限(疑似死循环)');
|
||
}
|
||
const idle = () => advance(null);
|
||
/** 清空全局定时器(用例边界卫生:上一局的看门狗不串场)。 */
|
||
const resetTimers = () => timers.clear();
|
||
|
||
// ---------------------------------------------------------------- 浏览器 mock
|
||
|
||
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); },
|
||
};
|
||
|
||
const vibrateLog = [];
|
||
Object.defineProperty(globalThis, 'navigator', {
|
||
// maxTouchPoints: 1 —— 手机 persona:inputMode() 判 touch,语音走触屏措辞主资产(键盘路由由 engine-input K9 专测)
|
||
value: { maxTouchPoints: 1, vibrate: (...a) => { vibrateLog.push(a[0]); return true; } }, configurable: true,
|
||
});
|
||
|
||
class MockParam {
|
||
constructor(v = 1) { this.value = v; }
|
||
setValueAtTime() {}
|
||
linearRampToValueAtTime() {}
|
||
cancelScheduledValues() {}
|
||
}
|
||
class MockGainNode {
|
||
constructor() { this.gain = new MockParam(); }
|
||
connect() {}
|
||
disconnect() {}
|
||
}
|
||
class MockOscillator {
|
||
constructor() {
|
||
this.type = 'sine'; this.frequency = new MockParam();
|
||
this.connect = () => {}; this.start = () => {}; this.stop = () => {};
|
||
}
|
||
}
|
||
class MockAudioContext {
|
||
constructor() { this.state = 'running'; this.destination = {}; this.sampleRate = 44100; }
|
||
// 跟随虚拟时钟:rhythm 用 ctx.currentTime 绝对排程(round3 G1 时间窗断言的前提)
|
||
get currentTime() { return now / 1000; }
|
||
createGain() { return new MockGainNode(); }
|
||
createOscillator() { return new MockOscillator(); }
|
||
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, playbackRate: new MockParam(), connect() {}, start() {}, stop() {} };
|
||
}
|
||
createScriptProcessor() { return { connect() {}, disconnect() {}, onaudioprocess: null }; }
|
||
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;
|
||
|
||
// fetch 读"当前测试游戏"的真实资产目录(语音链靠引擎 endTimer 推进,需要真文件)
|
||
globalThis.fetch = async (u) => {
|
||
const p = path.join(currentGameDir, decodeURIComponent(String(u)));
|
||
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 { Guide } = await import('../engine/guide.js');
|
||
const { StorylineGame } = await import('../storyline/game.js');
|
||
const { SimonGame } = await import('../simon/game.js');
|
||
const { RhythmGame } = await import('../rhythm/game.js');
|
||
const { SpaceGame } = await import('../space/game.js');
|
||
const { EscapeGame } = await import('../escape/game.js');
|
||
|
||
// guide='full':本文件验证向导层行为(既有 playtest 以 guide='off' 验证零干扰)
|
||
settings.set({ guide: 'full', voice: 'female', speed: 1.0, bgm: 1.0 });
|
||
|
||
/** 组装一局:playLog + 震动/事件捕获(每个用例全新 audio+game)。 */
|
||
function makeRig(GameClass) {
|
||
currentGameDir = GAME_DIRS[GameClass.name];
|
||
storageData.clear(); // 用例间进度隔离(rhythm tutorialDone / simon best 等不串场)
|
||
const audio = new WebAudioEngine('audio/');
|
||
const playLog = [];
|
||
const origPlay = audio.play.bind(audio);
|
||
audio.play = (id, opts) => { playLog.push({ id, t: now, opts: opts || {} }); return origPlay(id, opts); };
|
||
const game = new GameClass(audio, {});
|
||
const events = [];
|
||
// 26 号改造中的游戏可能已无 Guide(发言权层接管):无钩则空事件表
|
||
if (game.guide && game.guide.setEventHook) game.guide.setEventHook((evt) => events.push({ ...evt, t: now }));
|
||
const vibBase = vibrateLog.length;
|
||
return {
|
||
audio, playLog, game, events, vibBase,
|
||
vibrated: (pattern) => vibrateLog.slice(vibBase).some((p) => JSON.stringify(p) === JSON.stringify(pattern)),
|
||
vibratedSince: (mark, pattern) => vibrateLog.slice(mark).some((p) => JSON.stringify(p) === JSON.stringify(pattern)),
|
||
};
|
||
}
|
||
const voicePlays = (playLog, id) => playLog.filter((e) => e.id === id).length;
|
||
async function gesture(game, g) { game.onGesture(g); await flush(); }
|
||
/** 等到当前语音链播完(精确停在链尾,不越过其后的看门狗定时器)。 */
|
||
const waitChainDone = (game) => advance(() => !game.voicePlaying);
|
||
|
||
// ================================================================ ⓪ Guide 层单测(stub 游戏夹具)
|
||
// 26 号重构后 storyline/escape/simon/rhythm 不再使用 Guide(发言权/等待点接管),
|
||
// Guide 仍服务 space/rpg 等未改造游戏;这里用最小夹具锁 Guide 自身语义。
|
||
|
||
realLog('== ⓪ Guide 层:状态签名 / turn 排队 / error idle-only / off 全静 ==');
|
||
{
|
||
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 guide = new Guide(audio);
|
||
const events = [];
|
||
guide.setEventHook((evt) => events.push({ ...evt, t: now }));
|
||
await audio.unlock();
|
||
|
||
guide.state('explore');
|
||
assert(events.some((e) => e.type === 'state' && e.name === 'explore'), 'state() 发状态签名事件');
|
||
|
||
guide.turn({ voice: 'gd_continue' });
|
||
assert(events.some((e) => e.type === 'turn'), 'turn() 发交接事件');
|
||
|
||
// F1.4 idle-only:对白在播时 error 语音跳过(skipped 事件、不入队)
|
||
audio.play('voice/menu', { queue: false });
|
||
await flush();
|
||
assert(audio.isVoicePlaying(), '夹具对白在播');
|
||
guide.error({ voice: 'gd_invalid' });
|
||
assert(events.some((e) => e.type === 'error' && e.skipped), '对白在播时 error 语音跳过(skipped)');
|
||
await advance(() => !audio.isVoicePlaying());
|
||
assert(!playLog.some((e) => e.id === 'voice/gd_invalid'), '被跳过的 error 不入队迟到');
|
||
|
||
// off:全部 API 静默 no-op(无事件、无定时器)
|
||
settings.set({ guide: 'off' });
|
||
const evBefore = events.length;
|
||
guide.state('battle');
|
||
guide.turn({ voice: 'gd_continue' });
|
||
guide.error({ voice: 'gd_invalid' });
|
||
guide.watchdog({ arm: true, at: [1000], onEscalate: () => assert(false, 'off 档不应触发升级') });
|
||
assert(events.length === evBefore, 'off 档全部 API 静默(零事件)');
|
||
await advance(() => false, );
|
||
settings.set({ guide: 'full' });
|
||
}
|
||
|
||
// ================================================================ ⑥⑦ rhythm(已迁移)
|
||
// 26 号深度重构(docs/design/26-apply-rhythm.md)后 rhythm 不再接 Guide:
|
||
// 发言权/等待点/三档提示/迷茫接管改走 engine/{floor,hints,confusion,receipts}.
|
||
// 原 ⑥ G1/G3(gd_answer 落窗前 1 小节、菜单恢复 gd_count+count-in)与
|
||
// ⑦ G2(教学连 2 败降速 25%/封顶跳步)断言已随交互层重写迁至
|
||
// web/rhythm/test/playtest.mjs(场景 H 菜单恢复 / 场景 F 教学 DDA / 场景 J
|
||
// gd_answer+交接提示音同窗)。此处保留空位防止节号漂移。
|
||
|
||
// ================================================================ ⑨ G7 space 方向呼喊
|
||
|
||
realLog('== ⑨ G7 space:dir_* 方向呼喊与呼啸同窗(spawn 当刻,round4 R1 改口) ==');
|
||
{
|
||
// 设计依据(research/04-playtests/live/round1/space-xiaozhao.md P1-1 + 13 号文档 §11):
|
||
// G7 原裁定为「loud 警报档(撞击前 500ms)同窗呼喊」,但第 1 轮全拟真实测每颗陨石的
|
||
// 呼喊都落在撞击判定前 ~0.4s——听完再滑必死,与教学培养的"听语音滑"直接矛盾。
|
||
// round4 R1 把呼喊提前到**陨石呼啸响起瞬间**(spawn 当刻):呼喊=反应窗口起点的指引,
|
||
// 与 whoosh 同窗;loud 警报保留在撞击前 500ms 承担"临近警示",二者**明确错峰**、
|
||
// 不再绑定(撞击点落在保护期内的陨石不呼喊,protect_<dir> 长句教学优先)。
|
||
const { audio, playLog, game } = makeRig(SpaceGame);
|
||
await audio.unlock();
|
||
game.start();
|
||
await advance(() => game.phase === 'drill');
|
||
await gesture(game, 'down'); // 无悬置演练陨石:下滑跳过演练
|
||
await advance(() => game.phase === 'playing');
|
||
assert(game.phase === 'playing', '前提:跳过演练进对局');
|
||
await advance(() => playLog.some((e) => e.id.startsWith('voice/dir_')));
|
||
const shout = playLog.find((e) => e.id.startsWith('voice/dir_'));
|
||
const dir = shout.id.slice('voice/dir_'.length);
|
||
assert(['left', 'right', 'up', 'down'].includes(dir), 'G7:呼喊方向合法');
|
||
const whooshes = playLog.filter((e) => e.id === `sfx/whoosh_${dir}`).map((e) => e.t);
|
||
assert(whooshes.some((t) => Math.abs(t - shout.t) < 60),
|
||
`G7:dir_* 与同向呼啸同窗(≤60ms,实际最小 ${Math.min(...whooshes.map((t) => Math.abs(t - shout.t)))}ms)`);
|
||
// 错峰验证:呼喊后第一声 loud 警报(同颗陨石的撞击前 500ms 档)应 ≥2s 才到
|
||
// (逼近时长 ≥2.8s → 呼喊到 loud 档间隔 ≥2.3s,取 2s 保守下界)
|
||
await advance(() => playLog.some((e) => e.id === 'sfx/alarm' && e.opts.volume === 1 && e.t > shout.t));
|
||
const nextLoud = playLog.find((e) => e.id === 'sfx/alarm' && e.opts.volume === 1 && e.t > shout.t);
|
||
assert(nextLoud.t - shout.t >= 2000,
|
||
`G7:呼喊先于其 loud 警报 ≥2s(错峰:呼喊在窗口起点,实际 ${nextLoud.t - shout.t}ms)`);
|
||
game.stop();
|
||
resetTimers();
|
||
}
|
||
|
||
realLog(`\n通过 ${passCount} 条断言,失败 ${failures.length} 条`);
|
||
if (failures.length > 0) {
|
||
realLog('失败清单:');
|
||
for (const f of failures) realLog(` - ${f}`);
|
||
process.exit(1);
|
||
}
|
||
realLog('全部断言通过。');
|