fix: pet obedience pacing, stale AI wake rescheduling, and Guard order persistence through combat (#2594)
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.
This commit is contained in:
parent
38c74a968b
commit
4420872b22
11 changed files with 712 additions and 73 deletions
|
|
@ -0,0 +1,96 @@
|
|||
using System.Collections.Generic;
|
||||
using Server;
|
||||
using Server.Mobiles;
|
||||
using Xunit;
|
||||
|
||||
namespace UOContent.Tests.Mobiles.AI;
|
||||
|
||||
// Guard-following may pathfind, so this shares the pathfinding collection.
|
||||
[Collection("Sequential Pathfinding Tests")]
|
||||
public class GuardFollowTests
|
||||
{
|
||||
[Fact]
|
||||
public void GuardFollow_StepsTowardMaster_AndRegistersMoveIntent()
|
||||
{
|
||||
var map = Map.Maps[1];
|
||||
Assert.NotNull(map);
|
||||
map.GetAverageZ(1500, 1600, out _, out var z, out _);
|
||||
|
||||
var master = new PlayerMobile(World.NewMobile);
|
||||
master.DefaultMobileInit();
|
||||
master.MoveToWorld(new Point3D(1494, 1600, (sbyte)z), map);
|
||||
|
||||
var pet = new PetTestStub();
|
||||
pet.MoveToWorld(new Point3D(1500, 1600, (sbyte)z), map); // 6 tiles east, open terrain
|
||||
pet.SetControlMaster(master);
|
||||
|
||||
var ai = pet.AIObject;
|
||||
ai.AITimer?.Stop(); // drive manually
|
||||
pet.ControlOrder = OrderType.Guard;
|
||||
ai.AITimer?.Stop(); // the order change may restart the timer
|
||||
|
||||
var start = pet.Location;
|
||||
ai.NextMove = 0;
|
||||
ai.Obey();
|
||||
|
||||
var moved = pet.Location != start;
|
||||
var hasIntent = ai.TryGetMoveWake(out _);
|
||||
var currentSpeed = pet.CurrentSpeed;
|
||||
var currentMoveSpeed = pet.CurrentMoveSpeed;
|
||||
|
||||
pet.Delete();
|
||||
master.Delete();
|
||||
|
||||
Assert.True(moved, "a guarding pet beyond guard range must step toward its master");
|
||||
// Without a move intent, guard-following only steps on the think grid.
|
||||
Assert.True(hasIntent, "guard-following must register a move intent");
|
||||
|
||||
// AOS return sprint on both clocks; the per-step speed flip must not undo it.
|
||||
Assert.Equal(0.1, currentSpeed);
|
||||
Assert.Equal(0.1, currentMoveSpeed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GuardReturn_PreAOS_RunsActive()
|
||||
{
|
||||
var previous = Core.Expansion;
|
||||
|
||||
try
|
||||
{
|
||||
Core.Expansion = Expansion.UOR;
|
||||
|
||||
var map = Map.Maps[1];
|
||||
Assert.NotNull(map);
|
||||
map.GetAverageZ(1500, 1600, out _, out var z, out _);
|
||||
|
||||
var master = new PlayerMobile(World.NewMobile);
|
||||
master.DefaultMobileInit();
|
||||
master.MoveToWorld(new Point3D(1494, 1600, (sbyte)z), map);
|
||||
|
||||
var pet = new PetTestStub();
|
||||
pet.MoveToWorld(new Point3D(1500, 1600, (sbyte)z), map);
|
||||
pet.SetControlMaster(master);
|
||||
|
||||
var ai = pet.AIObject;
|
||||
ai.AITimer?.Stop();
|
||||
pet.ControlOrder = OrderType.Guard;
|
||||
ai.AITimer?.Stop();
|
||||
pet.SetCurrentSpeedToPassive(); // a stale passive state must not persist
|
||||
|
||||
ai.NextMove = 0;
|
||||
ai.Obey();
|
||||
|
||||
var currentSpeed = pet.CurrentSpeed;
|
||||
|
||||
pet.Delete();
|
||||
master.Delete();
|
||||
|
||||
// No sprint pre-AOS: the return runs active.
|
||||
Assert.Equal(0.2, currentSpeed);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Core.Expansion = previous;
|
||||
}
|
||||
}
|
||||
}
|
||||
137
Projects/UOContent.Tests/Tests/Mobiles/AI/GuardOrderTests.cs
Normal file
137
Projects/UOContent.Tests/Tests/Mobiles/AI/GuardOrderTests.cs
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Server;
|
||||
using Server.Mobiles;
|
||||
using Xunit;
|
||||
|
||||
namespace UOContent.Tests.Mobiles.AI;
|
||||
|
||||
// A guarding pet fights without leaving the Guard order, retargets toward the master's
|
||||
// closest aggressor, and stands down when nothing threatens. Scene: the open
|
||||
// (1495..1500, 1600) Trammel segment; targets are adjacent so no pathfinding runs.
|
||||
[Collection("Sequential UOContent Tests")]
|
||||
public class GuardOrderTests : IDisposable
|
||||
{
|
||||
private readonly List<Mobile> _created = new();
|
||||
|
||||
private sealed class AggressorStub : Mobile
|
||||
{
|
||||
public AggressorStub() => Body = 0xC9;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
foreach (var m in _created)
|
||||
{
|
||||
m?.Delete();
|
||||
}
|
||||
|
||||
_created.Clear();
|
||||
}
|
||||
|
||||
private (PlayerMobile master, PetTestStub pet) SpawnGuardingPet(out Map map, out int z)
|
||||
{
|
||||
map = Map.Maps[1];
|
||||
Assert.NotNull(map);
|
||||
map.GetAverageZ(1500, 1600, out _, out z, out _);
|
||||
|
||||
var master = new PlayerMobile(World.NewMobile);
|
||||
master.DefaultMobileInit();
|
||||
master.MoveToWorld(new Point3D(1500, 1600, (sbyte)z), map);
|
||||
_created.Add(master);
|
||||
|
||||
var pet = new PetTestStub();
|
||||
pet.MoveToWorld(new Point3D(1499, 1600, (sbyte)z), map);
|
||||
pet.SetControlMaster(master);
|
||||
_created.Add(pet);
|
||||
|
||||
pet.AIObject.AITimer?.Stop(); // drive manually
|
||||
pet.ControlOrder = OrderType.Guard;
|
||||
pet.AIObject.AITimer?.Stop(); // the order change restarts the timer
|
||||
|
||||
return (master, pet);
|
||||
}
|
||||
|
||||
private AggressorStub SpawnAggressor(PetTestStub pet, Point3D loc, Mobile attacking)
|
||||
{
|
||||
var aggr = new AggressorStub();
|
||||
aggr.MoveToWorld(loc, pet.Map);
|
||||
_created.Add(aggr);
|
||||
|
||||
// Setup guard: the scene must stay LOS-clear and the combatant must not be vetoed.
|
||||
Assert.True(pet.InLOS(aggr), $"no LOS from pet to aggressor at {loc}");
|
||||
|
||||
if (attacking != null)
|
||||
{
|
||||
aggr.Combatant = attacking;
|
||||
Assert.Same(attacking, aggr.Combatant);
|
||||
}
|
||||
|
||||
return aggr;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GuardEngage_KeepsGuardOrder()
|
||||
{
|
||||
var (master, pet) = SpawnGuardingPet(out _, out var z);
|
||||
var aggr = SpawnAggressor(pet, new Point3D(1498, 1600, (sbyte)z), master);
|
||||
|
||||
pet.AIObject.Obey();
|
||||
|
||||
Assert.Same(aggr, pet.Combatant);
|
||||
Assert.Equal(OrderType.Guard, pet.ControlOrder);
|
||||
Assert.Equal(OrderType.Guard, pet.AIObject.PersistentOrder);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Guard_RetargetsToAggressorClosestToMaster()
|
||||
{
|
||||
var (master, pet) = SpawnGuardingPet(out _, out var z);
|
||||
var far = SpawnAggressor(pet, new Point3D(1495, 1600, (sbyte)z), master);
|
||||
var near = SpawnAggressor(pet, new Point3D(1498, 1600, (sbyte)z), master);
|
||||
|
||||
pet.Combatant = far; // already fighting the far aggressor
|
||||
|
||||
pet.AIObject.Obey();
|
||||
|
||||
Assert.Same(near, pet.Combatant); // defends the master, not the current fight
|
||||
Assert.Equal(OrderType.Guard, pet.ControlOrder);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExplicitAttack_ResumesGuard_WithoutChainingIntoAttack()
|
||||
{
|
||||
var (master, pet) = SpawnGuardingPet(out _, out var z);
|
||||
|
||||
// Explicit kill order on a target that then becomes invalid.
|
||||
var victim = SpawnAggressor(pet, new Point3D(1498, 1600, (sbyte)z), null);
|
||||
pet.ControlTarget = victim;
|
||||
pet.ControlOrder = OrderType.Attack;
|
||||
victim.Hidden = true;
|
||||
|
||||
// A second aggressor is still after the master; FightMode.Closest would chain it.
|
||||
var aggr2 = SpawnAggressor(pet, new Point3D(1497, 1600, (sbyte)z), master);
|
||||
|
||||
pet.AIObject.Obey(); // attack completes -> resume the persistent Guard
|
||||
|
||||
Assert.Equal(OrderType.Guard, pet.ControlOrder);
|
||||
|
||||
pet.AIObject.Obey(); // the guard scan engages the remaining aggressor in-order
|
||||
|
||||
Assert.Same(aggr2, pet.Combatant);
|
||||
Assert.Equal(OrderType.Guard, pet.ControlOrder);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PeacefulGuard_StandsDown()
|
||||
{
|
||||
var (_, pet) = SpawnGuardingPet(out _, out _);
|
||||
Assert.True(pet.Warmode); // the guard order opens in war stance
|
||||
|
||||
pet.AIObject.Obey(); // nothing to guard against
|
||||
|
||||
Assert.False(pet.Warmode);
|
||||
Assert.Null(pet.Combatant);
|
||||
Assert.Null(pet.FocusMob);
|
||||
}
|
||||
}
|
||||
222
Projects/UOContent.Tests/Tests/Mobiles/AI/PetPacingTests.cs
Normal file
222
Projects/UOContent.Tests/Tests/Mobiles/AI/PetPacingTests.cs
Normal file
|
|
@ -0,0 +1,222 @@
|
|||
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");
|
||||
}
|
||||
}
|
||||
|
|
@ -118,18 +118,17 @@ public abstract partial class BaseAI
|
|||
|
||||
if (TryMove(d))
|
||||
{
|
||||
// Writes the think clock only; hurt slowdown applies in ConsumeMoveBudget.
|
||||
if (Core.AOS && IsFollowingMaster())
|
||||
// Obeying pets are paced by their order handlers.
|
||||
if (!IsObeyingMoveOrder())
|
||||
{
|
||||
Mobile.CurrentSpeed = 0.1;
|
||||
}
|
||||
else if (Mobile.Warmode || Mobile.Combatant != null)
|
||||
{
|
||||
Mobile.SetCurrentSpeedToActive();
|
||||
}
|
||||
else
|
||||
{
|
||||
Mobile.SetCurrentSpeedToPassive();
|
||||
if (Mobile.Warmode || Mobile.Combatant != null)
|
||||
{
|
||||
Mobile.SetCurrentSpeedToActive();
|
||||
}
|
||||
else
|
||||
{
|
||||
Mobile.SetCurrentSpeedToPassive();
|
||||
}
|
||||
}
|
||||
|
||||
ConsumeMoveBudget();
|
||||
|
|
@ -541,8 +540,7 @@ public abstract partial class BaseAI
|
|||
{
|
||||
nextMove = NextMove;
|
||||
|
||||
return (_moveIntentTarget != null || _moveIntentPoint != null) &&
|
||||
Core.TickCount - _moveIntentExpire < 0;
|
||||
return (_moveIntentTarget != null || _moveIntentPoint != null) && Core.TickCount - _moveIntentExpire < 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -574,9 +572,9 @@ public abstract partial class BaseAI
|
|||
}
|
||||
|
||||
var distance = (int)Mobile.GetDistanceToSqrt(m);
|
||||
var distanceThreshold = Core.AOS && IsFollowingMaster() ? 1 : 5;
|
||||
|
||||
var shouldRun = run && distance > distanceThreshold;
|
||||
//TODO Derive the Running bit from CurrentMoveSpeed in DoMoveImpl and drop the run parameter
|
||||
var distanceThreshold = Core.AOS && IsFollowingMaster() ? 1 : 3;
|
||||
var shouldRun = distance > distanceThreshold;
|
||||
|
||||
if (Mobile.InRange(m, range))
|
||||
{
|
||||
|
|
@ -599,6 +597,13 @@ public abstract partial class BaseAI
|
|||
Mobile.ControlTarget == Mobile.ControlMaster &&
|
||||
Mobile.Combatant == null;
|
||||
|
||||
// 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, bool run, int range)
|
||||
{
|
||||
var distance = (int)Mobile.GetDistanceToSqrt(target);
|
||||
|
|
@ -649,14 +654,12 @@ public abstract partial class BaseAI
|
|||
{
|
||||
var iCurrDist = (int)Mobile.GetDistanceToSqrt(m);
|
||||
|
||||
var shouldRun = run && iCurrDist > 5;
|
||||
|
||||
if (iCurrDist >= iWantDistMin && iCurrDist <= iWantDistMax)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!MoveTowardsOrAwayFrom(m, shouldRun, iCurrDist, iWantDistMax))
|
||||
if (!MoveTowardsOrAwayFrom(m, run, iCurrDist, iWantDistMax))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
|
@ -667,18 +670,17 @@ public abstract partial class BaseAI
|
|||
return dist >= iWantDistMin && dist <= iWantDistMax;
|
||||
}
|
||||
|
||||
// run only sets the client animation; callers gate it on their own distance thresholds.
|
||||
private bool MoveTowardsOrAwayFrom(Mobile m, bool run, int iCurrDist, int iWantDistMax)
|
||||
{
|
||||
var shouldRun = run && iCurrDist > 5;
|
||||
|
||||
if (iCurrDist > iWantDistMax)
|
||||
{
|
||||
// Too far: approach via the centralized progress-based primitive.
|
||||
return ApproachTarget(m, shouldRun, iWantDistMax);
|
||||
return ApproachTarget(m, run, iWantDistMax);
|
||||
}
|
||||
|
||||
// Too close: back away. Retreat keeps the simple greedy behavior (out of scope).
|
||||
if (DoMove(m.GetDirectionTo(Mobile, shouldRun), true))
|
||||
if (DoMove(m.GetDirectionTo(Mobile, run), true))
|
||||
{
|
||||
Path = null;
|
||||
return true;
|
||||
|
|
|
|||
|
|
@ -26,6 +26,8 @@ public sealed class AITimer : Timer
|
|||
{
|
||||
private readonly BaseAI _owner;
|
||||
private long _nextThink;
|
||||
private long _nextWake; // when the pending wheel entry fires
|
||||
private bool _inTick;
|
||||
private int _detectHiddenMinDelay;
|
||||
private int _detectHiddenMaxDelay;
|
||||
|
||||
|
|
@ -40,8 +42,30 @@ public sealed class AITimer : Timer
|
|||
public void Activate()
|
||||
{
|
||||
_nextThink = Core.TickCount;
|
||||
Interval = TimeSpan.FromSeconds(_owner.Mobile.CurrentSpeed);
|
||||
|
||||
if (Running)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Start(); // keeps the stagger Delay
|
||||
_nextWake = Core.TickCount + (long)Delay.TotalMilliseconds;
|
||||
}
|
||||
|
||||
// Think now. A think grants no action: steps, swings, casts, and abilities keep their own gates.
|
||||
public void Prod()
|
||||
{
|
||||
_nextThink = Core.TickCount;
|
||||
|
||||
if (Running)
|
||||
{
|
||||
Reschedule();
|
||||
return;
|
||||
}
|
||||
|
||||
Delay = TimeSpan.Zero;
|
||||
Start();
|
||||
_nextWake = Core.TickCount + (long)Delay.TotalMilliseconds;
|
||||
}
|
||||
|
||||
// A speed-up must not wait out a stale, longer think deadline.
|
||||
|
|
@ -52,12 +76,53 @@ public sealed class AITimer : Timer
|
|||
if (candidate - _nextThink < 0)
|
||||
{
|
||||
_nextThink = candidate;
|
||||
Reschedule();
|
||||
}
|
||||
}
|
||||
|
||||
// Moves the pending wake earlier. Interval is only read after the next fire,
|
||||
// so this needs Stop, Delay = remaining, Start.
|
||||
private void Reschedule()
|
||||
{
|
||||
if (_inTick || !Running)
|
||||
{
|
||||
return; // ScheduleNext handles it at tick end
|
||||
}
|
||||
|
||||
Interval = TimeSpan.FromSeconds(_owner.Mobile.CurrentSpeed);
|
||||
var now = Core.TickCount;
|
||||
var deadline = _nextThink;
|
||||
|
||||
if (_owner.TryGetMoveWake(out var nextMove) && nextMove - now > 0 && nextMove - deadline < 0)
|
||||
{
|
||||
deadline = nextMove;
|
||||
}
|
||||
|
||||
if (deadline - _nextWake >= 0)
|
||||
{
|
||||
return; // pending wake is already early enough
|
||||
}
|
||||
|
||||
Stop();
|
||||
Delay = TimeSpan.FromMilliseconds(Math.Max(0, deadline - now));
|
||||
Start();
|
||||
_nextWake = now + (long)Delay.TotalMilliseconds;
|
||||
}
|
||||
|
||||
protected override void OnTick()
|
||||
{
|
||||
_inTick = true;
|
||||
|
||||
try
|
||||
{
|
||||
OnTickCore();
|
||||
}
|
||||
finally
|
||||
{
|
||||
_inTick = false;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnTickCore()
|
||||
{
|
||||
if (ShouldStop())
|
||||
{
|
||||
|
|
@ -111,6 +176,7 @@ public sealed class AITimer : Timer
|
|||
|
||||
// The wheel rounds up to its 8ms resolution; a non-positive delay becomes one turn.
|
||||
Interval = TimeSpan.FromMilliseconds(delay);
|
||||
_nextWake = now + (long)Interval.TotalMilliseconds;
|
||||
}
|
||||
|
||||
private bool ShouldStop()
|
||||
|
|
|
|||
|
|
@ -64,7 +64,7 @@ public abstract partial class BaseAI
|
|||
|
||||
if (!m.PlayerRangeSensitive || !World.Loading && m.Map != null && m.Map != Map.Internal && m.Map.GetSector(m.Location).Active)
|
||||
{
|
||||
AITimer.Start();
|
||||
AITimer.Activate();
|
||||
}
|
||||
|
||||
if (Action != ActionType.Wander)
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ public abstract partial class BaseAI
|
|||
return;
|
||||
}
|
||||
|
||||
Activate();
|
||||
AITimer.Prod();
|
||||
|
||||
switch (Mobile.ControlOrder)
|
||||
{
|
||||
|
|
@ -36,6 +36,10 @@ public abstract partial class BaseAI
|
|||
break;
|
||||
}
|
||||
case OrderType.Come:
|
||||
{
|
||||
Mobile.SetCurrentSpeedToActive();
|
||||
break;
|
||||
}
|
||||
case OrderType.Drop:
|
||||
case OrderType.Friend:
|
||||
case OrderType.Unfriend:
|
||||
|
|
@ -135,6 +139,7 @@ public abstract partial class BaseAI
|
|||
Mobile.FocusMob = null;
|
||||
Mobile.Warmode = false;
|
||||
Mobile.Combatant = null;
|
||||
Mobile.SetCurrentSpeedToPassive();
|
||||
}
|
||||
|
||||
private void HandleTransferOrder()
|
||||
|
|
@ -148,6 +153,7 @@ public abstract partial class BaseAI
|
|||
Mobile.FocusMob = null;
|
||||
Mobile.Warmode = false;
|
||||
Mobile.Combatant = null;
|
||||
Mobile.SetCurrentSpeedToPassive();
|
||||
Mobile.PlaySound(Mobile.GetIdleSound());
|
||||
_commandIssuer = null;
|
||||
}
|
||||
|
|
@ -162,9 +168,16 @@ public abstract partial class BaseAI
|
|||
_commandIssuer?.RevealingAction();
|
||||
Mobile.FocusMob = null;
|
||||
Mobile.Warmode = true;
|
||||
Mobile.PlaySound(Mobile.GetAttackSound());
|
||||
Mobile.ControlMaster?.SendLocalizedMessage(1049671, Mobile.Name);
|
||||
// ~1_NAME~ is now guarding you.
|
||||
Mobile.SetCurrentSpeedToActive();
|
||||
|
||||
// Resuming the persistent order must not replay the flourish.
|
||||
if (!_resolvingOrder)
|
||||
{
|
||||
Mobile.PlaySound(Mobile.GetAttackSound());
|
||||
Mobile.ControlMaster?.SendLocalizedMessage(1049671, Mobile.Name);
|
||||
// ~1_NAME~ is now guarding you.
|
||||
}
|
||||
|
||||
_commandIssuer = null;
|
||||
}
|
||||
|
||||
|
|
@ -191,6 +204,7 @@ public abstract partial class BaseAI
|
|||
}
|
||||
|
||||
Mobile.Warmode = true;
|
||||
Mobile.SetCurrentSpeedToActive();
|
||||
Mobile.PlaySound(Mobile.GetAttackSound());
|
||||
_commandIssuer = null;
|
||||
}
|
||||
|
|
@ -206,6 +220,7 @@ public abstract partial class BaseAI
|
|||
Mobile.FocusMob = null;
|
||||
Mobile.Warmode = false;
|
||||
Mobile.Combatant = null;
|
||||
Mobile.SetCurrentSpeedToActive();
|
||||
Mobile.PlaySound(Mobile.GetIdleSound());
|
||||
_commandIssuer = null;
|
||||
}
|
||||
|
|
@ -221,6 +236,7 @@ public abstract partial class BaseAI
|
|||
Mobile.FocusMob = null;
|
||||
Mobile.Warmode = false;
|
||||
Mobile.Combatant = null;
|
||||
Mobile.SetCurrentSpeedToPassive();
|
||||
Mobile.PlaySound(Mobile.GetIdleSound());
|
||||
_commandIssuer = null;
|
||||
// Home (the stay anchor) is owned by SetPersistentOrder, not this handler.
|
||||
|
|
|
|||
|
|
@ -128,6 +128,12 @@ public abstract partial class BaseAI
|
|||
|
||||
this.DebugSayFormatted($"I am ordered to follow {Mobile.ControlTarget?.Name}.");
|
||||
|
||||
// AOS: sprint after the master (bespoke 0.1 paces both clocks).
|
||||
if (Core.AOS && Mobile.ControlTarget == Mobile.ControlMaster && Mobile.Combatant == null)
|
||||
{
|
||||
Mobile.CurrentSpeed = 0.1;
|
||||
}
|
||||
|
||||
if (currentDistance > 1)
|
||||
{
|
||||
WalkMobileRange(Mobile.ControlTarget, 1, currentDistance > 2, 1, 2);
|
||||
|
|
@ -291,14 +297,13 @@ public abstract partial class BaseAI
|
|||
return true;
|
||||
}
|
||||
|
||||
FindCombatant();
|
||||
var combatant = FindGuardTarget();
|
||||
|
||||
if (IsValidCombatant(Mobile.Combatant))
|
||||
if (combatant != null)
|
||||
{
|
||||
var combatant = Mobile.Combatant;
|
||||
|
||||
this.DebugSayFormatted($"Attacking target: {combatant.Name}");
|
||||
|
||||
// Engage without leaving the Guard order so tags, recall handling, and retargeting persist.
|
||||
Mobile.Combatant = combatant;
|
||||
Mobile.FocusMob = combatant;
|
||||
Action = ActionType.Combat;
|
||||
|
|
@ -309,16 +314,30 @@ public abstract partial class BaseAI
|
|||
{
|
||||
this.DebugSayFormatted($"Guarding my master, {controlMaster.Name}.");
|
||||
|
||||
var guardLocation = controlMaster.Location;
|
||||
// Stand down; a stale Warmode would skew the return pace.
|
||||
Mobile.FocusMob = null;
|
||||
Mobile.Warmode = false;
|
||||
Mobile.Combatant = null;
|
||||
|
||||
var distance = (int)Mobile.GetDistanceToSqrt(guardLocation);
|
||||
var distance = (int)Mobile.GetDistanceToSqrt(controlMaster);
|
||||
|
||||
if (distance > 3)
|
||||
{
|
||||
DoMove(Mobile.GetDirectionTo(guardLocation));
|
||||
// AOS: sprint back (bespoke 0.1 paces both clocks); earlier eras run active.
|
||||
if (Core.AOS)
|
||||
{
|
||||
Mobile.CurrentSpeed = 0.1;
|
||||
}
|
||||
else
|
||||
{
|
||||
Mobile.SetCurrentSpeedToActive();
|
||||
}
|
||||
|
||||
WalkMobileRange(controlMaster, 1, true, 1, 3);
|
||||
}
|
||||
else
|
||||
{
|
||||
Mobile.SetCurrentSpeedToActive(); // alert at the master's side
|
||||
WalkRandom(3, 1, 1);
|
||||
}
|
||||
}
|
||||
|
|
@ -359,67 +378,83 @@ public abstract partial class BaseAI
|
|||
Mobile.ControlTarget = Mobile.ControlMaster;
|
||||
ResumePersistentOrder();
|
||||
|
||||
if (Mobile.FightMode is FightMode.Closest or FightMode.Aggressor)
|
||||
// A resumed Guard engages through its own scan; other fallbacks chain an explicit Attack.
|
||||
if (Mobile.ControlOrder == OrderType.Guard ||
|
||||
Mobile.FightMode is not (FightMode.Closest or FightMode.Aggressor))
|
||||
{
|
||||
FindCombatant();
|
||||
return;
|
||||
}
|
||||
|
||||
var next = FindGuardTarget();
|
||||
|
||||
if (next != null)
|
||||
{
|
||||
Mobile.ControlTarget = next;
|
||||
Mobile.ControlOrder = OrderType.Attack;
|
||||
Mobile.Combatant = next;
|
||||
|
||||
this.DebugSayFormatted($"{next.Name} is still hostile! Engaging...");
|
||||
|
||||
Think();
|
||||
}
|
||||
}
|
||||
|
||||
private void FindCombatant()
|
||||
/// <summary>
|
||||
/// Selects the aggressor closest to the master. The current combatant is kept
|
||||
/// unless a strictly closer one exists. Never mutates order state.
|
||||
/// </summary>
|
||||
private Mobile FindGuardTarget()
|
||||
{
|
||||
var controlMaster = Mobile.ControlMaster;
|
||||
var anchor = controlMaster ?? Mobile;
|
||||
|
||||
var current = Mobile.Combatant;
|
||||
var best = current != controlMaster && IsValidCombatant(current) ? current : null;
|
||||
var bestDist = best?.GetDistanceToSqrt(anchor) ?? double.MaxValue;
|
||||
|
||||
foreach (var aggr in Mobile.GetMobilesInRange(Mobile.RangePerception))
|
||||
{
|
||||
if (!Mobile.CanSee(aggr) || aggr.IsDeadBondedPet || !aggr.Alive)
|
||||
if (aggr == best || aggr == Mobile || aggr == controlMaster ||
|
||||
aggr.IsDeadBondedPet || !aggr.Alive ||
|
||||
aggr.Combatant != Mobile && (controlMaster == null || aggr.Combatant != controlMaster))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var isAttackingPet = aggr.Combatant == Mobile;
|
||||
var isAttackingMaster = controlMaster != null && aggr.Combatant == controlMaster;
|
||||
var dist = aggr.GetDistanceToSqrt(anchor);
|
||||
|
||||
if (isAttackingPet || isAttackingMaster)
|
||||
if (dist < bestDist && Mobile.CanSee(aggr) && Mobile.InLOS(aggr))
|
||||
{
|
||||
if (Mobile.InLOS(aggr))
|
||||
{
|
||||
Mobile.ControlTarget = aggr;
|
||||
Mobile.ControlOrder = OrderType.Attack;
|
||||
Mobile.Combatant = aggr;
|
||||
|
||||
var target = isAttackingMaster ? "master" : "me";
|
||||
this.DebugSayFormatted($"{aggr.Name} is attacking my {target}! Engaging...");
|
||||
|
||||
Think();
|
||||
return;
|
||||
}
|
||||
best = aggr;
|
||||
bestDist = dist;
|
||||
}
|
||||
}
|
||||
|
||||
if (controlMaster?.Aggressors != null)
|
||||
{
|
||||
for (var i = 0; i < controlMaster.Aggressors.Count; i++)
|
||||
{
|
||||
var aggressor = controlMaster.Aggressors[i].Attacker;
|
||||
var aggressors = controlMaster?.Aggressors;
|
||||
|
||||
if (aggressor?.Deleted != false || !aggressor.Alive || aggressor.IsDeadBondedPet)
|
||||
if (aggressors != null)
|
||||
{
|
||||
for (var i = 0; i < aggressors.Count; i++)
|
||||
{
|
||||
var aggressor = aggressors[i].Attacker;
|
||||
|
||||
if (aggressor == best || aggressor?.Deleted != false || !aggressor.Alive ||
|
||||
aggressor.IsDeadBondedPet || !Mobile.InRange(aggressor, Mobile.RangePerception))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (Mobile.InRange(aggressor, Mobile.RangePerception) && Mobile.CanSee(aggressor) && Mobile.InLOS(aggressor))
|
||||
var dist = aggressor.GetDistanceToSqrt(anchor);
|
||||
|
||||
if (dist < bestDist && Mobile.CanSee(aggressor) && Mobile.InLOS(aggressor))
|
||||
{
|
||||
Mobile.ControlTarget = aggressor;
|
||||
Mobile.ControlOrder = OrderType.Attack;
|
||||
Mobile.Combatant = aggressor;
|
||||
|
||||
this.DebugSayFormatted($"{aggressor.Name} recently attacked my master! Retaliating...");
|
||||
|
||||
Think();
|
||||
return;
|
||||
best = aggressor;
|
||||
bestDist = dist;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return best;
|
||||
}
|
||||
|
||||
public virtual bool DoOrderRelease()
|
||||
|
|
|
|||
|
|
@ -750,8 +750,9 @@ namespace Server.Mobiles
|
|||
|
||||
/// <summary>
|
||||
/// Resolved seconds per step: a verbatim active/passive <see cref="CurrentSpeed"/>
|
||||
/// maps to the matching movement value; a bespoke pace stays fused to both clocks.
|
||||
/// A herded creature is always driven at <see cref="HerdingMoveSpeed"/>.
|
||||
/// maps to the matching movement value; a bespoke pace (e.g. the pet-order 0.1 sprint)
|
||||
/// stays fused to both clocks. A herded creature is always driven at
|
||||
/// <see cref="HerdingMoveSpeed"/>.
|
||||
/// </summary>
|
||||
[CommandProperty(AccessLevel.GameMaster)]
|
||||
public double CurrentMoveSpeed
|
||||
|
|
@ -845,6 +846,8 @@ namespace Server.Mobiles
|
|||
[CommandProperty(AccessLevel.GameMaster)]
|
||||
public Point3D ControlDest { get; set; }
|
||||
|
||||
// Fires on every assignment, not only changes: a reissued order is a command
|
||||
// (retarget, break off combat, re-anchor Home). Handlers receive the previous order.
|
||||
[CommandProperty(AccessLevel.GameMaster)]
|
||||
public OrderType ControlOrder
|
||||
{
|
||||
|
|
|
|||
|
|
@ -29,6 +29,13 @@ description: >
|
|||
overridden). Prefer `npc-speeds.json` buckets (`SpeedClass`); `SetSpeed()` sets think
|
||||
AND clears move overrides, `SetMoveSpeed()` sets move only -- see
|
||||
`dev-docs/content-patterns.md` § Creature Speeds
|
||||
8. **`OnThink` overrides must be excess-call tolerant** -- it fires more often than the
|
||||
think cadence (player commands prod it; speed-ups reschedule it). Gate consequential
|
||||
work on a tick-count deadline (subtraction form) or make it idempotent; bare per-call
|
||||
random rolls are cosmetics-only. `MonsterAbility` is under the same contract: the
|
||||
trigger cooldown is the rate limit, `ChanceToTrigger` is per-sample jitter, and a
|
||||
zero-cooldown `Think`/`CombatAction` ability triggers every sampled think -- see
|
||||
`dev-docs/content-patterns.md` § OnThink: the excess-call contract
|
||||
|
||||
## New Item Template
|
||||
|
||||
|
|
|
|||
|
|
@ -283,6 +283,61 @@ ClearMoveSpeed(); // back to inheriting the think clock
|
|||
All four are `[props`-tunable per instance (move values: set `0` to re-inherit); per-instance
|
||||
move overrides serialize. Being badly hurt slows steps, never decisions (RunUO parity).
|
||||
|
||||
### OnThink: the excess-call contract
|
||||
|
||||
`OnThink()` is a scheduler pass, not an action. The AI timer calls it *at least* at the
|
||||
think cadence (`CurrentSpeed`), but it can and does fire more often: a player command
|
||||
wakes the AI immediately (`AITimer.Prod()`), a speed-up reschedules the pending wake, and
|
||||
players run command macros that drive extra thinks deliberately (order spam is spam-safe
|
||||
by design — reaction, never action). RunUO had the same property (its timer restarted
|
||||
with a random delay on every speed change), so this has never been a fixed-rate callback.
|
||||
|
||||
**Every `OnThink` override must be excess-call tolerant.** An extra call must never grant
|
||||
an extra action:
|
||||
|
||||
- Gate consequential work on its own deadline field, compared in subtraction form
|
||||
(`Core.TickCount - _nextX >= 0` — see `tick-counts.md`), or make it idempotent.
|
||||
- Never pace a consequential action with a bare per-call `Utility.RandomDouble()` roll —
|
||||
its frequency then scales with think rate, which players can influence. Per-call rolls
|
||||
are acceptable only for pure cosmetics (idle animations, flavor sounds).
|
||||
- The engine already gates the expensive things: steps (the `NextMove` budget), weapon
|
||||
swings, spell casts, detect-hidden, and the base `BaseCreature.OnThink` actions (heal,
|
||||
rummage, aura) all carry their own clocks. Follow that pattern.
|
||||
|
||||
```csharp
|
||||
private long _nextSpecial;
|
||||
|
||||
public override void OnThink()
|
||||
{
|
||||
base.OnThink();
|
||||
|
||||
if (Core.TickCount - _nextSpecial >= 0)
|
||||
{
|
||||
DoSpecial();
|
||||
_nextSpecial = Core.TickCount + 5000; // the real rate limit lives here
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### MonsterAbility: same contract
|
||||
|
||||
`MonsterAbility.CanTrigger` is sampled once per think for `Think`- and
|
||||
`CombatAction`-triggered abilities, so abilities live under the same rule:
|
||||
|
||||
- **`MinTriggerCooldown`/`MaxTriggerCooldown` is the real rate limit** — the floor holds
|
||||
no matter how often thinks fire. Always give a triggered ability a real cooldown.
|
||||
- **`ChanceToTrigger` is a per-sample roll**: above the cooldown floor, the expected
|
||||
trigger delay shrinks as think rate rises. Treat the chance as flavor jitter, never as
|
||||
the rate limiter, and keep cooldowns long relative to the think interval so the jitter
|
||||
stays negligible (fire breath — chance 0.5, cooldown 30–45s — varies under 1% between
|
||||
natural and spammed think rates).
|
||||
- A **zero-cooldown ability records no cooldown at all** and triggers on every sampled
|
||||
think that passes its chance — only ever correct for passive alteration hooks, never
|
||||
for `Think`/`CombatAction` triggers.
|
||||
- An ability that breaks pet orders (fear-style effects) must own its duration explicitly
|
||||
(a hold state, or a "refuses orders until" deadline checked in the order handlers) —
|
||||
pets react to re-issued commands immediately, so think latency is not a hold.
|
||||
|
||||
---
|
||||
|
||||
## New Spell
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue