This repository has been archived on 2026-10-31. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
Ananta-ps/Ananta.Server/Ananta.Handlers/Game/GameRouter.EnemyAiActions.cs
T

933 lines
46 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.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;
}
}