ModernUO/Projects/UOContent/Mobiles/AI/BaseAI/BaseAI.cs
Kamron Batman f606225f47
fix: pets freeze, forget their orders, obey the wrong players, and fight when told not to (#2614)
## Summary

Pet orders lived in two files with nothing enforcing which phase owned what: `PetOrderHandlers.cs` ran one-shot handlers inside the `ControlOrder` setter and `PetOrders.cs` ran `DoOrderXxx` every AI tick from `Obey`. Friend/Unfriend refusals repeated their message every tick, Rename froze the pet, Drop on a dead pet never ended, the loyalty drain bypassed the release handler, and the command issuer leaked through a public field that only some handlers cleared (#2613 fixed the Release casualty of that split; this finishes the job).

Every order now lives in one place, `PetOrders.cs`, with two named phases:

- **Issue** — `BaseAI.IssueOrder(order, previous, issuer, resuming, interruptedTarget)` runs once, synchronously, from the new `BaseCreature.SetControlOrder` funnel. It may only set state and emit (message, sound, reveal) and returns the order to rest in. The funnel loops to a fixed point, so transient orders (Drop, Friend, Unfriend, Transfer, Release, Rename, Stop, Patrol) resolve before the setter returns and can never rest.
- **Tick** — `DoOrderXxx` runs from `Obey` for the six restable orders only (None, Come, Guard, Attack, Stay, Follow). Anything else that arrives there came from an old save and falls back to the standing order.

The issuer is a parameter: `BaseCreature.IssueOrder(order, issuer, target)` is the entry for player commands (speech, context menu, targeting), a raw `ControlOrder = x` assignment is a system-issued order, and nothing has to remember to clear anything. A resumed Follow restores the mobile the standing Follow was following, never a transient's target.

Because the funnel is synchronous it also carries the order being interrupted, so an administrative command can hand control back to what the pet was doing without storing anything per creature.

## Era behaviour, with sources

Two publishes govern most of the questions here, and the inherited code matched neither exactly.

[**Publish 16**](https://uo.com/wiki/ultima-online-wiki/technical/previous-publishes/2002-2/publish-16-part-2-4-23rd-july/) (23 July 2002) — *"The 'stop' command will stop a pet from guarding, following, and attacking."* Stop cancels the current attack and leaves the pet idle but still reactive. That is what this branch does whenever the stand-down policy below is off, in every era. (The same publish's *"Friends will only be able to issue movement commands to pets"* is the rule already enforced by `IsFriendOrder`.)

[**Publish 51**](https://uo.com/wiki/ultima-online-wiki/technical/previous-publishes/2008-2/publish-51-26th-march/) (26 March 2008) lists it per command:

> Follow: The pet should follow. It will not attack anything, even if it is attacked.
>
> Come: The pet should come. It will not attack anything, even if it is attacked.
>
> Stay: The pet will stay where it is currently, and will not attack anything, even if it is attacked.
>
> Stop: The pet will stop attacking. It will not attack anything, even if it is attacked, and may wander.
>
> Guard: The pet should guard as it does currently.
>
> Kill/Attack: The pet will attack its target as it does currently.

The inherited rule covered Follow and Stay only, so a pet told to come fought back, and it could not cover Stop at all: Stop resolves to None rather than resting, and None is exactly the state the publish describes. `BaseAI.IsStandDownOrder` now names the set — Follow, Come, Stay, None — and both halves of `AggressiveAction` read it.

## Configuration

`taming.petsStandDownOnCommand` (default `Core.ML`) controls the Publish 51 behaviour. The publish has no step of its own on the expansion ladder — it lands between ML and SA, and Kingdom Reborn was a client rather than an expansion — so it keeps riding ML as before, and the setting carries the rest: the behaviour is popular well outside its era, so a shard on AOS that wants it sets the key, and one that does not clears it. `BaseCreature.StandsDownOnCommand` is virtual for a creature that should differ.

## Bugs fixed along the way

- Friend/Unfriend refusal spam (every tick until the next command); Rename freezing the pet; Drop on a dead or non-`CanDrop` pet freezing the pet.
- Loyalty-zero release skipping the name clear and the summoned kill; a released pet keeping its `Friends` list and its previous owner's standing order.
- A transferred pet still answering to the previous owner's friends; transfer playing the idle sound twice.
- `BaseTalisman` summons issued `Friend` with no target (*looks confused* forever, or young-player spam); they follow their owner.
- Speech: single-pet commands lost their name gate after #2232 (a bare "come" moved every pet in range; "all stay" issued Stay twice); the speech cases passed a hardcoded `isOwner: true`, so a pet friend could say "`<name>` drop" and dump the pack, or issue Come/Guard.
- GM "`<name>` obey" was unreachable for a controlled pet; context-menu Release and speech Release disagreed about the control roll (resolved by removing it from both, below).
- Login derived the standing order from proximity even for a pet saved on Stay (zeroing its post), and only recorded the derived order without issuing it, so a pet saved mid-transient idled after a restart.
- `Friends` mutations never marked the creature dirty for delta saves.
- A resumed standing Follow was left without a target and cancelled itself to idle on the next think — the same defect as #2616, fixed here by `IssueFollow(resuming)` restoring the remembered target.

## Behaviour changes a shard maintainer will notice

- Every player command reveals its issuer, including context-menu commands from a hidden owner. This restores the blanket reveal (RunUO reveals in every order arm) minus its bug: it revealed the **control master**, so a friend's command popped the owner wherever they stood. Speech already reveals through `Mobile.OnSaid`, so the practical change is the context menu, target picks and the release gump.
- Resumed/chained orders are silent (no idle sound when falling back after Drop, Stop, a refused Friend, etc.).
- **Administrative commands no longer call the pet off.** Drop, Friend, Unfriend and Rename keep the pet's combat posture and hand control back to the order they interrupted, target and all; the standing order is the fallback only when the interrupted order cannot resume (a transient, or an attack whose target died, left or hid). Resuming an attack does not repeat its aggression or replay its bark.
- **Friend and Unfriend no longer rewrite the standing order.** Previously a success pointed the pet at the new friend and made Follow its standing order, so a pet left on Stay silently became a Follow with its anchor cleared. Friending grants a permission and nothing else; the friend has movement commands and can ask the pet to follow.
- **Releasing a pet no longer rolls the control chance**, on either path. A refused roll cost 3 loyalty, and loyalty reaching zero releases the pet anyway, so refusing only converted a deliberate release into an involuntary one minutes later. Both paths gate on `CanBeControlledBy` instead: if you can command it, you can dismiss it.
- **The pet distraction roll is gone.** A pet on Follow had a 10% chance per damage callback of dropping the order and attacking whoever hit it, issued without consulting anything, so it overrode the stand-down policy a few hits after the aggression path had correctly ignored it. Its era gate was guesswork by its own comment's admission and no publish describes it; `CanBeDistracted`, `CheckDistracted`, both call sites and the `Golem` override are deleted. Pre-ML shards lose the mechanic.
- **A pet's follow pace moved off the think clock.** The AOS sprint wrote a bespoke `CurrentSpeed = 0.1`, which fused both clocks — discarding any configured `ActiveMoveSpeed`/`PassiveMoveSpeed` — and pinned a following pet's AI at 10 Hz even while standing still. `BaseCreature.FollowMoveSpeed` (virtual, AOS 0.1) now caps the resolved step delay while the pet is closing on its master, the same way herding does: nothing stored, and a creature configured faster keeps its own pace.
- Transfer with an invalid target is a refusal (resumes) instead of forcing Stay; the transfer combat gate rests on the aggressor lists and `NextCombatTime`.
- Stop with no standing order anchors the idle where the pet stands (a vendor-bought pet no longer wanders off unbounded).
- The old "master must be alive" bails in the handlers are gone; stand-down and sounds run for orphaned pets too. RunUO gates only on the master being null or deleted, and a living friend commanding a dead owner's pet could not previously call it out of a fight.
- Login: a pet at None near its master is issued Follow silently; a saved Stay/Follow/Guard is adopted as is.
- GM "all obey" only reaches wild creatures; a controlled pet must be named.
- Death still issues Follow with the idle sound, as RunUO did.

## Tests

`PetOrderTests` grew from 16 to 63, plus 13 in a new `PetRetaliationTests` for the Publish 51 matrix and 13 in `PetPacingTests` for the clocks. Together they cover order resolution, reveal on every entry path, release parity (player vs drain, summoned), transfer/friend refusals and successes, stand-down and war-mode invariants, the interrupted-order resume, speech gating and permissions, GM obey, login derivation, the load probe, and the retaliation matrix across eras and both damage callbacks. Whole project green: 845 `UOContent.Tests`, 869 `Server.Tests`.
2026-09-10 19:28:13 -07:00

1149 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
{
// How far a guarding pet may drift from its master before it closes the gap.
internal const int GuardRange = 3;
// 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;
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.IssueOrder(order, from, target);
}
}
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 && !IsFriendOrder(order))
{
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, 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);
}
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);
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, 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;
}
var reacquireDelay = (long)Mobile.ReacquireDelay.TotalMilliseconds;
var gateRemaining = Mobile.NextReacquireTime - Core.TickCount;
if (gateRemaining > 0 && gateRemaining <= reacquireDelay)
{
Mobile.FocusMob = null;
return false;
}
DebugSay("Acquiring new target...", 0);
var acquired = AcquireNewFocusMob(Mobile.Map, iRange, acqType, bPlayerOnly, bFacFriend, bFacFoe);
// Reaction time is the approach path (BaseCreature.ScheduleAcquireOnApproach),
// not this poll — every scan honors the full delay.
Mobile.NextReacquireTime = Core.TickCount + reacquireDelay;
return acquired;
}
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;
Mobile enemySummonMob = null;
var val = double.MinValue;
var 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();
}
}