/*************************************************************************
* 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 . *
************************************************************************/
using System;
namespace Server.Mobiles;
///
/// Drives an AI on two clocks: decisions at , plus
/// move-only wakes at while a pursuit is live. Each tick
/// schedules the earlier of the two deadlines.
///
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 / _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);
}
}