### Summary
Fixes the long-standing reports of monsters losing track of players who run around a corner ("Is monster AI not using pathfinding? It seems to be LOS blocked by statics"). Root-cause investigation compared current behavior against RunUO line-by-line and traced the regressions through the AI overhaul era (#2232, #2246, #2379, #2401, #2461).
### Root causes and fixes
1. **Movement contract** — `MoveTo`/`ApproachTarget` returned false on every healthy mid-chase tick (true only on arrival), so MeleeAI's RunUO-inherited *"move failed and beyond RangePerception+1 → Guard"* clause — which RunUO only evaluated on genuine blockage — fired **every tick of every chase**. A mounted player trivially opens 17 tiles at a corner, the monster guards, Guard nulls the combatant, and re-acquisition is LOS-gated — unrecoverable through a wall. Movement now reports failure only on genuine failure (no step taken with no working path, or approach give-up). ArcherAI's equivalent clause moves to the hard leash.
2. **Last-known-position pursuit** — while a combatant is in LOS its position is recorded each think tick. When the target vanishes (corner, hiding, recall), the creature walks to the last-seen spot, stands guard there ~10s (restoring RunUO's guard grace, which had decayed to a single tick since #2246), and **re-engages instantly** if the same target re-enters view — bypassing the 10s reacquire throttle.
3. **`ChaseLeashRange`** — new virtual on BaseCreature (default `RangePerception * 2` = 32 tiles) replaces the inline `RangePerception * 3` (48) in Melee/Mage/Archer AI. Per-creature tunable via `[props`.
4. **Group movement demoted to a crowding refinement** — previously any uncontrolled creature with one ally within 8 tiles on the same target used greedy ring-stepping for the *entire* chase, with wall-slides counted as success, never invoking the pathfinder — the "aggroed but won't come around the corner" symptom for spawn groups. It now engages only near the target when allies actually contest the ring, and blocked/wall-slid steps escalate to the pathfinding approach primitive.
5. **Mages close distance on broken LOS** — a mage within casting range but LOS-blocked by geometry stood at the wall holding a spell target until the 60s combatant expiry (ProcessTarget short-circuits Think and its RunTo stands off at RangeFight). Geometry-blocked mages now close in until LOS returns, both pre-cast and while holding a target. Hidden targets (CanSee) and poison-cure priority unchanged. The new movement contract also stops the constant spurious `OnFailedMove` teleport rolls mid-chase.
6. **Move budget: one actual step per AI tick** — nothing advanced `NextMove` on a normal step (RunUO's `m_NextMove` budget was lost), so code paths attempting several moves in one think tick could cross multiple tiles at once — visible as "warping" when crowded creatures jockey for position. A successful step now consumes a half-step budget (floor 50ms): blocks intra-tick double moves, stays safely below the timer interval so legitimate next-tick moves are never jitter-throttled, and does not reintroduce `TransformMoveDelay` inflation. Blocked attempts consume nothing, so retry ladders (repath-and-step, the collision fan) are unaffected. `CanMoveNow` is also wraparound-safe now.
### Reference behavior
RunUO requires LOS to *acquire* a target and to *land* a hit or spell — never to *continue* a chase (its MeleeAI LOS bail-out is literally commented out in stock code). Chases drop only on: target hidden, target dead/off-map, beyond `RangePerception * 3`, 60s without combat interaction, or blocked movement while far away. This PR restores those semantics while adding the last-known-position investigation on top. NPC run flags are untouched — pace is AI-timer-driven and most NPC art has no run animation.
1138 lines
32 KiB
C#
1138 lines
32 KiB
C#
using System;
|
|
using Server.Items;
|
|
using Server.Spells;
|
|
using Server.Spells.Fifth;
|
|
using Server.Spells.First;
|
|
using Server.Spells.Fourth;
|
|
using Server.Spells.Necromancy;
|
|
using Server.Spells.Second;
|
|
using Server.Spells.Seventh;
|
|
using Server.Spells.Sixth;
|
|
using Server.Spells.Third;
|
|
using Server.Targeting;
|
|
|
|
namespace Server.Mobiles;
|
|
|
|
public class MageAI : BaseAI
|
|
{
|
|
private const double HealChance = 0.10; // 10% chance to heal at gm magery
|
|
private const double TeleportChance = 0.05; // 5% chance to teleport at gm magery
|
|
private const double DispelChance = 0.75; // 75% chance to dispel at gm magery
|
|
private const double InvisChance = 0.50; // 50% chance to invis at gm magery
|
|
|
|
public override double FleeHealthThreshold => 0.2; // 20% is default
|
|
public override double FleeChance => 0.1; // 10% is default
|
|
|
|
private static readonly int[] Offsets =
|
|
[
|
|
-1, -1,
|
|
-1, 0,
|
|
-1, 1,
|
|
0, -1,
|
|
0, 1,
|
|
1, -1,
|
|
1, 0,
|
|
1, 1,
|
|
|
|
-2, -2,
|
|
-2, -1,
|
|
-2, 0,
|
|
-2, 1,
|
|
-2, 2,
|
|
-1, -2,
|
|
-1, 2,
|
|
0, -2,
|
|
0, 2,
|
|
1, -2,
|
|
1, 2,
|
|
2, -2,
|
|
2, -1,
|
|
2, 0,
|
|
2, 1,
|
|
2, 2
|
|
];
|
|
|
|
protected int _combo = -1;
|
|
|
|
private Mobile _lastTarget;
|
|
private Point3D _lastTargetLoc;
|
|
private long _nextCastTime;
|
|
private long _nextHealTime;
|
|
|
|
private LandTarget _revealTarget;
|
|
|
|
public MageAI(BaseCreature m) : base(m)
|
|
{
|
|
}
|
|
|
|
public virtual bool SmartAI => Mobile is BaseVendor or BaseEscortable or Changeling;
|
|
|
|
public virtual bool IsNecromancer => Core.AOS && Mobile.Skills.Necromancy.Value > 50;
|
|
|
|
public override bool Think()
|
|
{
|
|
if (Mobile.Deleted)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
return ProcessTarget() || base.Think();
|
|
}
|
|
|
|
public virtual double ScaleBySkill(double v, SkillName skill) => v * Mobile.Skills[skill].Value / 100;
|
|
|
|
public override bool DoActionWander()
|
|
{
|
|
if (AcquireFocusMob(Mobile.RangePerception, Mobile.FightMode, false, false, true))
|
|
{
|
|
this.DebugSayFormatted($"I am going to attack {Mobile.FocusMob.Name}");
|
|
|
|
Mobile.Combatant = Mobile.FocusMob;
|
|
Action = ActionType.Combat;
|
|
_nextCastTime = Core.TickCount;
|
|
}
|
|
else if (SmartAI && Mobile.Mana < Mobile.ManaMax && !Mobile.Meditating)
|
|
{
|
|
DebugSay("I am going to meditate");
|
|
|
|
Mobile.UseSkill(SkillName.Meditation);
|
|
}
|
|
else
|
|
{
|
|
DebugSay("I am wandering");
|
|
|
|
Mobile.Warmode = false;
|
|
|
|
base.DoActionWander();
|
|
|
|
if (Utility.RandomDouble() < 0.05)
|
|
{
|
|
var spell = CheckCastHealingSpell();
|
|
|
|
spell?.Cast();
|
|
}
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
private Spell CheckCastHealingSpell()
|
|
{
|
|
// If I'm poisoned, always attempt to cure.
|
|
if (Mobile.Poisoned)
|
|
{
|
|
return new CureSpell(Mobile);
|
|
}
|
|
|
|
// Summoned creatures never heal themselves.
|
|
if (Mobile.Summoned || Mobile.Controlled && Core.TickCount - _nextHealTime < 0)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
if (!SmartAI)
|
|
{
|
|
if (HealChance > 0 && ScaleBySkill(HealChance, SkillName.Magery) < Utility.RandomDouble())
|
|
{
|
|
return null;
|
|
}
|
|
}
|
|
else if (Utility.Random(0, 4 + (Mobile.Hits == 0 ? Mobile.HitsMax : Mobile.HitsMax / Mobile.Hits)) < 3)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
Spell spell = null;
|
|
|
|
if (Mobile.Hits < Mobile.HitsMax - 50)
|
|
{
|
|
if (UseNecromancy())
|
|
{
|
|
Mobile.UseSkill(SkillName.SpiritSpeak);
|
|
}
|
|
else
|
|
{
|
|
spell = new GreaterHealSpell(Mobile);
|
|
}
|
|
}
|
|
else if (Mobile.Hits < Mobile.HitsMax - 10)
|
|
{
|
|
spell = new HealSpell(Mobile);
|
|
}
|
|
|
|
var delay = Mobile.Int >= 500 ? Utility.RandomMinMax(7, 10) : Math.Sqrt(600 - Mobile.Int);
|
|
|
|
_nextHealTime = Core.TickCount + (int)TimeSpan.FromSeconds(delay).TotalMilliseconds;
|
|
|
|
return spell;
|
|
}
|
|
|
|
public void RunTo(Mobile m)
|
|
{
|
|
if (!SmartAI)
|
|
{
|
|
if (!MoveTo(m, false, Mobile.RangeFight))
|
|
{
|
|
OnFailedMove();
|
|
}
|
|
|
|
return;
|
|
}
|
|
|
|
if (m.Paralyzed || m.Frozen)
|
|
{
|
|
if (Mobile.InRange(m, 1))
|
|
{
|
|
RunFrom(m);
|
|
}
|
|
else if (!Mobile.InRange(m, Math.Max(Mobile.RangeFight, 2)) && !MoveTo(m, false, 1))
|
|
{
|
|
OnFailedMove();
|
|
}
|
|
}
|
|
else if (!Mobile.InRange(m, Mobile.RangeFight))
|
|
{
|
|
if (!MoveTo(m, false, 1))
|
|
{
|
|
OnFailedMove();
|
|
}
|
|
}
|
|
else if (Mobile.InRange(m, Mobile.RangeFight - 1))
|
|
{
|
|
RunFrom(m);
|
|
}
|
|
}
|
|
|
|
public void RunFrom(Mobile m)
|
|
{
|
|
Run((Mobile.GetDirectionTo(m) - 4) & Direction.Mask);
|
|
}
|
|
|
|
public void OnFailedMove()
|
|
{
|
|
if (!Mobile.DisallowAllMoves && (SmartAI
|
|
? Utility.Random(4) == 0
|
|
: TeleportChance > 0 && ScaleBySkill(TeleportChance, SkillName.Magery) > Utility.RandomDouble()))
|
|
{
|
|
Mobile.Target?.Cancel(Mobile, TargetCancelType.Canceled);
|
|
|
|
new TeleportSpell(Mobile).Cast();
|
|
|
|
DebugSay("I am stuck, I'm going to try teleporting away");
|
|
}
|
|
else if (AcquireFocusMob(Mobile.RangePerception, Mobile.FightMode, false, false, true))
|
|
{
|
|
this.DebugSayFormatted($"My move is blocked, so I am going to attack {Mobile.FocusMob.Name}");
|
|
|
|
Mobile.Combatant = Mobile.FocusMob;
|
|
Action = ActionType.Combat;
|
|
}
|
|
else
|
|
{
|
|
DebugSay("I am stuck");
|
|
}
|
|
}
|
|
|
|
public void Run(Direction d)
|
|
{
|
|
if (Mobile.Spell?.IsCasting == true || Mobile.Paralyzed || Mobile.Frozen ||
|
|
Mobile.DisallowAllMoves)
|
|
{
|
|
return;
|
|
}
|
|
|
|
Mobile.Direction = d | Direction.Running;
|
|
|
|
if (!DoMove(Mobile.Direction, true))
|
|
{
|
|
OnFailedMove();
|
|
}
|
|
}
|
|
|
|
public virtual bool UseNecromancy()
|
|
{
|
|
var magery = Mobile.Skills.Magery.BaseFixedPoint;
|
|
var necro = Mobile.Skills.Necromancy.BaseFixedPoint;
|
|
return IsNecromancer && Utility.Random(magery + necro) >= magery;
|
|
}
|
|
|
|
public virtual Spell GetRandomDamageSpell() =>
|
|
UseNecromancy() ? GetRandomDamageSpellNecro() : GetRandomDamageSpellMage();
|
|
|
|
public virtual Spell GetRandomDamageSpellNecro()
|
|
{
|
|
var bound = Mobile.Skills.Necromancy.Value >= 100 ? 5 : 3;
|
|
|
|
return Utility.Random(bound) switch
|
|
{
|
|
0 => new PainSpikeSpell(Mobile),
|
|
1 => new PoisonStrikeSpell(Mobile),
|
|
2 => new StrangleSpell(Mobile),
|
|
3 => new WitherSpell(Mobile),
|
|
_ => new VengefulSpiritSpell(Mobile)
|
|
};
|
|
}
|
|
|
|
public virtual Spell GetRandomDamageSpellMage()
|
|
{
|
|
var maxCircle = Math.Clamp((int)((Mobile.Skills.Magery.Value + 20.0) / (100.0 / 7.0)), 1, 8);
|
|
|
|
return Utility.Random(maxCircle * 2) switch
|
|
{
|
|
0 => new MagicArrowSpell(Mobile),
|
|
1 => new MagicArrowSpell(Mobile),
|
|
2 => new HarmSpell(Mobile),
|
|
3 => new HarmSpell(Mobile),
|
|
4 => new FireballSpell(Mobile),
|
|
5 => new FireballSpell(Mobile),
|
|
6 => new LightningSpell(Mobile),
|
|
7 => new LightningSpell(Mobile),
|
|
8 => new MindBlastSpell(Mobile),
|
|
9 => new MindBlastSpell(Mobile),
|
|
10 => new EnergyBoltSpell(Mobile),
|
|
11 => new ExplosionSpell(Mobile),
|
|
_ => new FlameStrikeSpell(Mobile)
|
|
};
|
|
}
|
|
|
|
public virtual Spell GetRandomCurseSpell() =>
|
|
UseNecromancy() ? GetRandomCurseSpellNecro() : GetRandomCurseSpellMage();
|
|
|
|
public virtual Spell GetRandomCurseSpellNecro()
|
|
{
|
|
return Utility.Random(4) switch
|
|
{
|
|
0 => new BloodOathSpell(Mobile),
|
|
1 => new CorpseSkinSpell(Mobile),
|
|
2 => new EvilOmenSpell(Mobile),
|
|
_ => new MindRotSpell(Mobile)
|
|
};
|
|
}
|
|
|
|
public virtual Spell GetRandomCurseSpellMage()
|
|
{
|
|
if (Mobile.Skills.Magery.Value >= 40.0 && Utility.Random(4) == 0)
|
|
{
|
|
return new CurseSpell(Mobile);
|
|
}
|
|
|
|
return Utility.Random(3) switch
|
|
{
|
|
0 => new WeakenSpell(Mobile),
|
|
1 => new ClumsySpell(Mobile),
|
|
_ => new FeeblemindSpell(Mobile)
|
|
};
|
|
}
|
|
|
|
public virtual Spell GetRandomManaDrainSpell()
|
|
{
|
|
if (Mobile.Skills.Magery.Value >= 80.0 && Utility.RandomBool())
|
|
{
|
|
return new ManaVampireSpell(Mobile);
|
|
}
|
|
|
|
return new ManaDrainSpell(Mobile);
|
|
}
|
|
|
|
public virtual Spell DoDispel(Mobile toDispel)
|
|
{
|
|
if (!SmartAI)
|
|
{
|
|
if (DispelChance > 0 && ScaleBySkill(DispelChance, SkillName.Magery) > Utility.RandomDouble())
|
|
{
|
|
return new DispelSpell(Mobile);
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
var spell = CheckCastHealingSpell();
|
|
|
|
if (spell != null)
|
|
{
|
|
return spell;
|
|
}
|
|
|
|
var distance = (int)Mobile.GetDistanceToSqrt(toDispel);
|
|
if (!Mobile.DisallowAllMoves && distance > 0 && Utility.Random(distance) == 0)
|
|
{
|
|
return new TeleportSpell(Mobile);
|
|
}
|
|
|
|
if (Utility.Random(3) == 0 && !Mobile.InRange(toDispel, 3) && !toDispel.Paralyzed && !toDispel.Frozen)
|
|
{
|
|
return new ParalyzeSpell(Mobile);
|
|
}
|
|
|
|
return new DispelSpell(Mobile);
|
|
}
|
|
|
|
public virtual Spell ChooseSpell(Mobile c)
|
|
{
|
|
Spell spell;
|
|
|
|
if (!SmartAI)
|
|
{
|
|
spell = CheckCastHealingSpell();
|
|
|
|
if (spell != null)
|
|
{
|
|
return spell;
|
|
}
|
|
|
|
if (IsNecromancer)
|
|
{
|
|
var psDamage =
|
|
(Mobile.Skills.SpiritSpeak.Value - c.Skills.MagicResist.Value) / 10 +
|
|
(c.Player ? 18 : 30);
|
|
|
|
if (psDamage > c.Hits)
|
|
{
|
|
return new PainSpikeSpell(Mobile);
|
|
}
|
|
}
|
|
|
|
switch (Utility.Random(16))
|
|
{
|
|
case 0:
|
|
case 1: // Poison them
|
|
{
|
|
if (c.Poisoned)
|
|
{
|
|
goto default;
|
|
}
|
|
|
|
DebugSay("Attempting to poison");
|
|
|
|
spell = new PoisonSpell(Mobile);
|
|
break;
|
|
}
|
|
case 2: // Bless ourselves
|
|
{
|
|
DebugSay("Blessing myself");
|
|
|
|
spell = new BlessSpell(Mobile);
|
|
break;
|
|
}
|
|
case 3:
|
|
case 4: // Curse them
|
|
{
|
|
DebugSay("Attempting to curse");
|
|
|
|
spell = GetRandomCurseSpell();
|
|
break;
|
|
}
|
|
case 5: // Paralyze them
|
|
{
|
|
if (c.Paralyzed || Mobile.Skills.Magery.Value <= 50.0)
|
|
{
|
|
goto default;
|
|
}
|
|
|
|
DebugSay("Attempting to paralyze");
|
|
|
|
spell = new ParalyzeSpell(Mobile);
|
|
break;
|
|
}
|
|
case 6: // Drain mana
|
|
{
|
|
DebugSay("Attempting to drain mana");
|
|
|
|
spell = GetRandomManaDrainSpell();
|
|
break;
|
|
}
|
|
case 7: // Invis ourselves
|
|
{
|
|
if (InvisChance <= 0 || ScaleBySkill(InvisChance, SkillName.Magery) <= Utility.RandomDouble())
|
|
{
|
|
goto default;
|
|
}
|
|
|
|
DebugSay("Attempting to invis myself");
|
|
|
|
spell = new InvisibilitySpell(Mobile);
|
|
break;
|
|
}
|
|
default: // Damage them
|
|
{
|
|
DebugSay("Just doing damage");
|
|
|
|
spell = GetRandomDamageSpell();
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
else
|
|
{
|
|
spell = CheckCastHealingSpell();
|
|
|
|
if (spell == null)
|
|
{
|
|
switch (Utility.Random(3))
|
|
{
|
|
case 0: // Poison them
|
|
{
|
|
if (c.Poisoned)
|
|
{
|
|
goto case 1;
|
|
}
|
|
|
|
spell = new PoisonSpell(Mobile);
|
|
break;
|
|
}
|
|
case 1: // Deal some damage
|
|
{
|
|
spell = GetRandomDamageSpell();
|
|
break;
|
|
}
|
|
default: // Set up a combo
|
|
{
|
|
if (Mobile.Mana is > 15 and < 40)
|
|
{
|
|
if (c.Paralyzed && !c.Poisoned && !Mobile.Meditating)
|
|
{
|
|
DebugSay("I am going to meditate");
|
|
|
|
Mobile.UseSkill(SkillName.Meditation);
|
|
}
|
|
else if (!c.Poisoned)
|
|
{
|
|
spell = new ParalyzeSpell(Mobile);
|
|
}
|
|
}
|
|
else if (Mobile.Mana > 60)
|
|
{
|
|
if (Utility.RandomBool() && !c.Paralyzed && !c.Frozen && !c.Poisoned)
|
|
{
|
|
_combo = 0;
|
|
spell = new ParalyzeSpell(Mobile);
|
|
}
|
|
else
|
|
{
|
|
_combo = 1;
|
|
spell = new ExplosionSpell(Mobile);
|
|
}
|
|
}
|
|
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (spell != null)
|
|
{
|
|
this.DebugSayFormatted($"Casting {spell.Name}");
|
|
}
|
|
else
|
|
{
|
|
DebugSay("I don't have a spell to use!");
|
|
}
|
|
|
|
return spell;
|
|
}
|
|
|
|
public virtual Spell DoCombo(Mobile c)
|
|
{
|
|
Spell spell = null;
|
|
|
|
if (_combo == 0)
|
|
{
|
|
spell = new ExplosionSpell(Mobile);
|
|
++_combo; // Move to next spell
|
|
}
|
|
else if (_combo == 1)
|
|
{
|
|
spell = new WeakenSpell(Mobile);
|
|
++_combo; // Move to next spell
|
|
}
|
|
else if (_combo == 2)
|
|
{
|
|
if (!c.Poisoned)
|
|
{
|
|
spell = new PoisonSpell(Mobile);
|
|
}
|
|
else if (IsNecromancer)
|
|
{
|
|
spell = new StrangleSpell(Mobile);
|
|
}
|
|
|
|
++_combo; // Move to next spell
|
|
}
|
|
|
|
if (_combo == 3 && spell == null)
|
|
{
|
|
switch (Utility.Random(IsNecromancer ? 4 : 3))
|
|
{
|
|
case 0:
|
|
{
|
|
if (c.Int < c.Dex)
|
|
{
|
|
spell = new FeeblemindSpell(Mobile);
|
|
}
|
|
else
|
|
{
|
|
spell = new ClumsySpell(Mobile);
|
|
}
|
|
|
|
++_combo; // Move to next spell
|
|
|
|
break;
|
|
}
|
|
case 1:
|
|
{
|
|
spell = new EnergyBoltSpell(Mobile);
|
|
_combo = -1; // Reset combo state
|
|
break;
|
|
}
|
|
case 2:
|
|
{
|
|
spell = new FlameStrikeSpell(Mobile);
|
|
_combo = -1; // Reset combo state
|
|
break;
|
|
}
|
|
default:
|
|
{
|
|
spell = new PainSpikeSpell(Mobile);
|
|
_combo = -1; // Reset combo state
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
else if (_combo == 4 && spell == null)
|
|
{
|
|
spell = new MindBlastSpell(Mobile);
|
|
_combo = -1;
|
|
}
|
|
|
|
return spell;
|
|
}
|
|
|
|
private TimeSpan GetDelay(Spell spell)
|
|
{
|
|
if (SmartAI || spell is DispelSpell)
|
|
{
|
|
return TimeSpan.FromSeconds(Mobile.ActiveSpeed);
|
|
}
|
|
|
|
var del = ScaleBySkill(3.0, SkillName.Magery);
|
|
var min = 6.0 - del * 0.75;
|
|
var max = 6.0 - del * 1.25;
|
|
|
|
return TimeSpan.FromSeconds(min + (max - min) * Utility.RandomDouble());
|
|
}
|
|
|
|
public override bool DoActionCombat()
|
|
{
|
|
var c = Mobile.Combatant;
|
|
Mobile.Warmode = true;
|
|
|
|
if (c?.Deleted != false || !c.Alive || c.IsDeadBondedPet || !Mobile.CanSee(c) ||
|
|
!Mobile.CanBeHarmful(c, false) || c.Map != Mobile.Map)
|
|
{
|
|
// Our combatant is deleted, dead, hidden, or we cannot hurt them
|
|
// Try to find another combatant
|
|
|
|
if (AcquireFocusMob(Mobile.RangePerception, Mobile.FightMode, false, false, true))
|
|
{
|
|
Mobile.Combatant = c = Mobile.FocusMob!;
|
|
|
|
this.DebugSayFormatted($"Something happened to my combatant, so I am going to fight {c.Name}");
|
|
|
|
Mobile.FocusMob = null;
|
|
}
|
|
else
|
|
{
|
|
DebugSay("Something happened to my combatant, and nothing is around. I am on guard.");
|
|
|
|
Action = ActionType.Guard;
|
|
return true;
|
|
}
|
|
}
|
|
|
|
if (!Mobile.InLOS(c))
|
|
{
|
|
DebugSay("I can't see my target");
|
|
|
|
if (AcquireFocusMob(Mobile.RangePerception, Mobile.FightMode, false, false, true))
|
|
{
|
|
Mobile.Combatant = c = Mobile.FocusMob!;
|
|
|
|
this.DebugSayFormatted($"I will switch to {c.Name}");
|
|
|
|
Mobile.FocusMob = null;
|
|
}
|
|
}
|
|
|
|
if (!Core.AOS && SmartAI && !Mobile.StunReady && Mobile.Skills.Wrestling.Value >= 80.0 &&
|
|
Mobile.Skills.Anatomy.Value >= 80.0)
|
|
{
|
|
Fists.StunRequest(Mobile);
|
|
}
|
|
|
|
if (!Mobile.InRange(c, Mobile.RangePerception))
|
|
{
|
|
// They are somewhat far away, can we find something else?
|
|
|
|
if (AcquireFocusMob(Mobile.RangePerception, Mobile.FightMode, false, false, true))
|
|
{
|
|
Mobile.Combatant = Mobile.FocusMob;
|
|
Mobile.FocusMob = null;
|
|
}
|
|
else if (!Mobile.InRange(c, Mobile.ChaseLeashRange))
|
|
{
|
|
Mobile.Combatant = null;
|
|
}
|
|
|
|
c = Mobile.Combatant;
|
|
|
|
if (c == null)
|
|
{
|
|
DebugSay("My combatant has fled, so I am on guard");
|
|
|
|
Action = ActionType.Guard;
|
|
return true;
|
|
}
|
|
}
|
|
|
|
// Geometry (not hiding — CanSee passed above) is blocking the shot: close in until
|
|
// line of sight returns. Poisoned mages still fall through to cure.
|
|
if (!Mobile.Poisoned && Mobile.Spell?.IsCasting != true && !Mobile.InLOS(c))
|
|
{
|
|
DebugSay("I cannot see my target, moving to regain line of sight");
|
|
|
|
if (!MoveTo(c, false, 1))
|
|
{
|
|
OnFailedMove();
|
|
}
|
|
|
|
_lastTarget = c;
|
|
_lastTargetLoc = c.Location;
|
|
|
|
return true;
|
|
}
|
|
|
|
if (Mobile.TriggerAbility(MonsterAbilityTrigger.CombatAction, c))
|
|
{
|
|
DebugSay("I used my abilities!");
|
|
}
|
|
else if (Mobile.Spell == null && Core.TickCount - _nextCastTime >= 0)
|
|
{
|
|
if (Mobile.Controlled && c == Mobile)
|
|
{
|
|
DebugSay("I should not attack myself!");
|
|
Mobile.Combatant = null;
|
|
Action = ActionType.Guard;
|
|
return true;
|
|
}
|
|
|
|
// We are ready to cast a spell
|
|
Spell spell;
|
|
var toDispel = FindDispelTarget(true);
|
|
|
|
if (Mobile.Poisoned) // Top cast priority is cure
|
|
{
|
|
DebugSay("I am going to cure myself");
|
|
|
|
spell = new CureSpell(Mobile);
|
|
}
|
|
else if (toDispel != null) // Something dispellable is attacking us
|
|
{
|
|
this.DebugSayFormatted($"I am going to dispel {toDispel}");
|
|
|
|
spell = DoDispel(toDispel); // May return null if dumb AI and doesn't have enough skill
|
|
}
|
|
else
|
|
{
|
|
spell = null;
|
|
}
|
|
|
|
spell ??= SmartAI switch
|
|
{
|
|
// We are doing a spell combo
|
|
true when _combo != -1 => DoCombo(c),
|
|
// They have a heal spell out
|
|
true when !c.Poisoned && c.Spell is HealSpell or GreaterHealSpell => new PoisonSpell(Mobile),
|
|
_ => ChooseSpell(c)
|
|
};
|
|
|
|
// Now we have a spell picked
|
|
var range = (spell as IRangedSpell)?.TargetRange ?? Mobile.RangePerception;
|
|
|
|
// Move first before casting
|
|
if (!SmartAI || toDispel == null)
|
|
{
|
|
RunTo(c);
|
|
}
|
|
else if (Mobile.InRange(toDispel, Math.Min(10, range)))
|
|
{
|
|
RunFrom(toDispel);
|
|
}
|
|
else if (!Mobile.InRange(toDispel, range))
|
|
{
|
|
RunTo(toDispel);
|
|
}
|
|
|
|
// After running, make sure we are still in range
|
|
if (spell == null || Mobile.InRange(c, range))
|
|
{
|
|
spell?.Cast();
|
|
|
|
// Even if we don't have a spell, delay the next potential cast
|
|
_nextCastTime = Core.TickCount + (int)GetDelay(spell).TotalMilliseconds;
|
|
}
|
|
}
|
|
else if (Mobile.Spell?.IsCasting != true)
|
|
{
|
|
RunTo(c);
|
|
}
|
|
|
|
if (Mobile.InRange(c, 1) || Mobile.Spell?.IsCasting == true || Core.TickCount - Mobile.LastMoveTime > 400)
|
|
{
|
|
Mobile.Direction = Mobile.GetDirectionTo(c);
|
|
}
|
|
|
|
_lastTarget = c;
|
|
_lastTargetLoc = c.Location;
|
|
|
|
return true;
|
|
}
|
|
|
|
public override bool DoActionGuard()
|
|
{
|
|
if (_lastTarget?.Hidden == true)
|
|
{
|
|
var map = Mobile.Map;
|
|
|
|
if (map == null || !Mobile.InRange(_lastTargetLoc, Mobile.RangePerception))
|
|
{
|
|
_lastTarget = null;
|
|
}
|
|
else if (Mobile.Spell == null && Core.TickCount - _nextCastTime >= 0)
|
|
{
|
|
DebugSay("I am going to reveal my last target");
|
|
|
|
_revealTarget = new LandTarget(_lastTargetLoc, map);
|
|
Spell spell = new RevealSpell(Mobile);
|
|
|
|
if (spell.Cast())
|
|
{
|
|
_lastTarget = null; // only do it once
|
|
}
|
|
|
|
_nextCastTime = Core.TickCount + (int)GetDelay(spell).TotalMilliseconds;
|
|
}
|
|
}
|
|
|
|
if (AcquireFocusMob(Mobile.RangePerception, Mobile.FightMode, false, false, true))
|
|
{
|
|
this.DebugSayFormatted($"I am going to attack {Mobile.FocusMob.Name}");
|
|
|
|
Mobile.Combatant = Mobile.FocusMob;
|
|
Action = ActionType.Combat;
|
|
}
|
|
else
|
|
{
|
|
if (!Mobile.Controlled)
|
|
{
|
|
ProcessTarget();
|
|
|
|
var spell = CheckCastHealingSpell();
|
|
|
|
spell?.Cast();
|
|
}
|
|
|
|
base.DoActionGuard();
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
public Mobile FindDispelTarget(bool activeOnly)
|
|
{
|
|
if (Mobile.Deleted || Mobile.Int < 95 || CanDispel(Mobile) || Mobile.AutoDispel)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
if (activeOnly)
|
|
{
|
|
var aggressed = Mobile.Aggressed;
|
|
var aggressors = Mobile.Aggressors;
|
|
|
|
Mobile active = null;
|
|
var activePrio = 0.0;
|
|
|
|
var comb = Mobile.Combatant;
|
|
|
|
if (comb?.Deleted == false && comb.Alive && !comb.IsDeadBondedPet &&
|
|
Mobile.InRange(comb, Mobile.RangePerception) && CanDispel(comb))
|
|
{
|
|
active = comb;
|
|
activePrio = Mobile.GetDistanceToSqrt(comb);
|
|
|
|
if (activePrio <= 2)
|
|
{
|
|
return active;
|
|
}
|
|
}
|
|
|
|
for (var i = 0; i < aggressed.Count; ++i)
|
|
{
|
|
var info = aggressed[i];
|
|
var m = info.Defender;
|
|
|
|
if (m != comb && m.Combatant == Mobile && Mobile.InRange(m, Mobile.RangePerception) && CanDispel(m))
|
|
{
|
|
var prio = Mobile.GetDistanceToSqrt(m);
|
|
|
|
if (active == null || prio < activePrio)
|
|
{
|
|
active = m;
|
|
activePrio = prio;
|
|
|
|
if (activePrio <= 2)
|
|
{
|
|
return active;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
for (var i = 0; i < aggressors.Count; ++i)
|
|
{
|
|
var info = aggressors[i];
|
|
var m = info.Attacker;
|
|
|
|
if (m != comb && m.Combatant == Mobile && Mobile.InRange(m, Mobile.RangePerception) && CanDispel(m))
|
|
{
|
|
var prio = Mobile.GetDistanceToSqrt(m);
|
|
|
|
if (active == null || prio < activePrio)
|
|
{
|
|
active = m;
|
|
activePrio = prio;
|
|
|
|
if (activePrio <= 2)
|
|
{
|
|
return active;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return active;
|
|
}
|
|
|
|
var map = Mobile.Map;
|
|
|
|
if (map != null)
|
|
{
|
|
Mobile active = null, inactive = null;
|
|
double actPrio = 0.0, inactPrio = 0.0;
|
|
|
|
var comb = Mobile.Combatant;
|
|
|
|
if (comb?.Deleted == false && comb.Alive && !comb.IsDeadBondedPet && CanDispel(comb))
|
|
{
|
|
active = inactive = comb;
|
|
actPrio = inactPrio = Mobile.GetDistanceToSqrt(comb);
|
|
}
|
|
|
|
foreach (var m in Mobile.GetMobilesInRange(Mobile.RangePerception))
|
|
{
|
|
if (m != Mobile && CanDispel(m))
|
|
{
|
|
var prio = Mobile.GetDistanceToSqrt(m);
|
|
|
|
if (inactive == null || prio < inactPrio)
|
|
{
|
|
inactive = m;
|
|
inactPrio = prio;
|
|
}
|
|
|
|
if ((Mobile.Combatant == m || m.Combatant == Mobile) && (active == null || prio < actPrio))
|
|
{
|
|
active = m;
|
|
actPrio = prio;
|
|
}
|
|
}
|
|
}
|
|
|
|
return active ?? inactive;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
public bool CanDispel(Mobile m) =>
|
|
m is BaseCreature creature && creature.Summoned && creature.SummonMaster != Mobile &&
|
|
Mobile.CanBeHarmful(creature, false) && !creature.IsAnimatedDead;
|
|
|
|
private bool ProcessTarget()
|
|
{
|
|
var targ = Mobile.Target;
|
|
|
|
if (targ == null)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
var spellTarg = targ as ISpellTarget<Mobile>;
|
|
|
|
var isReveal = spellTarg?.Spell is RevealSpell;
|
|
var isDispel = spellTarg?.Spell is DispelSpell;
|
|
var isParalyze = spellTarg?.Spell is ParalyzeSpell;
|
|
var isTeleport = spellTarg?.Spell is TeleportSpell;
|
|
var isInvisible = spellTarg?.Spell is InvisibilitySpell;
|
|
var teleportAway = false;
|
|
|
|
Mobile toTarget;
|
|
|
|
if (isInvisible)
|
|
{
|
|
toTarget = Mobile;
|
|
}
|
|
else if (isDispel)
|
|
{
|
|
toTarget = FindDispelTarget(false);
|
|
|
|
if (!SmartAI && toTarget != null)
|
|
{
|
|
RunTo(toTarget);
|
|
}
|
|
else if (toTarget != null && Mobile.InRange(toTarget, 10))
|
|
{
|
|
RunFrom(toTarget);
|
|
}
|
|
}
|
|
else if (SmartAI && (isParalyze || isTeleport))
|
|
{
|
|
toTarget = FindDispelTarget(true);
|
|
|
|
if (toTarget == null)
|
|
{
|
|
toTarget = Mobile.Combatant;
|
|
|
|
if (toTarget != null)
|
|
{
|
|
RunTo(toTarget);
|
|
}
|
|
}
|
|
else if (Mobile.InRange(toTarget, 10))
|
|
{
|
|
RunFrom(toTarget);
|
|
teleportAway = true;
|
|
}
|
|
else
|
|
{
|
|
teleportAway = true;
|
|
}
|
|
}
|
|
else
|
|
{
|
|
toTarget = Mobile.Combatant;
|
|
|
|
if (toTarget != null)
|
|
{
|
|
// Without line of sight the stand-off is pointless — close in so the held
|
|
// target can be invoked.
|
|
if (!Mobile.InLOS(toTarget))
|
|
{
|
|
MoveTo(toTarget, true, 1);
|
|
}
|
|
else
|
|
{
|
|
RunTo(toTarget);
|
|
}
|
|
}
|
|
}
|
|
|
|
if ((targ.Flags & TargetFlags.Harmful) != 0 && toTarget != null)
|
|
{
|
|
if ((targ.Range == -1 || Mobile.InRange(toTarget, targ.Range)) && Mobile.CanSee(toTarget) &&
|
|
Mobile.InLOS(toTarget))
|
|
{
|
|
targ.Invoke(Mobile, toTarget);
|
|
}
|
|
else if (isDispel)
|
|
{
|
|
targ.Cancel(Mobile, TargetCancelType.Canceled);
|
|
}
|
|
}
|
|
else if ((targ.Flags & TargetFlags.Beneficial) != 0)
|
|
{
|
|
targ.Invoke(Mobile, Mobile);
|
|
}
|
|
else if (isReveal && _revealTarget != null)
|
|
{
|
|
targ.Invoke(Mobile, _revealTarget);
|
|
}
|
|
else
|
|
{
|
|
var map = Mobile.Map;
|
|
|
|
if (map != null && isTeleport && toTarget != null)
|
|
{
|
|
var teleRange = targ.Range >= 0 ? targ.Range :
|
|
Core.ML ? 11 : 12;
|
|
|
|
int px, py;
|
|
|
|
if (teleportAway)
|
|
{
|
|
var rx = Mobile.X - toTarget.X;
|
|
var ry = Mobile.Y - toTarget.Y;
|
|
|
|
var d = Mobile.GetDistanceToSqrt(toTarget);
|
|
|
|
px = toTarget.X + (int)(rx * (10 / d));
|
|
py = toTarget.Y + (int)(ry * (10 / d));
|
|
}
|
|
else
|
|
{
|
|
px = toTarget.X;
|
|
py = toTarget.Y;
|
|
}
|
|
|
|
for (var i = 0; i < Offsets.Length; i += 2)
|
|
{
|
|
int x = Offsets[i], y = Offsets[i + 1];
|
|
|
|
var p = new Point3D(px + x, py + y, 0);
|
|
|
|
var lt = new LandTarget(p, map);
|
|
|
|
if ((targ.Range == -1 || Mobile.InRange(p, targ.Range)) && Mobile.InLOS(lt) &&
|
|
map.CanSpawnMobile(px + x, py + y, lt.Z) && !SpellHelper.CheckMulti(p, map))
|
|
{
|
|
targ.Invoke(Mobile, lt);
|
|
return true;
|
|
}
|
|
}
|
|
|
|
for (var i = 0; i < 10; ++i)
|
|
{
|
|
var randomPoint = new Point3D(
|
|
Mobile.X - teleRange + Utility.Random(teleRange * 2 + 1),
|
|
Mobile.Y - teleRange + Utility.Random(teleRange * 2 + 1),
|
|
0
|
|
);
|
|
|
|
var lt = new LandTarget(randomPoint, map);
|
|
|
|
if (Mobile.InLOS(lt) && map.CanSpawnMobile(lt.X, lt.Y, lt.Z) &&
|
|
!SpellHelper.CheckMulti(randomPoint, map))
|
|
{
|
|
targ.Invoke(Mobile, new LandTarget(randomPoint, map));
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
|
|
targ.Cancel(Mobile, TargetCancelType.Canceled);
|
|
}
|
|
|
|
return true;
|
|
}
|
|
}
|