Fixes walk-up aggro latency (up to a full 10 s of obliviousness) and hardens the reacquire gate so no state can silence acquisition, while turning `AcquireOnApproach` into the reaction-time knob for future per-creature intelligence tuning. ### Why `AcquireFocusMob` re-armed the 10 s `ReacquireDelay` **before** scanning, success or failure. A creature that scanned an empty room was blind for 10 s to a player walking up — walk-up aggro latency was uniform in 0..10 s. Waking from sector sleep stacked the AI timer's 0–3 s construction stagger on top. And `NextReacquireTime` is not serialized: on hosts whose tick counter starts negative (GCP pass-through), the 0 default blocked **all** acquisition shard-wide after a restart until the counter crossed zero. ### What **Event-driven reaction — `AcquireOnApproachDelay` (the intelligence gradient)** - The paragon `AcquireOnApproach` bool becomes a `TimeSpan` on every creature: an enemy moving inside `AcquireOnApproachRange` (10 for all creatures — on-screen reactive aggro; the periodic scan keeps the wide `RangePerception` sweep) *clamps* the next scan to at most the delay. Repeated steps cannot shorten it further — one scan per delay period, not per step or think. - `Zero` (paragons) also prods the AI timer: the ranked scan engages within a wheel turn — the old snap, minus the special-cased engage path. The target now comes from the normal FightMode ranking instead of whichever mobile happened to move, and the `Combatant == null` guard stops re-engage spam. - The 2 s default reads as "took a beat to notice you"; larger values are dumber; `ReacquireDelay` alone is the oblivious floor. Mover checks are the approach logic's `IsEnemy` + `CanBeHarmful` (so pets count and hidden movers are excluded via `CanSee`), with `IsEnemy` first to cheaply reject same-team wild creatures wandering past. The check rides the `OnMovement` callback every step already pays for — no polling added. **Gate correctness** - Every scan re-arms the full `ReacquireDelay`, success or failure (classic semantics; reaction time is the approach path, not the poll). - Self-healing by construction: a deadline further out than `ReacquireDelay` is an illegal state and reads as open — no wedged or wrapped value can silence acquisition beyond one delay period. - `NextReacquireTime` is seeded from a live tick on deserialize (the GCP negative-tick blackout). **AI timer wake** - Activation (sector wake, spawn, resurrection) starts within a 0–256 ms spread instead of the 0–3 s construction stagger, which read as lag. - The stagger's real job — keeping same-speed cohorts out of lock-step (the RunUO town artifact) — is now a zero-mean ±period/8 jitter on each **idle** think, so phases random-walk apart within seconds and can never re-lock. Instrumentation showed why a one-shot spread can't do this job: the timer wheel fires within ±1 ms, so with 10 creatures on a 500 ms period some pair collides on nearly the same phase ~75% of the time (birthday paradox) and then steps in the same loop iteration *forever*. Jitter is scoped to passive speed: engaged cadence stays exact, since pursuit timing anchors to real step times. **Debug** - The `AcquireFocusMob` scan message no longer re-arms the shared 5 s debug cooldown, which swallowed every AI's "I have detected X" transition line. **API change** for custom scripts: `AcquireOnApproach` (bool) → `AcquireOnApproachDelay` (TimeSpan). Documented in `content-patterns.md` § Target Acquisition, `runuo-migration-docs/09` + `11`, and the migration skill checklist. ### Tests `AcquisitionTests`: both scan outcomes honor `ReacquireDelay`; a 60 s-wedged gate still acquires; enemy movement clamps the deadline (same-team wild movers and out-of-range movers ignored); repeated movement cannot shorten below the delay; `Zero` opens the gate and prods without a direct engage. Full suite: 755 UOContent green.
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 / _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);
|
|
}
|
|
}
|