Files
SoundGame/web/space/test/playtest.mjs
T
xsl 566241e05d feat(platform/P1): 配对码跨设备 + 认领打磨 + 键盘语音双套资产(两 agent 并行 + 集成收口)
配对码与认领(B):
- POST /api/pair/start|join:6 位码 10 分钟单次有效、仅存内存零泄露面、5 连错 per-IP 封锁 10 分钟;join=新设备档案按桶 LWW 并入老档案+token 改绑(复用 claim-merge);日志脱敏
- 大厅:生成配对码(speechSynthesis 逐位朗读+aria-live+重听)/从其他设备转入(one-time-code 输入框)/从本机移除/401 过期自愈引导/aria 补全/玩家名可听化
- api-smoke 12/12(配对全流程/频控/封锁/过期自愈/移除/digitsToCn)+ CDP 真浏览器 E2E 22/22

键盘语音双套资产(A,路线 a):
- 164 变体句(8 游戏 grep 实测,超出调研预估 153:含轻触/点两下/按住变体)×2 性别=328 生成 656 文件 36.9 分钟;触屏主资产零字节改动(PCM 逐样本比对后还原)
- 引擎 _candidates 三跳路由 key→gender→平铺(inputMode 跟随);缓存键 mode|gender|id;engine-input 新增 K9 路由断言(72 条)
- check_all_assets 增 key 列(两性别一致且 ⊆ 主集)8 游戏全绿;voice_text.json 增 __key__ 表;live control.mjs key 会话对照键盘措辞验收(集成)
- voice-ids 文档:8 游戏键盘变体小节由 KEY_LINES 程序化生成(与台词表逐字断言一致);补齐 storyline/escape/simon 三个历史缺失文档;design README 增 new8 索引
- mock 适配:playtest persona 加 maxTouchPoints=1 保持触屏链断言语义零改动

回归:12 套件全绿(8 playtest + guide 122 + engine-voice 19 + engine-input 72 + api-smoke 12/12)
同批入库:批次 2/3 已完稿设计文档(17-abyss/18-detective/19-cipher 全套,20-22 在途)
2026-09-10 00:43:23 +08:00

718 lines
36 KiB
JavaScript
Raw 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)无头试玩测试 —— 覆盖 docs/design/13-playtest-criteria.md §5(space 关键路径
* + 边界断言)与 §7(全局通用断言),以及本轮改动(状态 BGM / skipVoice / skip_hint)、
* §11 round4-live 修复(方向呼喊提前到 spawn / 闪避窗口=呼啸全程 / 护盾 15s+不可用应答 /
* 保护期断崖过渡 / hit_last 呼喊接力 / feat 教学仅首局),以及 round2 R2 修复
* (下滑开菜单冷静期防误暂停 / 暂停恢复缓冲 / 回大厅收尾流程 / 警告链呼喊抑制+坠毁掐断)。
*
* 用法:tools/node/bin/node web/space/test/playtest.mjs
*
* 设计:
* - 真实加载 engine/{audio,settings,haptics,game}.js 与 vendor/soundtouch.js(纯 JS),
* 只 mock 浏览器环境:window/document/navigator/localStorage/fetch/AudioContext。
* - fetch 映射到 web/space/audio/ 真实文件系统:资产缺失 → ok:false(顺带做资产校验)。
* - decodeAudioData 返回带 duration 的假 buffer(voice 1.0s / bgm 60s / sfx 0.5s);
* ScriptProcessorNode 永不触发 onaudioprocess,voice 自然结束由引擎内部定时器驱动。
* - 虚拟时钟:setTimeout/clearTimeout/performance.now 全部替换,测试零真实等待。
* - 手势直接注入 game.onGesture(引擎判定路径之外的稳定注入点,13 号文档允许);
* 刷怪方向用 Math.random 队列控制,陨石投放/快进用游戏实例钩子(文档明示允许)。
*/
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)), '..');
// ---------------- 虚拟时钟 ----------------
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,语音走触屏措辞主资产(键盘路由由 engine-input K9 专测)
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(),
};
}
Object.defineProperty(globalThis, 'localStorage', {
value: makeLocalStorage(), 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_good'。 */
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/');
if (missingAssets.has(id) || !existsSync(path.join(GAME_DIR, u))) {
fetchLog.push({ url: u, opts, t: clock.now, ok: false });
return { ok: false, status: 404, arrayBuffer: async () => new ArrayBuffer(0) };
}
fetchLog.push({ url: u, opts, t: clock.now, ok: true });
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 () => ({}) };
};
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 }; }
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 { SpaceGame } = await import('../game.js');
// 向导层关闭:回归测试只验证纯游戏逻辑(guide='full' 的行为由 harness/guide.test.mjs 覆盖)
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 链是 async 的)。 */
const tick = async (n = 10) => { for (let i = 0; i < n; i++) await new Promise((r) => setImmediate(r)); };
async function advance(ms) { clock.advance(ms); await tick(); }
function makeHarness() {
const audio = new WebAudioEngine('audio/');
const plays = [], skips = [], voiceStarts = [];
const origPlay = audio.play.bind(audio);
audio.play = (id, opts) => { plays.push({ id, opts: opts || {}, t: clock.now }); return origPlay(id, opts); };
// _playVoice 记录 voice 实际起播(queue 排队件由引擎内部接力,不经 play() 调用日志)
const origPlayVoice = audio._playVoice.bind(audio);
audio._playVoice = (id, opts) => { voiceStarts.push({ id, t: clock.now }); return origPlayVoice(id, opts); };
const origSkip = audio.skipVoice.bind(audio);
audio.skipVoice = () => { skips.push(clock.now); return origSkip(); };
const game = new SpaceGame(audio, {});
return { audio, game, plays, skips, voiceStarts };
}
const voices = (plays) => plays.filter((p) => p.id.startsWith('voice/')).map((p) => p.id);
const lastVoice = (plays) => voices(plays).at(-1);
const vibrated = (pat) => vibrations.some((v) => Array.isArray(v) && v.join() === pat);
try {
// ======================================================================
section('S1 关键路径:演练 → 保护期 → 分批引入 → 坠毁结算 → 重开');
// ======================================================================
const h = makeHarness();
const { game, audio, plays, skips } = h;
// -- §7.1 解锁前无音频播放 --
audio.play('sfx/dodge');
assert(fetchLog.length === 0, '音频解锁前 play 不加载不播放(§7.1)');
await audio.unlock();
game.start();
await tick();
assert(game.phase === 'drill', '首局进入演练 phase');
assert(lastVoice(plays) === 'voice/drill_welcome', 'D0 drill_welcome 开播');
assert(!!audio._voice, 'voice 层播放中');
// -- ② 演练旁白单击跳过 + ③ skip_hint 插播 --
game.onGesture('tap'); await tick();
assert(skips.length === 1, '演练旁白中单击 = skipVoice()');
assert(lastVoice(plays) === 'voice/skip_hint', 'skip_hint 紧随 drill_welcome 插播(③)');
game.onGesture('tap'); await tick();
assert(lastVoice(plays) === 'voice/drill_rule', '再单击跳到 D0 下一句 drill_rule');
await advance(1300); // drill_rule 播完 → D1 旁白
assert(game.drillIdx === 0 && lastVoice(plays) === 'voice/drill_left', 'D1 旁白 drill_left');
assert(!game.drillOb, '旁白播完前不出现演练陨石(语音先、音效后)');
game.onGesture('tap'); await tick(); // 跳过 drill_left → 立即 spawn
assert(game.drillOb && game.drillOb.dir === 'left', '跳句后 D1 演练陨石(左)出现');
assert(plays.some((p) => p.id === 'sfx/whoosh_left'), 'whoosh_left 方位音');
// -- 边界:演练做错重教 + 失败 2 次带过 --
game.onGesture('right'); await tick();
assert(game.drillFails === 1 && game.drillIdx === 0, 'D1 右滑(应左)→ 同一步重试');
assert(lastVoice(plays) === 'voice/drill_retry_left', 'drill_retry_left 当场重教');
assert(vibrated('60,40,60'), '演练做错 ERROR 震动');
await advance(1300);
assert(game.drillOb && game.drillOb.dir === 'left', '重教后同一颗重来');
game.onGesture('up'); await tick();
assert(game.drillFails === 2, 'D1 连错第 2 次');
assert(lastVoice(plays) === 'voice/drill_moveon', '失败 2 次 drill_moveon 带过');
await advance(1300);
assert(game.drillIdx === 1 && lastVoice(plays) === 'voice/drill_right', '自动进入 D2');
// -- 边界回归:演练等待输入时单击维持原义(算做错) --
await advance(1300); // drill_right 播完 → spawn
assert(game.drillOb && game.drillOb.dir === 'right', 'D2 演练陨石(右)出现');
game.onGesture('tap'); await tick();
assert(game.drillFails === 1 && lastVoice(plays) === 'voice/drill_retry_right',
'演练等待输入时单击维持原义(判错重教),不被跳过逻辑劫持');
await advance(1300);
// D2 做对(good=false → 直接下一步)
game.onGesture('right'); await tick();
assert(game.drillIdx === 2 && lastVoice(plays) === 'voice/drill_up', 'D2 做对进 D3');
// D3 做对(good=true → drill_good 短肯定)
await advance(1300);
assert(game.drillOb && game.drillOb.dir === 'up', 'D3 演练陨石(上)出现');
game.onGesture('up'); await tick();
assert(lastVoice(plays) === 'voice/drill_good', 'D3 做对播 drill_good');
await advance(1300);
assert(game.drillIdx === 3 && lastVoice(plays) === 'voice/drill_down', '进 D4');
// D4 做对 → D5 收尾
await advance(1300);
assert(game.drillOb && game.drillOb.dir === 'down', 'D4 演练陨石(下)出现');
game.onGesture('down'); await tick();
assert(lastVoice(plays) === 'voice/drill_done', 'D5 收尾句');
await advance(1300);
assert(localStorage.getItem('soundgame.space.drillDone') === '1', 'drillDone 落盘(§5.1)');
assert(game.phase === 'playing', 'D5 后进入 playing');
assert(voices(plays).filter((id) => id === 'voice/skip_hint').length === 1,
'skip_hint 全程仅插播一次(③)');
await tick();
assert(audio._bgm && audio._bgm.id === 'bgm/engine', '对局 BGM = bgm/engine');
// -- §5.2 保护期:任其撞不掉血 + 方位归因 --
const t0 = game.runStart;
await advance(1500); // 首颗(左,Math.random 默认 0)
assert(game.obstacles.size >= 1, '保护期首颗陨石刷出');
await advance(4100); // 到撞击点
assert(game.hits === 0, '保护期撞击不掉血(训练护盾吸收)');
assert(lastVoice(plays) === 'voice/protect_left', 'protect_<dir> 与来袭方向一致(左)');
assert(vibrated('30,50,30,50,30'), '保护期吸收 WARNING 震动');
assert(!plays.some((p) => p.t >= t0 && p.id.startsWith('voice/dir_')),
'撞击点落在保护期内的陨石 spawn 不播 dir_<dir>(§11:protect 长句教学优先)');
// -- §5.3 第 20s 单击引入(feat_boost 可跳) --
await advance(t0 + 20000 - clock.now);
assert(game.phase === 'feature' && game.featureKind === 'boost', '第 20s 暂停刷怪教单击');
assert(lastVoice(plays) === 'voice/feat_boost', 'feat_boost 引入句');
const skipsBefore = skips.length;
game.onGesture('tap'); await tick();
assert(skips.length === skipsBefore + 1 && game.phase === 'feature',
'feat_boost 在播时单击 = 跳过本句(不直接触发加速)');
game.onGesture('tap'); await tick();
assert(game.boostUntil > clock.now, '旁白已跳后单击 → boost 生效(§5.3)');
assert(game.phase === 'playing', '分批引入结束恢复 playing');
assert(lastVoice(plays) === 'voice/feat_boost_done', 'feat_boost_done 确认句');
// 加速增益期间的撞击算冲过空隙
game.obstacles.clear();
const obBoost = { dir: 'right', impactAt: clock.now + 100 };
game.obstacles.add(obBoost);
game._after(100, () => game._resolve(obBoost));
await advance(150);
assert(game.dodges === 1 && game.hits === 0, '加速期间撞击 = 冲过空隙(dodge)');
// -- §5.3 第 45s 双击引入 --
await advance(t0 + 45000 - clock.now);
assert(game.phase === 'feature' && game.featureKind === 'shield', '第 45s 教双击护盾');
game.onGesture('doubletap'); await tick();
assert(game.shieldUpUntil > clock.now, '双击 → 护盾生效(§5.3)');
assert(game.shieldReadyAt === clock.now + 15000, '护盾充能 15s 锁定(§11 改口,原 30s)');
assert(game.phase === 'playing', '护盾引入结束恢复 playing');
// 护盾格挡一颗(消耗护盾,不掉血)
game.obstacles.clear();
const obShield = { dir: 'left', impactAt: clock.now + 100 };
game.obstacles.add(obShield);
game._after(100, () => game._resolve(obShield));
await advance(150);
assert(game.hits === 0 && game.shieldUpUntil === 0, '护盾挡下一颗后消耗');
assert(plays.some((p) => p.id === 'sfx/shield_block'), 'shield_block 格挡音');
await advance(1400); // feat_shield_done 播完 → 排队的 shield_block 语音
assert(voices(plays).includes('voice/shield_block'), 'shield_block 排队语音播出');
// -- §11 护盾充能中双击:即时应答 + 4s 去抖(P1 panic 双击零反馈) --
const featDoneCount = () => voices(plays).filter((id) => id === 'voice/feat_shield_done').length;
const featDoneBefore = featDoneCount();
game.onGesture('doubletap'); await tick();
assert(game.shieldUpUntil === 0, '充能中双击不展开护盾');
assert(featDoneCount() === featDoneBefore + 1,
'护盾不可用即时应答:播 feat_shield_done 现成句(§11,不新增语音 id)');
assert(vibrated('40,100,40'), '护盾不可用 HEARTBEAT 震动(与撞击/警报可区分)');
game.onGesture('doubletap'); await tick();
assert(featDoneCount() === featDoneBefore + 1, '护盾不可用应答 4s 去抖(连刷不重播)');
// -- 切断自然刷怪(测试钩子),后续手动投放,避免难度曲线干扰 --
const naturalSpawn = game._spawnObstacle.bind(game);
game._spawnObstacle = () => {};
// -- §5.4 保护解除播报 --
await advance(t0 + 60050 - clock.now);
assert(game._protectAnnounced && voices(plays).includes('voice/protect_end'), '60s 保护解除播报');
// -- 边界:护盾充能完毕播报,且只播一次(回归:曾双重调度播两次) --
await advance(game.shieldReadyAt - clock.now + 50);
assert(voices(plays).filter((id) => id === 'voice/shield_ready').length === 1,
'shield_ready 充能完毕播报恰好一次');
assert(vibrated('15'), '护盾就绪 PICKUP 震动');
// -- §5.4/§11 正常闪避 3 颗(同向滑;§11 起窗口=呼啸全程,此处仍在撞击前 800ms 出手) --
// 同循环验证 §11 断崖过渡(裸飞第 1~3 颗逼近固定 4.0s)与方向呼喊时机(spawn 同窗)
let expectDodges = game.dodges;
let bareIdx = 0;
for (const rv of [0, 0.25, 0.5]) { // left / right / up
bareIdx++;
randQueue.push(rv);
game.obstacles.clear(); game.swipes = [];
const tSpawn = clock.now;
naturalSpawn();
const ob = [...game.obstacles].at(-1);
await tick(); // voice 播放安全门:播放经 microtask 落位(虚拟时钟不走,t 仍 = tSpawn)
assert(ob.impactAt - tSpawn === 4000,
`断崖过渡:裸飞第 ${bareIdx} 颗逼近 4.0s 不收缩(§11)`);
assert(plays.some((p) => p.id === `voice/dir_${ob.dir}` && p.t === tSpawn),
`方向呼喊 dir_${ob.dir} 与呼啸同窗(spawn 当刻,§11)`);
await advance(ob.impactAt - clock.now - 800); // 逼近到撞击前 800ms
game.onGesture(ob.dir); // 判定窗口内同向滑
await advance(900); // 过撞击点
expectDodges++;
assert(game.dodges === expectDodges, `同向滑闪避成功(${ob.dir},dodges=${expectDodges})`);
assert(game.hits === 0, '同向闪避不掉血');
assert(plays.filter((p) => p.id === `voice/dir_${ob.dir}` && p.t >= tSpawn).length === 1,
'撞击前 500ms loud 警报档不再重复方向呼喊(§11)');
}
// -- §11 早滑不再被吞:呼啸响起立刻滑(距撞击 >1.5s)也判躲开(P1 核心回归) --
randQueue.push(0.75); // down
game.obstacles.clear(); game.swipes = [];
const tEarly = clock.now;
naturalSpawn();
const obEarly = [...game.obstacles].at(-1);
assert(obEarly.impactAt - tEarly < 4000, '断崖过渡:裸飞第 4 颗起接入难度曲线(<4.0s,§11)');
await advance(200); // 呼啸响起 0.2s 内立刻同向滑(旧版 1.5s 窗口之外,必被吞的位置)
game.onGesture('down');
await advance(obEarly.impactAt - clock.now + 100); // 过撞击点
expectDodges++;
assert(game.dodges === expectDodges && game.hits === 0,
'早滑(spawn+0.2s,距撞击 >1.5s)判定躲开(§11 窗口=呼啸全程)');
// -- 边界:反向滑 ≠ dodge(顺便作为第 1 次实打实撞击) --
randQueue.push(0); // left
game.obstacles.clear(); game.swipes = [];
naturalSpawn();
const obH1 = [...game.obstacles].at(-1);
game.onGesture('right'); // 反向
await advance(obH1.impactAt - clock.now + 50);
assert(game.hits === 1, '反向滑不判闪避,撞击 hits=1');
assert(lastVoice(plays) === 'voice/hit_left', 'hit_<dir> 归因方向与来袭一致');
assert(vibrated('80'), '受击 HIT 震动');
// -- ② 对局归因语音可跳 --
const skipsB2 = skips.length;
game.onGesture('tap'); await tick();
assert(skips.length === skipsB2 + 1, '归因语音在播时单击 = skipVoice()');
// -- 边界/§11+round2 R2:第 2 次被撞追加 hit_last;呼喊接力保留 + 接播期间呼喊抑制 --
randQueue.push(0.25); // right
game.obstacles.clear(); game.swipes = [];
naturalSpawn();
const obH2 = [...game.obstacles].at(-1);
await advance(obH2.impactAt - clock.now + 50);
assert(game.hits === 2 && game.phase === 'playing', 'hits===2 不坠毁');
assert(h.voiceStarts.at(-1).id === 'voice/hit_right', '归因 hit_right 开播');
assert(game._hitLastPending === true, 'hit_last 挂起待接播(§11 呼喊接力)');
randQueue.push(0); // left:刷下一颗裸飞陨石,其 spawn 呼喊顶断归因句(生存优先级保留)
naturalSpawn();
const obNext = [...game.obstacles].at(-1);
await tick();
assert(h.voiceStarts.at(-1).id === 'voice/dir_left', '下一颗 spawn 呼喊顶断归因(生存优先级)');
await advance(1300); // 呼喊播完 → onComplete 接力 → hit_last 自动接播
assert(h.voiceStarts.at(-1).id === 'voice/hit_last',
'归因被呼喊顶断后 hit_last 仍接播(§11:最后一命不再被静默吞掉)');
assert(game._hitLastPending === false, 'hit_last 接播后挂起标记清除');
assert(game._hitLastPlaying === true, 'hit_last 在播标记置位(round2 R2)');
// round2 R2:hit_last 在播期间新刷陨石的 spawn 呼喊被抑制(警告播完前不被顶断)
const shoutMark2 = h.voiceStarts.length;
randQueue.push(0.5); // up
naturalSpawn();
const obNext2 = [...game.obstacles].at(-1);
await tick();
assert(!h.voiceStarts.slice(shoutMark2).some((v) => v.id === 'voice/dir_up'),
'hit_last 在播期间 spawn 不播方向呼喊(round2 R2:警告播完前不被顶断)');
await advance(1300); // hit_last 播完 → 呼喊抑制解除
assert(game._hitLastPlaying === false, 'hit_last 播完在播标记清除');
randQueue.push(0.5); // up:链后再刷陨石,方向呼喊恢复
naturalSpawn();
await tick();
assert(h.voiceStarts.at(-1).id === 'voice/dir_up', '警告链结束后方向呼喊恢复(round2 R2)');
game.obstacles.delete(obNext);
game.obstacles.delete(obNext2);
game.obstacles.clear(); game.swipes = [];
// -- 边界:里程碑 dodges=10 播 ms_10 --
game.dodges = 9; // 13 号文档允许的测试钩子
randQueue.push(0.5); // up
game.obstacles.clear(); game.swipes = [];
naturalSpawn();
const obM = [...game.obstacles].at(-1);
await advance(obM.impactAt - clock.now - 800);
game.onGesture('up');
await advance(900);
assert(game.dodges === 10, 'dodges=10');
assert(voices(plays).includes('voice/ms_10'), '里程碑 ms_10 播报');
assert(vibrated('20'), '里程碑 CONFIRM 震动');
await advance(1300);
// -- §5.4 第 3 次撞击坠毁(round2 R2 B4:警告链在播时坠毁即掐断,结算不叠播) --
randQueue.push(0.75); // down
game.obstacles.clear(); game.swipes = [];
naturalSpawn();
const obH3 = [...game.obstacles].at(-1);
const crashAt = obH3.impactAt;
await advance(obH3.impactAt - clock.now - 500); // 撞击前 500ms:重挂"最后一命"警告链
game._hitLastPending = true; // 测试钩子:模拟第 2 撞后归因刚播完、警告待接播
game._drainHitLast(); await tick();
assert(h.voiceStarts.at(-1).id === 'voice/hit_last' && !!audio._voice, '坠毁前 hit_last 在播');
await advance(600); // 过撞击点 → hits===3 → 坠毁
assert(game.hits === 3 && game.phase === 'over', 'hits===3 才坠毁 gameover');
assert(!audio._voice, '坠毁即掐断警告链:hit_last 不再与结算叠播(round2 R2 B4)');
assert(plays.some((p) => p.id === 'sfx/crash'), '坠毁音 crash');
assert(audio._bgm === null, '坠毁即 stopBgm(结算前先静)');
assert(vibrated('60,40,60'), '坠毁 ERROR 震动');
await advance(350);
assert(vibrations.filter((v) => Array.isArray(v) && v.join() === '80').length >= 2, '坠毁追加 HIT 震动');
// -- §5.5 结算分档 + 状态 BGM(10 号文档 §6) --
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';
const expectDodge = game.dodges < 5 ? 'voice/dodge_1' : game.dodges < 15 ? 'voice/dodge_2'
: game.dodges < 50 ? 'voice/dodge_3' : 'voice/dodge_4';
await advance(1300); // 爆炸声 1.2s 后开口
assert(lastVoice(plays) === expectTime, `结算时间段句 ${expectTime}(存活 ${aliveSec}s)`);
game.onGesture('tap'); await tick(); // 跳过时间段句
assert(lastVoice(plays) === 'voice/new_record', '结算句可跳;首局必破纪录 new_record');
await advance(1300);
assert(lastVoice(plays) === expectDodge, `结算躲避段句 ${expectDodge}(dodges=${game.dodges})`);
await advance(1300);
assert(lastVoice(plays) === 'voice/restart_hint', 'restart_hint 收尾');
await advance(1300); // 链播完
assert(plays.some((p) => p.id === 'bgm/menu' && p.opts.fadeMs === 800),
'结算语音链播完淡入 bgm/menu 800ms(10 号文档 §6)');
await tick();
assert(audio._bgm && audio._bgm.id === 'bgm/menu', '结算态 BGM = bgm/menu');
assert(vibrated('100'), '破纪录 SUCCESS 震动');
// -- §5.5 单击重开 --
game.onGesture('tap'); await tick();
assert(game.phase === 'playing' && game.hits === 0 && game.dodges === 0, '结算态单击重开新局');
assert(audio._bgm && audio._bgm.id === 'bgm/engine', '重开淡回 bgm/engine');
// -- §11 feat 教学仅首局:第 2 局过 20s/45s 不进 feature、不重播,机制开局即用 --
const featMark = plays.length;
await advance(20050); // 过单击引入时刻(保护期内陨石不伤血)
assert(game.phase === 'playing', '第 2 局过 20s 不进 feature phase(§11)');
game.onGesture('tap'); await tick(); // 无旁白在播 → 加速直接生效
assert(game.boostUntil > clock.now, '第 2 局单击加速开局即用(§11)');
game.onGesture('doubletap'); await tick();
assert(game.shieldUpUntil > clock.now, '第 2 局双击护盾开局即用(§11)');
await advance(25050); // 过双击引入时刻
assert(game.phase === 'playing', '第 2 局过 45s 仍不进 feature phase(§11)');
assert(!plays.slice(featMark).some((p) => p.id === 'voice/feat_boost' || p.id === 'voice/feat_shield'),
'第 2 局 feat_boost/feat_shield 不再重播(§11)');
// ======================================================================
section('S2 菜单:状态 BGM 切换 + 下滑折中 + 全球约定(§7.5)');
// ======================================================================
game.obstacles.clear(); game.swipes = [];
game._lastResolveAt = -Infinity; // 测试钩子:跳过开菜单冷静期(round2 R2)
game.onGesture('down'); await tick();
assert(game.phase === 'menu', '无悬置陨石且过冷静期时下滑 = 菜单(下滑折中)');
assert(plays.some((p) => p.id === 'bgm/menu' && p.opts.fadeMs === 300),
'进菜单 crossfade 300ms 切 bgm/menu');
await tick();
assert(audio._bgm && audio._bgm.id === 'bgm/menu', '菜单态 BGM = bgm/menu');
game.onGesture('tap'); await tick();
assert(lastVoice(plays) === 'voice/menu_lobby', '菜单单击循环 → menu_lobby');
game.onGesture('tap'); await tick();
assert(lastVoice(plays) === 'voice/menu_help', '菜单单击循环 → menu_help');
game.onGesture('tap'); await tick();
assert(lastVoice(plays) === 'voice/menu_resume', '菜单单击循环回卷 → menu_resume');
game.onGesture('up'); await tick();
assert(lastVoice(plays) === 'voice/help_recap', '菜单上滑 = 帮助');
game.onGesture('doubletap'); await tick(); // _menuIdx=0 → 恢复
assert(game.phase === 'playing', '菜单双击确认 menu_resume = 回对局');
assert(plays.some((p) => p.id === 'bgm/engine' && p.opts.fadeMs === 500),
'回对局淡回 bgm/engine 500ms');
await tick();
assert(audio._bgm && audio._bgm.id === 'bgm/engine', '对局态 BGM 恢复 engine');
// -- round2 R2 恢复缓冲:恢复后前 3 颗裸飞陨石回 4.0s 慢节奏(软重启,不再 3 秒暴毙) --
game.protectionUntil = clock.now - 1000; // 测试钩子:强制视为保护期已过
game._protectAnnounced = true; // 不再补播 protect_end
for (let i = 1; i <= 3; i++) {
randQueue.push(0); // left
game.obstacles.clear(); game.swipes = [];
const tR = clock.now;
naturalSpawn();
const obR = [...game.obstacles].at(-1);
assert(obR.impactAt - tR === 4000,
`恢复缓冲:暂停恢复后第 ${i} 颗裸飞陨石逼近 4.0s 不收缩(round2 R2)`);
}
randQueue.push(0);
game.obstacles.clear(); game.swipes = [];
const tR4 = clock.now;
naturalSpawn();
const obR4 = [...game.obstacles].at(-1);
assert(obR4.impactAt - tR4 < 4000, '恢复缓冲:第 4 颗起回到难度曲线(round2 R2)');
game.obstacles.clear(); game.swipes = [];
// -- round2 R2 误暂停守卫:判定后冷静期内的下滑绝不开菜单;过冷静期保留旧折中 --
game._lastResolveAt = clock.now; // 测试钩子:模拟"刚判定完(撞/躲)"
game.onGesture('down'); await tick();
assert(game.phase === 'playing', '判定后 800ms 冷静期内下滑不开菜单(round2 R2 误暂停修复)');
game._lastResolveAt = -Infinity;
randQueue.push(0); // left:非下方陨石悬置中(过冷静期保留旧折中,仍可开菜单)
naturalSpawn();
await tick();
game.onGesture('down'); await tick();
assert(game.phase === 'menu', '非下方陨石悬置且过冷静期下滑 = 菜单(旧折中保留,round2 R2)');
game._closeMenu(); await tick(); // 恢复对局继续后续用例
game.obstacles.clear(); game.swipes = [];
// 下滑折中另一侧:有下方陨石时下滑 = 闪避
randQueue.push(0.75); // down
game.obstacles.clear(); game.swipes = [];
naturalSpawn();
const obD = [...game.obstacles].at(-1);
assert(obD.dir === 'down', '投放下方陨石');
await advance(obD.impactAt - clock.now - 800);
game.onGesture('down');
assert(game.phase === 'playing', '有下方陨石时下滑 = 闪避,不开菜单');
await advance(900);
assert(game.dodges === 1, '下滑闪避生效 dodges=1');
// 菜单"回大厅"(round2 R2 收尾流程:leaving 态 + 确认句 → 跳转,不再落无声死界面)
game.obstacles.clear(); game.swipes = [];
game._lastResolveAt = -Infinity; // 测试钩子:跳过开菜单冷静期
game.onGesture('down'); await tick();
assert(game.phase === 'menu', '过冷静期后下滑再开菜单(回大厅前置)');
game.onGesture('tap'); await tick(); // → menu_lobby
game.onGesture('doubletap'); await tick();
assert(game.phase === 'leaving', '回大厅确认中:leaving 收尾态(round2 R2)');
assert(lastVoice(plays) === 'voice/menu_lobby', '先播回大厅确认句再跳转(round2 R2)');
assert(window.location.href !== '../', '确认句播完前不跳转(round2 R2)');
game.onGesture('left'); await tick(); // leaving 中滑动不再受理
assert(game.phase === 'leaving', 'leaving 中滑动手势被忽略(round2 R2)');
game.onGesture('tap'); await tick(); // leaving 中单击 = 跳过确认句 → 立即跳转
assert(window.location.href === '../', '确认句跳过后跳转大厅(round2 R2)');
window.location.href = '';
game.stop();
// ======================================================================
section('S3 二周目:ask 流程 + skip_hint 不重复插播');
// ======================================================================
const h2 = makeHarness();
await h2.audio.unlock();
h2.game.start(); await tick();
assert(h2.game.phase === 'ask' && lastVoice(h2.plays) === 'voice/second_run_ask',
'二周目 second_run_ask 询问');
h2.game.onGesture('up'); await tick(); // 上滑重听演练
assert(h2.game.phase === 'drill' && lastVoice(h2.plays) === 'voice/drill_welcome',
'上滑重听演练');
await advance(1300);
assert(lastVoice(h2.plays) === 'voice/drill_rule', '重听演练 D0 无 skip_hint(仅首局一次)');
assert(!voices(h2.plays).includes('voice/skip_hint'), 'skip_hint 未重复插播');
h2.game.stop();
const h2b = makeHarness();
await h2b.audio.unlock();
h2b.game.start(); await tick();
h2b.game.onGesture('tap'); await tick(); // 单击直接出发
assert(h2b.game.phase === 'playing', '二周目单击直接出发');
h2b.game.stop();
// ======================================================================
section('S4 看门狗:屏蔽 voice 资产不卡死(§7.2 / §5 说明)');
// ======================================================================
globalThis.localStorage = makeLocalStorage(); // 全新存档 → 走演练
missingAssets.add('voice/drill_good');
const h3 = makeHarness();
await h3.audio.unlock();
h3.game.start(); await tick();
for (let i = 0; i < 3; i++) await advance(1300); // D0 三句逐句播完(welcome/skip_hint/rule)
assert(lastVoice(h3.plays) === 'voice/drill_left', '看门狗局:D1 旁白');
await advance(1300); // spawn D1
h3.game.onGesture('left'); await tick(); // 做对 → drill_good(缺失)
assert(fetchLog.some((f) => f.url.includes('drill_good') && !f.ok) && !h3.audio._voice,
'缺失资产 404 且未起播(静默降级)');
await advance(10100); // 看门狗 10s
assert(h3.game.drillIdx === 1 && lastVoice(h3.plays) === 'voice/drill_right',
'语音缺失时看门狗推进到 D2');
await advance(1300);
h3.game.onGesture('right'); await tick(); // D2 做对(无 drill_good)
assert(h3.game.drillIdx === 2, 'D2 无短肯定直接进 D3');
await advance(1300);
h3.game.onGesture('up'); await tick(); // D3 做对 → drill_good(缺失)→ 看门狗
await advance(10100);
assert(h3.game.drillIdx === 3, '看门狗再次推进到 D4');
await advance(1300);
h3.game.onGesture('down'); await tick(); // D4 做对 → drill_done
await advance(1400);
assert(h3.game.phase === 'playing', '屏蔽资产全流程不卡死,进 playing');
h3.game.stop();
missingAssets.clear();
// ======================================================================
section('S5 设置边界(§7.3)');
// ======================================================================
const h4 = makeHarness();
await h4.audio.unlock();
globalThis.localStorage = makeLocalStorage(); // 全新存档 → 走演练(S4 已写过 drillDone)
settings.set({ speed: 1.7 });
h4.game.start(); await tick();
assert(h4.game.phase === 'drill', 'S5 首局进演练');
assert(h4.audio._voice && h4.audio._voice.st.tempo === 1.7, 'speed=1.7 时 voice tempo 生效');
settings.set({ voice: 'male' });
const f0 = fetchLog.length;
h4.game.onGesture('tap'); await tick(); // 跳过 welcome → 下一句走 male 目录
const maleFetches = fetchLog.slice(f0).filter((f) => f.url.includes('/voice/'));
assert(maleFetches.length > 0 && maleFetches.every((f) => f.url.includes('/voice/male/')),
'settings.voice=male 后下一句起用 male 目录');
h4.game.stop();
settings.set({ bgm: 0 });
localStorage.setItem('soundgame.space.drillDone', '1');
const f1 = fetchLog.length;
h4.game.start(); await tick(); // ask
h4.game.onGesture('tap'); await tick(); // → _startRun → play('bgm/engine')
assert(h4.game.phase === 'playing', 'bgm=0 局进入 playing');
assert(!fetchLog.slice(f1).some((f) => f.url.startsWith('audio/bgm/')),
'bgm=0 时不加载 BGM 资产');
assert(!h4.audio._bgm && h4.audio._bgmWanted && h4.audio._bgmWanted.id === 'bgm/engine',
'bgm=0 时只记"想播"意图');
settings.set({ bgm: 1 });
await tick();
assert(h4.audio._bgm && h4.audio._bgm.id === 'bgm/engine', 'bgm 调回非零自动补播最后状态 BGM');
h4.game.stop();
settings.set({ voice: 'female', speed: 1.0 });
// ======================================================================
section('S6 资产引用校验(game.js 引用 id × ogg+m4a × 双性别)');
// ======================================================================
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}`);
}
const missing = [];
for (const id of ids) {
if (id.startsWith('voice/')) {
for (const g of ['female', 'male']) {
for (const ext of ['ogg', 'm4a']) {
const rel = `audio/voice/${g}/${id.slice(6)}.${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} 个`);
assert(missing.length === 0, '资产零缺失' + (missing.length ? `(缺:${missing.join(', ')})` : ''));
} catch (e) {
failed++;
console.error('\n未捕获异常(§7.2 失败):', e);
}
console.log(`\n========== 结果: ${passed} 通过, ${failed} 失败 ==========`);
process.exit(failed ? 1 : 0);