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;
///
/// 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.
///
internal sealed partial class GameRouter
{
/// Flat distance at which the enemy stops closing in and swings instead.
private const float EnemyAiAttackRangeMeters = 2.8f;
/// Flat distance at which the enemy notices the player at all.
// 2026-09-26:原为硬编码 30f,现提到 Aether.EnemyAiAggroRangeMeters。
// ⛑ 填 0 ⇒ EnemyAiSelectTarget 恒返回 0 ⇒ 敌人永不主动锁定玩家(保持未察觉)
// ⇒ 蹲伏暗杀才有窗口(已交战的敌人不可暗杀)。想恢复原行为填 30。
private static float EnemyAiAggroRangeMeters
=> MathF.Max(0f, PrivateServerConfigStore.Current.Gameplay.Aether.EnemyAiAggroRangeMeters);
///
/// 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.
///
private const int EnemyAiPollMs = 100;
/// Move dispatch interval. Each command is a finite walk (MoveTime), so re-issuing
/// faster than that would restart the walk instead of extending it.
private const int EnemyAiMoveIntervalMs = 1000;
// ---- 移动节奏(2026-09-23:修「走一步停一下」的抽搐)----
//
// SyncEnemyBattleMoveTowardUnit 是**有限位移**:客户端只走 MoveTime 秒就停在那儿等下一条。
// 原来 MoveTime=0.65s 而派发间隔 1000ms ⇒ 走 0.65s、干站 0.35s,看起来就是一步一顿。
// 所以这两个值必须配对:间隔 ≥ 时长(短了会打断),又不能长太多(长了就是停顿)。
// 都从 PrivateServerConfig 读,便于实机微调,不用重编译。
/// 单条移动命令让客户端走多久(秒)。
private static float EnemyAiMoveSecondsNow()
=> Math.Clamp(PrivateServerConfigStore.Current.Gameplay.Aether.EnemyAiMoveSeconds, 0.25f, 12f);
/// 派发间隔:默认贴着时长 +50ms;配置值更大时以配置为准。
private static int EnemyAiMoveIntervalMsNow(float moveSeconds)
=> Math.Max((int)(moveSeconds * 1000f) + 50,
Math.Max(200, PrivateServerConfigStore.Current.Gameplay.Aether.EnemyAiMoveIntervalMs));
///
/// 每次派发把估算锚点推进多少米。1.2 m 是 MoveTime=0.65s 时调出来的,按时长等比放大,
/// 免得「估算还在 5m 外、实体其实已经贴脸」导致它一直发移动命令原地挤。
///
private static float EnemyAiMoveStepNow(float moveSeconds)
=> EnemyAiMoveStepMeters * (moveSeconds / 0.65f);
/// 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.
private const int EnemyAiCastBusyMs = 6000;
private const int EnemyAiCastIntervalMs = 6500;
///
/// 攻击后的冻结时长改为可配。原来是硬编码 6000/6500 ms —— 打一下原地站 6.5 秒,
/// 这也是"走一步停一下"里那段"停"的来源之一(玩家看到的就是打完杵着不动)。
/// 配置缺项时退回硬编码默认值,避免老配置直接把敌人变成连击机关枪。
///
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);
/// Suppression after the enemy itself was hit, so being shot does not make it
/// attack faster.
private const int EnemyAiHurtSuppressMs = 1500;
/// 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.
private const float EnemyAiMoveStepMeters = 1.2f;
///
/// 客户端走这条命令的实际速度(米/秒),由 donor 配方反推:MoveTime=0.65s 走 1.2m。
/// 用来把「还剩几米」换算成「这条命令该走几秒」,好让一条命令正好走完剩下的路 ——
/// 命令越少起停越少,"走一步停一下"就越不明显。
///
private const float EnemyAiMoveMetersPerSecond = EnemyAiMoveStepMeters / 0.65f;
/// Worker exit threshold once nothing is left to drive.
private const int EnemyAiIdleExitMs = 15000;
///
/// 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.
///
private const int EnemyAiUnlockDelayMsEarly = 2200;
private const int EnemyAiUnlockDelayMsLate = 5000;
///
/// 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.
///
private const int EnemyAiHitDelayMs = 2200;
/// How many commands get a log line before the log goes quiet.
private const int EnemyAiVerboseLogLimit = 3;
///
/// 日志密度:前 3 条必打,之后每 N 条打一条(N 来自配置 EnemyAiVerboseLogEvery)。
/// 原来只打前 3 条 —— 实机上敌人一次接敌要走十几条移动命令,后面全被吞掉,
/// 排查「一步一停」时完全看不到节奏。
///
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;
}
///
/// 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.
///
private static readonly uint[] EnemyAiFallbackAttacks = [51900523, 51900524];
/// Resolved once per template: the behaviour set's own attacks when it has any.
/// 2026-09-26 Wave 2:改走 ——
/// 战斗模式开启时这里拿到的就是模式套件(melee 空手 / ranged 远程),与 spawn 时
/// 绑的技能槽、换的武器、推的动画集是同一份解析,不会出现「手拿空手动画集、
/// 甩的却是模板武器技能」的错位。off 时 = Find 本体,旧行为不变。
private static IReadOnlyList EnemyAiAttacksFor(uint templateId)
{
var profile = EnemyAiCatalog4229938.ResolveSpawnProfile(templateId);
return profile is not null && profile.Attacks.Count > 0 ? profile.Attacks : EnemyAiFallbackAttacks;
}
/// Chinese name of one skill in a template's pool, for logs. "" when unknown.
/// ⛑ Wave 2:anames 只覆盖模板原配攻击集;模式套件(空手/远程池)查不到名字,
/// 日志里就是空串 —— 只影响可读性,不影响行为。
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;
}
}
///
/// 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.
///
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}");
}
}
///
/// ★ 蹲伏暗杀播种(硬规则 192)—— 主动下发 SyncEnemyCanAssassinate(68218494)。
///
/// 为什么要这一段:客户端的候选筛选是自举死锁。
/// FeiSuoCrouchManager.CheckCanCrouchAss(0x14CB11E0) →
/// PreCheckCanAss(0x131F6700) 要求目标的 BattleDataModule.canAssassinate
/// (私有字段,偏移 0xB1)== true;该字段全 .text 唯一写入点就是这条 S2C 的接收函数
/// (0x127D102F mov byte ptr [rax+0xB1], bl,set_CanAssassinate 零调用者);
/// 而这条 S2C 名义上是 ReportCanAssassinateEnemies(67161256) 的回应,
/// 上报却必须先过 canAssassinate ⇒ 没有任何敌人天生带标记 ⇒ 循环永远启动不了
/// (实机:AskEnterFeiSuoCrouch 19 次 / ReportCanAssassinateEnemies 0 次)。
/// 官方用 Spoon 剧本动作 L18.Spoon.EnemySetCanAssassinate(dump.cs:3194822) 播种,
/// 我们没有 Spoon 系统 ⇒ 用这条主动下发替代。
///
/// ⛑⛑ 只在蹲伏期间播:离开蹲伏态由 RevokeAllAssassinationGrants 全部撤销。
/// ⛑ 每个敌人每次蹲伏只播一次(AssassinationSeededAgents):播完客户端若判定
/// 它不合格,会上报一个不含它的名单 ⇒ 上报路径撤销它;只看
/// AssassinationGrantedAgents 的话播种会以为「没授权过」而重播,
/// 撤销→播种→撤销来回甩。离开半径后重新靠近才会再播。
///
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();
var toRevoke = new List();
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}");
}
/// 0 when the player is too far, unknown, or the enemy is busy dying.
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;
}
/// Engagement batch, re-sent only when the lock actually changes — spamming it
/// resets the client's action layer and cuts every animation short.
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}");
}
}
///
/// 出招前让单位面向目标。三种方式由 gameplay.aether.enemyAiTurnPacket 选
/// ():
/// target(默认)= SyncUnitSmoothFaceToTarget,把目标单位甩给客户端算 ——
/// 转得准,因为客户端知道自己的真实朝向;
/// smooth = 旧的 SyncUnitSmoothFace(fromAngle, changeAngle),服务端按估算
/// 朝向算增量 —— 估算一旦过期就会转歪(实机上就是"转 90° 打空气");
/// off = 完全不发转向包(对照用:若技能都带 NoRotation=true,此时应当朝哪打哪)。
///
/// ⛑ 单位是度(记忆 2026-09-21.md:1066:Facing 与 EulerAngles.y 同值),
/// 一开始怀疑过的"度/弧度搞反"已经排除了。
///
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}");
}
}
/// 踱步两段之间随机停顿多久(毫秒),避免所有敌人同步齐步走。
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));
}
///
/// 哨兵踱步:玩家不在仇恨范围(30m)时让敌人「走一段 → 停一会 → 倒着走回来」。
///
/// 原来这段时间是完全不动的(EnemyAiSelectTarget 给不出目标 ⇒ RefreshEnemyActionAsync 直接
/// return),敌人从生成到玩家走近一直杵着。这里复用同一条 BattleMove 通道,靠
/// WalkFront / WalkBack 交替让位移互相抵消 —— 它只会原地来回踱步,不会一路漂到玩家身上。
///
/// ⛑️ 服务端拿不到真实坐标(客户端不回传),所以 AiPosition 是估算;估算漂太远会让实体和
/// 锚点对不上,因此用 HomePosition 做保险绳:离出生点超过 leash 就强制往回走。
///
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}");
}
}
///
/// 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.
///
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}");
}
}
///
/// 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.
///
[Handler(MethodId.ReportEnemyBattleMoveEnd, HandlerPacketKind.Notify)]
private Task ReportEnemyBattleMoveEnd(Connection conn, UxRpcMessage msg)
{
try
{
if (!msg.TryGetArgs(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;
}
}