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

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

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

720 lines
34 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* playtest.mjs — 《音序回响》无头试玩仿真测试(docs/design/13-playtest-criteria.md §3 + §7 + §9 round1-live 变更)。
*
* 思路:mock 浏览器环境(window/document/navigator/localStorage/fetch/AudioContext),
* 加载**真实** engine/audio.js(拿到真实 skipVoice/onComplete/设置语义)与 game.js,
* 用虚拟时钟推进所有 setTimeout(语音链结束定时器也在其列),手势直接注入
* game.onGesture(即引擎回调路径)。Math.random 注入种子,全流程可重复。
*
* 运行:tools/node/bin/node web/simon/test/playtest.mjs
*/
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); // web/simon
// ---------------------------------------------------------------- 虚拟时钟
let nowMs = 0;
const timers = new Map();
let nextTimerId = 1;
function vSetTimeout(fn, ms) {
const id = nextTimerId++;
timers.set(id, { at: nowMs + (ms || 0), fn });
return id;
}
function vClearTimeout(id) { timers.delete(id); }
async function flush(rounds = 50) {
for (let i = 0; i < rounds; i++) await Promise.resolve();
}
/** 推进虚拟时钟 ms,沿途触发到期定时器(含级联)。 */
async function advance(ms) {
const target = nowMs + ms;
for (;;) {
let bestId = null;
let bestAt = Infinity;
for (const [id, t] of timers) {
if (t.at <= target && t.at < bestAt) { bestAt = t.at; bestId = id; }
}
if (bestId === null) break;
const t = timers.get(bestId);
timers.delete(bestId);
nowMs = t.at;
t.fn();
await flush();
}
nowMs = target;
await flush();
}
async function advanceUntil(cond, { step = 25, max = 60000, desc = '' } = {}) {
let t = 0;
while (!cond()) {
if (t >= max) {
(globalThis.__origError || console.error)(`[debug] phase=${game?.phase} teachIdx=${game?.teachIdx} teachAwait=${game?.teachAwait} teachErrs=${game?.teachErrs} round=${game?.round} voice=${lastVoice?.() ?? '?'} voicePlaying=${!!audio?._voice} timers=${timers.size}`);
throw new Error(`advanceUntil 超时(${max}ms):${desc}`);
}
await advance(step);
t += step;
}
}
// ---------------------------------------------------------------- 浏览器 mock
const vibLog = [];
const fetchLog = [];
const warnLog = [];
const errorLog = [];
const blockedAssets = new Set(); // 子串匹配,命中的资产 fetch 返回 !ok
const bufDuration = new Map(); // ArrayBuffer -> 秒
function durFor(url) {
if (url.includes('voice/')) return 1.0;
if (url.includes('sfx/gameover')) return 0.95;
if (url.includes('sfx/tone')) return 0.3;
if (url.includes('bgm/')) return 90;
return 1.0;
}
function fakeEl() {
return {
id: '', className: '', textContent: '', src: '', alt: '', offsetWidth: 0,
style: { setProperty() {} },
children: [],
appendChild(c) { this.children.push(c); },
remove() {},
addEventListener() {},
};
}
const winListeners = new Map();
const windowMock = {
addEventListener(type, fn) {
if (!winListeners.has(type)) winListeners.set(type, []);
winListeners.get(type).push(fn);
},
removeEventListener() {},
dispatch(type, event = {}) {
for (const fn of winListeners.get(type) || []) fn(event);
},
location: { href: '' },
};
const lsStore = new Map();
const localStorageMock = {
getItem: (k) => (lsStore.has(k) ? lsStore.get(k) : null),
setItem: (k, v) => lsStore.set(k, String(v)),
removeItem: (k) => lsStore.delete(k),
};
class FakeAudioParam {
constructor(v = 0) { this.value = v; }
setValueAtTime() {}
cancelScheduledValues() {}
linearRampToValueAtTime() {}
}
class FakeAudioContext {
constructor() {
this.state = 'suspended';
this.currentTime = 0;
this.sampleRate = 44100;
this.destination = {};
}
async resume() { this.state = 'running'; }
createGain() { return { gain: new FakeAudioParam(1), connect() {}, disconnect() {} }; }
createDynamicsCompressor() {
return {
threshold: new FakeAudioParam(), ratio: new FakeAudioParam(),
attack: new FakeAudioParam(), release: new FakeAudioParam(),
connect() {}, disconnect() {},
};
}
createBufferSource() {
return { buffer: null, loop: false, onended: null, connect() {}, disconnect() {}, start() {}, stop() {} };
}
createScriptProcessor() { return { connect() {}, disconnect() {}, onaudioprocess: null }; }
async decodeAudioData(ab) {
const duration = bufDuration.get(ab) ?? 1;
return {
duration, numberOfChannels: 1, sampleRate: 44100,
getChannelData: () => new Float32Array(8),
};
}
}
async function fetchMock(url) {
fetchLog.push(url);
const blocked = [...blockedAssets].some((b) => url.includes(b));
const fsPath = path.join(ROOT, decodeURIComponent(url));
const ok = !blocked && fs.existsSync(fsPath);
return {
ok,
async arrayBuffer() {
const ab = new ArrayBuffer(8);
bufDuration.set(ab, durFor(url));
return ab;
},
};
}
// 种子随机(LCG),保证序列可重复
let seed = 20260830;
function seededRandom() {
seed = (seed * 1103515245 + 12345) & 0x7fffffff;
return seed / 0x7fffffff;
}
// 全局安装(defineProperty:Node 24 自带 fetch/performance/navigator 只读 getter)
for (const [k, v] of Object.entries({
window: windowMock,
document: {
body: fakeEl(),
createElement: () => fakeEl(),
getElementById: () => null,
},
// maxTouchPoints: 1 —— 手机 persona:inputMode() 判 touch,语音走触屏措辞主资产
// (键盘变体路由的桌面路径由 harness/engine-input.test.mjs K9 专测)
navigator: { maxTouchPoints: 1, vibrate: (p) => vibLog.push(p) },
localStorage: localStorageMock,
fetch: fetchMock,
performance: { now: () => nowMs },
setTimeout: vSetTimeout,
clearTimeout: vClearTimeout,
})) {
Object.defineProperty(globalThis, k, { value: v, configurable: true, writable: true });
}
windowMock.AudioContext = FakeAudioContext;
Math.random = seededRandom;
const origWarn = console.warn;
const origError = console.error;
globalThis.__origError = origError;
console.warn = (...a) => { warnLog.push(a.join(' ')); };
console.error = (...a) => { errorLog.push(a.join(' ')); };
// ---------------------------------------------------------------- 断言框架
let passed = 0;
let failed = 0;
function assert(cond, msg) {
if (cond) { passed++; console.log(` ✓ ${msg}`); }
else { failed++; origError(` ✗ ${msg}`); } // 失败行走原始 console(console.error 被 mock 捕获)
}
function section(name) { console.log(`\n== ${name}`); }
process.on('unhandledRejection', (e) => {
failed++;
origError(' ✗ 未处理 Promise 拒绝:', e);
});
// ---------------------------------------------------------------- 加载引擎与游戏
const { WebAudioEngine } = await import('../../engine/audio.js');
const { settings } = await import('../../engine/settings.js');
const { SimonGame } = await import('../game.js');
// 向导层关闭:回归测试只验证纯游戏逻辑(guide='full' 的行为由 harness/guide.test.mjs 覆盖)
settings.set({ guide: 'off', voice: 'female', speed: 1.0, bgm: 1.0 });
const audio = new WebAudioEngine('audio/');
const game = new SimonGame(audio, fakeEl());
let gameRef = game; // 手势注入目标(第二场景换新实例时切换)
// 间谍:记录播放/跳过/停 BGM
const playLog = [];
const skipLog = [];
const stopBgmLog = [];
const origPlay = audio.play.bind(audio);
audio.play = (id, opts) => { playLog.push({ id, opts: opts || {}, t: nowMs }); return origPlay(id, opts); };
const origSkip = audio.skipVoice.bind(audio);
audio.skipVoice = () => { skipLog.push(nowMs); return origSkip(); };
const origStopBgm = audio.stopBgm.bind(audio);
audio.stopBgm = (ms) => { stopBgmLog.push(ms); return origStopBgm(ms); };
const played = (id) => playLog.some((e) => e.id === id);
const playCount = (id) => playLog.filter((e) => e.id === id).length;
const OCC_IDS = ['voice/gd_occ_up', 'voice/gd_occ_dt', 'voice/gd_occ_both'];
const occTotal = () => OCC_IDS.reduce((n, id) => n + playCount(id), 0);
const lastVoice = () => {
for (let i = playLog.length - 1; i >= 0; i--) {
if (playLog[i].id.startsWith('voice/')) return playLog[i].id;
}
return null;
};
let gestureCount = 0;
// 真实手势回调是独立宏任务;每次注入后 flush 微任务,模拟事件循环间隔
//(否则同一 tick 内连续两个 voice play 会撞上引擎 _playVoice 的异步加载窗口)
async function gesture(g) { gestureCount++; gameRef.onGesture(g); await flush(); }
const GESTURES = ['up', 'down', 'left', 'right', 'tap', 'doubletap'];
// ---------------------------------------------------------------- 全局断言 1:解锁前无音频
section('启动遮罩前(引擎解锁语义)');
audio.play('voice/t0_welcome');
await flush();
assert(warnLog.some((w) => w.includes('尚未解锁')), '解锁前 play 仅 console.warn');
assert(fetchLog.length === 0, '解锁前无任何资产加载');
await audio.unlock();
// ---------------------------------------------------------------- 启动 + 设置边界(全局断言 3)
section('启动 / 设置生效(voice 性别、语速)');
settings.set({ voice: 'male', speed: 1.7 });
game.start();
await advanceUntil(() => !!audio._voice, { desc: 't0_welcome 开播' });
assert(game.phase === 'tutorial', '0.5s 后进入教学(T0)');
assert(lastVoice() === 'voice/t0_welcome', 't0_welcome 播放');
assert(fetchLog.some((u) => u.includes('audio/voice/male/t0_welcome.ogg')), 'voice=male 时从 male 目录取材');
assert(audio._voice.st.tempo === 1.7, 'speed=1.7 时播放实例 tempo=1.7(不变调变速)');
settings.set({ voice: 'female', speed: 1.0 });
// skip_hint 插播 + 长按加速(引擎内置行为过一遍真实代码路径)
await advanceUntil(() => lastVoice() === 'voice/skip_hint' && !!audio._voice, { desc: 'skip_hint 开播' });
assert(fetchLog.some((u) => u.includes('audio/voice/female/skip_hint.ogg')), '切回 female 后下一句从 female 目录取材');
assert(playCount('voice/skip_hint') === 1, 'skip_hint 在 t0 附近插播一次');
windowMock.dispatch('soundgame:longpress');
assert(audio._voice && audio._voice.st.tempo === 1.7, '长按 → 旁白临时 1.7 倍速');
windowMock.dispatch('soundgame:longpressend');
assert(audio._voice && audio._voice.st.tempo === 1.0, '松开 → 恢复 settings.speed');
// ---------------------------------------------------------------- 教学 T1-T6
section('交互式教学(T0-T7)');
// T1 讲解句播放中单击 = 跳过本句(12 号文档 + 本轮裁定)
// 注意:teachIdx 构造即 0,必须以"当前在播 t1_up"为准
await advanceUntil(() => lastVoice() === 'voice/t1_up' && !!audio._voice, { desc: 't1_up 开播' });
assert(game.phase === 'tutorial' && game.teachIdx === 0, 'T1 教学句播放');
{
const skips = skipLog.length;
await gesture('tap');
assert(skipLog.length === skips + 1, '教学讲解播放中单击 = skipVoice()');
assert(game.phase === 'tutorial' && game.teachIdx === 0, '跳过后教学状态机不破坏');
}
await advanceUntil(() => game.teachAwait, { desc: 'T1 等待输入' });
for (let i = 0; i < GESTURES.length; i++) {
await advanceUntil(() => game.teachAwait, { desc: `T${i + 1} 等待输入` });
assert(game.teachIdx === i, `teachIdx 递进到 ${i}`);
const expected = GESTURES[i];
if (i === 1) {
// 错手势:播 t_wrong_* + error 震动,不推进
const wrong = GESTURES.find((g) => g !== expected && g !== 'down'); // down=跳教学,避开
await gesture(wrong);
assert(played('voice/t_wrong_down'), '教学错手势播 t_wrong_down');
assert(vibLog.some((p) => Array.isArray(p) && p[0] === 60), '教学错手势发 error 震动');
assert(game.teachIdx === 1 && game.teachAwait, '错手势后仍在原步骤等待');
}
await gesture(expected); // i=4 时 expected='tap':等待输入阶段单击=答题(不跳过)
if (i < GESTURES.length - 1) {
await advanceUntil(() => game.teachIdx === i + 1, { desc: `teachIdx → ${i + 1}` });
}
}
await advanceUntil(() => game.phase === 'announce', { desc: 'T7 收尾后进第 1 轮' });
assert(game.round === 1 && played('voice/round_01'), '教学完成直达第 1 轮播报');
// ---------------------------------------------------------------- 对局辅助
function checkSeqConstraints() {
const s = game.sequence;
if (game.round <= 5) {
for (let i = 2; i < s.length; i++) {
assert(!(s[i] === s[i - 1] && s[i - 1] === s[i - 2]), `第 ${game.round} 轮序列前三连同手势(位置 ${i})`);
}
}
for (let i = 3; i < s.length; i++) {
assert(!(s[i] === s[i - 1] && s[i - 1] === s[i - 2] && s[i - 2] === s[i - 3]), `全程无四连同手势(第 ${game.round} 轮位置 ${i})`);
}
}
/** announce →(单击跳过轮次播报)→ playback(手势静默忽略)→ input。 */
async function toInputViaSkip(round) {
await advanceUntil(() => game.phase === 'announce' && !!audio._voice, { desc: `第 ${round} 轮播报` });
const skips = skipLog.length;
await gesture('tap');
assert(skipLog.length === skips + 1, `第 ${round} 轮 round_* 播报单击可跳`);
assert(game.phase === 'playback', '跳过后直接进入序列播放');
const seqSnapshot = game.sequence.join(',');
await gesture('left'); // playback 阶段任意手势静默忽略(含 voice 也不跳)
assert(game.phase === 'playback' && game.sequence.join(',') === seqSnapshot, 'playback 阶段手势静默忽略、序列不受影响');
const pl0 = playLog.length;
await advanceUntil(() => game.phase === 'input', { desc: `第 ${round} 轮进入输入` });
assert(game.inputIdx === 0, `第 ${round} 轮 inputIdx 归零`);
checkOccAnnouncedOnce(pl0);
checkSeqConstraints();
}
/** 语音缺失(round_03 被屏蔽)→ 2.5s 看门狗直接进 playback。 */
async function toInputViaWatchdog(round) {
await advanceUntil(() => game.phase === 'announce', { desc: `第 ${round} 轮 announce` });
await advance(300);
assert(!audio._voice, '被屏蔽的语音确实没有播起来');
const pl0 = playLog.length;
await advanceUntil(() => game.phase === 'playback', { desc: '看门狗兜底进 playback', max: 10000 });
assert(errorLog.some((e) => e.includes('资产缺失')), '资产缺失仅 console.error');
const seqSnapshot = game.sequence.join(',');
await gesture('left');
assert(game.phase === 'playback' && game.sequence.join(',') === seqSnapshot, '看门狗路径 playback 手势同样忽略');
await advanceUntil(() => game.phase === 'input', { desc: `第 ${round} 轮进入输入` });
checkOccAnnouncedOnce(pl0);
}
// P2(round1 live,13 号文档 §9):组合占用提示 gd_occ_* 每局每种只在首次出现
// 轮播一次——在轮次开场窗口(playback→input)跨轮跟踪,重复播报即失败。
const occAtRoundStart = new Set();
function checkOccAnnouncedOnce(fromIdx) {
for (const e of playLog.slice(fromIdx)) {
if (!e.id.startsWith('voice/gd_occ_')) continue;
assert(!occAtRoundStart.has(e.id), `P2:${e.id} 只在首次出现轮播(第 ${game.round} 轮开场重复播报)`);
occAtRoundStart.add(e.id);
}
}
let dtAnswerTested = false; // 裁定①:期望恰为 doubletap 时双击=答题
/** 输入阶段按序列原样复现;途中遇到期望 doubletap 时验证"双击=答题不触发重听"。 */
async function answerAll() {
while (game.phase === 'input' && game.inputIdx < game.sequence.length) {
const exp = game.sequence[game.inputIdx];
if (exp === 'doubletap' && !dtAnswerTested) {
const ch = game.chances;
const idx = game.inputIdx;
const relistenCount = playCount('voice/relisten');
await gesture('doubletap');
assert(game.inputIdx === idx + 1 && game.chances === ch && playCount('voice/relisten') === relistenCount,
'裁定①:期望输入恰为 doubletap 时双击=答题(inputIdx 前进、不触发重听)');
dtAnswerTested = true;
} else {
await gesture(exp);
}
}
}
/** 把前缀里连续的 doubletap 答掉,直到期望输入非 doubletap。 */
async function answerPastDoubletaps() {
while (game.phase === 'input' && game.sequence[game.inputIdx] === 'doubletap') {
await gesture('doubletap'); // 期望即 doubletap → 答题
}
}
/** 把前缀里连续的 up 答掉,直到期望输入非 up(round3 G5:期望恰为 up 时上滑=作答)。 */
async function answerPastUps() {
while (game.phase === 'input' && game.sequence[game.inputIdx] === 'up') {
await gesture('up'); // 期望即 up → 答题
}
}
// ---------------------------------------------------------------- 第 1-3 轮
section('第 1-3 轮(含单击跳播报、playback 忽略、看门狗)');
assert(game._interval() === 700, '第 1 轮播放间隔 700ms');
await toInputViaSkip(1);
await answerAll();
await advanceUntil(() => game.phase === 'announce' && game.round === 2, { desc: '进第 2 轮' });
assert(played('voice/correct'), 'P1:每轮答对都播表扬(第 1 轮 correct,池轮播)');
await toInputViaSkip(2);
await answerAll();
assert(played('voice/gd_clear'), 'P1:每轮答对都播表扬(第 2 轮 gd_clear,池轮播)');
blockedAssets.add('round_03'); // 看门狗被测项:在第 3 轮播报发起前屏蔽该语音资产
await advanceUntil(() => game.phase === 'announce' && game.round === 3, { desc: '进第 3 轮' });
await toInputViaWatchdog(3);
await answerAll();
assert(playCount('voice/correct') === 2, 'P1:每轮答对都播表扬(第 3 轮 correct 轮播)');
await advanceUntil(() => game.phase === 'announce' && game.round === 4, { desc: '进第 4 轮' });
// ---------------------------------------------------------------- 第 4 轮:答错触发复活
section('第 4 轮:答错消耗复活、序列不变重播');
await toInputViaSkip(4);
assert(game.chances === 1, '初始 1 次重听机会');
assert(game.freeRelistens === 2, 'P1(round2 live):免费重听每局 2 次开局即给足(教学承诺「免费机会有两次」)');
{
const prevSeq = game.sequence;
const exp = game.sequence[0];
// round3 G5:up=重听键,注入错手势时避开(否则触发重听而非判错)
const wrong = GESTURES.find((g) => g !== exp && g !== 'doubletap' && g !== 'up');
const occBefore = occTotal();
await gesture(wrong);
assert(game.chances === 0, '答错自动消耗复活:chances 1→0');
assert(game.phase === 'roundend' && played('voice/retry_use'), '播 retry_use');
await advanceUntil(() => played('voice/chances_out'), { desc: 'chances_out 链播' });
assert(played('voice/chances_out'), '机会归 0 追加播 chances_out');
assert(vibLog.some((p) => Array.isArray(p) && p[0] === 60), '答错发 error 震动');
await advanceUntil(() => game.phase === 'input', { desc: '重播后回输入' });
assert(game.sequence === prevSeq, '复活重播序列不变(引用相等)');
assert(game.inputIdx === 0, '复活后输入进度清零');
// P2(round1 live,13 号文档 §9):答错重听链尾重播本轮组合占用提示
const seqHasOcc = game.sequence.includes('up') || game.sequence.includes('doubletap');
assert(occTotal() === occBefore + (seqHasOcc ? 1 : 0), 'P2:答错重听时重播本轮组合提示(唯一重播途径)');
}
await answerAll();
await advanceUntil(() => game.phase === 'announce' && game.round === 5, { desc: '进第 5 轮' });
assert(game.chances === 1, '每完成 4 轮 chances +1(回到 1)');
assert(played('voice/chance_plus'), '播 chance_plus');
assert(playCount('voice/gd_clear') === 2, 'P1:每轮答对都播表扬(第 4 轮 gd_clear 轮播)');
// ---------------------------------------------------------------- 第 5 轮:主动重听 + 边界
section('第 5 轮:主动重听(上滑)两层账 / 重听拒绝 / 双击=作答(round3 G5 §8 + round2 live P1 §15)');
await toInputViaSkip(5);
{
// 未支持手势兜底(round1 live P1:答题阶段长按 9 秒零反馈,玩家以为卡死)
const idx = game.inputIdx;
const ch = game.chances;
const vib0 = vibLog.length;
await gesture('longpress');
assert(game.phase === 'input' && game.inputIdx === idx && game.chances === ch,
'P1:input 阶段长按不判错、不耗机会、不进重听');
assert(vibLog.slice(vib0).some((p) => Array.isArray(p) && p[0] === 60),
'P1:input 阶段长按发 error 震动兜底(不再零反馈,也不再抛 TypeError)');
const vib1 = vibLog.length;
await gesture('longpressend');
assert(vibLog.length === vib1 && game.inputIdx === idx, 'P1:longpressend(同一动作收尾)静默不重复反馈');
}
{
// round3 G5:input 阶段双击永远=作答,主动重听改绑上滑(期望恰为 up 时上滑=作答)
// round2 live P1(13 号文档 §15):重听分两层账——免费 2 次/局先用(不碰真机会),
// 第 3 次起才扣 chances。教学 gd_economy 承诺「免费机会有两次」,承诺几次给几次。
await answerPastDoubletaps();
await answerPastUps(); // 先把期望恰为 up 的音答掉
const relistenCount = playCount('voice/relisten');
assert(game.freeRelistens === 2 && game.chances === 1, 'P1:第 5 轮账目起点 = 免费 2 次 + 真机会 1 次');
await gesture('up'); // 期望非 up → 第 1 次主动重听(免费)
assert(game.freeRelistens === 1 && game.chances === 1, 'P1:第 1 次重听用免费额度(2→1),真机会不动');
assert(playCount('voice/relisten') === relistenCount + 1 && game.phase === 'roundend', '播 relisten 并重播');
await advanceUntil(() => game.phase === 'input', { desc: '重听重播后回输入' });
assert(played('voice/gd_chances_left1'), 'P1:第 1 次后如实报余量(gd_chances_left1「还剩一次」,链尾句)');
assert(game.inputIdx === 0, '重听后输入进度清零');
}
{
// 第 2 次主动重听:仍走免费额度(兑现教学承诺的第二次)
await answerPastDoubletaps();
await answerPastUps();
await gesture('up');
assert(game.freeRelistens === 0 && game.chances === 1, 'P1:第 2 次重听仍免费(1→0),真机会仍不动');
await advanceUntil(() => game.phase === 'input', { desc: '第 2 次重听重播后回输入' });
assert(played('voice/gd_chances_left0'), 'P1:第 2 次后播 gd_chances_left0(免费用完、之后扣机会,链尾句)');
assert(game.inputIdx === 0, '第 2 次重听后输入进度清零');
}
{
// 第 3 次主动重听:免费已尽,扣真机会(chances 1→0);真机会扣空如实报 chances_out
await answerPastDoubletaps();
await answerPastUps();
const coCount = playCount('voice/chances_out');
await gesture('up');
assert(game.freeRelistens === 0 && game.chances === 0, 'P1:第 3 次重听才扣真机会(chances 1→0)');
await advanceUntil(() => game.phase === 'input', { desc: '第 3 次重听重播后回输入' });
assert(playCount('voice/chances_out') === coCount + 1, 'P1:真机会扣空追加播 chances_out(链尾句)');
assert(game.inputIdx === 0, '第 3 次重听后输入进度清零');
}
{
// 免费+真机会皆空时上滑非期望手势:拒绝重听——不判错、不耗账、播 chances_out
await answerPastDoubletaps();
await answerPastUps();
const round = game.round;
const idx = game.inputIdx;
const coCount = playCount('voice/chances_out');
await gesture('up');
assert(game.phase === 'input' && game.round === round && game.inputIdx === idx, '两账皆空上滑非期望:拒绝重听不判错(round/inputIdx 不变)');
assert(playCount('voice/chances_out') === coCount + 1, '拒绝重听播 chances_out');
assert(game.chances === 0 && game.freeRelistens === 0, '两账不变(不为负)');
}
// 答完第 5 轮(途中验证双击=答题);若本轮没遇到 doubletap 则继续往下轮找
await answerAll();
assert(playCount('voice/correct') === 3, 'P1:每轮答对都播表扬(第 5 轮 correct 轮播)');
while (!dtAnswerTested && game.round < 10) {
await advanceUntil(() => game.phase === 'announce', { desc: '继续找 doubletap 回合' });
const r = game.round;
await advanceUntil(() => !!audio._voice, { desc: `第 ${r} 轮播报` });
await gesture('tap'); // 跳播报
await advanceUntil(() => game.phase === 'input', { desc: `第 ${r} 轮输入` });
await answerAll();
}
assert(dtAnswerTested, '双击冲突裁定①已覆盖(序列中出现 doubletap 并验证)');
// ---------------------------------------------------------------- 耗尽后答错 → gameover
section('复活耗尽 → gameover → 结算');
await advanceUntil(() => game.phase === 'input' || game.phase === 'roundend', { desc: '最后一轮输入' });
if (game.phase === 'roundend') {
await advanceUntil(() => game.phase === 'input', { desc: '进入输入阶段' });
}
assert(game.chances === 0, '复活已耗尽');
const finalRound = game.round;
{
const exp = game.sequence[game.inputIdx];
// round3 G5:up=重听键,注入错手势时避开
const wrong = GESTURES.find((g) => g !== exp && g !== 'doubletap' && g !== 'up');
await gesture(wrong);
}
assert(game.phase === 'gameover', 'chances=0 答错 → gameover');
const gameoverT = nowMs;
const score = finalRound - 1;
const expectedBand = score <= 0 ? 'voice/score_band1' : score <= 3 ? 'voice/score_band2'
: score <= 6 ? 'voice/score_band3' : score <= 9 ? 'voice/score_band4'
: score <= 14 ? 'voice/score_band5' : 'voice/score_band6';
assert(!played('bgm/menu'), 'gameover 瞬间尚无 BGM');
// 结算语音链自然播完,不在此插手——播放中单击=直接重开(P0 言行一致),
// 该断言需要一次专门的 gameover,见后文「结算屏言行一致」专项场景。
await advanceUntil(() => !!audio._voice, { desc: '结算语音链开播' });
await advanceUntil(() => !audio._voice, { desc: '结算语音链播完' });
assert(played('voice/gameover') && played(expectedBand), `score_band 语音与 round-1=${score} 匹配(${expectedBand})`);
assert(played('voice/new_record'), '破纪录播 new_record');
assert(lsStore.get('soundgame.simon.best') === String(score), 'best 写入 localStorage');
assert(played('sfx/gameover'), 'sfx/gameover 播出');
// 状态 BGM:sfx/gameover 播完 1s 后淡入 menu pad(gameover 起 +2650ms,volume 0.3)
await advanceUntil(() => played('bgm/menu'), { desc: '结算 menu pad 淡入' });
{
const e = playLog.find((x) => x.id === 'bgm/menu');
assert(e.opts.volume === 0.3, '结算 bgm/menu 以 volume 0.3 播放');
assert(e.t - gameoverT >= 2500 && e.t - gameoverT <= 2800, `menu pad 在 sfx/gameover 播完 1s 后淡入(实际 +${e.t - gameoverT}ms)`);
}
// 全程无 BGM(教学/对局):gameover 之前无任何 bgm 层播放调用
assert(playLog.filter((e) => e.id.startsWith('bgm/') && e.t < gameoverT).length === 0,
'教学/对局/序列播放全程无 BGM(用户硬性要求)');
// ---------------------------------------------------------------- 速度下限(单测 _interval)
{
const r = game.round;
game.round = 1; assert(game._interval() === 700, '第 1 轮 interval=700ms');
game.round = 11; assert(game._interval() === 440, '第 11 轮 interval=440ms(未到下限)');
game.round = 12; assert(game._interval() === 420, '第 12 轮起 interval=420ms 下限');
game.round = 30; assert(game._interval() === 420, '第 30 轮仍为 420ms(不穿了下限)');
game.round = r;
}
// ---------------------------------------------------------------- 菜单约定 + 重开
section('菜单(下滑呼出)与单击重开');
await gesture('down');
assert(game.phase === 'menu' && played('voice/menu_open'), '下滑呼出菜单');
assert(playCount('bgm/menu') >= 1, '菜单状态挂 bgm/menu(与结算同一首)');
// P1(round1 live,13 号文档 §9):菜单项切换逐项播报——右滑=下一项、左滑=上一项
await gesture('right');
assert(game._menuIdx === 1 && played('voice/menu_home'), 'P1:菜单右滑=下一项并播报("回大厅")');
const mr = playCount('voice/menu_restart'); // _openMenu 链上的 menu_restart 可能已被右滑顶掉,用增量断言
await gesture('left');
assert(game._menuIdx === 0 && playCount('voice/menu_restart') === mr + 1, 'P1:菜单左滑=上一项并播报("重新开始")');
await gesture('left');
assert(game._menuIdx === 2 && played('voice/menu_back'), 'P1:菜单首项再左滑回绕末项并播报("返回游戏")');
await gesture('right');
assert(game._menuIdx === 0, 'P1:菜单右滑回到首项');
await gesture('tap');
assert(game._menuIdx === 1 && played('voice/menu_home'), '菜单单击循环到"回大厅"');
await gesture('tap');
assert(game._menuIdx === 2 && played('voice/menu_back'), '菜单单击循环到"返回"');
await gesture('up');
assert(played('voice/rules'), '菜单上滑=帮助(规则播报)');
await gesture('doubletap'); // 选中"返回"
assert(game.phase === 'gameover', '菜单双击确认"返回"→ 回结算页');
await advanceUntil(() => !audio._voice, { desc: 'rules 链播完' });
// 下滑=关闭菜单(13 号文档 §7.5 菜单约定的 simon 落地补全)
await gesture('down');
assert(game.phase === 'menu', '再次下滑呼出菜单');
await gesture('down');
assert(game.phase === 'gameover', '菜单内下滑=关闭菜单回结算页');
const stopsBefore = stopBgmLog.length;
await gesture('tap'); // 结算页单击 = 直接重开(P0 言行一致,无论语音是否在播)
assert(stopBgmLog.length === stopsBefore + 1 && stopBgmLog[stopBgmLog.length - 1] === 300, '单击重开 stopBgm(300) 回静默');
await advanceUntil(() => game.phase === 'announce' && game.round === 1, { desc: '重开进第 1 轮' });
assert(game.phase !== 'tutorial' && playCount('voice/skip_hint') === 1, '单击重开跳过教学直达第 1 轮(skip_hint 不重播)');
await advanceUntil(() => game.phase === 'input', { desc: '重开后第 1 轮输入' });
await answerAll();
assert(game.round === 1 && (game.phase === 'roundend' || game.phase === 'announce'), '重开后对局功能正常');
// ---------------------------------------------------------------- 结算屏言行一致(round1 live P0)
section('结算屏言行一致:结算语音链播放中单击=直接重开');
// 新局第 2 轮故意连错两次耗尽复活,再造一次 gameover
await advanceUntil(() => game.phase === 'input' && game.round === 2, { desc: '新局第 2 轮输入' });
{
const wrong = GESTURES.find((g) => g !== game.sequence[0] && g !== 'doubletap' && g !== 'up');
await gesture(wrong);
assert(game.phase === 'roundend' && game.chances === 0, '新局答错消耗唯一复活');
await advanceUntil(() => game.phase === 'input', { desc: '新局重播后回输入' });
const wrong2 = GESTURES.find((g) => g !== game.sequence[0] && g !== 'doubletap' && g !== 'up');
await gesture(wrong2);
assert(game.phase === 'gameover', '复活耗尽再答错 → 第二次 gameover');
}
await advanceUntil(() => !!audio._voice, { desc: '第二次结算语音链开播' });
{
const r1 = playCount('voice/round_01');
await gesture('tap'); // 语音链播放中单击:不再跳句,直接开新局(言行一致是铁律)
assert(game.phase === 'announce' && game.round === 1, 'P0:结算语音播放中单击=直接开新局(不再跳句)');
assert(playCount('voice/round_01') === r1 + 1, 'P0:新局立即开播 round_01(结算语音链被打断)');
}
await advanceUntil(() => game.phase === 'input', { desc: '第三次开局进入第 1 轮输入' });
// ---------------------------------------------------------------- settings.bgm 边界(全局断言 3)
section('settings.bgm 边界');
settings.set({ bgm: 0 });
await flush();
assert(audio._bgm === null, 'bgm=0 立即停播进行中 BGM');
{
const fetches = fetchLog.filter((u) => u.includes('bgm/menu')).length;
audio.play('bgm/menu', { volume: 0.3 });
await flush();
assert(fetchLog.filter((u) => u.includes('bgm/menu')).length === fetches && audio._bgm === null,
'bgm=0 时 play(bgm) 只记意图不加载不播放');
}
settings.set({ bgm: 1.0 });
await advanceUntil(() => !!audio._bgm, { desc: 'bgm 恢复自动补播' });
assert(audio._bgm.id === 'bgm/menu', 'bgm 调回非零自动补播最后的状态 BGM');
audio.stopBgm();
settings.set({ bgm: 1.0 });
// ---------------------------------------------------------------- 场景二:教学期下滑=跳教学(现有裁定回归)
section('场景二:教学期下滑=跳过教学');
game.stop(); // 主场景实例收尾(作废旧会话计时器/语音链)
await advance(500);
{
const game2 = new SimonGame(audio, fakeEl());
gameRef = game2;
game2.start();
await advanceUntil(() => game2.phase === 'tutorial' && lastVoice() === 'voice/t1_up' && !!audio._voice,
{ desc: '场景二 T1 讲解开播' });
await gesture('down'); // 非"等待练下滑"时刻:下滑=跳整个教学
assert(played('voice/t_skip'), '教学讲解期下滑播 t_skip 确认句');
await advanceUntil(() => game2.phase === 'announce' && game2.round === 1, { desc: '跳教学直达第 1 轮' });
assert(game2.phase !== 'tutorial', '下滑跳过教学直达第 1 轮');
game2.stop();
gameRef = game;
await advance(500);
}
// ---------------------------------------------------------------- 资产引用校验
section('资产引用校验(ogg+m4a 双格式,voice 双性别)');
{
const src = fs.readFileSync(path.join(ROOT, 'game.js'), 'utf8');
const ids = new Set();
for (const m of src.matchAll(/'(voice|sfx|bgm)\/[a-z0-9_]+'/gi)) ids.add(m[0].slice(1, -1));
for (let i = 1; i <= 20; i++) ids.add(`voice/round_${String(i).padStart(2, '0')}`); // 动态 id
ids.add('voice/round_more');
const missing = [];
for (const id of ids) {
const [layer, name] = id.split('/');
if (layer === 'voice') {
for (const gender of ['male', 'female']) {
for (const ext of ['ogg', 'm4a']) {
if (!fs.existsSync(path.join(ROOT, 'audio/voice', gender, `${name}.${ext}`))) {
missing.push(`audio/voice/${gender}/${name}.${ext}`);
}
}
}
} else {
for (const ext of ['ogg', 'm4a']) {
if (!fs.existsSync(path.join(ROOT, 'audio', layer, `${name}.${ext}`))) {
missing.push(`audio/${layer}/${name}.${ext}`);
}
}
}
}
assert(missing.length === 0, `game.js 引用 ${ids.size} 个资产 id,双格式双性别零缺失${missing.length ? ':缺 ' + missing.join(', ') : ''}`);
}
// ---------------------------------------------------------------- 汇总
section('汇总');
assert(gestureCount <= 120, `手势预算达标(${gestureCount} ≤ 120)`);
console.warn = origWarn;
console.error = origError;
console.log(`\n通过 ${passed} 条断言,失败 ${failed} 条,注入手势 ${gestureCount} 次,虚拟时长 ${(nowMs / 1000).toFixed(1)}s`);
if (failed > 0) {
console.error('PLAYTEST FAILED');
process.exit(1);
}
console.log('PLAYTEST PASSED');