feat(new8): 批次1收口——radio/dungeon 双双标记可交付(R3 synthesis×2 + H1 热修 + 定向live复验PASS)

- radio:R3 放行(4/4 人设满分档 5/5、愿意玩、P0=0;live 四修坐实+SA3 真声入局)
- dungeon:R1 0/4→R3 4/4 翻案达成;H1 死亡结算链截断根修(真凶=双击窗幽灵 tap 死后回调路由进 SETTLE,800ms 手势死窗;7 断言含旧代码反证)+ 定向 live 复验 PASS(两局含 R3 同款幽灵场景,结算链 7 句逐句完整)
- 章程账本更新:批次 1 验收 ☑(R4-R6 经用户裁定暂停,视上手反馈续;v2 首条=漫游迷路抢救盲区)
- 断言账面:radio 246 / dungeon 1649 / 9 套件全绿
This commit is contained in:
xsl
2026-09-03 03:01:45 +08:00
parent f7509ad5d8
commit 45124a1b9b
11 changed files with 464 additions and 3 deletions
+17 -1
View File
@@ -17,7 +17,9 @@
* 死亡归因真话律:die_cause_* / die_adv_* 按 lastHit(击杀者/背刺/未格挡)与
* run.parries 真实选择;卡关双轨(R3 E0-1):无操作轨 60/120/180s(§10.3,手势重置)
* + 无进展轨 30/60/90s(只由楼层/格位置/战斗回合/拾取重置:earcon→短句→导航接管,
* 乱按不再饿死;教学态接管=滚出教学);踩上楼梯格到达即报贴身铁器回声(R3 E1-1)。
* 乱按不再饿死;教学态接管=滚出教学);踩上楼梯格到达即报贴身铁器回声(R3 E1-1);
* 结算入口 800ms 手势死窗(H1:死亡瞬间在途的 tap 按新状态路由会把结算链掐死在
* 半句——_enterTitle/stopVoice 截杀 die_intro,R3 live A 局 2787.057 实锤)。
*
* 豁免 E1-E4:单击=脉冲、长按=前进/左右滑=转身(E2)、对局期
* setRateBoostEnabled(false)(E3,EXPLORE/BATTLE/DRILL/DESCEND,TITLE/MENU/SETTLE
@@ -50,6 +52,13 @@ const AMB_AFTER_MS = 20000; // 探索呼吸层(R2 D0-3②):静默超 2
const AMB_VOLUME = 0.35;
const FLEE_HOLD_MS = 900; // 战斗逃跑长按最短时长(R3 打磨:探索走步 450ms 不变)
const LONGPRESS_MS = 450; // 引擎 longpress 判定阈值(gesture.js cfg,只读参照)
// H1:进入结算后的手势死窗。gesture.js 非即时模式单击要等双击窗(300ms)关闭才回调——
// 致命一击落地瞬间仍在途的 tap 会在死后 ~300ms 才派发,按「当时」状态路由进 SETTLE:
// tap=离开结算 → _enterTitle 的 stopVoice() 把 die_intro 掐死在半句、整条结算链作废
// (R3 A 局 2787.057 实锤:die_intro vstart 2786.88 → vend kill 2787.057)。手势从按下
// 到回调最迟 touchstart+450ms(长按判定)+300ms(双击窗)≈ 750ms,800ms 死窗全盖住;
// 死窗后结算手势照常(tap 回标题 / 双击重听链 / 下滑菜单)。_settleWin 同栅。
const SETTLE_GRACE_MS = 800;
/** 回声编码(§3.1):正前距离 4 档 → 延迟 / 音量。 */
const ECHO_DELAY = [0, 250, 500, 800, 1100];
@@ -139,6 +148,7 @@ export class DungeonGame extends Game {
this._walk = null;
this._pulseAt = -1e9;
this._lastHit = null; // 归因真话律依据(击杀者/背刺/未格挡)
this._settleGraceUntil = 0; // H1:结算手势死窗截止时刻(performance.now() 时基)
this._lastEcho = null; // 最近一次脉冲回声(双击重发一致性断言用)
this._onStairs = false;
this._stairsSpokenFloor = 0;
@@ -570,6 +580,10 @@ export class DungeonGame extends Game {
this.guide.disarm(); // 任何手势复位向导看门狗(off 档 no-op)
if (g === 'longpressend') { this._stopWalk(); this._battleFleeCancel(); return; } // 仅停走/取消逃跑,不承载第二语义
if (this.menuOpen) { this._onMenuGesture(g); return; }
// H1:结算死窗。死亡/通关瞬间仍在途的手势(引擎双击窗 300ms 延迟派发)落进
// SETTLE 会被当成本态手势——tap=离开结算 → stopVoice 把结算链掐死在半句。
// 死窗内一律吞掉(ghost 手势不算玩家输入,_stuck 队列也不作废)。
if (this.state === 'SETTLE' && !this.menuOpen && performance.now() < this._settleGraceUntil) return;
this._stuck.pending.length = 0; // 玩家操作即作废未播的卡关提示(§10.3)
switch (this.state) {
case 'TITLE': this._onTitleGesture(g); break;
@@ -1252,6 +1266,7 @@ export class DungeonGame extends Game {
this._battle = null;
this._stopWalk();
this.state = 'SETTLE';
this._settleGraceUntil = performance.now() + SETTLE_GRACE_MS; // H1:吞掉在途手势(见常量注)
this._session++;
this._clearTimers();
this.audio.setRateBoostEnabled(true); // E3:结算恢复
@@ -1281,6 +1296,7 @@ export class DungeonGame extends Game {
this._battle = null;
this._stopWalk();
this.state = 'SETTLE';
this._settleGraceUntil = performance.now() + SETTLE_GRACE_MS; // H1:同 _die(在途 tap 会误离开结算)
this._session++;
this._clearTimers();
this.audio.setRateBoostEnabled(true);
+52
View File
@@ -1240,6 +1240,57 @@ async function scenario6c() {
game.stop();
}
// ================================ 场景 6d:H1——死亡瞬间在途手势不得截杀结算链 ================================
// R3 live A 局实锤(dungeon-20260902150633 events.jsonl):致命 tel_heavy 落地
// (t=2786.78 resolve → _die)时玩家上一记 tap 还压在引擎双击窗(300ms)里没派发,
// 2787.057 才回调——此时 state 已是 SETTLE,tap=「离开结算」→ _enterTitle() 的
// stopVoice() 把 die_intro 掐死在 0.18s(vend kill),后 6 句零 fetch 零 vstart,
// 结算屏静默 76s(下一次 tap 在 TITLE 上开出新周目 sys_new_run)。
// 回归断言:死亡结算后死窗(game SETTLE_GRACE_MS=800ms)内在途 tap 被吞,
// 整链 7 句完整播出(结算屏有内容);死窗后 tap 正常离开结算(设计出口不受影响)。
// 旧代码在本场景必挂:首个在途 tap 即跳 TITLE,die_cause_* 起零播放。
async function scenario6d() {
origConsoleLog('\n=== 场景 6d(H1):死亡瞬间在途 tap → 结算链不被截杀,7 句完整播出 ===');
storageMap.clear();
const { game, audio } = newGame({});
game.start();
await tick(400);
g(game, 'tap');
await tick(6000);
g(game, 'down');
await tick(2000);
game._mobStep = () => {};
game.run.hp = 1; // 钩子:窟主反一刀致命(对齐 live:crab 重击收尾)
game._encounter({ type: 'crab', x: game.run.pos.x, y: game.run.pos.y, hp: 20, maxHp: 20 });
await tick(2500);
game._battle.bearing = (DIR_ORDER.indexOf(game.run.face) + 2) % 4; // 钩子:绕到背后(live 尾段同款)
const mark = audio.log.length;
g(game, 'tap'); // 挥空 → btl_swish 战斗收尾链 → 敌前摇 → 反一刀 → 死(live 同款序列)
await tick(2300); // swish(1s) 播完 → enemyTurn → 前摇 1.2s → resolve 死亡(tap 后 +2.2s)
assert(game.state === 'SETTLE', 'H1: 致命一击 → SETTLE');
// 死亡瞬间仍在途的 tap #1:与死亡同拍(旧代码此处即 _enterTitle 截杀结算链)
g(game, 'tap');
assert(game.state === 'SETTLE', 'H1: 死后同拍在途 tap 被死窗吞掉(仍在 SETTLE,不离开结算)');
// tap #2:死亡 +300ms 派发(对齐引擎双击窗回调延迟,live 2787.057-2786.78≈277ms)
await tick(200);
g(game, 'tap');
assert(game.state === 'SETTLE', 'H1: 死后 +300ms 在途 tap 同样被吞(仍在 SETTLE)');
await tick(8000); // 结算链 7 句应完整播出(旧代码在 die_intro 半句即断)
const p = plays(audio, mark);
assert(p.orderOk('voice/die_intro', 'voice/die_cause_3', 'voice/die_adv_3', 'voice/st_floor_1',
'voice/meta_gold_1', 'voice/settle_stronger', 'voice/settle_again'),
'H1: 结算链 7 句完整播出(die_intro → die_cause_3 → die_adv_3 → st_floor → 金币档 → settle_stronger → settle_again)——结算屏有内容');
assert(p.count('voice/die_intro') === 1, 'H1: die_intro 恰播一次(在途 tap 未触发 _enterTitle/stopVoice 截杀)');
assert(game.state === 'SETTLE' && game.run === null, 'H1: 链播完仍安居 SETTLE(run 已清、meta 保留)');
// 死窗后(>800ms)设计出口照常:tap → TITLE
await tick(2000);
g(game, 'tap');
await tick(1500);
assert(game.state === 'TITLE' && !game.run, 'H1: 死窗后 tap 正常离开结算 → TITLE(设计出口不受死窗影响)');
game.stop();
}
// ================================ 场景 7:菜单暂停/恢复(E4,§2.4/§12.8) ================================
async function scenario7({ game, audio }) {
@@ -2039,6 +2090,7 @@ try {
await scenario7(s6);
await scenario6b();
await scenario6c();
await scenario6d(); // H1:死亡瞬间在途 tap 不截杀结算链
await scenario8();
await scenario9();
await scenario10();