live试玩round1+round2修复: 引擎gesture新增tapcancel(immediateTap误判定回滚,默认关闭零行为变化)+ rhythm判定账簿/超时Miss复活/菜单1s去抖/正片下滑永不弹菜单 + space判定冷静期800ms/暂停恢复3颗缓冲/回大厅leaving收尾/hit_last在播标记 + storyline intro满播不掐/抉择25s兜底 + simon免费重听两层账 + escape教学链自动接播/90s高频零提示 + rpg擦伤零误报/tap不丢 + guide.test适配去抖期(语义不变)+ 文档同步(13号§9-§19/web-engine-api/README)。回归:storyline82/escape140/simon132/rhythm141/space155/rpg188/guide122共921断言全绿

This commit is contained in:
xsl
2026-09-01 22:00:00 +08:00
parent 624fec8a23
commit 4e9970e227
22 changed files with 3161 additions and 401 deletions
+148 -18
View File
@@ -1,6 +1,9 @@
/**
* 盲航(space)无头试玩测试 —— 覆盖 docs/design/13-playtest-criteria.md §5(space 关键路径
* + 边界断言)与 §7(全局通用断言),以及本轮改动(状态 BGM / skipVoice / skip_hint)。
* + 边界断言)与 §7(全局通用断言),以及本轮改动(状态 BGM / skipVoice / skip_hint)、
* §11 round4-live 修复(方向呼喊提前到 spawn / 闪避窗口=呼啸全程 / 护盾 15s+不可用应答 /
* 保护期断崖过渡 / hit_last 呼喊接力 / feat 教学仅首局),以及 round2 R2 修复
* (下滑开菜单冷静期防误暂停 / 暂停恢复缓冲 / 回大厅收尾流程 / 警告链呼喊抑制+坠毁掐断)。
*
* 用法:tools/node/bin/node web/space/test/playtest.mjs
*
@@ -161,13 +164,16 @@ async function advance(ms) { clock.advance(ms); await tick(); }
function makeHarness() {
const audio = new WebAudioEngine('audio/');
const plays = [], skips = [];
const plays = [], skips = [], voiceStarts = [];
const origPlay = audio.play.bind(audio);
audio.play = (id, opts) => { plays.push({ id, opts: opts || {}, t: clock.now }); return origPlay(id, opts); };
// _playVoice 记录 voice 实际起播(queue 排队件由引擎内部接力,不经 play() 调用日志)
const origPlayVoice = audio._playVoice.bind(audio);
audio._playVoice = (id, opts) => { voiceStarts.push({ id, t: clock.now }); return origPlayVoice(id, opts); };
const origSkip = audio.skipVoice.bind(audio);
audio.skipVoice = () => { skips.push(clock.now); return origSkip(); };
const game = new SpaceGame(audio, {});
return { audio, game, plays, skips };
return { audio, game, plays, skips, voiceStarts };
}
const voices = (plays) => plays.filter((p) => p.id.startsWith('voice/')).map((p) => p.id);
const lastVoice = (plays) => voices(plays).at(-1);
@@ -257,6 +263,8 @@ try {
assert(game.hits === 0, '保护期撞击不掉血(训练护盾吸收)');
assert(lastVoice(plays) === 'voice/protect_left', 'protect_<dir> 与来袭方向一致(左)');
assert(vibrated('30,50,30,50,30'), '保护期吸收 WARNING 震动');
assert(!plays.some((p) => p.t >= t0 && p.id.startsWith('voice/dir_')),
'撞击点落在保护期内的陨石 spawn 不播 dir_<dir>(§11:protect 长句教学优先)');
// -- §5.3 第 20s 单击引入(feat_boost 可跳) --
await advance(t0 + 20000 - clock.now);
@@ -284,7 +292,7 @@ try {
assert(game.phase === 'feature' && game.featureKind === 'shield', '第 45s 教双击护盾');
game.onGesture('doubletap'); await tick();
assert(game.shieldUpUntil > clock.now, '双击 → 护盾生效(§5.3)');
assert(game.shieldReadyAt === clock.now + 30000, '护盾充能 30s 锁定');
assert(game.shieldReadyAt === clock.now + 15000, '护盾充能 15s 锁定(§11 改口,原 30s)');
assert(game.phase === 'playing', '护盾引入结束恢复 playing');
// 护盾格挡一颗(消耗护盾,不掉血)
@@ -298,6 +306,17 @@ try {
await advance(1400); // feat_shield_done 播完 → 排队的 shield_block 语音
assert(voices(plays).includes('voice/shield_block'), 'shield_block 排队语音播出');
// -- §11 护盾充能中双击:即时应答 + 4s 去抖(P1 panic 双击零反馈) --
const featDoneCount = () => voices(plays).filter((id) => id === 'voice/feat_shield_done').length;
const featDoneBefore = featDoneCount();
game.onGesture('doubletap'); await tick();
assert(game.shieldUpUntil === 0, '充能中双击不展开护盾');
assert(featDoneCount() === featDoneBefore + 1,
'护盾不可用即时应答:播 feat_shield_done 现成句(§11,不新增语音 id)');
assert(vibrated('40,100,40'), '护盾不可用 HEARTBEAT 震动(与撞击/警报可区分)');
game.onGesture('doubletap'); await tick();
assert(featDoneCount() === featDoneBefore + 1, '护盾不可用应答 4s 去抖(连刷不重播)');
// -- 切断自然刷怪(测试钩子),后续手动投放,避免难度曲线干扰 --
const naturalSpawn = game._spawnObstacle.bind(game);
game._spawnObstacle = () => {};
@@ -312,21 +331,46 @@ try {
'shield_ready 充能完毕播报恰好一次');
assert(vibrated('15'), '护盾就绪 PICKUP 震动');
// -- §5.4 正常闪避 3 颗(同向滑,须在撞击前 1.5s 判定窗口内出手) --
// -- §5.4/§11 正常闪避 3 颗(同向滑;§11 起窗口=呼啸全程,此处仍在撞击前 800ms 出手) --
// 同循环验证 §11 断崖过渡(裸飞第 1~3 颗逼近固定 4.0s)与方向呼喊时机(spawn 同窗)
let expectDodges = game.dodges;
let bareIdx = 0;
for (const rv of [0, 0.25, 0.5]) { // left / right / up
bareIdx++;
randQueue.push(rv);
game.obstacles.clear(); game.swipes = [];
const tSpawn = clock.now;
naturalSpawn();
const ob = [...game.obstacles].at(-1);
await tick(); // voice 播放安全门:播放经 microtask 落位(虚拟时钟不走,t 仍 = tSpawn)
assert(ob.impactAt - tSpawn === 4000,
`断崖过渡:裸飞第 ${bareIdx} 颗逼近 4.0s 不收缩(§11)`);
assert(plays.some((p) => p.id === `voice/dir_${ob.dir}` && p.t === tSpawn),
`方向呼喊 dir_${ob.dir} 与呼啸同窗(spawn 当刻,§11)`);
await advance(ob.impactAt - clock.now - 800); // 逼近到撞击前 800ms
game.onGesture(ob.dir); // 判定窗口内同向滑
await advance(900); // 过撞击点
expectDodges++;
assert(game.dodges === expectDodges, `同向滑闪避成功(${ob.dir},dodges=${expectDodges})`);
assert(game.hits === 0, '同向闪避不掉血');
assert(plays.filter((p) => p.id === `voice/dir_${ob.dir}` && p.t >= tSpawn).length === 1,
'撞击前 500ms loud 警报档不再重复方向呼喊(§11)');
}
// -- §11 早滑不再被吞:呼啸响起立刻滑(距撞击 >1.5s)也判躲开(P1 核心回归) --
randQueue.push(0.75); // down
game.obstacles.clear(); game.swipes = [];
const tEarly = clock.now;
naturalSpawn();
const obEarly = [...game.obstacles].at(-1);
assert(obEarly.impactAt - tEarly < 4000, '断崖过渡:裸飞第 4 颗起接入难度曲线(<4.0s,§11)');
await advance(200); // 呼啸响起 0.2s 内立刻同向滑(旧版 1.5s 窗口之外,必被吞的位置)
game.onGesture('down');
await advance(obEarly.impactAt - clock.now + 100); // 过撞击点
expectDodges++;
assert(game.dodges === expectDodges && game.hits === 0,
'早滑(spawn+0.2s,距撞击 >1.5s)判定躲开(§11 窗口=呼啸全程)');
// -- 边界:反向滑 ≠ dodge(顺便作为第 1 次实打实撞击) --
randQueue.push(0); // left
game.obstacles.clear(); game.swipes = [];
@@ -343,18 +387,42 @@ try {
game.onGesture('tap'); await tick();
assert(skips.length === skipsB2 + 1, '归因语音在播时单击 = skipVoice()');
// -- 边界:第 2 次被撞追加 hit_last --
// -- 边界/§11+round2 R2:第 2 次被撞追加 hit_last;呼喊接力保留 + 接播期间呼喊抑制 --
randQueue.push(0.25); // right
game.obstacles.clear(); game.swipes = [];
naturalSpawn();
const obH2 = [...game.obstacles].at(-1);
await advance(obH2.impactAt - clock.now + 50);
assert(game.hits === 2 && game.phase === 'playing', 'hits===2 不坠毁');
await advance(1300); // hit_right 播完
await advance(1300); // hit_last 播出并播完
const vi = voices(plays);
const h2i = vi.lastIndexOf('voice/hit_right');
assert(h2i >= 0 && vi[h2i + 1] === 'voice/hit_last', '第 2 次被撞追加 hit_last');
assert(h.voiceStarts.at(-1).id === 'voice/hit_right', '归因 hit_right 开播');
assert(game._hitLastPending === true, 'hit_last 挂起待接播(§11 呼喊接力)');
randQueue.push(0); // left:刷下一颗裸飞陨石,其 spawn 呼喊顶断归因句(生存优先级保留)
naturalSpawn();
const obNext = [...game.obstacles].at(-1);
await tick();
assert(h.voiceStarts.at(-1).id === 'voice/dir_left', '下一颗 spawn 呼喊顶断归因(生存优先级)');
await advance(1300); // 呼喊播完 → onComplete 接力 → hit_last 自动接播
assert(h.voiceStarts.at(-1).id === 'voice/hit_last',
'归因被呼喊顶断后 hit_last 仍接播(§11:最后一命不再被静默吞掉)');
assert(game._hitLastPending === false, 'hit_last 接播后挂起标记清除');
assert(game._hitLastPlaying === true, 'hit_last 在播标记置位(round2 R2)');
// round2 R2:hit_last 在播期间新刷陨石的 spawn 呼喊被抑制(警告播完前不被顶断)
const shoutMark2 = h.voiceStarts.length;
randQueue.push(0.5); // up
naturalSpawn();
const obNext2 = [...game.obstacles].at(-1);
await tick();
assert(!h.voiceStarts.slice(shoutMark2).some((v) => v.id === 'voice/dir_up'),
'hit_last 在播期间 spawn 不播方向呼喊(round2 R2:警告播完前不被顶断)');
await advance(1300); // hit_last 播完 → 呼喊抑制解除
assert(game._hitLastPlaying === false, 'hit_last 播完在播标记清除');
randQueue.push(0.5); // up:链后再刷陨石,方向呼喊恢复
naturalSpawn();
await tick();
assert(h.voiceStarts.at(-1).id === 'voice/dir_up', '警告链结束后方向呼喊恢复(round2 R2)');
game.obstacles.delete(obNext);
game.obstacles.delete(obNext2);
game.obstacles.clear(); game.swipes = [];
// -- 边界:里程碑 dodges=10 播 ms_10 --
game.dodges = 9; // 13 号文档允许的测试钩子
@@ -370,14 +438,19 @@ try {
assert(vibrated('20'), '里程碑 CONFIRM 震动');
await advance(1300);
// -- §5.4 第 3 次撞击坠毁 --
// -- §5.4 第 3 次撞击坠毁(round2 R2 B4:警告链在播时坠毁即掐断,结算不叠播) --
randQueue.push(0.75); // down
game.obstacles.clear(); game.swipes = [];
naturalSpawn();
const obH3 = [...game.obstacles].at(-1);
const crashAt = obH3.impactAt;
await advance(obH3.impactAt - clock.now + 50);
await advance(obH3.impactAt - clock.now - 500); // 撞击前 500ms:重挂"最后一命"警告链
game._hitLastPending = true; // 测试钩子:模拟第 2 撞后归因刚播完、警告待接播
game._drainHitLast(); await tick();
assert(h.voiceStarts.at(-1).id === 'voice/hit_last' && !!audio._voice, '坠毁前 hit_last 在播');
await advance(600); // 过撞击点 → hits===3 → 坠毁
assert(game.hits === 3 && game.phase === 'over', 'hits===3 才坠毁 gameover');
assert(!audio._voice, '坠毁即掐断警告链:hit_last 不再与结算叠播(round2 R2 B4)');
assert(plays.some((p) => p.id === 'sfx/crash'), '坠毁音 crash');
assert(audio._bgm === null, '坠毁即 stopBgm(结算前先静)');
assert(vibrated('60,40,60'), '坠毁 ERROR 震动');
@@ -410,12 +483,26 @@ try {
assert(game.phase === 'playing' && game.hits === 0 && game.dodges === 0, '结算态单击重开新局');
assert(audio._bgm && audio._bgm.id === 'bgm/engine', '重开淡回 bgm/engine');
// -- §11 feat 教学仅首局:第 2 局过 20s/45s 不进 feature、不重播,机制开局即用 --
const featMark = plays.length;
await advance(20050); // 过单击引入时刻(保护期内陨石不伤血)
assert(game.phase === 'playing', '第 2 局过 20s 不进 feature phase(§11)');
game.onGesture('tap'); await tick(); // 无旁白在播 → 加速直接生效
assert(game.boostUntil > clock.now, '第 2 局单击加速开局即用(§11)');
game.onGesture('doubletap'); await tick();
assert(game.shieldUpUntil > clock.now, '第 2 局双击护盾开局即用(§11)');
await advance(25050); // 过双击引入时刻
assert(game.phase === 'playing', '第 2 局过 45s 仍不进 feature phase(§11)');
assert(!plays.slice(featMark).some((p) => p.id === 'voice/feat_boost' || p.id === 'voice/feat_shield'),
'第 2 局 feat_boost/feat_shield 不再重播(§11)');
// ======================================================================
section('S2 菜单:状态 BGM 切换 + 下滑折中 + 全球约定(§7.5)');
// ======================================================================
game.obstacles.clear();
game.obstacles.clear(); game.swipes = [];
game._lastResolveAt = -Infinity; // 测试钩子:跳过开菜单冷静期(round2 R2)
game.onGesture('down'); await tick();
assert(game.phase === 'menu', '无下方陨石时下滑 = 菜单(下滑折中)');
assert(game.phase === 'menu', '无悬置陨石且过冷静期时下滑 = 菜单(下滑折中)');
assert(plays.some((p) => p.id === 'bgm/menu' && p.opts.fadeMs === 300),
'进菜单 crossfade 300ms 切 bgm/menu');
await tick();
@@ -435,6 +522,39 @@ try {
await tick();
assert(audio._bgm && audio._bgm.id === 'bgm/engine', '对局态 BGM 恢复 engine');
// -- round2 R2 恢复缓冲:恢复后前 3 颗裸飞陨石回 4.0s 慢节奏(软重启,不再 3 秒暴毙) --
game.protectionUntil = clock.now - 1000; // 测试钩子:强制视为保护期已过
game._protectAnnounced = true; // 不再补播 protect_end
for (let i = 1; i <= 3; i++) {
randQueue.push(0); // left
game.obstacles.clear(); game.swipes = [];
const tR = clock.now;
naturalSpawn();
const obR = [...game.obstacles].at(-1);
assert(obR.impactAt - tR === 4000,
`恢复缓冲:暂停恢复后第 ${i} 颗裸飞陨石逼近 4.0s 不收缩(round2 R2)`);
}
randQueue.push(0);
game.obstacles.clear(); game.swipes = [];
const tR4 = clock.now;
naturalSpawn();
const obR4 = [...game.obstacles].at(-1);
assert(obR4.impactAt - tR4 < 4000, '恢复缓冲:第 4 颗起回到难度曲线(round2 R2)');
game.obstacles.clear(); game.swipes = [];
// -- round2 R2 误暂停守卫:判定后冷静期内的下滑绝不开菜单;过冷静期保留旧折中 --
game._lastResolveAt = clock.now; // 测试钩子:模拟"刚判定完(撞/躲)"
game.onGesture('down'); await tick();
assert(game.phase === 'playing', '判定后 800ms 冷静期内下滑不开菜单(round2 R2 误暂停修复)');
game._lastResolveAt = -Infinity;
randQueue.push(0); // left:非下方陨石悬置中(过冷静期保留旧折中,仍可开菜单)
naturalSpawn();
await tick();
game.onGesture('down'); await tick();
assert(game.phase === 'menu', '非下方陨石悬置且过冷静期下滑 = 菜单(旧折中保留,round2 R2)');
game._closeMenu(); await tick(); // 恢复对局继续后续用例
game.obstacles.clear(); game.swipes = [];
// 下滑折中另一侧:有下方陨石时下滑 = 闪避
randQueue.push(0.75); // down
game.obstacles.clear(); game.swipes = [];
@@ -447,13 +567,22 @@ try {
await advance(900);
assert(game.dodges === 1, '下滑闪避生效 dodges=1');
// 菜单"回大厅"
game.obstacles.clear();
// 菜单"回大厅"(round2 R2 收尾流程:leaving 态 + 确认句 → 跳转,不再落无声死界面)
game.obstacles.clear(); game.swipes = [];
game._lastResolveAt = -Infinity; // 测试钩子:跳过开菜单冷静期
game.onGesture('down'); await tick();
assert(game.phase === 'menu', '过冷静期后下滑再开菜单(回大厅前置)');
game.onGesture('tap'); await tick(); // → menu_lobby
game.onGesture('doubletap'); await tick();
assert(window.location.href === '../', '菜单双击确认 menu_lobby = 回大厅');
assert(game.phase === 'leaving', '回大厅确认中:leaving 收尾态(round2 R2)');
assert(lastVoice(plays) === 'voice/menu_lobby', '先播回大厅确认句再跳转(round2 R2)');
assert(window.location.href !== '../', '确认句播完前不跳转(round2 R2)');
game.onGesture('left'); await tick(); // leaving 中滑动不再受理
assert(game.phase === 'leaving', 'leaving 中滑动手势被忽略(round2 R2)');
game.onGesture('tap'); await tick(); // leaving 中单击 = 跳过确认句 → 立即跳转
assert(window.location.href === '../', '确认句跳过后跳转大厅(round2 R2)');
window.location.href = '';
game.stop();
// ======================================================================
section('S3 二周目:ask 流程 + skip_hint 不重复插播');
@@ -557,6 +686,7 @@ try {
for (const d of ['left', 'right', 'up', 'down']) {
ids.add(`voice/protect_${d}`);
ids.add(`voice/hit_${d}`);
ids.add(`voice/dir_${d}`);
}
const missing = [];
for (const id of ids) {