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 置完成。
952 lines
51 KiB
JavaScript
952 lines
51 KiB
JavaScript
/**
|
||
* 节拍行者(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);
|