## 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`.
719 lines
23 KiB
C#
719 lines
23 KiB
C#
/*************************************************************************
|
|
* ModernUO *
|
|
* Copyright 2019-2026 - ModernUO Development Team *
|
|
* Email: hi@modernuo.com *
|
|
* File: AIMovement.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 System.Runtime.CompilerServices;
|
|
using Server.Collections;
|
|
using Server.Items;
|
|
using MoveImpl = Server.Movement.MovementImpl;
|
|
using Moves = Server.Movement.Movement;
|
|
|
|
namespace Server.Mobiles;
|
|
|
|
public abstract partial class BaseAI
|
|
{
|
|
// --- Centralized progress-based approach state (see ApproachTarget) ---------------
|
|
// Consecutive move-eligible ticks a creature may fail to improve its best distance to a
|
|
// STATIONARY goal before it gives up and idles. A moving goal (an active chase) never
|
|
// triggers give-up. Must exceed the longest no-improvement stretch of a valid detour
|
|
// (the Britain Inn detour's is ~13 ticks), with margin; this also bounds the largest
|
|
// concave detour a creature will navigate before idling on a stationary goal.
|
|
private const int ApproachGiveUpTicks = 40;
|
|
|
|
private Mobile _approachGoal;
|
|
private Point3D _approachGoalLoc;
|
|
private double _approachBestDist;
|
|
private int _approachStallTicks;
|
|
private bool _approachGaveUp;
|
|
private Point3D _approachGaveUpGoalLoc;
|
|
|
|
// --- Move intent (see ContinueMove) ------------------------------------------------
|
|
// Durable movement goal renewed by en-route ApproachTarget/MoveToPoint calls; while
|
|
// live, the AITimer wakes at NextMove between think ticks to advance the step.
|
|
private Mobile _moveIntentTarget;
|
|
private IPoint3D _moveIntentPoint;
|
|
private int _moveIntentRange;
|
|
private long _moveIntentExpire;
|
|
|
|
// Inflates a step delay while badly hurt; computed from the passed base so it cannot
|
|
// compound across steps. Damage slows steps, never decisions.
|
|
public static double BadlyHurtMoveDelay(BaseCreature bc, double delay)
|
|
{
|
|
var statMin = Core.HS ? bc.Stam : bc.Hits;
|
|
var statMax = Core.HS ? bc.StamMax : bc.HitsMax;
|
|
|
|
if (!bc.IsDeadPet && (bc.ReduceSpeedWithDamage || bc.IsSubdued)
|
|
&& statMax > 0 && statMin < statMax * 0.3)
|
|
{
|
|
var stat = (double)statMin / statMax;
|
|
|
|
if (stat < 0.1) { return delay + 0.15; }
|
|
if (stat < 0.2) { return delay + 0.1; }
|
|
|
|
return delay + 0.05;
|
|
}
|
|
|
|
return delay;
|
|
}
|
|
|
|
public bool CanMoveNow(out double delay)
|
|
{
|
|
delay = 0.0;
|
|
return Core.TickCount - NextMove >= 0;
|
|
}
|
|
|
|
// Seconds per step as the client observes it: the move clock plus the hurt inflation.
|
|
private double EffectiveStepDelay()
|
|
{
|
|
var stepDelay = Mobile.CurrentMoveSpeed;
|
|
|
|
return Core.AOS && IsFollowingMaster() ? stepDelay : BadlyHurtMoveDelay(Mobile, stepDelay);
|
|
}
|
|
|
|
// The Running bit only selects the client's per-step interpolation (walk 400ms / run
|
|
// 200ms on foot, 200/100 mounted). A step shorter than the walk time must run or the
|
|
// client falls behind and snaps — but an isolated step (after standing at least a walk
|
|
// interval) renders alone and darts if run-flagged, so it goes out as a walk. A true
|
|
// sprinter always runs: a walk-rendered first step would flood the client's queue.
|
|
public bool ShouldRun()
|
|
{
|
|
var mounted = Mobile.Mounted || Mobile.Flying;
|
|
var walkDelay = mounted ? Moves.WalkMountDelay : Moves.WalkFootDelay;
|
|
var pace = EffectiveStepDelay() * 1000;
|
|
|
|
if (pace >= walkDelay)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
var runDelay = mounted ? Moves.RunMountDelay : Moves.RunFootDelay;
|
|
|
|
return pace < runDelay || Core.TickCount - Mobile.LastMoveTime < walkDelay;
|
|
}
|
|
|
|
// One step per period, paced from the step just taken — no debt accrual: repaying a
|
|
// late step with a quicker follow-up puts two steps ~100ms apart, which renders as a
|
|
// dart. In continuous pursuit the move-wake lands within wheel resolution of this
|
|
// deadline, so the only cost is single-digit-ms drift per step.
|
|
private void ConsumeMoveBudget()
|
|
{
|
|
NextMove = Core.TickCount + Math.Max(50, (long)(EffectiveStepDelay() * 1000));
|
|
}
|
|
|
|
public virtual bool CheckMove() => !(Mobile.Deleted || Mobile.DisallowAllMoves);
|
|
|
|
public virtual bool DoMove(Direction d, bool badStateOk = false) => IsMoveSuccessful(DoMoveImpl(d, badStateOk), badStateOk);
|
|
|
|
private static bool IsMoveSuccessful(MoveResult res, bool badStateOk) =>
|
|
res is MoveResult.Success or MoveResult.SuccessAutoTurn
|
|
|| badStateOk && res == MoveResult.BadState;
|
|
|
|
public virtual MoveResult DoMoveImpl(Direction d, bool badStateOk)
|
|
{
|
|
if (IsInBadState() || !CanMoveNow(out _))
|
|
{
|
|
return MoveResult.BadState;
|
|
}
|
|
|
|
d = (d & Direction.Mask) | (ShouldRun() ? Direction.Running : 0);
|
|
|
|
if ((Mobile.Direction & Direction.Mask) != (d & Direction.Mask))
|
|
{
|
|
Mobile.Direction = d;
|
|
}
|
|
|
|
Mobile.Pushing = false;
|
|
var mobDirection = Mobile.Direction;
|
|
|
|
if (TryMove(d))
|
|
{
|
|
// Obeying pets are paced by their order handlers.
|
|
if (!IsObeyingMoveOrder())
|
|
{
|
|
if (Mobile.Warmode || Mobile.Combatant != null)
|
|
{
|
|
Mobile.SetCurrentSpeedToActive();
|
|
}
|
|
else
|
|
{
|
|
Mobile.SetCurrentSpeedToPassive();
|
|
}
|
|
}
|
|
|
|
ConsumeMoveBudget();
|
|
|
|
return MoveResult.Success;
|
|
}
|
|
|
|
if ((mobDirection & Direction.Mask) != (d & Direction.Mask))
|
|
{
|
|
Mobile.Direction = d;
|
|
return MoveResult.SuccessAutoTurn;
|
|
}
|
|
|
|
return HandleBlockedMovement(d);
|
|
}
|
|
|
|
private bool TryMove(Direction d)
|
|
{
|
|
MoveImpl.IgnoreMovableImpassables = Mobile.CanMoveOverObstacles && !Mobile.CanDestroyObstacles;
|
|
|
|
var result = Mobile.Move(d);
|
|
|
|
MoveImpl.IgnoreMovableImpassables = false;
|
|
return result;
|
|
}
|
|
|
|
private bool IsInBadState() =>
|
|
Mobile == null || Mobile.Deleted || Mobile.Frozen || Mobile.Paralyzed ||
|
|
Mobile.Spell?.IsCasting == true || Mobile.DisallowAllMoves;
|
|
|
|
private MoveResult HandleBlockedMovement(Direction d)
|
|
{
|
|
var wasPushing = Mobile.Pushing;
|
|
|
|
if ((Mobile.CanOpenDoors || Mobile.CanDestroyObstacles) && !TryClearObstacles(d))
|
|
{
|
|
return MoveResult.Success;
|
|
}
|
|
|
|
return TryAlternateMovement(wasPushing);
|
|
}
|
|
|
|
private MoveResult TryAlternateMovement(bool wasPushing)
|
|
{
|
|
var offset = Utility.Random(2) == 0 ? 1 : -1;
|
|
|
|
for (var i = 0; i < 2; ++i)
|
|
{
|
|
Mobile.TurnInternal(offset);
|
|
|
|
if (Mobile.Move(Mobile.Direction))
|
|
{
|
|
ConsumeMoveBudget();
|
|
return MoveResult.SuccessAutoTurn;
|
|
}
|
|
}
|
|
|
|
return wasPushing ? MoveResult.BadState : MoveResult.Blocked;
|
|
}
|
|
|
|
private bool TryClearObstacles(Direction d)
|
|
{
|
|
DebugSay("My movement is blocked. Trying to push through.");
|
|
|
|
var map = Mobile.Map;
|
|
|
|
if (map == null) { return true; }
|
|
|
|
var (x, y) = GetOffsetLocation(d);
|
|
|
|
var queue = GatherObstacles(x, y, out var destroyables);
|
|
|
|
if (destroyables > 0)
|
|
{
|
|
Effects.PlaySound(new Point3D(x, y, Mobile.Z), Mobile.Map, 0x3B3);
|
|
}
|
|
|
|
try
|
|
{
|
|
return ProcessObstacles(ref queue, d);
|
|
}
|
|
finally
|
|
{
|
|
queue.Dispose();
|
|
}
|
|
}
|
|
|
|
private (int x, int y) GetOffsetLocation(Direction d)
|
|
{
|
|
var x = Mobile.X;
|
|
var y = Mobile.Y;
|
|
Movement.Movement.Offset(d, ref x, ref y);
|
|
return (x, y);
|
|
}
|
|
|
|
private PooledRefQueue<Item> GatherObstacles(int x, int y, out int destroyables)
|
|
{
|
|
var queue = PooledRefQueue<Item>.Create();
|
|
destroyables = 0;
|
|
|
|
foreach (var item in Mobile.Map.GetItemsInRange(new Point2D(x, y), 1))
|
|
{
|
|
if (IsValidDoor(item, x, y) || IsValidDestroyableItem(item))
|
|
{
|
|
queue.Enqueue(item);
|
|
if (item is not BaseDoor)
|
|
{
|
|
destroyables++;
|
|
}
|
|
}
|
|
}
|
|
|
|
return queue;
|
|
}
|
|
|
|
private bool IsValidDoor(Item item, int x, int y)
|
|
{
|
|
if (!Mobile.CanOpenDoors || item is not BaseDoor door)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
if (door.Z + door.ItemData.Height <= Mobile.Z || Mobile.Z + 16 <= door.Z)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
if (door.X != x || door.Y != y)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
return !door.Locked || !door.UseLocks();
|
|
}
|
|
|
|
private bool IsValidDestroyableItem(Item item)
|
|
{
|
|
if (!Mobile.CanDestroyObstacles || !item.Movable || !item.ItemData.Impassable)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
if (item.Z + item.ItemData.Height <= Mobile.Z || Mobile.Z + 16 <= item.Z)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
return Mobile.InRange(item.GetWorldLocation(), 1);
|
|
}
|
|
|
|
private bool ProcessObstacles(ref PooledRefQueue<Item> queue, Direction d)
|
|
{
|
|
if (queue.Count == 0) { return true; }
|
|
|
|
while (queue.Count > 0)
|
|
{
|
|
ProcessObstacle(queue.Dequeue(), ref queue);
|
|
}
|
|
|
|
return !Mobile.Move(d);
|
|
}
|
|
|
|
private void ProcessObstacle(Item item, ref PooledRefQueue<Item> queue)
|
|
{
|
|
if (item is BaseDoor door)
|
|
{
|
|
DebugSay("Opening the door.");
|
|
door.Use(Mobile);
|
|
}
|
|
else
|
|
{
|
|
this.DebugSayFormatted($"Destroying item: {item.GetType().Name}");
|
|
|
|
if (item is Container cont)
|
|
{
|
|
ProcessContainer(cont, ref queue);
|
|
cont.Destroy();
|
|
}
|
|
else
|
|
{
|
|
item.Delete();
|
|
}
|
|
}
|
|
}
|
|
|
|
private void ProcessContainer(Container cont, ref PooledRefQueue<Item> queue)
|
|
{
|
|
foreach (var check in cont.Items)
|
|
{
|
|
if (check.Movable && check.ItemData.Impassable && cont.Z + check.ItemData.Height > Mobile.Z)
|
|
{
|
|
queue.Enqueue(check);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Centralized "move toward <paramref name="target"/> until within
|
|
/// <paramref name="range"/>" decision shared by MoveTo and WalkMobileRange. A greedy
|
|
/// step is taken only when it actually gets the creature closer; a blocked step or an
|
|
/// auto-turn sidestep that made no progress falls through to a persistent PathFollower
|
|
/// that routes around the obstacle and is never discarded by a greedy step. A
|
|
/// best-distance stall counter idles the creature if an in-range goal is genuinely
|
|
/// unreachable, without ever abandoning a real chase or detour.
|
|
/// </summary>
|
|
protected bool ApproachTarget(Mobile target, int range)
|
|
{
|
|
if (Mobile.Deleted || Mobile.DisallowAllMoves || target?.Deleted != false)
|
|
{
|
|
ClearMoveIntent();
|
|
return false;
|
|
}
|
|
|
|
if (Mobile.InRange(target, range))
|
|
{
|
|
ResetApproach();
|
|
ClearMoveIntent();
|
|
return true;
|
|
}
|
|
|
|
// Already gave up on this exact (unreachable) goal: idle until it moves.
|
|
if (_approachGaveUp && _approachGoal == target)
|
|
{
|
|
if (target.Location == _approachGaveUpGoalLoc)
|
|
{
|
|
ClearMoveIntent();
|
|
return false;
|
|
}
|
|
|
|
ResetApproach(); // target moved — try again fresh
|
|
}
|
|
|
|
RenewMoveIntent(target, null, range);
|
|
|
|
// FAST PATH: greedy step toward the target, counted as success ONLY when the move
|
|
// fully succeeded (not an auto-turn sidestep) and actually got us closer. An
|
|
// auto-turn sidestep can reduce Euclidean distance while moving in the wrong
|
|
// direction (e.g., east when the true route requires going south-first around a
|
|
// concave obstacle); treating it as progress would discard a PathFollower that is
|
|
// the only way to navigate. A blocked step or a non-Success result falls through to
|
|
// the planner immediately.
|
|
if (Path == null && Mobile.InLOS(target))
|
|
{
|
|
var distBefore = Mobile.GetDistanceToSqrt(target);
|
|
var res = DoMoveImpl(Mobile.GetDirectionTo(target), true);
|
|
|
|
if (res == MoveResult.BadState)
|
|
{
|
|
return true; // not allowed to move this tick (frozen/casting/throttled); not a failure
|
|
}
|
|
|
|
if (res == MoveResult.Success && Mobile.GetDistanceToSqrt(target) < distBefore)
|
|
{
|
|
|
|
ResetApproach();
|
|
return true; // healthy en-route progress
|
|
}
|
|
|
|
// else: fall through; let the PathFollower route around the obstacle.
|
|
}
|
|
|
|
// PLANNING PATH: a persistent PathFollower, never discarded by a greedy step.
|
|
if (Path == null || Path.Goal != target)
|
|
{
|
|
|
|
Path = new PathFollower(Mobile, target) { Mover = DoMoveImpl };
|
|
}
|
|
|
|
// Sample move-eligibility BEFORE the attempt: a successful step consumes the move
|
|
// budget, which would mask stall accounting and the progress signal.
|
|
var couldMove = CanMoveNow(out _) && !IsInBadState();
|
|
var locBefore = Mobile.Location;
|
|
|
|
if (Path.Follow(range))
|
|
{
|
|
ResetApproach();
|
|
return true;
|
|
}
|
|
|
|
TrackApproachProgress(target, couldMove);
|
|
|
|
// En-route progress is success; failure only when a move-eligible tick took no step
|
|
// (no working path), or the approach has given up.
|
|
var progressed = !_approachGaveUp && (Mobile.Location != locBefore || !couldMove);
|
|
|
|
return progressed;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Walks toward a fixed point (e.g. a target's last-known position), pathfinding around
|
|
/// obstacles. Returns false on arrival or when genuinely unable to make progress.
|
|
/// </summary>
|
|
public bool MoveToPoint(IPoint3D goal)
|
|
{
|
|
if (Mobile.Deleted || Mobile.DisallowAllMoves || goal == null)
|
|
{
|
|
ClearMoveIntent();
|
|
return false;
|
|
}
|
|
|
|
if (Path?.Goal != goal)
|
|
{
|
|
Path = new PathFollower(Mobile, goal) { Mover = DoMoveImpl };
|
|
}
|
|
|
|
RenewMoveIntent(null, goal, 1);
|
|
|
|
var couldMove = CanMoveNow(out _) && !IsInBadState();
|
|
var locBefore = Mobile.Location;
|
|
|
|
if (Path.Follow(1))
|
|
{
|
|
Path = null;
|
|
ClearMoveIntent();
|
|
return false; // arrived
|
|
}
|
|
|
|
var progressed = Mobile.Location != locBefore || !couldMove;
|
|
|
|
if (!progressed)
|
|
{
|
|
ClearMoveIntent();
|
|
}
|
|
|
|
return progressed;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Best-distance stuck detection. A creature making real headway keeps lowering its
|
|
/// closest-ever distance to the goal (a detour's outbound leg pauses that, but it
|
|
/// resumes once the creature rounds the obstacle). A creature that cannot reach a
|
|
/// STATIONARY goal never lowers it and, after <see cref="ApproachGiveUpTicks"/> ticks,
|
|
/// gives up and idles. A MOVING goal (an active chase) resets the baseline every tick,
|
|
/// so chases never give up even when the gap holds constant.
|
|
/// </summary>
|
|
private void TrackApproachProgress(Mobile target, bool couldMove)
|
|
{
|
|
if (!couldMove)
|
|
{
|
|
return; // a tick that was never allowed to move (stun, stall) is not a stall
|
|
}
|
|
|
|
var dist = Mobile.GetDistanceToSqrt(target);
|
|
var goalLoc = target.Location;
|
|
|
|
// New goal, or the goal moved (active chase): reset the stall baseline. Clearing the
|
|
// give-up flag here prevents a prior goal's give-up state from leaking onto a new one.
|
|
if (_approachGoal != target || goalLoc != _approachGoalLoc)
|
|
{
|
|
_approachGoal = target;
|
|
_approachGoalLoc = goalLoc;
|
|
_approachBestDist = dist;
|
|
_approachStallTicks = 0;
|
|
_approachGaveUp = false;
|
|
return;
|
|
}
|
|
|
|
// Stationary goal: getting closer than ever resets the stall.
|
|
if (dist < _approachBestDist)
|
|
{
|
|
_approachBestDist = dist;
|
|
_approachStallTicks = 0;
|
|
return;
|
|
}
|
|
|
|
if (++_approachStallTicks >= ApproachGiveUpTicks)
|
|
{
|
|
_approachGaveUp = true;
|
|
_approachGaveUpGoalLoc = goalLoc;
|
|
Path = null;
|
|
ClearMoveIntent();
|
|
}
|
|
}
|
|
|
|
/// <summary>Clears all approach state (called on arrival, real greedy progress, or when
|
|
/// a given-up goal moves).</summary>
|
|
private void ResetApproach()
|
|
{
|
|
Path = null;
|
|
_approachGoal = null;
|
|
_approachGoalLoc = Point3D.Zero;
|
|
_approachBestDist = 0;
|
|
_approachStallTicks = 0;
|
|
_approachGaveUp = false;
|
|
}
|
|
|
|
private void RenewMoveIntent(Mobile target, IPoint3D point, int range)
|
|
{
|
|
_moveIntentTarget = target;
|
|
_moveIntentPoint = point;
|
|
_moveIntentRange = range;
|
|
|
|
// A live pursuit renews every think tick; unrenewed intent dies on its own.
|
|
_moveIntentExpire = Core.TickCount + (long)(Mobile.CurrentSpeed * 2000) + 250;
|
|
}
|
|
|
|
public void ClearMoveIntent()
|
|
{
|
|
_moveIntentTarget = null;
|
|
_moveIntentPoint = null;
|
|
}
|
|
|
|
/// <summary>
|
|
/// True while a durable movement goal is live; <paramref name="nextMove"/> is the tick
|
|
/// the movement budget elapses.
|
|
/// </summary>
|
|
public bool TryGetMoveWake(out long nextMove)
|
|
{
|
|
nextMove = NextMove;
|
|
|
|
return (_moveIntentTarget != null || _moveIntentPoint != null) && Core.TickCount - _moveIntentExpire < 0;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Advances the current pursuit/investigation by one step on a movement-clock wake;
|
|
/// no decisions run.
|
|
/// </summary>
|
|
public void ContinueMove()
|
|
{
|
|
if (!TryGetMoveWake(out var nextMove) || Core.TickCount - nextMove < 0)
|
|
{
|
|
return;
|
|
}
|
|
|
|
if (_moveIntentTarget != null)
|
|
{
|
|
ApproachTarget(_moveIntentTarget, _moveIntentRange);
|
|
}
|
|
else
|
|
{
|
|
MoveToPoint(_moveIntentPoint);
|
|
}
|
|
}
|
|
|
|
public virtual bool MoveTo(Mobile m, int range)
|
|
{
|
|
if (Mobile.Deleted || Mobile.DisallowAllMoves || m?.Deleted != false)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
if (Mobile.InRange(m, range))
|
|
{
|
|
ResetApproach();
|
|
return true;
|
|
}
|
|
|
|
if (UseGroupMovement(m, range))
|
|
{
|
|
return MoveToWithGroup(this, m, range);
|
|
}
|
|
|
|
return ApproachTarget(m, range);
|
|
}
|
|
|
|
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
|
public bool IsFollowingMaster() =>
|
|
Mobile.Controlled &&
|
|
Mobile.ControlOrder == OrderType.Follow &&
|
|
Mobile.ControlTarget == Mobile.ControlMaster &&
|
|
Mobile.Combatant == null;
|
|
|
|
// Following its master, or guarding from outside guard range. FollowMoveSpeed caps the step
|
|
// delay while this holds.
|
|
public bool IsPacingToMaster()
|
|
{
|
|
if (!Mobile.Controlled || Mobile.Combatant != null)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
return Mobile.ControlOrder switch
|
|
{
|
|
OrderType.Follow => Mobile.ControlTarget == Mobile.ControlMaster,
|
|
OrderType.Guard => Mobile.ControlMaster?.Deleted == false &&
|
|
(int)Mobile.GetDistanceToSqrt(Mobile.ControlMaster) > GuardRange,
|
|
_ => false
|
|
};
|
|
}
|
|
|
|
// A pet executing a movement order outside combat; its order handler owns its speed.
|
|
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
|
public bool IsObeyingMoveOrder() =>
|
|
Mobile.Controlled &&
|
|
Mobile.Combatant == null &&
|
|
Mobile.ControlOrder is OrderType.Come or OrderType.Follow or OrderType.Guard;
|
|
|
|
private bool MoveToWithCollisionAvoidance(Mobile target, int range)
|
|
{
|
|
var direction = Mobile.GetDirectionTo(target);
|
|
|
|
// Wall-slide auto-turns must not count as progress, or a creature pinned on
|
|
// geometry reports success forever.
|
|
var res = DoMoveImpl(direction, true);
|
|
|
|
if (res is MoveResult.Success or MoveResult.BadState)
|
|
{
|
|
return true;
|
|
}
|
|
|
|
for (var i = 1; i <= 3; i++)
|
|
{
|
|
var clockwise = (Direction)(((int)direction + i) % 8);
|
|
|
|
if (DoMoveImpl(clockwise, true) == MoveResult.Success)
|
|
{
|
|
return true;
|
|
}
|
|
|
|
var counterclockwise = (Direction)(((int)direction - i + 8) % 8);
|
|
|
|
if (DoMoveImpl(counterclockwise, true) == MoveResult.Success)
|
|
{
|
|
return true;
|
|
}
|
|
}
|
|
|
|
// Tactical sidesteps exhausted — route around the obstacle via the centralized
|
|
// approach primitive (persistent PathFollower, no oscillation).
|
|
return ApproachTarget(target, range);
|
|
}
|
|
|
|
public virtual bool WalkMobileRange(Mobile m, int iSteps, int iWantDistMin, int iWantDistMax)
|
|
{
|
|
if (Mobile.Deleted || Mobile.DisallowAllMoves || m == null)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
for (var i = 0; i < iSteps; i++)
|
|
{
|
|
var iCurrDist = (int)Mobile.GetDistanceToSqrt(m);
|
|
|
|
if (iCurrDist >= iWantDistMin && iCurrDist <= iWantDistMax)
|
|
{
|
|
return true;
|
|
}
|
|
|
|
if (!MoveTowardsOrAwayFrom(m, iCurrDist, iWantDistMax))
|
|
{
|
|
return false;
|
|
}
|
|
}
|
|
|
|
var dist = Mobile.GetDistanceToSqrt(m);
|
|
|
|
return dist >= iWantDistMin && dist <= iWantDistMax;
|
|
}
|
|
|
|
private bool MoveTowardsOrAwayFrom(Mobile m, int iCurrDist, int iWantDistMax)
|
|
{
|
|
if (iCurrDist > iWantDistMax)
|
|
{
|
|
// Too far: approach via the centralized progress-based primitive.
|
|
return ApproachTarget(m, iWantDistMax);
|
|
}
|
|
|
|
// Too close: back away. Retreat keeps the simple greedy behavior (out of scope).
|
|
if (DoMove(m.GetDirectionTo(Mobile), true))
|
|
{
|
|
Path = null;
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
}
|