## 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.
140 lines
4.3 KiB
C#
140 lines
4.3 KiB
C#
/*************************************************************************
|
|
* ModernUO *
|
|
* Copyright 2019-2026 - ModernUO Development Team *
|
|
* Email: hi@modernuo.com *
|
|
* File: WalkRandomLogic.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.Spawners;
|
|
|
|
namespace Server.Mobiles;
|
|
|
|
public abstract partial class BaseAI
|
|
{
|
|
public virtual void WalkRandom(int chanceToNotMove, int chanceToDir, int steps)
|
|
{
|
|
if (Mobile.Deleted || Mobile.DisallowAllMoves || chanceToNotMove <= 0)
|
|
{
|
|
return;
|
|
}
|
|
|
|
var maxSteps = Math.Min(steps, 3);
|
|
|
|
for (var i = 0; i < maxSteps; i++)
|
|
{
|
|
if (Utility.Random(1 + chanceToNotMove) == 0)
|
|
{
|
|
DoMove(GetRandomDirection(chanceToDir));
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
{
|
|
return;
|
|
}
|
|
|
|
if (Mobile.Home == Point3D.Zero)
|
|
{
|
|
WalkRandomNoHome(chanceToNotMove, chanceToDir, steps);
|
|
}
|
|
else
|
|
{
|
|
WalkRandomWithHome(chanceToNotMove, chanceToDir, steps);
|
|
}
|
|
}
|
|
|
|
private void WalkRandomNoHome(int chanceToNotMove, int chanceToDir, int steps)
|
|
{
|
|
if (Mobile.Spawner is RegionSpawner rs)
|
|
{
|
|
var region = rs.SpawnRegion;
|
|
|
|
if (Mobile.Region.AcceptsSpawnsFrom(region))
|
|
{
|
|
Mobile.WalkRegion = region;
|
|
|
|
WalkRandom(chanceToNotMove, chanceToDir, steps);
|
|
|
|
Mobile.WalkRegion = null;
|
|
}
|
|
else if (region.GoLocation != Point3D.Zero && Utility.RandomBool())
|
|
{
|
|
DoMove(Mobile.GetDirectionTo(region.GoLocation));
|
|
}
|
|
else
|
|
{
|
|
WalkRandom(chanceToNotMove, chanceToDir, 1);
|
|
}
|
|
}
|
|
else
|
|
{
|
|
WalkRandom(chanceToNotMove, chanceToDir, steps);
|
|
}
|
|
}
|
|
|
|
private void WalkRandomWithHome(int chanceToNotMove, int chanceToDir, int steps)
|
|
{
|
|
if (Mobile.RangeHome == 0)
|
|
{
|
|
if (Mobile.Location != Mobile.Home)
|
|
{
|
|
DoMove(Mobile.GetDirectionTo(Mobile.Home));
|
|
}
|
|
|
|
return;
|
|
}
|
|
|
|
for (var i = 0; i < steps; i++)
|
|
{
|
|
var currDist = (int)Mobile.GetDistanceToSqrt(Mobile.Home);
|
|
|
|
if (currDist > Mobile.RangeHome)
|
|
{
|
|
DoMove(Mobile.GetDirectionTo(Mobile.Home));
|
|
}
|
|
else if (currDist < Mobile.RangeHome * 2 / 3 || Utility.Random(10) <= 5)
|
|
{
|
|
WalkRandom(chanceToNotMove, chanceToDir, 1);
|
|
}
|
|
else
|
|
{
|
|
DoMove(Mobile.GetDirectionTo(Mobile.Home));
|
|
}
|
|
}
|
|
}
|
|
|
|
private Direction GetRandomDirection(int chanceToDir)
|
|
{
|
|
var randomMove = Utility.Random(8 * (chanceToDir + 1));
|
|
|
|
if (randomMove < 8)
|
|
{
|
|
return (Direction)randomMove;
|
|
}
|
|
|
|
return Mobile.Direction;
|
|
}
|
|
}
|