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

765 lines
39 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.
/**
* 盲航(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_<dir> 归因 + 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_<dir> 与来袭方向一致(左)');
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_<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);