411 lines
21 KiB
C#
411 lines
21 KiB
C#
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<=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");
|
||
}
|
||
}
|