Closes #2593. Closes #2595. Two related pet-AI fixes: the post-#2591 pacing/wake regression (#2593), and the guard order silently converting to Attack during combat (#2595). Root-cause analyses are in the issues. ## #2593 — pets follow slowly; stale AITimer wakes **Why pets slowed:** - The per-step budget grew from **half a think interval** (`CurrentSpeed * 500`) to the full RunUO-parity move table (`CurrentMoveSpeed * 1000`). Medium-bucket pets (Horse, Dog, most tamables): passiveMove **1.05s/step**. - Pet order speed depended on stale `Warmode`: `HandleGuardOrder` set it once, but `OnCombatantChange` clears it whenever the combatant drops, so obedience ran active or passive **by combat history** — usually passive. Net: Guard/Come at ~1.05s/step (~2.1x slower than pre-#2591), vs a player running at 0.1–0.2s/step. - The AITimer never rescheduled its pending wheel entry: the wheel reads `Interval` only after the next fire, so a speed-up or a fresh order (`Activate()` no-ops while running) waited out the stale wake — up to a full passive think, stacked on the residual move budget on Guard → Follow. **What changed:** - **Order handlers own obedience speed** (RunUO `OnCurrentOrderChanged`/`DoOrder*` parity, re-derived continuously): issuing a movement order (Come/Follow/Guard/Attack) sets the **active** think clock, resting orders (Stay/None/Transfer) set passive, and the guard/follow peaceful branches write **RunUO's AOS `CurrentSpeed = 0.1` sprint** — RunUO's guard else-branch had the identical write as follow. The bespoke 0.1 fuses to both clocks through #2591's existing classification, so `CurrentMoveSpeed` stays **pure herding + classification** with no obedience special case, and `DoMoveImpl`'s per-step flip skips obeying pets (their handler owns the pace) and loses its old follow-only 0.1 write. Combat still re-derives organically via warmode/combatant. - **`AITimer`**: tracks the pending wake and reschedules (`Stop`, `Delay` = remaining, `Start`) when a speed-up or fresh order moves the earliest deadline up; changes inside a tick still flow through `ScheduleNext`. New `Prod()` wakes the AI immediately on player commands — including from a stopped timer, so stable claims no longer wait out the random construction stagger. Sector/spawn wakes keep the stagger. Spam-safe: a prodded think grants reaction, never action — steps/swings/casts/abilities are gated by their own budgets and timers. The residual move budget is deliberately **not** cleared on order change — that would let order-spam macros grant free steps. Deadline changes reschedule the timer; rate changes take effect at the next deadline computation. ## #2595 — Guard order converts to Attack during combat **Why:** `FindCombatant()` set `ControlOrder = OrderType.Attack` when engaging, so a guarding pet left the Guard order for the whole fight: OPL tags wiped (pet `1080078` + master `501129`), no retargeting (`DoOrderAttack` locks its target), `TeleportPets` left the pet behind on recall/gate, and every engage→kill→resume cycle replayed the guard flourish. **What changed:** - **`FindGuardTarget()`** (was `FindCombatant`): a pure selector — prefers the aggressor **closest to the master** (RunUO guard parity, dynamic retargeting to protect the owner), keeps the current combatant unless a strictly closer one exists, and never mutates order state. `DoOrderGuard` engages through it while **staying in Guard** the whole fight. - **Persistent-order semantics** (the ModernUO improvement over RunUO): an explicit `all attack` completes → `ResumePersistentOrder()` returns to Guard → the guard scan engages remaining threats in-order. The Attack-chaining fallback (`FightMode.Closest/Aggressor`) now applies only to non-guard persistent orders. Resuming Guard no longer replays the sound/"is now guarding you" message. - **Peaceful guard stands down deterministically** (`Warmode`/`Combatant`/`FocusMob` cleared) and returns to the master at the RunUO sprint (see above); at the master's side it stays organically active. - **`WalkMobileRange` honors the caller's run flag** (the internal hardcoded `dist > 5` gate silently overrode it). Run is animation-only server-side; the only callers passing anything but `false` — follow, guard, clone — gate on their own thresholds. ## Resulting behavior (Medium-bucket pet) | Scenario | Broken | This PR | |---|---|---| | Guard trailing master (AOS) | ~1.05s/step, think-grid quantized | 0.1s/step sprint (RunUO parity), smooth move wakes | | Guard during combat | order flips to Attack; tags lost; no retarget; left behind on recall | stays Guard; retargets to master's closest aggressor; teleports with master | | `all attack` while guarding | resume spams guard flourish per kill; chains into Attack | resumes Guard silently; guard scan takes over | | Come / friend-follow | 1.05s/step | activeMove 0.45s/step (≈ pre-#2591 feel) | | Guard → Follow reaction | up to ~1.5s dead time | think within one wheel turn | | Follow master (AOS sprint) | 0.1s/step | 0.1s/step (unchanged) | | Wild creature chase | RunUO-parity move table | unchanged | Also documents two contracts this work leaned on: the `ControlOrder` setter deliberately fires on every assignment (a reissued order is a command — retarget/break-off/re-anchor), and `OnThink`/`MonsterAbility` must be excess-call tolerant (`dev-docs/content-patterns.md` § OnThink: the excess-call contract). ## Testing - Full suite passes (1570: 837 Server + 733 UOContent). - `PetPacingTests`: order-issue think-clock parity, follow-master sprint via Obey, guard organically active at the master's side, combat-chase and herding boundaries, plus two deterministic timer-wheel tests (8ms-lockstep slicing) proving a fresh order and a mid-wait speed-up wake the AI promptly. - `GuardOrderTests`: engage keeps the Guard order; retargets to the aggressor closest to the master; explicit attack resumes Guard without chaining into Attack; peaceful guard stands down. Setup self-validates LOS/terrain. - `GuardFollowTests`: guard-following registers a move intent, steps toward the master, sprints at 0.1 under AOS (per-step flip must not undo it), and runs active pre-AOS. - All behavioral tests were written first and failed for the documented reasons.
222 lines
6.3 KiB
C#
222 lines
6.3 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using Server;
|
|
using Server.Mobiles;
|
|
using Xunit;
|
|
|
|
namespace UOContent.Tests.Mobiles.AI;
|
|
|
|
// Pet order handlers own the speed clocks; combat chases and herding keep their own pacing.
|
|
[Collection("Sequential UOContent Tests")]
|
|
public class PetPacingTests : IDisposable
|
|
{
|
|
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();
|
|
}
|
|
|
|
// Movement orders run active, resting orders run passive; the move clock follows.
|
|
[Fact]
|
|
public void OrderIssue_SetsThinkClock()
|
|
{
|
|
var (master, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1001, 1000, 0));
|
|
pet.SetMoveSpeed(0.3, 0.9);
|
|
pet.SetCurrentSpeedToPassive();
|
|
|
|
pet.ControlOrder = OrderType.Come;
|
|
Assert.Equal(0.2, pet.CurrentSpeed);
|
|
Assert.Equal(0.3, pet.CurrentMoveSpeed); // verbatim active -> activeMove
|
|
|
|
pet.ControlOrder = OrderType.Stay;
|
|
Assert.Equal(0.4, pet.CurrentSpeed);
|
|
Assert.Equal(0.9, pet.CurrentMoveSpeed);
|
|
|
|
pet.ControlTarget = master;
|
|
pet.ControlOrder = OrderType.Follow;
|
|
Assert.Equal(0.2, pet.CurrentSpeed);
|
|
|
|
pet.ControlOrder = OrderType.Guard;
|
|
Assert.Equal(0.2, pet.CurrentSpeed);
|
|
}
|
|
|
|
// AOS: following the master sprints at a bespoke 0.1 on both clocks.
|
|
[Fact]
|
|
public void FollowMaster_ObeySprints()
|
|
{
|
|
var (master, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1001, 1000, 0));
|
|
pet.SetMoveSpeed(0.3, 0.9);
|
|
pet.AIObject.AITimer?.Stop();
|
|
|
|
pet.ControlTarget = master;
|
|
pet.ControlOrder = OrderType.Follow; // fixture era is EJ
|
|
pet.AIObject.Obey();
|
|
|
|
Assert.Equal(0.1, pet.CurrentSpeed);
|
|
Assert.Equal(0.1, pet.CurrentMoveSpeed);
|
|
}
|
|
|
|
// At the master's side a guarding pet stays active: no stale-warmode passive, no sprint.
|
|
[Fact]
|
|
public void GuardAtMastersSide_IsActive()
|
|
{
|
|
var (_, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1001, 1000, 0));
|
|
pet.SetMoveSpeed(0.3, 0.9);
|
|
pet.AIObject.AITimer?.Stop();
|
|
pet.SetCurrentSpeedToPassive();
|
|
|
|
pet.ControlOrder = OrderType.Guard;
|
|
pet.AIObject.Obey(); // nothing to guard against, master adjacent
|
|
|
|
Assert.Equal(0.2, pet.CurrentSpeed);
|
|
Assert.Equal(0.3, pet.CurrentMoveSpeed);
|
|
}
|
|
|
|
// A pet chasing a combatant keeps the move table.
|
|
[Fact]
|
|
public void CombatChasingPet_KeepsMoveTable()
|
|
{
|
|
var (_, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1001, 1000, 0));
|
|
var target = new PetTestStub();
|
|
target.MoveToWorld(new Point3D(1003, 1000, 0), Map.Felucca);
|
|
_created.Add(target);
|
|
|
|
pet.SetMoveSpeed(0.3, 0.9);
|
|
pet.ControlOrder = OrderType.Guard;
|
|
pet.Combatant = target;
|
|
pet.SetCurrentSpeedToActive();
|
|
|
|
Assert.Equal(0.3, pet.CurrentMoveSpeed);
|
|
}
|
|
|
|
// Herding overrides order pacing.
|
|
[Fact]
|
|
public void HerdedObeyingPet_KeepsHerdingPace()
|
|
{
|
|
var (_, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1001, 1000, 0));
|
|
pet.SetMoveSpeed(0.45, 0.9);
|
|
pet.SetCurrentSpeedToPassive();
|
|
|
|
pet.TargetLocation = new Point2D(1010, 1010);
|
|
|
|
Assert.Equal(0.3, pet.CurrentMoveSpeed); // fixed herding pace
|
|
}
|
|
|
|
private sealed class ThinkProbe : PetTestStub
|
|
{
|
|
public int Thinks;
|
|
|
|
public override void OnThink()
|
|
{
|
|
Thinks++;
|
|
base.OnThink();
|
|
}
|
|
}
|
|
|
|
private (PlayerMobile master, ThinkProbe pet) SpawnProbe()
|
|
{
|
|
var master = new PlayerMobile(World.NewMobile);
|
|
master.DefaultMobileInit();
|
|
master.MoveToWorld(new Point3D(1000, 1000, 0), Map.Felucca);
|
|
_created.Add(master);
|
|
|
|
var pet = new ThinkProbe();
|
|
pet.MoveToWorld(new Point3D(1001, 1000, 0), Map.Felucca);
|
|
pet.SetControlMaster(master);
|
|
_created.Add(pet);
|
|
|
|
return (master, pet);
|
|
}
|
|
|
|
// Advances time in 8ms lockstep so the wheel and Core.TickCount stay in sync.
|
|
private static void RunFor(long ms)
|
|
{
|
|
var deadline = Core._tickCount + ms;
|
|
|
|
while (Core._tickCount < deadline)
|
|
{
|
|
Core._tickCount += 8;
|
|
Timer.Slice(Core._tickCount);
|
|
}
|
|
}
|
|
|
|
private static bool RunUntil(Func<bool> condition, long maxMs)
|
|
{
|
|
var deadline = Core._tickCount + maxMs;
|
|
|
|
while (Core._tickCount < deadline)
|
|
{
|
|
if (condition())
|
|
{
|
|
return true;
|
|
}
|
|
|
|
Core._tickCount += 8;
|
|
Timer.Slice(Core._tickCount);
|
|
}
|
|
|
|
return condition();
|
|
}
|
|
|
|
// Runs past the spawn stagger; returns right after a think with the next 0.4s away.
|
|
private ThinkProbe SettledProbe(out PlayerMobile master)
|
|
{
|
|
Core._tickCount = 0;
|
|
Timer.Init(0);
|
|
|
|
var (m, pet) = SpawnProbe();
|
|
master = m;
|
|
pet.ForceIdle = true; // no wandering; pure cadence
|
|
pet.ControlOrder = OrderType.Stay;
|
|
|
|
var settled = RunUntil(() => pet.Thinks >= 2, 8000);
|
|
Assert.True(settled, "the AI must reach a steady think cadence");
|
|
|
|
return pet;
|
|
}
|
|
|
|
[Fact]
|
|
public void OrderChange_WakesStaleThinkTimer()
|
|
{
|
|
var pet = SettledProbe(out var master);
|
|
var thinksBefore = pet.Thinks;
|
|
|
|
RunFor(200); // mid-wait, next think ~200ms out
|
|
Assert.Equal(thinksBefore, pet.Thinks);
|
|
|
|
pet.ControlTarget = master;
|
|
pet.ControlOrder = OrderType.Follow;
|
|
|
|
RunFor(80);
|
|
Assert.True(pet.Thinks > thinksBefore, "a fresh order must wake the AI promptly");
|
|
}
|
|
|
|
[Fact]
|
|
public void SpeedUp_ReschedulesPendingWake()
|
|
{
|
|
var pet = SettledProbe(out _);
|
|
var thinksBefore = pet.Thinks;
|
|
|
|
RunFor(200); // mid-wait, next think ~200ms out
|
|
Assert.Equal(thinksBefore, pet.Thinks);
|
|
|
|
pet.CurrentSpeed = 0.1;
|
|
|
|
RunFor(120);
|
|
Assert.True(pet.Thinks > thinksBefore, "a speed-up must reschedule the pending wake");
|
|
}
|
|
}
|