933 lines
46 KiB
C#
933 lines
46 KiB
C#
using Ananta.SDK.Network;
|
||
using Ananta.SDK.Rpc;
|
||
using Ananta.SDK.Serialization;
|
||
using Ananta.Server.ClientData.Client4229938;
|
||
using Ananta.Server.Configuration;
|
||
using Ananta.Server.Gameplay;
|
||
using Ananta.Server.Protocol.Client4229938;
|
||
using Ananta.Server.RpcTypes.Client4229938;
|
||
using SceneMethods = Ananta.Server.RpcTypes.Client4229938.Methods.GameScene;
|
||
|
||
namespace Ananta.Server.Handlers.Game;
|
||
|
||
/// <summary>
|
||
/// Attack AI — action layer for SERVER-SPAWNED enemies (GameAdminBridge.SpawnAgentAsync).
|
||
///
|
||
/// Why this exists: BattleAiS only switches the unit onto its combat behaviour tree; it is
|
||
/// not a decision loop, and the client's tree on this build does not close the distance or
|
||
/// open an attack on its own for a server-spawned unit. So the server issues bounded native
|
||
/// commands — move / turn / cast — and the client owns the animation, the movement and the
|
||
/// hit detection. Nothing here teleports an enemy or fabricates a hit: every packet is a
|
||
/// request the client's own systems execute.
|
||
///
|
||
/// Compatibility scope, stated up front: this is NOT a reconstruction of the retail behaviour
|
||
/// trees. Complex navigation, vehicle/seat skills, grabs/executions and co-op sequences are
|
||
/// out of scope. Short-range approach can be blocked by geometry.
|
||
///
|
||
/// Damage: intentionally zero. Enemy hits on the player are visual feedback only (see
|
||
/// ApplyEnemyPlayerHitFeedbackAsync) — no HP, no death, no resource packets.
|
||
/// </summary>
|
||
internal sealed partial class GameRouter
|
||
{
|
||
/// <summary>Flat distance at which the enemy stops closing in and swings instead.</summary>
|
||
private const float EnemyAiAttackRangeMeters = 2.8f;
|
||
|
||
/// <summary>Flat distance at which the enemy notices the player at all.</summary>
|
||
// 2026-09-26:原为硬编码 30f,现提到 Aether.EnemyAiAggroRangeMeters。
|
||
// ⛑ 填 0 ⇒ EnemyAiSelectTarget 恒返回 0 ⇒ 敌人永不主动锁定玩家(保持未察觉)
|
||
// ⇒ 蹲伏暗杀才有窗口(已交战的敌人不可暗杀)。想恢复原行为填 30。
|
||
private static float EnemyAiAggroRangeMeters
|
||
=> MathF.Max(0f, PrivateServerConfigStore.Current.Gameplay.Aether.EnemyAiAggroRangeMeters);
|
||
|
||
/// <summary>
|
||
/// Worker poll period. Was 500ms, which alone put up to half a second of dead air between
|
||
/// "the previous walk command ran out" and "the next one is dispatched" — the loop walks
|
||
/// every enemy serially, so the real gap grew with the enemy count. 100ms keeps that gap
|
||
/// below what the eye reads as a stop.
|
||
/// </summary>
|
||
private const int EnemyAiPollMs = 100;
|
||
|
||
/// <summary>Move dispatch interval. Each command is a finite walk (MoveTime), so re-issuing
|
||
/// faster than that would restart the walk instead of extending it.</summary>
|
||
private const int EnemyAiMoveIntervalMs = 1000;
|
||
|
||
// ---- 移动节奏(2026-09-23:修「走一步停一下」的抽搐)----
|
||
//
|
||
// SyncEnemyBattleMoveTowardUnit 是**有限位移**:客户端只走 MoveTime 秒就停在那儿等下一条。
|
||
// 原来 MoveTime=0.65s 而派发间隔 1000ms ⇒ 走 0.65s、干站 0.35s,看起来就是一步一顿。
|
||
// 所以这两个值必须配对:间隔 ≥ 时长(短了会打断),又不能长太多(长了就是停顿)。
|
||
// 都从 PrivateServerConfig 读,便于实机微调,不用重编译。
|
||
|
||
/// <summary>单条移动命令让客户端走多久(秒)。</summary>
|
||
private static float EnemyAiMoveSecondsNow()
|
||
=> Math.Clamp(PrivateServerConfigStore.Current.Gameplay.Aether.EnemyAiMoveSeconds, 0.25f, 12f);
|
||
|
||
/// <summary>派发间隔:默认贴着时长 +50ms;配置值更大时以配置为准。</summary>
|
||
private static int EnemyAiMoveIntervalMsNow(float moveSeconds)
|
||
=> Math.Max((int)(moveSeconds * 1000f) + 50,
|
||
Math.Max(200, PrivateServerConfigStore.Current.Gameplay.Aether.EnemyAiMoveIntervalMs));
|
||
|
||
/// <summary>
|
||
/// 每次派发把估算锚点推进多少米。1.2 m 是 MoveTime=0.65s 时调出来的,按时长等比放大,
|
||
/// 免得「估算还在 5m 外、实体其实已经贴脸」导致它一直发移动命令原地挤。
|
||
/// </summary>
|
||
private static float EnemyAiMoveStepNow(float moveSeconds)
|
||
=> EnemyAiMoveStepMeters * (moveSeconds / 0.65f);
|
||
|
||
/// <summary>How long a cast is assumed to occupy the unit, and the gap before the next
|
||
/// one. Conservative on purpose: overwriting an attack every frame produces twitching.</summary>
|
||
private const int EnemyAiCastBusyMs = 6000;
|
||
private const int EnemyAiCastIntervalMs = 6500;
|
||
|
||
/// <summary>
|
||
/// 攻击后的冻结时长改为可配。原来是硬编码 6000/6500 ms —— 打一下原地站 6.5 秒,
|
||
/// 这也是"走一步停一下"里那段"停"的来源之一(玩家看到的就是打完杵着不动)。
|
||
/// 配置缺项时退回硬编码默认值,避免老配置直接把敌人变成连击机关枪。
|
||
/// </summary>
|
||
private static int EnemyAiCastBusyMsNow()
|
||
=> Math.Clamp(PrivateServerConfigStore.Current.Gameplay.Aether.EnemyAiCastBusyMs, 300, 30000);
|
||
|
||
private static int EnemyAiCastIntervalMsNow()
|
||
=> Math.Clamp(PrivateServerConfigStore.Current.Gameplay.Aether.EnemyAiCastIntervalMs, 400, 30000);
|
||
|
||
/// <summary>Suppression after the enemy itself was hit, so being shot does not make it
|
||
/// attack faster.</summary>
|
||
private const int EnemyAiHurtSuppressMs = 1500;
|
||
|
||
/// <summary>Estimated advance per move command. The client never reports coordinates
|
||
/// back, so the anchor is walked towards the player by this much each dispatch purely to
|
||
/// decide when to switch from "closing in" to "swing". Tune against observed behaviour.</summary>
|
||
private const float EnemyAiMoveStepMeters = 1.2f;
|
||
|
||
/// <summary>
|
||
/// 客户端走这条命令的实际速度(米/秒),由 donor 配方反推:MoveTime=0.65s 走 1.2m。
|
||
/// 用来把「还剩几米」换算成「这条命令该走几秒」,好让一条命令正好走完剩下的路 ——
|
||
/// 命令越少起停越少,"走一步停一下"就越不明显。
|
||
/// </summary>
|
||
private const float EnemyAiMoveMetersPerSecond = EnemyAiMoveStepMeters / 0.65f;
|
||
|
||
/// <summary>Worker exit threshold once nothing is left to drive.</summary>
|
||
private const int EnemyAiIdleExitMs = 15000;
|
||
|
||
/// <summary>
|
||
/// Automatic control-state release, timed off each cast.
|
||
///
|
||
/// Enemy casts are faked by us but the hit — and the stiffness / cast-lock states that
|
||
/// come with it — are resolved entirely client-side, and on a real server the same
|
||
/// settlement pass that applies them also releases them. Nobody does that here, so they
|
||
/// stick and the player is left unable to move or act until they press E (which only
|
||
/// frees movement, not the cast lock).
|
||
///
|
||
/// There is no C2S "player was hit" report to hook, so the release is scheduled from the
|
||
/// one thing we do know: when a cast went out. Two passes are queued per cast — the
|
||
/// early one catches fast swings, the late one catches slow skills whose impact lands
|
||
/// well after the animation starts. Verified 2026-09-21: the curated set restores the
|
||
/// player from a boss hit.
|
||
/// </summary>
|
||
private const int EnemyAiUnlockDelayMsEarly = 2200;
|
||
private const int EnemyAiUnlockDelayMsLate = 5000;
|
||
|
||
/// <summary>
|
||
/// When the server settles an enemy hit on the player, counted from the cast dispatch.
|
||
/// The client resolves the impact locally and never reports it, so this is an estimate —
|
||
/// it reuses the early release window because that is the proven "fast swing" landing time.
|
||
/// </summary>
|
||
private const int EnemyAiHitDelayMs = 2200;
|
||
|
||
/// <summary>How many commands get a log line before the log goes quiet.</summary>
|
||
private const int EnemyAiVerboseLogLimit = 3;
|
||
|
||
/// <summary>
|
||
/// 日志密度:前 3 条必打,之后每 N 条打一条(N 来自配置 EnemyAiVerboseLogEvery)。
|
||
/// 原来只打前 3 条 —— 实机上敌人一次接敌要走十几条移动命令,后面全被吞掉,
|
||
/// 排查「一步一停」时完全看不到节奏。
|
||
/// </summary>
|
||
private static bool EnemyAiShouldLog(int count)
|
||
{
|
||
if (count <= EnemyAiVerboseLogLimit)
|
||
return true;
|
||
var every = Math.Max(1, PrivateServerConfigStore.Current.Gameplay.Aether.EnemyAiVerboseLogEvery);
|
||
return count % every == 0;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Fallback attack pool, used only for the handful of templates whose whole behaviour set
|
||
/// is unusable (6 of 3160 as of 2026-09-21 — e.g. 地缺/天残 whose kit is entirely
|
||
/// placeholder). These two are the metallic guitarist's verified swings. A borrowed skill
|
||
/// does not animate on every skeleton, so a template that closes in and then freezes is
|
||
/// what this fallback looks like when it fails.
|
||
/// </summary>
|
||
private static readonly uint[] EnemyAiFallbackAttacks = [51900523, 51900524];
|
||
|
||
/// <summary>Resolved once per template: the behaviour set's own attacks when it has any.
|
||
/// 2026-09-26 Wave 2:改走 <see cref="EnemyAiCatalog4229938.ResolveSpawnProfile"/> ——
|
||
/// 战斗模式开启时这里拿到的就是模式套件(melee 空手 / ranged 远程),与 spawn 时
|
||
/// 绑的技能槽、换的武器、推的动画集是同一份解析,不会出现「手拿空手动画集、
|
||
/// 甩的却是模板武器技能」的错位。off 时 = Find 本体,旧行为不变。</summary>
|
||
private static IReadOnlyList<uint> EnemyAiAttacksFor(uint templateId)
|
||
{
|
||
var profile = EnemyAiCatalog4229938.ResolveSpawnProfile(templateId);
|
||
return profile is not null && profile.Attacks.Count > 0 ? profile.Attacks : EnemyAiFallbackAttacks;
|
||
}
|
||
|
||
/// <summary>Chinese name of one skill in a template's pool, for logs. "" when unknown.
|
||
/// ⛑ Wave 2:anames 只覆盖模板原配攻击集;模式套件(空手/远程池)查不到名字,
|
||
/// 日志里就是空串 —— 只影响可读性,不影响行为。</summary>
|
||
private static string EnemyAiAttackName(uint templateId, uint skillId)
|
||
{
|
||
var profile = EnemyAiCatalog4229938.ResolveSpawnProfile(templateId);
|
||
if (profile is null) return string.Empty;
|
||
for (var i = 0; i < profile.Attacks.Count && i < profile.AttackNames.Count; i++)
|
||
if (profile.Attacks[i] == skillId) return profile.AttackNames[i];
|
||
return string.Empty;
|
||
}
|
||
|
||
private static async Task RunEnemyAttackAiAsync(TcpSession session)
|
||
{
|
||
var runtime = GetLocalEnemyRuntimeState(session);
|
||
var lastWorkTicks = Environment.TickCount64;
|
||
session.Log.Info("[ENEMY-AI] worker started");
|
||
try
|
||
{
|
||
while (true)
|
||
{
|
||
await Task.Delay(EnemyAiPollMs);
|
||
|
||
// 客户端挂起期间只睡不推 —— 见 GameRouter.IsBackgroundPushPaused 的说明。
|
||
if (IsBackgroundPushPaused(session))
|
||
continue;
|
||
|
||
var state = GetStateIfExists(session);
|
||
var now = Environment.TickCount64;
|
||
|
||
ulong[] ids;
|
||
lock (runtime.SyncRoot)
|
||
ids = runtime.Enemies.Values
|
||
.Where(e => e.AttackAiEnabled && !e.Dead)
|
||
.OrderBy(e => e.AttackAiActionAt)
|
||
.Select(e => e.Uid)
|
||
.ToArray();
|
||
|
||
// 虚弱出口 + 尸体清理兜底(GameRouter.EnemyStiff.cs),跑在"还有没有活敌人"的判断
|
||
// 之前:两者都只服务已经不参与 AI 的单位(虚弱中的、已经死的),挂在 ids 后面会
|
||
// 在最后一个活敌人倒下时被跳过。
|
||
await MaintainLocalEnemyGroggyAsync(session, now);
|
||
await SweepLocalEnemyDeathClearsAsync(session, now);
|
||
|
||
if (ids.Length == 0)
|
||
{
|
||
if (now - lastWorkTicks > EnemyAiIdleExitMs)
|
||
{
|
||
session.Log.Info("[ENEMY-AI] worker exited (no enemies)");
|
||
return;
|
||
}
|
||
continue;
|
||
}
|
||
lastWorkTicks = now;
|
||
|
||
if (state is null)
|
||
continue;
|
||
|
||
foreach (var id in ids)
|
||
{
|
||
await RefreshEnemyEngageAsync(session, id, state, now);
|
||
await RefreshEnemyActionAsync(session, id, state, now);
|
||
}
|
||
|
||
// 警戒状态机(血条显示时机)—— GameRouter.EnemyWarning.cs。
|
||
// 排在 AI 派发之后:它只改 HUD,不该抢在移动/放技能命令前面。
|
||
// 内部自己节流到 2 s,100 ms 的 tick 频率不会变成刷包。
|
||
await RefreshLocalEnemyWarningsAsync(session, state, now);
|
||
|
||
// ★ 蹲伏暗杀播种(硬规则 192)—— GameRouter.EnemyAiActions.cs 底部。
|
||
// 客户端的候选筛选是自举死锁:PreCheckCanAss 要 canAssassinate==true,
|
||
// 而它只能由这条 S2C 写入,这条 S2C 又名义上是「收到上报才回」。
|
||
// 不主动播种 ⇒ ReportCanAssassinateEnemies 永远为 0。内部自己节流。
|
||
await SeedAssassinationCandidatesAsync(session, state, now);
|
||
|
||
await DrainPendingUnlocksAsync(session, state, now);
|
||
}
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
session.Log.Warn($"[ENEMY-AI] worker stopped: {ex.Message}");
|
||
}
|
||
finally
|
||
{
|
||
lock (runtime.SyncRoot)
|
||
runtime.AttackAiWorkerStarted = false;
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Fires the queued control-state releases that have come due. Several casts can queue
|
||
/// deadlines that land in the same poll, so every due deadline collapses into one batch —
|
||
/// otherwise a boss swinging twice in a row would send the same 24 packets four times.
|
||
/// </summary>
|
||
private static async Task DrainPendingUnlocksAsync(TcpSession session, WorldEntryState state, long now)
|
||
{
|
||
var runtime = GetLocalEnemyRuntimeState(session);
|
||
lock (runtime.SyncRoot)
|
||
{
|
||
if (!runtime.PendingUnlocks.Any(t => t <= now))
|
||
return;
|
||
runtime.PendingUnlocks.RemoveAll(t => t <= now);
|
||
}
|
||
|
||
var unit = state.ActiveSpiritUnitId != 0 ? state.ActiveSpiritUnitId : 100000000000UL;
|
||
try
|
||
{
|
||
await GameAdminBridge.ClearControlStatesAsync(session, unit, GameAdminBridge.PlayerControlStates, "auto");
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
session.Log.Warn($"[ENEMY-AI] auto-unlock failed unit={unit}: {ex.Message}");
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// ★ 蹲伏暗杀播种(硬规则 192)—— 主动下发 <c>SyncEnemyCanAssassinate</c>(68218494)。
|
||
///
|
||
/// 为什么要这一段:<b>客户端的候选筛选是自举死锁</b>。
|
||
/// <c>FeiSuoCrouchManager.CheckCanCrouchAss(0x14CB11E0)</c> →
|
||
/// <c>PreCheckCanAss(0x131F6700)</c> 要求目标的 <c>BattleDataModule.canAssassinate</c>
|
||
/// (私有字段,偏移 <c>0xB1</c>)== true;该字段全 .text 唯一写入点就是这条 S2C 的接收函数
|
||
/// (<c>0x127D102F mov byte ptr [rax+0xB1], bl</c>,<c>set_CanAssassinate</c> 零调用者);
|
||
/// 而这条 S2C 名义上是 <c>ReportCanAssassinateEnemies</c>(67161256) 的<b>回应</b>,
|
||
/// 上报却必须先过 <c>canAssassinate</c> ⇒ 没有任何敌人天生带标记 ⇒ 循环永远启动不了
|
||
/// (实机:AskEnterFeiSuoCrouch 19 次 / ReportCanAssassinateEnemies 0 次)。
|
||
/// 官方用 Spoon 剧本动作 <c>L18.Spoon.EnemySetCanAssassinate</c>(dump.cs:3194822) 播种,
|
||
/// 我们没有 Spoon 系统 ⇒ 用这条主动下发替代。
|
||
///
|
||
/// ⛑⛑ 只在<b>蹲伏期间</b>播:离开蹲伏态由 <c>RevokeAllAssassinationGrants</c> 全部撤销。
|
||
/// ⛑ 每个敌人<b>每次蹲伏只播一次</b>(<c>AssassinationSeededAgents</c>):播完客户端若判定
|
||
/// 它不合格,会上报一个不含它的名单 ⇒ 上报路径撤销它;只看
|
||
/// <c>AssassinationGrantedAgents</c> 的话播种会以为「没授权过」而重播,
|
||
/// 撤销→播种→撤销来回甩。离开半径后重新靠近才会再播。
|
||
/// </summary>
|
||
private static async Task SeedAssassinationCandidatesAsync(TcpSession session, WorldEntryState state, long now)
|
||
{
|
||
if (!WebTraversal.PushAssassinationSeed || !WebTraversal.AnswerCrouchAssassination)
|
||
return;
|
||
|
||
// 不蹲伏 ⇒ 不播,并把节流清零,下次进蹲伏立刻播第一波。
|
||
if (!state.FeiSuoCrouchActive)
|
||
{
|
||
state.AssassinationSeedAtTicks = 0;
|
||
return;
|
||
}
|
||
if (now - state.AssassinationSeedAtTicks < WebTraversal.AssassinationSeedIntervalMs)
|
||
return;
|
||
state.AssassinationSeedAtTicks = now;
|
||
|
||
if (!state.HasLastReportedPlayerTransform || state.ActiveSpiritUnitId == 0)
|
||
return;
|
||
|
||
var runtime = GetLocalEnemyRuntimeState(session);
|
||
var player = state.LastReportedPlayerPosition;
|
||
var rSq = WebTraversal.AssassinationSeedRadiusMeters;
|
||
rSq *= rSq;
|
||
|
||
var toGrant = new List<ulong>();
|
||
var toRevoke = new List<ulong>();
|
||
lock (runtime.SyncRoot)
|
||
{
|
||
foreach (var e in runtime.Enemies.Values)
|
||
{
|
||
if (e is null || e.Dead || e.Uid == 0)
|
||
continue;
|
||
|
||
// 服务端生成的敌人用估算位(AiPosition,由服务端移动命令推进);
|
||
// 客户端自己生成的(打过才入账)只有命中点 Position。
|
||
var p = e.ServerSpawned ? e.AiPosition : e.Position;
|
||
var dx = player.X - p.X;
|
||
var dz = player.Z - p.Z;
|
||
if (!float.IsFinite(dx) || !float.IsFinite(dz))
|
||
continue;
|
||
|
||
if (dx * dx + dz * dz <= rSq)
|
||
{
|
||
if (state.AssassinationGrantedAgents.Contains(e.Uid))
|
||
continue; // 已经授权着(可能是上报路径给的)
|
||
if (!state.AssassinationSeededAgents.Add(e.Uid))
|
||
continue; // 本次蹲伏已播过,客户端自己否了
|
||
toGrant.Add(e.Uid);
|
||
}
|
||
else if (state.AssassinationSeededAgents.Remove(e.Uid))
|
||
{
|
||
// 走出半径 ⇒ 允许下次靠近再播一次;若此刻还授权着就撤掉。
|
||
if (state.AssassinationGrantedAgents.Contains(e.Uid))
|
||
toRevoke.Add(e.Uid);
|
||
}
|
||
}
|
||
}
|
||
|
||
foreach (var uid in toRevoke)
|
||
{
|
||
state.AssassinationGrantedAgents.Remove(uid);
|
||
try
|
||
{
|
||
await session.NotifyAsync(MethodId.IGameSceneToClient_SyncEnemyCanAssassinate,
|
||
UxSerializer.Serialize(new SceneMethods.SyncEnemyCanAssassinate4229938 { agentId = uid, canAssassinate = false }),
|
||
CancellationToken.None);
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
session.Log.Warn($"[ASSASS] seed-revoke failed uid={uid}: {ex.Message}");
|
||
}
|
||
}
|
||
|
||
foreach (var uid in toGrant)
|
||
{
|
||
state.AssassinationGrantedAgents.Add(uid);
|
||
try
|
||
{
|
||
await session.NotifyAsync(MethodId.IGameSceneToClient_SyncEnemyCanAssassinate,
|
||
UxSerializer.Serialize(new SceneMethods.SyncEnemyCanAssassinate4229938 { agentId = uid, canAssassinate = true }),
|
||
CancellationToken.None);
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
session.Log.Warn($"[ASSASS] seed-grant failed uid={uid}: {ex.Message}");
|
||
}
|
||
}
|
||
|
||
if (toGrant.Count > 0 || toRevoke.Count > 0)
|
||
session.Log.Info($"[ASSASS] seed grant=+{toGrant.Count} revoke=-{toRevoke.Count} " +
|
||
$"radius={WebTraversal.AssassinationSeedRadiusMeters:F1}m total={state.AssassinationGrantedAgents.Count}");
|
||
}
|
||
|
||
/// <summary>0 when the player is too far, unknown, or the enemy is busy dying.</summary>
|
||
private static ulong EnemyAiSelectTarget(LocalEnemyRecord enemy, WorldEntryState state)
|
||
{
|
||
if (enemy.Dead || !state.HasLastReportedPlayerTransform || state.ActiveSpiritUnitId == 0)
|
||
return 0;
|
||
var player = state.LastReportedPlayerPosition;
|
||
var dx = player.X - enemy.AiPosition.X;
|
||
var dz = player.Z - enemy.AiPosition.Z;
|
||
if (!float.IsFinite(dx) || !float.IsFinite(dz))
|
||
return 0;
|
||
|
||
// 滞回(2026-09-24):单阈值会让 engagement 在边界反复开关,而每次开关都要重发
|
||
// LockTarget / FightEdict / BattleAiS —— 客户端动作层被重置、刚起步的动画被裁断,
|
||
// 实测表现就是"走一步就僵住"。所以已经咬住的目标用更大的脱离半径。
|
||
var limit = EnemyAiAggroRangeMeters;
|
||
if (enemy.AttackAiTarget != 0)
|
||
limit = MathF.Max(limit, PrivateServerConfigStore.Current.Gameplay.Aether.EnemyAiAggroExitMeters);
|
||
|
||
return dx * dx + dz * dz <= limit * limit
|
||
? state.ActiveSpiritUnitId
|
||
: 0;
|
||
}
|
||
|
||
/// <summary>Engagement batch, re-sent only when the lock actually changes — spamming it
|
||
/// resets the client's action layer and cuts every animation short.</summary>
|
||
private static async Task RefreshEnemyEngageAsync(TcpSession session, ulong uid, WorldEntryState state, long now)
|
||
{
|
||
var runtime = GetLocalEnemyRuntimeState(session);
|
||
var cfg = PrivateServerConfigStore.Current.Gameplay.Aether;
|
||
ulong target;
|
||
lock (runtime.SyncRoot)
|
||
{
|
||
if (!runtime.Enemies.TryGetValue(uid, out var enemy) || enemy.Dead)
|
||
return;
|
||
target = EnemyAiSelectTarget(enemy, state);
|
||
if (target == enemy.AttackAiTarget)
|
||
return;
|
||
// 二次防抖:刚翻过的状态先按住 EnemyAiEngageHoldMs,别在边界来回甩。
|
||
if (now - enemy.AttackAiEngageChangedAt < Math.Max(0, cfg.EnemyAiEngageHoldMs))
|
||
return;
|
||
enemy.AttackAiTarget = target;
|
||
enemy.AttackAiEngageChangedAt = now;
|
||
}
|
||
|
||
try
|
||
{
|
||
await session.NotifyAsync(MethodId.SyncUnitLockTarget,
|
||
UxSerializer.Serialize(new SceneMethods.SyncUnitLockTarget { entityId = uid, targetId = target }),
|
||
CancellationToken.None);
|
||
await session.NotifyAsync(MethodId.SyncEnemyFightEdict,
|
||
UxSerializer.Serialize(new SceneMethods.SyncEnemyFightEdict { uid = uid, isGive = target != 0 }),
|
||
CancellationToken.None);
|
||
await session.NotifyAsync(MethodId.SyncBattleAiS,
|
||
UxSerializer.Serialize(new SceneMethods.SyncBattleAiS { agentId = uid, value = target != 0 }),
|
||
CancellationToken.None);
|
||
session.Log.Info($"[ENEMY-AI] engage entity={uid} player={target} attackToken={target != 0}");
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
session.Log.Warn($"[ENEMY-AI] engage failed entity={uid}: {ex.Message}");
|
||
}
|
||
}
|
||
|
||
private static async Task RefreshEnemyActionAsync(TcpSession session, ulong uid, WorldEntryState state, long now)
|
||
{
|
||
var runtime = GetLocalEnemyRuntimeState(session);
|
||
var cfg = PrivateServerConfigStore.Current.Gameplay.Aether;
|
||
var moveSeconds = EnemyAiMoveSecondsNow();
|
||
var moveInterval = EnemyAiMoveIntervalMsNow(moveSeconds);
|
||
var moveStep = EnemyAiMoveStepNow(moveSeconds);
|
||
// 踱步比追击走得更久:一条命令内是连续的,命令次数越少起停越少。
|
||
var patrolSeconds = Math.Clamp(cfg.EnemyAiPatrolMoveSeconds, 0.5f, 6f);
|
||
var patrolInterval = (int)(patrolSeconds * 1000f) + 50;
|
||
|
||
SceneMethods.EnemyAiMoveTowardUnit4229938? move = null;
|
||
SceneMethods.EnemyAiFace4229938? face = null;
|
||
SceneMethods.EnemyAiSkillParam4229938? cast = null;
|
||
int moveCount = 0, castCount = 0;
|
||
uint castTemplate = 0, castSkill = 0;
|
||
bool wantPatrol = false;
|
||
(float Flat, int Count)? suppressed = null;
|
||
|
||
lock (runtime.SyncRoot)
|
||
{
|
||
if (!runtime.Enemies.TryGetValue(uid, out var enemy) || enemy.Dead || !enemy.AttackAiEnabled)
|
||
return;
|
||
|
||
var target = EnemyAiSelectTarget(enemy, state);
|
||
if (target != enemy.AttackAiTarget)
|
||
return;
|
||
|
||
// 玩家在仇恨范围(30m)之外:EnemyAiSelectTarget 给不出目标,原来直接 return ⇒
|
||
// 敌人从生成到玩家走近这段时间完全不动。这里改交给哨兵踱步(见 DispatchEnemyPatrolStepAsync)。
|
||
if (target == 0)
|
||
{
|
||
wantPatrol = cfg.EnemyAiPatrolEnabled && now >= enemy.AttackAiActionAt;
|
||
return;
|
||
}
|
||
if (now < enemy.AttackAiActionAt || now < enemy.AttackAiCastUntil
|
||
|| (enemy.LastHurtFeedbackTicks > 0 && now - enemy.LastHurtFeedbackTicks < EnemyAiHurtSuppressMs))
|
||
return;
|
||
|
||
// Stance break ("虚弱"): a drained stance pool suspends the swing entirely for
|
||
// LocalEnemyGroggyMs, independent of HP. This is the reward for pressuring the enemy
|
||
// instead of trading damage with it, and it is the only thing that makes the stance
|
||
// bar worth showing. The pool refills when the break is applied, so this window ends
|
||
// on its own — nothing has to reset it.
|
||
if (now < enemy.GroggyUntilTicks)
|
||
{
|
||
enemy.AttackAiActionAt = now + 250;
|
||
return;
|
||
}
|
||
|
||
var player = state.LastReportedPlayerPosition;
|
||
var dx = player.X - enemy.AiPosition.X;
|
||
var dy = player.Y - enemy.AiPosition.Y;
|
||
var dz = player.Z - enemy.AiPosition.Z;
|
||
// Never attack through a floor, and never "fix" coordinates by teleporting the
|
||
// unit: wait for the player to come back to this level.
|
||
if (!float.IsFinite(dx + dy + dz) || Math.Abs(dy) > 2.5f)
|
||
{
|
||
enemy.AttackAiActionAt = now + moveInterval;
|
||
return;
|
||
}
|
||
|
||
var flat = MathF.Sqrt(dx * dx + dz * dz);
|
||
var moveId = unchecked(++enemy.AttackAiMoveId);
|
||
if (moveId == 0) moveId = unchecked(++enemy.AttackAiMoveId);
|
||
|
||
if (flat > EnemyAiAttackRangeMeters)
|
||
{
|
||
moveCount = ++enemy.AttackAiMoveCount;
|
||
|
||
// ★ 实验开关:关掉后不再发有限位移命令,看客户端自己的战斗 AI 会不会把接近
|
||
// 动作走完 —— 2026-09-24 实测:关掉后敌人**会攻击但完全不移动**,
|
||
// 说明 forbidAetherAI=true 的敌人客户端不会自己走,只能服务端喂命令。
|
||
if (!cfg.EnemyAiServerMoveEnabled)
|
||
{
|
||
// 不推进估算锚点:客户端没接到命令就不会走,锚点要是自己贴上去,
|
||
// 敌人就会隔着半条街开始挥空气。
|
||
enemy.AttackAiActionAt = now + moveInterval;
|
||
suppressed = (flat, moveCount);
|
||
return;
|
||
}
|
||
|
||
// 一条命令走完剩下的路,中间就不用补第二条 —— 客户端「走 MoveTime 秒就停」,
|
||
// 命令越长起停越少。按剩余距离反算需要几秒,比整段短就按短的走(免得冲过玩家),
|
||
// 比整段长就封顶在配置值,剩下的交给下一条。
|
||
var needSeconds = (flat - EnemyAiAttackRangeMeters) / EnemyAiMoveMetersPerSecond;
|
||
var useSeconds = Math.Clamp(needSeconds, 0.4f, moveSeconds);
|
||
var useInterval = (int)(useSeconds * 1000f) + 50;
|
||
var useStep = EnemyAiMoveStepNow(useSeconds);
|
||
enemy.AttackAiActionAt = now + useInterval;
|
||
|
||
move = new SceneMethods.EnemyAiMoveTowardUnit4229938
|
||
{
|
||
EntityId = uid,
|
||
TargetId = target,
|
||
MoveId = moveId,
|
||
InstanceId = moveId,
|
||
MoveTime = useSeconds,
|
||
// 略短于 MoveTime:客户端「至少走这么久」,防止它提前刹车收脚。
|
||
MinMoveTime = MathF.Max(0.1f, useSeconds * 0.9f),
|
||
ReportOnStop = true,
|
||
};
|
||
moveInterval = useInterval;
|
||
moveStep = useStep;
|
||
|
||
// Advance the estimate towards the player so repeated dispatches eventually
|
||
// cross the attack threshold. The real movement is the client's.
|
||
if (flat > moveStep)
|
||
{
|
||
var k = moveStep / flat;
|
||
enemy.AiPosition = new Vec3(
|
||
enemy.AiPosition.X + dx * k,
|
||
enemy.AiPosition.Y,
|
||
enemy.AiPosition.Z + dz * k);
|
||
}
|
||
else
|
||
{
|
||
enemy.AiPosition = new Vec3(player.X, enemy.AiPosition.Y, player.Z);
|
||
}
|
||
}
|
||
else
|
||
{
|
||
// Per-template first: a skill belongs to a skeleton, so the behaviour set's own
|
||
// attacks are the only ones guaranteed to animate on this rig.
|
||
var pool = EnemyAiAttacksFor(enemy.EnemyId);
|
||
var skill = pool[enemy.AttackAiCastCount % pool.Count];
|
||
enemy.AttackAiCastInstance = enemy.AttackAiCastInstance == int.MaxValue
|
||
? 1
|
||
: enemy.AttackAiCastInstance + 1;
|
||
var facing = MathF.Atan2(dx, dz) * (180f / MathF.PI);
|
||
var deltaAngle = (facing - enemy.Facing) % 360f;
|
||
if (deltaAngle > 180f) deltaAngle -= 360f;
|
||
if (deltaAngle < -180f) deltaAngle += 360f;
|
||
|
||
// Turn first: several authored NPC skills carry NoRotation=true, so the cast
|
||
// alone would swing at whatever the unit happened to be facing.
|
||
face = new SceneMethods.EnemyAiFace4229938
|
||
{
|
||
pid = uid,
|
||
fromAngle = enemy.Facing,
|
||
changeAngle = deltaAngle,
|
||
};
|
||
cast = new SceneMethods.EnemyAiSkillParam4229938
|
||
{
|
||
entityId = uid,
|
||
targetId = target,
|
||
moveId = moveId,
|
||
instanceId = enemy.AttackAiCastInstance,
|
||
skillId = skill,
|
||
rotate = facing,
|
||
unitPosition = new SceneMethods.UxVector3(enemy.AiPosition.X, enemy.AiPosition.Y, enemy.AiPosition.Z),
|
||
location = new SceneMethods.UxVector3(player.X, player.Y, player.Z),
|
||
faceToPos = new SceneMethods.UxVector3(player.X, player.Y, player.Z),
|
||
SelfMobilityPos = new SceneMethods.UxVector3(enemy.AiPosition.X, enemy.AiPosition.Y, enemy.AiPosition.Z),
|
||
TarMobilityPos = new SceneMethods.UxVector3(player.X, player.Y, player.Z),
|
||
};
|
||
castTemplate = enemy.EnemyId;
|
||
castSkill = skill;
|
||
// Queue the player's release. Timing is the closest thing to a hit report we
|
||
// have: the stiff lands somewhere inside the cast animation.
|
||
if (runtime.AutoUnlockEnabled)
|
||
{
|
||
runtime.PendingUnlocks.Add(now + EnemyAiUnlockDelayMsEarly);
|
||
runtime.PendingUnlocks.Add(now + EnemyAiUnlockDelayMsLate);
|
||
}
|
||
enemy.Facing = facing;
|
||
enemy.AttackAiCastSkill = skill;
|
||
enemy.AttackAiCastUntil = now + EnemyAiCastBusyMsNow();
|
||
enemy.AttackAiActionAt = now + EnemyAiCastIntervalMsNow();
|
||
castCount = ++enemy.AttackAiCastCount;
|
||
// Logged once per enemy: the whole pool, so a boss that was silently falling
|
||
// back to two borrowed swings is visible in the log.
|
||
if (castCount == 1)
|
||
session.Log.Info($"[ENEMY-AI] attack-pool entity={uid} template={enemy.EnemyId} size={pool.Count} skills={string.Join(',', pool)} names={string.Join(',', pool.Select(s => EnemyAiAttackName(enemy.EnemyId, s)))}");
|
||
}
|
||
}
|
||
|
||
if (wantPatrol)
|
||
{
|
||
await DispatchEnemyPatrolStepAsync(session, uid, state, now, cfg,
|
||
patrolSeconds, patrolInterval, moveStep);
|
||
return;
|
||
}
|
||
|
||
if (suppressed is not null && EnemyAiShouldLog(suppressed.Value.Count))
|
||
session.Log.Info($"[ENEMY-AI] move-suppressed entity={uid} (enemyAiServerMoveEnabled=false) " +
|
||
$"flat={suppressed.Value.Flat:F1}m count={suppressed.Value.Count} " +
|
||
$"— 若此时实体仍在朝你移动,说明客户端行为树会自己走,服务端不该插手");
|
||
|
||
try
|
||
{
|
||
if (move is not null)
|
||
{
|
||
await session.NotifyAsync(MethodId.SyncEnemyBattleMoveTowardUnit,
|
||
UxSerializer.Serialize(move), CancellationToken.None);
|
||
if (EnemyAiShouldLog(moveCount))
|
||
session.Log.Info($"[ENEMY-AI] move-dispatched entity={uid} target={move.TargetId} instance={move.InstanceId} duration={move.MoveTime} gap={moveInterval}ms count={moveCount}");
|
||
}
|
||
else if (cast is not null)
|
||
{
|
||
await TurnEnemyToAsync(session, uid, cast.targetId, face);
|
||
await session.NotifyAsync(MethodId.SyncUseTargetSkill,
|
||
UxSerializer.Serialize(cast), CancellationToken.None);
|
||
if (EnemyAiShouldLog(castCount))
|
||
session.Log.Info($"[ENEMY-AI] cast-dispatched entity={uid} target={cast.targetId} skill={cast.skillId} name={EnemyAiAttackName(castTemplate, castSkill)} instance={cast.instanceId} count={castCount} method=SyncUseTargetSkill");
|
||
|
||
// Server-authoritative damage. There is no C2S "player was hit" report, so the hit
|
||
// is settled off the same clock as the control-state release: the impact lands
|
||
// somewhere inside the cast animation. No-op while the GM vitals lock is on.
|
||
_ = ApplyEnemyHitToPlayerDelayedAsync(session, state, uid, cast.skillId);
|
||
}
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
session.Log.Warn($"[ENEMY-AI] action dispatch failed entity={uid}: {ex.Message}");
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// 出招前让单位<b>面向目标</b>。三种方式由 <c>gameplay.aether.enemyAiTurnPacket</c> 选
|
||
/// (<see cref="AetherSettings.EnemyAiTurnPacket"/>):
|
||
/// <c>target</c>(默认)= <c>SyncUnitSmoothFaceToTarget</c>,把目标单位甩给客户端算 ——
|
||
/// 转得准,因为客户端知道自己的真实朝向;
|
||
/// <c>smooth</c> = 旧的 <c>SyncUnitSmoothFace(fromAngle, changeAngle)</c>,服务端按估算
|
||
/// 朝向算增量 —— 估算一旦过期就会转歪(实机上就是"转 90° 打空气");
|
||
/// <c>off</c> = 完全不发转向包(对照用:若技能都带 NoRotation=true,此时应当朝哪打哪)。
|
||
///
|
||
/// ⛑ 单位是<b>度</b>(记忆 2026-09-21.md:1066:<c>Facing</c> 与 <c>EulerAngles.y</c> 同值),
|
||
/// 一开始怀疑过的"度/弧度搞反"已经排除了。
|
||
/// </summary>
|
||
internal static async Task TurnEnemyToAsync(TcpSession session, ulong uid, ulong targetId,
|
||
SceneMethods.EnemyAiFace4229938? smooth)
|
||
{
|
||
var mode = PrivateServerConfigStore.Current.Gameplay.Aether.EnemyAiTurnPacket;
|
||
try
|
||
{
|
||
if (string.Equals(mode, "off", StringComparison.OrdinalIgnoreCase))
|
||
return;
|
||
|
||
if (string.Equals(mode, "smooth", StringComparison.OrdinalIgnoreCase))
|
||
{
|
||
if (smooth is null) return;
|
||
await session.NotifyAsync(MethodId.SyncUnitSmoothFace,
|
||
UxSerializer.Serialize(smooth), CancellationToken.None);
|
||
return;
|
||
}
|
||
|
||
// 默认 target:官方的 "face the target unit" 包,转角由客户端算。
|
||
if (targetId == 0)
|
||
{
|
||
if (smooth is null) return;
|
||
await session.NotifyAsync(MethodId.SyncUnitSmoothFace,
|
||
UxSerializer.Serialize(smooth), CancellationToken.None);
|
||
return;
|
||
}
|
||
await session.NotifyAsync(MethodId.SyncUnitSmoothFaceToTarget,
|
||
UxSerializer.Serialize(new SceneMethods.EnemyAiFaceToTarget4229938
|
||
{
|
||
pid = uid,
|
||
targetPid = targetId,
|
||
rotationSpeed = 720,
|
||
}), CancellationToken.None);
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
session.Log.Warn($"[ENEMY-AI] turn failed entity={uid} mode={mode}: {ex.Message}");
|
||
}
|
||
}
|
||
|
||
/// <summary>踱步两段之间随机停顿多久(毫秒),避免所有敌人同步齐步走。</summary>
|
||
private static int RandomPause(AetherSettings cfg)
|
||
{
|
||
var lo = Math.Max(0, cfg.EnemyAiPatrolPauseMinMs);
|
||
var hi = Math.Max(lo, cfg.EnemyAiPatrolPauseMaxMs);
|
||
return lo + Random.Shared.Next(Math.Max(1, hi - lo + 1));
|
||
}
|
||
|
||
/// <summary>
|
||
/// 哨兵踱步:玩家不在仇恨范围(30m)时让敌人「走一段 → 停一会 → 倒着走回来」。
|
||
///
|
||
/// 原来这段时间是完全不动的(EnemyAiSelectTarget 给不出目标 ⇒ RefreshEnemyActionAsync 直接
|
||
/// return),敌人从生成到玩家走近一直杵着。这里复用同一条 BattleMove 通道,靠
|
||
/// WalkFront / WalkBack 交替让位移互相抵消 —— 它只会原地来回踱步,不会一路漂到玩家身上。
|
||
///
|
||
/// ⛑️ 服务端拿不到真实坐标(客户端不回传),所以 AiPosition 是估算;估算漂太远会让实体和
|
||
/// 锚点对不上,因此用 HomePosition 做保险绳:离出生点超过 leash 就强制往回走。
|
||
/// </summary>
|
||
private static async Task DispatchEnemyPatrolStepAsync(
|
||
TcpSession session, ulong uid, WorldEntryState state, long now,
|
||
AetherSettings cfg, float moveSeconds, int moveInterval, float moveStep)
|
||
{
|
||
var runtime = GetLocalEnemyRuntimeState(session);
|
||
var playerUnit = state.ActiveSpiritUnitId;
|
||
if (playerUnit == 0)
|
||
return;
|
||
|
||
SceneMethods.EnemyAiMoveTowardUnit4229938? move = null;
|
||
SceneMethods.MoveWanderingData4229938? wanderData = null;
|
||
int moveCount = 0;
|
||
bool forward = true;
|
||
int pause = 0;
|
||
int wanderMs = 0;
|
||
|
||
lock (runtime.SyncRoot)
|
||
{
|
||
if (!runtime.Enemies.TryGetValue(uid, out var enemy) || enemy.Dead || !enemy.AttackAiEnabled)
|
||
return;
|
||
// 玩家可能刚好跨进 30m —— 那就交回战斗逻辑,别再踱步。
|
||
if (EnemyAiSelectTarget(enemy, state) != 0)
|
||
return;
|
||
|
||
// ---- 实验路线:官方游荡包(默认关)----
|
||
// SyncMoveWandering 不带目的地,客户端自己挑路径连续走,行人走的就是它 ⇒
|
||
// 真正的连续行走,而不是一串"走 N 秒然后停"的有限位移命令。
|
||
if (cfg.EnemyAiWanderEnabled)
|
||
{
|
||
var secs = Math.Clamp(cfg.EnemyAiWanderSeconds, 3f, 60f);
|
||
var minDis = Math.Max(0.5f, cfg.EnemyAiWanderMinDis);
|
||
var maxDis = Math.Max(minDis + 1f, cfg.EnemyAiWanderMaxDis);
|
||
wanderData = new SceneMethods.MoveWanderingData4229938
|
||
{
|
||
PathTags = cfg.AmbientPedsWanderPathTags,
|
||
CloseObstacleAvoidance = true,
|
||
MinDis = minDis,
|
||
MaxDis = maxDis,
|
||
InRangeAngle = cfg.AmbientPedsWanderLeftAngle,
|
||
OutRangeAngle = cfg.AmbientPedsWanderRightAngle,
|
||
MaxTime = secs + 5f,
|
||
MaxOnceWanderTime = secs,
|
||
UnitId = uid,
|
||
MoveType = 0,
|
||
MoveId = WorldCodec.MoveAction.WalkFront,
|
||
CloseIK = false,
|
||
InstanceId = 0,
|
||
BlockCloseCapsuleCollision = false,
|
||
};
|
||
wanderMs = (int)(secs * 1000f);
|
||
pause = RandomPause(cfg);
|
||
// 游荡位移不回传,AiPosition 保持不动 —— 所以只能靠估算判断仇恨,别走太远。
|
||
enemy.AttackAiActionAt = now + wanderMs + pause;
|
||
moveCount = ++enemy.AttackAiMoveCount;
|
||
return;
|
||
}
|
||
|
||
var home = enemy.HomePosition;
|
||
if (!float.IsFinite(home.X + home.Z))
|
||
home = enemy.AiPosition;
|
||
var pl = state.LastReportedPlayerPosition;
|
||
var hx = pl.X - enemy.AiPosition.X;
|
||
var hz = pl.Z - enemy.AiPosition.Z;
|
||
var hlen = MathF.Sqrt(hx * hx + hz * hz);
|
||
if (!float.IsFinite(hlen) || hlen < 0.001f)
|
||
return;
|
||
|
||
var fromHomeX = enemy.AiPosition.X - home.X;
|
||
var fromHomeZ = enemy.AiPosition.Z - home.Z;
|
||
var leash = MathF.Max(2f, cfg.EnemyAiPatrolLeashMeters);
|
||
var wantForward = enemy.PatrolForward;
|
||
if (fromHomeX * fromHomeX + fromHomeZ * fromHomeZ > leash * leash)
|
||
wantForward = (fromHomeX * hx + fromHomeZ * hz) < 0; // 已经跑远了:这一步往回走
|
||
|
||
var step = MathF.Min(moveStep, MathF.Max(0.3f, hlen - 2f));
|
||
var dir = wantForward ? 1f : -1f;
|
||
|
||
var pMoveId = unchecked(++enemy.AttackAiMoveId);
|
||
if (pMoveId == 0) pMoveId = unchecked(++enemy.AttackAiMoveId);
|
||
move = new SceneMethods.EnemyAiMoveTowardUnit4229938
|
||
{
|
||
EntityId = uid,
|
||
TargetId = playerUnit,
|
||
BattleMoveActionType = wantForward
|
||
? WorldCodec.MoveAction.WalkFront
|
||
: WorldCodec.MoveAction.WalkBack,
|
||
MoveId = pMoveId,
|
||
InstanceId = pMoveId,
|
||
MoveTime = moveSeconds,
|
||
ReportOnStop = true,
|
||
};
|
||
|
||
enemy.AiPosition = new Vec3(
|
||
enemy.AiPosition.X + (hx / hlen) * step * dir,
|
||
enemy.AiPosition.Y,
|
||
enemy.AiPosition.Z + (hz / hlen) * step * dir);
|
||
enemy.PatrolForward = !wantForward;
|
||
moveCount = ++enemy.AttackAiMoveCount;
|
||
forward = wantForward;
|
||
pause = RandomPause(cfg);
|
||
enemy.AttackAiActionAt = now + moveInterval + pause;
|
||
}
|
||
|
||
if (wanderData is not null)
|
||
{
|
||
try
|
||
{
|
||
await session.NotifyAsync(MethodId.SyncMoveWandering,
|
||
UxSerializer.Serialize(new SceneMethods.SyncMoveWandering4229938 { Data = wanderData }),
|
||
CancellationToken.None);
|
||
if (EnemyAiShouldLog(moveCount))
|
||
session.Log.Info($"[ENEMY-AI] patrol-wander entity={uid} seconds={wanderMs / 1000} " +
|
||
$"pause={pause}ms count={moveCount}");
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
session.Log.Warn($"[ENEMY-AI] wander dispatch failed entity={uid}: {ex.Message}");
|
||
}
|
||
return;
|
||
}
|
||
|
||
if (move is null)
|
||
return;
|
||
|
||
try
|
||
{
|
||
await session.NotifyAsync(MethodId.SyncEnemyBattleMoveTowardUnit,
|
||
UxSerializer.Serialize(move), CancellationToken.None);
|
||
if (EnemyAiShouldLog(moveCount))
|
||
session.Log.Info($"[ENEMY-AI] patrol-step entity={uid} dir={(forward ? "WalkFront" : "WalkBack")} " +
|
||
$"instance={move.InstanceId} duration={move.MoveTime} pause={pause}ms " +
|
||
$"step={moveStep:F2}m count={moveCount}");
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
session.Log.Warn($"[ENEMY-AI] patrol dispatch failed entity={uid}: {ex.Message}");
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Waits out the cast animation and then settles the hit on the player.
|
||
///
|
||
/// Fire-and-forget on purpose: the AI worker must keep polling, and a session that disappears
|
||
/// during the wait must not surface as an AI failure.
|
||
/// </summary>
|
||
private static async Task ApplyEnemyHitToPlayerDelayedAsync(
|
||
TcpSession session, WorldEntryState state, ulong uid, uint skillId)
|
||
{
|
||
try
|
||
{
|
||
await Task.Delay(EnemyAiHitDelayMs, CancellationToken.None);
|
||
await ApplyEnemyHitToPlayerAsync(session, state, uid, skillId);
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
session.Log.Warn($"[PLAYER-HIT] delayed settlement failed entity={uid}: {ex.Message}");
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// C2S end-of-move report. Carries a pid and nothing else — no token, no success flag —
|
||
/// so it is logged as a diagnostic acknowledgement and never treated as proof of arrival
|
||
/// or as a reason to shorten the move interval.
|
||
/// </summary>
|
||
[Handler(MethodId.ReportEnemyBattleMoveEnd, HandlerPacketKind.Notify)]
|
||
private Task ReportEnemyBattleMoveEnd(Connection conn, UxRpcMessage msg)
|
||
{
|
||
try
|
||
{
|
||
if (!msg.TryGetArgs<SceneMethods.EnemyAiMoveEnd4229938>(out var report) || report is null)
|
||
return Task.CompletedTask;
|
||
var runtime = GetLocalEnemyRuntimeState(conn.Session);
|
||
lock (runtime.SyncRoot)
|
||
if (runtime.Enemies.TryGetValue(report.pid, out var enemy) && enemy.AttackAiEnabled)
|
||
conn.Session.Log.Info($"[ENEMY-AI] native-move-end entity={report.pid}; end-report-not-arrival-proof");
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
conn.Log.Info($"[ENEMY-AI] move-end report unparsed: {ex.Message}");
|
||
}
|
||
return Task.CompletedTask;
|
||
}
|
||
}
|