fix(ai): pet order/home refactor — stop & post-combat behavior (#2459)
## Summary
Fixes two related pet-behavior bugs and the underlying design flaw behind both:
1. **Post-combat erratic** — after a pet killed its `all kill` target it milled around erratically at the kill site (or failed to return to the master) until the player issued `all follow`/`all stop`.
2. **`all stop` returns home** — a pet with a non-zero `Home` walked back toward that location on `all stop` (on ML; on non-ML the old `DoOrderStop` was a no-op, so the sighting there came from a residual `Stay`).
### Root cause
`ControlOrder` and the wild-creature `Home` field were overloaded to express several distinct ideas, mutated/read inconsistently across order transitions:
- `Home` doubled as the controlled-pet "stay anchor" (`HandleStayOrder` set `Home = Location`) but nothing cleared it when the pet left the staying state; `HandleStopOrder` was the only handler that never touched it.
- `DoOrderStop` had dropped RunUO's `Home = Location` re-anchor, so on ML it walked to a stale anchor.
- The post-combat fallback was a fragile `_lastPetOrder` hack in `DoOrderNone` that re-anchored a resumed `Stay` at the corpse.
- Controlled idle-wander bypassed the `CheckIdle()` rest gate that every non-controlled creature uses, so idling pets jittered every AI tick.
## Approach
Separate three concepts that were tangled together:
- **`ControlOrder`** — the active order (may be transient: Come/Attack/Drop).
- **Persistent command** (`PersistentOrder` ∈ `{None, Stay, Follow, Guard}`) — the standing directive a pet falls back to when a transient order completes. Runtime-only (not serialized; reset to `None` on load) and **derived from master proximity on login** (near → Follow, far → Stay).
- **Anchor** (`Home`) — a pure function of the persistent command, set only when that command changes (never on transient transitions or fallback-resume), so it can't go stale.
### Behavior
- **Stop** is resolved immediately from what the pet was doing: Attack/Come → resume the persistent command; Follow/Guard → cancel to idle where it stands; Stay → stay put.
- **Stay** holds its post (returns only if displaced, e.g. after a fight) — no shuffle.
- **Idle** (`None`) is a gentle wander routed through `CheckMove/CanMoveNow/CheckIdle`, so idling pets take the same 15–25s rest periods as other creatures, on both ML and non-ML.
- **Post-combat** the pet resumes its persistent command (a staying pet returns to its original post, not the corpse).
- **Release** without a spawner anchors where the pet stands instead of pathing to a stale anchor.
This restores the RunUO-intended behavior (verified against the RunUO reference) while fixing the ModernUO regressions.
## Tests
New `PetOrderTests` (13 deterministic xUnit tests) cover: anchor lifecycle, the full Stop truth table, report 1 (post-combat return to post), report 2 (no stale-anchor walk-home), frozen-Stay/gated-idle wiring, release fix, derive-on-login, and a non-ML spot-check. The subjective wander *feel* is covered by a manual-QA checklist in the implementation plan.
## Notes
- Engine project (`Projects/Server`) untouched; the one `BaseCreature.cs` change is the `ControlOrder` setter passing the previous order to `OnCurrentOrderChanged`.
- `DoOrderCome` keeps auto-converting to `Stay` on arrival, which under the new model cleanly means "come and hold near me."
- Commits in this PR are temporarily **unsigned** (the signing agent's passphrase cache expired mid-session); happy to re-sign / amend on request.
This commit is contained in:
parent
c7aaf33de9
commit
346228fa69
7 changed files with 446 additions and 84 deletions
236
Projects/UOContent.Tests/Tests/Mobiles/AI/PetOrderTests.cs
Normal file
236
Projects/UOContent.Tests/Tests/Mobiles/AI/PetOrderTests.cs
Normal file
|
|
@ -0,0 +1,236 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Server;
|
||||
using Server.Mobiles;
|
||||
using Xunit;
|
||||
|
||||
namespace UOContent.Tests.Mobiles.AI;
|
||||
|
||||
[Collection("Sequential UOContent Tests")]
|
||||
public class PetOrderTests : IDisposable
|
||||
{
|
||||
// Track and delete every mobile we spawn so they don't linger in the shared static World
|
||||
// and pollute other tests in this collection (e.g. Tracking's nearby-mobile scan).
|
||||
private readonly List<Mobile> _created = new();
|
||||
|
||||
private (PlayerMobile master, PetTestStub pet) Spawn(Point3D masterLoc, Point3D petLoc)
|
||||
{
|
||||
var pair = PetTestSetup.SpawnControlledPet(masterLoc, petLoc);
|
||||
_created.Add(pair.master);
|
||||
_created.Add(pair.pet);
|
||||
return pair;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
foreach (var m in _created)
|
||||
{
|
||||
m?.Delete();
|
||||
}
|
||||
|
||||
_created.Clear();
|
||||
}
|
||||
[Fact]
|
||||
public void SetPersistentOrder_Stay_AnchorsHomeToCurrentLocation()
|
||||
{
|
||||
var (_, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1005, 1000, 0));
|
||||
|
||||
pet.AIObject.SetPersistentOrder(OrderType.Stay);
|
||||
|
||||
Assert.Equal(OrderType.Stay, pet.AIObject.PersistentOrder);
|
||||
Assert.Equal(pet.Location, pet.Home);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SetPersistentOrder_Follow_ClearsAnchor()
|
||||
{
|
||||
var (_, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1005, 1000, 0));
|
||||
pet.Home = new Point3D(900, 900, 0); // stale anchor
|
||||
|
||||
pet.AIObject.SetPersistentOrder(OrderType.Follow);
|
||||
|
||||
Assert.Equal(OrderType.Follow, pet.AIObject.PersistentOrder);
|
||||
Assert.Equal(Point3D.Zero, pet.Home);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Stop_WhileAttacking_FallsBackToPersistentFollow()
|
||||
{
|
||||
var (_, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1002, 1000, 0));
|
||||
pet.ControlOrder = OrderType.Follow; // persistent = Follow
|
||||
pet.ControlOrder = OrderType.Attack; // transient
|
||||
Assert.Equal(OrderType.Follow, pet.AIObject.PersistentOrder);
|
||||
|
||||
pet.ControlOrder = OrderType.Stop;
|
||||
|
||||
Assert.Equal(OrderType.Follow, pet.ControlOrder); // resumed standing order
|
||||
Assert.Equal(OrderType.Follow, pet.AIObject.PersistentOrder);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Stop_WhileFollowing_CancelsToIdleNone()
|
||||
{
|
||||
var (_, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1002, 1000, 0));
|
||||
pet.ControlOrder = OrderType.Follow;
|
||||
|
||||
pet.ControlOrder = OrderType.Stop;
|
||||
|
||||
Assert.Equal(OrderType.None, pet.ControlOrder);
|
||||
Assert.Equal(OrderType.None, pet.AIObject.PersistentOrder);
|
||||
Assert.Equal(pet.Location, pet.Home); // idle anchor = where stopped
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Stop_WhileStaying_RemainsStayingAtOriginalPost()
|
||||
{
|
||||
var post = new Point3D(1005, 1005, 0);
|
||||
var (_, pet) = Spawn(new Point3D(1000, 1000, 0), post);
|
||||
pet.ControlOrder = OrderType.Stay; // Home = post
|
||||
Assert.Equal(post, pet.Home);
|
||||
|
||||
pet.ControlOrder = OrderType.Stop;
|
||||
|
||||
Assert.Equal(OrderType.Stay, pet.ControlOrder);
|
||||
Assert.Equal(OrderType.Stay, pet.AIObject.PersistentOrder);
|
||||
Assert.Equal(post, pet.Home); // post unchanged
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Stay_ThenFollow_ThenStop_DoesNotReturnToOldStayAnchor() // report 2
|
||||
{
|
||||
var postA = new Point3D(1005, 1005, 0);
|
||||
var (_, pet) = Spawn(new Point3D(1000, 1000, 0), postA);
|
||||
pet.ControlOrder = OrderType.Stay; // Home = A
|
||||
pet.ControlOrder = OrderType.Follow; // Home cleared to Zero
|
||||
pet.MoveToWorld(new Point3D(1050, 1050, 0), pet.Map); // walked to B
|
||||
pet.ControlOrder = OrderType.Stop; // stop while following
|
||||
|
||||
Assert.NotEqual(postA, pet.Home); // never re-acquires A
|
||||
Assert.Equal(pet.Location, pet.Home); // idles at B
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AttackTargetLost_ResumesPersistentFollow()
|
||||
{
|
||||
var (_, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1002, 1000, 0));
|
||||
pet.ControlOrder = OrderType.Follow; // persistent = Follow
|
||||
pet.ControlOrder = OrderType.Attack;
|
||||
pet.ControlTarget = null; // target gone
|
||||
|
||||
pet.AIObject.DoOrderAttack(); // invalid-target path
|
||||
|
||||
Assert.Equal(OrderType.Follow, pet.ControlOrder);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StayPet_AttacksThenTargetLost_ReturnsToOriginalPost() // report 1
|
||||
{
|
||||
var post = new Point3D(1005, 1005, 0);
|
||||
var (_, pet) = Spawn(new Point3D(1000, 1000, 0), post);
|
||||
pet.ControlOrder = OrderType.Stay; // persistent = Stay, Home = post
|
||||
pet.ControlOrder = OrderType.Attack;
|
||||
pet.MoveToWorld(new Point3D(1060, 1060, 0), pet.Map); // chased far to the "corpse"
|
||||
pet.ControlTarget = null;
|
||||
|
||||
pet.AIObject.DoOrderAttack();
|
||||
|
||||
Assert.Equal(OrderType.Stay, pet.ControlOrder);
|
||||
Assert.Equal(post, pet.Home); // anchor still the original post, not the corpse
|
||||
}
|
||||
|
||||
// NOTE: the test fixture does not load tile data, so Mobile.Move is blocked and Location
|
||||
// never changes here. DoMoveImpl still sets Mobile.Direction before the (blocked) move,
|
||||
// so an *attempted* wander is observable via Direction. The subjective wander cadence is
|
||||
// covered by manual QA; these tests verify the gate/frozen wiring deterministically.
|
||||
[Fact]
|
||||
public void IdlePet_DoesNotAttemptToMove_WhileResting()
|
||||
{
|
||||
var (_, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1002, 1000, 0));
|
||||
pet.ControlOrder = OrderType.Follow;
|
||||
pet.ControlOrder = OrderType.Stop; // -> idle None
|
||||
Assert.Equal(OrderType.None, pet.ControlOrder);
|
||||
|
||||
pet.ForceIdle = true; // CheckIdle() reports resting -> idle wander must be skipped
|
||||
pet.Direction = Direction.North;
|
||||
for (var i = 0; i < 40; i++)
|
||||
{
|
||||
pet.AIObject.DoOrderNone();
|
||||
}
|
||||
|
||||
Assert.Equal(Direction.North, pet.Direction); // gated by CheckIdle -> never attempts a step
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StayingPet_DoesNotAttemptToMove()
|
||||
{
|
||||
var post = new Point3D(1005, 1005, 0);
|
||||
var (_, pet) = Spawn(new Point3D(1000, 1000, 0), post);
|
||||
pet.ControlOrder = OrderType.Stay;
|
||||
pet.Direction = Direction.North;
|
||||
|
||||
for (var i = 0; i < 40; i++)
|
||||
{
|
||||
pet.AIObject.DoOrderStay();
|
||||
}
|
||||
|
||||
Assert.Equal(Direction.North, pet.Direction); // frozen -> no wander attempts
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Release_WithoutSpawner_AnchorsHomeToCurrentLocation()
|
||||
{
|
||||
var loc = new Point3D(1010, 1010, 0);
|
||||
var (_, pet) = Spawn(new Point3D(1000, 1000, 0), loc);
|
||||
pet.ControlOrder = OrderType.Stay; // sets Home to loc
|
||||
pet.Home = new Point3D(800, 800, 0); // simulate a stale anchor
|
||||
pet.Spawner = null;
|
||||
|
||||
pet.AIObject.DoOrderRelease();
|
||||
|
||||
Assert.Equal(loc, pet.Home); // released where it stands, not the stale point
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Login_NearMaster_DerivesFollow()
|
||||
{
|
||||
var (master, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1001, 1000, 0));
|
||||
Assert.Equal(OrderType.None, pet.AIObject.PersistentOrder);
|
||||
|
||||
PetLoginHandler.DeriveFollowerOrders(master);
|
||||
|
||||
Assert.Equal(OrderType.Follow, pet.AIObject.PersistentOrder);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Login_FarFromMaster_DerivesStay()
|
||||
{
|
||||
var (master, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1040, 1000, 0));
|
||||
|
||||
PetLoginHandler.DeriveFollowerOrders(master);
|
||||
|
||||
Assert.Equal(OrderType.Stay, pet.AIObject.PersistentOrder);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Stop_WhileFollowing_CancelsToIdle_NonML()
|
||||
{
|
||||
var previous = Core.Expansion;
|
||||
try
|
||||
{
|
||||
Core.Expansion = Expansion.SE; // pre-ML: Core.ML is false
|
||||
var (_, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1002, 1000, 0));
|
||||
pet.ControlOrder = OrderType.Follow;
|
||||
|
||||
pet.ControlOrder = OrderType.Stop;
|
||||
|
||||
// Stop/idle resolution is era-independent.
|
||||
Assert.Equal(OrderType.None, pet.ControlOrder);
|
||||
Assert.Equal(OrderType.None, pet.AIObject.PersistentOrder);
|
||||
Assert.Equal(pet.Location, pet.Home);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Core.Expansion = previous;
|
||||
}
|
||||
}
|
||||
}
|
||||
51
Projects/UOContent.Tests/Tests/Mobiles/AI/PetTestStub.cs
Normal file
51
Projects/UOContent.Tests/Tests/Mobiles/AI/PetTestStub.cs
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
using Server;
|
||||
using Server.Mobiles;
|
||||
|
||||
namespace UOContent.Tests.Mobiles.AI;
|
||||
|
||||
// Minimal tameable creature for AI-state tests. Uses the AIType constructor so the
|
||||
// creature gets a real AIObject (the Serial ctor does not initialize AI).
|
||||
public class PetTestStub : BaseCreature
|
||||
{
|
||||
// When true, CheckIdle() reports "resting" so the idle-wander path must not move.
|
||||
public bool ForceIdle { get; set; }
|
||||
|
||||
public PetTestStub() : base(AIType.AI_Animal, FightMode.Closest, 10, 1)
|
||||
{
|
||||
Body = 0xC8; // dog
|
||||
}
|
||||
|
||||
// NPCSpeeds isn't configured in the test fixture; provide fixed speeds so the
|
||||
// AIType constructor doesn't hit the unconfigured speed table.
|
||||
public override void GetSpeeds(out double activeSpeed, out double passiveSpeed)
|
||||
{
|
||||
activeSpeed = 0.2;
|
||||
passiveSpeed = 0.4;
|
||||
}
|
||||
|
||||
public override bool CheckIdle() => ForceIdle || base.CheckIdle();
|
||||
|
||||
public PetTestStub(Serial serial) : base(serial)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
public static class PetTestSetup
|
||||
{
|
||||
// Places a player master and a controlled pet on Felucca and returns both.
|
||||
public static (PlayerMobile master, PetTestStub pet) SpawnControlledPet(
|
||||
Point3D masterLoc, Point3D petLoc)
|
||||
{
|
||||
var map = Map.Felucca;
|
||||
|
||||
var master = new PlayerMobile(World.NewMobile);
|
||||
master.DefaultMobileInit();
|
||||
master.MoveToWorld(masterLoc, map);
|
||||
|
||||
var pet = new PetTestStub();
|
||||
pet.MoveToWorld(petLoc, map);
|
||||
pet.SetControlMaster(master); // sets Controlled, Home=Zero, ControlOrder=Come
|
||||
|
||||
return (master, pet);
|
||||
}
|
||||
}
|
||||
50
Projects/UOContent/Mobiles/AI/BaseAI/PetLogin.cs
Normal file
50
Projects/UOContent/Mobiles/AI/BaseAI/PetLogin.cs
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
/*************************************************************************
|
||||
* ModernUO *
|
||||
* Copyright 2019-2026 - ModernUO Development Team *
|
||||
* Email: hi@modernuo.com *
|
||||
* File: PetLogin.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 ModernUO.CodeGeneratedEvents;
|
||||
|
||||
namespace Server.Mobiles;
|
||||
|
||||
public static class PetLoginHandler
|
||||
{
|
||||
// Within this many tiles of the master we assume the pet was following; otherwise staying.
|
||||
private const int FollowRange = 12;
|
||||
|
||||
[OnEvent(nameof(PlayerMobile.PlayerLoginEvent))]
|
||||
public static void OnLogin(PlayerMobile pm) => DeriveFollowerOrders(pm);
|
||||
|
||||
// The persistent command is runtime-only and reset to None on load. When the master logs
|
||||
// in we give each controlled pet that still has no standing command a sane one, inferred
|
||||
// from proximity: near master -> Follow, otherwise Stay.
|
||||
public static void DeriveFollowerOrders(PlayerMobile master)
|
||||
{
|
||||
if (master?.AllFollowers == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var follower in master.AllFollowers)
|
||||
{
|
||||
if (follower is BaseCreature { Controlled: true, Deleted: false } bc
|
||||
&& bc.ControlMaster == master
|
||||
&& bc.AIObject is { } ai
|
||||
&& ai.PersistentOrder == OrderType.None)
|
||||
{
|
||||
var near = bc.Map == master.Map && bc.GetDistanceToSqrt(master) <= FollowRange;
|
||||
ai.SetPersistentOrder(near ? OrderType.Follow : OrderType.Stay);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -19,7 +19,7 @@ namespace Server.Mobiles;
|
|||
|
||||
public abstract partial class BaseAI
|
||||
{
|
||||
public virtual void OnCurrentOrderChanged()
|
||||
public virtual void OnCurrentOrderChanged(OrderType previous)
|
||||
{
|
||||
if (Mobile.Deleted || Mobile.ControlMaster?.Deleted != false)
|
||||
{
|
||||
|
|
@ -49,8 +49,9 @@ public abstract partial class BaseAI
|
|||
}
|
||||
case OrderType.Stop:
|
||||
{
|
||||
HandleStopOrder();
|
||||
break;
|
||||
// Stop is resolved into another order; it never rests as the active order.
|
||||
ResolveStop(previous);
|
||||
return;
|
||||
}
|
||||
case OrderType.Transfer:
|
||||
{
|
||||
|
|
@ -83,6 +84,49 @@ public abstract partial class BaseAI
|
|||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// A freshly issued standing command becomes the persistent fallback and (re)anchors
|
||||
// Home. Skipped while resuming a fallback so a resume never re-anchors. See
|
||||
// ResumePersistentOrder.
|
||||
if (!_resolvingOrder && Mobile.ControlOrder is OrderType.Stay or OrderType.Follow or OrderType.Guard)
|
||||
{
|
||||
SetPersistentOrder(Mobile.ControlOrder);
|
||||
}
|
||||
}
|
||||
|
||||
// "Stop" cancels the active order, mapping to a resting order based on what the pet was
|
||||
// doing: Attack/Come/etc. -> resume the persistent command; Follow/Guard -> cancel to idle
|
||||
// (None) where it stands; Stay -> remain staying at its post.
|
||||
private void ResolveStop(OrderType previous)
|
||||
{
|
||||
_commandIssuer?.RevealingAction();
|
||||
_commandIssuer = null;
|
||||
Mobile.ControlTarget = null;
|
||||
|
||||
switch (previous)
|
||||
{
|
||||
case OrderType.Stay:
|
||||
{
|
||||
_resolvingOrder = true;
|
||||
Mobile.ControlOrder = OrderType.Stay; // remain staying; anchor untouched
|
||||
_resolvingOrder = false;
|
||||
break;
|
||||
}
|
||||
case OrderType.Follow:
|
||||
case OrderType.Guard:
|
||||
{
|
||||
SetPersistentOrder(OrderType.None); // cancel standing order; anchor = current
|
||||
_resolvingOrder = true;
|
||||
Mobile.ControlOrder = OrderType.None; // idle
|
||||
_resolvingOrder = false;
|
||||
break;
|
||||
}
|
||||
default: // Attack / Come / Drop / None / etc. -> resume the standing order
|
||||
{
|
||||
ResumePersistentOrder();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void HandleNoOrder()
|
||||
|
|
@ -178,24 +222,8 @@ public abstract partial class BaseAI
|
|||
Mobile.Warmode = false;
|
||||
Mobile.Combatant = null;
|
||||
Mobile.PlaySound(Mobile.GetIdleSound());
|
||||
Mobile.Home = Mobile.Location;
|
||||
_commandIssuer = null;
|
||||
}
|
||||
|
||||
private void HandleStopOrder()
|
||||
{
|
||||
if (Mobile.ControlMaster?.Alive != true)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_commandIssuer?.RevealingAction();
|
||||
Mobile.ControlTarget = null;
|
||||
Mobile.FocusMob = null;
|
||||
Mobile.Warmode = false;
|
||||
Mobile.Combatant = null;
|
||||
Mobile.PlaySound(Mobile.GetIdleSound());
|
||||
_commandIssuer = null;
|
||||
// Home (the stay anchor) is owned by SetPersistentOrder, not this handler.
|
||||
}
|
||||
|
||||
private void HandleReleaseOrder()
|
||||
|
|
|
|||
|
|
@ -17,7 +17,29 @@ namespace Server.Mobiles;
|
|||
|
||||
public abstract partial class BaseAI
|
||||
{
|
||||
private OrderType _lastPetOrder = OrderType.None;
|
||||
// The standing command a pet falls back to when a transient order (Attack/Come/Drop)
|
||||
// completes: None, Stay, Follow, or Guard. Runtime-only (not serialized); reset to None
|
||||
// on load and derived from master proximity on login. See PetLoginHandler.
|
||||
internal OrderType PersistentOrder { get; private set; } = OrderType.None;
|
||||
|
||||
// Guards anchor/persistent derivation while we resume a fallback order, so a resume
|
||||
// never re-derives the persistent command or re-anchors Home. See OnCurrentOrderChanged.
|
||||
private bool _resolvingOrder;
|
||||
|
||||
// The controlled-pet wander anchor (Home) is a pure function of the persistent command.
|
||||
internal void SetPersistentOrder(OrderType order)
|
||||
{
|
||||
PersistentOrder = order;
|
||||
Mobile.Home = order is OrderType.Follow or OrderType.Guard ? Point3D.Zero : Mobile.Location;
|
||||
}
|
||||
|
||||
// Resume the persistent command without re-deriving the persistent order or anchor.
|
||||
private void ResumePersistentOrder()
|
||||
{
|
||||
_resolvingOrder = true;
|
||||
Mobile.ControlOrder = PersistentOrder;
|
||||
_resolvingOrder = false;
|
||||
}
|
||||
|
||||
public virtual bool Obey() =>
|
||||
!Mobile.Deleted && Mobile.ControlOrder switch
|
||||
|
|
@ -43,33 +65,9 @@ public abstract partial class BaseAI
|
|||
|
||||
Mobile.Warmode = IsValidCombatant(Mobile.Combatant);
|
||||
|
||||
if (_lastPetOrder == OrderType.Guard)
|
||||
{
|
||||
DebugSay("Target lost, resuming guard duty.");
|
||||
|
||||
Mobile.ControlOrder = OrderType.Guard;
|
||||
_lastPetOrder = OrderType.None;
|
||||
return true;
|
||||
}
|
||||
else if (_lastPetOrder == OrderType.Stay)
|
||||
{
|
||||
DebugSay("Target lost, resuming stay position.");
|
||||
|
||||
Mobile.ControlOrder = OrderType.Stay;
|
||||
_lastPetOrder = OrderType.None;
|
||||
return true;
|
||||
}
|
||||
else if (_lastPetOrder == OrderType.Follow)
|
||||
{
|
||||
DebugSay("Target lost, resuming follow command.");
|
||||
|
||||
Mobile.ControlTarget = Mobile.ControlMaster;
|
||||
Mobile.ControlOrder = OrderType.Follow;
|
||||
_lastPetOrder = OrderType.None;
|
||||
return true;
|
||||
}
|
||||
|
||||
WalkRandom(3, 2, 1);
|
||||
// Pure idle: gently wander near the anchor, with CheckIdle rest periods. Pets resume
|
||||
// a standing order via ResumePersistentOrder, not by re-deriving it here.
|
||||
WalkRandomIdle();
|
||||
return true;
|
||||
}
|
||||
|
||||
|
|
@ -106,8 +104,6 @@ public abstract partial class BaseAI
|
|||
|
||||
if (Mobile.ControlTarget?.Deleted == false && Mobile.ControlTarget != Mobile)
|
||||
{
|
||||
_lastPetOrder = OrderType.Follow;
|
||||
|
||||
FollowTarget();
|
||||
}
|
||||
else
|
||||
|
|
@ -147,9 +143,8 @@ public abstract partial class BaseAI
|
|||
|
||||
this.DebugSayFormatted($"I am ordered to drop my items by {Mobile.ControlMaster?.Name ?? "Unknown"}.");
|
||||
|
||||
Mobile.ControlOrder = OrderType.None;
|
||||
|
||||
DropItems();
|
||||
ResumePersistentOrder();
|
||||
return true;
|
||||
}
|
||||
|
||||
|
|
@ -224,7 +219,7 @@ public abstract partial class BaseAI
|
|||
{
|
||||
from.SendLocalizedMessage(1049691);
|
||||
// That person is already a friend.
|
||||
Mobile.ControlOrder = OrderType.None;
|
||||
ResumePersistentOrder();
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -270,7 +265,7 @@ public abstract partial class BaseAI
|
|||
{
|
||||
from.SendLocalizedMessage(1070953);
|
||||
// That person is not a friend.
|
||||
Mobile.ControlOrder = OrderType.None;
|
||||
ResumePersistentOrder();
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -296,8 +291,6 @@ public abstract partial class BaseAI
|
|||
return true;
|
||||
}
|
||||
|
||||
_lastPetOrder = OrderType.Guard;
|
||||
|
||||
FindCombatant();
|
||||
|
||||
if (IsValidCombatant(Mobile.Combatant))
|
||||
|
|
@ -364,7 +357,7 @@ public abstract partial class BaseAI
|
|||
DebugSay("Target is either dead, hidden, or out of range.");
|
||||
|
||||
Mobile.ControlTarget = Mobile.ControlMaster;
|
||||
Mobile.ControlOrder = OrderType.None;
|
||||
ResumePersistentOrder();
|
||||
|
||||
if (Mobile.FightMode is FightMode.Closest or FightMode.Aggressor)
|
||||
{
|
||||
|
|
@ -442,6 +435,9 @@ public abstract partial class BaseAI
|
|||
}
|
||||
else
|
||||
{
|
||||
// No spawner to return to: anchor where it stands so it idle-wanders here
|
||||
// instead of pathing toward a stale (e.g. former stay) anchor.
|
||||
Mobile.Home = Mobile.Location;
|
||||
Action = ActionType.Wander;
|
||||
}
|
||||
|
||||
|
|
@ -473,31 +469,20 @@ public abstract partial class BaseAI
|
|||
this.DebugSayFormatted($"I have been ordered to stay by {Mobile.ControlMaster?.Name ?? "Unknown"}.");
|
||||
}
|
||||
|
||||
_lastPetOrder = OrderType.Stay;
|
||||
|
||||
WalkRandomInHome(3, 2, 1);
|
||||
return true;
|
||||
}
|
||||
|
||||
public virtual bool DoOrderStop()
|
||||
{
|
||||
if (CheckHerding())
|
||||
// Hold position at the post (Home). Stand still when there; only walk back if displaced
|
||||
// (e.g. after chasing a kill). No idle shuffle.
|
||||
if (Mobile.Home != Point3D.Zero && Mobile.Location != Mobile.Home)
|
||||
{
|
||||
this.DebugSayFormatted($"I am being herded by {Mobile.ControlTarget?.Name ?? "Unknown"}.");
|
||||
}
|
||||
else
|
||||
{
|
||||
this.DebugSayFormatted($"I have been ordered to stop by {Mobile.ControlMaster?.Name ?? "Unknown"}.");
|
||||
}
|
||||
|
||||
if (Core.ML)
|
||||
{
|
||||
WalkRandomInHome(5, 2, 1);
|
||||
DoMove(Mobile.GetDirectionTo(Mobile.Home));
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// Stop is resolved into another order in OnCurrentOrderChanged and never rests as the
|
||||
// active order; this is a defensive no-op.
|
||||
public virtual bool DoOrderStop() => true;
|
||||
|
||||
public virtual bool DoOrderTransfer()
|
||||
{
|
||||
if (Mobile.IsDeadPet)
|
||||
|
|
@ -519,7 +504,7 @@ public abstract partial class BaseAI
|
|||
{
|
||||
from.SendLocalizedMessage(502040);
|
||||
// As a young player, you may not friend pets to older players.
|
||||
Mobile.ControlOrder = OrderType.None;
|
||||
ResumePersistentOrder();
|
||||
return true;
|
||||
}
|
||||
|
||||
|
|
@ -527,7 +512,7 @@ public abstract partial class BaseAI
|
|||
{
|
||||
from.SendLocalizedMessage(502041);
|
||||
// As an older player, you may not friend pets to young players.
|
||||
Mobile.ControlOrder = OrderType.None;
|
||||
ResumePersistentOrder();
|
||||
return true;
|
||||
}
|
||||
|
||||
|
|
@ -536,7 +521,7 @@ public abstract partial class BaseAI
|
|||
SendTransferRefusalMessages(from, to, 1043248, 1043249);
|
||||
// 1043248: The pet refuses to be transferred because it will not obey ~1_NAME~.~3_BLANK~
|
||||
// 1043249: The pet will not accept you as a master because it does not trust you.~3_BLANK~
|
||||
Mobile.ControlOrder = OrderType.None;
|
||||
ResumePersistentOrder();
|
||||
return true;
|
||||
}
|
||||
|
||||
|
|
@ -545,7 +530,7 @@ public abstract partial class BaseAI
|
|||
SendTransferRefusalMessages(from, to, 1043250, 1043251);
|
||||
// 1043250: The pet refuses to be transferred because it will not obey you sufficiently.~3_BLANK~
|
||||
// 1043251: The pet will not accept you as a master because it does not trust ~2_NAME~.~3_BLANK~
|
||||
Mobile.ControlOrder = OrderType.None;
|
||||
ResumePersistentOrder();
|
||||
return true;
|
||||
}
|
||||
|
||||
|
|
@ -554,7 +539,7 @@ public abstract partial class BaseAI
|
|||
{
|
||||
from.SendMessage("You can not transfer a pet while in combat.");
|
||||
to.SendMessage("You can not transfer a pet while in combat.");
|
||||
Mobile.ControlOrder = OrderType.None;
|
||||
ResumePersistentOrder();
|
||||
return true;
|
||||
}
|
||||
|
||||
|
|
@ -563,7 +548,7 @@ public abstract partial class BaseAI
|
|||
|
||||
if (fromState == null || toState == null)
|
||||
{
|
||||
Mobile.ControlOrder = OrderType.None;
|
||||
ResumePersistentOrder();
|
||||
return true;
|
||||
}
|
||||
|
||||
|
|
@ -573,7 +558,7 @@ public abstract partial class BaseAI
|
|||
// You cannot transfer a pet with a trade pending
|
||||
to.SendLocalizedMessage(1010507);
|
||||
// You cannot transfer a pet with a trade pending
|
||||
Mobile.ControlOrder = OrderType.None;
|
||||
ResumePersistentOrder();
|
||||
return true;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -38,6 +38,17 @@ public abstract partial class BaseAI
|
|||
}
|
||||
}
|
||||
|
||||
// Idle wander for controlled pets. Routes through the same CheckMove/CanMoveNow/CheckIdle
|
||||
// gate that non-controlled DoActionWander uses, so idling pets take the gentle 15-25s
|
||||
// CheckIdle rest periods instead of shuffling every AI tick.
|
||||
public void WalkRandomIdle()
|
||||
{
|
||||
if (CheckMove() && CanMoveNow(out _) && !Mobile.CheckIdle())
|
||||
{
|
||||
WalkRandomInHome(3, 2, 1);
|
||||
}
|
||||
}
|
||||
|
||||
public virtual void WalkRandomInHome(int chanceToNotMove, int chanceToDir, int steps)
|
||||
{
|
||||
if (Mobile.Deleted || Mobile.DisallowAllMoves)
|
||||
|
|
|
|||
|
|
@ -803,9 +803,10 @@ namespace Server.Mobiles
|
|||
get => m_ControlOrder;
|
||||
set
|
||||
{
|
||||
var previous = m_ControlOrder;
|
||||
m_ControlOrder = value;
|
||||
|
||||
AIObject?.OnCurrentOrderChanged();
|
||||
AIObject?.OnCurrentOrderChanged(previous);
|
||||
|
||||
InvalidateProperties();
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue