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

952 lines
51 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.
/**
* 节拍行者(rhythm)鼓手模式无头试玩测试 —— docs/design/24-rhythm-drummer.md §10
* (A1-A28 玩法断言保留适配)+ docs/design/26-apply-rhythm.md(26 号交互断言 +
* §6.2 乱点机器人)。
*
* 方法:mock 浏览器/WebAudio 最小接口(window/document/navigator/localStorage/
* fetch/AudioContext),加载**真实** engine(audio.js/settings.js/haptics.js/
* floor.js/pipeline.js/hints.js/confusion.js/novice.js/receipts.js)与 game.js;
* 手势走 makeInputPipeline 管道(index.html 同款接线),tapcancel/longpressend
* 收尾噪声直通游戏层(immediateTap 撤销账簿);键盘数字键直接调 game._onKeyDown;
* 虚拟时钟驱动 setTimeout/setInterval/performance.now/ctx.currentTime,voice 链
* 由引擎真实结束定时器推进,零真实等待。
*
* 运行(仓库根):tools/node/bin/node web/rhythm/test/playtest.mjs
*/
import { readFileSync, existsSync } from 'node:fs';
// ================= 虚拟时钟 =================
let nowMs = 0; // 全程单调递增(跨场景不归零:hints 台账按 performance.now 去重)
let timerSeq = 1;
const timers = new Map(); // id -> { t, fn, iv }
globalThis.setTimeout = (fn, ms = 0, ...args) => {
const id = timerSeq++;
timers.set(id, { t: nowMs + Math.max(0, ms), fn: () => fn(...args), iv: 0 });
return id;
};
globalThis.setInterval = (fn, ms = 1) => {
const id = timerSeq++;
timers.set(id, { t: nowMs + ms, fn, iv: ms });
return id;
};
globalThis.clearTimeout = (id) => timers.delete(id);
globalThis.clearInterval = (id) => timers.delete(id);
Object.defineProperty(globalThis, 'performance', { value: { now: () => nowMs }, configurable: true });
async function flush() {
for (let i = 0; i < 4; i++) await new Promise((r) => setImmediate(r));
}
/** 推进虚拟时钟 ms 毫秒,沿途触发到期的 timeout/interval 并冲刷微任务。 */
async function advance(ms) {
const target = nowMs + ms;
for (;;) {
await flush();
let nid = -1; let nt = Infinity;
for (const [id, t] of timers) if (t.t < nt) { nt = t.t; nid = id; }
if (nid < 0 || nt > target) break;
const timer = timers.get(nid);
nowMs = nt;
if (timer.iv) timer.t = nt + timer.iv; else timers.delete(nid);
timer.fn();
}
nowMs = target;
await flush();
}
const nowSec = () => nowMs / 1000;
async function runUntil(tSec) { await advance(Math.max(0, tSec * 1000 - nowMs)); }
async function waitFor(cond, label, maxMs = 90000) {
let t = 0;
while (!cond()) {
if (t >= maxMs) throw new Error('waitFor 超时: ' + label + ` (t=${(nowMs / 1000).toFixed(2)}s)`);
await advance(100); t += 100;
}
}
// ================= 浏览器环境 mock =================
const store = new Map();
globalThis.localStorage = {
getItem: (k) => (store.has(k) ? store.get(k) : null),
setItem: (k, v) => store.set(k, String(v)),
removeItem: (k) => store.delete(k),
clear: () => store.clear(),
};
const vibLog = []; // navigator.vibrate 调用记录(震型数组)
Object.defineProperty(globalThis, 'navigator', {
value: { maxTouchPoints: 1, vibrate: (p) => { vibLog.push(p); return true; } },
configurable: true,
});
// 分区坐标系:root rect 300×600 → 列界 100/200,行界 300(zoneOf 三等分/对半)
const RECT = { left: 0, top: 0, width: 300, height: 600 };
const ZC = {
TL: { x: 50, y: 100 }, TC: { x: 150, y: 100 }, TR: { x: 250, y: 100 },
BL: { x: 50, y: 500 }, BC: { x: 150, y: 500 }, BR: { x: 250, y: 500 },
};
const mkEl = () => ({
id: '', className: '', textContent: '', src: '', alt: '', offsetWidth: 0,
style: { setProperty() {} },
getBoundingClientRect: () => ({ ...RECT, right: RECT.width, bottom: RECT.height }),
appendChild() {}, remove() {}, addEventListener() {},
});
globalThis.document = {
createElement: mkEl, getElementById: () => null, body: mkEl(),
};
globalThis.window = {
addEventListener() {}, location: { href: '' }, AudioContext: null, // 下方填充
};
// ================= Web Audio mock =================
const VOICE_DUR = 1.2; // 假 voice 时长(秒),短时长让虚拟时钟跑得快
const BGM_DUR = 64;
const SFX_DUR = 0.3;
const durFor = (url) => (url.includes('/voice/') ? VOICE_DUR : url.includes('/bgm/') ? BGM_DUR : SFX_DUR);
const srcLog = []; // BufferSource.start 记录 { url, when, offset }
class MockParam {
constructor(v = 0) { this.value = v; }
cancelScheduledValues() {}
setValueAtTime(v) { this.value = v; }
linearRampToValueAtTime(v) { this.value = v; }
}
class MockAudioContext {
constructor() { this.state = 'suspended'; this.destination = {}; this.sampleRate = 44100; }
get currentTime() { return nowSec(); }
async resume() { this.state = 'running'; }
createGain() { return { gain: new MockParam(1), connect() {}, disconnect() {} }; }
createOscillator() {
return { type: 'sine', frequency: new MockParam(), connect() {}, start() {}, stop() {} };
}
createStereoPanner() { return { pan: new MockParam(0), connect() {}, disconnect() {} }; }
createDynamicsCompressor() {
return { threshold: new MockParam(), ratio: new MockParam(), attack: new MockParam(), release: new MockParam(), connect() {} };
}
createBufferSource() {
return {
buffer: null, loop: false, onended: null,
connect() {}, disconnect() {},
start(when = 0, offset = 0) { srcLog.push({ url: this.buffer?.__url ?? null, when, offset, at: nowMs }); },
stop() {},
};
}
createScriptProcessor() { return { connect() {}, disconnect() {}, onaudioprocess: null }; }
async decodeAudioData(raw) { return { duration: durFor(raw.__url || ''), __url: raw.__url }; }
}
window.AudioContext = MockAudioContext;
// ================= fetch mock(资产存在性由末尾 fs 校验单独保证) =================
const fetchLog = [];
const putBodies = []; // /api/progress PUT body
const blocked = []; // 子串命中即 404(看门狗测试用)
globalThis.fetch = async (url, opts) => {
fetchLog.push(url);
if (typeof url === 'string' && url.startsWith('/api/')) {
if (url === '/api/progress' && opts && opts.body) putBodies.push(String(opts.body));
return { ok: true, json: async () => ({}) };
}
if (blocked.some((b) => String(url).includes(b))) return { ok: false, status: 404 };
return { ok: true, arrayBuffer: async () => ({ __url: url }) };
};
// ================= 断言框架 =================
let passed = 0; let failed = 0;
function assert(cond, msg) {
if (cond) { passed++; console.log(' ✓', msg); } else { failed++; console.error(' ✗ 失败:', msg); }
}
const uncaught = [];
process.on('uncaughtException', (e) => { uncaught.push(e); process.exitCode = 1; origConsoleError('UNCAUGHT', e); });
process.on('unhandledRejection', (e) => { uncaught.push(e); process.exitCode = 1; origConsoleError('UNHANDLED', e); });
const origConsoleError = console.error.bind(console);
let consoleErrs = 0; let consoleWarns = 0;
console.error = (...a) => { consoleErrs++; origConsoleError(...a); };
console.warn = (...a) => { consoleWarns++; };
// ================= 加载真实引擎与游戏 =================
const { WebAudioEngine } = await import('../../engine/audio.js');
const { settings } = await import('../../engine/settings.js');
const { makeInputPipeline } = await import('../../engine/pipeline.js');
const { RhythmGame } = await import('../game.js');
const PROGRESS_KEY = 'soundgame.progress.rhythm';
const AUTH_KEY = 'soundgame.auth';
const GESTURES_KEY = 'soundgame.gestures';
let audio = null; let game = null; let playLog = []; let receiptLog = []; let chimeLog = []; let gestures = 0;
let pipe = null;
async function makeWorld(progress = null, { ladder = null, guide = 'off' } = {}) {
store.clear();
if (progress) store.set(PROGRESS_KEY, JSON.stringify(progress));
if (ladder) store.set(GESTURES_KEY, JSON.stringify(ladder));
settings.set({ guide, voice: 'female', speed: 1.0, bgm: 1.0 });
vibLog.length = 0; srcLog.length = 0; fetchLog.length = 0; blocked.length = 0;
putBodies.length = 0;
audio = new WebAudioEngine('audio/');
playLog = [];
const origPlay = audio.play.bind(audio);
audio.play = (id, opts = {}) => { playLog.push({ id, t: nowMs }); return origPlay(id, opts); };
await audio.unlock();
timers.clear(); // 上一世界的调度泵/看门狗全停(否则旧 game 跨世界继续判谱、触发结算)
game = new RhythmGame(audio, mkEl());
game.floor.bedEnabled = false; // 虚拟时钟下不起等待声(26 号测试约定)
receiptLog = [];
const origR = game.receipts.play.bind(game.receipts);
game.receipts.play = (kind) => { receiptLog.push({ kind, t: nowMs }); return origR(kind); };
chimeLog = [];
const origChime = game.floor.chime.bind(game.floor);
game.floor.chime = () => { chimeLog.push(nowMs); return origChime(); };
const origEarcon = game.floor._playTurnEarcon.bind(game.floor);
game.floor._playTurnEarcon = () => { chimeLog.push(nowMs); return origEarcon(); }; // yieldTurn 直呼内部法
pipe = makeInputPipeline({
floor: game.floor, confusion: game.confusion, receipts: game.receipts,
invalidHints: game.invalidHints, game, ladder: game.ladder, opts: game.PIPELINE_OPTS,
});
gestures = 0;
return game;
}
/** 管道入口(index.html 同款:收尾噪声直通游戏层,其余走管道)。 */
function G(g, info) {
gestures++;
if (g === 'tapcancel' || g === 'longpressend') { game.onGesture(g, info); return; }
return pipe(g, info);
}
const key = (code) => game._onKeyDown({ code, repeat: false, preventDefault() {} });
const voicePlays = () => playLog.filter((p) => p.id.startsWith('voice/')).map((p) => p.id);
const voicePlayed = (id) => voicePlays().includes(id);
const plays = (id) => playLog.filter((p) => p.id === id).length;
const lastVoice = () => { const v = voicePlays(); return v[v.length - 1] || ''; };
const busyReceipts = () => receiptLog.filter((r) => r.kind === 'busy').length;
/** 确定性通过教学(探针摸三区 → 三步跟打全对)。 */
async function passTutorial() {
await waitFor(() => game.phase === 'tut' && game._tutStage === 'probe', '进入摸鼓认区');
G('tap', ZC.TL); G('tap', ZC.TR); G('tap', ZC.BL);
for (let step = 0; step < 3; step++) {
await waitFor(() => game._tutIdx === step && game.phase === 'tut' && game._cues.length > 0
&& game._tutAttempts.length === 0, `T${step + 1} 领奏排程`);
for (const cue of game._cues) {
if (cue.judged) continue;
await runUntil(cue.expected);
G('tap', ZC[cue.zone]);
await flush();
}
await runUntil(game._phaseEnd + 0.2);
await flush();
}
await waitFor(() => game.phase === 'select', '教学完成进选曲');
}
/** 把当前曲目全部按拍点敲完(全 Perfect)。 */
async function playAllPerfect() {
await waitFor(() => game.phase === 'main' && game._cues.length > 0, '曲目排程');
for (const cue of game._cues) {
if (cue.judged) continue;
await runUntil(cue.expected);
G('tap', ZC[cue.zone]);
await flush();
}
await waitFor(() => game.phase === 'end', '曲目结算');
}
// ================= 场景 A:开场(§4.10)与摸鼓探针(A1/A7/A25) =================
console.log('\n[场景 A] 26 号开场:首句 critical 动手 / 摸鼓认区 A1 A7 A25 / 首次成功 ≤10s');
{
await makeWorld();
const tStart = nowMs;
game.start(); await flush();
assert(game.phase === 'intro' && game.floor.speaking && plays('voice/sys_first_tap') === 1,
'0~3s:第一句 = 「点一下屏幕。」(critical 指令句,§4.10)');
{
const busyBefore = busyReceipts();
G('tap', ZC.BC); await flush();
assert(busyReceipts() === busyBefore + 1 && game.floor.speaking,
'开场指令播放中单击 = busy 回执(critical 不被打断,§4.6)');
}
await waitFor(() => game.phase === 'tut' && game._tutStage === 'probe', '指令播完进摸鼓探针');
assert(game.floor.state === 'ACTION', '摸鼓探针 = ACTION(鼓声即状态声,无等待声,A27)');
// —— A1 分区映射:九宫边界逐一断言 ——
for (const [zone, pt] of Object.entries(ZC)) {
assert(game.zoneOf(pt) === zone, `A1 zoneOf(${pt.x},${pt.y}) = ${zone}`);
}
assert(game.zoneOf({ x: 99, y: 299 }) === 'TL', 'A1 左上角边界点归 TL');
assert(game.zoneOf({ x: 100, y: 300 }) === 'BC', 'A1 正中心点归 BC(键盘空格语义)');
assert(game.zoneOf(null) === null && game._zoneFrom(null) === 'BC', 'A1 payload 缺失兜底 BC');
// —— A7 摸鼓认区:每区音色+区名一次;重复摸不报名;≥3 区自动进阶 ——
const dmMark = playLog.length;
G('tap', ZC.TL); await flush();
assert(playLog.slice(dmMark).some((p) => p.id === 'sfx/dm_hat'), 'A7 摸左上播 dm_hat(区音色)');
assert(voicePlayed('voice/zone_tl'), 'A7 区名播报 zone_tl');
assert(nowMs - tStart <= 10000, `A7 第一次成功(第一声鼓)≤10s(实际 ${(nowMs - tStart) / 1000}s,§4.10)`);
const tlCount = plays('voice/zone_tl');
G('tap', ZC.TL); await flush();
assert(plays('voice/zone_tl') === tlCount, 'A7 重复摸同一区不重复报名(每区一次)');
G('tap', ZC.TR); await flush();
assert(plays('voice/zone_tr') === 1, 'A7 第二区(右上)报名');
assert(plays('voice/tut_probe') === 1, 'A7 首摸后「摸到三个不同的地方,我们就开始。」排在区名后');
G('tap', ZC.BL); await flush();
assert(game._probeHeard.size >= 3, 'A7 摸到 3 个不同区');
// —— A25(R1 F2):三连快速摸 → 区名全部播完不被掐断,教学句 queue 其后 ——
await waitFor(() => voicePlayed('voice/tut_z_kick'), 'A25 F2:教学句排在区名链后播');
assert(voicePlays().indexOf('voice/tut_z_kick') > voicePlays().indexOf('voice/zone_bl'),
'A25 F2:教学句在区名之后(链序)');
// —— 8s 无触摸自动进阶(防呆,guide.test G2 路径)——
await waitFor(() => game._tutStage === 'step' && game._cues.length > 0, '自动进阶 T1 领奏');
}
// ================= 场景 B:教学跟打(A9 数拍 / 演出 busy / 等待点 / A8 通过) =================
console.log('\n[场景 B] 教学跟打:A9 数拍 / 演出期 busy 回执 / 跟打等待点 / 三步通过');
{
await makeWorld();
game.start(); await flush();
await waitFor(() => game.phase === 'tut' && game._tutStage === 'probe', '进入探针');
G('tap', ZC.TL); G('tap', ZC.TC); G('tap', ZC.TR); await flush();
await waitFor(() => game._tutStage === 'step' && game._cues.length > 0, 'T1 领奏排程');
// 教学指令(critical)播完 → 数拍+领奏 = BUSY:手势只给 busy 回执(演出不打断)
assert(game.floor.state === 'BUSY' || game.floor.speaking, '数拍/领奏示范 = BUSY(演出)');
{
const sfxMark = playLog.length;
const busyBefore = busyReceipts();
await advance(300); // 数拍/领奏小节内
G('tap', ZC.BC); await flush();
assert(busyReceipts() === busyBefore + 1, '演出期单击 = busy 回执(示范小节/倒数,26 号 §4.1)');
assert(!playLog.slice(sfxMark).some((p) => p.id.startsWith('sfx/')), '演出期点按不出鼓声(不误触演奏)');
G('left'); await flush();
assert(busyReceipts() === busyBefore + 2, '演出期滑动 = busy 回执');
}
// A9 教学侧:数拍 4 记木鱼 click 已入 _sched,且全部早于本步最早领奏音
{
const leadT = Math.min(...game._cues.map((c) => c.expected - 4 * (60 / 90)));
const clicks = game._sched.filter((e) => e.sfx === 'sfx/dm_accent' && e.time < leadT);
assert(clicks.length === 4, `A9 教学数拍恰 4 记排程且早于领奏(实际 ${clicks.length})`);
}
// 应答窗开 = 教学跟打等待点(YOUR_TURN,等待声关)
await waitFor(() => game.floor.state === 'YOUR_TURN' && game.floor.waitPoint
&& game.floor.waitPoint.conf.id === 'tut_0', '应答窗开:教学跟打等待点 tut_0');
assert(chimeLog.length >= 1, '应答窗开有交接提示音(原则 1)');
for (const cue of game._cues) {
if (cue.judged) continue;
await runUntil(cue.expected);
G('tap', ZC[cue.zone]);
await flush();
}
assert(game._tutAttempts.every((a) => a.correct && Math.abs(a.dt) <= 0.35), 'T1 应答全部正确且 Good 窗内');
await runUntil(game._phaseEnd + 0.2); await flush();
await waitFor(() => voicePlayed('voice/tut_ok'), 'T1 通过 → 「对了。」(≤4 字肯定,原则 11)');
assert(game.ladder.stage() >= 1, '第一次成功跟打 → ladder.onGoal() 解锁上滑(阶段 1)');
// T2 / T3
for (let step = 1; step <= 2; step++) {
await waitFor(() => game._tutIdx === step && game._cues.length > 0 && game._tutAttempts.length === 0,
`T${step + 1} 领奏排程`);
for (const cue of game._cues) {
if (cue.judged) continue;
await runUntil(cue.expected);
G('tap', ZC[cue.zone]);
await flush();
}
await runUntil(game._phaseEnd + 0.2); await flush();
}
await waitFor(() => voicePlayed('voice/tut_done'), 'tut_done「很好,演出开始。」');
await waitFor(() => game.phase === 'select', '教学完成进选曲');
assert(game._progress.tutorialDone === true, 'tutorialDone === true');
const saved = JSON.parse(store.get(PROGRESS_KEY) || '{}');
assert(saved.tutorialDone === true, 'localStorage 持久化 tutorialDone');
}
// ================= 场景 C:选曲 → 曲目一全 Perfect → S 级解锁 → 结算导航(A9/A12/A13/A14/A15/A16/A19-26 号) =================
console.log('\n[场景 C] 选曲等待点 / 曲目一全 Perfect / S 级解锁 / 结算承诺段与导航 / 进度同步');
{
await makeWorld();
game.start(); await flush();
await passTutorial();
await waitFor(() => game.floor.state === 'YOUR_TURN' && game.floor.waitPoint
&& game.floor.waitPoint.conf.id === 'select',
'选曲链播完交出 select 等待点(A27:选曲有等待声/等待点)');
assert(voicePlayed('voice/select_hint') && voicePlayed('voice/song1_name'),
'选曲入口:select_hint + 当前曲名');
// 左右滑切曲报名(解锁制:右滑到未解锁曲 → song_locked;30s 台账去重)
await waitFor(() => !game.floor.speaking && game.floor.state === 'YOUR_TURN', '入口播报完(等待声起)');
G('right'); await waitFor(() => game._songIdx === 1 && voicePlayed('voice/song_locked'), '右滑到未解锁曲');
await waitFor(() => game.floor.state === 'YOUR_TURN', '锁定解释播完');
G('right'); await flush(); // 到 song3(同样未解锁):台账去重只给提示音
assert(plays('voice/song_locked') === 1, '重复锁定解释 30s 内不原样重播(原则 9,台账去重)');
assert(chimeLog.length >= 1, '重复路过只给交接提示音');
G('left'); G('left'); await waitFor(() => game._songIdx === 0, '切回曲目一');
await waitFor(() => game.floor.state === 'YOUR_TURN', '导航播报/提示音完');
assert(plays('voice/song1_name') === 1, '切回曲目一不重播曲名(入口已播过,台账去重)');
G('tap'); await waitFor(() => game.phase === 'main', '单击开始曲目一');
assert(game.floor.state === 'ACTION' && !game.floor.waitPoint, '正片 = ACTION、无等待点(A27)');
assert(game.audio._rateBoostEnabled === false, '演奏期豁免长按快进(setRateBoostEnabled(false))');
assert(srcLog.some((s) => s.url && s.url.includes('/bgm/track90')), '曲目一 BGM track90(无鼓版)按网格启动');
// A9 正片数拍:4 记底鼓早于句 1 领奏
{
await runUntil(game._bgmT0 + 4.5 * (60 / 90));
const kicks = srcLog.filter((s) => s.url && s.url.includes('/sfx/inst_kick')
&& s.when >= game._bgmT0 - 1e-6 && s.when < game._bgmT0 + 4 * (60 / 90) - 1e-6);
assert(kicks.length === 4, `A9 数拍小节恰 4 记底鼓(实际 ${kicks.length})`);
const firstLead = Math.min(...game._cues.map((c) => c.expected)) - 4 * (60 / 90);
assert(kicks.every((k) => k.when < firstLead), 'A9 数拍全部早于句 1 领奏');
}
// 首应答窗:sys_turn_teach(每账号一次)+ 交接提示音
await waitFor(() => voicePlayed('voice/sys_turn_teach'), '首个应答窗前「听到这个声音,就是轮到你了。」(原则 2 状态教学)');
assert(game._progress.turnTaught === true, 'turnTaught 持久化(每账号一次)');
await playAllPerfect();
assert(game._stats.perfect === game._cues.length, `A2 曲目一 ${game._cues.length} 音全部 Perfect(perfect=${game._stats.perfect})`);
assert(game._stats.miss === 0 && game._stats.combo === game._cues.length, 'A14 无 Miss 连击贯穿全曲');
assert(vibLog.some((p) => Array.isArray(p) && p[0] === 100), 'A14 16 连击 success 震动');
assert(vibLog.some((p) => Array.isArray(p) && p[0] === 20 && p[1] === 40), 'A14 乐句全 Perfect [20,40,20] 震动');
assert(voicePlayed('voice/mid_song'), 'A15 mid_song 曲中播报');
assert(voicePlayed('voice/tut_fill'), 'A11 曲目一终曲加花前 tut_fill 预告(每账号一次)');
assert(game._progress.tutFillDone === true, 'A11 tutFillDone 持久化');
{
const fillCues = game._cues.filter((c) => c.phrase === game._song.units.length - 1);
assert(fillCues[fillCues.length - 1].zone === 'TR', 'A11 加花尾音 = TR 吊镲');
}
// 结算链:grade → new_best_combo → (解锁教学挂尾)→ restart(critical 承诺段)
assert(game._progress.unlocked === 2, 'A12 unlocked === 2(B 以上解锁下一首)');
await waitFor(() => voicePlayed('voice/grade_s'), '结算链播到 grade_s');
assert(game._grade() === 's' && game._progress.grades.song1 === 's', 'A12 全 Perfect 评级 S');
await waitFor(() => voicePlayed('voice/new_best_combo'), 'A12 结算链播到 new_best_combo');
await waitFor(() => voicePlayed('voice/restart'), '结算承诺段 restart(critical)');
{
const busyBefore = busyReceipts();
G('tap', ZC.BC); await flush();
assert(busyReceipts() === busyBefore + 1 && game.phase === 'end',
'承诺段播放中单击 = busy 回执(A21:不误触重开)');
}
await waitFor(() => game.floor.state === 'YOUR_TURN' && game.floor.waitPoint
&& game.floor.waitPoint.conf.id === 'settle', '结算链播完交出 settle 等待点(不留安静态)');
// A16 进度同步:summary/grades/unlocked/bestCombo
{
store.set(AUTH_KEY, JSON.stringify({ token: 'tk' }));
game._putProgress();
await flush();
const body = JSON.parse(putBodies[putBodies.length - 1] || '{}');
assert(body.game === 'rhythm' && body.data && /已解锁2\/3首/.test(body.data.summary),
'A16 PUT summary 含解锁进度(summary 字段保留)');
assert(body.data.grades && body.data.grades.song1 === 's' && body.data.bestCombo > 0,
'A16 PUT 含 grades 与 bestCombo');
}
// 结算 BGM:menu pad 在语音链末句之后起播(对比本次结算的 restart)
{
await waitFor(() => audio._bgm && audio._bgm.id === 'bgm/menu', '结算 menu pad 淡入');
const menuPlayIdx = playLog.map((p) => p.id).lastIndexOf('bgm/menu');
const restartIdx = playLog.map((p) => p.id).lastIndexOf('voice/restart');
assert(menuPlayIdx > restartIdx && restartIdx >= 0, '结算 menu pad 在语音链末句之后起播');
}
// A13 结算导航:单击=同曲重开;右滑=下一首
{
G('tap'); await waitFor(() => game.phase === 'main', 'A13 结算单击 = 同曲再来一遍');
assert(game._songIdx === 0, 'A13 同曲(song1)');
game._finish(game._runId); await flush();
await waitFor(() => game.phase === 'end', '钩子:曲目一二次结算');
G('right'); await waitFor(() => game.phase === 'main' && game._song.id === 'song2', 'A13 结算右滑 = 下一首');
assert(srcLog.some((s) => s.url && s.url.includes('/bgm/track105')), 'song2 BGM track105 启动');
}
}
// ================= 场景 D:A2/A3/A4/A6 判定窗 + 错鼓 Miss + 自由演奏 + 双击两连击 =================
console.log('\n[场景 D] A2 判定窗 / A3 错鼓 Miss / A4 超时 Miss / A6 双击=两连击 / 自由演奏不判定');
{
await makeWorld({ tutorialDone: true, unlocked: 3, grades: {}, bestCombo: 0 });
game.start(); await flush();
await waitFor(() => game.phase === 'select', '进入选曲');
game._startSong(game._runId, 0);
await waitFor(() => game.phase === 'main' && game._cues.length > 0, 'song1 排程就绪');
// —— A2b Good 窗:|dt|=0.25 → Good ——
const goodCue = game._cues[0];
await runUntil(goodCue.expected + 0.25);
G('tap', ZC[goodCue.zone]); await flush();
assert(goodCue.result === 'good', 'A2 |dt|=0.25 → Good');
// —— A2a Perfect 窗:|dt|=0.10 → Perfect + accent ——
const perfCue = game._cues.find((c) => !c.judged);
{
const accMark = plays('sfx/dm_accent');
await runUntil(perfCue.expected - 0.10);
G('tap', ZC[perfCue.zone]); await flush();
assert(perfCue.result === 'perfect', 'A2 |dt|=0.10 → Perfect');
assert(plays('sfx/dm_accent') === accMark + 1, 'A2 Perfect 叠 accent');
}
// —— A2c 窗口外同区出手 = 自由演奏:出声不判定、不 Miss ——
const freeCue = game._cues.find((c) => !c.judged);
{
const missMark = game._stats.miss;
const sfxMark = playLog.length;
await runUntil(freeCue.expected + 0.40); // > GOOD 0.35
G('tap', ZC[freeCue.zone]); await flush();
assert(freeCue.judged === false, 'A2 窗口外同区出手不判定(音符未判)');
assert(game._stats.miss === missMark, 'A2 自由演奏不计 Miss');
assert(playLog.length > sfxMark, 'A2 自由演奏照样出声');
await runUntil(freeCue.expected + 0.50); await flush();
assert(freeCue.result === 'miss' && freeCue.missKind === 'timeout', 'A4 无人接 → 超时 Miss(GOOD+0.10 死线)');
}
// —— A3 错鼓 Miss:异分区半窗内被敲 → Miss + 闷镲 + combo 清零 ——
const wrongCue = game._cues.find((c) => !c.judged);
{
const wrongZone = wrongCue.zone === 'BC' ? 'BL' : 'BC';
game._stats.combo = 5;
const srcMark = srcLog.length;
const r = G('right'); // A19-26 号:正片左右滑 = 非音符(invalid 回执,不出声)
await runUntil(wrongCue.expected - 0.10); // 半窗内(0.175)
const vibMark = vibLog.length;
G('tap', ZC[wrongZone]); await flush();
assert(r === undefined || true, '(左右滑在正片只给 invalid 回执)');
assert(wrongCue.result === 'miss' && wrongCue.missKind === 'wrong', 'A3 半窗内敲错区 → Miss(错鼓)');
assert(game._stats.combo === 0, 'A3 错鼓 combo 清零');
assert(vibLog.slice(vibMark).some((p) => Array.isArray(p) && p[0] === 60), 'A3 错鼓 error 震动');
assert(srcLog.slice(srcMark).some((s) => s.url && s.url.includes('dm_hat')), 'A3 错鼓闷镲反馈音(dm_hat 降调)');
}
// —— A28 Miss 主反馈是声音:闷镲后无语音堆积(guide off 无 gd_miss)——
assert(!voicePlayed('voice/gd_miss'), 'A28 guide off:Miss 只有闷镲+回执,不插语音');
// —— A6 双击 = 两连击:两下 tap 各按各自拍点出手,doubletap 到达无角色、无撤销 ——
{
const dblCues = game._cues.filter((c) => !c.judged).slice(0, 2);
const judgMark = game._tapJudg.length;
for (const cue of dblCues) {
await runUntil(cue.expected);
G('tap', ZC[cue.zone]); await flush();
}
await advance(250);
const missBeforeDbl = game._stats.miss;
G('doubletap'); await flush(); // 双击到达:新手期折算 tap → 自由演奏,无撤销
assert(dblCues[0].result === 'perfect' && dblCues[1].result === 'perfect', 'A6 两连击各自独立判定(双击不撤销)');
assert(game._tapJudg.length >= judgMark + 2, 'A6 账簿只增不撤(doubletap 不撤账)');
assert(game._stats.miss === missBeforeDbl, 'A6 无额外 Miss(双击序曲不判死)');
}
}
// ================= 场景 E:A5 tapcancel 撤销 + 长按状态查询 + 上滑 =================
console.log('\n[场景 E] A5 tapcancel 撤销序曲 tap 判定 / 长按与上滑 = 状态查询(演奏期不快进)');
{
const cue = game._cues.find((c) => !c.judged);
await runUntil(cue.expected);
G('tap', ZC[cue.zone]); await flush();
assert(cue.result === 'perfect', 'A5 序曲 tap 先认领命中');
const perfBefore = game._stats.perfect;
G('tapcancel', ZC[cue.zone]); await flush();
assert(cue.judged === false && cue.result === null, 'A5 tapcancel 撤销认领(音符回未判定)');
assert(game._stats.perfect === perfBefore - 1, `A5 统计回滚(perfect=${game._stats.perfect})`);
await runUntil(cue.expected + 0.50); await flush();
assert(cue.result === 'miss', 'A5 撤销后真不接 → 超时 Miss 兜底(撤销不是免死)');
// 长按 = 状态查询(≤20 字一句,演奏期不判定音符、不快进)
{
const judgedMark = game._cues.filter((c) => c.judged).length;
const mark = voicePlays().length;
G('longpress'); await flush();
await waitFor(() => voicePlays().length > mark, '长按出一句状态');
const said = voicePlays().slice(mark);
assert(said.some((v) => /^voice\/(st_|rules)/.test(v)), `长按 = 状态查询句(${said[0]},原则 3)`);
assert(game._cues.filter((c) => c.judged).length === judgedMark, '长按不判定任何音符');
G('longpressend'); await flush();
}
// 上滑(阶段 1 已解锁)= 同长按状态查询
{
const mark = voicePlays().length;
G('up'); await flush();
await waitFor(() => voicePlays().length > mark, '上滑出一句状态');
assert(game.floor.state === 'ACTION' || game.floor.speaking, '状态句播完回 ACTION(不留安静态)');
}
}
// ================= 场景 F:A8 教学重教(DDA 降速 + 跳步 + 归因真话律) =================
console.log('\n[场景 F] A8 教学:失败归因(feedback 类)→ 2 败降速 25% → 封顶跳过;归因真话律');
{
await makeWorld(null, { guide: 'full' });
game.start(); await flush();
await waitFor(() => game.phase === 'tut' && game._tutStage === 'probe', 'F 进探针');
G('tap', ZC.TL); G('tap', ZC.TR); G('tap', ZC.BL); await flush();
// —— T1 失败①:敲错区(BL 冒充 BC)——
await waitFor(() => game._tutStage === 'step' && game._cues.length > 0 && game._tutAttempts.length === 0, 'F T1 领奏');
{
const cue = game._cues[0]; // BC
await runUntil(cue.expected - 0.1);
G('tap', ZC.BL); await flush(); // 半窗内错区
await runUntil(game._phaseEnd + 0.2); await flush();
}
assert(game._tutFails === 1, 'F 失败 1 次');
await waitFor(() => voicePlayed('voice/retry_wrong'), 'F 错鼓纠正 retry_wrong(2 秒内归因)');
await waitFor(() => voicePlayed('voice/gd_miss_wrong'), 'F 归因 gd_miss_wrong(feedback 类,guide full)');
await waitFor(() => game._tutAttempts.length === 0 && game._cues.length > 0 && game._phaseEnd < 1e8, 'F T1 重教领奏');
// T1 失败②:整拍不出手 → 连 2 败 → G2 DDA(降速重演重新给机会,_tutFails 归零)
await runUntil(game._phaseEnd + 0.2); await flush();
assert(game._tutSlowUsed === true && game._tutRate === 0.75, 'F G2 DDA:连 2 败触发降速 25%(每教学局 1 次)');
assert(game._tutFails === 0, 'F 降速重演 = 该步重新计机会(fails 归零)');
assert(voicePlayed('voice/gd_slow'), 'F gd_slow「我放慢一些。」');
// 降速重演:跟着慢速领奏做对(步进间隔 = spb/0.75)
await waitFor(() => game._phaseEnd < 1e8 && game._tutAttempts.length === 0, 'F 慢速领奏排程');
for (const cue of game._cues) {
if (cue.judged) continue;
await runUntil(cue.expected);
G('tap', ZC[cue.zone]); await flush();
}
await runUntil(game._phaseEnd + 0.2); await flush();
await waitFor(() => game._tutIdx === 1, 'F 降速重演通过 → 进 T2(tut_ok+指令链后)');
assert(game._tutRate === 1.0, 'F 做对恢复常速');
// T2 连错 2 次(不出手 ×2)→ 封顶已用 → 直接跳过
for (let round = 0; round < 2; round++) {
await waitFor(() => game._tutIdx === 1 && game._phaseEnd < 1e8
&& (round === 0 ? game._tutAttempts.length === 0 : true), `F T2 第 ${round + 1} 轮`);
await runUntil(game._phaseEnd + 0.2); await flush();
}
await waitFor(() => game._tutIdx === 2, 'F T2 跳过进 T3(DDA 封顶后走 retry_skip)');
assert(voicePlayed('voice/retry_skip'), 'F 跳步播 retry_skip');
// —— T3 失败①不出手 → 归因必须是 gd_miss_none(真话律)——
{
await waitFor(() => game._tutIdx === 2 && game._cues.length > 0 && game._tutAttempts.length === 0, 'F T3 领奏');
const rlMark = plays('voice/retry_listen');
await runUntil(game._phaseEnd + 0.2); await flush();
assert(game._tutFails === 1, 'F T3 失败 1 次');
assert(voicePlayed('voice/gd_miss_none'), 'F 归因真话律:没出手 → gd_miss_none');
await waitFor(() => game._phaseEnd < 1e8, 'F T3 重教(纠正链播完)');
assert(plays('voice/retry_listen') === rlMark + 1,
'A23 F3:没出手失败的纠正链 retry_listen 只播一次(不二连复读)');
for (const cue of game._cues) {
if (cue.judged) continue;
await runUntil(cue.expected);
G('tap', ZC[cue.zone]); await flush();
}
await runUntil(game._phaseEnd + 0.2); await flush();
}
await waitFor(() => game.phase === 'select', 'F 教学完成进选曲(26 号:教学后选曲等待点)');
assert(voicePlayed('voice/tut_done'), 'F tut_done 已播');
}
// ================= 场景 G:A12 评级分档 + A13 末位右滑 + C/B 解锁 =================
console.log('\n[场景 G] A12 评级阈值 / A13 song3 末位右滑 → 不播 song_locked / C 不解锁 B 解锁');
{
const stats = { perfect: 0, good: 0, miss: 0, combo: 0, maxCombo: 0 };
game._stats = stats;
const setPgm = (p, g) => { stats.perfect = p; stats.good = g; stats.miss = 20 - p - g; };
setPgm(19, 1); // (38+1)/40 = 0.975 → S
assert(game._grade() === 's', 'A12 rate 0.975 → S');
setPgm(17, 1); // 35/40 = 0.875 → A
assert(game._grade() === 'a', 'A12 rate 0.875 → A');
setPgm(14, 2); // 30/40 = 0.75 → B
assert(game._grade() === 'b', 'A12 rate 0.75 → B');
setPgm(10, 4); // 24/40 = 0.60 → C
assert(game._grade() === 'c', 'A12 rate 0.60 → C');
// song3 末位右滑:restart 刚说过 → 台账去重只给提示音,不播 song_locked
game._startSong(game._runId, 2);
game._finish(game._runId); await flush();
assert(game.phase === 'end' && game._songIdx === 2, '钩子:song3 结算页');
{
const chimMark = chimeLog.length;
const voiceMark = voicePlays().length;
G('right'); await flush();
assert(!voicePlays().slice(voiceMark).includes('voice/song_locked'),
'A13 末位曲目右滑 → 不播 song_locked');
assert(chimeLog.length > chimMark, 'A13 末位曲目右滑 → 台账去重只给提示音(原则 9)');
}
// —— C 级不解锁 / B 级解锁 ——
{
game._startSong(game._runId, 0); await flush();
game._stats = { perfect: 4, good: 4, miss: 12, combo: 0, maxCombo: 0 }; // rate=(8+4)/40=0.3 → C
game._progress.unlocked = 1;
game._finish(game._runId); await flush();
assert(game._grade() === 'c' && game._progress.unlocked === 1, 'A12 C 级不解锁下一首');
game._startSong(game._runId, 0); await flush();
game._stats = { perfect: 14, good: 2, miss: 4, combo: 0, maxCombo: 16 }; // 30/40=0.75 → B
game._progress.unlocked = 1;
game._finish(game._runId); await flush();
assert(game._progress.unlocked === 2, 'A12 B 级解锁下一首');
await waitFor(() => voicePlayed('voice/unlock_song2'), 'A12 B 级结算链播 unlock_song2');
}
}
// ================= 场景 H:A10 菜单(左右滑换项 + 单击确认)+ 暂停冻结/恢复平移 + gd_count =================
console.log('\n[场景 H] A10 菜单:阶段 2 下滑呼出 / 冻结 / 左右滑+单击 / 平移 / 断点续播 / gd_count 每曲一次');
{
await makeWorld({ tutorialDone: true, unlocked: 3, grades: {}, bestCombo: 0 },
{ ladder: { stage: 3, games: 5 }, guide: 'full' });
game.start(); await flush();
await waitFor(() => game.phase === 'select', 'H 进入选曲');
game._startSong(game._runId, 0);
await waitFor(() => game.phase === 'main' && game._cues.length > 0 && game._bgmT0 > 0, 'H song1 排程');
// —— A10:有未判定音符临近时下滑照样弹菜单(无条件)——
const nearCue = game._cues.find((c) => !c.judged);
await runUntil(nearCue.expected - 0.3);
G('down'); await flush();
assert(game.phase === 'menu', 'A10 音符临近时下滑照样呼出菜单(无条件)');
assert(game.floor.state === 'SPEAKING' || (game.floor.state === 'YOUR_TURN'
&& game.floor.waitPoint && game.floor.waitPoint.conf.id === 'menu'),
'菜单开屏播报中,等待点 menu 已挂(开菜单手势未把它结算掉)');
assert(!playLog.some((p) => p.id === 'bgm/menu' && p.t >= nowMs - 100), '暂停菜单保持静默(不叠 menu pad)');
await waitFor(() => voicePlayed('voice/menu_main'), '菜单语音 menu_main');
await waitFor(() => voicePlayed('voice/menu_resume'), '菜单首项报名 menu_resume');
await waitFor(() => game.floor.state === 'YOUR_TURN' && game.floor.waitPoint
&& game.floor.waitPoint.conf.id === 'menu', '菜单 = YOUR_TURN + menu 等待点');
const missAtMenu = game._stats.miss;
const srcAtMenu = srcLog.length;
await advance(2000);
assert(game._stats.miss === missAtMenu, 'A10 菜单开启 2s:判定冻结(无新 Miss)');
assert(srcLog.length === srcAtMenu, 'A10 菜单开启 2s:谱面冻结(无新排程)');
// 左右滑换项报名(00 号 §5.4:左右滑切换、单击确认)
G('left'); await waitFor(() => voicePlayed('voice/menu_lobby'), '左滑 → 第二项「回大厅」报名');
G('right'); await waitFor(() => plays('voice/menu_resume') >= 2, '右滑 → 切回「继续演奏」');
// 关菜单恢复:gd_count + 4 拍 count-in + 网格平移(guide full)
const bgmT0Before = game._bgmT0;
const expBefore = game._cues.map((c) => c.expected);
await advance(1100); // 菜单 1s 去抖独占期
G('down'); await flush();
assert(game.phase === 'main', '下滑关闭菜单恢复 main');
assert(voicePlayed('voice/gd_count'), 'A19 full 档恢复先播 gd_count「四拍之后,继续」');
await waitFor(() => game.phase === 'main' && game._bgmT0 > bgmT0Before, 'H 恢复排程(网格已平移)');
{
const lastTrack90 = srcLog.filter((s) => s.url && s.url.includes('/bgm/track90')).pop();
const pauseDur = game._bgmT0 - bgmT0Before;
const countIn = 4 * (60 / 90);
assert(pauseDur > countIn, `A10 _bgmT0 平移含暂停时长 + 4 拍 count-in(${pauseDur.toFixed(2)}s > ${countIn.toFixed(2)}s)`);
assert(game._cues.every((c, i) => Math.abs(c.expected - expBefore[i] - pauseDur) < 1e-6),
'A10 全部 cue.expected 同步平移');
assert(lastTrack90 && lastTrack90.offset > 0, `A10 BGM 断点续播(offset=${lastTrack90 && lastTrack90.offset.toFixed(2)}s > 0)`);
}
assert(game._gdCountUsed === true, 'A19 gd_count 每曲限 1 次(已用)');
assert(game.floor.state === 'ACTION', '恢复后回 ACTION');
// 二次开菜单关菜单:不再播 gd_count;单击确认「继续演奏」路径
await advance(200);
G('down'); await flush();
await waitFor(() => game.phase === 'menu' && !game.floor.speaking, '二次菜单播报完(去抖期已过)');
const gdCountMark = plays('voice/gd_count');
G('tap'); await waitFor(() => game.phase === 'main', '单击确认「继续演奏」恢复'); // 当前项 = 继续演奏
assert(plays('voice/gd_count') === gdCountMark, 'A19 二次恢复不重播 gd_count(每曲一次)');
}
// ================= 场景 I:断点复述(§4.12) =================
console.log('\n[场景 I] 断点复述:进曲存档 lastSong / 回来 sys_continue_ask 问继续或重选');
{
await makeWorld({ tutorialDone: true, unlocked: 3, grades: {}, bestCombo: 0, lastSong: 1 });
game.start(); await flush();
await waitFor(() => game.floor.state === 'YOUR_TURN' && game.floor.waitPoint
&& game.floor.waitPoint.conf.id === 'reopen', '回来先交出 reopen 等待点');
assert(voicePlayed('voice/sys_continue_ask'), '断点复述句 sys_continue_ask');
G('right'); await waitFor(() => game.phase === 'select', '右滑 = 重新选曲');
// 另一世界:单击 = 继续上次曲目
await makeWorld({ tutorialDone: true, unlocked: 3, grades: {}, bestCombo: 0, lastSong: 2 });
game.start(); await flush();
await waitFor(() => game.floor.waitPoint && game.floor.waitPoint.conf.id === 'reopen', ' reopen 等待点');
G('tap'); await waitFor(() => game.phase === 'main' && game._song.id === 'song3', '单击 = 继续上次曲目(song3)');
// 一曲打完清除断点
game._finish(game._runId); await flush();
assert(game._progress.lastSong === null, '一曲打完清除断点 lastSong');
}
// ================= 场景 J:A24 曲尾 gd_miss 守卫 + guide 接入(full) =================
console.log('\n[场景 J] A24:曲尾 3s 内连 2 Miss 不插播 gd_miss;gd_answer/交接提示音');
{
await makeWorld({ tutorialDone: true, unlocked: 3, grades: {}, bestCombo: 0, turnTaught: true },
{ guide: 'full' });
game.start(); await flush();
await waitFor(() => game.phase === 'select', 'J 进入选曲');
game._startSong(game._runId, 0);
await waitFor(() => game.phase === 'main' && game._cues.length > 0, 'J song1 排程');
// 把全部 cue 压到曲尾:直接改 expected 到 phaseEnd 前 2s(等价曲尾连漏)
const lastTwo = game._cues.slice(-2);
lastTwo[0].expected = game._phaseEnd - 2.2;
lastTwo[1].expected = game._phaseEnd - 1.2;
for (const cue of game._cues.slice(0, -2)) { cue.judged = true; cue.result = 'perfect'; }
game._stats.perfect = game._cues.length - 2;
await runUntil(game._phaseEnd + 0.3); await flush();
assert(game._stats.miss === 2, 'A24 曲尾两音超时 Miss');
assert(!voicePlayed('voice/gd_miss'), 'A24 F1:曲尾 3s 内不插播 gd_miss(结算链不被顶断)');
await waitFor(() => game.phase === 'end', 'A24 结算到达');
// guide full + turnTaught:下一曲首个应答窗前播 gd_answer + 交接提示音
game._startSong(game._runId, 1);
await waitFor(() => game.phase === 'main' && game._cues.length > 0, 'J song2 排程');
const chimMark = chimeLog.length;
const gaMark = plays('voice/gd_answer');
await waitFor(() => plays('voice/gd_answer') > gaMark, 'A19 full 档首应答窗前 gd_answer');
assert(chimeLog.length > chimMark, 'A19 交接提示音与 gd_answer 同点');
// A17:应答出手不触发任何语音
const phrase0 = game._cues.filter((c) => c.phrase === 0);
for (const cue of phrase0) {
if (cue.judged) continue;
await runUntil(cue.expected);
const mark = playLog.length;
G('tap', ZC[cue.zone]); await flush();
for (let i = mark; i < playLog.length; i++) {
assert(!playLog[i].id.startsWith('voice/'), 'A17 应答出手不触发任何语音');
}
}
}
// ================= 场景 K:A18 键盘平权 + 看门狗(资产缺失不卡死) =================
console.log('\n[场景 K] A18 键盘:Digit1-6 零延迟 / 空格中心 = BC / 选曲态数字键无效;资产缺失看门狗');
{
await makeWorld({ tutorialDone: true, unlocked: 3, grades: {}, bestCombo: 0 });
game.start(); await flush();
await waitFor(() => game.phase === 'select', 'K 进入选曲');
{
const idx = game._songIdx;
key('Digit1'); key('Digit4'); await flush();
assert(game.phase === 'select' && game._songIdx === idx, 'A18 选曲态数字键无效(不切曲不进曲)');
}
game._startSong(game._runId, 0);
await waitFor(() => game.phase === 'main' && game._cues.length > 0, 'K song1 排程');
const kc = game._cues.find((c) => !c.judged && c.zone === 'BC');
assert(!!kc, 'A18 前置:找到未判定的 BC 音符');
await runUntil(kc.expected - 0.05);
key('Digit5'); await flush(); // Digit5 = BC
assert(kc.result === 'perfect', 'A18 Digit5 命中 BC(零延迟判定)');
const kc2 = game._cues.find((c) => !c.judged);
if (kc2 && kc2.zone !== 'BC') {
const map = { TL: 'Digit1', TC: 'Digit2', TR: 'Digit3', BL: 'Digit4', BR: 'Digit6' };
await runUntil(kc2.expected - 0.05);
key(map[kc2.zone]); await flush();
assert(kc2.result === 'perfect', `A18 ${map[kc2.zone]} 命中 ${kc2.zone}`);
}
assert(game.zoneOf({ x: 150, y: 300 }) === 'BC', 'A18 元素中心点(键盘空格虚拟指针)→ BC 家区');
const comboMark = game._stats.combo;
key('Digit1'); key('Digit6'); await flush(); // 窗口外连按:自由演奏不判定
assert(game._stats.combo === comboMark, 'A18 键盘自由演奏不判定');
// 看门狗:开场指令资产缺失 → Floor 哨兵推进,不卡死
const errBefore = consoleErrs; const warnBefore = consoleWarns;
await makeWorld();
blocked.push('/sys_first_tap.');
game.start(); await flush();
await waitFor(() => game.phase === 'tut' && game._tutStage === 'probe', '指令缺失:Floor 哨兵 3s 推进进探针');
assert(consoleErrs + consoleWarns > errBefore + warnBefore, '缺失资产仅 console 告警不崩');
{
const locked = new WebAudioEngine('audio/');
const fetchMark = fetchLog.length;
locked.play('voice/rules');
await flush();
assert(fetchLog.length === fetchMark, '音频解锁前 play 静默跳过(§7.1)');
}
}
// ================= 场景 L:§6.2 乱点机器人 =================
console.log('\n[场景 L] 乱点机器人(§6.2):无声操作 0 / 被动原句重复 0 / 打断失败 0 / 首次成功 ≤10s');
{
await makeWorld(); // 新手:阶梯阶段 0、guide off
let seed = 99;
const rnd = () => { seed = (seed * 1103515245 + 12345) & 0x7fffffff; return seed / 0x7fffffff; };
const zones = Object.keys(ZC);
const gestureLog = []; // { g, t, voiceToken, floorState }
const botGesture = (g, info) => {
gestureLog.push({ g, t: nowMs, voiceToken: audio._voice, floorState: game.floor.state,
critical: !!(game.floor._chain && game.floor._chain.critical) });
return G(g, info);
};
game.start(); await flush();
const t0 = nowMs;
await waitFor(() => game.phase === 'tut' && game._tutStage === 'probe', '机器人进入探针');
botGesture('tap', ZC.TL); await flush(); // 确定性第一击(第一次成功)
let firstSuccessMs = null;
let steps = 0;
for (; steps < 260; steps++) {
const roll = rnd();
if (roll < 0.35) {
botGesture('tap', ZC[zones[Math.floor(rnd() * 6)]]); await flush();
} else if (roll < 0.5) {
botGesture('tap', ZC[zones[Math.floor(rnd() * 6)]]); await flush(1);
await advance(80 + Math.floor(rnd() * 170));
botGesture('tap', ZC[zones[Math.floor(rnd() * 6)]]); await flush();
} else if (roll < 0.85) {
const dirs = ['up', 'down', 'left', 'right'];
botGesture(dirs[Math.floor(rnd() * 4)]); await flush();
} else {
const before = nowMs;
await advance(2000 + Math.floor(rnd() * 8000));
if (nowMs - before < 2000) break; // advance 被时钟上限截断(保险)
}
if (firstSuccessMs === null
&& (receiptLog.some((r) => r.kind === 'ok' && r.t >= t0) || playLog.some((p) => p.t >= t0 && p.id.startsWith('sfx/')))) {
firstSuccessMs = nowMs - t0;
}
if (game.phase === 'end') break;
}
// 指标①:无声操作 = 0(操作后 100ms 内有回执或内容音)
let silentOps = 0;
for (const ge of gestureLog) {
const receipt = receiptLog.some((r) => r.t >= ge.t && r.t <= ge.t + 100);
const content = playLog.some((p) => p.t >= ge.t && p.t <= ge.t + 100);
if (!receipt && !content) silentOps++;
}
assert(silentOps === 0, `无声操作 = 0(实际 ${silentOps}/${gestureLog.length})——演出期也有 busy 回执`);
// 指标②:被动原句重复 = 0(30s 内同一语音 id 无主动重听播两次)。
// 排除回应类(原则 15 要求每次失败都归因、原则 11 每次做对都肯定):失败归因/
// 纠错/重教指令/肯定/重听引导/状态查询/等待点三档提示(有台账,只会降档不会重播)
const FEEDBACK = /^(voice\/(retry_|gd_|tut_ok|tut_z_|st_|rules|inv_|relisten|slow_down))/;
let passiveRepeats = 0;
const repeatIds = new Map();
const lastPlay = new Map();
for (const p of playLog) {
if (!p.id.startsWith('voice/') || FEEDBACK.test(p.id)) continue;
const sayAgainAt = playLog.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 (repeatIds.size) console.log(' 重复明细:', [...repeatIds.entries()].map(([k, v]) => `${k}×${v}`).join(' '));
assert(passiveRepeats === 0, `被动原句重复 = 0(实际 ${passiveRepeats})`);
// 指标③:打断失败 = 0(操作 150ms 后同一句语音还在播;critical 句除外;
// ACTION 态直播音不属「游戏在说」,26 号 §4.1 规定 ACTION 不被打断)
let interruptFails = 0;
for (const ge of gestureLog) {
if (!ge.voiceToken) continue;
if (ge.floorState === 'SPEAKING' && !ge.critical) {
const still = await (async () => {
const mark = nowMs;
await advance(200);
return audio._voice === ge.voiceToken;
})();
if (still) interruptFails++;
}
}
assert(interruptFails === 0, `打断失败 = 0(实际 ${interruptFails})——critical 句除外`);
assert(firstSuccessMs !== null && firstSuccessMs <= 10000,
`第一次成功 ≤10s(实际 ${firstSuccessMs}ms,§4.10)`);
assert(uncaught.length === 0, '机器人全程无未捕获异常');
console.log(` 机器人注入 ${gestureLog.length} 次手势(${steps} 步),最终 phase=${game.phase}`);
game.stop();
}
// ================= 资产引用校验(A20:ogg+m4a 双格式;待生成新 id 以台词清单登记为准) =================
console.log('\n[资产校验 A20] game.js 引用的 id 对照 audio/ 目录 + tools/voice_lines_26/rhythm.md 登记台账');
{
const src = readFileSync(new URL('../game.js', import.meta.url), 'utf8');
const ids = new Set();
for (const m of src.matchAll(/['"`]((?:voice|sfx|bgm)\/[a-z0-9_]+)['"`]/g)) ids.add(m[1]);
if (src.includes('`voice/grade_${grade}`') || src.includes("'voice/grade_' + grade") || src.includes('voice/grade_')) {
for (const g of ['s', 'a', 'b', 'c']) ids.add(`voice/grade_${g}`);
}
// 台词清单(tools/voice_lines_26/rhythm.md)登记的 id = 待统一生成的新资产
const registry = new Set();
try {
const voiceList = readFileSync(new URL('../../../tools/voice_lines_26/rhythm.md', import.meta.url), 'utf8');
for (const m of voiceList.matchAll(/^\s*\("([a-z0-9_]+)",\s*_RHY,/gm)) registry.add(`voice/${m[1]}`);
} catch (_) { /* 清单缺失则全按盘上校验 */ }
const missing = [];
const has = (p) => existsSync(new URL(p, import.meta.url));
for (const id of ids) {
const [layer, name] = id.split('/');
if (layer === 'voice') {
const dual = ['male', 'female'].every((g) => has(`../audio/voice/${g}/${name}.ogg`) && has(`../audio/voice/${g}/${name}.m4a`));
const flat = has(`../audio/voice/${name}.ogg`) && has(`../audio/voice/${name}.m4a`);
if (!dual && !flat) {
if (registry.has(id)) continue; // 已登记待生成(新语音资产统一生成后自动转入盘上校验)
missing.push(id + '(voice 需双性别或平铺 ogg+m4a,且未在台词清单登记)');
}
} else if (!has(`../audio/${layer}/${name}.ogg`) || !has(`../audio/${layer}/${name}.m4a`)) {
missing.push(id + '(缺 ogg 或 m4a)');
}
}
console.log(` 共引用 ${ids.size} 个资产 id,其中 ${registry.size} 个登记待生成`);
assert(missing.length === 0, `A20 资产零缺失${missing.length ? '——缺失: ' + missing.join(', ') : ''}`);
}
// ================= 汇总 =================
console.log('\n========== 汇总 ==========');
assert(uncaught.length === 0, `全程无未捕获异常${uncaught.length ? '——' + uncaught.map(String).join('; ') : ''}`);
console.log(`通过 ${passed} 项,失败 ${failed} 项,注入手势/按键 ${gestures} 次`);
process.exit(failed > 0 ? 1 : 0);