Files
xsl ee5d4d2544 feat(playbook): 26 号交互手册全量落地——剩余 12 款深度重构 + 语音全量重制
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 置完成。
2026-10-02 12:33:20 +08:00

294 lines
14 KiB
JavaScript
Raw Permalink 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.
/**
* 向导层无头测试(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('全部断言通过。');