/**
* 盲航(space)无头试玩测试 —— 26 号 playbook 深度重构版(docs/design/26-apply-space.md)。
*
* 覆盖:
* - §4.10 首 3 分钟:演练第一句 = D1 指令(critical,手势一律 busy 回执)、
* 第一次成功 ≤10s、做错当场重教、没出手说「没拨到」(禁说慢了)、2 次后演示通过;
* - 26 号交互层:对局 = Floor ACTION(玩法声即状态)、演练指令 critical、
* 结算链播完 yieldTurn settle 等待点、长按=我在哪/重听、新手期无双击(管道折算 tap)、
* 未解锁单击 = invalid + 首次「现在只要滑。」、挥空 = whiff + invalid + 首次解释;
* - 04 号玩法核心保留:同向判定窗(呼啸全程 + 250ms 宽限,早滑不吞)、whoosh 方位、
* 60s 保护期(吸收 + protect_
归因 + R5 去重)、断崖过渡、归因分方位 + hit_last、
* R5-2 呼喊顶断登记重排、里程碑、警报两档去重;
* - 原则 15:首局前两次失败锁血(归因照说真话)、第 3 次失败起自动降刷怪;
* - 阶段 4 高级手势:第一局结束解锁加速(一句教学,不暂停刷怪);护盾仅双击已识别
* 的会话(DT_CORE 快照,新手期双击折算);冷却拒绝必须出声(boost_not_ready);
* - §4.12 断点复述:stop() 自动存档 → 回来问继续/重开 → 复述句 + count-in 慢速陨石;
* - 菜单(00 §5.4):左右滑切换 + 单击确认(阶段 2 结算后下滑呼出);回大厅收尾态;
* - §6.2 乱点机器人:无声操作 = 0、被动原句重复 = 0(dir 呼喊是玩法内容音,不计)、
* 打断失败 = 0(同一 voice token 在手势 150ms 后仍在播;critical 与呼喊豁免);
* - 资产引用校验(26 号改造新增 id 白名单 = 待生成资产,见 tools/voice_lines_26/space.md)。
*
* 用法:tools/node/bin/node web/space/test/playtest.mjs
*
* 设计:
* - 真实加载 engine/{audio,settings,pipeline}.js 与 game.js,只 mock 浏览器环境;
* 手势经 makeInputPipeline 注入(与 index.html 同款接线),回执可观测。
* - fetch 映射到 web/space/audio/ 真实文件:sfx/bgm 缺失 → 404;voice id 属
* PENDING_NEW(26 号新增、资产待统一生成)→ 模拟存在(duration 1.0s),
* 资产校验段单独断言白名单与实际文件一致。
* - 虚拟时钟:setTimeout/clearTimeout/performance.now 全部替换,测试零真实等待。
* - 刷怪方向用 Math.random 队列控制;陨石投放用 naturalSpawn(游戏实例方法注入)。
*/
import { existsSync, readFileSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const GAME_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
// ---------------- 26 号改造新增 voice id(资产待统一生成,先按存在模拟) ----------------
const PENDING_NEW = new Set([
'hint_tap', 'd_short_left', 'd_short_right', 'd_short_up', 'd_short_down',
'hint_swipe', 'inv_tap', 'inv_whiff', 'miss', 'lp_run', 'lp_teach',
'sys_turn_teach', 'sys_say_again', 'sys_unlock_down', 'sys_demo', 'sys_now_you',
'resume_ask', 'resume_recap',
]);
// ---------------- 虚拟时钟 ----------------
const clock = {
now: 0,
_timers: new Map(),
_nextId: 1,
setTimeout(fn, ms = 0) {
const id = this._nextId++;
this._timers.set(id, { at: this.now + Math.max(0, ms), fn });
return id;
},
clearTimeout(id) { this._timers.delete(id); },
/** 推进虚拟时钟,按到期顺序触发所有 ≤ target 的定时器。 */
advance(ms) {
const target = this.now + ms;
for (;;) {
let bestId = -1, bestAt = Infinity;
for (const [id, t] of this._timers) {
if (t.at < bestAt || (t.at === bestAt && id < bestId)) { bestAt = t.at; bestId = id; }
}
if (bestId < 0 || bestAt > target) break;
const { fn } = this._timers.get(bestId);
this._timers.delete(bestId);
this.now = bestAt;
fn();
}
this.now = target;
},
};
// ---------------- 浏览器环境 mock(必须先于引擎 import) ----------------
globalThis.setTimeout = (fn, ms) => clock.setTimeout(fn, ms);
globalThis.clearTimeout = (id) => clock.clearTimeout(id);
Object.defineProperty(globalThis, 'performance', {
value: { now: () => clock.now }, configurable: true,
});
Object.defineProperty(globalThis, 'navigator', {
// maxTouchPoints: 1 —— 手机 persona:inputMode() 判 touch,语音走触屏措辞主资产
value: { maxTouchPoints: 1, vibrate: (p) => { vibrations.push(p); return true; } }, configurable: true,
});
const vibrations = [];
function makeLocalStorage() {
const m = new Map();
return {
getItem: (k) => (m.has(k) ? m.get(k) : null),
setItem: (k, v) => m.set(k, String(v)),
removeItem: (k) => m.delete(k),
clear: () => m.clear(),
};
}
const storage = makeLocalStorage();
Object.defineProperty(globalThis, 'localStorage', { value: storage, writable: true, configurable: true });
globalThis.window = globalThis;
window.addEventListener = () => {};
window.location = { href: '' };
globalThis.document = {
createElement: () => ({ style: { setProperty() {} }, appendChild() {}, addEventListener() {}, remove() {} }),
getElementById: () => null,
body: { appendChild() {} },
};
/** 置为缺失的资产 id(看门狗测试用),如 'voice/drill_left'。 */
const missingAssets = new Set();
const fetchLog = [];
globalThis.fetch = async (url, opts) => {
const u = String(url);
if (u.startsWith('audio/')) {
// url 形如 audio/voice/female/x.ogg → 归一化 id(voice/x)
const noExt = u.replace(/\.[a-z0-9]+$/i, '');
const id = noExt.replace(/^audio\//, '').replace(/^voice\/(male|female)\//, 'voice/');
const name = id.replace(/^voice\//, '');
let ok;
if (missingAssets.has(id)) ok = false;
else if (id.startsWith('voice/')) ok = PENDING_NEW.has(name) || fileExists(u);
else ok = fileExists(u);
fetchLog.push({ url: u, opts, t: clock.now, ok });
if (!ok) return { ok: false, status: 404, arrayBuffer: async () => new ArrayBuffer(0) };
return { ok: true, status: 200, arrayBuffer: async () => ({ __url: u }) };
}
fetchLog.push({ url: u, opts, t: clock.now, ok: true });
return { ok: true, status: 200, arrayBuffer: async () => new ArrayBuffer(0), json: async () => ({}) };
};
/** 引擎按 ogg→m4a 逐个探测;键盘变体目录缺失会回退主资产(key/ → 同性别主目录)。 */
function fileExists(u) {
const base = u.replace(/\.[a-z0-9]+$/i, '');
const cands = [base, base.includes('/key/') ? base.replace('/key/', '/') : null];
return cands.some((c) => c && ['.ogg', '.m4a'].some((ext) => existsSync(path.join(GAME_DIR, c + ext))));
}
class MockParam {
constructor(v = 0) { this.value = v; }
cancelScheduledValues() {}
setValueAtTime() {}
linearRampToValueAtTime() {}
}
class MockAudioContext {
constructor() { this.state = 'suspended'; this.destination = {}; }
get currentTime() { return clock.now / 1000; }
async resume() { this.state = 'running'; }
createGain() { return { gain: new MockParam(1), connect() {}, disconnect() {} }; }
createDynamicsCompressor() {
return {
threshold: new MockParam(), ratio: new MockParam(),
attack: new MockParam(), release: new MockParam(),
connect() {}, disconnect() {},
};
}
createBufferSource() { return { buffer: null, loop: false, connect() {}, disconnect() {}, start() {}, stop() {} }; }
createScriptProcessor() { return { connect() {}, disconnect() {}, onaudioprocess: null }; }
createOscillator() {
return { type: 'sine', frequency: new MockParam(440), connect() {}, start() {}, stop() {} };
}
async decodeAudioData(raw) {
const u = (raw && raw.__url) || '';
let duration = 0.5;
if (u.includes('/voice/')) duration = 1.0;
else if (u.includes('/bgm/')) duration = 60;
return {
duration, numberOfChannels: 1, sampleRate: 44100,
getChannelData: () => new Float32Array(16),
};
}
}
globalThis.AudioContext = MockAudioContext;
// ---------------- 受控随机 ----------------
const randQueue = [];
Math.random = () => (randQueue.length ? randQueue.shift() : 0); // 默认 0 = left
// ---------------- 加载引擎与游戏(globals 就绪后动态 import) ----------------
const { WebAudioEngine } = await import('../../engine/audio.js');
const { settings } = await import('../../engine/settings.js');
const { makeInputPipeline } = await import('../../engine/pipeline.js');
const { SpaceGame } = await import('../game.js');
// 向导层关闭:回归测试只验证纯游戏逻辑(guide 设置只影响等待点提示档位)
settings.set({ guide: 'off', voice: 'female', speed: 1.0, bgm: 1.0 });
// ---------------- 断言与工具 ----------------
let passed = 0, failed = 0;
function assert(cond, msg) {
if (cond) { passed++; console.log(' ✓', msg); }
else { failed++; console.error(' ✗ FAIL:', msg); }
}
function section(name) { console.log('\n== ' + name); }
/** 冲刷微任务(fetch/decode/play 链与 voice 播放安全门是 async 的)。 */
const tick = async (n = 10) => { for (let i = 0; i < n; i++) await new Promise((r) => setImmediate(r)); };
/** 分段推进:每 ≤400ms 让出主线程冲微任务(voice 安全门/decode 不被饿死)。 */
async function advance(ms) {
let left = ms;
while (left > 0) {
const step = Math.min(400, left);
clock.advance(step);
await tick(2);
left -= step;
}
}
/** 轮询推进直到条件成立(虚拟时长上限内)。 */
async function waitFor(cond, maxMs = 30000) {
const target = clock.now + maxMs;
while (!cond() && clock.now < target) await advance(300);
return cond();
}
function makeRig() {
const audio = new WebAudioEngine('audio/');
const plays = [], skips = [], voiceStarts = [], receiptLog = [], chimeLog = [];
const origPlay = audio.play.bind(audio);
audio.play = (id, opts) => { plays.push({ id, opts: opts || {}, t: clock.now }); return origPlay(id, opts); };
const origPV = audio._playVoice.bind(audio);
audio._playVoice = (id, opts) => { voiceStarts.push({ id, t: clock.now }); return origPV(id, opts); };
const origSkip = audio.skipVoice.bind(audio);
audio.skipVoice = () => { skips.push(clock.now); return origSkip(); };
const game = new SpaceGame(audio, {});
game.floor.bedEnabled = false; // 虚拟时钟测试关等待声(周期定时器不排水)
const origR = game.receipts.play.bind(game.receipts);
game.receipts.play = (kind) => { receiptLog.push({ kind, t: clock.now }); return origR(kind); };
const origChime = game.floor.chime.bind(game.floor);
game.floor.chime = () => { chimeLog.push(clock.now); return origChime(); };
const pipe = makeInputPipeline({
floor: game.floor, confusion: game.confusion, receipts: game.receipts,
invalidHints: game.invalidHints, game, ladder: game.ladder, opts: game.PIPELINE_OPTS,
});
return { audio, game, plays, skips, voiceStarts, receiptLog, chimeLog, pipe };
}
const voices = (plays) => plays.filter((p) => p.id.startsWith('voice/')).map((p) => p.id);
const lastVoice = (plays) => voices(plays).at(-1);
const voicePlays = (plays, id) => plays.filter((p) => p.id === id).length;
const vibrated = (pat) => vibrations.some((v) => Array.isArray(v) && v.join() === pat);
const confirmCount = () => vibrations.filter((v) => Array.isArray(v) && v.join() === '20').length;
const invalidCount = (rig) => rig.receiptLog.filter((r) => r.kind === 'invalid').length;
const busyCount = (rig) => rig.receiptLog.filter((r) => r.kind === 'busy').length;
const freshStorage = () => storage.clear();
try {
// ======================================================================
section('S1 演练(§4.10 首 3 分钟):第一句就让玩家拨一颗');
// ======================================================================
freshStorage();
const h = makeRig();
const { game, audio, plays, skips, receiptLog } = h;
assert(fetchLog.length === 0, '音频解锁前 play 不加载不播放(§7.1)');
await audio.unlock();
game.start(); await tick();
assert(game.phase === 'drill', '首局直接进演练(删 D0 零操作欢迎)');
assert(lastVoice(plays) === 'voice/drill_left', '第一句 = D1 指令「听,左边有陨石。向左滑。」');
assert(game.floor.speaking && game.floor._chain && game.floor._chain.critical === true,
'演练指令 = critical 关键句(§4.6)');
// critical 关键句:手势一律 busy 回执,不跳句、不打断
const busy0 = busyCount(h);
await h.pipe('tap'); await tick();
await h.pipe('right'); await tick();
await h.pipe('longpress'); await tick();
assert(busyCount(h) === busy0 + 3, '关键句在播:单击/滑动/长按全部 busy 回执');
assert(skips.length === 0, '关键句不可跳过(12 号旧「单击跳旁白」对 critical 失效)');
// 指令播完 → 演练陨石 spawn(语音先、音效后)→ 交出等待点
await waitFor(() => game.drillOb);
assert(game.drillOb.dir === 'left', 'D1 演练陨石(左)出现');
assert(plays.some((p) => p.id === 'sfx/whoosh_left'), 'whoosh_left 方位音');
assert(game.floor.state === 'YOUR_TURN' && game.floor.waitPoint
&& game.floor.waitPoint.conf.id === 'd1_left', '演练陨石 = 等待点 d1_left(04 §0.5)');
// 等待点单击 = invalid + 首次「向声音那边滑。」(矩阵行 2)
const inv0 = invalidCount(h);
await h.pipe('tap'); await tick();
assert(invalidCount(h) === inv0 + 1, '演练等待点单击 = invalid 回执');
await waitFor(() => voicePlays(plays, 'voice/hint_swipe') === 1);
assert(true, '首次解释「向声音那边滑。」');
await h.pipe('tap'); await tick();
await advance(1500);
assert(voicePlays(plays, 'voice/hint_swipe') === 1, '同格解释只说一次(原则 7)');
// 做对:dodge 音 + drill_good + 马上教长按帮助(04 §0.3 阶段 1)
await waitFor(() => game.floor.state === 'YOUR_TURN');
await h.pipe('left'); await tick();
assert(plays.some((p) => p.id === 'sfx/dodge'), '做对 → dodge 音(内容音即反馈)');
await waitFor(() => voicePlays(plays, 'voice/drill_good') === 1);
await waitFor(() => voicePlays(plays, 'voice/lp_teach') === 1);
assert(storage.getItem('soundgame.space.lpTaught') === '1', '长按教学落盘(只教一次)');
assert(game.ladder.stage() === 1, '第一次成功闪避 → 手势阶梯阶段 1');
assert(game.drillIdx === 0 && game.floor.speaking, '第一次成功:肯定句后接 D2');
// D2:做对 → 教「轮到你」状态(10~30s 窗口,§4.10 模板)
await waitFor(() => game.drillIdx === 1 && !!game.drillOb && game.drillOb.dir === 'right');
await h.pipe('right'); await tick();
await waitFor(() => voicePlays(plays, 'voice/sys_turn_teach') === 1);
assert(true, '10~30s 窗口:教「听到这个声音,就是轮到你了」');
// D3:没出手(超时)→ 真话归因「没拨到」(禁说慢了)+ 同一颗重来
await waitFor(() => game.drillIdx === 2 && !!game.drillOb && game.drillOb.dir === 'up');
const impact3 = game.drillOb.impactAt;
await advance(impact3 - clock.now + 100);
assert(game.drillFails === 1, '演练陨石到点未拨开 = 失败 1 次');
await waitFor(() => voicePlays(plays, 'voice/miss') === 1);
assert(true, '没出手 → 「没拨到。声音一响就滑。」(不说「慢了」)');
await waitFor(() => voicePlays(plays, 'voice/drill_retry_up') === 1);
await waitFor(() => game.drillOb && game.drillOb.dir === 'up');
await h.pipe('up'); await tick();
assert(voicePlays(plays, 'voice/drill_good') === 1, '语音肯定仅 D1(D2-D4 只内容音,04 §2)');
// D4:连错两次 → 演示通过(04 §0.6「2 次后演示通过」)
await waitFor(() => game.drillIdx === 3 && !!game.drillOb && game.drillOb.dir === 'down');
await h.pipe('left'); await tick(); // 错向 1
assert(game.drillFails === 1, '错向 → 失败计数 1');
await waitFor(() => voicePlays(plays, 'voice/drill_retry_down') === 1);
await waitFor(() => game.drillOb && game.drillOb.dir === 'down');
await h.pipe('right'); await tick(); // 错向 2
assert(game.drillFails === 2, '同一步失败 2 次');
await waitFor(() => voicePlays(plays, 'voice/drill_moveon') === 1);
assert(plays.filter((p) => p.id === 'sfx/dodge').length >= 1, '演示拨开有 dodge 音');
await waitFor(() => voicePlays(plays, 'voice/drill_done') === 1);
await waitFor(() => game.phase === 'playing');
assert(storage.getItem('soundgame.space.drillDone') === '1', 'drillDone 落盘');
assert(game.floor.state === 'ACTION', '对局 = Floor ACTION 态(玩法声即状态,不起等待声)');
await tick();
assert(audio._bgm && audio._bgm.id === 'bgm/engine', '对局 BGM = bgm/engine');
// ======================================================================
section('S2 保护期:吸收归因 + R5 去重 + 新手单击/挥空拒绝 + 播报');
// ======================================================================
const t0 = game.runStart;
await advance(1600); // 首颗(左,Math.random 默认 0)
assert(game.obstacles.size >= 1, '保护期首颗陨石刷出');
assert(!plays.some((p) => p.t >= t0 && p.id.startsWith('voice/dir_')),
'撞击点落在保护期内的陨石 spawn 不播 dir 呼喊(protect 长句教学优先)');
await advance(4100); // 到撞击点
assert(game.hits === 0, '保护期撞击不掉血(训练护盾吸收)');
await waitFor(() => voicePlays(plays, 'voice/protect_left') === 1);
assert(true, 'protect_ 与来袭方向一致(左)');
assert(vibrated('30,50,30,50,30'), '保护期吸收 WARNING 震动');
assert(plays.some((p) => p.id === 'sfx/shield_block'), '吸收有 shield_block 音');
// R5 去重:同方位吸收 30s 内不重播教学句(只有音+震)
game.obstacles.clear();
const obP2 = { dir: 'left', impactAt: clock.now + 100 };
game.obstacles.add(obP2);
game._after(100, () => game._resolve(obP2));
await advance(150);
assert(game.hits === 0 && voicePlays(plays, 'voice/protect_left') === 1,
'同方位吸收 30s 台账去重(被动原句重复 = 0,原则 9)');
// 新手期对局单击 = invalid + 首次「现在只要滑。」(加速未解锁)
//(先等教学句播完回 ACTION,避免这下被当成「跳过这句」)
await waitFor(() => !audio.isVoicePlaying() && game.floor.state === 'ACTION');
const inv1 = invalidCount(h);
await h.pipe('tap'); await tick();
assert(invalidCount(h) === inv1 + 1, '未解锁加速:对局单击 = invalid 回执');
await waitFor(() => voicePlays(plays, 'voice/inv_tap') === 1);
await h.pipe('tap'); await tick();
await advance(1500);
assert(voicePlays(plays, 'voice/inv_tap') === 1, '「现在只要滑。」只说一次(原则 7)');
// 挥空 = whiff 音 + invalid + 首次解释(R5 保留 + 04 §0.7)
const inv2 = invalidCount(h);
await h.pipe('up'); await tick();
assert(invalidCount(h) === inv2 + 1 && plays.some((p) => p.id === 'sfx/whiff'),
'挥空 → whiff 音 + invalid 回执');
await waitFor(() => voicePlays(plays, 'voice/inv_whiff') === 1);
assert(true, '挥空首次解释「现在没有这边的陨石。」');
// 新手期双击折算成单击(管道处理,护盾不存在)
const shieldBefore = game.shieldUpUntil;
await h.pipe('doubletap'); await tick();
assert(game.shieldUpUntil === shieldBefore, '新手期双击不展开护盾(折算 tap,无双击)');
// 50s 预告 / 60s 保护结束
await advance(t0 + 50500 - clock.now);
assert(voicePlays(plays, 'voice/protect_soon') === 1, '50s 预告 protect_soon(R5)');
await advance(t0 + 60500 - clock.now);
assert(game._protectAnnounced && voicePlays(plays, 'voice/protect_end') === 1,
'60s 保护解除播报 protect_end');
// ======================================================================
section('S3 对局核心:同向判定窗 + 呼喊 + 新手锁血 + 归因接力 + 坠毁');
// ======================================================================
// 切断自然刷怪(测试钩子),后续手动投放,避免难度曲线干扰
const naturalSpawn = game._spawnObstacle.bind(game);
game._spawnObstacle = () => {};
game._bareCount = 0; // 测试钩子:裸飞计数清零(断崖过渡从第 1 颗起验)
// 断崖过渡 + 呼喊同窗 + 早滑不吞
randQueue.push(0.75); // down
game.obstacles.clear(); game.swipes = [];
const tS = clock.now;
naturalSpawn();
const obD = [...game.obstacles].at(-1);
await tick();
assert(obD.impactAt - tS === 4000, '保护期后裸飞第 1 颗逼近 4.0s 不收缩(断崖过渡)');
assert(plays.some((p) => p.id === 'voice/dir_down' && p.t === tS),
'方向呼喊 dir_ 与呼啸同窗(spawn 当刻)');
await advance(200); // 呼啸响起 0.2s 内立刻同向滑(旧 1.5s 窗口之外的位置)
await h.pipe('down'); await tick();
await advance(obD.impactAt - clock.now + 100);
assert(game.dodges === 1 && game.hits === 0, '早滑(spawn+0.2s)判定躲开(窗口 = 呼啸全程)');
// 新手锁血:保护期后前两次失败不扣资源(原则 15),归因照说真话
//(S2 保护期吸收已累计 _failCount,这里看增量)
const fc0 = game._failCount;
randQueue.push(0); // left
game.obstacles.clear(); game.swipes = [];
naturalSpawn();
const obA = [...game.obstacles].at(-1);
await h.pipe('right'); await tick(); // 反向滑 = 挥空
await advance(obA.impactAt - clock.now + 100);
assert(game.hits === 0 && game._failCount === fc0 + 1, '首局第 1 次失败:锁血吸收不掉血');
await waitFor(() => voicePlays(plays, 'voice/hit_left') === 1);
assert(plays.some((p) => p.id === 'sfx/shield_block'), '锁血吸收有 shield_block 音');
randQueue.push(0.25); // right
game.obstacles.clear(); game.swipes = [];
naturalSpawn();
const obB = [...game.obstacles].at(-1);
await advance(obB.impactAt - clock.now + 100);
assert(game.hits === 0 && game._failCount === fc0 + 2, '首局第 2 次失败:仍锁血');
await waitFor(() => voicePlays(plays, 'voice/hit_right') === 1);
// 第 3 次失败:实打实掉血 + 自动降难度(原则 15)
randQueue.push(0.5); // up
game.obstacles.clear(); game.swipes = [];
naturalSpawn();
const obC = [...game.obstacles].at(-1);
await advance(obC.impactAt - clock.now + 100);
assert(game.hits === 1 && game._failCount === fc0 + 3, '第 3 次失败起扣血');
assert(game._eased === true, '第 3 次失败自动降难度(刷怪放缓)');
randQueue.push(0.5); // up
game.obstacles.clear(); game.swipes = [];
naturalSpawn();
const obE = [...game.obstacles].at(-1);
await tick();
assert(obE.impactAt - obE.spawnAt > 2800, '降难度后逼近时长加宽(原则 15)');
await advance(obE.impactAt - clock.now + 100);
assert(game.hits === 2, '第 4 次失败 hits=2');
await waitFor(() => voicePlays(plays, 'voice/hit_up') === 2);
await waitFor(() => voicePlays(plays, 'voice/hit_last') === 1);
assert(vibrated('80'), '受击 HIT 震动');
// R5-2:呼喊顶断归因链 → 登记重排(先因后果整链重播一次)
randQueue.push(0); // left:新陨石 spawn 呼喊顶断在播的 [hit_up, hit_last] 链
game.obstacles.clear(); game.swipes = [];
naturalSpawn();
const obNext = [...game.obstacles].at(-1);
await waitFor(() => h.voiceStarts.some((v) => v.id === 'voice/dir_left'));
assert(true, '下一颗 spawn 呼喊顶断归因(生存优先级保留)');
await h.pipe('left'); await tick(); // 滑动打断并闪避(矩阵行 4)
await advance(obNext.impactAt - clock.now + 100);
assert(game.dodges === 2 && game.hits === 2, '打断呼喊的滑动照样拨开陨石');
await waitFor(() => voicePlays(plays, 'voice/hit_up') === 3);
assert(true, '归因被顶断后整链重排一次(R5-2 补播,先因后果)');
game.obstacles.clear(); game.swipes = [];
// 第 3 次实撞 → 坠毁结算
randQueue.push(0.75); // down
naturalSpawn();
const obH3 = [...game.obstacles].at(-1);
const crashAt = obH3.impactAt;
await advance(obH3.impactAt - clock.now + 100);
assert(game.hits === 3 && game.phase === 'over', 'hits===3 坠毁 gameover');
assert(plays.some((p) => p.id === 'sfx/crash'), '坠毁音 crash');
assert(audio._bgm === null, '坠毁即 stopBgm(结算前先静)');
assert(vibrated('60,40,60'), '坠毁 ERROR 震动');
// 结算链:time → new_record → dodge → restart_hint(critical)→ settle 等待点
const aliveSec = Math.floor((crashAt - t0) / 1000);
const expectTime = aliveSec < 30 ? 'voice/time_1' : aliveSec < 60 ? 'voice/time_2'
: aliveSec < 120 ? 'voice/time_3' : 'voice/time_4';
await advance(1300); // 爆炸声 1.2s 后开口
await waitFor(() => voicePlays(plays, 'voice/restart_hint') === 1);
assert(voicePlays(plays, expectTime) === 1, `结算时间段句 ${expectTime}(存活 ${aliveSec}s)`);
assert(voicePlays(plays, 'voice/new_record') === 1, '首局必破纪录 new_record');
await waitFor(() => game.floor.state === 'YOUR_TURN');
assert(game.floor.waitPoint && game.floor.waitPoint.conf.id === 'settle',
'结算链播完 yieldTurn 结算等待点(不留安静态,§4.1)');
assert(voicePlays(plays, 'voice/sys_unlock_down') === 1, '第一局结束教「向下滑,可以打开菜单」(阶段 2)');
assert(game.ladder.stage() === 2, 'onGameDone → 阶梯阶段 2');
// 结算滑动 = invalid + 解释(invalidHint over → restart_hint)
const inv3 = invalidCount(h);
await h.pipe('left'); await tick();
assert(invalidCount(h) === inv3 + 1, '结算态滑动 = invalid 回执');
// 长按 = 重听结算(再说一遍: + 成绩链)
await waitFor(() => game.floor.state === 'YOUR_TURN');
await h.pipe('longpress'); await tick();
await waitFor(() => voicePlays(plays, 'voice/sys_say_again') === 1);
assert(true, '长按重听结算(前缀「再说一遍:」)');
// ======================================================================
section('S4 菜单(00 §5.4):左右滑切换 + 单击确认;回大厅收尾');
// ======================================================================
await waitFor(() => game.floor.state === 'YOUR_TURN' && game.phase === 'over');
await h.pipe('down'); await tick();
assert(game.phase === 'menu', '结算态下滑 = 菜单(阶段 2 解锁;对局中下滑永远是闪避)');
await waitFor(() => voicePlays(plays, 'voice/menu_resume') === 1);
assert(voicePlays(plays, 'voice/menu_main') === 1, '菜单打开句 + 首项报名');
await waitFor(() => game.floor.state === 'YOUR_TURN' && game.floor.waitPoint.conf.id === 'menu');
await h.pipe('right'); await tick();
await waitFor(() => voicePlays(plays, 'voice/menu_lobby') === 1);
assert(true, '右滑 → 下一项报名(menu_lobby)');
await waitFor(() => game.floor.state === 'YOUR_TURN');
await h.pipe('left'); await tick();
await waitFor(() => voicePlays(plays, 'voice/menu_resume') === 2);
assert(true, '左滑 → 上一项报名(回卷 menu_resume)');
// 长按 = 重听菜单说明
await waitFor(() => game.floor.state === 'YOUR_TURN');
await h.pipe('longpress'); await tick();
await waitFor(() => voicePlays(plays, 'voice/menu_main') === 2);
assert(true, '长按重听菜单说明');
// 教学要点项:右滑两次 → menu_help → 单击确认播 help_recap
await waitFor(() => game.floor.state === 'YOUR_TURN');
await h.pipe('right'); await tick();
await h.pipe('right'); await tick();
await waitFor(() => game.floor.state === 'YOUR_TURN');
await h.pipe('tap'); await tick();
await waitFor(() => voicePlays(plays, 'voice/help_recap') === 1);
assert(true, '确认「教学要点」→ help_recap(改版口径:无单击加速/双击护盾)');
// 回大厅收尾(round2 R2:先播确认句再跳转,不落无声死界面)
await waitFor(() => game.floor.state === 'YOUR_TURN');
await h.pipe('left'); await tick(); // → menu_lobby
await waitFor(() => game.floor.state === 'YOUR_TURN');
const lobbyBefore = voicePlays(plays, 'voice/menu_lobby');
await h.pipe('tap'); await tick();
assert(game.phase === 'leaving', '确认「回大厅」→ leaving 收尾态');
await waitFor(() => voicePlays(plays, 'voice/menu_lobby') === lobbyBefore + 1);
assert(true, '先播回大厅确认句');
assert(window.location.href !== '../', '确认句播完前不跳转');
await advance(2600); // 确认句播完 / 看门狗兜底跳转
assert(window.location.href === '../', '确认句播完(或看门狗)后跳转大厅');
window.location.href = ''; // 测试钩子:复位跳转
game._goingHome = false; // 测试钩子:解除收尾标记
// 单击 = 确认当前项(menu_resume 继续飞行 → 新一局;走真实菜单路径重开)
game._openMenu(); // 测试钩子:重新呼出菜单(结算态语义)
await waitFor(() => game.floor.state === 'YOUR_TURN' && game.floor.waitPoint.conf.id === 'menu');
await h.pipe('tap'); await tick();
assert(game.phase === 'playing', '单击确认 menu_resume = 再飞一次');
assert(audio._bgm && audio._bgm.id === 'bgm/engine', '新局 bgm/engine');
// ======================================================================
section('S5 第二局:加速解锁教学 + 冷却拒绝出声 + 断点复述');
// ======================================================================
const t2 = game.runStart;
await waitFor(() => voicePlays(plays, 'voice/feat_boost') === 1, 12000);
assert(voicePlays(plays, 'voice/feat_boost') === 1, '第一局结束 → 第二局 6s 教一句加速(不暂停刷怪)');
assert(game.phase === 'playing', '教学不进独立 phase(04 §3 废止保护期插课)');
// 单击 = 加速(已解锁;boost 音即反馈)。先等教学句播完回 ACTION——
// 语音在播时单击 = 跳过这句(consumed),不会触发加速
await waitFor(() => !audio.isVoicePlaying() && game.floor.state === 'ACTION');
const boostPlays0 = plays.filter((p) => p.id === 'sfx/boost').length;
const conf0 = confirmCount();
await h.pipe('tap'); await tick();
assert(game.boostUntil > clock.now, '单击 → 加速生效(runs>=1 解锁)');
assert(plays.filter((p) => p.id === 'sfx/boost').length === boostPlays0 + 1, '加速内容音落位');
assert(confirmCount() === conf0 + 1, '加速激活 CONFIRM 震(04 §5)');
// 冷却拒绝必须出声(R5-4)
await h.pipe('tap'); await tick();
await waitFor(() => voicePlays(plays, 'voice/boost_not_ready') === 1);
assert(vibrated('40,100,40'), '冷却拒绝 HEARTBEAT 震(禁止静默)');
await h.pipe('tap'); await tick();
await advance(1500);
assert(voicePlays(plays, 'voice/boost_not_ready') === 1, '冷却拒绝 4s 去抖');
// 护盾未解锁(本会话双击折算)→ 双击走单击路径
await h.pipe('doubletap'); await tick();
assert(game.shieldUpUntil === 0, '双击未识别的会话:双击仍按单击处理(无护盾)');
// 断点复述(§4.12):对局中 stop() 自动存档 → 新 rig 问继续/重开
game.dodges = 7; // 测试钩子(13 号文档允许)
game._saveRun();
game.stop();
const h2 = makeRig();
await h2.audio.unlock();
h2.game.start(); await tick();
assert(voicePlays(h2.plays, 'voice/resume_ask') === 1, '回来先问:继续上次还是重开(§4.12)');
assert(h2.game.phase === 'reopen', 'reopen 态');
await waitFor(() => h2.game.floor.state === 'YOUR_TURN');
await h2.pipe('tap'); await tick();
assert(h2.game.phase === 'playing', '单击 = 继续上次航程');
await waitFor(() => voicePlays(h2.plays, 'voice/resume_recap') === 1);
assert(true, '续玩先播复述句(你在航道上。躲开呼啸。向声音那边滑。)');
assert(h2.game.dodges === 7, '断点成绩恢复(dodges=7)');
// count-in:恢复后前 3 颗裸飞陨石保持 4.0s 慢速(再瞄准,不暴毙)
const spawn2 = h2.game._spawnObstacle.bind(h2.game);
h2.game._spawnObstacle = () => {};
h2.game.protectionUntil = clock.now - 1000; // 测试钩子:强制视为保护期已过
h2.game._protectAnnounced = true;
for (let i = 1; i <= 3; i++) {
randQueue.push(0);
h2.game.obstacles.clear(); h2.game.swipes = [];
const tR = clock.now;
spawn2();
const obR = [...h2.game.obstacles].at(-1);
assert(obR.impactAt - tR === 4000, `恢复 count-in:第 ${i} 颗慢速 4.0s(round2 R2 机制复用)`);
}
h2.game.stop();
// ======================================================================
section('S6 看门狗:屏蔽 voice 资产不卡死(Floor 哨兵推进链)');
// ======================================================================
freshStorage();
missingAssets.add('voice/drill_left');
const h3 = makeRig();
await h3.audio.unlock();
h3.game.start(); await tick();
assert(voicePlays(h3.plays, 'voice/drill_left') === 1, '指令尝试起播(记录 play 调用)');
await advance(3600); // Floor 资产缺失哨兵 3s 宽限后推进链
assert(h3.game.drillOb && h3.game.drillOb.dir === 'left', '资产缺失不卡死:哨兵推进到 spawn');
h3.game.stop();
missingAssets.clear();
// ======================================================================
section('S7 乱点机器人(§6.2 指标)');
// ======================================================================
freshStorage();
const rb = makeRig();
const { game: rg, audio: raudio } = rb;
await raudio.unlock();
let seed = 42;
const rnd = () => { seed = (seed * 1103515245 + 12345) & 0x7fffffff; return seed / 0x7fffffff; };
const gestures = [];
const bot = async (g) => {
const tokBefore = raudio._voice;
const playingId = rb.voiceStarts.filter((v) => v.t <= clock.now).at(-1)?.id || null;
const critical = !!(rg.floor._chain && rg.floor._chain.critical);
const ge = { g, t: clock.now, tokBefore, playingId, critical };
gestures.push(ge);
rb.pipe(g);
await tick();
await advance(150);
// 打断失败:非 critical、非 dir 呼喊的句子在手势 150ms 后仍是同一 token
if (ge.tokBefore && raudio._voice === ge.tokBefore && !ge.critical
&& !(ge.playingId && ge.playingId.startsWith('voice/dir_'))) {
ge.interruptFail = true;
}
};
const wt = (ms) => advance(ms);
rg.start(); await tick();
const t0b = clock.now;
// 确定性首拍:等第一颗演练陨石(等待点)→ 同向滑开 = 第一次成功(≤10s)
await waitFor(() => rg.drillOb, 15000);
assert(rg.drillOb && clock.now - t0b <= 10000, `第一次成功 ≤10s(实际 ${clock.now - t0b}ms)`);
await bot(rg.drillOb.dir);
// 乱点直到进入对局(急躁新手:跳句、连点、乱滑)
let guard = 0;
while (rg.phase !== 'playing' && guard++ < 260) {
const roll = rnd();
if (roll < 0.35) { await bot('tap'); await wt(900 + rnd() * 1200); }
else if (roll < 0.55) {
await bot('tap'); await wt(150);
await bot('tap'); await wt(150);
if (rnd() < 0.5) { await bot('tap'); await wt(500); }
} else {
await bot(['up', 'down', 'left', 'right'][Math.floor(rnd() * 4)]);
await wt(700 + rnd() * 1300);
}
}
assert(rg.phase === 'playing', `乱点也能走完演练进对局(${gestures.length} 次手势)`);
// 对局乱点 28s(保护期内:乱滑全挥空、乱点全 invalid,都有声)
const tPlay = clock.now;
guard = 0;
while (clock.now - tPlay < 28000 && rg.phase === 'playing' && guard++ < 200) {
const roll = rnd();
if (roll < 0.3) { await bot('tap'); await wt(800 + rnd() * 1500); }
else if (roll < 0.5) { await bot('tap'); await wt(120); await bot('tap'); await wt(600); }
else {
await bot(['up', 'down', 'left', 'right'][Math.floor(rnd() * 4)]);
await wt(600 + rnd() * 1200);
}
}
rg.stop();
// 指标 1:无声操作 = 0(每个手势 100ms 内有回执或内容音)
let silentOps = 0;
for (const ge of gestures) {
const receipt = rb.receiptLog.some((r) => r.t >= ge.t && r.t <= ge.t + 100);
const content = rb.plays.some((p) => p.t >= ge.t && p.t <= ge.t + 100);
if (!receipt && !content) silentOps++;
}
assert(silentOps === 0, `无声操作 = 0(实际 ${silentOps}/${gestures.length})`);
// 指标 2:打断失败 = 0(手势 150ms 后同一 voice token 不应仍在播;critical/呼喊豁免)
const interruptFails = gestures.filter((ge) => ge.interruptFail).length;
assert(interruptFails === 0, `打断失败 = 0(实际 ${interruptFails})`);
// 指标 3:被动原句重复 = 0(30s 内同一 id 原样重播且无「再说一遍:」前缀;
// dir 呼喊是玩法内容音(同 alarm),不属被动提示句,不计)
let passiveRepeats = 0;
const repeatIds = new Map();
const lastPlay = new Map();
for (const p of rb.plays) {
if (!p.id.startsWith('voice/') || p.id.startsWith('voice/dir_')) continue;
const sayAgainAt = rb.plays.some((q) => q.id === 'voice/sys_say_again' && q.t <= p.t && p.t - q.t < 2500);
if (lastPlay.has(p.id) && p.t - lastPlay.get(p.id) < 30000 && !sayAgainAt) {
passiveRepeats++;
repeatIds.set(p.id, (repeatIds.get(p.id) || 0) + 1);
}
lastPlay.set(p.id, p.t);
}
if (passiveRepeats) {
console.log(' 重复明细:', [...repeatIds.entries()].map(([k, v]) => `${k}×${v}`).join(' '));
}
assert(passiveRepeats === 0, `被动原句重复 = 0(实际 ${passiveRepeats})`);
// ======================================================================
section('S8 资产引用校验(26 号改造新增 id 白名单 = 待生成资产)');
// ======================================================================
const src = readFileSync(path.join(GAME_DIR, 'game.js'), 'utf8')
.replace(/\/\*[\s\S]*?\*\//g, '') // 去块注释
.replace(/\/\/[^\n]*/g, ''); // 去行注释
const ids = new Set();
for (const m of src.matchAll(/['"`]((?:bgm|sfx|voice)\/[A-Za-z0-9_]+)['"`]/g)) {
if (!m[1].endsWith('_')) ids.add(m[1]); // 滤掉模板前缀(voice/protect_ 等)
}
for (const d of ['left', 'right', 'up', 'down']) {
ids.add(`voice/protect_${d}`);
ids.add(`voice/hit_${d}`);
ids.add(`voice/dir_${d}`);
ids.add(`voice/d_short_${d}`);
}
const missing = [];
const pendingSeen = new Set();
for (const id of ids) {
const name = id.slice(6);
if (id.startsWith('voice/')) {
if (PENDING_NEW.has(name)) { pendingSeen.add(name); continue; }
for (const g of ['female', 'male']) {
for (const ext of ['ogg', 'm4a']) {
const rel = `audio/voice/${g}/${name}.${ext}`;
if (!existsSync(path.join(GAME_DIR, rel))) missing.push(rel);
}
}
} else {
for (const ext of ['ogg', 'm4a']) {
const rel = `audio/${id}.${ext}`;
if (!existsSync(path.join(GAME_DIR, rel))) missing.push(rel);
}
}
}
console.log(` 引用音频 id ${ids.size} 个(其中待生成新增 ${pendingSeen.size} 个)`);
assert(missing.length === 0, '存量资产零缺失' + (missing.length ? `(缺:${missing.join(', ')})` : ''));
assert([...PENDING_NEW].every((n) => pendingSeen.has(n)),
`白名单与 game.js 引用一致(多出:${[...PENDING_NEW].filter((n) => !pendingSeen.has(n)).join(', ') || '无'})`);
} catch (e) {
failed++;
console.error('\n未捕获异常(§7.2 失败):', e);
}
console.log(`\n========== 结果: ${passed} 通过, ${failed} 失败 ==========`);
process.exit(failed ? 1 : 0);