Supersedes #2658 (rebased onto main; edits by maintainers were not enabled on the original branch). Credit to @MithrilHammer for both fixes and the regression tests; their three commits are kept as authored. Addresses two items from #2627: - The detect-hidden cooldown clamps its divisor to at least one. A creature whose effective intelligence reaches zero now uses the existing capped cooldown instead of throwing. - Pet friends are unique. `AddPetFriend` ignores an existing friend, so one removal revokes the membership. On top of that, `AddPetFriend` and `RemovePetFriend` now go through the generated `AddToFriends` / `RemoveFromFriends`, which mark the creature dirty. `Friends` goes back to null when the last friend is removed, and removing a non-friend no longer touches the list. Duplicates already stored in saves are not migrated. ## Tests `DetectHiddenTimerTests` (7 cases) drives the real AI timer and checks cooldown bounds and retry timing. `PetFriendMembershipTests` (3 cases) covers duplicate adds, removal, and re-adding without disturbing other friends. `UOContent.Tests`: 1158 passed, 2 skipped, 0 failed on Windows.
249 lines
7.4 KiB
C#
249 lines
7.4 KiB
C#
/*************************************************************************
|
|
* ModernUO *
|
|
* Copyright 2019-2026 - ModernUO Development Team *
|
|
* Email: hi@modernuo.com *
|
|
* File: AITimer.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;
|
|
|
|
namespace Server.Mobiles;
|
|
|
|
/// <summary>
|
|
/// Drives an AI on two clocks: decisions at <see cref="BaseCreature.CurrentSpeed"/>, plus
|
|
/// move-only wakes at <see cref="BaseAI.NextMove"/> while a pursuit is live. Each tick
|
|
/// schedules the earlier of the two deadlines.
|
|
/// </summary>
|
|
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;
|
|
|
|
// The initial delay is irrelevant: Activate is the only start path and sets its own.
|
|
public AITimer(BaseAI owner) : base(TimeSpan.Zero, TimeSpan.FromSeconds(owner.Mobile.CurrentSpeed))
|
|
{
|
|
_owner = owner;
|
|
_owner._nextDetectHidden = Core.TickCount;
|
|
_nextThink = Core.TickCount;
|
|
}
|
|
|
|
public void Activate()
|
|
{
|
|
_nextThink = Core.TickCount;
|
|
|
|
if (Running)
|
|
{
|
|
return;
|
|
}
|
|
|
|
// Short random spread: the creature responds within a think while a sector's
|
|
// worth of timers avoids a same-tick burst; the idle think jitter keeps the
|
|
// cohort apart from there.
|
|
Delay = TimeSpan.FromMilliseconds(Utility.Random(256));
|
|
Start();
|
|
_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.
|
|
public void OnSpeedChanged()
|
|
{
|
|
var candidate = Core.TickCount + (long)(_owner.Mobile.CurrentSpeed * 1000);
|
|
|
|
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
|
|
}
|
|
|
|
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())
|
|
{
|
|
Stop();
|
|
return;
|
|
}
|
|
|
|
if (Core.TickCount - _nextThink >= 0)
|
|
{
|
|
_owner.Mobile.OnThink();
|
|
|
|
if (ShouldStop())
|
|
{
|
|
Stop();
|
|
return;
|
|
}
|
|
|
|
HandleBardEffects();
|
|
|
|
if (_owner.Mobile.Controlled ? _owner.Obey() : _owner.Think())
|
|
{
|
|
HandleDetectHidden();
|
|
}
|
|
|
|
// Cadence from the post-decision speed (decisions may flip active/passive).
|
|
var period = (long)(_owner.Mobile.CurrentSpeed * 1000);
|
|
_nextThink = Core.TickCount + period;
|
|
|
|
// Idle cadence drifts: a zero-mean jitter random-walks think phases apart, so
|
|
// creatures spawned or woken together cannot stay in lock-step (a one-shot
|
|
// spread can collide and identical periods never separate). Engaged cadence
|
|
// stays exact — pursuit timing anchors to real step times.
|
|
if (_owner.Mobile.CurrentSpeed == _owner.Mobile.PassiveSpeed)
|
|
{
|
|
var jitter = (int)(period >> 3);
|
|
_nextThink += Utility.RandomMinMax(-jitter, jitter);
|
|
}
|
|
}
|
|
else
|
|
{
|
|
_owner.ContinueMove();
|
|
}
|
|
|
|
ScheduleNext();
|
|
}
|
|
|
|
private void ScheduleNext()
|
|
{
|
|
var now = Core.TickCount;
|
|
var delay = _nextThink - now;
|
|
|
|
if (_owner.TryGetMoveWake(out var nextMove))
|
|
{
|
|
var moveDelay = nextMove - now;
|
|
|
|
// Only a future budget is a wake — a blocked creature must not spin the timer.
|
|
if (moveDelay > 0 && moveDelay < delay)
|
|
{
|
|
delay = moveDelay;
|
|
}
|
|
}
|
|
|
|
// 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()
|
|
{
|
|
if (_owner.Mobile.Deleted)
|
|
{
|
|
return true;
|
|
}
|
|
|
|
if (_owner.Mobile.Map == null || _owner.Mobile.Map == Map.Internal || _owner.Mobile.PlayerRangeSensitive &&
|
|
!_owner.Mobile.Controlled && !_owner.Mobile.Map.GetSector(_owner.Mobile.Location).Active)
|
|
{
|
|
_owner.Deactivate();
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
private void HandleBardEffects()
|
|
{
|
|
if (_owner.Mobile.BardPacified)
|
|
{
|
|
_owner.DoBardPacified();
|
|
}
|
|
else if (_owner.Mobile.BardProvoked)
|
|
{
|
|
_owner.DoBardProvoked();
|
|
}
|
|
}
|
|
|
|
private void CacheDetectHiddenDelays()
|
|
{
|
|
var delay = Math.Min(30000 / Math.Max(1, _owner.Mobile.Int), 120);
|
|
_detectHiddenMinDelay = delay * 900; // 26s to 108s
|
|
_detectHiddenMaxDelay = delay * 1100; // 32s to 132s
|
|
}
|
|
|
|
private void HandleDetectHidden()
|
|
{
|
|
if (!_owner.CanDetectHidden || Core.TickCount - _owner._nextDetectHidden < 0)
|
|
{
|
|
return;
|
|
}
|
|
|
|
_owner.DetectHidden();
|
|
|
|
if (_detectHiddenMinDelay == 0 || _detectHiddenMaxDelay == 0)
|
|
{
|
|
CacheDetectHiddenDelays();
|
|
}
|
|
|
|
_owner._nextDetectHidden = Core.TickCount + Utility.RandomMinMax(_detectHiddenMinDelay, _detectHiddenMaxDelay);
|
|
}
|
|
}
|