ModernUO/Projects/UOContent/Mobiles/AI/BaseAI/AITimer.cs
Kamron Batman 1a17146e64
docs: note the spam-safety invariant on AITimer.Prod
A prodded think grants reaction, not action: steps are gated by the NextMove
budget, swings/casts/abilities/detect-hidden by their own timers, and each
command yields at most one think with no compounding. Verified against RunUO,
which granted zero extra thinks (change-guarded CurrentSpeed -> random 0-1s
timer restart) but also no action-rate advantage, for the same reason.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 09:12:21 -07:00

239 lines
7 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; the wheel cannot tell us
private bool _inTick;
private int _detectHiddenMinDelay;
private int _detectHiddenMaxDelay;
public AITimer(BaseAI owner) : base(TimeSpan.FromMilliseconds(Utility.Random(3000)),
TimeSpan.FromSeconds(owner.Mobile.CurrentSpeed))
{
_owner = owner;
_owner._nextDetectHidden = Core.TickCount;
_nextThink = Core.TickCount;
}
public void Activate()
{
_nextThink = Core.TickCount;
if (Running)
{
return;
}
Start(); // keeps the current Delay: the construction stagger for spawn/sector wakes
_nextWake = Core.TickCount + (long)Delay.TotalMilliseconds;
}
// A fresh command must not wait out the previous cadence: think now. Pets are exempt
// from sector deactivation, so dropping the stagger Delay here cannot bunch sector wakes.
// Spam-safe: at most one think per command and no compounding, and a think grants no
// action — steps (NextMove budget), swings, casts, abilities and detect-hidden are all
// gated by their own budgets/timers that this never touches.
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();
}
}
// Restarts the timer when the new earliest deadline lands before the pending wake.
// The wheel reads Interval only after the next fire, so moving a pending wake earlier
// requires Stop, Delay = remaining, Start.
private void Reschedule()
{
if (_inTick || !Running)
{
return; // ScheduleNext reads the updated deadlines 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; // the pending wake is already at or before the new deadline
}
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).
_nextThink = Core.TickCount + (long)(_owner.Mobile.CurrentSpeed * 1000);
}
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 / _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);
}
}