using Ananta.SDK.Network; using Ananta.SDK.Rpc; using Ananta.SDK.Serialization; using Ananta.Server.Protocol.Client4229938; using Ananta.Server.RpcTypes.Client4229938; using GameMethods = Ananta.Server.RpcTypes.Client4229938.Methods.Game; using SceneMethods = Ananta.Server.RpcTypes.Client4229938.Methods.GameScene; namespace Ananta.Server.Handlers.Game; /// /// 硬直 / 虚弱 / 死亡表现 的回执与出口,2026-09-23 补上。 /// /// 为什么单独一个文件:在此之前服务端只做"发射"——推伤害、推架势条、推死亡包——却没有一条 /// 客户端回执的路径,于是客户端的状态机有入口无出口。玩家看到的三条症状全部出自这里: /// /// 1. 「击倒 / 打晕之后敌人卡住」:客户端用 ReportUnitStandUp(67329238) 和 /// AskAgentExitStiff(67581079) 告诉我们"硬直结束了",而且 ReportUnitStandUp 是 /// Invoke——客户端阻塞等回执。我们没有 handler,客户端的起身流程就一直挂着, /// 单位停在硬直动作里,既不受击也不攻击。 /// 2. 「虚弱窗口关不掉」:破防时我们只改自己的账本,从不推 SyncEnemyDisarmState(true), /// 窗口到点也从不推 SyncEnemyDisarmState(false) + 架势条回满。客户端的虚弱状态是 /// 靠这两个包进出场的(kimika 捐赠端 GameRouter.EnemyGroggy.cs 的完整闭环)。 /// 3. 「死了之后敌人又会出现,变成打不死的状态」:SyncEnemyPrepareDie 只让客户端播死亡, /// 尸体要 SyncClearSpoonEnemies(68237191) 才拆得掉。我们从来没发过它,所以单位带着一条 /// 空血条继续站在场上;而它的账本在死亡序列末尾已经被删,之后的命中无人结算 /// (serverSpawned 且未注册 ⇒ 直接 return),就成了"打不死的幽灵"。 /// /// 来源:kimika 捐赠端(2026-09-22 用户口述可信方)GameRouter.EnemyGroggy.cs / GameRouter.Enemy.cs。 /// 与之不同的只有一点:捐赠端把 stand-up 注册成 HandlerPacketKind.Invoke,这里不带 kind 注册, /// 两种包形态都收、内部再判 IsInvoke 回执——只收 Invoke 会漏掉以 Notify 形态发来的那一次 /// (本服务端自己的 AskEnemyStartFall 就是踩过这个才改成双形态的)。 /// internal sealed partial class GameRouter { /// Deadline for a corpse whose removal the client never acknowledged. After this the /// server pushes SyncClearSpoonEnemies itself, so a dropped report cannot leave an immortal /// corpse behind. Longer than the authored death (HurtStiffFirearmDieConfig: 88300 RemainTime /// 4 s) so the animation is never cut short. private const int LocalEnemyDeathClearGraceMs = 6000; /// 尸体最短存活时间(ms):从死亡序列起算,这段时间内 SyncClearSpoonEnemies /// 一律不发,客户端的死亡报告(ReportEnemyPrepareFinish / ReportAgentDiePerfEnd)只能把清理 /// 提前到这条线,不能提前到倒地动作播完之前。 /// /// 2026-09-23 19:2x 实测:同一套代码随机出现"正常倒地"和"直接消失"两种结果,差别只在客户端 /// 那份报告什么时候到 —— 它是在死亡动画起手时就发的,收到即拆就等于把动画掐了。 /// 3000 ms 对齐 fastpatch hook 的 DEATH_REMAIN(3.0 s,之后它自己 DestroyAgentUnit): /// 服务端不比客户端拆得更早,也不更晚。 private const int LocalEnemyMinCorpseMs = 3000; // ---------------------------------------------------------------- 硬直 (stiff) /// AskAgentEnterStiff(67352229) — 客户端说这个单位进硬直了。Notify,不用回。 /// 记下来是为了让攻击 AI 别在硬直里继续挥(GameRouter.EnemyAiActions)。 [Handler(MethodId.AskAgentEnterStiff)] private async Task LocalEnemyEnterStiff(Connection conn, UxRpcMessage msg) { var uid = TryReadStiffAgentId(msg); if (uid == 0) return; var runtime = GetLocalEnemyRuntimeState(conn.Session); bool tracked; lock (runtime.SyncRoot) { tracked = runtime.Enemies.TryGetValue(uid, out var record) && record is not null; if (tracked) record!.StiffActive = true; } if (!tracked) return; // 尸体又被打进硬直 = 玩家在死后继续连招(近战)。客户端本地的命中反应把倒地动作顶掉 // —— 这就是"近战杀死敌人只会消失"。把倒地动作顶回去,见 // ReassertLocalEnemyDeathAsync 的实测记录。 bool isCorpse; lock (runtime.SyncRoot) isCorpse = runtime.PendingDeathClear.ContainsKey(uid); if (isCorpse) { conn.Log.Info($"[ENEMY] uid={uid} stiff reported on a corpse — re-asserting the down pose"); await ReassertLocalEnemyDeathAsync(conn.Session, uid); return; } // 活着的单位在虚弱窗口里又被打进硬直 = 同一件事:本地受击把击晕姿势(88300)顶掉了。 // 这是"打晕/击倒之后敌人只是呆呆站着"的直接原因 —— 虚弱窗口内玩家当然还在继续打。 // 上报就是"已经被顶掉"的即时证据,立刻补一次(ReassertLocalEnemyGroggyAsync 自带节流)。 bool groggy; lock (runtime.SyncRoot) groggy = runtime.Enemies.TryGetValue(uid, out var g) && g is not null && Environment.TickCount64 < g.GroggyUntilTicks; if (groggy) { conn.Log.Info($"[ENEMY] uid={uid} stiff reported inside the groggy window — re-asserting the down pose"); await ReassertLocalEnemyGroggyAsync(conn.Session, uid); return; } conn.Log.Info($"[ENEMY] client stiff enter uid={uid}"); } /// AskAgentExitStiff(67581079) — 客户端的硬直结束了。Notify,但同样两种形态都收。 /// /// ⛑⛑ 2026-09-23 17:3x:这里不再关闭虚弱窗口。日志证据(console-20260923-172435.log): /// uid=…836 在 17:30:04 破防,17:30:05 就被 client-exit-stiff 关掉 —— 窗口只活了 1 s, /// 因为 67149 一次性动作播完客户端立刻上报退出硬直。而我们正是靠周期重推 67149 来"撑" /// 这个窗口的,于是第一次破防之后每次都被自己提前结束 = "第一次正常,后面不正常"。 /// 窗口的权威出口只剩两个:ReportUnitStandUp(真起身)和 groggy 超时兜底。 /// (捐赠端 kimika 是拿它关窗口的,但它没有周期重推,所以不冲突;我们不能照搬。) [Handler(MethodId.AskAgentExitStiff)] private async Task LocalEnemyExitStiff(Connection conn, UxRpcMessage msg) { var uid = TryReadStiffAgentId(msg); if (uid != 0) { var runtime = GetLocalEnemyRuntimeState(conn.Session); bool wasStiff; lock (runtime.SyncRoot) { wasStiff = runtime.Enemies.TryGetValue(uid, out var record) && record is not null && record.StiffActive; if (runtime.Enemies.TryGetValue(uid, out var r2) && r2 is not null) r2.StiffActive = false; } conn.Log.Info($"[ENEMY] client stiff exit uid={uid} wasStiff={wasStiff} (groggy window left intact)"); } if (msg.IsInvoke) await conn.ReturnEmptyOkAsync(msg); } /// ReportUnitStandUp(67329238) — 单位起身,Invoke,必须回执。 /// 这是"虚弱结束"的权威信号:收到即关窗口,不等超时。 [Handler(MethodId.ReportUnitStandUp)] private async Task LocalEnemyStandUp(Connection conn, UxRpcMessage msg) { if (msg.TryGetArgs(out var args) && args is not null) { var runtime = GetLocalEnemyRuntimeState(conn.Session); bool tracked; lock (runtime.SyncRoot) { tracked = runtime.Enemies.TryGetValue(args.UnitId, out var record) && record is not null; if (tracked) record!.StiffActive = false; } conn.Log.Info($"[ENEMY] stand-up uid={args.UnitId} stiff={args.StiffId} time={args.StiffTime:F2} tracked={tracked}"); await ExitLocalEnemyGroggyAsync(conn.Session, args.UnitId, "client-stand-up"); } else { conn.Log.Info($"[ENEMY] ReportUnitStandUp undecodable bytes={msg.Body.Length}"); } if (msg.IsInvoke) await conn.ReturnEmptyOkAsync(msg); } private static ulong TryReadStiffAgentId(UxRpcMessage msg) { try { return msg.TryGetArgs(out var args) && args is not null ? args.AgentId : 0UL; } catch { return 0UL; } } // ---------------------------------------------------------------- 虚弱 (groggy) 进出场 /// 破防:推 SyncEnemyDisarmState(true) + 架势条 0。 /// 客户端的虚弱 UI 在这个 build 里是半成品(捐赠端注释:SyncEnemyPoiseRate 的 C# 实现有反向 /// guard、SyncEnemyDisarmState 的 C# 体是空的、CheckShowDisarmBar 还卡 maxDisarmValue<=0), /// 真正被客户端"听进去"的是这两条:它自己上报的硬直,以及敌人在这段时间里不再挥击。 /// 账本侧(Poise 归零、GroggyUntilTicks)由调用方在锁内写好,这里只负责出场包。 internal static async Task EnterLocalEnemyGroggyAsync(TcpSession session, ulong uid, string reason) { var runtime = GetLocalEnemyRuntimeState(session); bool groggy; int count; lock (runtime.SyncRoot) { if (!runtime.Enemies.TryGetValue(uid, out var record) || record is null) return; groggy = record.GroggyUntilTicks > 0; count = record.GroggyCount; record.Poise = 0f; } if (!groggy) return; try { await session.NotifyAsync(MethodId.SyncEnemyDisarmState, UxSerializer.Serialize(new SceneMethods.SyncEnemyDisarmState { id = uid, isDisarmed = true }), CancellationToken.None); await session.NotifyAsync(MethodId.SyncEnemyPoiseRate, UxSerializer.Serialize(new GameMethods.SyncEnemyPoiseRateData4229938 { Id = uid, Ratio = 0f }), CancellationToken.None); // 虚弱要有"看得见"的姿势。⛑⛑ 2026-09-23 19:4x 起默认关闭:要么推 88300 倒地 // (重动作,补推必然重启 ⇒ "连续倒地三次"),要么什么都不推(敌人呆站着)。用户已 // 明确接受"击晕无法倒地、只会呆呆站着",保留开关以便日后找到短眩晕动作时一键打开。 if (LocalEnemyGroggyPoseEnabled) { lock (runtime.SyncRoot) runtime.LastGroggyReassert[uid] = Environment.TickCount64; await session.NotifyAsync(MethodId.SyncEnemyPlayAction, UxSerializer.Serialize(new GameMethods.EnemyPlayActionData4229938 { Uid = uid, ActionId = LocalEnemyGroggyActionId, // 88300 倒地,见常量说明 MaxTime = LocalEnemyGroggyMs / 1000f, }), CancellationToken.None); } session.Log.Info($"[ENEMY] groggy enter uid={uid} reason={reason} count={count} windowMs={LocalEnemyGroggyMs}"); } catch (Exception ex) { session.Log.Warn($"[ENEMY] groggy enter push failed uid={uid}: {ex.Message}"); } } /// 虚弱结束:清窗口、架势回满、推 SyncEnemyDisarmState(false) + 架势条 1。 /// 幂等:对不在虚弱中的单位是 no-op,所以 stand-up 报告和超时不会重复推。 internal static async Task ExitLocalEnemyGroggyAsync(TcpSession session, ulong uid, string reason) { var runtime = GetLocalEnemyRuntimeState(session); lock (runtime.SyncRoot) { if (!runtime.Enemies.TryGetValue(uid, out var record) || record is null) return; if (record.GroggyUntilTicks == 0) return; record.GroggyUntilTicks = 0; record.StiffActive = false; record.Poise = LocalEnemyMaxPoise; // 虚弱结束就停止顶姿势,否则敌人会一直被钉在硬直里(正是"打晕之后卡住"的老症状)。 runtime.LastGroggyReassert.Remove(uid); // 让 AI 重新走一次"进入攻击"的路径,拿到新的起手,而不是窗口一关就立刻挥。 record.AttackAiActionAt = Environment.TickCount64 + 300; record.AttackAiCastUntil = 0; } try { await session.NotifyAsync(MethodId.SyncEnemyDisarmState, UxSerializer.Serialize(new SceneMethods.SyncEnemyDisarmState { id = uid, isDisarmed = false }), CancellationToken.None); await session.NotifyAsync(MethodId.SyncEnemyPoiseRate, UxSerializer.Serialize(new GameMethods.SyncEnemyPoiseRateData4229938 { Id = uid, Ratio = 1f }), CancellationToken.None); session.Log.Info($"[ENEMY] groggy exit uid={uid} reason={reason} poise={LocalEnemyMaxPoise:F0}"); } catch (Exception ex) { session.Log.Warn($"[ENEMY] groggy exit push failed uid={uid}: {ex.Message}"); } } /// 每个 AI tick 调一次:关掉到期的虚弱窗口。stand-up 报告没来时这就是兜底出口。 /// 由 GameRouter.EnemyAiActions 的 worker 驱动;worker 退出后若敌人还活着会由命中路径补。 internal static async Task MaintainLocalEnemyGroggyAsync(TcpSession session, long now) { var runtime = GetLocalEnemyRuntimeState(session); List? expired = null; List? reassert = null; lock (runtime.SyncRoot) { foreach (var record in runtime.Enemies.Values) { if (record.GroggyUntilTicks <= 0) continue; // 尸体不再撑姿势。2026-09-23 17:3x 实测:uid=…836 死在破防窗口里(17:30:08 killed, // 窗口到 17:30:12),这 4 s 里周期重推的 67149 一直把倒地动作 88300 顶掉 // —— "死亡动画有时正常、有时直接消失"就是这么来的:死在窗口内 = 消失。 // 窗口本身由死亡序列在开头清掉(LocalEnemyDeathSequenceAsync)。 if (record.Dead || runtime.PendingDeathClear.ContainsKey(record.Uid)) continue; if (now >= record.GroggyUntilTicks) (expired ??= new List()).Add(record.Uid); else if (LocalEnemyGroggyPoseEnabled && (!runtime.LastGroggyReassert.TryGetValue(record.Uid, out var last) || now - last >= LocalEnemyGroggyReassertMs)) { runtime.LastGroggyReassert[record.Uid] = now; (reassert ??= new List()).Add(record.Uid); } } } if (reassert is not null) foreach (var uid in reassert) await SendLocalEnemyGroggyPoseAsync(session, uid); if (expired is null) return; foreach (var uid in expired) await ExitLocalEnemyGroggyAsync(session, uid, "groggy-timeout"); } // ---------------------------------------------------------------- 死亡表现 / 尸体清理 /// 拆尸体:SyncClearSpoonEnemies(68237191)。幂等——第一个调用者赢,后来的都是 no-op。 /// 这是"死了之后敌人还站在场上"的唯一解药;SyncEnemyPrepareDie 只负责播动画。 internal static async Task CompleteLocalEnemyDeathClearAsync(TcpSession session, ulong uid, string reason) { var runtime = GetLocalEnemyRuntimeState(session); lock (runtime.SyncRoot) { if (!runtime.PendingDeathClear.Remove(uid)) return; // 尸体最短存活时间。客户端的死亡报告来得多早都不许把尸体在倒地播完之前拆掉 —— // 见 LocalEnemyRuntimeState.DeathEarliest 的实测说明。到期后由 // SweepLocalEnemyDeathClearsAsync 用同一条路径收尾(deadline 已被设成这个下限)。 if (runtime.DeathEarliest.TryGetValue(uid, out var earliest) && Environment.TickCount64 < earliest) { runtime.PendingDeathClear[uid] = earliest; session.Log.Info($"[ENEMY] clear deferred uid={uid} reason={reason} " + $"until +{(earliest - Environment.TickCount64)}ms (min corpse hold)"); return; } runtime.DeathEarliest.Remove(uid); runtime.Enemies.Remove(uid); runtime.LastDeathReassert.Remove(uid); } try { await session.NotifyAsync(MethodId.SyncClearSpoonEnemies, UxSerializer.Serialize(new GameMethods.SyncClearSpoonEnemiesData4229938 { EnemyIds = [uid] }), CancellationToken.None); session.Log.Info($"[ENEMY] clear uid={uid} reason={reason}"); } catch (Exception ex) { session.Log.Warn($"[ENEMY] clear push failed uid={uid}: {ex.Message}"); } } /// ReportEnemyPrepareFinish(67427299) — 客户端说死亡准备流程走完了,立刻拆尸体。 [Handler(MethodId.ReportEnemyPrepareFinish)] private async Task LocalEnemyPrepareFinish(Connection conn, UxRpcMessage msg) { ulong uid = 0; if (msg.TryGetArgs(out var args) && args is not null) uid = args.Id; if (uid != 0) { conn.Log.Info($"[ENEMY] prepare-finish uid={uid}"); await CompleteLocalEnemyDeathClearAsync(conn.Session, uid, "client-prepare-finish"); } if (msg.IsInvoke) await conn.ReturnEmptyOkAsync(msg); } /// ReportAgentDiePerfEnd(67638502) — 客户端说死亡表现播完了(Invoke,必须回执, /// 否则客户端的死亡流程会挂住)。同样立刻拆尸体。 [Handler(MethodId.ReportAgentDiePerfEnd)] private async Task LocalEnemyDiePerfEnd(Connection conn, UxRpcMessage msg) { ulong uid = 0; if (msg.TryGetArgs(out var args) && args is not null) uid = args.Id; if (uid != 0) { conn.Log.Info($"[ENEMY] die-perf end uid={uid}"); await CompleteLocalEnemyDeathClearAsync(conn.Session, uid, "client-die-perf-end"); } else { conn.Log.Info($"[ENEMY] ReportAgentDiePerfEnd undecodable bytes={msg.Body.Length}"); } if (msg.IsInvoke) await conn.ReturnEmptyOkAsync(msg); } /// 尸体清理的兜底扫描:客户端一个报告都没发来时,超时也要把尸体拆掉。 /// 由攻击 AI worker 驱动。 /// /// ⛑⛑ 2026-09-23 19:4x:这里曾经在扫描时顺手周期补推一次倒地动作,已撤销 —— 那是 /// "远程杀死敌人放两次倒地动画"的一半原因。远程打完玩家就停手,hook 播的那次 88300 本来 /// 完整播完了,周期补推又重启了一次。补推只能挂在"确实被顶掉了"的证据上,也就是客户端 /// 的 AskAgentEnterStiff 上报(ReassertLocalEnemyDeathAsync,且带 900 ms 静默窗口)。 internal static async Task SweepLocalEnemyDeathClearsAsync(TcpSession session, long now) { var runtime = GetLocalEnemyRuntimeState(session); List expired = null!; lock (runtime.SyncRoot) { foreach (var kv in runtime.PendingDeathClear) if (now >= kv.Value) (expired ??= new List()).Add(kv.Key); } if (expired is null) return; foreach (var uid in expired) await CompleteLocalEnemyDeathClearAsync(session, uid, "grace-timeout"); } }