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 置完成。
765 lines
39 KiB
JavaScript
765 lines
39 KiB
JavaScript
/**
|
||
* 盲航(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);
|