Pets slowed dramatically after #2591 (issue #2593): the per-step budget grew from half a think interval to the full RunUO-parity move table, and a guarding pet resolves passive (OnCombatantChange clears Warmode whenever the combatant clears), putting Guard/Come at ~1.05s/step for Medium-bucket pets. Order changes and speed-ups also waited out the previously scheduled AITimer wake, because the timer wheel reads Interval only after the next fire. - CurrentMoveSpeed: a controlled pet executing a master's movement order (Come/Follow/Guard, no combatant) paces steps on the think clock — the wild-creature move table no longer slows obedience. Combat chases and herding keep their own pacing. - AITimer: track the pending wake and reschedule (Stop, Delay = remaining, Start) when a speed-up or fresh order moves the earliest deadline up; changes inside a tick still flow through ScheduleNext. New Prod() wakes the AI immediately on player commands, including from a stopped timer (stable claims no longer wait out the random construction stagger). - DoOrderGuard: guard-following routes through WalkMobileRange so it registers a move intent (between-think move wakes) and paths around obstacles instead of bare greedy stepping quantized to the think grid. Closes #2593 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1151 lines
32 KiB
C#
1151 lines
32 KiB
C#
/*************************************************************************
|
|
* ModernUO *
|
|
* Copyright 2019-2026 - ModernUO Development Team *
|
|
* Email: hi@modernuo.com *
|
|
* File: BaseAI.cs *
|
|
* *
|
|
* This program is free software: you can redistribute it and/or modify *
|
|
* it under the terms of the GNU General Public License as published by *
|
|
* the Free Software Foundation, either version 3 of the License, or *
|
|
* (at your option) any later version. *
|
|
* *
|
|
* You should have received a copy of the GNU General Public License *
|
|
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
|
|
************************************************************************/
|
|
|
|
using System;
|
|
using Server.Engines.Quests.Necro;
|
|
using Server.Engines.Spawners;
|
|
using Server.Engines.Virtues;
|
|
using Server.Factions;
|
|
using Server.Spells;
|
|
using Server.Spells.Spellweaving;
|
|
using Server.Targets;
|
|
|
|
namespace Server.Mobiles;
|
|
|
|
public abstract partial class BaseAI
|
|
{
|
|
// Last-known-position tracking: recorded while the combatant is in LOS; drives the
|
|
// guard-time investigation and the instant re-engage.
|
|
private const int GuardGraceDuration = 10_000;
|
|
private const int LkpFreshDuration = 30_000;
|
|
private const int InvestigateDuration = 15_000;
|
|
|
|
private ActionType _action;
|
|
public long _nextDetectHidden;
|
|
public DateTime _lastOrder = DateTime.MinValue;
|
|
public Mobile _commandIssuer;
|
|
|
|
private Mobile _lkpTarget;
|
|
private Point3D _lkpLocation;
|
|
private IPoint3D _lkpGoal; // boxed _lkpLocation handed to the PathFollower
|
|
private IPoint3D _herdGoal; // boxed herding goal handed to the PathFollower
|
|
private long _lkpExpireTick;
|
|
private long _guardStopTick;
|
|
private long _investigateStopTick;
|
|
private bool _investigating;
|
|
|
|
public PathFollower Path { get; protected set; }
|
|
public AITimer AITimer { get; }
|
|
public long NextMove { get; set; }
|
|
|
|
public BaseCreature Mobile { get; }
|
|
|
|
public long NextDebugMessage { get; set; }
|
|
|
|
public virtual bool CanDetectHidden => Mobile.Skills.DetectHidden.Value > 0;
|
|
|
|
public BaseAI(BaseCreature m)
|
|
{
|
|
Mobile = m;
|
|
NextMove = Core.TickCount;
|
|
AITimer = new AITimer(this);
|
|
|
|
if (!m.PlayerRangeSensitive || !World.Loading && m.Map != null && m.Map != Map.Internal && m.Map.GetSector(m.Location).Active)
|
|
{
|
|
AITimer.Activate();
|
|
}
|
|
|
|
if (Action != ActionType.Wander)
|
|
{
|
|
Action = ActionType.Wander;
|
|
}
|
|
}
|
|
|
|
public ActionType Action
|
|
{
|
|
get => _action;
|
|
set
|
|
{
|
|
if (_action != value)
|
|
{
|
|
_action = value;
|
|
OnActionChanged();
|
|
}
|
|
}
|
|
}
|
|
|
|
public virtual bool WasNamed(string speech) => !string.IsNullOrEmpty(Mobile.Name) && speech.InsensitiveStartsWith(Mobile.Name);
|
|
|
|
public virtual void BeginPickTarget(Mobile from, OrderType order)
|
|
{
|
|
if (!IsValidTarget(from, order))
|
|
{
|
|
return;
|
|
}
|
|
|
|
if (from.Target == null)
|
|
{
|
|
SendOrderMessage(from, order);
|
|
from.Target = new AIControlMobileTarget(this, order);
|
|
}
|
|
else if (from.Target is AIControlMobileTarget t && t.Order == order)
|
|
{
|
|
t.AddAI(this);
|
|
}
|
|
}
|
|
|
|
private static void SendOrderMessage(Mobile from, OrderType order)
|
|
{
|
|
switch (order)
|
|
{
|
|
case OrderType.Transfer:
|
|
{
|
|
from.SendLocalizedMessage(502038);
|
|
// Click on the person to transfer ownership to.
|
|
break;
|
|
}
|
|
case OrderType.Friend:
|
|
{
|
|
from.SendLocalizedMessage(502020);
|
|
// Click on the player whom you wish to make a co-owner.
|
|
break;
|
|
}
|
|
case OrderType.Unfriend:
|
|
{
|
|
from.SendLocalizedMessage(1070948);
|
|
// Click on the player whom you wish to remove as a co-owner.
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
public virtual void OnAggressiveAction(Mobile aggressor)
|
|
{
|
|
if (aggressor.Hidden)
|
|
{
|
|
return;
|
|
}
|
|
|
|
var currentCombat = Mobile.Combatant;
|
|
|
|
if (currentCombat == null || currentCombat == aggressor)
|
|
{
|
|
return;
|
|
}
|
|
|
|
if (Mobile.GetDistanceToSqrt(aggressor) < Mobile.GetDistanceToSqrt(currentCombat))
|
|
{
|
|
Mobile.Combatant = aggressor;
|
|
}
|
|
}
|
|
|
|
public virtual void EndPickTarget(Mobile from, Mobile target, OrderType order)
|
|
{
|
|
if (!IsValidTarget(from, order) || order == OrderType.Attack && !CanAttackTarget(from, target))
|
|
{
|
|
return;
|
|
}
|
|
|
|
if (Mobile.CheckControlChance(from))
|
|
{
|
|
Mobile.ControlTarget = target;
|
|
Mobile.ControlOrder = order;
|
|
|
|
if (order == OrderType.Attack)
|
|
{
|
|
Mobile.FocusMob = target;
|
|
Mobile.Combatant = target;
|
|
Action = ActionType.Combat;
|
|
}
|
|
}
|
|
}
|
|
|
|
private bool IsValidTarget(Mobile from, OrderType order)
|
|
{
|
|
if (Mobile.Deleted || !Mobile.Controlled || !from.InRange(Mobile, 14)
|
|
|| from.Map != Mobile.Map || !from.CheckAlive())
|
|
{
|
|
return false;
|
|
}
|
|
|
|
var isOwner = from == Mobile.ControlMaster;
|
|
var isFriend = !isOwner && Mobile.IsPetFriend(from);
|
|
|
|
if (!isOwner && !isFriend)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
if (isFriend && order is not (OrderType.Follow or OrderType.Stay or OrderType.Stop))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
private bool CanAttackTarget(Mobile from, Mobile target)
|
|
{
|
|
if (target.Hidden)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
if (target is BaseCreature creature && creature.IsScaryToPets && Mobile.IsScaredOfScaryThings)
|
|
{
|
|
Mobile.SayTo(from, "Your pet refuses to attack this creature!");
|
|
return false;
|
|
}
|
|
|
|
if (SolenHelper.CheckRedFriendship(from) &&
|
|
target is RedSolenInfiltratorQueen or RedSolenInfiltratorWarrior or RedSolenQueen or RedSolenWarrior or RedSolenWorker ||
|
|
SolenHelper.CheckBlackFriendship(from) &&
|
|
target is BlackSolenInfiltratorQueen or BlackSolenInfiltratorWarrior or BlackSolenQueen or BlackSolenWarrior or BlackSolenWorker)
|
|
{
|
|
from.SendLocalizedMessage(1063106);
|
|
// You can not force your pet to attack a creature you are protected from.
|
|
return false;
|
|
}
|
|
|
|
if (target is BaseFactionGuard)
|
|
{
|
|
Mobile.SayTo(from, "Your pet refuses to attack the guard.");
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
public void DebugSay(string message, int cooldownMs = 5000)
|
|
{
|
|
if (Mobile.Debug && NextDebugMessage - Core.TickCount <= 0)
|
|
{
|
|
Mobile.PublicOverheadMessage(MessageType.Regular, 41, false, message);
|
|
NextDebugMessage = Core.TickCount + cooldownMs;
|
|
}
|
|
}
|
|
|
|
public virtual bool Think()
|
|
{
|
|
if (Mobile.Deleted || Mobile.Map == null)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
if (CheckFlee())
|
|
{
|
|
return true;
|
|
}
|
|
|
|
if (_action == ActionType.Combat)
|
|
{
|
|
UpdateLastKnownLocation();
|
|
}
|
|
else if (_action is ActionType.Wander or ActionType.Guard)
|
|
{
|
|
TryReengageLastKnown();
|
|
}
|
|
|
|
switch (Action)
|
|
{
|
|
case ActionType.Wander:
|
|
{
|
|
Mobile.OnActionWander();
|
|
return DoActionWander();
|
|
}
|
|
case ActionType.Combat:
|
|
{
|
|
Mobile.OnActionCombat();
|
|
return DoActionCombat();
|
|
}
|
|
case ActionType.Guard:
|
|
{
|
|
Mobile.OnActionGuard();
|
|
return DoActionGuard();
|
|
}
|
|
case ActionType.Flee:
|
|
{
|
|
Mobile.OnActionFlee();
|
|
return DoActionFlee();
|
|
}
|
|
case ActionType.Interact:
|
|
{
|
|
Mobile.OnActionInteract();
|
|
return DoActionInteract();
|
|
}
|
|
case ActionType.Backoff:
|
|
{
|
|
Mobile.OnActionBackoff();
|
|
return DoActionBackoff();
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
public virtual void OnActionChanged()
|
|
{
|
|
// A change of course invalidates between-think movement continuation.
|
|
ClearMoveIntent();
|
|
|
|
switch (Action)
|
|
{
|
|
case ActionType.Wander:
|
|
{
|
|
HandleWanderAction();
|
|
break;
|
|
}
|
|
case ActionType.Combat:
|
|
{
|
|
HandleCombatAction();
|
|
break;
|
|
}
|
|
case ActionType.Guard:
|
|
{
|
|
HandleGuardAction();
|
|
break;
|
|
}
|
|
case ActionType.Flee:
|
|
{
|
|
HandleFleeAction();
|
|
break;
|
|
}
|
|
case ActionType.Interact:
|
|
{
|
|
HandleInteractAction();
|
|
break;
|
|
}
|
|
case ActionType.Backoff:
|
|
{
|
|
HandleBackoffAction();
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
private void HandleWanderAction()
|
|
{
|
|
Mobile.FocusMob = null;
|
|
Mobile.Warmode = false;
|
|
Mobile.Combatant = null;
|
|
}
|
|
|
|
private void HandleCombatAction()
|
|
{
|
|
Mobile.Warmode = true;
|
|
}
|
|
|
|
private void HandleGuardAction()
|
|
{
|
|
Mobile.Warmode = true;
|
|
Mobile.Combatant = null;
|
|
|
|
// Investigate a fresh last-seen position that is not already in view; the guard
|
|
// grace period begins once the investigation ends.
|
|
_investigating = _lkpTarget != null && Core.TickCount - _lkpExpireTick < 0 &&
|
|
!(Mobile.InRange(_lkpLocation, 1) ||
|
|
Mobile.InLOS(_lkpLocation) && Mobile.InRange(_lkpLocation, Mobile.RangePerception));
|
|
_investigateStopTick = Core.TickCount + InvestigateDuration;
|
|
_guardStopTick = Core.TickCount + GuardGraceDuration;
|
|
_lkpGoal = null;
|
|
}
|
|
|
|
private void HandleFleeAction()
|
|
{
|
|
Mobile.FocusMob = null;
|
|
Mobile.Warmode = true;
|
|
}
|
|
|
|
private void HandleInteractAction()
|
|
{
|
|
Mobile.Warmode = false;
|
|
}
|
|
|
|
private void HandleBackoffAction()
|
|
{
|
|
Mobile.Warmode = false;
|
|
}
|
|
|
|
public virtual bool DoActionWander()
|
|
{
|
|
if (CheckHerding())
|
|
{
|
|
this.DebugSayFormatted($"I am being herded by {Mobile.ControlTarget?.Name ?? "Unknown"}.");
|
|
}
|
|
else if (ShouldBackoff())
|
|
{
|
|
DebugSay("Detected threat, backing off!");
|
|
|
|
Action = ActionType.Backoff;
|
|
return true;
|
|
}
|
|
else if (Mobile.CurrentWayPoint != null)
|
|
{
|
|
HandleWayPoint();
|
|
}
|
|
else if (Mobile.IsAnimatedDead)
|
|
{
|
|
FollowMaster();
|
|
|
|
if (CheckMove() && CanMoveNow(out _) && !Mobile.CheckIdle())
|
|
{
|
|
WalkRandomInHome(3, 2, 1);
|
|
}
|
|
}
|
|
else if (CheckMove() && CanMoveNow(out _) && !Mobile.CheckIdle())
|
|
{
|
|
WalkRandomInHome(3, 2, 1);
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
public virtual bool OnAtWayPoint() => true;
|
|
|
|
private void HandleWayPoint()
|
|
{
|
|
var point = Mobile.CurrentWayPoint;
|
|
|
|
if ((point.X != Mobile.Location.X || point.Y != Mobile.Location.Y)
|
|
&& point.Map == Mobile.Map && point.Parent == null && !point.Deleted)
|
|
{
|
|
this.DebugSayFormatted($"Moving towards waypoint {point.X}, {point.Y}.");
|
|
|
|
DoMove(Mobile.GetDirectionTo(point));
|
|
}
|
|
else if (OnAtWayPoint())
|
|
{
|
|
this.DebugSayFormatted($"I have reached waypoint {point.X}, {point.Y}.");
|
|
|
|
Mobile.CurrentWayPoint = point.NextPoint;
|
|
if (point.NextPoint?.Deleted == true)
|
|
{
|
|
Mobile.CurrentWayPoint = point.NextPoint = point.NextPoint.NextPoint;
|
|
}
|
|
}
|
|
}
|
|
|
|
private void FollowMaster()
|
|
{
|
|
var master = Mobile.SummonMaster;
|
|
if (master != null && master.Map == Mobile.Map && master.InRange(Mobile, Mobile.RangePerception))
|
|
{
|
|
MoveTo(master, false, 1);
|
|
}
|
|
}
|
|
|
|
public virtual bool DoActionCombat()
|
|
{
|
|
if (Core.AOS && CheckHerding())
|
|
{
|
|
this.DebugSayFormatted($"I am being herded by {Mobile.ControlTarget?.Name ?? "Unknown"}.");
|
|
return true;
|
|
}
|
|
|
|
var combatant = Mobile.Combatant;
|
|
if (!IsValidCombatant(combatant))
|
|
{
|
|
DebugSay("My combatant is missing. Wandering...");
|
|
|
|
Mobile.FocusMob = null;
|
|
Mobile.Warmode = false;
|
|
Mobile.Combatant = null;
|
|
|
|
Action = ActionType.Wander;
|
|
return true;
|
|
}
|
|
|
|
if (Mobile.TriggerAbility(MonsterAbilityTrigger.CombatAction, combatant))
|
|
{
|
|
this.DebugSayFormatted($"I used my abilities on {combatant.Name}!");
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
public bool IsValidCombatant(Mobile combatant) => IsValidFocusMob(combatant)
|
|
&& (Mobile.InLOS(combatant) || IsHostile(combatant));
|
|
|
|
public bool IsValidFocusMob(Mobile focusMob) =>
|
|
focusMob != null
|
|
&& !focusMob.Deleted
|
|
&& focusMob.Map == Mobile.Map
|
|
&& focusMob.Alive
|
|
&& (focusMob is not BaseCreature bc || !bc.IsDeadPet)
|
|
&& focusMob.AccessLevel == AccessLevel.Player
|
|
&& Mobile.CanSee(focusMob)
|
|
&& Mobile.InRange(focusMob, Mobile.RangePerception);
|
|
|
|
public virtual bool DoActionGuard()
|
|
{
|
|
if (_investigating)
|
|
{
|
|
if (InvestigateLastKnown())
|
|
{
|
|
return true;
|
|
}
|
|
|
|
_investigating = false;
|
|
_guardStopTick = Core.TickCount + GuardGraceDuration;
|
|
}
|
|
|
|
if (Core.TickCount - _guardStopTick < 0)
|
|
{
|
|
DebugSay("I am on guard.");
|
|
|
|
if (Utility.Random(8) == 0)
|
|
{
|
|
Mobile.Direction = (Direction)Utility.Random(8);
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
DebugSay("I stopped being on guard. Going home...");
|
|
Action = ActionType.Wander;
|
|
|
|
return true;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Records the combatant's position while it is visible and in line of sight.
|
|
/// </summary>
|
|
private void UpdateLastKnownLocation()
|
|
{
|
|
var combatant = Mobile.Combatant;
|
|
|
|
if (combatant?.Deleted == false && combatant.Map == Mobile.Map &&
|
|
Mobile.CanSee(combatant) && Mobile.InLOS(combatant))
|
|
{
|
|
_lkpTarget = combatant;
|
|
_lkpLocation = combatant.Location;
|
|
_lkpExpireTick = Core.TickCount + LkpFreshDuration;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Re-engages the last-seen target when it returns to view within perception range,
|
|
/// bypassing the reacquire throttle.
|
|
/// </summary>
|
|
private bool TryReengageLastKnown()
|
|
{
|
|
var target = _lkpTarget;
|
|
|
|
if (target == null)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
if (target.Deleted || !target.Alive || target.Map != Mobile.Map ||
|
|
target is BaseCreature { IsDeadPet: true } || Core.TickCount - _lkpExpireTick >= 0)
|
|
{
|
|
ClearLastKnown();
|
|
return false;
|
|
}
|
|
|
|
if (Mobile.Controlled || Mobile.BardPacified || Mobile.BardProvoked || Mobile.FightMode == FightMode.None)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
if (!Mobile.InRange(target, Mobile.RangePerception) || !Mobile.CanSee(target) ||
|
|
!Mobile.InLOS(target) || !Mobile.CanBeHarmful(target, false))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
DebugSay("There you are!");
|
|
Mobile.Combatant = target;
|
|
Mobile.FocusMob = null;
|
|
Action = ActionType.Combat;
|
|
return true;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Walks toward the last-seen position until it is in view, reached, timed out, or
|
|
/// unreachable. Returns false when the investigation is finished.
|
|
/// </summary>
|
|
private bool InvestigateLastKnown()
|
|
{
|
|
if (_lkpTarget == null || Core.TickCount - _investigateStopTick >= 0)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
if (Mobile.InRange(_lkpLocation, 1) ||
|
|
Mobile.InLOS(_lkpLocation) && Mobile.InRange(_lkpLocation, Mobile.RangePerception))
|
|
{
|
|
DebugSay("They truly disappeared...");
|
|
return false;
|
|
}
|
|
|
|
_lkpGoal ??= _lkpLocation;
|
|
return MoveToPoint(_lkpGoal, false);
|
|
}
|
|
|
|
private void ClearLastKnown()
|
|
{
|
|
_lkpTarget = null;
|
|
_lkpGoal = null;
|
|
_investigating = false;
|
|
}
|
|
|
|
public virtual bool DoActionFlee()
|
|
{
|
|
var from = Mobile.Combatant;
|
|
|
|
if (!IsValidCombatant(from))
|
|
{
|
|
DebugSay("Combatant is missing.");
|
|
|
|
WalkRandomInHome(3, 2, 1);
|
|
return true;
|
|
}
|
|
|
|
DebugSay("I am fleeing!");
|
|
|
|
DoMove(from.GetDirectionTo(Mobile));
|
|
return true;
|
|
}
|
|
|
|
public virtual bool DoActionInteract() => true;
|
|
|
|
public virtual bool CheckHerding()
|
|
{
|
|
var target = Mobile.TargetLocation;
|
|
|
|
if (target == null)
|
|
{
|
|
_herdGoal = null;
|
|
return false;
|
|
}
|
|
|
|
var distance = Mobile.GetDistanceToSqrt(target);
|
|
|
|
if (distance >= 1 && distance <= 15)
|
|
{
|
|
// A cached boxed goal keeps the PathFollower persistent across ticks; walking
|
|
// through MoveToPoint paces herding on the movement clock and paths around
|
|
// obstacles.
|
|
if (_herdGoal == null || _herdGoal.X != target.X || _herdGoal.Y != target.Y)
|
|
{
|
|
_herdGoal = new Point3D(target.X, target.Y, Mobile.Map?.GetAverageZ(target.X, target.Y) ?? Mobile.Z);
|
|
}
|
|
|
|
MoveToPoint(_herdGoal, false);
|
|
return true;
|
|
}
|
|
|
|
if (distance < 1 && IsSpecialHerdingCase(target))
|
|
{
|
|
HandleSpecialHerdingCase();
|
|
}
|
|
|
|
Mobile.TargetLocation = null;
|
|
_herdGoal = null;
|
|
return false;
|
|
}
|
|
|
|
private bool IsSpecialHerdingCase(IPoint2D target) => target.X == 1076 && target.Y == 450 && Mobile is HordeMinionFamiliar;
|
|
|
|
private void HandleSpecialHerdingCase()
|
|
{
|
|
if (Mobile.ControlMaster is PlayerMobile pm && pm.Quest is DarkTidesQuest qs)
|
|
{
|
|
var obj = qs.FindObjective<FetchAbraxusScrollObjective>();
|
|
|
|
if (obj?.Completed == false)
|
|
{
|
|
Mobile.AddToBackpack(new ScrollOfAbraxus());
|
|
obj.Complete();
|
|
}
|
|
}
|
|
}
|
|
|
|
public virtual void DoBardPacified()
|
|
{
|
|
if (Core.Now < Mobile.BardEndTime)
|
|
{
|
|
DebugSay("I am pacified. Can not fight.");
|
|
|
|
Mobile.Warmode = false;
|
|
Mobile.Combatant = null;
|
|
}
|
|
else
|
|
{
|
|
DebugSay("I am free from pacification.");
|
|
|
|
Mobile.BardPacified = false;
|
|
}
|
|
}
|
|
|
|
public virtual void DoBardProvoked()
|
|
{
|
|
if (Core.Now >= Mobile.BardEndTime && IsProvokerLost())
|
|
{
|
|
DebugSay("Provoker missing.");
|
|
|
|
Mobile.BardProvoked = false;
|
|
Mobile.BardMaster = null;
|
|
Mobile.BardTarget = null;
|
|
Mobile.Warmode = false;
|
|
Mobile.Combatant = null;
|
|
}
|
|
else if (IsProvokeTargetLost())
|
|
{
|
|
DebugSay("Provoke target missing.");
|
|
|
|
Mobile.BardProvoked = false;
|
|
Mobile.BardMaster = null;
|
|
Mobile.BardTarget = null;
|
|
Mobile.Warmode = false;
|
|
Mobile.Combatant = null;
|
|
}
|
|
else
|
|
{
|
|
Mobile.Combatant = Mobile.BardTarget;
|
|
Action = ActionType.Combat;
|
|
}
|
|
}
|
|
|
|
private bool IsProvokerLost() =>
|
|
Mobile.BardMaster?.Deleted != false
|
|
|| Mobile.BardMaster.Map != Mobile.Map
|
|
|| Mobile.GetDistanceToSqrt(Mobile.BardMaster) > Mobile.RangePerception;
|
|
|
|
private bool IsProvokeTargetLost() =>
|
|
Mobile.BardTarget?.Deleted != false
|
|
|| Mobile.BardTarget.Map != Mobile.Map
|
|
|| Mobile.GetDistanceToSqrt(Mobile.BardTarget) > Mobile.RangePerception;
|
|
|
|
public virtual double FleeHealthThreshold => 0.0;
|
|
public virtual double FleeChance => 0.0;
|
|
|
|
public virtual bool CheckFlee()
|
|
{
|
|
if (Mobile.CheckFlee())
|
|
{
|
|
if (Mobile.Combatant == null)
|
|
{
|
|
Action = ActionType.Wander;
|
|
return false;
|
|
}
|
|
|
|
Action = ActionType.Flee;
|
|
return true;
|
|
}
|
|
|
|
if (StartFlee())
|
|
{
|
|
var duration = TimeSpan.FromSeconds(Utility.Random(10, 30));
|
|
Mobile.BeginFlee(duration);
|
|
|
|
Action = ActionType.Flee;
|
|
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
protected virtual bool StartFlee()
|
|
{
|
|
if (!Mobile.CanFlee)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
var hitPercent = (double)Mobile.Hits / Mobile.HitsMax;
|
|
|
|
if (hitPercent > FleeHealthThreshold)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
return Utility.RandomDouble() < FleeChance;
|
|
}
|
|
|
|
public virtual double BackoffChance => 0.0;
|
|
|
|
protected virtual bool ShouldBackoff()
|
|
{
|
|
if (Mobile.Combatant != null || BackoffChance <= 0.0)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
if (AcquireFocusMob(Mobile.RangePerception, Mobile.FightMode, false, false, true))
|
|
{
|
|
return Utility.RandomDouble() < BackoffChance;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
public virtual bool DoActionBackoff()
|
|
{
|
|
if (AcquireFocusMob(Mobile.RangePerception * 2, FightMode.Closest, true, false, true))
|
|
{
|
|
if (WalkMobileRange(Mobile.FocusMob, 1, false, Mobile.RangePerception, Mobile.RangePerception * 2))
|
|
{
|
|
DebugSay("I backed off to safety. Wandering...");
|
|
|
|
Action = ActionType.Wander;
|
|
}
|
|
}
|
|
else
|
|
{
|
|
DebugSay("Focus target missing. Wandering...");
|
|
|
|
Action = ActionType.Wander;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
public virtual void OnTeleported()
|
|
{
|
|
DebugSay("Teleported; recalculating path...");
|
|
|
|
Path?.ForceRepath();
|
|
}
|
|
|
|
public virtual bool AcquireFocusMob(int iRange, FightMode acqType, bool bPlayerOnly, bool bFacFriend, bool bFacFoe)
|
|
{
|
|
if (Mobile.Deleted || Mobile.Map == null)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
if (Mobile.BardPacified)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
if (HandleBardProvoked() || HandleControlled() || HandleConstantFocus())
|
|
{
|
|
return true;
|
|
}
|
|
|
|
if (acqType == FightMode.None)
|
|
{
|
|
Mobile.FocusMob = null;
|
|
return false;
|
|
}
|
|
|
|
if (HandleAggressor(acqType))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
if (Core.TickCount - Mobile.NextReacquireTime < 0)
|
|
{
|
|
Mobile.FocusMob = null;
|
|
return false;
|
|
}
|
|
|
|
Mobile.NextReacquireTime = Core.TickCount + (int)Mobile.ReacquireDelay.TotalMilliseconds;
|
|
|
|
DebugSay("Acquiring new target...");
|
|
|
|
if (Mobile.Map == null)
|
|
{
|
|
return Mobile.FocusMob != null;
|
|
}
|
|
|
|
return AcquireNewFocusMob(Mobile.Map, iRange, acqType, bPlayerOnly, bFacFriend, bFacFoe);
|
|
}
|
|
|
|
private bool HandleBardProvoked()
|
|
{
|
|
if (!Mobile.BardProvoked)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
if (Mobile.BardTarget?.Deleted != false)
|
|
{
|
|
Mobile.FocusMob = null;
|
|
return false;
|
|
}
|
|
|
|
Mobile.FocusMob = Mobile.BardTarget;
|
|
return true;
|
|
}
|
|
|
|
private bool HandleControlled()
|
|
{
|
|
if (!Mobile.Controlled)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
if (Mobile.ControlTarget?.Deleted == false &&
|
|
Mobile.ControlTarget?.Hidden != true &&
|
|
Mobile.ControlTarget?.Alive == true &&
|
|
Mobile.ControlTarget?.IsDeadBondedPet != true &&
|
|
Mobile.InRange(Mobile.ControlTarget, Mobile.RangePerception * 2))
|
|
{
|
|
Mobile.FocusMob = Mobile.ControlTarget;
|
|
return true;
|
|
}
|
|
|
|
if (Mobile.ControlTarget != null && Mobile.ControlTarget != Mobile.ControlMaster)
|
|
{
|
|
Mobile.ControlTarget = null;
|
|
}
|
|
|
|
Mobile.FocusMob = null;
|
|
return false;
|
|
}
|
|
|
|
private bool HandleConstantFocus()
|
|
{
|
|
if (Mobile.ConstantFocus == null)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
this.DebugSayFormatted($"Acquired focused target: {Mobile.ConstantFocus.Name}.");
|
|
|
|
Mobile.FocusMob = Mobile.ConstantFocus;
|
|
return true;
|
|
}
|
|
|
|
private bool HandleAggressor(FightMode acqType)
|
|
{
|
|
if (acqType != FightMode.Aggressor ||
|
|
Mobile.Aggressors.Count > 0 ||
|
|
Mobile.Aggressed.Count > 0 ||
|
|
Mobile.FactionAllegiance != null ||
|
|
Mobile.EthicAllegiance != null)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
Mobile.FocusMob = null;
|
|
return true;
|
|
}
|
|
|
|
private bool AcquireNewFocusMob(Map map, int iRange, FightMode acqType, bool bPlayerOnly, bool bFacFriend, bool bFacFoe)
|
|
{
|
|
Mobile newFocusMob = null, enemySummonMob = null;
|
|
double val = double.MinValue, enemySummonVal = double.MinValue;
|
|
|
|
foreach (var m in map.GetMobilesInRange(Mobile.Location, iRange))
|
|
{
|
|
if (IsInvalidTarget(m, bPlayerOnly))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
var bc = m as BaseCreature;
|
|
var pm = m as PlayerMobile;
|
|
|
|
if (IsInvalidSummonTarget(m, bc, pm) || IsInvalidFactionTarget(m, bFacFriend, bFacFoe)
|
|
|| IsInvalidFightModeTarget(m, acqType, bc))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
var theirVal = Mobile.GetFightModeRanking(m, acqType, bPlayerOnly);
|
|
|
|
if (theirVal > val && Mobile.InLOS(m))
|
|
{
|
|
newFocusMob = m;
|
|
val = theirVal;
|
|
}
|
|
else if (Core.AOS && theirVal > enemySummonVal
|
|
&& Mobile.InLOS(m) && bc?.Summoned == true && bc.Controlled != true)
|
|
{
|
|
enemySummonMob = m;
|
|
enemySummonVal = theirVal;
|
|
}
|
|
}
|
|
|
|
Mobile.FocusMob = newFocusMob ?? enemySummonMob;
|
|
return Mobile.FocusMob != null;
|
|
}
|
|
|
|
private bool IsInvalidTarget(Mobile m, bool bPlayerOnly) =>
|
|
m.Deleted || m.Blessed || m == Mobile || m is BaseFamiliar || !m.Alive || m.IsDeadBondedPet ||
|
|
m.AccessLevel > AccessLevel.Player || bPlayerOnly && !m.Player || !Mobile.CanSee(m);
|
|
|
|
private bool IsInvalidSummonTarget(Mobile m, BaseCreature bc, PlayerMobile pm)
|
|
{
|
|
if (Core.AOS && bc?.Summoned == true &&
|
|
(bc.SummonMaster == Mobile || !bc.SummonMaster.Player && IsHostile(bc.SummonMaster)))
|
|
{
|
|
return true;
|
|
}
|
|
|
|
if (!Mobile.Summoned || Mobile.SummonMaster == null)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
return m == Mobile.SummonMaster || !SpellHelper.ValidIndirectTarget(Mobile.SummonMaster, m) ||
|
|
Mobile.IsAnimatedDead && (pm != null || bc?.IsAnimatedDead == true || bc?.Controlled == true);
|
|
}
|
|
|
|
private bool IsInvalidFactionTarget(Mobile m, bool bFacFriend, bool bFacFoe)
|
|
{
|
|
if (bFacFriend && !Mobile.IsFriend(m))
|
|
{
|
|
return true;
|
|
}
|
|
|
|
if (TransformationSpellHelper.UnderTransformation(m, typeof(EtherealVoyageSpell)) ||
|
|
Mobile.Combatant != m && VirtueSystem.GetVirtues(m as PlayerMobile)?.HonorActive == true)
|
|
{
|
|
return true;
|
|
}
|
|
|
|
return bFacFoe && (!Mobile.IsEnemy(m) || !bFacFriend && !Mobile.CanBeHarmful(m, false));
|
|
}
|
|
|
|
private bool IsInvalidFightModeTarget(Mobile m, FightMode acqType, BaseCreature bc)
|
|
{
|
|
if (acqType is not (FightMode.Aggressor or FightMode.Evil))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
var valid = IsHostile(m) || Mobile.GetFactionAllegiance(m) == BaseCreature.Allegiance.Enemy
|
|
|| Mobile.GetEthicAllegiance(m) == BaseCreature.Allegiance.Enemy;
|
|
|
|
// Valid if FightMode is Evil and the target's karma is negative
|
|
return !valid && (acqType != FightMode.Evil || (bc?.GetMaster()?.Karma ?? m.Karma) >= 0);
|
|
}
|
|
|
|
private bool IsHostile(Mobile from) =>
|
|
Mobile.Combatant == from || from.Combatant == Mobile || IsAggressor(from) || IsAggressed(from);
|
|
|
|
private bool IsAggressor(Mobile from)
|
|
{
|
|
foreach (var aggressor in Mobile.Aggressors)
|
|
{
|
|
if (aggressor.Defender == from)
|
|
{
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
private bool IsAggressed(Mobile from)
|
|
{
|
|
foreach (var aggressed in Mobile.Aggressed)
|
|
{
|
|
if (aggressed.Attacker == from)
|
|
{
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
public virtual void DetectHidden()
|
|
{
|
|
if (Mobile.Deleted || Mobile.Map == null || !CanDetectHidden)
|
|
{
|
|
return;
|
|
}
|
|
|
|
DebugSay("Checking for hidden entities...");
|
|
|
|
var srcSkill = Mobile.Skills.DetectHidden.Value;
|
|
|
|
if (srcSkill <= 0)
|
|
{
|
|
return;
|
|
}
|
|
|
|
foreach (var trg in Mobile.GetMobilesInRange(Mobile.RangePerception))
|
|
{
|
|
if (IsValidTargetCombatTarget(trg))
|
|
{
|
|
TryDetectHidden(trg, srcSkill);
|
|
}
|
|
}
|
|
}
|
|
|
|
private bool IsValidTargetCombatTarget(Mobile trg) => trg != Mobile && trg.Player && trg.Alive && trg.Hidden &&
|
|
trg.AccessLevel == AccessLevel.Player && Mobile.InLOS(trg);
|
|
|
|
private void TryDetectHidden(Mobile trg, double srcSkill)
|
|
{
|
|
this.DebugSayFormatted($"Trying to detect: {trg.Name}");
|
|
|
|
var trgHiding = trg.Skills.Hiding.Value / 2.9;
|
|
var trgStealth = trg.Skills.Stealth.Value / 1.8;
|
|
var chance = Math.Max(srcSkill / 10, srcSkill / 1.2 - Math.Min(trgHiding, trgStealth)) / 100;
|
|
|
|
if (chance > Utility.RandomDouble())
|
|
{
|
|
trg.RevealingAction();
|
|
trg.SendLocalizedMessage(500814);
|
|
// You have been revealed!
|
|
}
|
|
}
|
|
|
|
public virtual void Deactivate()
|
|
{
|
|
if (Mobile.Map == Map.Internal || !Mobile.Controlled && !Mobile.Map.GetSector(Mobile.Location).Active)
|
|
{
|
|
AITimer.Stop();
|
|
}
|
|
|
|
if (ShouldReturnToHome(Mobile.Spawner))
|
|
{
|
|
Timer.StartTimer(ReturnToHome);
|
|
}
|
|
}
|
|
|
|
private bool ShouldReturnToHome(ISpawner spawner) =>
|
|
spawner?.ReturnOnDeactivate == true && !Mobile.Controlled &&
|
|
!spawner.IsInSpawnBounds(Mobile.Location);
|
|
|
|
private void ReturnToHome()
|
|
{
|
|
if (Mobile.Spawner is not Spawner spawner)
|
|
{
|
|
return;
|
|
}
|
|
|
|
var loc = spawner.GetSpawnPosition(Mobile, spawner.Map);
|
|
|
|
if (loc != Point3D.Zero)
|
|
{
|
|
Mobile.MoveToWorld(loc, spawner.Map);
|
|
}
|
|
|
|
Activate();
|
|
}
|
|
|
|
public virtual void Activate()
|
|
{
|
|
if (!AITimer.Running)
|
|
{
|
|
AITimer.Activate();
|
|
}
|
|
}
|
|
|
|
public virtual void OnCurrentSpeedChanged()
|
|
{
|
|
AITimer.OnSpeedChanged();
|
|
}
|
|
}
|