Files
Ananta-ps/Ananta.Server/Ananta.Handlers/Game/GameRouter.EnemyStiff.cs
T

411 lines
21 KiB
C#
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.
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;
/// <summary>
/// 硬直 / 虚弱 / 死亡表现 的<b>回执与出口</b>,2026-09-23 补上。
///
/// 为什么单独一个文件:在此之前服务端只做"发射"——推伤害、推架势条、推死亡包——却没有一条
/// 客户端回执的路径,于是客户端的状态机有入口无出口。玩家看到的三条症状全部出自这里:
///
/// 1. 「击倒 / 打晕之后敌人卡住」:客户端用 ReportUnitStandUp(67329238) 和
/// AskAgentExitStiff(67581079) 告诉我们"硬直结束了",而且 ReportUnitStandUp 是
/// <b>Invoke</b>——客户端阻塞等回执。我们没有 handler,客户端的起身流程就一直挂着,
/// 单位停在硬直动作里,既不受击也不攻击。
/// 2. 「虚弱窗口关不掉」:破防时我们只改自己的账本,从不推 SyncEnemyDisarmState(true),
/// 窗口到点也从不推 SyncEnemyDisarmState(false) + 架势条回满。客户端的虚弱状态是
/// 靠这两个包进出场的(kimika 捐赠端 GameRouter.EnemyGroggy.cs 的完整闭环)。
/// 3. 「死了之后敌人又会出现,变成打不死的状态」:SyncEnemyPrepareDie 只让客户端<b>播</b>死亡,
/// 尸体要 SyncClearSpoonEnemies(68237191) 才拆得掉。我们从来没发过它,所以单位带着一条
/// 空血条继续站在场上;而它的账本在死亡序列末尾已经被删,之后的命中无人结算
/// (serverSpawned 且未注册 ⇒ 直接 return),就成了"打不死的幽灵"。
///
/// 来源:kimika 捐赠端(2026-09-22 用户口述可信方)GameRouter.EnemyGroggy.cs / GameRouter.Enemy.cs。
/// 与之不同的只有一点:捐赠端把 stand-up 注册成 HandlerPacketKind.Invoke,这里不带 kind 注册,
/// 两种包形态都收、内部再判 IsInvoke 回执——只收 Invoke 会漏掉以 Notify 形态发来的那一次
/// (本服务端自己的 AskEnemyStartFall 就是踩过这个才改成双形态的)。
/// </summary>
internal sealed partial class GameRouter
{
/// <summary>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.</summary>
private const int LocalEnemyDeathClearGraceMs = 6000;
/// <summary>尸体<b>最短</b>存活时间(ms):从死亡序列起算,这段时间内 SyncClearSpoonEnemies
/// 一律不发,客户端的死亡报告(ReportEnemyPrepareFinish / ReportAgentDiePerfEnd)只能把清理
/// 提前到这条线,不能提前到倒地动作播完之前。
///
/// 2026-09-23 19:2x 实测:同一套代码随机出现"正常倒地"和"直接消失"两种结果,差别只在客户端
/// 那份报告什么时候到 —— 它是在死亡动画<b>起手</b>时就发的,收到即拆就等于把动画掐了。
/// 3000 ms 对齐 fastpatch hook 的 DEATH_REMAIN(3.0 s,之后它自己 DestroyAgentUnit):
/// 服务端不比客户端拆得更早,也不更晚。</summary>
private const int LocalEnemyMinCorpseMs = 3000;
// ---------------------------------------------------------------- 硬直 (stiff)
/// <summary>AskAgentEnterStiff(67352229) — 客户端说这个单位进硬直了。Notify,不用回。
/// 记下来是为了让攻击 AI 别在硬直里继续挥(GameRouter.EnemyAiActions)。</summary>
[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}");
}
/// <summary>AskAgentExitStiff(67581079) — 客户端的硬直结束了。Notify,但同样两种形态都收。
///
/// ⛑⛑ 2026-09-23 17:3x:<b>这里不再关闭虚弱窗口</b>。日志证据(console-20260923-172435.log):
/// uid=…836 在 17:30:04 破防,17:30:05 就被 client-exit-stiff 关掉 —— 窗口只活了 1 s,
/// 因为 67149 一次性动作播完客户端立刻上报退出硬直。而我们正是靠周期重推 67149 来"撑"
/// 这个窗口的,于是第一次破防之后每次都被自己提前结束 = "第一次正常,后面不正常"。
/// 窗口的权威出口只剩两个:ReportUnitStandUp(真起身)和 groggy 超时兜底。
/// (捐赠端 kimika 是拿它关窗口的,但它没有周期重推,所以不冲突;我们不能照搬。)</summary>
[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);
}
/// <summary>ReportUnitStandUp(67329238) — 单位起身,<b>Invoke</b>,必须回执。
/// 这是"虚弱结束"的权威信号:收到即关窗口,不等超时。</summary>
[Handler(MethodId.ReportUnitStandUp)]
private async Task LocalEnemyStandUp(Connection conn, UxRpcMessage msg)
{
if (msg.TryGetArgs<GameMethods.ReportUnitStandUpArgs4229938>(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<GameMethods.AskAgentStiffArgs4229938>(out var args) && args is not null
? args.AgentId
: 0UL;
}
catch
{
return 0UL;
}
}
// ---------------------------------------------------------------- 虚弱 (groggy) 进出场
/// <summary>破防:推 SyncEnemyDisarmState(true) + 架势条 0。
/// 客户端的虚弱 UI 在这个 build 里是半成品(捐赠端注释:SyncEnemyPoiseRate 的 C# 实现有反向
/// guard、SyncEnemyDisarmState 的 C# 体是空的、CheckShowDisarmBar 还卡 maxDisarmValue&lt;=0),
/// 真正被客户端"听进去"的是这两条:它自己上报的硬直,以及敌人在这段时间里不再挥击。
/// 账本侧(Poise 归零、GroggyUntilTicks)由调用方在锁内写好,这里只负责出场包。</summary>
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 起<b>默认关闭</b>:要么推 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}");
}
}
/// <summary>虚弱结束:清窗口、架势回满、推 SyncEnemyDisarmState(false) + 架势条 1。
/// 幂等:对不在虚弱中的单位是 no-op,所以 stand-up 报告和超时不会重复推。</summary>
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}");
}
}
/// <summary>每个 AI tick 调一次:关掉到期的虚弱窗口。stand-up 报告没来时这就是兜底出口。
/// 由 GameRouter.EnemyAiActions 的 worker 驱动;worker 退出后若敌人还活着会由命中路径补。</summary>
internal static async Task MaintainLocalEnemyGroggyAsync(TcpSession session, long now)
{
var runtime = GetLocalEnemyRuntimeState(session);
List<ulong>? expired = null;
List<ulong>? 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<ulong>()).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<ulong>()).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");
}
// ---------------------------------------------------------------- 死亡表现 / 尸体清理
/// <summary>拆尸体:SyncClearSpoonEnemies(68237191)。幂等——第一个调用者赢,后来的都是 no-op。
/// 这是"死了之后敌人还站在场上"的唯一解药;SyncEnemyPrepareDie 只负责播动画。</summary>
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}");
}
}
/// <summary>ReportEnemyPrepareFinish(67427299) — 客户端说死亡准备流程走完了,立刻拆尸体。</summary>
[Handler(MethodId.ReportEnemyPrepareFinish)]
private async Task LocalEnemyPrepareFinish(Connection conn, UxRpcMessage msg)
{
ulong uid = 0;
if (msg.TryGetArgs<GameMethods.ReportEnemyPrepareFinishArgs4229938>(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);
}
/// <summary>ReportAgentDiePerfEnd(67638502) — 客户端说死亡表现播完了(<b>Invoke</b>,必须回执,
/// 否则客户端的死亡流程会挂住)。同样立刻拆尸体。</summary>
[Handler(MethodId.ReportAgentDiePerfEnd)]
private async Task LocalEnemyDiePerfEnd(Connection conn, UxRpcMessage msg)
{
ulong uid = 0;
if (msg.TryGetArgs<GameMethods.ReportAgentDiePerfEndArgs4229938>(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);
}
/// <summary>尸体清理的兜底扫描:客户端一个报告都没发来时,超时也要把尸体拆掉。
/// 由攻击 AI worker 驱动。
///
/// ⛑⛑ 2026-09-23 19:4x:这里曾经在扫描时<b>顺手</b>周期补推一次倒地动作,已撤销 —— 那是
/// "远程杀死敌人放两次倒地动画"的一半原因。远程打完玩家就停手,hook 播的那次 88300 本来
/// 完整播完了,周期补推又重启了一次。补推只能挂在"确实被顶掉了"的证据上,也就是客户端
/// 的 AskAgentEnterStiff 上报(ReassertLocalEnemyDeathAsync,且带 900 ms 静默窗口)。</summary>
internal static async Task SweepLocalEnemyDeathClearsAsync(TcpSession session, long now)
{
var runtime = GetLocalEnemyRuntimeState(session);
List<ulong> expired = null!;
lock (runtime.SyncRoot)
{
foreach (var kv in runtime.PendingDeathClear)
if (now >= kv.Value)
(expired ??= new List<ulong>()).Add(kv.Key);
}
if (expired is null)
return;
foreach (var uid in expired)
await CompleteLocalEnemyDeathClearAsync(session, uid, "grace-timeout");
}
}