Compare commits
11 commits
main
...
feat/basec
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a082202e98 | ||
|
|
6d7eb24cc1 | ||
|
|
cdcf82cfd8 | ||
|
|
4238980c6d | ||
|
|
695efc7d6e | ||
|
|
f2f8313b42 | ||
|
|
f46780d554 | ||
|
|
93b9238f6c | ||
|
|
232c5ed868 | ||
|
|
963b9b3b85 | ||
|
|
f4327e6a3a |
59 changed files with 2090 additions and 2847 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -1,5 +1,4 @@
|
||||||
# Distribution Files
|
# Distribution Files
|
||||||
/Distribution/Data/Files
|
|
||||||
/Distribution/Logger
|
/Distribution/Logger
|
||||||
/Distribution/Logger.*
|
/Distribution/Logger.*
|
||||||
/Distribution/ModernUO
|
/Distribution/ModernUO
|
||||||
|
|
|
||||||
|
|
@ -1,311 +0,0 @@
|
||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using Server.Collections;
|
|
||||||
using Xunit;
|
|
||||||
|
|
||||||
namespace Server.Tests;
|
|
||||||
|
|
||||||
[Collection("Sequential Server Tests")]
|
|
||||||
public class DamageEntryTests
|
|
||||||
{
|
|
||||||
private class TestMobile : Mobile
|
|
||||||
{
|
|
||||||
}
|
|
||||||
|
|
||||||
private class PetMobile : Mobile
|
|
||||||
{
|
|
||||||
public Mobile Master { get; set; }
|
|
||||||
|
|
||||||
public override Mobile GetDamageMaster(Mobile damagee) => Master;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static List<Mobile> Damagers(Mobile victim)
|
|
||||||
{
|
|
||||||
var result = new List<Mobile>();
|
|
||||||
foreach (var de in victim.DamageEntries)
|
|
||||||
{
|
|
||||||
result.Add(de.Damager);
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void FreshMobile_HasNoEntries()
|
|
||||||
{
|
|
||||||
var m = new TestMobile();
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
Assert.Equal(0, m.DamageEntries.Count);
|
|
||||||
Assert.Null(m.FindMostRecentDamageEntry(true));
|
|
||||||
Assert.Null(m.FindLeastRecentDamageEntry(true));
|
|
||||||
Assert.Null(m.FindMostTotalDamageEntry(true));
|
|
||||||
Assert.Null(m.FindLeastTotalDamageEntry(true));
|
|
||||||
Assert.Null(m.FindDamageEntryFor(m));
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
m.Delete();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void RegisterDamage_OrdersLeastRecentToMostRecent()
|
|
||||||
{
|
|
||||||
var victim = new TestMobile();
|
|
||||||
var a = new TestMobile();
|
|
||||||
var b = new TestMobile();
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
victim.RegisterDamage(10, a);
|
|
||||||
victim.RegisterDamage(20, b);
|
|
||||||
victim.RegisterDamage(5, a); // a becomes most recent again
|
|
||||||
|
|
||||||
Assert.Equal(2, victim.DamageEntries.Count);
|
|
||||||
Assert.Equal(new[] { b, a }, Damagers(victim));
|
|
||||||
Assert.Equal(15, victim.FindDamageEntryFor(a).DamageGiven);
|
|
||||||
Assert.Same(a, victim.FindMostRecentDamager(true));
|
|
||||||
Assert.Same(b, victim.FindLeastRecentDamager(true));
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
victim.Delete();
|
|
||||||
a.Delete();
|
|
||||||
b.Delete();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void FindRecent_HonorsAllowSelf()
|
|
||||||
{
|
|
||||||
var victim = new TestMobile();
|
|
||||||
var a = new TestMobile();
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
victim.RegisterDamage(10, a);
|
|
||||||
victim.RegisterDamage(10, victim); // self is most recent
|
|
||||||
|
|
||||||
Assert.Same(victim, victim.FindMostRecentDamager(true));
|
|
||||||
Assert.Same(a, victim.FindMostRecentDamager(false));
|
|
||||||
Assert.Same(a, victim.FindLeastRecentDamager(false));
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
victim.Delete();
|
|
||||||
a.Delete();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void FindLeastRecent_HonorsAllowSelf()
|
|
||||||
{
|
|
||||||
var victim = new TestMobile();
|
|
||||||
var a = new TestMobile();
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
victim.RegisterDamage(10, victim); // self is least recent, so the head is the one to skip
|
|
||||||
victim.RegisterDamage(10, a);
|
|
||||||
|
|
||||||
Assert.Same(victim, victim.FindLeastRecentDamager(true));
|
|
||||||
Assert.Same(a, victim.FindLeastRecentDamager(false));
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
victim.Delete();
|
|
||||||
a.Delete();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void FindTotal_PicksByDamage_MostRecentWinsTies()
|
|
||||||
{
|
|
||||||
var victim = new TestMobile();
|
|
||||||
var a = new TestMobile();
|
|
||||||
var b = new TestMobile();
|
|
||||||
var c = new TestMobile();
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
victim.RegisterDamage(30, a);
|
|
||||||
victim.RegisterDamage(30, b); // ties a; b is more recent
|
|
||||||
victim.RegisterDamage(1, c);
|
|
||||||
|
|
||||||
Assert.Same(b, victim.FindMostTotalDamager(true));
|
|
||||||
Assert.Same(c, victim.FindLeastTotalDamager(true));
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
victim.Delete();
|
|
||||||
a.Delete();
|
|
||||||
b.Delete();
|
|
||||||
c.Delete();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void FindLeastTotal_MostRecentWinsTies()
|
|
||||||
{
|
|
||||||
var victim = new TestMobile();
|
|
||||||
var a = new TestMobile();
|
|
||||||
var b = new TestMobile();
|
|
||||||
var c = new TestMobile();
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
victim.RegisterDamage(30, a);
|
|
||||||
victim.RegisterDamage(5, b);
|
|
||||||
victim.RegisterDamage(5, c); // ties b for the minimum; c is more recent
|
|
||||||
|
|
||||||
Assert.Same(a, victim.FindMostTotalDamager(true));
|
|
||||||
Assert.Same(c, victim.FindLeastTotalDamager(true));
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
victim.Delete();
|
|
||||||
a.Delete();
|
|
||||||
b.Delete();
|
|
||||||
c.Delete();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void Prune_RemovesExpiredPrefix_KeepsOrder()
|
|
||||||
{
|
|
||||||
var start = Core._now;
|
|
||||||
var victim = new TestMobile();
|
|
||||||
var a = new TestMobile();
|
|
||||||
var b = new TestMobile();
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
victim.RegisterDamage(10, a);
|
|
||||||
|
|
||||||
Core._now = start + DamageEntry.ExpireDelay + TimeSpan.FromSeconds(1);
|
|
||||||
victim.RegisterDamage(10, b); // a is now expired, b is live
|
|
||||||
|
|
||||||
Assert.Equal(new[] { b }, Damagers(victim));
|
|
||||||
Assert.Null(victim.FindDamageEntryFor(a));
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
Core._now = start;
|
|
||||||
victim.Delete();
|
|
||||||
a.Delete();
|
|
||||||
b.Delete();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void Prune_AllExpired_EmptiesList()
|
|
||||||
{
|
|
||||||
var start = Core._now;
|
|
||||||
var victim = new TestMobile();
|
|
||||||
var a = new TestMobile();
|
|
||||||
var b = new TestMobile();
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
victim.RegisterDamage(10, a);
|
|
||||||
victim.RegisterDamage(10, b);
|
|
||||||
|
|
||||||
Core._now = start + DamageEntry.ExpireDelay + TimeSpan.FromSeconds(1);
|
|
||||||
|
|
||||||
Assert.Equal(0, victim.DamageEntries.Count);
|
|
||||||
Assert.Null(victim.FindMostRecentDamageEntry(true));
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
Core._now = start;
|
|
||||||
victim.Delete();
|
|
||||||
a.Delete();
|
|
||||||
b.Delete();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void ClearDamageEntries_UnlinksEveryNode()
|
|
||||||
{
|
|
||||||
var victim = new TestMobile();
|
|
||||||
var a = new TestMobile();
|
|
||||||
var b = new TestMobile();
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var ea = victim.RegisterDamage(10, a);
|
|
||||||
var eb = victim.RegisterDamage(10, b);
|
|
||||||
|
|
||||||
victim.ClearDamageEntries();
|
|
||||||
|
|
||||||
Assert.Equal(0, victim.DamageEntries.Count);
|
|
||||||
Assert.False(ea.OnLinkList);
|
|
||||||
Assert.False(eb.OnLinkList);
|
|
||||||
Assert.Null(ea.Next);
|
|
||||||
Assert.Null(ea.Previous);
|
|
||||||
Assert.Null(eb.Next);
|
|
||||||
Assert.Null(eb.Previous);
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
victim.Delete();
|
|
||||||
a.Delete();
|
|
||||||
b.Delete();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void FullHitPoints_ClearsEntries()
|
|
||||||
{
|
|
||||||
var victim = new TestMobile();
|
|
||||||
var a = new TestMobile();
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
victim.RawStr = 50; // HitsMax follows Str for a base Mobile
|
|
||||||
victim.Hits = 10;
|
|
||||||
victim.RegisterDamage(10, a);
|
|
||||||
Assert.Equal(1, victim.DamageEntries.Count);
|
|
||||||
|
|
||||||
// Also stops the HitsTimer the Hits = 10 write started, so the test leaves no timer behind.
|
|
||||||
victim.Hits = victim.HitsMax;
|
|
||||||
|
|
||||||
Assert.Equal(0, victim.DamageEntries.Count);
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
victim.Delete();
|
|
||||||
a.Delete();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void RegisterDamage_AccumulatesResponsibleMaster()
|
|
||||||
{
|
|
||||||
var victim = new TestMobile();
|
|
||||||
var master = new TestMobile();
|
|
||||||
var pet = new PetMobile { Master = master };
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
victim.RegisterDamage(10, pet);
|
|
||||||
var entry = victim.RegisterDamage(5, pet);
|
|
||||||
|
|
||||||
Assert.Same(pet, entry.Damager);
|
|
||||||
Assert.Equal(15, entry.DamageGiven);
|
|
||||||
Assert.NotNull(entry.Responsible);
|
|
||||||
Assert.Single(entry.Responsible);
|
|
||||||
Assert.Same(master, entry.Responsible[0].Damager);
|
|
||||||
Assert.Equal(15, entry.Responsible[0].DamageGiven);
|
|
||||||
Assert.False(entry.Responsible[0].OnLinkList); // sub-entries never join the main list
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
victim.Delete();
|
|
||||||
master.Delete();
|
|
||||||
pet.Delete();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -42,7 +42,7 @@ public delegate void PromptCallback(Mobile from, string text);
|
||||||
|
|
||||||
public delegate void PromptStateCallback<in T>(Mobile from, string text, T state);
|
public delegate void PromptStateCallback<in T>(Mobile from, string text, T state);
|
||||||
|
|
||||||
public class DamageEntry : IValueLinkListNode<DamageEntry>
|
public class DamageEntry
|
||||||
{
|
{
|
||||||
public DamageEntry(Mobile damager) => Damager = damager;
|
public DamageEntry(Mobile damager) => Damager = damager;
|
||||||
|
|
||||||
|
|
@ -57,11 +57,6 @@ public class DamageEntry : IValueLinkListNode<DamageEntry>
|
||||||
public List<DamageEntry> Responsible { get; set; }
|
public List<DamageEntry> Responsible { get; set; }
|
||||||
|
|
||||||
public static TimeSpan ExpireDelay { get; set; } = TimeSpan.FromMinutes(2.0);
|
public static TimeSpan ExpireDelay { get; set; } = TimeSpan.FromMinutes(2.0);
|
||||||
|
|
||||||
// Intrusive links for Mobile._damageEntries. Sub-entries in Responsible never join a list.
|
|
||||||
public DamageEntry Next { get; set; }
|
|
||||||
public DamageEntry Previous { get; set; }
|
|
||||||
public bool OnLinkList { get; set; }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[Flags]
|
[Flags]
|
||||||
|
|
@ -382,6 +377,7 @@ public partial class Mobile : IHued, IComparable<Mobile>, ISpawnable, IObjectPro
|
||||||
Aggressors = new List<AggressorInfo>();
|
Aggressors = new List<AggressorInfo>();
|
||||||
Aggressed = new List<AggressorInfo>();
|
Aggressed = new List<AggressorInfo>();
|
||||||
NextSkillTime = Core.TickCount;
|
NextSkillTime = Core.TickCount;
|
||||||
|
DamageEntries = new List<DamageEntry>();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sectors
|
// Sectors
|
||||||
|
|
@ -962,23 +958,7 @@ public partial class Mobile : IHued, IComparable<Mobile>, ISpawnable, IObjectPro
|
||||||
|
|
||||||
public static VisibleDamageType VisibleDamageType { get; set; }
|
public static VisibleDamageType VisibleDamageType { get; set; }
|
||||||
|
|
||||||
private ValueLinkList<DamageEntry> _damageEntries;
|
public List<DamageEntry> DamageEntries { get; private set; }
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Damage entries ordered least recent (head) to most recent (tail). Expired entries are
|
|
||||||
/// pruned on access. Enumerate with <c>foreach</c> (ascending) or <c>.ByDescending()</c>.
|
|
||||||
/// Mutate only through <see cref="RegisterDamage"/> and <see cref="ClearDamageEntries"/>.
|
|
||||||
/// Calling a ValueLinkList mutator on this reference compiles, but operates on a defensive copy
|
|
||||||
/// while still unlinking the real nodes — it silently corrupts the list.
|
|
||||||
/// </summary>
|
|
||||||
public ref readonly ValueLinkList<DamageEntry> DamageEntries
|
|
||||||
{
|
|
||||||
get
|
|
||||||
{
|
|
||||||
PruneExpiredDamageEntries();
|
|
||||||
return ref _damageEntries;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
[CommandProperty(AccessLevel.GameMaster)]
|
[CommandProperty(AccessLevel.GameMaster)]
|
||||||
public Mobile LastKiller { get; set; }
|
public Mobile LastKiller { get; set; }
|
||||||
|
|
@ -1647,7 +1627,7 @@ public partial class Mobile : IHued, IComparable<Mobile>, ISpawnable, IObjectPro
|
||||||
|
|
||||||
public virtual bool KeepsItemsOnDeath => m_AccessLevel > AccessLevel.Player;
|
public virtual bool KeepsItemsOnDeath => m_AccessLevel > AccessLevel.Player;
|
||||||
|
|
||||||
public bool HasTrade => m_NetState?.Trades?.Count > 0;
|
public bool HasTrade => m_NetState?.Trades.Count > 0;
|
||||||
|
|
||||||
public bool NoMoveHS { get; set; }
|
public bool NoMoveHS { get; set; }
|
||||||
|
|
||||||
|
|
@ -2040,7 +2020,10 @@ public partial class Mobile : IHued, IComparable<Mobile>, ISpawnable, IObjectPro
|
||||||
Aggressors[i].CanReportMurder = false;
|
Aggressors[i].CanReportMurder = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
ClearDamageEntries(); // reset damage entries on full HP
|
if (DamageEntries.Count > 0)
|
||||||
|
{
|
||||||
|
DamageEntries.Clear(); // reset damage entries on full HP
|
||||||
|
}
|
||||||
}
|
}
|
||||||
else if (CanRegenHits)
|
else if (CanRegenHits)
|
||||||
{
|
{
|
||||||
|
|
@ -5762,54 +5745,24 @@ public partial class Mobile : IHued, IComparable<Mobile>, ISpawnable, IObjectPro
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Entries are kept in LastDamage order, so expired entries are always a head prefix.
|
|
||||||
private void PruneExpiredDamageEntries()
|
|
||||||
{
|
|
||||||
#if DEBUG
|
|
||||||
for (var node = _damageEntries._first; node != null; node = node.Next)
|
|
||||||
{
|
|
||||||
Debug.Assert(
|
|
||||||
node.Next == null || node.Next.LastDamage >= node.LastDamage,
|
|
||||||
"Damage entries must be ordered by LastDamage ascending."
|
|
||||||
);
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
|
|
||||||
var first = _damageEntries._first;
|
|
||||||
|
|
||||||
if (first?.HasExpired != true)
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var firstLive = first.Next;
|
|
||||||
|
|
||||||
while (firstLive?.HasExpired == true)
|
|
||||||
{
|
|
||||||
firstLive = firstLive.Next;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (firstLive == null)
|
|
||||||
{
|
|
||||||
_damageEntries.RemoveAll();
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
_damageEntries.RemoveAllBefore(firstLive);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public void ClearDamageEntries() => _damageEntries.RemoveAll();
|
|
||||||
|
|
||||||
public Mobile FindMostRecentDamager(bool allowSelf) => FindMostRecentDamageEntry(allowSelf)?.Damager;
|
public Mobile FindMostRecentDamager(bool allowSelf) => FindMostRecentDamageEntry(allowSelf)?.Damager;
|
||||||
|
|
||||||
public DamageEntry FindMostRecentDamageEntry(bool allowSelf)
|
public DamageEntry FindMostRecentDamageEntry(bool allowSelf)
|
||||||
{
|
{
|
||||||
PruneExpiredDamageEntries();
|
for (var i = DamageEntries.Count - 1; i >= 0; --i)
|
||||||
|
|
||||||
for (var de = _damageEntries._last; de != null; de = de.Previous)
|
|
||||||
{
|
{
|
||||||
if (allowSelf || de.Damager != this)
|
if (i >= DamageEntries.Count)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var de = DamageEntries[i];
|
||||||
|
|
||||||
|
if (de.HasExpired)
|
||||||
|
{
|
||||||
|
DamageEntries.RemoveAt(i);
|
||||||
|
}
|
||||||
|
else if (allowSelf || de.Damager != this)
|
||||||
{
|
{
|
||||||
return de;
|
return de;
|
||||||
}
|
}
|
||||||
|
|
@ -5822,11 +5775,21 @@ public partial class Mobile : IHued, IComparable<Mobile>, ISpawnable, IObjectPro
|
||||||
|
|
||||||
public DamageEntry FindLeastRecentDamageEntry(bool allowSelf)
|
public DamageEntry FindLeastRecentDamageEntry(bool allowSelf)
|
||||||
{
|
{
|
||||||
PruneExpiredDamageEntries();
|
for (var i = 0; i < DamageEntries.Count; ++i)
|
||||||
|
|
||||||
for (var de = _damageEntries._first; de != null; de = de.Next)
|
|
||||||
{
|
{
|
||||||
if (allowSelf || de.Damager != this)
|
if (i < 0)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var de = DamageEntries[i];
|
||||||
|
|
||||||
|
if (de.HasExpired)
|
||||||
|
{
|
||||||
|
DamageEntries.RemoveAt(i);
|
||||||
|
--i;
|
||||||
|
}
|
||||||
|
else if (allowSelf || de.Damager != this)
|
||||||
{
|
{
|
||||||
return de;
|
return de;
|
||||||
}
|
}
|
||||||
|
|
@ -5837,17 +5800,24 @@ public partial class Mobile : IHued, IComparable<Mobile>, ISpawnable, IObjectPro
|
||||||
|
|
||||||
public Mobile FindMostTotalDamager(bool allowSelf) => FindMostTotalDamageEntry(allowSelf)?.Damager;
|
public Mobile FindMostTotalDamager(bool allowSelf) => FindMostTotalDamageEntry(allowSelf)?.Damager;
|
||||||
|
|
||||||
// Walks most recent first with a strict comparison so the most recent entry wins ties,
|
|
||||||
// matching the previous reverse-indexed loop.
|
|
||||||
public DamageEntry FindMostTotalDamageEntry(bool allowSelf)
|
public DamageEntry FindMostTotalDamageEntry(bool allowSelf)
|
||||||
{
|
{
|
||||||
PruneExpiredDamageEntries();
|
|
||||||
|
|
||||||
DamageEntry mostTotal = null;
|
DamageEntry mostTotal = null;
|
||||||
|
|
||||||
for (var de = _damageEntries._last; de != null; de = de.Previous)
|
for (var i = DamageEntries.Count - 1; i >= 0; --i)
|
||||||
{
|
{
|
||||||
if ((allowSelf || de.Damager != this) && (mostTotal == null || de.DamageGiven > mostTotal.DamageGiven))
|
if (i >= DamageEntries.Count)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var de = DamageEntries[i];
|
||||||
|
|
||||||
|
if (de.HasExpired)
|
||||||
|
{
|
||||||
|
DamageEntries.RemoveAt(i);
|
||||||
|
}
|
||||||
|
else if ((allowSelf || de.Damager != this) && (mostTotal == null || de.DamageGiven > mostTotal.DamageGiven))
|
||||||
{
|
{
|
||||||
mostTotal = de;
|
mostTotal = de;
|
||||||
}
|
}
|
||||||
|
|
@ -5860,28 +5830,46 @@ public partial class Mobile : IHued, IComparable<Mobile>, ISpawnable, IObjectPro
|
||||||
|
|
||||||
public DamageEntry FindLeastTotalDamageEntry(bool allowSelf)
|
public DamageEntry FindLeastTotalDamageEntry(bool allowSelf)
|
||||||
{
|
{
|
||||||
PruneExpiredDamageEntries();
|
DamageEntry mostTotal = null;
|
||||||
|
|
||||||
DamageEntry leastTotal = null;
|
for (var i = DamageEntries.Count - 1; i >= 0; --i)
|
||||||
|
|
||||||
for (var de = _damageEntries._last; de != null; de = de.Previous)
|
|
||||||
{
|
{
|
||||||
if ((allowSelf || de.Damager != this) && (leastTotal == null || de.DamageGiven < leastTotal.DamageGiven))
|
if (i >= DamageEntries.Count)
|
||||||
{
|
{
|
||||||
leastTotal = de;
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var de = DamageEntries[i];
|
||||||
|
|
||||||
|
if (de.HasExpired)
|
||||||
|
{
|
||||||
|
DamageEntries.RemoveAt(i);
|
||||||
|
}
|
||||||
|
else if ((allowSelf || de.Damager != this) && (mostTotal == null || de.DamageGiven < mostTotal.DamageGiven))
|
||||||
|
{
|
||||||
|
mostTotal = de;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return leastTotal;
|
return mostTotal;
|
||||||
}
|
}
|
||||||
|
|
||||||
public DamageEntry FindDamageEntryFor(Mobile m)
|
public DamageEntry FindDamageEntryFor(Mobile m)
|
||||||
{
|
{
|
||||||
PruneExpiredDamageEntries();
|
for (var i = DamageEntries.Count - 1; i >= 0; --i)
|
||||||
|
|
||||||
for (var de = _damageEntries._last; de != null; de = de.Previous)
|
|
||||||
{
|
{
|
||||||
if (de.Damager == m)
|
if (i >= DamageEntries.Count)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var de = DamageEntries[i];
|
||||||
|
|
||||||
|
if (de.HasExpired)
|
||||||
|
{
|
||||||
|
DamageEntries.RemoveAt(i);
|
||||||
|
}
|
||||||
|
else if (de.Damager == m)
|
||||||
{
|
{
|
||||||
return de;
|
return de;
|
||||||
}
|
}
|
||||||
|
|
@ -5899,13 +5887,8 @@ public partial class Mobile : IHued, IComparable<Mobile>, ISpawnable, IObjectPro
|
||||||
de.DamageGiven += amount;
|
de.DamageGiven += amount;
|
||||||
de.LastDamage = Core.Now;
|
de.LastDamage = Core.Now;
|
||||||
|
|
||||||
// Move to the tail so the list stays in LastDamage order.
|
DamageEntries.Remove(de);
|
||||||
if (de.OnLinkList)
|
DamageEntries.Add(de);
|
||||||
{
|
|
||||||
_damageEntries.Remove(de);
|
|
||||||
}
|
|
||||||
|
|
||||||
_damageEntries.AddLast(de);
|
|
||||||
|
|
||||||
var master = from.GetDamageMaster(this);
|
var master = from.GetDamageMaster(this);
|
||||||
|
|
||||||
|
|
@ -6495,6 +6478,9 @@ public partial class Mobile : IHued, IComparable<Mobile>, ISpawnable, IObjectPro
|
||||||
m_DexLock = (StatLockType)reader.ReadByte();
|
m_DexLock = (StatLockType)reader.ReadByte();
|
||||||
m_IntLock = (StatLockType)reader.ReadByte();
|
m_IntLock = (StatLockType)reader.ReadByte();
|
||||||
|
|
||||||
|
_statMods = new List<StatMod>();
|
||||||
|
_skillMods = new List<SkillMod>();
|
||||||
|
|
||||||
if (version < 32)
|
if (version < 32)
|
||||||
{
|
{
|
||||||
if (reader.ReadBool())
|
if (reader.ReadBool())
|
||||||
|
|
@ -7827,10 +7813,13 @@ public partial class Mobile : IHued, IComparable<Mobile>, ISpawnable, IObjectPro
|
||||||
m_FollowersMax = 5;
|
m_FollowersMax = 5;
|
||||||
Skills = new Skills(this);
|
Skills = new Skills(this);
|
||||||
Items = new List<Item>();
|
Items = new List<Item>();
|
||||||
|
_statMods = new List<StatMod>();
|
||||||
|
_skillMods = new List<SkillMod>();
|
||||||
Map = Map.Internal;
|
Map = Map.Internal;
|
||||||
AutoPageNotify = true;
|
AutoPageNotify = true;
|
||||||
Aggressors = new List<AggressorInfo>();
|
Aggressors = new List<AggressorInfo>();
|
||||||
Aggressed = new List<AggressorInfo>();
|
Aggressed = new List<AggressorInfo>();
|
||||||
|
DamageEntries = new List<DamageEntry>();
|
||||||
|
|
||||||
NextSkillTime = Core.TickCount;
|
NextSkillTime = Core.TickCount;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -50,6 +50,9 @@ public static class MovementThrottle
|
||||||
private const int ClientMaxUnackedMovements = 5;
|
private const int ClientMaxUnackedMovements = 5;
|
||||||
private const int MaxQueueWithUnmodifiedClient = ClientMaxUnackedMovements - 1; // 4
|
private const int MaxQueueWithUnmodifiedClient = ClientMaxUnackedMovements - 1; // 4
|
||||||
|
|
||||||
|
// Debug logging - enable for testing speed hack detection
|
||||||
|
private static bool _debugLogging = false;
|
||||||
|
|
||||||
// Track NetStates with queued movements for efficient processing
|
// Track NetStates with queued movements for efficient processing
|
||||||
private static readonly HashSet<NetState> _netStatesWithQueuedMovements = new(256);
|
private static readonly HashSet<NetState> _netStatesWithQueuedMovements = new(256);
|
||||||
|
|
||||||
|
|
@ -80,9 +83,15 @@ public static class MovementThrottle
|
||||||
|
|
||||||
public static void Configure()
|
public static void Configure()
|
||||||
{
|
{
|
||||||
_maxCredit = ServerConfiguration.GetOrUpdateSetting("movementThrottle.maxCredit", _maxCredit);
|
_maxCredit = ServerConfiguration.GetOrUpdateSetting(
|
||||||
_maxRttBonus = ServerConfiguration.GetOrUpdateSetting("movementThrottle.maxRttBonus", _maxRttBonus);
|
"movementThrottle.maxCredit",
|
||||||
_hardQueueLimit = ServerConfiguration.GetOrUpdateSetting("movementThrottle.hardQueueLimit", _hardQueueLimit);
|
_maxCredit
|
||||||
|
);
|
||||||
|
|
||||||
|
_hardQueueLimit = ServerConfiguration.GetOrUpdateSetting(
|
||||||
|
"movementThrottle.hardQueueLimit",
|
||||||
|
_hardQueueLimit
|
||||||
|
);
|
||||||
|
|
||||||
_movementHistorySize = ServerConfiguration.GetOrUpdateSetting(
|
_movementHistorySize = ServerConfiguration.GetOrUpdateSetting(
|
||||||
"movementThrottle.movementHistorySize",
|
"movementThrottle.movementHistorySize",
|
||||||
|
|
@ -94,13 +103,6 @@ public static class MovementThrottle
|
||||||
_minSamplesForRate
|
_minSamplesForRate
|
||||||
);
|
);
|
||||||
|
|
||||||
_maxChainGap = ServerConfiguration.GetOrUpdateSetting("movementThrottle.maxChainGap", _maxChainGap);
|
|
||||||
|
|
||||||
_speedHackNotificationCooldown = ServerConfiguration.GetOrUpdateSetting(
|
|
||||||
"movementThrottle.speedHackNotificationCooldown",
|
|
||||||
_speedHackNotificationCooldown
|
|
||||||
);
|
|
||||||
|
|
||||||
_suspiciousRateThreshold = (float)ServerConfiguration.GetOrUpdateSetting(
|
_suspiciousRateThreshold = (float)ServerConfiguration.GetOrUpdateSetting(
|
||||||
"movementThrottle.suspiciousRateThreshold",
|
"movementThrottle.suspiciousRateThreshold",
|
||||||
_suspiciousRateThreshold
|
_suspiciousRateThreshold
|
||||||
|
|
@ -110,6 +112,11 @@ public static class MovementThrottle
|
||||||
"movementThrottle.definiteRateThreshold",
|
"movementThrottle.definiteRateThreshold",
|
||||||
_definiteRateThreshold
|
_definiteRateThreshold
|
||||||
);
|
);
|
||||||
|
|
||||||
|
_debugLogging = ServerConfiguration.GetOrUpdateSetting(
|
||||||
|
"movementThrottle.debugLogging",
|
||||||
|
_debugLogging
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
@ -184,16 +191,15 @@ public static class MovementThrottle
|
||||||
// Credit can go negative up to -dynamicCredit (debt limit)
|
// Credit can go negative up to -dynamicCredit (debt limit)
|
||||||
if (ns._movementCredit - earlyAmount >= -dynamicCredit)
|
if (ns._movementCredit - earlyAmount >= -dynamicCredit)
|
||||||
{
|
{
|
||||||
|
var prevCredit = ns._movementCredit;
|
||||||
// Use credit to cover early arrival
|
// Use credit to cover early arrival
|
||||||
ns._movementCredit -= earlyAmount;
|
ns._movementCredit -= earlyAmount;
|
||||||
|
|
||||||
if (ns._movementLogging)
|
if (_debugLogging && ns._movementLogging)
|
||||||
{
|
{
|
||||||
var prevCredit = ns._movementCredit + earlyAmount;
|
|
||||||
|
|
||||||
logger.Debug(
|
logger.Debug(
|
||||||
"[Credit] {Name}: delta={Delta}ms early={Early}ms credit={PrevCredit}->{Credit}/{MaxCredit} action=execute",
|
"[Credit] {Name}: delta={Delta}ms early={Early}ms credit={PrevCredit}->{Credit}/{MaxCredit} action=execute",
|
||||||
mobile, delta, earlyAmount, prevCredit, ns._movementCredit, dynamicCredit
|
mobile.RawName, delta, earlyAmount, prevCredit, ns._movementCredit, dynamicCredit
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -202,11 +208,11 @@ public static class MovementThrottle
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (ns._movementLogging)
|
if (_debugLogging && ns._movementLogging)
|
||||||
{
|
{
|
||||||
logger.Debug(
|
logger.Debug(
|
||||||
"[Credit] {Name}: delta={Delta}ms early={Early}ms credit={Credit}/{MaxCredit} EXHAUSTED -> queue",
|
"[Credit] {Name}: delta={Delta}ms early={Early}ms credit={Credit}/{MaxCredit} EXHAUSTED -> queue",
|
||||||
mobile, delta, earlyAmount, ns._movementCredit, dynamicCredit
|
mobile.RawName, delta, earlyAmount, ns._movementCredit, dynamicCredit
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -221,11 +227,11 @@ public static class MovementThrottle
|
||||||
var prevCredit = ns._movementCredit;
|
var prevCredit = ns._movementCredit;
|
||||||
ns._movementCredit = Math.Min(ns._movementCredit + delta, dynamicCredit);
|
ns._movementCredit = Math.Min(ns._movementCredit + delta, dynamicCredit);
|
||||||
|
|
||||||
if (ns._movementLogging && ns._movementCredit != prevCredit)
|
if (_debugLogging && ns._movementLogging && ns._movementCredit != prevCredit)
|
||||||
{
|
{
|
||||||
logger.Debug(
|
logger.Debug(
|
||||||
"[Credit] {Name}: delta=+{Delta}ms credit={PrevCredit}->{Credit}/{MaxCredit} action=execute",
|
"[Credit] {Name}: delta=+{Delta}ms credit={PrevCredit}->{Credit}/{MaxCredit} action=execute",
|
||||||
mobile, delta, prevCredit, ns._movementCredit, dynamicCredit
|
mobile.RawName, delta, prevCredit, ns._movementCredit, dynamicCredit
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -241,9 +247,12 @@ public static class MovementThrottle
|
||||||
{
|
{
|
||||||
if (!mobile.Move(dir))
|
if (!mobile.Move(dir))
|
||||||
{
|
{
|
||||||
if (ns._movementLogging)
|
if (_debugLogging && ns._movementLogging)
|
||||||
{
|
{
|
||||||
logger.Debug("[Execute] {Name}: Move FAILED dir={Dir} seq={Seq} -> reject+reset", mobile, dir, seq);
|
logger.Debug(
|
||||||
|
"[Execute] {Name}: Move FAILED dir={Dir} seq={Seq} -> reject+reset",
|
||||||
|
mobile.RawName, dir, seq
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Movement failed (blocked, paralyzed, frozen, etc.)
|
// Movement failed (blocked, paralyzed, frozen, etc.)
|
||||||
|
|
@ -251,11 +260,11 @@ public static class MovementThrottle
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (ns._movementLogging)
|
if (_debugLogging && ns._movementLogging)
|
||||||
{
|
{
|
||||||
logger.Debug(
|
logger.Debug(
|
||||||
"[Execute] {Name}: Move OK dir={Dir} seq={Seq} nextMove={NextMove}ms",
|
"[Execute] {Name}: Move OK dir={Dir} seq={Seq} nextMove={NextMove}ms",
|
||||||
mobile, dir, seq, ns._nextMovementTime - Core.TickCount
|
mobile.RawName, dir, seq, ns._nextMovementTime - Core.TickCount
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -295,11 +304,11 @@ public static class MovementThrottle
|
||||||
ns._hasQueuedMovements = true;
|
ns._hasQueuedMovements = true;
|
||||||
_netStatesWithQueuedMovements.Add(ns);
|
_netStatesWithQueuedMovements.Add(ns);
|
||||||
|
|
||||||
if (ns._movementLogging)
|
if (_debugLogging && ns._movementLogging)
|
||||||
{
|
{
|
||||||
logger.Debug(
|
logger.Debug(
|
||||||
"[Queue] {Name}: enqueued dir={Dir} seq={Seq} (depth={Depth})",
|
"[Queue] {Name}: enqueued dir={Dir} seq={Seq} (depth={Depth})",
|
||||||
ns.Mobile, dir, seq, ns._movementQueue.Count
|
ns.Mobile?.RawName, dir, seq, ns._movementQueue.Count
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -311,6 +320,7 @@ public static class MovementThrottle
|
||||||
{
|
{
|
||||||
ns.SendMovementRej(seq, mobile);
|
ns.SendMovementRej(seq, mobile);
|
||||||
ns.ResetMovementState();
|
ns.ResetMovementState();
|
||||||
|
_netStatesWithQueuedMovements.Remove(ns);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
@ -323,18 +333,20 @@ public static class MovementThrottle
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach (var ns in _netStatesWithQueuedMovements)
|
// Process each NetState with queued movements
|
||||||
|
// Use a snapshot to avoid modification during iteration
|
||||||
|
var toProcess = new List<NetState>(_netStatesWithQueuedMovements);
|
||||||
|
|
||||||
|
for (var i = 0; i < toProcess.Count; i++)
|
||||||
{
|
{
|
||||||
if (ns.Running)
|
var ns = toProcess[i];
|
||||||
|
if (!ns.Running)
|
||||||
{
|
{
|
||||||
ProcessMovementQueue(ns);
|
_netStatesWithQueuedMovements.Remove(ns);
|
||||||
if (ns._hasQueuedMovements)
|
continue;
|
||||||
{
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
_netStatesWithQueuedMovements.Remove(ns);
|
ProcessMovementQueue(ns);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -344,7 +356,6 @@ public static class MovementThrottle
|
||||||
public static void ProcessMovementQueue(NetState ns)
|
public static void ProcessMovementQueue(NetState ns)
|
||||||
{
|
{
|
||||||
var mobile = ns.Mobile;
|
var mobile = ns.Mobile;
|
||||||
|
|
||||||
if (mobile?.Deleted != false)
|
if (mobile?.Deleted != false)
|
||||||
{
|
{
|
||||||
ClearQueue(ns);
|
ClearQueue(ns);
|
||||||
|
|
@ -363,7 +374,7 @@ public static class MovementThrottle
|
||||||
while (ns._movementQueue?.Count > 0)
|
while (ns._movementQueue?.Count > 0)
|
||||||
{
|
{
|
||||||
// Check if it's time to execute
|
// Check if it's time to execute
|
||||||
if (now - ns._nextMovementTime < 0)
|
if (now < ns._nextMovementTime)
|
||||||
{
|
{
|
||||||
// Not yet - leave remaining items in queue for next Slice
|
// Not yet - leave remaining items in queue for next Slice
|
||||||
break;
|
break;
|
||||||
|
|
@ -383,11 +394,11 @@ public static class MovementThrottle
|
||||||
// Execute the move
|
// Execute the move
|
||||||
if (!mobile.Move(movement.Direction))
|
if (!mobile.Move(movement.Direction))
|
||||||
{
|
{
|
||||||
if (ns._movementLogging)
|
if (_debugLogging && ns._movementLogging)
|
||||||
{
|
{
|
||||||
logger.Debug(
|
logger.Debug(
|
||||||
"[Queue] {Name}: dequeued FAILED dir={Dir} (remaining={Remaining})",
|
"[Queue] {Name}: dequeued FAILED dir={Dir} (remaining={Remaining})",
|
||||||
mobile, movement.Direction, remaining
|
mobile.RawName, movement.Direction, remaining
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -396,12 +407,12 @@ public static class MovementThrottle
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (ns._movementLogging)
|
if (_debugLogging && ns._movementLogging)
|
||||||
{
|
{
|
||||||
var waited = now - ns._nextMovementTime;
|
var waited = now - ns._nextMovementTime;
|
||||||
logger.Debug(
|
logger.Debug(
|
||||||
"[Queue] {Name}: dequeued OK dir={Dir} (remaining={Remaining}, waited={Waited}ms)",
|
"[Queue] {Name}: dequeued OK dir={Dir} (remaining={Remaining}, waited={Waited}ms)",
|
||||||
mobile, movement.Direction, remaining, waited >= 0 ? waited : 0
|
mobile.RawName, movement.Direction, remaining, waited >= 0 ? waited : 0
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -419,6 +430,10 @@ public static class MovementThrottle
|
||||||
|
|
||||||
// Update tracking
|
// Update tracking
|
||||||
ns._hasQueuedMovements = ns._movementQueue?.Count > 0;
|
ns._hasQueuedMovements = ns._movementQueue?.Count > 0;
|
||||||
|
if (!ns._hasQueuedMovements)
|
||||||
|
{
|
||||||
|
_netStatesWithQueuedMovements.Remove(ns);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
@ -454,6 +469,7 @@ public static class MovementThrottle
|
||||||
{
|
{
|
||||||
ns._movementQueue?.Clear();
|
ns._movementQueue?.Clear();
|
||||||
ns._hasQueuedMovements = false;
|
ns._hasQueuedMovements = false;
|
||||||
|
_netStatesWithQueuedMovements.Remove(ns);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Maximum expected packets per second (mounted running = 100ms = 10/sec, plus tolerance)
|
// Maximum expected packets per second (mounted running = 100ms = 10/sec, plus tolerance)
|
||||||
|
|
@ -468,7 +484,7 @@ public static class MovementThrottle
|
||||||
logger.Information(
|
logger.Information(
|
||||||
"Movement queue overflow: {Character} ({Account}) | " +
|
"Movement queue overflow: {Character} ({Account}) | " +
|
||||||
"Queue reached hard limit: {Limit} | IP: {IP}",
|
"Queue reached hard limit: {Limit} | IP: {IP}",
|
||||||
mobile,
|
mobile?.RawName ?? "Unknown",
|
||||||
ns.Account?.Username ?? "Unknown",
|
ns.Account?.Username ?? "Unknown",
|
||||||
_hardQueueLimit,
|
_hardQueueLimit,
|
||||||
ns.Address
|
ns.Address
|
||||||
|
|
@ -500,7 +516,7 @@ public static class MovementThrottle
|
||||||
private static void RecordMovement(NetState ns, long now, int cost, Direction dir, Mobile mobile)
|
private static void RecordMovement(NetState ns, long now, int cost, Direction dir, Mobile mobile)
|
||||||
{
|
{
|
||||||
// Calculate interval since last movement
|
// Calculate interval since last movement
|
||||||
var interval = ns._hasMovementRecord
|
var interval = ns._lastMovementRecordTime > 0
|
||||||
? (int)(now - ns._lastMovementRecordTime)
|
? (int)(now - ns._lastMovementRecordTime)
|
||||||
: -1; // -1 indicates first movement (no previous time)
|
: -1; // -1 indicates first movement (no previous time)
|
||||||
|
|
||||||
|
|
@ -509,7 +525,6 @@ public static class MovementThrottle
|
||||||
if (interval <= 0 || interval > _maxChainGap)
|
if (interval <= 0 || interval > _maxChainGap)
|
||||||
{
|
{
|
||||||
ns._lastMovementRecordTime = now;
|
ns._lastMovementRecordTime = now;
|
||||||
ns._hasMovementRecord = true;
|
|
||||||
|
|
||||||
// Use RTT to distinguish "stopped moving" vs "lagged"
|
// Use RTT to distinguish "stopped moving" vs "lagged"
|
||||||
// - Stable low-latency connection with gap >> RTT → player stopped, reset history
|
// - Stable low-latency connection with gap >> RTT → player stopped, reset history
|
||||||
|
|
@ -529,19 +544,19 @@ public static class MovementThrottle
|
||||||
// A large gap followed by a burst of packets = likely lag recovery, not speed hack
|
// A large gap followed by a burst of packets = likely lag recovery, not speed hack
|
||||||
ns._lastGapDuration = interval;
|
ns._lastGapDuration = interval;
|
||||||
|
|
||||||
if (ns._movementLogging)
|
if (_debugLogging && mobile?.RawName != null)
|
||||||
{
|
{
|
||||||
var action = shouldReset ? "history reset" : "history preserved (possible lag)";
|
var action = shouldReset ? "history reset" : "history preserved (possible lag)";
|
||||||
logger.Debug(
|
logger.Debug(
|
||||||
"[Movement] {Name}: SKIP recording (gap {Gap}ms > {MaxGap}ms, " +
|
"[Movement] {Name}: SKIP recording (gap {Gap}ms > {MaxGap}ms, " +
|
||||||
"RTT={RTT}ms stable={Stable} → {Action})",
|
"RTT={RTT}ms stable={Stable} → {Action})",
|
||||||
mobile, interval, _maxChainGap, avgRtt, ns.HasStableConnection, action
|
mobile.RawName, interval, _maxChainGap, avgRtt, ns.HasStableConnection, action
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else if (ns._movementLogging)
|
else if (_debugLogging && mobile?.RawName != null)
|
||||||
{
|
{
|
||||||
logger.Debug("[Movement] {Name}: SKIP recording (first in chain)", mobile);
|
logger.Debug("[Movement] {Name}: SKIP recording (first in chain)", mobile.RawName);
|
||||||
}
|
}
|
||||||
|
|
||||||
return;
|
return;
|
||||||
|
|
@ -557,9 +572,12 @@ public static class MovementThrottle
|
||||||
// the next real move's interval artificially short, inflating rate.
|
// the next real move's interval artificially short, inflating rate.
|
||||||
if (cost == 0)
|
if (cost == 0)
|
||||||
{
|
{
|
||||||
if (ns._movementLogging)
|
if (_debugLogging && mobile?.RawName != null)
|
||||||
{
|
{
|
||||||
logger.Debug("[Movement] {Name}: SKIP direction-only change (preserves interval measurement)", mobile);
|
logger.Debug(
|
||||||
|
"[Movement] {Name}: SKIP direction-only change (preserves interval measurement)",
|
||||||
|
mobile.RawName
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -595,16 +613,15 @@ public static class MovementThrottle
|
||||||
}
|
}
|
||||||
|
|
||||||
ns._lastMovementRecordTime = now;
|
ns._lastMovementRecordTime = now;
|
||||||
ns._hasMovementRecord = true;
|
|
||||||
|
|
||||||
// Debug logging
|
// Debug logging
|
||||||
if (ns._movementLogging)
|
if (_debugLogging && mobile?.RawName != null)
|
||||||
{
|
{
|
||||||
var historyCount = ns._movementHistoryFull ? _movementHistorySize : ns._movementHistoryIndex;
|
var historyCount = ns._movementHistoryFull ? _movementHistorySize : ns._movementHistoryIndex;
|
||||||
logger.Debug(
|
logger.Debug(
|
||||||
"[Movement] {Name}: interval={Interval}ms target={Target}ms queue={Queue} " +
|
"[Movement] {Name}: interval={Interval}ms target={Target}ms queue={Queue} " +
|
||||||
"flags={Flags} history={History}/{MaxHistory} RTT={RTT}ms",
|
"flags={Flags} history={History}/{MaxHistory} RTT={RTT}ms",
|
||||||
mobile, interval, cost, record.QueueDepth,
|
mobile.RawName, interval, cost, record.QueueDepth,
|
||||||
flags, historyCount, _movementHistorySize, ns.AverageRtt
|
flags, historyCount, _movementHistorySize, ns.AverageRtt
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -797,7 +814,7 @@ public static class MovementThrottle
|
||||||
var averageRtt = ns.AverageRtt;
|
var averageRtt = ns.AverageRtt;
|
||||||
|
|
||||||
// Detailed rate breakdown for debugging
|
// Detailed rate breakdown for debugging
|
||||||
if (ns._movementLogging)
|
if (_debugLogging)
|
||||||
{
|
{
|
||||||
logger.Debug("[MovementAnalysis] Rate={Rate:F3}, Samples={Samples}, RTT={RTT}ms",
|
logger.Debug("[MovementAnalysis] Rate={Rate:F3}, Samples={Samples}, RTT={RTT}ms",
|
||||||
rate, sampleCount, averageRtt);
|
rate, sampleCount, averageRtt);
|
||||||
|
|
@ -960,19 +977,19 @@ public static class MovementThrottle
|
||||||
var verdict = AnalyzeMovement(ns, out var rate, out var sampleCount, out var confidence);
|
var verdict = AnalyzeMovement(ns, out var rate, out var sampleCount, out var confidence);
|
||||||
|
|
||||||
// Debug logging
|
// Debug logging
|
||||||
if (ns._movementLogging)
|
if (_debugLogging && ns.Mobile?.RawName != null)
|
||||||
{
|
{
|
||||||
var (burstSize, _) = DetectRecentBurst(ns);
|
var (burstSize, _) = DetectRecentBurst(ns);
|
||||||
var probeStatus = ns._rttProbePending ? "pending" : "idle";
|
var probeStatus = ns._rttProbeTime > 0 ? "pending" : "idle";
|
||||||
var queueDepth = ns._movementQueue?.Count ?? 0;
|
var queueDepth = ns._movementQueue?.Count ?? 0;
|
||||||
logger.Debug(
|
logger.Debug(
|
||||||
"[RateCheck] {Name}: rate={Rate:F3} samples={Samples} verdict={Verdict} " +
|
"[RateCheck] {Name}: rate={Rate:F3} samples={Samples} verdict={Verdict} " +
|
||||||
"confidence={Confidence:P0} queue={Queue} burst={Burst} sustained={Sustained}s",
|
"confidence={Confidence:P0} queue={Queue} burst={Burst} sustained={Sustained}s",
|
||||||
ns.Mobile, rate, sampleCount, verdict, confidence, queueDepth, burstSize, ns._consecutiveHighRateSeconds
|
ns.Mobile.RawName, rate, sampleCount, verdict, confidence, queueDepth, burstSize, ns._consecutiveHighRateSeconds
|
||||||
);
|
);
|
||||||
logger.Debug(
|
logger.Debug(
|
||||||
" RTT: avg={Avg}ms last={Last}ms var={Var} samples={RttSamples} stable={Stable} probe={Probe}",
|
" RTT: avg={Avg}ms last={Last}ms var={Var} samples={RttSamples} stable={Stable} probe={Probe}",
|
||||||
ns.AverageRtt, ns.LastRtt, ns._rttVariance, ns._rttSampleCount, ns.HasStableConnection, probeStatus
|
ns.AverageRtt, ns._lastRtt, ns._rttVariance, ns._rttSampleCount, ns.HasStableConnection, probeStatus
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1008,11 +1025,11 @@ public static class MovementThrottle
|
||||||
|
|
||||||
if (shouldNotify)
|
if (shouldNotify)
|
||||||
{
|
{
|
||||||
if (ns._movementLogging)
|
if (_debugLogging)
|
||||||
{
|
{
|
||||||
logger.Debug(
|
logger.Debug(
|
||||||
"[ALERT] {Urgency} - {Name}: rate={Rate:F3} verdict={Verdict} confidence={Confidence:P0}",
|
"[ALERT] {Urgency} - {Name}: rate={Rate:F3} verdict={Verdict} confidence={Confidence:P0}",
|
||||||
urgency, ns.Mobile, rate, verdict, confidence
|
urgency, ns.Mobile?.RawName, rate, verdict, confidence
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
NotifyStaff(ns, rate, sampleCount, confidence, verdict, urgency);
|
NotifyStaff(ns, rate, sampleCount, confidence, verdict, urgency);
|
||||||
|
|
@ -1037,12 +1054,11 @@ public static class MovementThrottle
|
||||||
var now = Core.TickCount;
|
var now = Core.TickCount;
|
||||||
|
|
||||||
// Rate-limit notifications per player
|
// Rate-limit notifications per player
|
||||||
if (ns._speedHackNotified && now - ns._lastSpeedHackNotification < _speedHackNotificationCooldown)
|
if (now - ns._lastSpeedHackNotification < _speedHackNotificationCooldown)
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
ns._speedHackNotified = true;
|
|
||||||
ns._lastSpeedHackNotification = now;
|
ns._lastSpeedHackNotification = now;
|
||||||
|
|
||||||
var mobile = ns.Mobile;
|
var mobile = ns.Mobile;
|
||||||
|
|
@ -1054,7 +1070,7 @@ public static class MovementThrottle
|
||||||
"PacketRate: {PacketRate}/s (peak: {PeakRate}/s) | RTT: {Rtt}ms (stable: {Stable}) | " +
|
"PacketRate: {PacketRate}/s (peak: {PeakRate}/s) | RTT: {Rtt}ms (stable: {Stable}) | " +
|
||||||
"Sustained: {Sustained}s | Queue: {Queue} | Location: {Location} Map: {Map} | IP: {IP}",
|
"Sustained: {Sustained}s | Queue: {Queue} | Location: {Location} Map: {Map} | IP: {IP}",
|
||||||
urgency,
|
urgency,
|
||||||
mobile,
|
mobile?.RawName ?? "Unknown",
|
||||||
ns.Account?.Username ?? "Unknown",
|
ns.Account?.Username ?? "Unknown",
|
||||||
rate,
|
rate,
|
||||||
sampleCount,
|
sampleCount,
|
||||||
|
|
@ -1122,7 +1138,7 @@ public static class MovementThrottle
|
||||||
Verdict = verdict,
|
Verdict = verdict,
|
||||||
Confidence = confidence,
|
Confidence = confidence,
|
||||||
AverageRtt = ns.AverageRtt,
|
AverageRtt = ns.AverageRtt,
|
||||||
LastRtt = ns.LastRtt,
|
LastRtt = ns._lastRtt,
|
||||||
RttVariance = ns._rttVariance,
|
RttVariance = ns._rttVariance,
|
||||||
StableConnection = ns.HasStableConnection,
|
StableConnection = ns.HasStableConnection,
|
||||||
RttSampleCount = ns._rttSampleCount,
|
RttSampleCount = ns._rttSampleCount,
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@
|
||||||
|
|
||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
|
using System.Diagnostics;
|
||||||
using System.Runtime.InteropServices;
|
using System.Runtime.InteropServices;
|
||||||
using Server.Logging;
|
using Server.Logging;
|
||||||
|
|
||||||
|
|
@ -69,24 +70,23 @@ public partial class NetState
|
||||||
internal Queue<QueuedMovement> _movementQueue; // Lazy initialized
|
internal Queue<QueuedMovement> _movementQueue; // Lazy initialized
|
||||||
internal long _movementCredit; // Credit buffer for timing jitter
|
internal long _movementCredit; // Credit buffer for timing jitter
|
||||||
internal long _nextMovementTime = Core.TickCount; // When next movement is allowed
|
internal long _nextMovementTime = Core.TickCount; // When next movement is allowed
|
||||||
internal long _lastQueueDepthCheck = Core.TickCount; // Throttle depth check frequency
|
internal int _sustainedQueueDepth; // Tracks sustained high queue depth
|
||||||
|
internal long _lastQueueDepthCheck; // Throttle depth check frequency
|
||||||
internal bool _hasQueuedMovements; // Fast check for Slice()
|
internal bool _hasQueuedMovements; // Fast check for Slice()
|
||||||
|
|
||||||
// Movement history for rate-based speed hack detection (lazy initialized)
|
// Movement history for rate-based speed hack detection (lazy initialized)
|
||||||
internal MovementRecord[] _movementHistory; // Circular buffer
|
internal MovementRecord[] _movementHistory; // Circular buffer
|
||||||
internal int _movementHistoryIndex; // Next write position (also serves as count until full)
|
internal int _movementHistoryIndex; // Next write position (also serves as count until full)
|
||||||
internal bool _movementHistoryFull; // True once buffer has wrapped
|
internal bool _movementHistoryFull; // True once buffer has wrapped
|
||||||
internal long _lastMovementRecordTime; // For calculating intervals (valid only when _hasMovementRecord)
|
internal long _lastMovementRecordTime; // For calculating intervals
|
||||||
internal bool _hasMovementRecord; // False until the first movement in a chain is seen
|
|
||||||
|
|
||||||
// Detection state
|
// Detection state
|
||||||
internal int _consecutiveHighRateSeconds; // Sustained detection counter
|
internal int _consecutiveHighRateSeconds; // Sustained detection counter
|
||||||
internal long _lastSpeedHackNotification; // Rate-limit notifications (valid only when _speedHackNotified)
|
internal long _lastSpeedHackNotification; // Rate-limit notifications
|
||||||
internal bool _speedHackNotified; // False until the first notification is sent
|
|
||||||
internal int _lastGapDuration; // Duration of last gap > maxChainGap (for burst forgiveness)
|
internal int _lastGapDuration; // Duration of last gap > maxChainGap (for burst forgiveness)
|
||||||
|
|
||||||
// Movement packet rate tracking (for speed hack detection)
|
// Movement packet rate tracking (for speed hack detection)
|
||||||
internal long _movementWindowStart = Core.TickCount; // Start of current 1-second window
|
internal long _movementWindowStart; // Start of current 1-second window
|
||||||
internal int _movementsInWindow; // Count in current window
|
internal int _movementsInWindow; // Count in current window
|
||||||
internal int _peakMovementRate; // Highest rate seen (packets/sec)
|
internal int _peakMovementRate; // Highest rate seen (packets/sec)
|
||||||
|
|
||||||
|
|
@ -100,9 +100,10 @@ public partial class NetState
|
||||||
_nextMovementTime = Core.TickCount;
|
_nextMovementTime = Core.TickCount;
|
||||||
_movementCredit = 0;
|
_movementCredit = 0;
|
||||||
_hasQueuedMovements = false;
|
_hasQueuedMovements = false;
|
||||||
|
_sustainedQueueDepth = 0;
|
||||||
|
|
||||||
// Reset movement history - next movement starts a new chain
|
// Reset movement history - next movement starts a new chain
|
||||||
_hasMovementRecord = false;
|
_lastMovementRecordTime = 0;
|
||||||
_movementHistoryIndex = 0;
|
_movementHistoryIndex = 0;
|
||||||
_movementHistoryFull = false;
|
_movementHistoryFull = false;
|
||||||
|
|
||||||
|
|
@ -112,7 +113,7 @@ public partial class NetState
|
||||||
_rttProbeInterval = RttProbeIntervalNormal;
|
_rttProbeInterval = RttProbeIntervalNormal;
|
||||||
|
|
||||||
// Reset packet rate window
|
// Reset packet rate window
|
||||||
_movementWindowStart = Core.TickCount;
|
_movementWindowStart = 0;
|
||||||
_movementsInWindow = 0;
|
_movementsInWindow = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -164,19 +165,17 @@ public partial class NetState
|
||||||
private const long MaxStableLatency = 200; // Max RTT (ms) for "stable" connection
|
private const long MaxStableLatency = 200; // Max RTT (ms) for "stable" connection
|
||||||
|
|
||||||
// RTT state
|
// RTT state
|
||||||
internal bool _rttProbePending; // True while waiting for a probe response
|
internal long _rttProbeTime; // When we sent the probe (0 = not waiting)
|
||||||
internal long _rttProbeTime; // When we sent the probe (valid only when _rttProbePending)
|
internal long _lastRtt; // Most recent RTT measurement
|
||||||
internal long[] _rttHistory; // Rolling history (lazy init)
|
internal long[] _rttHistory; // Rolling history (lazy init)
|
||||||
internal int _rttHistoryIndex; // Current position in history
|
internal int _rttHistoryIndex; // Current position in history
|
||||||
internal int _rttSampleCount; // Number of samples collected (saturates at RttHistorySize)
|
internal int _rttSampleCount; // Number of samples collected (saturates at RttHistorySize)
|
||||||
internal long _rttVariance; // Calculated variance for stability
|
internal long _rttVariance; // Calculated variance for stability
|
||||||
internal long _nextRttProbe = Core.TickCount; // When to send next probe
|
internal long _nextRttProbe; // When to send next probe
|
||||||
internal int _rttProbeInterval = RttProbeIntervalNormal; // Current probe interval
|
internal int _rttProbeInterval = RttProbeIntervalNormal; // Current probe interval
|
||||||
|
|
||||||
/// <summary>
|
// High-resolution timestamp for RTT measurement (Stopwatch ticks, not game loop ticks)
|
||||||
/// Gets the most recent RTT measurement, or 0 if none has been recorded.
|
private long _rttProbeTimestampHiRes;
|
||||||
/// </summary>
|
|
||||||
public long LastRtt => _rttSampleCount > 0 ? _rttHistory[(_rttHistoryIndex - 1) & (RttHistorySize - 1)] : 0;
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Sets the RTT probe interval based on suspicion level.
|
/// Sets the RTT probe interval based on suspicion level.
|
||||||
|
|
@ -207,22 +206,23 @@ public partial class NetState
|
||||||
var now = Core.TickCount;
|
var now = Core.TickCount;
|
||||||
|
|
||||||
// Don't send if we're still waiting for a response
|
// Don't send if we're still waiting for a response
|
||||||
if (_rttProbePending)
|
if (_rttProbeTime > 0)
|
||||||
{
|
{
|
||||||
// Timeout after 10 seconds - connection is probably dead or very laggy
|
// Timeout after 10 seconds - connection is probably dead or very laggy
|
||||||
if (now - _rttProbeTime > 10000)
|
if (now - _rttProbeTime > 10000)
|
||||||
{
|
{
|
||||||
_rttProbePending = false;
|
_rttProbeTime = 0;
|
||||||
|
_rttProbeTimestampHiRes = 0;
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// First probe: send immediately when player starts moving
|
// First probe: send immediately when player starts moving
|
||||||
// Subsequent probes: send when interval has passed
|
// Subsequent probes: send when interval has passed
|
||||||
if (now - _nextRttProbe >= 0)
|
if (_nextRttProbe == 0 || now >= _nextRttProbe)
|
||||||
{
|
{
|
||||||
_rttProbePending = true;
|
|
||||||
_rttProbeTime = now;
|
_rttProbeTime = now;
|
||||||
|
_rttProbeTimestampHiRes = Stopwatch.GetTimestamp();
|
||||||
_nextRttProbe = now + _rttProbeInterval + Utility.Random(RttProbeJitter);
|
_nextRttProbe = now + _rttProbeInterval + Utility.Random(RttProbeJitter);
|
||||||
|
|
||||||
if (_movementLogging)
|
if (_movementLogging)
|
||||||
|
|
@ -242,9 +242,10 @@ public partial class NetState
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public void RecordRttMeasurement()
|
public void RecordRttMeasurement()
|
||||||
{
|
{
|
||||||
|
var nowHiRes = Stopwatch.GetTimestamp();
|
||||||
var now = Core.TickCount;
|
var now = Core.TickCount;
|
||||||
|
|
||||||
if (!_rttProbePending)
|
if (_rttProbeTime <= 0)
|
||||||
{
|
{
|
||||||
// Not expecting a response (client-initiated version send) - ignore silently
|
// Not expecting a response (client-initiated version send) - ignore silently
|
||||||
return;
|
return;
|
||||||
|
|
@ -252,15 +253,19 @@ public partial class NetState
|
||||||
|
|
||||||
var rtt = now - _rttProbeTime;
|
var rtt = now - _rttProbeTime;
|
||||||
|
|
||||||
|
// High-resolution RTT in microseconds
|
||||||
|
var rttHiResUs = (nowHiRes - _rttProbeTimestampHiRes) * 1_000_000 / Stopwatch.Frequency;
|
||||||
|
|
||||||
if (_movementLogging)
|
if (_movementLogging)
|
||||||
{
|
{
|
||||||
movementLogger.Debug(
|
movementLogger.Debug(
|
||||||
"[RTT-Response] {Account}: {Rtt}ms",
|
"[RTT-Response] {Account}: {Rtt}ms (HiRes: {RttHiRes:F2}ms)",
|
||||||
Account?.Username ?? _toString, rtt
|
Account?.Username ?? _toString, rtt, rttHiResUs / 1000.0
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
_rttProbePending = false;
|
_rttProbeTime = 0;
|
||||||
|
_rttProbeTimestampHiRes = 0;
|
||||||
|
|
||||||
// Sanity check - RTT should be positive and reasonable
|
// Sanity check - RTT should be positive and reasonable
|
||||||
if (rtt is <= 0 or > 10000)
|
if (rtt is <= 0 or > 10000)
|
||||||
|
|
@ -280,6 +285,7 @@ public partial class NetState
|
||||||
|
|
||||||
// Update history
|
// Update history
|
||||||
_rttHistory[_rttHistoryIndex++ & (RttHistorySize - 1)] = rtt;
|
_rttHistory[_rttHistoryIndex++ & (RttHistorySize - 1)] = rtt;
|
||||||
|
_lastRtt = rtt;
|
||||||
|
|
||||||
// Track sample count (saturates at buffer size)
|
// Track sample count (saturates at buffer size)
|
||||||
if (_rttSampleCount < RttHistorySize)
|
if (_rttSampleCount < RttHistorySize)
|
||||||
|
|
|
||||||
|
|
@ -44,7 +44,7 @@ public partial class NetState : IComparable<NetState>, IValueLinkListNode<NetSta
|
||||||
|
|
||||||
private static readonly Queue<NetState> _connectingQueue = new(2048);
|
private static readonly Queue<NetState> _connectingQueue = new(2048);
|
||||||
private static readonly HashSet<NetState> _instances = new(2048);
|
private static readonly HashSet<NetState> _instances = new(2048);
|
||||||
public static HashSet<NetState> Instances => _instances;
|
public static IReadOnlySet<NetState> Instances => _instances;
|
||||||
|
|
||||||
private readonly string _toString;
|
private readonly string _toString;
|
||||||
private ClientVersion _version;
|
private ClientVersion _version;
|
||||||
|
|
@ -109,6 +109,9 @@ public partial class NetState : IComparable<NetState>, IValueLinkListNode<NetSta
|
||||||
Address = address;
|
Address = address;
|
||||||
|
|
||||||
Seeded = false;
|
Seeded = false;
|
||||||
|
HuePickers = [];
|
||||||
|
Menus = [];
|
||||||
|
Trades = [];
|
||||||
NextActivityCheck = Core.TickCount + 30000;
|
NextActivityCheck = Core.TickCount + 30000;
|
||||||
ConnectedOn = Core.Now;
|
ConnectedOn = Core.Now;
|
||||||
_toString = address?.ToString() ?? "(error)";
|
_toString = address?.ToString() ?? "(error)";
|
||||||
|
|
@ -163,7 +166,7 @@ public partial class NetState : IComparable<NetState>, IValueLinkListNode<NetSta
|
||||||
|
|
||||||
public bool BlockAllPackets { get; set; }
|
public bool BlockAllPackets { get; set; }
|
||||||
|
|
||||||
public List<SecureTrade> Trades { get; private set; }
|
public List<SecureTrade> Trades { get; }
|
||||||
|
|
||||||
public bool Seeded { get; set; }
|
public bool Seeded { get; set; }
|
||||||
|
|
||||||
|
|
@ -257,18 +260,8 @@ public partial class NetState : IComparable<NetState>, IValueLinkListNode<NetSta
|
||||||
|
|
||||||
public void ValidateAllTrades()
|
public void ValidateAllTrades()
|
||||||
{
|
{
|
||||||
if (Trades == null)
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (var i = Trades.Count - 1; i >= 0; --i)
|
for (var i = Trades.Count - 1; i >= 0; --i)
|
||||||
{
|
{
|
||||||
if (Trades == null)
|
|
||||||
{
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (i >= Trades.Count)
|
if (i >= Trades.Count)
|
||||||
{
|
{
|
||||||
continue;
|
continue;
|
||||||
|
|
@ -287,18 +280,8 @@ public partial class NetState : IComparable<NetState>, IValueLinkListNode<NetSta
|
||||||
|
|
||||||
public void CancelAllTrades()
|
public void CancelAllTrades()
|
||||||
{
|
{
|
||||||
if (Trades == null)
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (var i = Trades.Count - 1; i >= 0; --i)
|
for (var i = Trades.Count - 1; i >= 0; --i)
|
||||||
{
|
{
|
||||||
if (Trades != null)
|
|
||||||
{
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (i < Trades.Count)
|
if (i < Trades.Count)
|
||||||
{
|
{
|
||||||
Trades[i].Cancel();
|
Trades[i].Cancel();
|
||||||
|
|
@ -308,21 +291,11 @@ public partial class NetState : IComparable<NetState>, IValueLinkListNode<NetSta
|
||||||
|
|
||||||
public void RemoveTrade(SecureTrade trade)
|
public void RemoveTrade(SecureTrade trade)
|
||||||
{
|
{
|
||||||
Trades?.Remove(trade);
|
Trades.Remove(trade);
|
||||||
|
|
||||||
if (Trades?.Count == 0)
|
|
||||||
{
|
|
||||||
Trades = null;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public SecureTrade FindTrade(Mobile m)
|
public SecureTrade FindTrade(Mobile m)
|
||||||
{
|
{
|
||||||
if (Trades == null)
|
|
||||||
{
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (var i = 0; i < Trades.Count; ++i)
|
for (var i = 0; i < Trades.Count; ++i)
|
||||||
{
|
{
|
||||||
var trade = Trades[i];
|
var trade = Trades[i];
|
||||||
|
|
@ -338,11 +311,6 @@ public partial class NetState : IComparable<NetState>, IValueLinkListNode<NetSta
|
||||||
|
|
||||||
public SecureTradeContainer FindTradeContainer(Mobile m)
|
public SecureTradeContainer FindTradeContainer(Mobile m)
|
||||||
{
|
{
|
||||||
if (Trades == null)
|
|
||||||
{
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (var i = 0; i < Trades.Count; ++i)
|
for (var i = 0; i < Trades.Count; ++i)
|
||||||
{
|
{
|
||||||
var trade = Trades[i];
|
var trade = Trades[i];
|
||||||
|
|
@ -368,11 +336,7 @@ public partial class NetState : IComparable<NetState>, IValueLinkListNode<NetSta
|
||||||
{
|
{
|
||||||
var newTrade = new SecureTrade(Mobile, state.Mobile);
|
var newTrade = new SecureTrade(Mobile, state.Mobile);
|
||||||
|
|
||||||
Trades ??= [];
|
|
||||||
|
|
||||||
Trades.Add(newTrade);
|
Trades.Add(newTrade);
|
||||||
|
|
||||||
state.Trades ??= [];
|
|
||||||
state.Trades.Add(newTrade);
|
state.Trades.Add(newTrade);
|
||||||
|
|
||||||
return newTrade.From.Container;
|
return newTrade.From.Container;
|
||||||
|
|
@ -1212,16 +1176,8 @@ public partial class NetState : IComparable<NetState>, IValueLinkListNode<NetSta
|
||||||
|
|
||||||
var a = Account;
|
var a = Account;
|
||||||
|
|
||||||
Menus?.Clear();
|
Menus.Clear();
|
||||||
Menus = null;
|
HuePickers.Clear();
|
||||||
|
|
||||||
HuePickers?.Clear();
|
|
||||||
HuePickers = null;
|
|
||||||
|
|
||||||
// Just in case, but should already be nulled when Mobile.NetState is set to null and CancelAllTrades is called.
|
|
||||||
Trades?.Clear();
|
|
||||||
Trades = null;
|
|
||||||
|
|
||||||
Account = null;
|
Account = null;
|
||||||
ServerInfo = null;
|
ServerInfo = null;
|
||||||
CityInfo = null;
|
CityInfo = null;
|
||||||
|
|
|
||||||
|
|
@ -103,7 +103,6 @@ internal static class TestServerInitializer
|
||||||
// Registers the Accounts entity persistence; without it no test can construct an Account.
|
// Registers the Accounts entity persistence; without it no test can construct an Account.
|
||||||
Server.Accounting.Accounts.Configure();
|
Server.Accounting.Accounts.Configure();
|
||||||
RaceDefinitions.Configure();
|
RaceDefinitions.Configure();
|
||||||
Server.Movement.Movement.Configure();
|
|
||||||
MovementImpl.Configure();
|
MovementImpl.Configure();
|
||||||
PathFollower.Configure();
|
PathFollower.Configure();
|
||||||
World.Load();
|
World.Load();
|
||||||
|
|
|
||||||
|
|
@ -1,211 +0,0 @@
|
||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using Server;
|
|
||||||
using Server.Mobiles;
|
|
||||||
using Xunit;
|
|
||||||
|
|
||||||
namespace UOContent.Tests.Mobiles.AI;
|
|
||||||
|
|
||||||
// Pins the reacquire gate and the AcquireOnApproachDelay gradient: every scan re-arms the
|
|
||||||
// full ReacquireDelay; enemy movement clamps the deadline to the approach delay (Zero =
|
|
||||||
// prodded scan); an illegal deadline self-heals.
|
|
||||||
[Collection("Sequential Pathfinding Tests")]
|
|
||||||
public class AcquisitionTests : IDisposable
|
|
||||||
{
|
|
||||||
private readonly List<Mobile> _created = new();
|
|
||||||
|
|
||||||
public void Dispose()
|
|
||||||
{
|
|
||||||
foreach (var m in _created)
|
|
||||||
{
|
|
||||||
m?.Delete();
|
|
||||||
}
|
|
||||||
|
|
||||||
_created.Clear();
|
|
||||||
}
|
|
||||||
|
|
||||||
private sealed class WildStub : BaseCreature
|
|
||||||
{
|
|
||||||
public WildStub() : base(AIType.AI_Melee, FightMode.Closest, 16, 1) => Body = 0xC9;
|
|
||||||
|
|
||||||
public override void GetSpeeds(out double activeSpeed, out double passiveSpeed)
|
|
||||||
{
|
|
||||||
activeSpeed = 0.3;
|
|
||||||
passiveSpeed = 0.6;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private sealed class TargetStub : Mobile
|
|
||||||
{
|
|
||||||
public TargetStub() => Body = 0x190;
|
|
||||||
}
|
|
||||||
|
|
||||||
private WildStub Spawn(Map map, Point3D loc)
|
|
||||||
{
|
|
||||||
var bc = new WildStub();
|
|
||||||
bc.MoveToWorld(loc, map);
|
|
||||||
bc.AIObject.AITimer?.Stop();
|
|
||||||
_created.Add(bc);
|
|
||||||
return bc;
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void EmptyScan_HonorsReacquireDelay()
|
|
||||||
{
|
|
||||||
var map = Map.Maps[1];
|
|
||||||
Assert.NotNull(map);
|
|
||||||
map.GetAverageZ(1500, 1600, out _, out var z, out _);
|
|
||||||
|
|
||||||
var bc = Spawn(map, new Point3D(1500, 1600, (sbyte)z));
|
|
||||||
bc.NextReacquireTime = Core.TickCount;
|
|
||||||
|
|
||||||
Assert.False(bc.AIObject.AcquireFocusMob(bc.RangePerception, FightMode.Closest, false, false, true));
|
|
||||||
Assert.InRange(bc.NextReacquireTime - Core.TickCount, 5000, 10000);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void WedgedGate_SelfHeals()
|
|
||||||
{
|
|
||||||
var map = Map.Maps[1];
|
|
||||||
Assert.NotNull(map);
|
|
||||||
map.GetAverageZ(1500, 1600, out _, out var z, out _);
|
|
||||||
|
|
||||||
var bc = Spawn(map, new Point3D(1500, 1600, (sbyte)z));
|
|
||||||
|
|
||||||
var target = new TargetStub();
|
|
||||||
target.DefaultMobileInit();
|
|
||||||
target.MoveToWorld(new Point3D(1497, 1600, (sbyte)z), map);
|
|
||||||
_created.Add(target);
|
|
||||||
|
|
||||||
// Illegal deadline (beyond ReacquireDelay): must read as open, not block forever.
|
|
||||||
bc.NextReacquireTime = Core.TickCount + 60000;
|
|
||||||
|
|
||||||
Assert.True(bc.AIObject.AcquireFocusMob(bc.RangePerception, FightMode.Closest, false, false, true));
|
|
||||||
Assert.Equal(target, bc.FocusMob);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Theory]
|
|
||||||
[InlineData(false, 5, true)] // an enemy moving inside approach range (10) clamps the deadline
|
|
||||||
[InlineData(true, 5, false)] // a same-team wild creature is not an enemy — ignored
|
|
||||||
[InlineData(false, 12, false)] // inside RangePerception but outside approach range — poll only
|
|
||||||
[InlineData(false, 20, false)] // outside approach range (10) is ignored
|
|
||||||
public void MovementClampsScanDeadlineOnlyForEnemiesInRange(bool wildMover, int distance, bool notices)
|
|
||||||
{
|
|
||||||
var map = Map.Maps[1];
|
|
||||||
Assert.NotNull(map);
|
|
||||||
map.GetAverageZ(1500, 1600, out _, out var z, out _);
|
|
||||||
|
|
||||||
var bc = Spawn(map, new Point3D(1500, 1600, (sbyte)z));
|
|
||||||
bc.NextReacquireTime = Core.TickCount + 8000;
|
|
||||||
|
|
||||||
Mobile mover;
|
|
||||||
if (wildMover)
|
|
||||||
{
|
|
||||||
mover = Spawn(map, new Point3D(1500 - distance, 1600, (sbyte)z));
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
mover = new TargetStub { Player = true };
|
|
||||||
mover.DefaultMobileInit();
|
|
||||||
mover.MoveToWorld(new Point3D(1500 - distance, 1600, (sbyte)z), map);
|
|
||||||
_created.Add(mover);
|
|
||||||
}
|
|
||||||
|
|
||||||
bc.OnMovement(mover, new Point3D(1400, 1600, (sbyte)z));
|
|
||||||
|
|
||||||
var remaining = bc.NextReacquireTime - Core.TickCount;
|
|
||||||
|
|
||||||
if (notices)
|
|
||||||
{
|
|
||||||
// Clamped to the approach delay (2s), never opened outright.
|
|
||||||
Assert.InRange(remaining, 1, (long)bc.AcquireOnApproachDelay.TotalMilliseconds);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
Assert.True(remaining > 5000);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private sealed class InstantStub : BaseCreature
|
|
||||||
{
|
|
||||||
public InstantStub() : base(AIType.AI_Melee, FightMode.Closest, 16, 1) => Body = 0xC9;
|
|
||||||
|
|
||||||
public override TimeSpan AcquireOnApproachDelay => TimeSpan.Zero;
|
|
||||||
|
|
||||||
public override void GetSpeeds(out double activeSpeed, out double passiveSpeed)
|
|
||||||
{
|
|
||||||
activeSpeed = 0.3;
|
|
||||||
passiveSpeed = 0.6;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void ZeroApproachDelay_OpensGateImmediately()
|
|
||||||
{
|
|
||||||
var map = Map.Maps[1];
|
|
||||||
Assert.NotNull(map);
|
|
||||||
map.GetAverageZ(1500, 1600, out _, out var z, out _);
|
|
||||||
|
|
||||||
var bc = new InstantStub();
|
|
||||||
bc.MoveToWorld(new Point3D(1500, 1600, (sbyte)z), map);
|
|
||||||
bc.AIObject.AITimer?.Stop();
|
|
||||||
_created.Add(bc);
|
|
||||||
bc.NextReacquireTime = Core.TickCount + 8000;
|
|
||||||
|
|
||||||
var mover = new TargetStub { Player = true };
|
|
||||||
mover.DefaultMobileInit();
|
|
||||||
mover.MoveToWorld(new Point3D(1495, 1600, (sbyte)z), map);
|
|
||||||
_created.Add(mover);
|
|
||||||
|
|
||||||
bc.OnMovement(mover, new Point3D(1400, 1600, (sbyte)z));
|
|
||||||
|
|
||||||
// Zero = the gate opens and the AI is prodded to think now; no direct engage.
|
|
||||||
Assert.True(Core.TickCount - bc.NextReacquireTime >= 0);
|
|
||||||
Assert.Null(bc.Combatant);
|
|
||||||
Assert.True(bc.AIObject.AITimer.Running);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void RepeatedMovement_DoesNotShortenBelowApproachDelay()
|
|
||||||
{
|
|
||||||
var map = Map.Maps[1];
|
|
||||||
Assert.NotNull(map);
|
|
||||||
map.GetAverageZ(1500, 1600, out _, out var z, out _);
|
|
||||||
|
|
||||||
var bc = Spawn(map, new Point3D(1500, 1600, (sbyte)z));
|
|
||||||
bc.NextReacquireTime = Core.TickCount + 8000;
|
|
||||||
|
|
||||||
var mover = new TargetStub { Player = true };
|
|
||||||
mover.DefaultMobileInit();
|
|
||||||
mover.MoveToWorld(new Point3D(1495, 1600, (sbyte)z), map);
|
|
||||||
_created.Add(mover);
|
|
||||||
|
|
||||||
bc.OnMovement(mover, new Point3D(1400, 1600, (sbyte)z));
|
|
||||||
var afterFirst = bc.NextReacquireTime;
|
|
||||||
|
|
||||||
bc.OnMovement(mover, new Point3D(1496, 1600, (sbyte)z));
|
|
||||||
|
|
||||||
Assert.Equal(afterFirst, bc.NextReacquireTime);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void SuccessfulAcquire_HoldsFullDelay()
|
|
||||||
{
|
|
||||||
var map = Map.Maps[1];
|
|
||||||
Assert.NotNull(map);
|
|
||||||
map.GetAverageZ(1500, 1600, out _, out var z, out _);
|
|
||||||
|
|
||||||
var bc = Spawn(map, new Point3D(1500, 1600, (sbyte)z));
|
|
||||||
|
|
||||||
var target = new TargetStub();
|
|
||||||
target.DefaultMobileInit();
|
|
||||||
target.MoveToWorld(new Point3D(1497, 1600, (sbyte)z), map);
|
|
||||||
_created.Add(target);
|
|
||||||
|
|
||||||
bc.NextReacquireTime = Core.TickCount;
|
|
||||||
|
|
||||||
Assert.True(bc.AIObject.AcquireFocusMob(bc.RangePerception, FightMode.Closest, false, false, true));
|
|
||||||
Assert.Equal(target, bc.FocusMob);
|
|
||||||
Assert.True(bc.NextReacquireTime - Core.TickCount > 5000);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -40,7 +40,7 @@ public class ApproachTargetTests
|
||||||
for (var i = 0; i < maxTicks; i++)
|
for (var i = 0; i < maxTicks; i++)
|
||||||
{
|
{
|
||||||
ai.NextMove = 0;
|
ai.NextMove = 0;
|
||||||
ai.WalkMobileRange(target, 1, 1, 2);
|
ai.WalkMobileRange(target, 1, false, 1, 2);
|
||||||
if (bc.InRange(target, arriveDist))
|
if (bc.InRange(target, arriveDist))
|
||||||
{
|
{
|
||||||
return true;
|
return true;
|
||||||
|
|
@ -123,7 +123,7 @@ public class ApproachTargetTests
|
||||||
for (var i = 0; i < 200; i++)
|
for (var i = 0; i < 200; i++)
|
||||||
{
|
{
|
||||||
ai.NextMove = 0;
|
ai.NextMove = 0;
|
||||||
ai.MoveTo(target, 1);
|
ai.MoveTo(target, false, 1);
|
||||||
if (bc.InRange(target, 1))
|
if (bc.InRange(target, 1))
|
||||||
{
|
{
|
||||||
arrived = true;
|
arrived = true;
|
||||||
|
|
@ -154,7 +154,7 @@ public class ApproachTargetTests
|
||||||
for (var i = 0; i < 60; i++)
|
for (var i = 0; i < 60; i++)
|
||||||
{
|
{
|
||||||
ai.NextMove = 0;
|
ai.NextMove = 0;
|
||||||
ai.MoveTo(target, 1);
|
ai.MoveTo(target, true, 1);
|
||||||
|
|
||||||
// Target walks west every other tick for its first several steps, then stops,
|
// Target walks west every other tick for its first several steps, then stops,
|
||||||
// so a same-speed chaser eventually closes the gap.
|
// so a same-speed chaser eventually closes the gap.
|
||||||
|
|
@ -214,7 +214,7 @@ public class ApproachTargetTests
|
||||||
for (var i = 0; i < 120; i++)
|
for (var i = 0; i < 120; i++)
|
||||||
{
|
{
|
||||||
ai.NextMove = 0;
|
ai.NextMove = 0;
|
||||||
ai.MoveTo(target, 1);
|
ai.MoveTo(target, false, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
// After giving up, the creature must idle (not oscillate) while the goal is still.
|
// After giving up, the creature must idle (not oscillate) while the goal is still.
|
||||||
|
|
@ -223,7 +223,7 @@ public class ApproachTargetTests
|
||||||
for (var i = 0; i < 20; i++)
|
for (var i = 0; i < 20; i++)
|
||||||
{
|
{
|
||||||
ai.NextMove = 0;
|
ai.NextMove = 0;
|
||||||
ai.MoveTo(target, 1);
|
ai.MoveTo(target, false, 1);
|
||||||
if (bc.Location != idleStart)
|
if (bc.Location != idleStart)
|
||||||
{
|
{
|
||||||
stayedIdle = false;
|
stayedIdle = false;
|
||||||
|
|
|
||||||
|
|
@ -1,96 +0,0 @@
|
||||||
using System.Collections.Generic;
|
|
||||||
using Server;
|
|
||||||
using Server.Mobiles;
|
|
||||||
using Xunit;
|
|
||||||
|
|
||||||
namespace UOContent.Tests.Mobiles.AI;
|
|
||||||
|
|
||||||
// Guard-following may pathfind, so this shares the pathfinding collection.
|
|
||||||
[Collection("Sequential Pathfinding Tests")]
|
|
||||||
public class GuardFollowTests
|
|
||||||
{
|
|
||||||
[Fact]
|
|
||||||
public void GuardFollow_StepsTowardMaster_AndRegistersMoveIntent()
|
|
||||||
{
|
|
||||||
var map = Map.Maps[1];
|
|
||||||
Assert.NotNull(map);
|
|
||||||
map.GetAverageZ(1500, 1600, out _, out var z, out _);
|
|
||||||
|
|
||||||
var master = new PlayerMobile(World.NewMobile);
|
|
||||||
master.DefaultMobileInit();
|
|
||||||
master.MoveToWorld(new Point3D(1494, 1600, (sbyte)z), map);
|
|
||||||
|
|
||||||
var pet = new PetTestStub();
|
|
||||||
pet.MoveToWorld(new Point3D(1500, 1600, (sbyte)z), map); // 6 tiles east, open terrain
|
|
||||||
pet.SetControlMaster(master);
|
|
||||||
|
|
||||||
var ai = pet.AIObject;
|
|
||||||
ai.AITimer?.Stop(); // drive manually
|
|
||||||
pet.ControlOrder = OrderType.Guard;
|
|
||||||
ai.AITimer?.Stop(); // the order change may restart the timer
|
|
||||||
|
|
||||||
var start = pet.Location;
|
|
||||||
ai.NextMove = 0;
|
|
||||||
ai.Obey();
|
|
||||||
|
|
||||||
var moved = pet.Location != start;
|
|
||||||
var hasIntent = ai.TryGetMoveWake(out _);
|
|
||||||
var currentSpeed = pet.CurrentSpeed;
|
|
||||||
var currentMoveSpeed = pet.CurrentMoveSpeed;
|
|
||||||
|
|
||||||
pet.Delete();
|
|
||||||
master.Delete();
|
|
||||||
|
|
||||||
Assert.True(moved, "a guarding pet beyond guard range must step toward its master");
|
|
||||||
// Without a move intent, guard-following only steps on the think grid.
|
|
||||||
Assert.True(hasIntent, "guard-following must register a move intent");
|
|
||||||
|
|
||||||
// AOS return sprint on both clocks; the per-step speed flip must not undo it.
|
|
||||||
Assert.Equal(0.1, currentSpeed);
|
|
||||||
Assert.Equal(0.1, currentMoveSpeed);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void GuardReturn_PreAOS_RunsActive()
|
|
||||||
{
|
|
||||||
var previous = Core.Expansion;
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
Core.Expansion = Expansion.UOR;
|
|
||||||
|
|
||||||
var map = Map.Maps[1];
|
|
||||||
Assert.NotNull(map);
|
|
||||||
map.GetAverageZ(1500, 1600, out _, out var z, out _);
|
|
||||||
|
|
||||||
var master = new PlayerMobile(World.NewMobile);
|
|
||||||
master.DefaultMobileInit();
|
|
||||||
master.MoveToWorld(new Point3D(1494, 1600, (sbyte)z), map);
|
|
||||||
|
|
||||||
var pet = new PetTestStub();
|
|
||||||
pet.MoveToWorld(new Point3D(1500, 1600, (sbyte)z), map);
|
|
||||||
pet.SetControlMaster(master);
|
|
||||||
|
|
||||||
var ai = pet.AIObject;
|
|
||||||
ai.AITimer?.Stop();
|
|
||||||
pet.ControlOrder = OrderType.Guard;
|
|
||||||
ai.AITimer?.Stop();
|
|
||||||
pet.SetCurrentSpeedToPassive(); // a stale passive state must not persist
|
|
||||||
|
|
||||||
ai.NextMove = 0;
|
|
||||||
ai.Obey();
|
|
||||||
|
|
||||||
var currentSpeed = pet.CurrentSpeed;
|
|
||||||
|
|
||||||
pet.Delete();
|
|
||||||
master.Delete();
|
|
||||||
|
|
||||||
// No sprint pre-AOS: the return runs active.
|
|
||||||
Assert.Equal(0.2, currentSpeed);
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
Core.Expansion = previous;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,137 +0,0 @@
|
||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using Server;
|
|
||||||
using Server.Mobiles;
|
|
||||||
using Xunit;
|
|
||||||
|
|
||||||
namespace UOContent.Tests.Mobiles.AI;
|
|
||||||
|
|
||||||
// A guarding pet fights without leaving the Guard order, retargets toward the master's
|
|
||||||
// closest aggressor, and stands down when nothing threatens. Scene: the open
|
|
||||||
// (1495..1500, 1600) Trammel segment; targets are adjacent so no pathfinding runs.
|
|
||||||
[Collection("Sequential UOContent Tests")]
|
|
||||||
public class GuardOrderTests : IDisposable
|
|
||||||
{
|
|
||||||
private readonly List<Mobile> _created = new();
|
|
||||||
|
|
||||||
private sealed class AggressorStub : Mobile
|
|
||||||
{
|
|
||||||
public AggressorStub() => Body = 0xC9;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void Dispose()
|
|
||||||
{
|
|
||||||
foreach (var m in _created)
|
|
||||||
{
|
|
||||||
m?.Delete();
|
|
||||||
}
|
|
||||||
|
|
||||||
_created.Clear();
|
|
||||||
}
|
|
||||||
|
|
||||||
private (PlayerMobile master, PetTestStub pet) SpawnGuardingPet(out Map map, out int z)
|
|
||||||
{
|
|
||||||
map = Map.Maps[1];
|
|
||||||
Assert.NotNull(map);
|
|
||||||
map.GetAverageZ(1500, 1600, out _, out z, out _);
|
|
||||||
|
|
||||||
var master = new PlayerMobile(World.NewMobile);
|
|
||||||
master.DefaultMobileInit();
|
|
||||||
master.MoveToWorld(new Point3D(1500, 1600, (sbyte)z), map);
|
|
||||||
_created.Add(master);
|
|
||||||
|
|
||||||
var pet = new PetTestStub();
|
|
||||||
pet.MoveToWorld(new Point3D(1499, 1600, (sbyte)z), map);
|
|
||||||
pet.SetControlMaster(master);
|
|
||||||
_created.Add(pet);
|
|
||||||
|
|
||||||
pet.AIObject.AITimer?.Stop(); // drive manually
|
|
||||||
pet.ControlOrder = OrderType.Guard;
|
|
||||||
pet.AIObject.AITimer?.Stop(); // the order change restarts the timer
|
|
||||||
|
|
||||||
return (master, pet);
|
|
||||||
}
|
|
||||||
|
|
||||||
private AggressorStub SpawnAggressor(PetTestStub pet, Point3D loc, Mobile attacking)
|
|
||||||
{
|
|
||||||
var aggr = new AggressorStub();
|
|
||||||
aggr.MoveToWorld(loc, pet.Map);
|
|
||||||
_created.Add(aggr);
|
|
||||||
|
|
||||||
// Setup guard: the scene must stay LOS-clear and the combatant must not be vetoed.
|
|
||||||
Assert.True(pet.InLOS(aggr), $"no LOS from pet to aggressor at {loc}");
|
|
||||||
|
|
||||||
if (attacking != null)
|
|
||||||
{
|
|
||||||
aggr.Combatant = attacking;
|
|
||||||
Assert.Same(attacking, aggr.Combatant);
|
|
||||||
}
|
|
||||||
|
|
||||||
return aggr;
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void GuardEngage_KeepsGuardOrder()
|
|
||||||
{
|
|
||||||
var (master, pet) = SpawnGuardingPet(out _, out var z);
|
|
||||||
var aggr = SpawnAggressor(pet, new Point3D(1498, 1600, (sbyte)z), master);
|
|
||||||
|
|
||||||
pet.AIObject.Obey();
|
|
||||||
|
|
||||||
Assert.Same(aggr, pet.Combatant);
|
|
||||||
Assert.Equal(OrderType.Guard, pet.ControlOrder);
|
|
||||||
Assert.Equal(OrderType.Guard, pet.AIObject.PersistentOrder);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void Guard_RetargetsToAggressorClosestToMaster()
|
|
||||||
{
|
|
||||||
var (master, pet) = SpawnGuardingPet(out _, out var z);
|
|
||||||
var far = SpawnAggressor(pet, new Point3D(1495, 1600, (sbyte)z), master);
|
|
||||||
var near = SpawnAggressor(pet, new Point3D(1498, 1600, (sbyte)z), master);
|
|
||||||
|
|
||||||
pet.Combatant = far; // already fighting the far aggressor
|
|
||||||
|
|
||||||
pet.AIObject.Obey();
|
|
||||||
|
|
||||||
Assert.Same(near, pet.Combatant); // defends the master, not the current fight
|
|
||||||
Assert.Equal(OrderType.Guard, pet.ControlOrder);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void ExplicitAttack_ResumesGuard_WithoutChainingIntoAttack()
|
|
||||||
{
|
|
||||||
var (master, pet) = SpawnGuardingPet(out _, out var z);
|
|
||||||
|
|
||||||
// Explicit kill order on a target that then becomes invalid.
|
|
||||||
var victim = SpawnAggressor(pet, new Point3D(1498, 1600, (sbyte)z), null);
|
|
||||||
pet.ControlTarget = victim;
|
|
||||||
pet.ControlOrder = OrderType.Attack;
|
|
||||||
victim.Hidden = true;
|
|
||||||
|
|
||||||
// A second aggressor is still after the master; FightMode.Closest would chain it.
|
|
||||||
var aggr2 = SpawnAggressor(pet, new Point3D(1497, 1600, (sbyte)z), master);
|
|
||||||
|
|
||||||
pet.AIObject.Obey(); // attack completes -> resume the persistent Guard
|
|
||||||
|
|
||||||
Assert.Equal(OrderType.Guard, pet.ControlOrder);
|
|
||||||
|
|
||||||
pet.AIObject.Obey(); // the guard scan engages the remaining aggressor in-order
|
|
||||||
|
|
||||||
Assert.Same(aggr2, pet.Combatant);
|
|
||||||
Assert.Equal(OrderType.Guard, pet.ControlOrder);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void PeacefulGuard_StandsDown()
|
|
||||||
{
|
|
||||||
var (_, pet) = SpawnGuardingPet(out _, out _);
|
|
||||||
Assert.True(pet.Warmode); // the guard order opens in war stance
|
|
||||||
|
|
||||||
pet.AIObject.Obey(); // nothing to guard against
|
|
||||||
|
|
||||||
Assert.False(pet.Warmode);
|
|
||||||
Assert.Null(pet.Combatant);
|
|
||||||
Assert.Null(pet.FocusMob);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -57,8 +57,9 @@ public class MoveSpeedTests : IDisposable
|
||||||
{
|
{
|
||||||
var bc = NewCreature();
|
var bc = NewCreature();
|
||||||
|
|
||||||
Assert.Equal(0.3, bc.ActiveMoveSpeed);
|
// 0 = no override; the resolved pace comes from CurrentMoveSpeed.
|
||||||
Assert.Equal(0.6, bc.PassiveMoveSpeed);
|
Assert.Equal(0, bc.ActiveMoveSpeed);
|
||||||
|
Assert.Equal(0, bc.PassiveMoveSpeed);
|
||||||
Assert.Equal(bc.CurrentSpeed, bc.CurrentMoveSpeed);
|
Assert.Equal(bc.CurrentSpeed, bc.CurrentMoveSpeed);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -96,8 +97,8 @@ public class MoveSpeedTests : IDisposable
|
||||||
|
|
||||||
bc.SetSpeed(0.2, 0.4);
|
bc.SetSpeed(0.2, 0.4);
|
||||||
|
|
||||||
Assert.Equal(0.2, bc.ActiveMoveSpeed);
|
Assert.Equal(0, bc.ActiveMoveSpeed);
|
||||||
Assert.Equal(0.4, bc.PassiveMoveSpeed);
|
Assert.Equal(0, bc.PassiveMoveSpeed);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|
@ -108,8 +109,10 @@ public class MoveSpeedTests : IDisposable
|
||||||
|
|
||||||
bc.ActiveMoveSpeed = 0;
|
bc.ActiveMoveSpeed = 0;
|
||||||
|
|
||||||
Assert.Equal(0.3, bc.ActiveMoveSpeed); // inheriting again
|
Assert.Equal(0, bc.ActiveMoveSpeed); // inheriting again
|
||||||
Assert.Equal(0.9, bc.PassiveMoveSpeed); // other override untouched
|
Assert.Equal(0.9, bc.PassiveMoveSpeed); // other override untouched
|
||||||
|
bc.SetCurrentSpeedToActive();
|
||||||
|
Assert.Equal(0.3, bc.CurrentMoveSpeed); // resolves to the think clock
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|
@ -121,7 +124,7 @@ public class MoveSpeedTests : IDisposable
|
||||||
bc.ScaleMoveSpeed(1.0 / 1.2);
|
bc.ScaleMoveSpeed(1.0 / 1.2);
|
||||||
|
|
||||||
Assert.Equal(0.5, bc.ActiveMoveSpeed);
|
Assert.Equal(0.5, bc.ActiveMoveSpeed);
|
||||||
Assert.Equal(bc.PassiveSpeed, bc.PassiveMoveSpeed); // still inheriting, not 0 * scalar
|
Assert.Equal(0, bc.PassiveMoveSpeed); // still inheriting, not 0 * scalar
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|
@ -185,8 +188,8 @@ public class MoveSpeedTests : IDisposable
|
||||||
|
|
||||||
bc.MigrateMoveSpeeds();
|
bc.MigrateMoveSpeeds();
|
||||||
|
|
||||||
Assert.Equal(0.35, bc.ActiveMoveSpeed);
|
Assert.Equal(0, bc.ActiveMoveSpeed); // still inheriting the (tuned) think clock
|
||||||
Assert.Equal(0.6, bc.PassiveMoveSpeed);
|
Assert.Equal(0, bc.PassiveMoveSpeed);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Theory]
|
[Theory]
|
||||||
|
|
@ -213,7 +216,7 @@ public class MoveSpeedTests : IDisposable
|
||||||
|
|
||||||
// The v22 tail is the last block; exact consumption catches any offset mistake.
|
// The v22 tail is the last block; exact consumption catches any offset mistake.
|
||||||
Assert.Equal(buffer.Length, reader.Position);
|
Assert.Equal(buffer.Length, reader.Position);
|
||||||
Assert.Equal(overridden ? 0.45 : 0.3, copy.ActiveMoveSpeed);
|
Assert.Equal(overridden ? 0.45 : 0, copy.ActiveMoveSpeed);
|
||||||
Assert.Equal(overridden ? 0.9 : 0.6, copy.PassiveMoveSpeed);
|
Assert.Equal(overridden ? 0.9 : 0, copy.PassiveMoveSpeed);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,222 +0,0 @@
|
||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using Server;
|
|
||||||
using Server.Mobiles;
|
|
||||||
using Xunit;
|
|
||||||
|
|
||||||
namespace UOContent.Tests.Mobiles.AI;
|
|
||||||
|
|
||||||
// Pet order handlers own the speed clocks; combat chases and herding keep their own pacing.
|
|
||||||
[Collection("Sequential UOContent Tests")]
|
|
||||||
public class PetPacingTests : IDisposable
|
|
||||||
{
|
|
||||||
private readonly List<Mobile> _created = new();
|
|
||||||
|
|
||||||
private (PlayerMobile master, PetTestStub pet) Spawn(Point3D masterLoc, Point3D petLoc)
|
|
||||||
{
|
|
||||||
var pair = PetTestSetup.SpawnControlledPet(masterLoc, petLoc);
|
|
||||||
_created.Add(pair.master);
|
|
||||||
_created.Add(pair.pet);
|
|
||||||
return pair;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void Dispose()
|
|
||||||
{
|
|
||||||
foreach (var m in _created)
|
|
||||||
{
|
|
||||||
m?.Delete();
|
|
||||||
}
|
|
||||||
|
|
||||||
_created.Clear();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Movement orders run active, resting orders run passive; the move clock follows.
|
|
||||||
[Fact]
|
|
||||||
public void OrderIssue_SetsThinkClock()
|
|
||||||
{
|
|
||||||
var (master, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1001, 1000, 0));
|
|
||||||
pet.SetMoveSpeed(0.3, 0.9);
|
|
||||||
pet.SetCurrentSpeedToPassive();
|
|
||||||
|
|
||||||
pet.ControlOrder = OrderType.Come;
|
|
||||||
Assert.Equal(0.2, pet.CurrentSpeed);
|
|
||||||
Assert.Equal(0.3, pet.CurrentMoveSpeed); // verbatim active -> activeMove
|
|
||||||
|
|
||||||
pet.ControlOrder = OrderType.Stay;
|
|
||||||
Assert.Equal(0.4, pet.CurrentSpeed);
|
|
||||||
Assert.Equal(0.9, pet.CurrentMoveSpeed);
|
|
||||||
|
|
||||||
pet.ControlTarget = master;
|
|
||||||
pet.ControlOrder = OrderType.Follow;
|
|
||||||
Assert.Equal(0.2, pet.CurrentSpeed);
|
|
||||||
|
|
||||||
pet.ControlOrder = OrderType.Guard;
|
|
||||||
Assert.Equal(0.2, pet.CurrentSpeed);
|
|
||||||
}
|
|
||||||
|
|
||||||
// AOS: following the master sprints at a bespoke 0.1 on both clocks.
|
|
||||||
[Fact]
|
|
||||||
public void FollowMaster_ObeySprints()
|
|
||||||
{
|
|
||||||
var (master, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1001, 1000, 0));
|
|
||||||
pet.SetMoveSpeed(0.3, 0.9);
|
|
||||||
pet.AIObject.AITimer?.Stop();
|
|
||||||
|
|
||||||
pet.ControlTarget = master;
|
|
||||||
pet.ControlOrder = OrderType.Follow; // fixture era is EJ
|
|
||||||
pet.AIObject.Obey();
|
|
||||||
|
|
||||||
Assert.Equal(0.1, pet.CurrentSpeed);
|
|
||||||
Assert.Equal(0.1, pet.CurrentMoveSpeed);
|
|
||||||
}
|
|
||||||
|
|
||||||
// At the master's side a guarding pet stays active: no stale-warmode passive, no sprint.
|
|
||||||
[Fact]
|
|
||||||
public void GuardAtMastersSide_IsActive()
|
|
||||||
{
|
|
||||||
var (_, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1001, 1000, 0));
|
|
||||||
pet.SetMoveSpeed(0.3, 0.9);
|
|
||||||
pet.AIObject.AITimer?.Stop();
|
|
||||||
pet.SetCurrentSpeedToPassive();
|
|
||||||
|
|
||||||
pet.ControlOrder = OrderType.Guard;
|
|
||||||
pet.AIObject.Obey(); // nothing to guard against, master adjacent
|
|
||||||
|
|
||||||
Assert.Equal(0.2, pet.CurrentSpeed);
|
|
||||||
Assert.Equal(0.3, pet.CurrentMoveSpeed);
|
|
||||||
}
|
|
||||||
|
|
||||||
// A pet chasing a combatant keeps the move table.
|
|
||||||
[Fact]
|
|
||||||
public void CombatChasingPet_KeepsMoveTable()
|
|
||||||
{
|
|
||||||
var (_, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1001, 1000, 0));
|
|
||||||
var target = new PetTestStub();
|
|
||||||
target.MoveToWorld(new Point3D(1003, 1000, 0), Map.Felucca);
|
|
||||||
_created.Add(target);
|
|
||||||
|
|
||||||
pet.SetMoveSpeed(0.3, 0.9);
|
|
||||||
pet.ControlOrder = OrderType.Guard;
|
|
||||||
pet.Combatant = target;
|
|
||||||
pet.SetCurrentSpeedToActive();
|
|
||||||
|
|
||||||
Assert.Equal(0.3, pet.CurrentMoveSpeed);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Herding overrides order pacing.
|
|
||||||
[Fact]
|
|
||||||
public void HerdedObeyingPet_KeepsHerdingPace()
|
|
||||||
{
|
|
||||||
var (_, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1001, 1000, 0));
|
|
||||||
pet.SetMoveSpeed(0.45, 0.9);
|
|
||||||
pet.SetCurrentSpeedToPassive();
|
|
||||||
|
|
||||||
pet.TargetLocation = new Point2D(1010, 1010);
|
|
||||||
|
|
||||||
Assert.Equal(0.3, pet.CurrentMoveSpeed); // fixed herding pace
|
|
||||||
}
|
|
||||||
|
|
||||||
private sealed class ThinkProbe : PetTestStub
|
|
||||||
{
|
|
||||||
public int Thinks;
|
|
||||||
|
|
||||||
public override void OnThink()
|
|
||||||
{
|
|
||||||
Thinks++;
|
|
||||||
base.OnThink();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private (PlayerMobile master, ThinkProbe pet) SpawnProbe()
|
|
||||||
{
|
|
||||||
var master = new PlayerMobile(World.NewMobile);
|
|
||||||
master.DefaultMobileInit();
|
|
||||||
master.MoveToWorld(new Point3D(1000, 1000, 0), Map.Felucca);
|
|
||||||
_created.Add(master);
|
|
||||||
|
|
||||||
var pet = new ThinkProbe();
|
|
||||||
pet.MoveToWorld(new Point3D(1001, 1000, 0), Map.Felucca);
|
|
||||||
pet.SetControlMaster(master);
|
|
||||||
_created.Add(pet);
|
|
||||||
|
|
||||||
return (master, pet);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Advances time in 8ms lockstep so the wheel and Core.TickCount stay in sync.
|
|
||||||
private static void RunFor(long ms)
|
|
||||||
{
|
|
||||||
var deadline = Core._tickCount + ms;
|
|
||||||
|
|
||||||
while (Core._tickCount < deadline)
|
|
||||||
{
|
|
||||||
Core._tickCount += 8;
|
|
||||||
Timer.Slice(Core._tickCount);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static bool RunUntil(Func<bool> condition, long maxMs)
|
|
||||||
{
|
|
||||||
var deadline = Core._tickCount + maxMs;
|
|
||||||
|
|
||||||
while (Core._tickCount < deadline)
|
|
||||||
{
|
|
||||||
if (condition())
|
|
||||||
{
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
Core._tickCount += 8;
|
|
||||||
Timer.Slice(Core._tickCount);
|
|
||||||
}
|
|
||||||
|
|
||||||
return condition();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Runs past the spawn stagger; returns right after a think with the next 0.4s away.
|
|
||||||
private ThinkProbe SettledProbe(out PlayerMobile master)
|
|
||||||
{
|
|
||||||
Core._tickCount = 0;
|
|
||||||
Timer.Init(0);
|
|
||||||
|
|
||||||
var (m, pet) = SpawnProbe();
|
|
||||||
master = m;
|
|
||||||
pet.ForceIdle = true; // no wandering; pure cadence
|
|
||||||
pet.ControlOrder = OrderType.Stay;
|
|
||||||
|
|
||||||
var settled = RunUntil(() => pet.Thinks >= 2, 8000);
|
|
||||||
Assert.True(settled, "the AI must reach a steady think cadence");
|
|
||||||
|
|
||||||
return pet;
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void OrderChange_WakesStaleThinkTimer()
|
|
||||||
{
|
|
||||||
var pet = SettledProbe(out var master);
|
|
||||||
var thinksBefore = pet.Thinks;
|
|
||||||
|
|
||||||
RunFor(200); // mid-wait, next think ~200ms out
|
|
||||||
Assert.Equal(thinksBefore, pet.Thinks);
|
|
||||||
|
|
||||||
pet.ControlTarget = master;
|
|
||||||
pet.ControlOrder = OrderType.Follow;
|
|
||||||
|
|
||||||
RunFor(80);
|
|
||||||
Assert.True(pet.Thinks > thinksBefore, "a fresh order must wake the AI promptly");
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void SpeedUp_ReschedulesPendingWake()
|
|
||||||
{
|
|
||||||
var pet = SettledProbe(out _);
|
|
||||||
var thinksBefore = pet.Thinks;
|
|
||||||
|
|
||||||
RunFor(200); // mid-wait, next think ~200ms out
|
|
||||||
Assert.Equal(thinksBefore, pet.Thinks);
|
|
||||||
|
|
||||||
pet.CurrentSpeed = 0.1;
|
|
||||||
|
|
||||||
RunFor(120);
|
|
||||||
Assert.True(pet.Thinks > thinksBefore, "a speed-up must reschedule the pending wake");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,162 +0,0 @@
|
||||||
using System.Collections.Generic;
|
|
||||||
using Server;
|
|
||||||
using Server.Mobiles;
|
|
||||||
using Xunit;
|
|
||||||
|
|
||||||
namespace UOContent.Tests.Mobiles.AI;
|
|
||||||
|
|
||||||
// The Running bit is derived from the step pace: a step shorter than the client's walk
|
|
||||||
// interpolation (400ms on foot, 200ms mounted/flying) is flagged as a run.
|
|
||||||
[Collection("Sequential Pathfinding Tests")]
|
|
||||||
public class RunFlagTests : System.IDisposable
|
|
||||||
{
|
|
||||||
private readonly List<Mobile> _created = new();
|
|
||||||
|
|
||||||
private PetTestStub Spawn(double activeMove)
|
|
||||||
{
|
|
||||||
var pet = new PetTestStub();
|
|
||||||
pet.MoveToWorld(new Point3D(1000, 1000, 0), Map.Felucca);
|
|
||||||
pet.AIObject.AITimer?.Stop();
|
|
||||||
pet.SetMoveSpeed(activeMove, activeMove * 3);
|
|
||||||
pet.SetCurrentSpeedToActive();
|
|
||||||
pet.LastMoveTime = Core.TickCount; // mid-cadence unless a test says otherwise
|
|
||||||
_created.Add(pet);
|
|
||||||
return pet;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void Dispose()
|
|
||||||
{
|
|
||||||
foreach (var m in _created)
|
|
||||||
{
|
|
||||||
m?.Delete();
|
|
||||||
}
|
|
||||||
|
|
||||||
_created.Clear();
|
|
||||||
}
|
|
||||||
|
|
||||||
[Theory]
|
|
||||||
[InlineData(0.3, true)]
|
|
||||||
[InlineData(0.125, true)]
|
|
||||||
[InlineData(0.4, false)]
|
|
||||||
[InlineData(0.45, false)]
|
|
||||||
[InlineData(1.05, false)]
|
|
||||||
public void FootCreature_RunsOnlyWhenFasterThanWalk(double activeMove, bool expected)
|
|
||||||
{
|
|
||||||
var pet = Spawn(activeMove);
|
|
||||||
|
|
||||||
Assert.Equal(activeMove, pet.CurrentMoveSpeed);
|
|
||||||
Assert.Equal(expected, pet.AIObject.ShouldRun());
|
|
||||||
}
|
|
||||||
|
|
||||||
[Theory]
|
|
||||||
[InlineData(0.3, false)]
|
|
||||||
[InlineData(0.15, true)]
|
|
||||||
public void FlyingCreature_UsesMountThresholds(double activeMove, bool expected)
|
|
||||||
{
|
|
||||||
var pet = Spawn(activeMove);
|
|
||||||
pet.Flying = true;
|
|
||||||
|
|
||||||
Assert.Equal(expected, pet.AIObject.ShouldRun());
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void BadlyHurt_SlowsBelowWalk_DropsToWalk()
|
|
||||||
{
|
|
||||||
var pet = Spawn(0.35);
|
|
||||||
Assert.True(pet.AIObject.ShouldRun());
|
|
||||||
|
|
||||||
// The hurt inflation is on the observed step pace, so the flag follows it.
|
|
||||||
pet.SetHits(100);
|
|
||||||
pet.Hits = 5;
|
|
||||||
pet.SetStam(100);
|
|
||||||
pet.Stam = 5;
|
|
||||||
|
|
||||||
Assert.False(pet.AIObject.ShouldRun());
|
|
||||||
}
|
|
||||||
|
|
||||||
[Theory]
|
|
||||||
[InlineData(0.3, true)]
|
|
||||||
[InlineData(0.45, false)]
|
|
||||||
public void DoMove_StampsRunningBit(double activeMove, bool expected)
|
|
||||||
{
|
|
||||||
var map = Map.Maps[1];
|
|
||||||
Assert.NotNull(map);
|
|
||||||
map.GetAverageZ(1500, 1600, out _, out var z, out _);
|
|
||||||
|
|
||||||
var pet = Spawn(activeMove);
|
|
||||||
pet.MoveToWorld(new Point3D(1500, 1600, (sbyte)z), map);
|
|
||||||
|
|
||||||
var ai = pet.AIObject;
|
|
||||||
ai.NextMove = 0;
|
|
||||||
var start = pet.Location;
|
|
||||||
|
|
||||||
Assert.True(ai.DoMove(Direction.West));
|
|
||||||
Assert.NotEqual(start, pet.Location);
|
|
||||||
Assert.Equal(expected, (pet.Direction & Direction.Running) != 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
// An isolated step (after standing at least a walk interval) renders alone and darts
|
|
||||||
// if run-flagged, so it walks; continuing cadences and true sprinters keep the flag.
|
|
||||||
[Fact]
|
|
||||||
public void IsolatedStep_DropsToWalk()
|
|
||||||
{
|
|
||||||
var pet = Spawn(0.3);
|
|
||||||
pet.LastMoveTime = Core.TickCount - 1000;
|
|
||||||
|
|
||||||
Assert.False(pet.AIObject.ShouldRun());
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void IsolatedStep_SprinterStillRuns()
|
|
||||||
{
|
|
||||||
var pet = Spawn(0.125);
|
|
||||||
pet.LastMoveTime = Core.TickCount - 1000;
|
|
||||||
|
|
||||||
Assert.True(pet.AIObject.ShouldRun());
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void StallDoesNotBankCatchUpSteps()
|
|
||||||
{
|
|
||||||
var map = Map.Maps[1];
|
|
||||||
Assert.NotNull(map);
|
|
||||||
map.GetAverageZ(1500, 1600, out _, out var z, out _);
|
|
||||||
|
|
||||||
var pet = Spawn(0.3);
|
|
||||||
pet.MoveToWorld(new Point3D(1500, 1600, (sbyte)z), map);
|
|
||||||
|
|
||||||
var ai = pet.AIObject;
|
|
||||||
ai.NextMove = Core.TickCount - 1000;
|
|
||||||
|
|
||||||
Assert.True(ai.DoMove(Direction.West));
|
|
||||||
|
|
||||||
// A stall must restart the cadence at full pace: banked catch-up steps
|
|
||||||
// release as a burst the client renders as a sprint/teleport.
|
|
||||||
Assert.False(ai.CanMoveNow(out _));
|
|
||||||
Assert.True(ai.NextMove - Core.TickCount > 250);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void LateStepDoesNotEarnAQuickerFollowUp()
|
|
||||||
{
|
|
||||||
var map = Map.Maps[1];
|
|
||||||
Assert.NotNull(map);
|
|
||||||
map.GetAverageZ(1500, 1600, out _, out var z, out _);
|
|
||||||
|
|
||||||
var pet = Spawn(0.3);
|
|
||||||
pet.MoveToWorld(new Point3D(1500, 1600, (sbyte)z), map);
|
|
||||||
|
|
||||||
pet.Warmode = true; // keep the active move clock through the step
|
|
||||||
|
|
||||||
var ai = pet.AIObject;
|
|
||||||
// The step lands 200ms past the budget — under one period, the reactive
|
|
||||||
// mirroring case (think grid vs budget deadline misalignment).
|
|
||||||
ai.NextMove = Core.TickCount - 200;
|
|
||||||
|
|
||||||
Assert.True(ai.DoMove(Direction.West));
|
|
||||||
|
|
||||||
// The debt must not be repaid: a sub-period catch-up step follows ~100ms
|
|
||||||
// behind and renders as a dart pair beside the player.
|
|
||||||
Assert.True(ai.NextMove - Core.TickCount > 250);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -0,0 +1,281 @@
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using Server;
|
||||||
|
using Server.Mobiles;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace UOContent.Tests.Mobiles;
|
||||||
|
|
||||||
|
// BaseCreature's SaveFlag format round-trips both a default and a fully-populated
|
||||||
|
// creature with exact byte consumption, and back-to-back saves are byte-identical
|
||||||
|
// (freeze-time stability).
|
||||||
|
[Collection("Sequential UOContent Tests")]
|
||||||
|
public class BaseCreatureSerializationTests : IDisposable
|
||||||
|
{
|
||||||
|
private readonly List<Mobile> _created = new();
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
for (var i = 0; i < _created.Count; i++)
|
||||||
|
{
|
||||||
|
_created[i].Delete();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private class CreatureStub : BaseCreature
|
||||||
|
{
|
||||||
|
public CreatureStub() : base(AIType.AI_Melee) => Body = 0xC9;
|
||||||
|
|
||||||
|
public CreatureStub(Serial serial) : base(serial) => Body = 0xC9;
|
||||||
|
|
||||||
|
// Stands in for the npc-speeds table (unconfigured in the test fixture).
|
||||||
|
public override void GetSpeeds(out double activeSpeed, out double passiveSpeed)
|
||||||
|
{
|
||||||
|
activeSpeed = 0.3;
|
||||||
|
passiveSpeed = 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override void GetMoveSpeeds(out double activeMoveSpeed, out double passiveMoveSpeed)
|
||||||
|
{
|
||||||
|
activeMoveSpeed = 0.6;
|
||||||
|
passiveMoveSpeed = 1.2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private CreatureStub NewCreature()
|
||||||
|
{
|
||||||
|
var bc = new CreatureStub();
|
||||||
|
_created.Add(bc);
|
||||||
|
return bc;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static byte[] Snapshot(Mobile m)
|
||||||
|
{
|
||||||
|
var writer = new BufferWriter(true);
|
||||||
|
m.Serialize(writer);
|
||||||
|
|
||||||
|
var buffer = new byte[writer.Position];
|
||||||
|
writer.Buffer.AsSpan(0, (int)writer.Position).CopyTo(buffer);
|
||||||
|
return buffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
private CreatureStub Load(byte[] buffer)
|
||||||
|
{
|
||||||
|
var copy = new CreatureStub(World.NewMobile);
|
||||||
|
_created.Add(copy);
|
||||||
|
var reader = new BufferReader(buffer);
|
||||||
|
copy.Deserialize(reader);
|
||||||
|
|
||||||
|
Assert.Equal(buffer.Length, reader.Position); // exact consumption
|
||||||
|
return copy;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DefaultCreature_RoundTrips_AndElidesEverything()
|
||||||
|
{
|
||||||
|
var bc = NewCreature();
|
||||||
|
|
||||||
|
var buffer = Snapshot(bc);
|
||||||
|
var copy = Load(buffer);
|
||||||
|
|
||||||
|
Assert.Equal(AIType.AI_Melee, copy.AI);
|
||||||
|
Assert.Equal(BaseCreature.DefaultRangePerception, copy.RangePerception);
|
||||||
|
Assert.Equal(0.3, copy.ActiveSpeed);
|
||||||
|
Assert.Equal(0.6, copy.PassiveSpeed);
|
||||||
|
Assert.Equal(0.6, copy.CurrentSpeed);
|
||||||
|
Assert.Equal(0.6, copy.ActiveMoveSpeed); // class None (no table in tests): restored from the wire
|
||||||
|
Assert.Equal(1.2, copy.PassiveMoveSpeed);
|
||||||
|
Assert.Equal(100, copy.PhysicalDamage);
|
||||||
|
Assert.Equal(BaseCreature.MaxLoyalty, copy.Loyalty);
|
||||||
|
Assert.Equal(1, copy.ControlSlots);
|
||||||
|
Assert.NotNull(copy.Owners);
|
||||||
|
Assert.Empty(copy.Owners);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void BackToBackSaves_AreByteIdentical()
|
||||||
|
{
|
||||||
|
var bc = NewCreature();
|
||||||
|
bc.SetDamage(5, 10);
|
||||||
|
bc.PhysicalResistanceSeed = 25;
|
||||||
|
|
||||||
|
Assert.Equal(Snapshot(bc), Snapshot(bc));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void PopulatedCreature_RoundTrips()
|
||||||
|
{
|
||||||
|
var bc = NewCreature();
|
||||||
|
var master = new PlayerMobile(World.NewMobile);
|
||||||
|
master.DefaultMobileInit();
|
||||||
|
World.AddEntity(master); // ReadEntity resolves the reference through the world table
|
||||||
|
_created.Add(master);
|
||||||
|
|
||||||
|
bc.Tamable = true;
|
||||||
|
bc.MinTameSkill = 47.1;
|
||||||
|
bc.SetControlMaster(master);
|
||||||
|
bc.Owners.Add(master);
|
||||||
|
bc.ControlOrder = OrderType.Guard;
|
||||||
|
bc.SetDamage(11, 17);
|
||||||
|
bc.SetSpeed(0.2, 0.4); // hand-tuned: no longer matches the stub table
|
||||||
|
bc.SetMoveSpeed(0.25, 0.5);
|
||||||
|
bc.PhysicalResistanceSeed = 40;
|
||||||
|
bc.EnergyResistSeed = 15;
|
||||||
|
bc.FireDamage = 25;
|
||||||
|
bc.PhysicalDamage = 75;
|
||||||
|
bc.HitsMaxSeed = 250;
|
||||||
|
bc.Loyalty = 55;
|
||||||
|
bc.Home = new Point3D(1000, 1100, 5);
|
||||||
|
bc.RangeHome = 4;
|
||||||
|
bc.Team = 3;
|
||||||
|
bc.IsBonded = true;
|
||||||
|
bc.BondingBegin = Core.Now;
|
||||||
|
bc.RemoveIfUntamed = true;
|
||||||
|
bc.RemoveStep = 2;
|
||||||
|
bc.CorpseNameOverride = "a test corpse";
|
||||||
|
|
||||||
|
var copy = Load(Snapshot(bc));
|
||||||
|
|
||||||
|
Assert.True(copy.Controlled);
|
||||||
|
Assert.Equal(master, copy.ControlMaster);
|
||||||
|
Assert.Equal(OrderType.Guard, copy.ControlOrder);
|
||||||
|
Assert.True(copy.Tamable);
|
||||||
|
Assert.Equal(47.1, copy.MinTameSkill);
|
||||||
|
Assert.Equal(11, copy.DamageMin);
|
||||||
|
Assert.Equal(17, copy.DamageMax);
|
||||||
|
Assert.Equal(0.2, copy.ActiveSpeed);
|
||||||
|
Assert.Equal(0.4, copy.PassiveSpeed);
|
||||||
|
Assert.Equal(0.25, copy.ActiveMoveSpeed);
|
||||||
|
Assert.Equal(0.5, copy.PassiveMoveSpeed);
|
||||||
|
Assert.Equal(40, copy.PhysicalResistanceSeed);
|
||||||
|
Assert.Equal(15, copy.EnergyResistSeed);
|
||||||
|
Assert.Equal(25, copy.FireDamage);
|
||||||
|
Assert.Equal(75, copy.PhysicalDamage);
|
||||||
|
Assert.Equal(250, copy.HitsMaxSeed);
|
||||||
|
Assert.Equal(55, copy.Loyalty);
|
||||||
|
Assert.Equal(new Point3D(1000, 1100, 5), copy.Home);
|
||||||
|
Assert.Equal(4, copy.RangeHome);
|
||||||
|
Assert.Equal(3, copy.Team);
|
||||||
|
Assert.True(copy.IsBonded);
|
||||||
|
Assert.Equal(bc.BondingBegin, copy.BondingBegin);
|
||||||
|
Assert.True(copy.RemoveIfUntamed);
|
||||||
|
Assert.Equal(2, copy.RemoveStep);
|
||||||
|
Assert.Equal("a test corpse", copy.CorpseNameOverride);
|
||||||
|
Assert.Equal(master, copy.LastOwner);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void UncontrolledSummon_KeepsItsSummonMaster()
|
||||||
|
{
|
||||||
|
var bc = NewCreature();
|
||||||
|
var master = new PlayerMobile(World.NewMobile);
|
||||||
|
master.DefaultMobileInit();
|
||||||
|
World.AddEntity(master);
|
||||||
|
_created.Add(master);
|
||||||
|
|
||||||
|
// Energy vortex-style: summoned with a master, never controlled.
|
||||||
|
bc.Summoned = true;
|
||||||
|
bc.SummonMaster = master;
|
||||||
|
|
||||||
|
var copy = Load(Snapshot(bc));
|
||||||
|
|
||||||
|
Assert.True(copy.Summoned);
|
||||||
|
Assert.False(copy.Controlled);
|
||||||
|
Assert.Equal(master, copy.SummonMaster);
|
||||||
|
Assert.Null(copy.ControlMaster);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class BucketStub : BaseCreature
|
||||||
|
{
|
||||||
|
public BucketStub() : base(AIType.AI_Melee) => Body = 0xC9;
|
||||||
|
|
||||||
|
public BucketStub(Serial serial) : base(serial) => Body = 0xC9;
|
||||||
|
|
||||||
|
public override SpeedLevel DefaultSpeedClass => SpeedLevel.Fast;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SpeedClass_Assignment_AppliesBucket_AndRoundTrips()
|
||||||
|
{
|
||||||
|
NPCSpeeds.RegisterSpeed(new NPCSpeeds.SpeedClassEntry
|
||||||
|
{
|
||||||
|
Level = SpeedLevel.Fast, ActiveSpeed = 0.2, PassiveSpeed = 0.4,
|
||||||
|
ActiveMoveSpeed = 0.3, PassiveMoveSpeed = 0.9, Types = new HashSet<Type>()
|
||||||
|
});
|
||||||
|
NPCSpeeds.RegisterSpeed(new NPCSpeeds.SpeedClassEntry
|
||||||
|
{
|
||||||
|
Level = SpeedLevel.VeryFast, ActiveSpeed = 0.125, PassiveSpeed = 0.3,
|
||||||
|
ActiveMoveSpeed = 0.125, PassiveMoveSpeed = 0.6, Types = new HashSet<Type>()
|
||||||
|
});
|
||||||
|
|
||||||
|
var bc = new BucketStub();
|
||||||
|
_created.Add(bc);
|
||||||
|
|
||||||
|
Assert.Equal(0.2, bc.ActiveSpeed); // seeded from the default bucket
|
||||||
|
Assert.Equal(0.3, bc.ActiveMoveSpeed);
|
||||||
|
|
||||||
|
bc.SpeedClass = SpeedLevel.VeryFast; // boss state change
|
||||||
|
|
||||||
|
Assert.Equal(SpeedLevel.VeryFast, bc.SpeedClass); // conforming assignment holds
|
||||||
|
Assert.Equal(0.125, bc.ActiveSpeed);
|
||||||
|
Assert.Equal(0.3, bc.PassiveSpeed);
|
||||||
|
Assert.Equal(0.125, bc.ActiveMoveSpeed);
|
||||||
|
Assert.Equal(0.6, bc.PassiveMoveSpeed);
|
||||||
|
Assert.Equal(0.3, bc.CurrentSpeed); // stayed in the passive mode
|
||||||
|
|
||||||
|
// The changed bucket persists; the (bucket-matching) speeds elide but restore
|
||||||
|
// through the new bucket - the consistency the stateful SpeedClass guarantees.
|
||||||
|
var writer = new BufferWriter(true);
|
||||||
|
bc.Serialize(writer);
|
||||||
|
var buffer = new byte[writer.Position];
|
||||||
|
writer.Buffer.AsSpan(0, (int)writer.Position).CopyTo(buffer);
|
||||||
|
|
||||||
|
var copy = new BucketStub(World.NewMobile);
|
||||||
|
_created.Add(copy);
|
||||||
|
var reader = new BufferReader(buffer);
|
||||||
|
copy.Deserialize(reader);
|
||||||
|
|
||||||
|
Assert.Equal(buffer.Length, reader.Position);
|
||||||
|
Assert.Equal(SpeedLevel.VeryFast, copy.SpeedClass);
|
||||||
|
Assert.Equal(0.125, copy.ActiveSpeed);
|
||||||
|
Assert.Equal(0.3, copy.PassiveSpeed);
|
||||||
|
Assert.Equal(0.125, copy.ActiveMoveSpeed);
|
||||||
|
Assert.Equal(0.6, copy.PassiveMoveSpeed);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void PartialSpeedTuning_MakesTheCreatureFullyCustom()
|
||||||
|
{
|
||||||
|
NPCSpeeds.RegisterSpeed(new NPCSpeeds.SpeedClassEntry
|
||||||
|
{
|
||||||
|
Level = SpeedLevel.Fast, ActiveSpeed = 0.2, PassiveSpeed = 0.4,
|
||||||
|
ActiveMoveSpeed = 0.3, PassiveMoveSpeed = 0.9, Types = new HashSet<Type>()
|
||||||
|
});
|
||||||
|
|
||||||
|
var bc = new BucketStub();
|
||||||
|
_created.Add(bc);
|
||||||
|
|
||||||
|
bc.ActiveSpeed = 0.25; // one tuned value customizes the whole block
|
||||||
|
|
||||||
|
Assert.Equal(SpeedLevel.None, bc.SpeedClass); // the bucket label never lies
|
||||||
|
|
||||||
|
var writer = new BufferWriter(true);
|
||||||
|
bc.Serialize(writer);
|
||||||
|
var buffer = new byte[writer.Position];
|
||||||
|
writer.Buffer.AsSpan(0, (int)writer.Position).CopyTo(buffer);
|
||||||
|
|
||||||
|
var copy = new BucketStub(World.NewMobile);
|
||||||
|
_created.Add(copy);
|
||||||
|
var reader = new BufferReader(buffer);
|
||||||
|
copy.Deserialize(reader);
|
||||||
|
|
||||||
|
// All four persisted raw - no value is left silently tracking the table.
|
||||||
|
Assert.Equal(buffer.Length, reader.Position);
|
||||||
|
Assert.Equal(SpeedLevel.None, copy.SpeedClass);
|
||||||
|
Assert.Equal(0.25, copy.ActiveSpeed);
|
||||||
|
Assert.Equal(0.4, copy.PassiveSpeed);
|
||||||
|
Assert.Equal(0.3, copy.ActiveMoveSpeed);
|
||||||
|
Assert.Equal(0.9, copy.PassiveMoveSpeed);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
@ -1,146 +0,0 @@
|
||||||
using System.Collections.Generic;
|
|
||||||
using Server.Mobiles;
|
|
||||||
using Xunit;
|
|
||||||
|
|
||||||
namespace Server.Tests;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Pins the looting-rights rules that the inline damage entry list has to keep producing: the
|
|
||||||
/// returned stores are sorted by damage descending, the first (least recent) damager takes the
|
|
||||||
/// 1.25x bonus, the hitsMax band decides who clears the threshold, and a pet's damage is credited
|
|
||||||
/// to its damage master rather than to the pet.
|
|
||||||
/// </summary>
|
|
||||||
[Collection("Sequential UOContent Tests")]
|
|
||||||
public class LootingRightsTests
|
|
||||||
{
|
|
||||||
private class TestMobile : Mobile
|
|
||||||
{
|
|
||||||
}
|
|
||||||
|
|
||||||
private class PetMobile : Mobile
|
|
||||||
{
|
|
||||||
public Mobile Master { get; set; }
|
|
||||||
|
|
||||||
public override Mobile GetDamageMaster(Mobile damagee) => Master;
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetLootingRights only ever credits mobiles flagged as players.
|
|
||||||
private static TestMobile NewPlayer() => new() { Player = true };
|
|
||||||
|
|
||||||
private static DamageStore FindStore(List<DamageStore> rights, Mobile m)
|
|
||||||
{
|
|
||||||
for (var i = 0; i < rights.Count; i++)
|
|
||||||
{
|
|
||||||
if (rights[i].m_Mobile == m)
|
|
||||||
{
|
|
||||||
return rights[i];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void TwoPlayerDamagers_SortDescending_AndTheFirstDamagerTakesTheBonus()
|
|
||||||
{
|
|
||||||
var victim = new TestMobile();
|
|
||||||
var first = NewPlayer();
|
|
||||||
var second = NewPlayer();
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
victim.RegisterDamage(100, first);
|
|
||||||
victim.RegisterDamage(40, second); // second is the most recent, first is the "first damager"
|
|
||||||
|
|
||||||
// hitsMax < 200 puts the bar at topDamage / 2.
|
|
||||||
var rights = BaseCreature.GetLootingRights(victim.DamageEntries, 100);
|
|
||||||
|
|
||||||
Assert.Equal(2, rights.Count);
|
|
||||||
|
|
||||||
// Sorted by damage descending.
|
|
||||||
Assert.True(rights[0].m_Damage >= rights[1].m_Damage);
|
|
||||||
Assert.Same(first, rights[0].m_Mobile);
|
|
||||||
Assert.Same(second, rights[1].m_Mobile);
|
|
||||||
|
|
||||||
// The first damager - the least recent entry - gets the 1.25x bonus; nobody else does.
|
|
||||||
Assert.Equal(125, rights[0].m_Damage);
|
|
||||||
Assert.Equal(40, rights[1].m_Damage);
|
|
||||||
|
|
||||||
// topDamage 125 / 2 = 62, so 40 is below the bar.
|
|
||||||
Assert.True(rights[0].m_HasRight);
|
|
||||||
Assert.False(rights[1].m_HasRight);
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
victim.Delete();
|
|
||||||
first.Delete();
|
|
||||||
second.Delete();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void HitsMaxBand_MovesTheRightsThreshold()
|
|
||||||
{
|
|
||||||
var victim = new TestMobile();
|
|
||||||
var first = NewPlayer();
|
|
||||||
var second = NewPlayer();
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
victim.RegisterDamage(100, first);
|
|
||||||
victim.RegisterDamage(40, second);
|
|
||||||
|
|
||||||
// hitsMax >= 200 drops the bar to topDamage / 4 = 31, which 40 clears.
|
|
||||||
var rights = BaseCreature.GetLootingRights(victim.DamageEntries, 200);
|
|
||||||
|
|
||||||
Assert.Equal(2, rights.Count);
|
|
||||||
Assert.True(rights[0].m_HasRight);
|
|
||||||
Assert.True(rights[1].m_HasRight);
|
|
||||||
Assert.Same(second, rights[1].m_Mobile);
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
victim.Delete();
|
|
||||||
first.Delete();
|
|
||||||
second.Delete();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void PetDamage_CreditsTheMaster_NotThePet()
|
|
||||||
{
|
|
||||||
var victim = new TestMobile();
|
|
||||||
var master = NewPlayer();
|
|
||||||
var pet = new PetMobile { Master = master };
|
|
||||||
var wild = new TestMobile(); // no damage master, and not a player
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
victim.RegisterDamage(50, pet);
|
|
||||||
victim.RegisterDamage(20, wild);
|
|
||||||
|
|
||||||
var rights = BaseCreature.GetLootingRights(victim.DamageEntries, 100);
|
|
||||||
|
|
||||||
// The master is credited through the entry's Responsible sub-entry, and is the only one.
|
|
||||||
Assert.Single(rights);
|
|
||||||
|
|
||||||
var masterStore = FindStore(rights, master);
|
|
||||||
Assert.NotNull(masterStore);
|
|
||||||
Assert.Equal(62, masterStore.m_Damage); // 50, then the first-damager 1.25x bonus
|
|
||||||
Assert.True(masterStore.m_HasRight);
|
|
||||||
|
|
||||||
// The pet's own damage was fully handed to the master, so it earns no store.
|
|
||||||
Assert.Null(FindStore(rights, pet));
|
|
||||||
|
|
||||||
// A non-player damager earns nothing even when its damage was never reassigned.
|
|
||||||
Assert.Null(FindStore(rights, wild));
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
victim.Delete();
|
|
||||||
master.Delete();
|
|
||||||
pet.Delete();
|
|
||||||
wild.Delete();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -121,7 +121,7 @@ public class BloodOathSpellTests
|
||||||
|
|
||||||
BloodOathSpell.RegisterOath(caster, target, TimeSpan.FromMinutes(5));
|
BloodOathSpell.RegisterOath(caster, target, TimeSpan.FromMinutes(5));
|
||||||
|
|
||||||
BaseCreature.CreatureDeletedEvent(target); // central handler breaks the oath from the target side
|
CreatureEvents.CreatureDeletedEvent(target); // central handler breaks the oath from the target side
|
||||||
|
|
||||||
Assert.Null(BloodOathSpell.GetBloodOath(target));
|
Assert.Null(BloodOathSpell.GetBloodOath(target));
|
||||||
Assert.False(BloodOathSpell.RemoveCurse(caster));
|
Assert.False(BloodOathSpell.RemoveCurse(caster));
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,6 @@ using System.Net;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Runtime.InteropServices;
|
using System.Runtime.InteropServices;
|
||||||
using ModernUO.Serialization;
|
using ModernUO.Serialization;
|
||||||
using Server.Collections;
|
|
||||||
using Server.Engines.Virtues;
|
using Server.Engines.Virtues;
|
||||||
using Server.Gumps;
|
using Server.Gumps;
|
||||||
using Server.Items;
|
using Server.Items;
|
||||||
|
|
@ -1182,6 +1181,11 @@ public partial class ChampionSpawn : Item
|
||||||
|
|
||||||
foreach (var de in m.DamageEntries)
|
foreach (var de in m.DamageEntries)
|
||||||
{
|
{
|
||||||
|
if (de.HasExpired)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
var damager = de.Damager;
|
var damager = de.Damager;
|
||||||
var master = damager.GetDamageMaster(m);
|
var master = damager.GetDamageMaster(m);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -359,14 +359,14 @@ namespace Server.Factions
|
||||||
{
|
{
|
||||||
if (m_Mobile.InRange( m, 1 ))
|
if (m_Mobile.InRange( m, 1 ))
|
||||||
RunFrom( m );
|
RunFrom( m );
|
||||||
else if (!m_Mobile.InRange( m, m_Mobile.RangeFight > 2 ? m_Mobile.RangeFight : 2 ) && !MoveTo(m, 1))
|
else if (!m_Mobile.InRange( m, m_Mobile.RangeFight > 2 ? m_Mobile.RangeFight : 2 ) && !MoveTo( m, true, 1 ))
|
||||||
OnFailedMove();
|
OnFailedMove();
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{*/
|
{*/
|
||||||
if (!Mobile.InRange(m, Mobile.RangeFight))
|
if (!Mobile.InRange(m, Mobile.RangeFight))
|
||||||
{
|
{
|
||||||
if (!MoveTo(m, 1))
|
if (!MoveTo(m, true, 1))
|
||||||
{
|
{
|
||||||
OnFailedMove();
|
OnFailedMove();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -219,7 +219,7 @@ namespace Server.Engines.Harvest
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
bonusItem?.Delete();
|
item.Delete();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -83,7 +83,7 @@ public class PathFollower
|
||||||
public static bool Check(Point3D loc, Point3D goal, int range) =>
|
public static bool Check(Point3D loc, Point3D goal, int range) =>
|
||||||
Utility.InRange(loc, goal, range) && (range > 1 || (loc.Z - goal.Z).Abs() < 16);
|
Utility.InRange(loc, goal, range) && (range > 1 || (loc.Z - goal.Z).Abs() < 16);
|
||||||
|
|
||||||
public bool Follow(int range)
|
public bool Follow(bool run, int range)
|
||||||
{
|
{
|
||||||
var goal = GetGoalLocation();
|
var goal = GetGoalLocation();
|
||||||
Direction d;
|
Direction d;
|
||||||
|
|
@ -97,13 +97,13 @@ public class PathFollower
|
||||||
|
|
||||||
if (!(Enabled && m_Path.Success))
|
if (!(Enabled && m_Path.Success))
|
||||||
{
|
{
|
||||||
d = m_From.GetDirectionTo(goal);
|
d = m_From.GetDirectionTo(goal, run);
|
||||||
m_From.SetDirection(d);
|
m_From.SetDirection(d);
|
||||||
|
|
||||||
return Move(d) is MoveResult.Success or MoveResult.SuccessAutoTurn && Check(m_From.Location, goal, range);
|
return Move(d) is MoveResult.Success or MoveResult.SuccessAutoTurn && Check(m_From.Location, goal, range);
|
||||||
}
|
}
|
||||||
|
|
||||||
d = m_From.GetDirectionTo(m_Next);
|
d = m_From.GetDirectionTo(m_Next, run);
|
||||||
m_From.SetDirection(d);
|
m_From.SetDirection(d);
|
||||||
var res = Move(d);
|
var res = Move(d);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -227,12 +227,6 @@ public partial class BallOfSummoning : Item, TranslocationItem
|
||||||
if (pet.IsStabled)
|
if (pet.IsStabled)
|
||||||
{
|
{
|
||||||
pet.SetControlMaster(from);
|
pet.SetControlMaster(from);
|
||||||
|
|
||||||
if (pet.Summoned)
|
|
||||||
{
|
|
||||||
pet.SummonMaster = from;
|
|
||||||
}
|
|
||||||
|
|
||||||
pet.ControlTarget = from;
|
pet.ControlTarget = from;
|
||||||
pet.ControlOrder = OrderType.Follow;
|
pet.ControlOrder = OrderType.Follow;
|
||||||
|
|
||||||
|
|
|
||||||
471
Projects/UOContent/Migrations/Server.Mobiles.BaseCreature.v23.json
generated
Normal file
471
Projects/UOContent/Migrations/Server.Mobiles.BaseCreature.v23.json
generated
Normal file
|
|
@ -0,0 +1,471 @@
|
||||||
|
{
|
||||||
|
"version": 23,
|
||||||
|
"type": "Server.Mobiles.BaseCreature",
|
||||||
|
"properties": [
|
||||||
|
{
|
||||||
|
"name": "DefaultAI",
|
||||||
|
"type": "Server.Mobiles.AIType",
|
||||||
|
"rule": "EnumMigrationRule"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "CurrentAI",
|
||||||
|
"type": "Server.Mobiles.AIType",
|
||||||
|
"usesSaveFlag": true,
|
||||||
|
"rule": "EnumMigrationRule"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "RangePerception",
|
||||||
|
"type": "int",
|
||||||
|
"usesSaveFlag": true,
|
||||||
|
"rule": "PrimitiveTypeMigrationRule",
|
||||||
|
"ruleArguments": [
|
||||||
|
"EncodedInt"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "RangeFight",
|
||||||
|
"type": "int",
|
||||||
|
"usesSaveFlag": true,
|
||||||
|
"rule": "PrimitiveTypeMigrationRule",
|
||||||
|
"ruleArguments": [
|
||||||
|
"EncodedInt"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "RangeHome",
|
||||||
|
"type": "int",
|
||||||
|
"usesSaveFlag": true,
|
||||||
|
"rule": "PrimitiveTypeMigrationRule",
|
||||||
|
"ruleArguments": [
|
||||||
|
"EncodedInt"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Team",
|
||||||
|
"type": "int",
|
||||||
|
"usesSaveFlag": true,
|
||||||
|
"rule": "PrimitiveTypeMigrationRule",
|
||||||
|
"ruleArguments": [
|
||||||
|
"EncodedInt"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "FightMode",
|
||||||
|
"type": "Server.Mobiles.FightMode",
|
||||||
|
"usesSaveFlag": true,
|
||||||
|
"rule": "EnumMigrationRule"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "SpeedClass",
|
||||||
|
"type": "Server.Mobiles.SpeedLevel",
|
||||||
|
"usesSaveFlag": true,
|
||||||
|
"rule": "EnumMigrationRule"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "ActiveSpeed",
|
||||||
|
"type": "double",
|
||||||
|
"usesSaveFlag": true,
|
||||||
|
"rule": "PrimitiveTypeMigrationRule",
|
||||||
|
"ruleArguments": [
|
||||||
|
""
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "PassiveSpeed",
|
||||||
|
"type": "double",
|
||||||
|
"usesSaveFlag": true,
|
||||||
|
"rule": "PrimitiveTypeMigrationRule",
|
||||||
|
"ruleArguments": [
|
||||||
|
""
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "CurrentSpeed",
|
||||||
|
"type": "double",
|
||||||
|
"usesSaveFlag": true,
|
||||||
|
"rule": "PrimitiveTypeMigrationRule",
|
||||||
|
"ruleArguments": [
|
||||||
|
""
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "ActiveMoveSpeed",
|
||||||
|
"type": "double",
|
||||||
|
"usesSaveFlag": true,
|
||||||
|
"rule": "PrimitiveTypeMigrationRule",
|
||||||
|
"ruleArguments": [
|
||||||
|
""
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "PassiveMoveSpeed",
|
||||||
|
"type": "double",
|
||||||
|
"usesSaveFlag": true,
|
||||||
|
"rule": "PrimitiveTypeMigrationRule",
|
||||||
|
"ruleArguments": [
|
||||||
|
""
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Home",
|
||||||
|
"type": "Server.Point3D",
|
||||||
|
"usesSaveFlag": true,
|
||||||
|
"rule": "PrimitiveUOTypeMigrationRule",
|
||||||
|
"ruleArguments": [
|
||||||
|
"Point3D"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "HomeMap",
|
||||||
|
"type": "Server.Map",
|
||||||
|
"usesSaveFlag": true,
|
||||||
|
"rule": "PrimitiveUOTypeMigrationRule",
|
||||||
|
"ruleArguments": [
|
||||||
|
"Map"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Controlled",
|
||||||
|
"type": "bool",
|
||||||
|
"usesSaveFlag": true,
|
||||||
|
"rule": "PrimitiveTypeMigrationRule",
|
||||||
|
"ruleArguments": [
|
||||||
|
""
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "ControlTarget",
|
||||||
|
"type": "Server.Mobile",
|
||||||
|
"usesSaveFlag": true,
|
||||||
|
"rule": "SerializableInterfaceMigrationRule"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "ControlDest",
|
||||||
|
"type": "Server.Point3D",
|
||||||
|
"usesSaveFlag": true,
|
||||||
|
"rule": "PrimitiveUOTypeMigrationRule",
|
||||||
|
"ruleArguments": [
|
||||||
|
"Point3D"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "ControlOrder",
|
||||||
|
"type": "Server.Mobiles.OrderType",
|
||||||
|
"usesSaveFlag": true,
|
||||||
|
"rule": "EnumMigrationRule"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "MinTameSkill",
|
||||||
|
"type": "double",
|
||||||
|
"usesSaveFlag": true,
|
||||||
|
"rule": "PrimitiveTypeMigrationRule",
|
||||||
|
"ruleArguments": [
|
||||||
|
""
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Tamable",
|
||||||
|
"type": "bool",
|
||||||
|
"usesSaveFlag": true,
|
||||||
|
"rule": "PrimitiveTypeMigrationRule",
|
||||||
|
"ruleArguments": [
|
||||||
|
""
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Summoned",
|
||||||
|
"type": "bool",
|
||||||
|
"usesSaveFlag": true,
|
||||||
|
"rule": "PrimitiveTypeMigrationRule",
|
||||||
|
"ruleArguments": [
|
||||||
|
""
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "SummonEnd",
|
||||||
|
"type": "System.DateTime",
|
||||||
|
"usesSaveFlag": true,
|
||||||
|
"rule": "PrimitiveTypeMigrationRule",
|
||||||
|
"ruleArguments": [
|
||||||
|
"AnchoredTime"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Master",
|
||||||
|
"type": "Server.Mobile",
|
||||||
|
"usesSaveFlag": true,
|
||||||
|
"rule": "SerializableInterfaceMigrationRule"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "ControlSlots",
|
||||||
|
"type": "int",
|
||||||
|
"usesSaveFlag": true,
|
||||||
|
"rule": "PrimitiveTypeMigrationRule",
|
||||||
|
"ruleArguments": [
|
||||||
|
"EncodedInt"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Loyalty",
|
||||||
|
"type": "int",
|
||||||
|
"usesSaveFlag": true,
|
||||||
|
"rule": "PrimitiveTypeMigrationRule",
|
||||||
|
"ruleArguments": [
|
||||||
|
"EncodedInt"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "CurrentWayPoint",
|
||||||
|
"type": "Server.Items.WayPoint",
|
||||||
|
"usesSaveFlag": true,
|
||||||
|
"rule": "SerializableInterfaceMigrationRule"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "HitsMaxSeed",
|
||||||
|
"type": "int",
|
||||||
|
"usesSaveFlag": true,
|
||||||
|
"rule": "PrimitiveTypeMigrationRule",
|
||||||
|
"ruleArguments": [
|
||||||
|
"EncodedInt"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "StamMaxSeed",
|
||||||
|
"type": "int",
|
||||||
|
"usesSaveFlag": true,
|
||||||
|
"rule": "PrimitiveTypeMigrationRule",
|
||||||
|
"ruleArguments": [
|
||||||
|
"EncodedInt"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "ManaMaxSeed",
|
||||||
|
"type": "int",
|
||||||
|
"usesSaveFlag": true,
|
||||||
|
"rule": "PrimitiveTypeMigrationRule",
|
||||||
|
"ruleArguments": [
|
||||||
|
"EncodedInt"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "DamageMin",
|
||||||
|
"type": "int",
|
||||||
|
"usesSaveFlag": true,
|
||||||
|
"rule": "PrimitiveTypeMigrationRule",
|
||||||
|
"ruleArguments": [
|
||||||
|
"EncodedInt"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "DamageMax",
|
||||||
|
"type": "int",
|
||||||
|
"usesSaveFlag": true,
|
||||||
|
"rule": "PrimitiveTypeMigrationRule",
|
||||||
|
"ruleArguments": [
|
||||||
|
"EncodedInt"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "PhysicalResistanceSeed",
|
||||||
|
"type": "int",
|
||||||
|
"usesSaveFlag": true,
|
||||||
|
"rule": "PrimitiveTypeMigrationRule",
|
||||||
|
"ruleArguments": [
|
||||||
|
"EncodedInt"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "FireResistSeed",
|
||||||
|
"type": "int",
|
||||||
|
"usesSaveFlag": true,
|
||||||
|
"rule": "PrimitiveTypeMigrationRule",
|
||||||
|
"ruleArguments": [
|
||||||
|
"EncodedInt"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "ColdResistSeed",
|
||||||
|
"type": "int",
|
||||||
|
"usesSaveFlag": true,
|
||||||
|
"rule": "PrimitiveTypeMigrationRule",
|
||||||
|
"ruleArguments": [
|
||||||
|
"EncodedInt"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "PoisonResistSeed",
|
||||||
|
"type": "int",
|
||||||
|
"usesSaveFlag": true,
|
||||||
|
"rule": "PrimitiveTypeMigrationRule",
|
||||||
|
"ruleArguments": [
|
||||||
|
"EncodedInt"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "EnergyResistSeed",
|
||||||
|
"type": "int",
|
||||||
|
"usesSaveFlag": true,
|
||||||
|
"rule": "PrimitiveTypeMigrationRule",
|
||||||
|
"ruleArguments": [
|
||||||
|
"EncodedInt"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "PhysicalDamage",
|
||||||
|
"type": "int",
|
||||||
|
"usesSaveFlag": true,
|
||||||
|
"rule": "PrimitiveTypeMigrationRule",
|
||||||
|
"ruleArguments": [
|
||||||
|
"EncodedInt"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "FireDamage",
|
||||||
|
"type": "int",
|
||||||
|
"usesSaveFlag": true,
|
||||||
|
"rule": "PrimitiveTypeMigrationRule",
|
||||||
|
"ruleArguments": [
|
||||||
|
"EncodedInt"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "ColdDamage",
|
||||||
|
"type": "int",
|
||||||
|
"usesSaveFlag": true,
|
||||||
|
"rule": "PrimitiveTypeMigrationRule",
|
||||||
|
"ruleArguments": [
|
||||||
|
"EncodedInt"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "PoisonDamage",
|
||||||
|
"type": "int",
|
||||||
|
"usesSaveFlag": true,
|
||||||
|
"rule": "PrimitiveTypeMigrationRule",
|
||||||
|
"ruleArguments": [
|
||||||
|
"EncodedInt"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "EnergyDamage",
|
||||||
|
"type": "int",
|
||||||
|
"usesSaveFlag": true,
|
||||||
|
"rule": "PrimitiveTypeMigrationRule",
|
||||||
|
"ruleArguments": [
|
||||||
|
"EncodedInt"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Owners",
|
||||||
|
"type": "System.Collections.Generic.List\u003CServer.Mobile\u003E",
|
||||||
|
"usesSaveFlag": true,
|
||||||
|
"rule": "ListMigrationRule",
|
||||||
|
"ruleArguments": [
|
||||||
|
"@Tidy",
|
||||||
|
"Server.Mobile",
|
||||||
|
"SerializableInterfaceMigrationRule"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "IsDeadPet",
|
||||||
|
"type": "bool",
|
||||||
|
"usesSaveFlag": true,
|
||||||
|
"rule": "PrimitiveTypeMigrationRule",
|
||||||
|
"ruleArguments": [
|
||||||
|
""
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "IsBonded",
|
||||||
|
"type": "bool",
|
||||||
|
"usesSaveFlag": true,
|
||||||
|
"rule": "PrimitiveTypeMigrationRule",
|
||||||
|
"ruleArguments": [
|
||||||
|
""
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "BondingBegin",
|
||||||
|
"type": "System.DateTime",
|
||||||
|
"usesSaveFlag": true,
|
||||||
|
"rule": "PrimitiveTypeMigrationRule",
|
||||||
|
"ruleArguments": [
|
||||||
|
""
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "OwnerAbandonTime",
|
||||||
|
"type": "System.DateTime",
|
||||||
|
"usesSaveFlag": true,
|
||||||
|
"rule": "PrimitiveTypeMigrationRule",
|
||||||
|
"ruleArguments": [
|
||||||
|
""
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "HasGeneratedLoot",
|
||||||
|
"type": "bool",
|
||||||
|
"usesSaveFlag": true,
|
||||||
|
"rule": "PrimitiveTypeMigrationRule",
|
||||||
|
"ruleArguments": [
|
||||||
|
""
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "IsParagon",
|
||||||
|
"type": "bool",
|
||||||
|
"usesSaveFlag": true,
|
||||||
|
"rule": "PrimitiveTypeMigrationRule",
|
||||||
|
"ruleArguments": [
|
||||||
|
""
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Friends",
|
||||||
|
"type": "System.Collections.Generic.List\u003CServer.Mobile\u003E",
|
||||||
|
"usesSaveFlag": true,
|
||||||
|
"rule": "ListMigrationRule",
|
||||||
|
"ruleArguments": [
|
||||||
|
"@Tidy",
|
||||||
|
"Server.Mobile",
|
||||||
|
"SerializableInterfaceMigrationRule"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "RemoveIfUntamed",
|
||||||
|
"type": "bool",
|
||||||
|
"usesSaveFlag": true,
|
||||||
|
"rule": "PrimitiveTypeMigrationRule",
|
||||||
|
"ruleArguments": [
|
||||||
|
""
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "RemoveStep",
|
||||||
|
"type": "int",
|
||||||
|
"usesSaveFlag": true,
|
||||||
|
"rule": "PrimitiveTypeMigrationRule",
|
||||||
|
"ruleArguments": [
|
||||||
|
"EncodedInt"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "PendingDeleteTimer",
|
||||||
|
"type": "Server.Timer",
|
||||||
|
"usesSaveFlag": true,
|
||||||
|
"rule": "TimerMigrationRule",
|
||||||
|
"ruleArguments": [
|
||||||
|
"@AnchoredTimer"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "CorpseNameOverride",
|
||||||
|
"type": "string",
|
||||||
|
"usesSaveFlag": true,
|
||||||
|
"rule": "PrimitiveTypeMigrationRule",
|
||||||
|
"ruleArguments": [
|
||||||
|
""
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
@ -38,7 +38,7 @@ public class AnimalAI : BaseAI
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!WalkMobileRange(combatant, 1, Mobile.RangeFight, Mobile.RangeFight))
|
if (!WalkMobileRange(combatant, 1, false, Mobile.RangeFight, Mobile.RangeFight))
|
||||||
{
|
{
|
||||||
if (Mobile.GetDistanceToSqrt(combatant) > Mobile.RangePerception + 1)
|
if (Mobile.GetDistanceToSqrt(combatant) > Mobile.RangePerception + 1)
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@ public class ArcherAI : BaseAI
|
||||||
|
|
||||||
if (AcquireFocusMob(Mobile.RangePerception, Mobile.FightMode, false, false, true))
|
if (AcquireFocusMob(Mobile.RangePerception, Mobile.FightMode, false, false, true))
|
||||||
{
|
{
|
||||||
this.DebugSayFormatted($"I have detected {Mobile.FocusMob.Name}, attacking");
|
this.DebugSayFormatted($"I have detected {Mobile.FocusMob.Name} and I will attack");
|
||||||
|
|
||||||
Mobile.Combatant = Mobile.FocusMob;
|
Mobile.Combatant = Mobile.FocusMob;
|
||||||
Action = ActionType.Combat;
|
Action = ActionType.Combat;
|
||||||
|
|
@ -43,7 +43,7 @@ public class ArcherAI : BaseAI
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!WalkMobileRange(combatant, 1, Mobile.RangeFight, Mobile.Weapon.MaxRange))
|
if (!WalkMobileRange(combatant, 1, false, Mobile.RangeFight, Mobile.Weapon.MaxRange))
|
||||||
{
|
{
|
||||||
this.DebugSayFormatted($"I am still not in range of {combatant.Name}");
|
this.DebugSayFormatted($"I am still not in range of {combatant.Name}");
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -63,7 +63,7 @@ public abstract partial class BaseAI
|
||||||
return crowding;
|
return crowding;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static bool MoveToWithGroup(BaseAI ai, Mobile target, int range)
|
public static bool MoveToWithGroup(BaseAI ai, Mobile target, bool run, int range)
|
||||||
{
|
{
|
||||||
if (Core.TickCount - _lastGroupUpdateTime > 1000)
|
if (Core.TickCount - _lastGroupUpdateTime > 1000)
|
||||||
{
|
{
|
||||||
|
|
@ -79,7 +79,7 @@ public abstract partial class BaseAI
|
||||||
if (optimalPosition == Point3D.Zero)
|
if (optimalPosition == Point3D.Zero)
|
||||||
{
|
{
|
||||||
|
|
||||||
return ai.MoveToWithCollisionAvoidance(target, range);
|
return ai.MoveToWithCollisionAvoidance(target, run, range);
|
||||||
}
|
}
|
||||||
|
|
||||||
_reservedPositions[mobile] = optimalPosition;
|
_reservedPositions[mobile] = optimalPosition;
|
||||||
|
|
@ -99,7 +99,7 @@ public abstract partial class BaseAI
|
||||||
}
|
}
|
||||||
|
|
||||||
// A blocked or wall-slid step is not progress — route around the obstacle.
|
// A blocked or wall-slid step is not progress — route around the obstacle.
|
||||||
return ai.ApproachTarget(target, range);
|
return ai.ApproachTarget(target, run, range);
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,6 @@ using System.Runtime.CompilerServices;
|
||||||
using Server.Collections;
|
using Server.Collections;
|
||||||
using Server.Items;
|
using Server.Items;
|
||||||
using MoveImpl = Server.Movement.MovementImpl;
|
using MoveImpl = Server.Movement.MovementImpl;
|
||||||
using Moves = Server.Movement.Movement;
|
|
||||||
|
|
||||||
namespace Server.Mobiles;
|
namespace Server.Mobiles;
|
||||||
|
|
||||||
|
|
@ -44,6 +43,7 @@ public abstract partial class BaseAI
|
||||||
// live, the AITimer wakes at NextMove between think ticks to advance the step.
|
// live, the AITimer wakes at NextMove between think ticks to advance the step.
|
||||||
private Mobile _moveIntentTarget;
|
private Mobile _moveIntentTarget;
|
||||||
private IPoint3D _moveIntentPoint;
|
private IPoint3D _moveIntentPoint;
|
||||||
|
private bool _moveIntentRun;
|
||||||
private int _moveIntentRange;
|
private int _moveIntentRange;
|
||||||
private long _moveIntentExpire;
|
private long _moveIntentExpire;
|
||||||
|
|
||||||
|
|
@ -74,42 +74,23 @@ public abstract partial class BaseAI
|
||||||
return Core.TickCount - NextMove >= 0;
|
return Core.TickCount - NextMove >= 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Seconds per step as the client observes it: the move clock plus the hurt inflation.
|
// Accumulative full-step budget: long-run pacing averages CurrentMoveSpeed exactly
|
||||||
private double EffectiveStepDelay()
|
// regardless of timer-grid jitter; snap-to-now caps stall catch-up at one step.
|
||||||
|
private void ConsumeMoveBudget()
|
||||||
{
|
{
|
||||||
var stepDelay = Mobile.CurrentMoveSpeed;
|
var stepDelay = Mobile.CurrentMoveSpeed;
|
||||||
|
|
||||||
return Core.AOS && IsFollowingMaster() ? stepDelay : BadlyHurtMoveDelay(Mobile, stepDelay);
|
if (!(Core.AOS && IsFollowingMaster()))
|
||||||
}
|
|
||||||
|
|
||||||
// The Running bit only selects the client's per-step interpolation (walk 400ms / run
|
|
||||||
// 200ms on foot, 200/100 mounted). A step shorter than the walk time must run or the
|
|
||||||
// client falls behind and snaps — but an isolated step (after standing at least a walk
|
|
||||||
// interval) renders alone and darts if run-flagged, so it goes out as a walk. A true
|
|
||||||
// sprinter always runs: a walk-rendered first step would flood the client's queue.
|
|
||||||
public bool ShouldRun()
|
|
||||||
{
|
|
||||||
var mounted = Mobile.Mounted || Mobile.Flying;
|
|
||||||
var walkDelay = mounted ? Moves.WalkMountDelay : Moves.WalkFootDelay;
|
|
||||||
var pace = EffectiveStepDelay() * 1000;
|
|
||||||
|
|
||||||
if (pace >= walkDelay)
|
|
||||||
{
|
{
|
||||||
return false;
|
stepDelay = BadlyHurtMoveDelay(Mobile, stepDelay);
|
||||||
}
|
}
|
||||||
|
|
||||||
var runDelay = mounted ? Moves.RunMountDelay : Moves.RunFootDelay;
|
NextMove += Math.Max(50, (long)(stepDelay * 1000));
|
||||||
|
|
||||||
return pace < runDelay || Core.TickCount - Mobile.LastMoveTime < walkDelay;
|
if (Core.TickCount - NextMove > 0)
|
||||||
}
|
{
|
||||||
|
NextMove = Core.TickCount;
|
||||||
// One step per period, paced from the step just taken — no debt accrual: repaying a
|
}
|
||||||
// late step with a quicker follow-up puts two steps ~100ms apart, which renders as a
|
|
||||||
// dart. In continuous pursuit the move-wake lands within wheel resolution of this
|
|
||||||
// deadline, so the only cost is single-digit-ms drift per step.
|
|
||||||
private void ConsumeMoveBudget()
|
|
||||||
{
|
|
||||||
NextMove = Core.TickCount + Math.Max(50, (long)(EffectiveStepDelay() * 1000));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public virtual bool CheckMove() => !(Mobile.Deleted || Mobile.DisallowAllMoves);
|
public virtual bool CheckMove() => !(Mobile.Deleted || Mobile.DisallowAllMoves);
|
||||||
|
|
@ -127,8 +108,6 @@ public abstract partial class BaseAI
|
||||||
return MoveResult.BadState;
|
return MoveResult.BadState;
|
||||||
}
|
}
|
||||||
|
|
||||||
d = (d & Direction.Mask) | (ShouldRun() ? Direction.Running : 0);
|
|
||||||
|
|
||||||
if ((Mobile.Direction & Direction.Mask) != (d & Direction.Mask))
|
if ((Mobile.Direction & Direction.Mask) != (d & Direction.Mask))
|
||||||
{
|
{
|
||||||
Mobile.Direction = d;
|
Mobile.Direction = d;
|
||||||
|
|
@ -139,17 +118,18 @@ public abstract partial class BaseAI
|
||||||
|
|
||||||
if (TryMove(d))
|
if (TryMove(d))
|
||||||
{
|
{
|
||||||
// Obeying pets are paced by their order handlers.
|
// Writes the think clock only; hurt slowdown applies in ConsumeMoveBudget.
|
||||||
if (!IsObeyingMoveOrder())
|
if (Core.AOS && IsFollowingMaster())
|
||||||
{
|
{
|
||||||
if (Mobile.Warmode || Mobile.Combatant != null)
|
Mobile.CurrentSpeed = 0.1;
|
||||||
{
|
}
|
||||||
Mobile.SetCurrentSpeedToActive();
|
else if (Mobile.Warmode || Mobile.Combatant != null)
|
||||||
}
|
{
|
||||||
else
|
Mobile.SetCurrentSpeedToActive();
|
||||||
{
|
}
|
||||||
Mobile.SetCurrentSpeedToPassive();
|
else
|
||||||
}
|
{
|
||||||
|
Mobile.SetCurrentSpeedToPassive();
|
||||||
}
|
}
|
||||||
|
|
||||||
ConsumeMoveBudget();
|
ConsumeMoveBudget();
|
||||||
|
|
@ -355,7 +335,7 @@ public abstract partial class BaseAI
|
||||||
/// best-distance stall counter idles the creature if an in-range goal is genuinely
|
/// best-distance stall counter idles the creature if an in-range goal is genuinely
|
||||||
/// unreachable, without ever abandoning a real chase or detour.
|
/// unreachable, without ever abandoning a real chase or detour.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
protected bool ApproachTarget(Mobile target, int range)
|
protected bool ApproachTarget(Mobile target, bool run, int range)
|
||||||
{
|
{
|
||||||
if (Mobile.Deleted || Mobile.DisallowAllMoves || target?.Deleted != false)
|
if (Mobile.Deleted || Mobile.DisallowAllMoves || target?.Deleted != false)
|
||||||
{
|
{
|
||||||
|
|
@ -382,7 +362,7 @@ public abstract partial class BaseAI
|
||||||
ResetApproach(); // target moved — try again fresh
|
ResetApproach(); // target moved — try again fresh
|
||||||
}
|
}
|
||||||
|
|
||||||
RenewMoveIntent(target, null, range);
|
RenewMoveIntent(target, null, run, range);
|
||||||
|
|
||||||
// FAST PATH: greedy step toward the target, counted as success ONLY when the move
|
// FAST PATH: greedy step toward the target, counted as success ONLY when the move
|
||||||
// fully succeeded (not an auto-turn sidestep) and actually got us closer. An
|
// fully succeeded (not an auto-turn sidestep) and actually got us closer. An
|
||||||
|
|
@ -394,7 +374,7 @@ public abstract partial class BaseAI
|
||||||
if (Path == null && Mobile.InLOS(target))
|
if (Path == null && Mobile.InLOS(target))
|
||||||
{
|
{
|
||||||
var distBefore = Mobile.GetDistanceToSqrt(target);
|
var distBefore = Mobile.GetDistanceToSqrt(target);
|
||||||
var res = DoMoveImpl(Mobile.GetDirectionTo(target), true);
|
var res = DoMoveImpl(Mobile.GetDirectionTo(target, run), true);
|
||||||
|
|
||||||
if (res == MoveResult.BadState)
|
if (res == MoveResult.BadState)
|
||||||
{
|
{
|
||||||
|
|
@ -423,7 +403,7 @@ public abstract partial class BaseAI
|
||||||
var couldMove = CanMoveNow(out _) && !IsInBadState();
|
var couldMove = CanMoveNow(out _) && !IsInBadState();
|
||||||
var locBefore = Mobile.Location;
|
var locBefore = Mobile.Location;
|
||||||
|
|
||||||
if (Path.Follow(range))
|
if (Path.Follow(run, range))
|
||||||
{
|
{
|
||||||
ResetApproach();
|
ResetApproach();
|
||||||
return true;
|
return true;
|
||||||
|
|
@ -442,7 +422,7 @@ public abstract partial class BaseAI
|
||||||
/// Walks toward a fixed point (e.g. a target's last-known position), pathfinding around
|
/// Walks toward a fixed point (e.g. a target's last-known position), pathfinding around
|
||||||
/// obstacles. Returns false on arrival or when genuinely unable to make progress.
|
/// obstacles. Returns false on arrival or when genuinely unable to make progress.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public bool MoveToPoint(IPoint3D goal)
|
public bool MoveToPoint(IPoint3D goal, bool run)
|
||||||
{
|
{
|
||||||
if (Mobile.Deleted || Mobile.DisallowAllMoves || goal == null)
|
if (Mobile.Deleted || Mobile.DisallowAllMoves || goal == null)
|
||||||
{
|
{
|
||||||
|
|
@ -455,12 +435,12 @@ public abstract partial class BaseAI
|
||||||
Path = new PathFollower(Mobile, goal) { Mover = DoMoveImpl };
|
Path = new PathFollower(Mobile, goal) { Mover = DoMoveImpl };
|
||||||
}
|
}
|
||||||
|
|
||||||
RenewMoveIntent(null, goal, 1);
|
RenewMoveIntent(null, goal, run, 1);
|
||||||
|
|
||||||
var couldMove = CanMoveNow(out _) && !IsInBadState();
|
var couldMove = CanMoveNow(out _) && !IsInBadState();
|
||||||
var locBefore = Mobile.Location;
|
var locBefore = Mobile.Location;
|
||||||
|
|
||||||
if (Path.Follow(1))
|
if (Path.Follow(run, 1))
|
||||||
{
|
{
|
||||||
Path = null;
|
Path = null;
|
||||||
ClearMoveIntent();
|
ClearMoveIntent();
|
||||||
|
|
@ -536,10 +516,11 @@ public abstract partial class BaseAI
|
||||||
_approachGaveUp = false;
|
_approachGaveUp = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void RenewMoveIntent(Mobile target, IPoint3D point, int range)
|
private void RenewMoveIntent(Mobile target, IPoint3D point, bool run, int range)
|
||||||
{
|
{
|
||||||
_moveIntentTarget = target;
|
_moveIntentTarget = target;
|
||||||
_moveIntentPoint = point;
|
_moveIntentPoint = point;
|
||||||
|
_moveIntentRun = run;
|
||||||
_moveIntentRange = range;
|
_moveIntentRange = range;
|
||||||
|
|
||||||
// A live pursuit renews every think tick; unrenewed intent dies on its own.
|
// A live pursuit renews every think tick; unrenewed intent dies on its own.
|
||||||
|
|
@ -560,7 +541,8 @@ public abstract partial class BaseAI
|
||||||
{
|
{
|
||||||
nextMove = NextMove;
|
nextMove = NextMove;
|
||||||
|
|
||||||
return (_moveIntentTarget != null || _moveIntentPoint != null) && Core.TickCount - _moveIntentExpire < 0;
|
return (_moveIntentTarget != null || _moveIntentPoint != null) &&
|
||||||
|
Core.TickCount - _moveIntentExpire < 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
@ -576,21 +558,26 @@ public abstract partial class BaseAI
|
||||||
|
|
||||||
if (_moveIntentTarget != null)
|
if (_moveIntentTarget != null)
|
||||||
{
|
{
|
||||||
ApproachTarget(_moveIntentTarget, _moveIntentRange);
|
ApproachTarget(_moveIntentTarget, _moveIntentRun, _moveIntentRange);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
MoveToPoint(_moveIntentPoint);
|
MoveToPoint(_moveIntentPoint, _moveIntentRun);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public virtual bool MoveTo(Mobile m, int range)
|
public virtual bool MoveTo(Mobile m, bool run, int range)
|
||||||
{
|
{
|
||||||
if (Mobile.Deleted || Mobile.DisallowAllMoves || m?.Deleted != false)
|
if (Mobile.Deleted || Mobile.DisallowAllMoves || m?.Deleted != false)
|
||||||
{
|
{
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var distance = (int)Mobile.GetDistanceToSqrt(m);
|
||||||
|
var distanceThreshold = Core.AOS && IsFollowingMaster() ? 1 : 5;
|
||||||
|
|
||||||
|
var shouldRun = run && distance > distanceThreshold;
|
||||||
|
|
||||||
if (Mobile.InRange(m, range))
|
if (Mobile.InRange(m, range))
|
||||||
{
|
{
|
||||||
ResetApproach();
|
ResetApproach();
|
||||||
|
|
@ -599,10 +586,10 @@ public abstract partial class BaseAI
|
||||||
|
|
||||||
if (UseGroupMovement(m, range))
|
if (UseGroupMovement(m, range))
|
||||||
{
|
{
|
||||||
return MoveToWithGroup(this, m, range);
|
return MoveToWithGroup(this, m, shouldRun, range);
|
||||||
}
|
}
|
||||||
|
|
||||||
return ApproachTarget(m, range);
|
return ApproachTarget(m, shouldRun, range);
|
||||||
}
|
}
|
||||||
|
|
||||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||||
|
|
@ -612,15 +599,12 @@ public abstract partial class BaseAI
|
||||||
Mobile.ControlTarget == Mobile.ControlMaster &&
|
Mobile.ControlTarget == Mobile.ControlMaster &&
|
||||||
Mobile.Combatant == null;
|
Mobile.Combatant == null;
|
||||||
|
|
||||||
// A pet executing a movement order outside combat; its order handler owns its speed.
|
private bool MoveToWithCollisionAvoidance(Mobile target, bool run, int range)
|
||||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
|
||||||
public bool IsObeyingMoveOrder() =>
|
|
||||||
Mobile.Controlled &&
|
|
||||||
Mobile.Combatant == null &&
|
|
||||||
Mobile.ControlOrder is OrderType.Come or OrderType.Follow or OrderType.Guard;
|
|
||||||
|
|
||||||
private bool MoveToWithCollisionAvoidance(Mobile target, int range)
|
|
||||||
{
|
{
|
||||||
|
var distance = (int)Mobile.GetDistanceToSqrt(target);
|
||||||
|
|
||||||
|
var shouldRun = run && distance > 5;
|
||||||
|
|
||||||
var direction = Mobile.GetDirectionTo(target);
|
var direction = Mobile.GetDirectionTo(target);
|
||||||
|
|
||||||
// Wall-slide auto-turns must not count as progress, or a creature pinned on
|
// Wall-slide auto-turns must not count as progress, or a creature pinned on
|
||||||
|
|
@ -651,10 +635,10 @@ public abstract partial class BaseAI
|
||||||
|
|
||||||
// Tactical sidesteps exhausted — route around the obstacle via the centralized
|
// Tactical sidesteps exhausted — route around the obstacle via the centralized
|
||||||
// approach primitive (persistent PathFollower, no oscillation).
|
// approach primitive (persistent PathFollower, no oscillation).
|
||||||
return ApproachTarget(target, range);
|
return ApproachTarget(target, shouldRun, range);
|
||||||
}
|
}
|
||||||
|
|
||||||
public virtual bool WalkMobileRange(Mobile m, int iSteps, int iWantDistMin, int iWantDistMax)
|
public virtual bool WalkMobileRange(Mobile m, int iSteps, bool run, int iWantDistMin, int iWantDistMax)
|
||||||
{
|
{
|
||||||
if (Mobile.Deleted || Mobile.DisallowAllMoves || m == null)
|
if (Mobile.Deleted || Mobile.DisallowAllMoves || m == null)
|
||||||
{
|
{
|
||||||
|
|
@ -665,12 +649,14 @@ public abstract partial class BaseAI
|
||||||
{
|
{
|
||||||
var iCurrDist = (int)Mobile.GetDistanceToSqrt(m);
|
var iCurrDist = (int)Mobile.GetDistanceToSqrt(m);
|
||||||
|
|
||||||
|
var shouldRun = run && iCurrDist > 5;
|
||||||
|
|
||||||
if (iCurrDist >= iWantDistMin && iCurrDist <= iWantDistMax)
|
if (iCurrDist >= iWantDistMin && iCurrDist <= iWantDistMax)
|
||||||
{
|
{
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!MoveTowardsOrAwayFrom(m, iCurrDist, iWantDistMax))
|
if (!MoveTowardsOrAwayFrom(m, shouldRun, iCurrDist, iWantDistMax))
|
||||||
{
|
{
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
@ -681,16 +667,18 @@ public abstract partial class BaseAI
|
||||||
return dist >= iWantDistMin && dist <= iWantDistMax;
|
return dist >= iWantDistMin && dist <= iWantDistMax;
|
||||||
}
|
}
|
||||||
|
|
||||||
private bool MoveTowardsOrAwayFrom(Mobile m, int iCurrDist, int iWantDistMax)
|
private bool MoveTowardsOrAwayFrom(Mobile m, bool run, int iCurrDist, int iWantDistMax)
|
||||||
{
|
{
|
||||||
|
var shouldRun = run && iCurrDist > 5;
|
||||||
|
|
||||||
if (iCurrDist > iWantDistMax)
|
if (iCurrDist > iWantDistMax)
|
||||||
{
|
{
|
||||||
// Too far: approach via the centralized progress-based primitive.
|
// Too far: approach via the centralized progress-based primitive.
|
||||||
return ApproachTarget(m, iWantDistMax);
|
return ApproachTarget(m, shouldRun, iWantDistMax);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Too close: back away. Retreat keeps the simple greedy behavior (out of scope).
|
// Too close: back away. Retreat keeps the simple greedy behavior (out of scope).
|
||||||
if (DoMove(m.GetDirectionTo(Mobile), true))
|
if (DoMove(m.GetDirectionTo(Mobile, shouldRun), true))
|
||||||
{
|
{
|
||||||
Path = null;
|
Path = null;
|
||||||
return true;
|
return true;
|
||||||
|
|
|
||||||
|
|
@ -26,13 +26,11 @@ public sealed class AITimer : Timer
|
||||||
{
|
{
|
||||||
private readonly BaseAI _owner;
|
private readonly BaseAI _owner;
|
||||||
private long _nextThink;
|
private long _nextThink;
|
||||||
private long _nextWake; // when the pending wheel entry fires
|
|
||||||
private bool _inTick;
|
|
||||||
private int _detectHiddenMinDelay;
|
private int _detectHiddenMinDelay;
|
||||||
private int _detectHiddenMaxDelay;
|
private int _detectHiddenMaxDelay;
|
||||||
|
|
||||||
// The initial delay is irrelevant: Activate is the only start path and sets its own.
|
public AITimer(BaseAI owner) : base(TimeSpan.FromMilliseconds(Utility.Random(3000)),
|
||||||
public AITimer(BaseAI owner) : base(TimeSpan.Zero, TimeSpan.FromSeconds(owner.Mobile.CurrentSpeed))
|
TimeSpan.FromSeconds(owner.Mobile.CurrentSpeed))
|
||||||
{
|
{
|
||||||
_owner = owner;
|
_owner = owner;
|
||||||
_owner._nextDetectHidden = Core.TickCount;
|
_owner._nextDetectHidden = Core.TickCount;
|
||||||
|
|
@ -42,34 +40,8 @@ public sealed class AITimer : Timer
|
||||||
public void Activate()
|
public void Activate()
|
||||||
{
|
{
|
||||||
_nextThink = Core.TickCount;
|
_nextThink = Core.TickCount;
|
||||||
|
Interval = TimeSpan.FromSeconds(_owner.Mobile.CurrentSpeed);
|
||||||
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();
|
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.
|
// A speed-up must not wait out a stale, longer think deadline.
|
||||||
|
|
@ -80,53 +52,12 @@ public sealed class AITimer : Timer
|
||||||
if (candidate - _nextThink < 0)
|
if (candidate - _nextThink < 0)
|
||||||
{
|
{
|
||||||
_nextThink = candidate;
|
_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;
|
Interval = TimeSpan.FromSeconds(_owner.Mobile.CurrentSpeed);
|
||||||
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()
|
protected override void OnTick()
|
||||||
{
|
|
||||||
_inTick = true;
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
OnTickCore();
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
_inTick = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void OnTickCore()
|
|
||||||
{
|
{
|
||||||
if (ShouldStop())
|
if (ShouldStop())
|
||||||
{
|
{
|
||||||
|
|
@ -152,18 +83,7 @@ public sealed class AITimer : Timer
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cadence from the post-decision speed (decisions may flip active/passive).
|
// Cadence from the post-decision speed (decisions may flip active/passive).
|
||||||
var period = (long)(_owner.Mobile.CurrentSpeed * 1000);
|
_nextThink = Core.TickCount + (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
|
else
|
||||||
{
|
{
|
||||||
|
|
@ -191,7 +111,6 @@ public sealed class AITimer : Timer
|
||||||
|
|
||||||
// The wheel rounds up to its 8ms resolution; a non-positive delay becomes one turn.
|
// The wheel rounds up to its 8ms resolution; a non-positive delay becomes one turn.
|
||||||
Interval = TimeSpan.FromMilliseconds(delay);
|
Interval = TimeSpan.FromMilliseconds(delay);
|
||||||
_nextWake = now + (long)Interval.TotalMilliseconds;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private bool ShouldStop()
|
private bool ShouldStop()
|
||||||
|
|
|
||||||
|
|
@ -64,7 +64,7 @@ public abstract partial class BaseAI
|
||||||
|
|
||||||
if (!m.PlayerRangeSensitive || !World.Loading && m.Map != null && m.Map != Map.Internal && m.Map.GetSector(m.Location).Active)
|
if (!m.PlayerRangeSensitive || !World.Loading && m.Map != null && m.Map != Map.Internal && m.Map.GetSector(m.Location).Active)
|
||||||
{
|
{
|
||||||
AITimer.Activate();
|
AITimer.Start();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Action != ActionType.Wander)
|
if (Action != ActionType.Wander)
|
||||||
|
|
@ -442,7 +442,7 @@ public abstract partial class BaseAI
|
||||||
var master = Mobile.SummonMaster;
|
var master = Mobile.SummonMaster;
|
||||||
if (master != null && master.Map == Mobile.Map && master.InRange(Mobile, Mobile.RangePerception))
|
if (master != null && master.Map == Mobile.Map && master.InRange(Mobile, Mobile.RangePerception))
|
||||||
{
|
{
|
||||||
MoveTo(master, 1);
|
MoveTo(master, false, 1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -592,7 +592,7 @@ public abstract partial class BaseAI
|
||||||
}
|
}
|
||||||
|
|
||||||
_lkpGoal ??= _lkpLocation;
|
_lkpGoal ??= _lkpLocation;
|
||||||
return MoveToPoint(_lkpGoal);
|
return MoveToPoint(_lkpGoal, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void ClearLastKnown()
|
private void ClearLastKnown()
|
||||||
|
|
@ -644,7 +644,7 @@ public abstract partial class BaseAI
|
||||||
_herdGoal = new Point3D(target.X, target.Y, Mobile.Map?.GetAverageZ(target.X, target.Y) ?? Mobile.Z);
|
_herdGoal = new Point3D(target.X, target.Y, Mobile.Map?.GetAverageZ(target.X, target.Y) ?? Mobile.Z);
|
||||||
}
|
}
|
||||||
|
|
||||||
MoveToPoint(_herdGoal);
|
MoveToPoint(_herdGoal, false);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -798,7 +798,7 @@ public abstract partial class BaseAI
|
||||||
{
|
{
|
||||||
if (AcquireFocusMob(Mobile.RangePerception * 2, FightMode.Closest, true, false, true))
|
if (AcquireFocusMob(Mobile.RangePerception * 2, FightMode.Closest, true, false, true))
|
||||||
{
|
{
|
||||||
if (WalkMobileRange(Mobile.FocusMob, 1, Mobile.RangePerception, Mobile.RangePerception * 2))
|
if (WalkMobileRange(Mobile.FocusMob, 1, false, Mobile.RangePerception, Mobile.RangePerception * 2))
|
||||||
{
|
{
|
||||||
DebugSay("I backed off to safety. Wandering...");
|
DebugSay("I backed off to safety. Wandering...");
|
||||||
|
|
||||||
|
|
@ -850,24 +850,22 @@ public abstract partial class BaseAI
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
var reacquireDelay = (long)Mobile.ReacquireDelay.TotalMilliseconds;
|
if (Core.TickCount - Mobile.NextReacquireTime < 0)
|
||||||
var gateRemaining = Mobile.NextReacquireTime - Core.TickCount;
|
|
||||||
|
|
||||||
if (gateRemaining > 0 && gateRemaining <= reacquireDelay)
|
|
||||||
{
|
{
|
||||||
Mobile.FocusMob = null;
|
Mobile.FocusMob = null;
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
DebugSay("Acquiring new target...", 0);
|
Mobile.NextReacquireTime = Core.TickCount + (int)Mobile.ReacquireDelay.TotalMilliseconds;
|
||||||
|
|
||||||
var acquired = AcquireNewFocusMob(Mobile.Map, iRange, acqType, bPlayerOnly, bFacFriend, bFacFoe);
|
DebugSay("Acquiring new target...");
|
||||||
|
|
||||||
// Reaction time is the approach path (BaseCreature.ScheduleAcquireOnApproach),
|
if (Mobile.Map == null)
|
||||||
// not this poll — every scan honors the full delay.
|
{
|
||||||
Mobile.NextReacquireTime = Core.TickCount + reacquireDelay;
|
return Mobile.FocusMob != null;
|
||||||
|
}
|
||||||
|
|
||||||
return acquired;
|
return AcquireNewFocusMob(Mobile.Map, iRange, acqType, bPlayerOnly, bFacFriend, bFacFoe);
|
||||||
}
|
}
|
||||||
|
|
||||||
private bool HandleBardProvoked()
|
private bool HandleBardProvoked()
|
||||||
|
|
@ -943,10 +941,8 @@ public abstract partial class BaseAI
|
||||||
|
|
||||||
private bool AcquireNewFocusMob(Map map, int iRange, FightMode acqType, bool bPlayerOnly, bool bFacFriend, bool bFacFoe)
|
private bool AcquireNewFocusMob(Map map, int iRange, FightMode acqType, bool bPlayerOnly, bool bFacFriend, bool bFacFoe)
|
||||||
{
|
{
|
||||||
Mobile newFocusMob = null;
|
Mobile newFocusMob = null, enemySummonMob = null;
|
||||||
Mobile enemySummonMob = null;
|
double val = double.MinValue, enemySummonVal = double.MinValue;
|
||||||
var val = double.MinValue;
|
|
||||||
var enemySummonVal = double.MinValue;
|
|
||||||
|
|
||||||
foreach (var m in map.GetMobilesInRange(Mobile.Location, iRange))
|
foreach (var m in map.GetMobilesInRange(Mobile.Location, iRange))
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -447,11 +447,6 @@ public abstract partial class BaseAI
|
||||||
if (Mobile.FindMyName(e.Speech, true) && e.Speech.InsensitiveContains("obey"))
|
if (Mobile.FindMyName(e.Speech, true) && e.Speech.InsensitiveContains("obey"))
|
||||||
{
|
{
|
||||||
Mobile.SetControlMaster(e.Mobile);
|
Mobile.SetControlMaster(e.Mobile);
|
||||||
|
|
||||||
if (Mobile.Summoned)
|
|
||||||
{
|
|
||||||
Mobile.SummonMaster = e.Mobile;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,7 @@ public abstract partial class BaseAI
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
AITimer.Prod();
|
Activate();
|
||||||
|
|
||||||
switch (Mobile.ControlOrder)
|
switch (Mobile.ControlOrder)
|
||||||
{
|
{
|
||||||
|
|
@ -36,10 +36,6 @@ public abstract partial class BaseAI
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case OrderType.Come:
|
case OrderType.Come:
|
||||||
{
|
|
||||||
Mobile.SetCurrentSpeedToActive();
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case OrderType.Drop:
|
case OrderType.Drop:
|
||||||
case OrderType.Friend:
|
case OrderType.Friend:
|
||||||
case OrderType.Unfriend:
|
case OrderType.Unfriend:
|
||||||
|
|
@ -139,7 +135,6 @@ public abstract partial class BaseAI
|
||||||
Mobile.FocusMob = null;
|
Mobile.FocusMob = null;
|
||||||
Mobile.Warmode = false;
|
Mobile.Warmode = false;
|
||||||
Mobile.Combatant = null;
|
Mobile.Combatant = null;
|
||||||
Mobile.SetCurrentSpeedToPassive();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void HandleTransferOrder()
|
private void HandleTransferOrder()
|
||||||
|
|
@ -153,7 +148,6 @@ public abstract partial class BaseAI
|
||||||
Mobile.FocusMob = null;
|
Mobile.FocusMob = null;
|
||||||
Mobile.Warmode = false;
|
Mobile.Warmode = false;
|
||||||
Mobile.Combatant = null;
|
Mobile.Combatant = null;
|
||||||
Mobile.SetCurrentSpeedToPassive();
|
|
||||||
Mobile.PlaySound(Mobile.GetIdleSound());
|
Mobile.PlaySound(Mobile.GetIdleSound());
|
||||||
_commandIssuer = null;
|
_commandIssuer = null;
|
||||||
}
|
}
|
||||||
|
|
@ -168,16 +162,9 @@ public abstract partial class BaseAI
|
||||||
_commandIssuer?.RevealingAction();
|
_commandIssuer?.RevealingAction();
|
||||||
Mobile.FocusMob = null;
|
Mobile.FocusMob = null;
|
||||||
Mobile.Warmode = true;
|
Mobile.Warmode = true;
|
||||||
Mobile.SetCurrentSpeedToActive();
|
Mobile.PlaySound(Mobile.GetAttackSound());
|
||||||
|
Mobile.ControlMaster?.SendLocalizedMessage(1049671, Mobile.Name);
|
||||||
// Resuming the persistent order must not replay the flourish.
|
// ~1_NAME~ is now guarding you.
|
||||||
if (!_resolvingOrder)
|
|
||||||
{
|
|
||||||
Mobile.PlaySound(Mobile.GetAttackSound());
|
|
||||||
Mobile.ControlMaster?.SendLocalizedMessage(1049671, Mobile.Name);
|
|
||||||
// ~1_NAME~ is now guarding you.
|
|
||||||
}
|
|
||||||
|
|
||||||
_commandIssuer = null;
|
_commandIssuer = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -204,7 +191,6 @@ public abstract partial class BaseAI
|
||||||
}
|
}
|
||||||
|
|
||||||
Mobile.Warmode = true;
|
Mobile.Warmode = true;
|
||||||
Mobile.SetCurrentSpeedToActive();
|
|
||||||
Mobile.PlaySound(Mobile.GetAttackSound());
|
Mobile.PlaySound(Mobile.GetAttackSound());
|
||||||
_commandIssuer = null;
|
_commandIssuer = null;
|
||||||
}
|
}
|
||||||
|
|
@ -220,7 +206,6 @@ public abstract partial class BaseAI
|
||||||
Mobile.FocusMob = null;
|
Mobile.FocusMob = null;
|
||||||
Mobile.Warmode = false;
|
Mobile.Warmode = false;
|
||||||
Mobile.Combatant = null;
|
Mobile.Combatant = null;
|
||||||
Mobile.SetCurrentSpeedToActive();
|
|
||||||
Mobile.PlaySound(Mobile.GetIdleSound());
|
Mobile.PlaySound(Mobile.GetIdleSound());
|
||||||
_commandIssuer = null;
|
_commandIssuer = null;
|
||||||
}
|
}
|
||||||
|
|
@ -236,7 +221,6 @@ public abstract partial class BaseAI
|
||||||
Mobile.FocusMob = null;
|
Mobile.FocusMob = null;
|
||||||
Mobile.Warmode = false;
|
Mobile.Warmode = false;
|
||||||
Mobile.Combatant = null;
|
Mobile.Combatant = null;
|
||||||
Mobile.SetCurrentSpeedToPassive();
|
|
||||||
Mobile.PlaySound(Mobile.GetIdleSound());
|
Mobile.PlaySound(Mobile.GetIdleSound());
|
||||||
_commandIssuer = null;
|
_commandIssuer = null;
|
||||||
// Home (the stay anchor) is owned by SetPersistentOrder, not this handler.
|
// Home (the stay anchor) is owned by SetPersistentOrder, not this handler.
|
||||||
|
|
|
||||||
|
|
@ -84,7 +84,7 @@ public abstract partial class BaseAI
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
WalkMobileRange(Mobile.ControlMaster, 1, 1, 2);
|
WalkMobileRange(Mobile.ControlMaster, 1, false, 1, 2);
|
||||||
|
|
||||||
if (Mobile.GetDistanceToSqrt(Mobile.ControlMaster) <= 2)
|
if (Mobile.GetDistanceToSqrt(Mobile.ControlMaster) <= 2)
|
||||||
{
|
{
|
||||||
|
|
@ -128,15 +128,9 @@ public abstract partial class BaseAI
|
||||||
|
|
||||||
this.DebugSayFormatted($"I am ordered to follow {Mobile.ControlTarget?.Name}.");
|
this.DebugSayFormatted($"I am ordered to follow {Mobile.ControlTarget?.Name}.");
|
||||||
|
|
||||||
// AOS: sprint after the master (bespoke 0.1 paces both clocks).
|
|
||||||
if (Core.AOS && Mobile.ControlTarget == Mobile.ControlMaster && Mobile.Combatant == null)
|
|
||||||
{
|
|
||||||
Mobile.CurrentSpeed = 0.1;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (currentDistance > 1)
|
if (currentDistance > 1)
|
||||||
{
|
{
|
||||||
WalkMobileRange(Mobile.ControlTarget, 1, 1, 2);
|
WalkMobileRange(Mobile.ControlTarget, 1, currentDistance > 2, 1, 2);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -297,13 +291,14 @@ public abstract partial class BaseAI
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
var combatant = FindGuardTarget();
|
FindCombatant();
|
||||||
|
|
||||||
if (combatant != null)
|
if (IsValidCombatant(Mobile.Combatant))
|
||||||
{
|
{
|
||||||
|
var combatant = Mobile.Combatant;
|
||||||
|
|
||||||
this.DebugSayFormatted($"Attacking target: {combatant.Name}");
|
this.DebugSayFormatted($"Attacking target: {combatant.Name}");
|
||||||
|
|
||||||
// Engage without leaving the Guard order so tags, recall handling, and retargeting persist.
|
|
||||||
Mobile.Combatant = combatant;
|
Mobile.Combatant = combatant;
|
||||||
Mobile.FocusMob = combatant;
|
Mobile.FocusMob = combatant;
|
||||||
Action = ActionType.Combat;
|
Action = ActionType.Combat;
|
||||||
|
|
@ -314,30 +309,16 @@ public abstract partial class BaseAI
|
||||||
{
|
{
|
||||||
this.DebugSayFormatted($"Guarding my master, {controlMaster.Name}.");
|
this.DebugSayFormatted($"Guarding my master, {controlMaster.Name}.");
|
||||||
|
|
||||||
// Stand down; a stale Warmode would skew the return pace.
|
var guardLocation = controlMaster.Location;
|
||||||
Mobile.FocusMob = null;
|
|
||||||
Mobile.Warmode = false;
|
|
||||||
Mobile.Combatant = null;
|
|
||||||
|
|
||||||
var distance = (int)Mobile.GetDistanceToSqrt(controlMaster);
|
var distance = (int)Mobile.GetDistanceToSqrt(guardLocation);
|
||||||
|
|
||||||
if (distance > 3)
|
if (distance > 3)
|
||||||
{
|
{
|
||||||
// AOS: sprint back (bespoke 0.1 paces both clocks); earlier eras run active.
|
DoMove(Mobile.GetDirectionTo(guardLocation));
|
||||||
if (Core.AOS)
|
|
||||||
{
|
|
||||||
Mobile.CurrentSpeed = 0.1;
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
Mobile.SetCurrentSpeedToActive();
|
|
||||||
}
|
|
||||||
|
|
||||||
WalkMobileRange(controlMaster, 1, 1, 3);
|
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
Mobile.SetCurrentSpeedToActive(); // alert at the master's side
|
|
||||||
WalkRandom(3, 1, 1);
|
WalkRandom(3, 1, 1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -378,83 +359,67 @@ public abstract partial class BaseAI
|
||||||
Mobile.ControlTarget = Mobile.ControlMaster;
|
Mobile.ControlTarget = Mobile.ControlMaster;
|
||||||
ResumePersistentOrder();
|
ResumePersistentOrder();
|
||||||
|
|
||||||
// A resumed Guard engages through its own scan; other fallbacks chain an explicit Attack.
|
if (Mobile.FightMode is FightMode.Closest or FightMode.Aggressor)
|
||||||
if (Mobile.ControlOrder == OrderType.Guard ||
|
|
||||||
Mobile.FightMode is not (FightMode.Closest or FightMode.Aggressor))
|
|
||||||
{
|
{
|
||||||
return;
|
FindCombatant();
|
||||||
}
|
|
||||||
|
|
||||||
var next = FindGuardTarget();
|
|
||||||
|
|
||||||
if (next != null)
|
|
||||||
{
|
|
||||||
Mobile.ControlTarget = next;
|
|
||||||
Mobile.ControlOrder = OrderType.Attack;
|
|
||||||
Mobile.Combatant = next;
|
|
||||||
|
|
||||||
this.DebugSayFormatted($"{next.Name} is still hostile! Engaging...");
|
|
||||||
|
|
||||||
Think();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
private void FindCombatant()
|
||||||
/// Selects the aggressor closest to the master. The current combatant is kept
|
|
||||||
/// unless a strictly closer one exists. Never mutates order state.
|
|
||||||
/// </summary>
|
|
||||||
private Mobile FindGuardTarget()
|
|
||||||
{
|
{
|
||||||
var controlMaster = Mobile.ControlMaster;
|
var controlMaster = Mobile.ControlMaster;
|
||||||
var anchor = controlMaster ?? Mobile;
|
|
||||||
|
|
||||||
var current = Mobile.Combatant;
|
|
||||||
var best = current != controlMaster && IsValidCombatant(current) ? current : null;
|
|
||||||
var bestDist = best?.GetDistanceToSqrt(anchor) ?? double.MaxValue;
|
|
||||||
|
|
||||||
foreach (var aggr in Mobile.GetMobilesInRange(Mobile.RangePerception))
|
foreach (var aggr in Mobile.GetMobilesInRange(Mobile.RangePerception))
|
||||||
{
|
{
|
||||||
if (aggr == best || aggr == Mobile || aggr == controlMaster ||
|
if (!Mobile.CanSee(aggr) || aggr.IsDeadBondedPet || !aggr.Alive)
|
||||||
aggr.IsDeadBondedPet || !aggr.Alive ||
|
|
||||||
aggr.Combatant != Mobile && (controlMaster == null || aggr.Combatant != controlMaster))
|
|
||||||
{
|
{
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
var dist = aggr.GetDistanceToSqrt(anchor);
|
var isAttackingPet = aggr.Combatant == Mobile;
|
||||||
|
var isAttackingMaster = controlMaster != null && aggr.Combatant == controlMaster;
|
||||||
|
|
||||||
if (dist < bestDist && Mobile.CanSee(aggr) && Mobile.InLOS(aggr))
|
if (isAttackingPet || isAttackingMaster)
|
||||||
{
|
{
|
||||||
best = aggr;
|
if (Mobile.InLOS(aggr))
|
||||||
bestDist = dist;
|
{
|
||||||
|
Mobile.ControlTarget = aggr;
|
||||||
|
Mobile.ControlOrder = OrderType.Attack;
|
||||||
|
Mobile.Combatant = aggr;
|
||||||
|
|
||||||
|
var target = isAttackingMaster ? "master" : "me";
|
||||||
|
this.DebugSayFormatted($"{aggr.Name} is attacking my {target}! Engaging...");
|
||||||
|
|
||||||
|
Think();
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var aggressors = controlMaster?.Aggressors;
|
if (controlMaster?.Aggressors != null)
|
||||||
|
|
||||||
if (aggressors != null)
|
|
||||||
{
|
{
|
||||||
for (var i = 0; i < aggressors.Count; i++)
|
for (var i = 0; i < controlMaster.Aggressors.Count; i++)
|
||||||
{
|
{
|
||||||
var aggressor = aggressors[i].Attacker;
|
var aggressor = controlMaster.Aggressors[i].Attacker;
|
||||||
|
|
||||||
if (aggressor == best || aggressor?.Deleted != false || !aggressor.Alive ||
|
if (aggressor?.Deleted != false || !aggressor.Alive || aggressor.IsDeadBondedPet)
|
||||||
aggressor.IsDeadBondedPet || !Mobile.InRange(aggressor, Mobile.RangePerception))
|
|
||||||
{
|
{
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
var dist = aggressor.GetDistanceToSqrt(anchor);
|
if (Mobile.InRange(aggressor, Mobile.RangePerception) && Mobile.CanSee(aggressor) && Mobile.InLOS(aggressor))
|
||||||
|
|
||||||
if (dist < bestDist && Mobile.CanSee(aggressor) && Mobile.InLOS(aggressor))
|
|
||||||
{
|
{
|
||||||
best = aggressor;
|
Mobile.ControlTarget = aggressor;
|
||||||
bestDist = dist;
|
Mobile.ControlOrder = OrderType.Attack;
|
||||||
|
Mobile.Combatant = aggressor;
|
||||||
|
|
||||||
|
this.DebugSayFormatted($"{aggressor.Name} recently attacked my master! Retaliating...");
|
||||||
|
|
||||||
|
Think();
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return best;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public virtual bool DoOrderRelease()
|
public virtual bool DoOrderRelease()
|
||||||
|
|
|
||||||
|
|
@ -156,11 +156,6 @@ internal sealed partial class TransferItem : Item
|
||||||
|
|
||||||
private void TransferPetOwnership(Mobile from, Mobile to)
|
private void TransferPetOwnership(Mobile from, Mobile to)
|
||||||
{
|
{
|
||||||
if (_creature.Summoned)
|
|
||||||
{
|
|
||||||
_creature.SummonMaster = to;
|
|
||||||
}
|
|
||||||
|
|
||||||
_creature.ControlTarget = to;
|
_creature.ControlTarget = to;
|
||||||
_creature.ControlOrder = OrderType.Follow;
|
_creature.ControlOrder = OrderType.Follow;
|
||||||
_creature.BondingBegin = DateTime.MinValue;
|
_creature.BondingBegin = DateTime.MinValue;
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ public class BerserkAI : BaseAI
|
||||||
|
|
||||||
if (AcquireFocusMob(Mobile.RangePerception, FightMode.Closest, false, true, true))
|
if (AcquireFocusMob(Mobile.RangePerception, FightMode.Closest, false, true, true))
|
||||||
{
|
{
|
||||||
this.DebugSayFormatted($"I have detected {Mobile.FocusMob.Name}, attacking");
|
this.DebugSayFormatted($"I have detected {Mobile.FocusMob.Name} and I will attack");
|
||||||
|
|
||||||
Mobile.Combatant = Mobile.FocusMob;
|
Mobile.Combatant = Mobile.FocusMob;
|
||||||
Action = ActionType.Combat;
|
Action = ActionType.Combat;
|
||||||
|
|
@ -38,7 +38,7 @@ public class BerserkAI : BaseAI
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!WalkMobileRange(combatant, 1, Mobile.RangeFight, Mobile.RangeFight))
|
if (!WalkMobileRange(combatant, 1, false, Mobile.RangeFight, Mobile.RangeFight))
|
||||||
{
|
{
|
||||||
this.DebugSayFormatted($"I am still not in range of {combatant.Name}");
|
this.DebugSayFormatted($"I am still not in range of {combatant.Name}");
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -81,7 +81,7 @@ public class HealerAI : BaseAI
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
WalkMobileRange(Mobile.FocusMob, 1, 4, 7);
|
WalkMobileRange(Mobile.FocusMob, 1, false, 4, 7);
|
||||||
|
|
||||||
// TODO: Should it be able to do this?
|
// TODO: Should it be able to do this?
|
||||||
if (Mobile.TriggerAbility(MonsterAbilityTrigger.CombatAction, Mobile.Combatant))
|
if (Mobile.TriggerAbility(MonsterAbilityTrigger.CombatAction, Mobile.Combatant))
|
||||||
|
|
|
||||||
|
|
@ -171,7 +171,7 @@ public class MageAI : BaseAI
|
||||||
{
|
{
|
||||||
if (!SmartAI)
|
if (!SmartAI)
|
||||||
{
|
{
|
||||||
if (!MoveTo(m, Mobile.RangeFight))
|
if (!MoveTo(m, false, Mobile.RangeFight))
|
||||||
{
|
{
|
||||||
OnFailedMove();
|
OnFailedMove();
|
||||||
}
|
}
|
||||||
|
|
@ -185,14 +185,14 @@ public class MageAI : BaseAI
|
||||||
{
|
{
|
||||||
RunFrom(m);
|
RunFrom(m);
|
||||||
}
|
}
|
||||||
else if (!Mobile.InRange(m, Math.Max(Mobile.RangeFight, 2)) && !MoveTo(m, 1))
|
else if (!Mobile.InRange(m, Math.Max(Mobile.RangeFight, 2)) && !MoveTo(m, false, 1))
|
||||||
{
|
{
|
||||||
OnFailedMove();
|
OnFailedMove();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else if (!Mobile.InRange(m, Mobile.RangeFight))
|
else if (!Mobile.InRange(m, Mobile.RangeFight))
|
||||||
{
|
{
|
||||||
if (!MoveTo(m, 1))
|
if (!MoveTo(m, false, 1))
|
||||||
{
|
{
|
||||||
OnFailedMove();
|
OnFailedMove();
|
||||||
}
|
}
|
||||||
|
|
@ -701,7 +701,7 @@ public class MageAI : BaseAI
|
||||||
{
|
{
|
||||||
DebugSay("I cannot see my target, moving to regain line of sight");
|
DebugSay("I cannot see my target, moving to regain line of sight");
|
||||||
|
|
||||||
if (!MoveTo(c, 1))
|
if (!MoveTo(c, false, 1))
|
||||||
{
|
{
|
||||||
OnFailedMove();
|
OnFailedMove();
|
||||||
}
|
}
|
||||||
|
|
@ -1039,7 +1039,7 @@ public class MageAI : BaseAI
|
||||||
// target can be invoked.
|
// target can be invoked.
|
||||||
if (!Mobile.InLOS(toTarget))
|
if (!Mobile.InLOS(toTarget))
|
||||||
{
|
{
|
||||||
MoveTo(toTarget, 1);
|
MoveTo(toTarget, true, 1);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,3 @@
|
||||||
using System.Runtime.CompilerServices;
|
|
||||||
|
|
||||||
namespace Server.Mobiles;
|
namespace Server.Mobiles;
|
||||||
|
|
||||||
public class MeleeAI : BaseAI
|
public class MeleeAI : BaseAI
|
||||||
|
|
@ -16,7 +14,6 @@ public class MeleeAI : BaseAI
|
||||||
if (AcquireFocusMob(Mobile.RangePerception, Mobile.FightMode, false, false, true))
|
if (AcquireFocusMob(Mobile.RangePerception, Mobile.FightMode, false, false, true))
|
||||||
{
|
{
|
||||||
this.DebugSayFormatted($"I have detected {Mobile.FocusMob.Name}, attacking");
|
this.DebugSayFormatted($"I have detected {Mobile.FocusMob.Name}, attacking");
|
||||||
|
|
||||||
Mobile.Combatant = Mobile.FocusMob;
|
Mobile.Combatant = Mobile.FocusMob;
|
||||||
Action = ActionType.Combat;
|
Action = ActionType.Combat;
|
||||||
}
|
}
|
||||||
|
|
@ -68,9 +65,13 @@ public class MeleeAI : BaseAI
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
private bool IsValidCombatant(Mobile combatant)
|
||||||
private bool IsValidCombatant(Mobile combatant) =>
|
{
|
||||||
combatant?.Deleted == false && combatant.Map == Mobile.Map && combatant.Alive && !combatant.IsDeadBondedPet;
|
return combatant?.Deleted == false
|
||||||
|
&& combatant.Map == Mobile.Map
|
||||||
|
&& combatant.Alive
|
||||||
|
&& !combatant.IsDeadBondedPet;
|
||||||
|
}
|
||||||
|
|
||||||
private bool HandleOutOfRangeCombatant(Mobile combatant)
|
private bool HandleOutOfRangeCombatant(Mobile combatant)
|
||||||
{
|
{
|
||||||
|
|
@ -98,7 +99,7 @@ public class MeleeAI : BaseAI
|
||||||
|
|
||||||
private bool AttemptMoveToCombatant(Mobile combatant)
|
private bool AttemptMoveToCombatant(Mobile combatant)
|
||||||
{
|
{
|
||||||
if (MoveTo(combatant, Mobile.RangeFight))
|
if (MoveTo(combatant, false, Mobile.RangeFight))
|
||||||
{
|
{
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
@ -126,8 +127,7 @@ public class MeleeAI : BaseAI
|
||||||
{
|
{
|
||||||
if (AcquireFocusMob(Mobile.RangePerception, Mobile.FightMode, false, false, true))
|
if (AcquireFocusMob(Mobile.RangePerception, Mobile.FightMode, false, false, true))
|
||||||
{
|
{
|
||||||
this.DebugSayFormatted($"I have detected {Mobile.FocusMob.Name}, attacking");
|
this.DebugSayFormatted($"I have detected {Mobile.FocusMob.Name}, attacking.");
|
||||||
|
|
||||||
Mobile.Combatant = Mobile.FocusMob;
|
Mobile.Combatant = Mobile.FocusMob;
|
||||||
Action = ActionType.Combat;
|
Action = ActionType.Combat;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -41,7 +41,7 @@ public class PredatorAI : BaseAI
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!WalkMobileRange(combatant, 1, Mobile.RangeFight, Mobile.RangeFight))
|
if (!WalkMobileRange(combatant, 1, false, Mobile.RangeFight, Mobile.RangeFight))
|
||||||
{
|
{
|
||||||
if (Mobile.GetDistanceToSqrt(combatant) > Mobile.RangePerception + 1)
|
if (Mobile.GetDistanceToSqrt(combatant) > Mobile.RangePerception + 1)
|
||||||
{
|
{
|
||||||
|
|
@ -70,7 +70,7 @@ public class PredatorAI : BaseAI
|
||||||
}
|
}
|
||||||
else if (AcquireFocusMob(Mobile.RangePerception * 2, FightMode.Closest, true, false, true))
|
else if (AcquireFocusMob(Mobile.RangePerception * 2, FightMode.Closest, true, false, true))
|
||||||
{
|
{
|
||||||
if (WalkMobileRange(Mobile.FocusMob, 1, Mobile.RangePerception, Mobile.RangePerception * 2))
|
if (WalkMobileRange(Mobile.FocusMob, 1, false, Mobile.RangePerception, Mobile.RangePerception * 2))
|
||||||
{
|
{
|
||||||
DebugSay("Well, here I am safe");
|
DebugSay("Well, here I am safe");
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -43,7 +43,7 @@ public class ThiefAI : BaseAI
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!WalkMobileRange(combatant, 1, Mobile.RangeFight, Mobile.RangeFight))
|
if (!WalkMobileRange(combatant, 1, false, Mobile.RangeFight, Mobile.RangeFight))
|
||||||
{
|
{
|
||||||
this.DebugSayFormatted($"I should be closer to {combatant.Name}");
|
this.DebugSayFormatted($"I should be closer to {combatant.Name}");
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -99,8 +99,8 @@ public abstract class MonsterAbility
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
|
|
||||||
[OnEvent(nameof(BaseCreature.CreatureDeathEvent))]
|
[OnEvent(nameof(CreatureEvents.CreatureDeathEvent))]
|
||||||
[OnEvent(nameof(BaseCreature.CreatureDeletedEvent))]
|
[OnEvent(nameof(CreatureEvents.CreatureDeletedEvent))]
|
||||||
public static void InvalidateNextAbilityTriggers(BaseCreature source)
|
public static void InvalidateNextAbilityTriggers(BaseCreature source)
|
||||||
{
|
{
|
||||||
var abilities = source.GetMonsterAbilities();
|
var abilities = source.GetMonsterAbilities();
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
15
Projects/UOContent/Mobiles/CreatureEvents.cs
Normal file
15
Projects/UOContent/Mobiles/CreatureEvents.cs
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
using ModernUO.CodeGeneratedEvents;
|
||||||
|
|
||||||
|
namespace Server.Mobiles;
|
||||||
|
|
||||||
|
// Hosts BaseCreature's generated events. They cannot live on BaseCreature itself: the
|
||||||
|
// events generator and the serialization generator each emit a [GeneratedCode] partial for
|
||||||
|
// the declaring type, and the attribute does not allow duplicates (CS0579).
|
||||||
|
public static partial class CreatureEvents
|
||||||
|
{
|
||||||
|
[GeneratedEvent(nameof(CreatureDeathEvent))]
|
||||||
|
public static partial void CreatureDeathEvent(BaseCreature bc);
|
||||||
|
|
||||||
|
[GeneratedEvent(nameof(CreatureDeletedEvent))]
|
||||||
|
public static partial void CreatureDeletedEvent(BaseCreature bc);
|
||||||
|
}
|
||||||
|
|
@ -92,7 +92,7 @@ public abstract partial class BaseFamiliar : BaseCreature
|
||||||
Hidden = m_LastHidden = master.Hidden;
|
Hidden = m_LastHidden = master.Hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (AIObject?.WalkMobileRange(master, 5, 1, 1) == true)
|
if (AIObject?.WalkMobileRange(master, 5, false, 1, 1) == true)
|
||||||
{
|
{
|
||||||
Warmode = master.Warmode;
|
Warmode = master.Warmode;
|
||||||
Combatant = master.Combatant;
|
Combatant = master.Combatant;
|
||||||
|
|
|
||||||
|
|
@ -108,7 +108,7 @@ namespace Server.Mobiles
|
||||||
*/
|
*/
|
||||||
else if (!Combat(this))
|
else if (!Combat(this))
|
||||||
{
|
{
|
||||||
AIObject?.MoveTo(SummonMaster, 5);
|
AIObject?.MoveTo(SummonMaster, false, 5);
|
||||||
}
|
}
|
||||||
/*
|
/*
|
||||||
On OSI, if the summon attacks a mobile, the summoner meer also
|
On OSI, if the summon attacks a mobile, the summoner meer also
|
||||||
|
|
|
||||||
|
|
@ -154,7 +154,7 @@ namespace Server.Mobiles
|
||||||
public static bool UnderEffect(Mobile m) => m_Table.ContainsKey(m);
|
public static bool UnderEffect(Mobile m) => m_Table.ContainsKey(m);
|
||||||
|
|
||||||
[OnEvent(nameof(PlayerMobile.PlayerDeathEvent))]
|
[OnEvent(nameof(PlayerMobile.PlayerDeathEvent))]
|
||||||
[OnEvent(nameof(CreatureDeathEvent))]
|
[OnEvent(nameof(CreatureEvents.CreatureDeathEvent))]
|
||||||
public static void StopEffect(Mobile m, bool message = false)
|
public static void StopEffect(Mobile m, bool message = false)
|
||||||
{
|
{
|
||||||
if (m_Table.Remove(m, out var timer))
|
if (m_Table.Remove(m, out var timer))
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ namespace Server.Mobiles;
|
||||||
|
|
||||||
public enum SpeedLevel
|
public enum SpeedLevel
|
||||||
{
|
{
|
||||||
None,
|
None, // no bucket: the creature's own speeds are authoritative (custom)
|
||||||
VerySlow,
|
VerySlow,
|
||||||
Slow,
|
Slow,
|
||||||
Medium,
|
Medium,
|
||||||
|
|
@ -26,34 +26,29 @@ public static class NPCSpeeds
|
||||||
public static int MinIdleSeconds { get; private set; }
|
public static int MinIdleSeconds { get; private set; }
|
||||||
public static int MaxIdleSeconds { get; private set; }
|
public static int MaxIdleSeconds { get; private set; }
|
||||||
|
|
||||||
public static void GetSpeeds(BaseCreature bc, out double activeSpeed, out double passiveSpeed)
|
// Construction-time resolution of a type's bucket: an explicit DefaultSpeedClass,
|
||||||
|
// else the table's type list, else Medium so unconfigured creatures never construct
|
||||||
|
// at 0/0. None only when the table itself is unloaded (test fixtures).
|
||||||
|
public static SpeedLevel ResolveDefaultLevel(BaseCreature bc)
|
||||||
{
|
{
|
||||||
if ((bc.SpeedClass == SpeedLevel.None || !_speedsByLevel.TryGetValue(bc.SpeedClass, out var sp)) &&
|
if (bc.DefaultSpeedClass != SpeedLevel.None)
|
||||||
!_speedsByType.TryGetValue(bc.GetType(), out sp))
|
|
||||||
{
|
{
|
||||||
sp = _speedsByLevel[SpeedLevel.Medium];
|
return bc.DefaultSpeedClass;
|
||||||
}
|
}
|
||||||
|
|
||||||
activeSpeed = sp.ActiveSpeed;
|
if (_speedsByType.TryGetValue(bc.GetType(), out var sp))
|
||||||
passiveSpeed = sp.PassiveSpeed;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Move speeds are optional (0 = inherit), so this tolerates a missing entry or table.
|
|
||||||
public static void GetMoveSpeeds(BaseCreature bc, out double activeMoveSpeed, out double passiveMoveSpeed)
|
|
||||||
{
|
|
||||||
if ((bc.SpeedClass == SpeedLevel.None || !_speedsByLevel.TryGetValue(bc.SpeedClass, out var sp)) &&
|
|
||||||
!_speedsByType.TryGetValue(bc.GetType(), out sp) &&
|
|
||||||
!_speedsByLevel.TryGetValue(SpeedLevel.Medium, out sp))
|
|
||||||
{
|
{
|
||||||
activeMoveSpeed = 0;
|
return sp.Level;
|
||||||
passiveMoveSpeed = 0;
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
activeMoveSpeed = sp.ActiveMoveSpeed;
|
return _speedsByLevel.ContainsKey(SpeedLevel.Medium) ? SpeedLevel.Medium : SpeedLevel.None;
|
||||||
passiveMoveSpeed = sp.PassiveMoveSpeed;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Null for None (custom) or an unloaded table. Creatures cache the result — the
|
||||||
|
// table is immutable after Configure.
|
||||||
|
public static SpeedClassEntry FindEntry(SpeedLevel level) =>
|
||||||
|
level == SpeedLevel.None ? null : _speedsByLevel.GetValueOrDefault(level);
|
||||||
|
|
||||||
public static void RegisterSpeed(SpeedClassEntry entry)
|
public static void RegisterSpeed(SpeedClassEntry entry)
|
||||||
{
|
{
|
||||||
_speedsByLevel[entry.Level] = entry;
|
_speedsByLevel[entry.Level] = entry;
|
||||||
|
|
|
||||||
|
|
@ -3553,7 +3553,6 @@ namespace Server.Mobiles
|
||||||
pet.Internalize();
|
pet.Internalize();
|
||||||
|
|
||||||
pet.SetControlMaster(null);
|
pet.SetControlMaster(null);
|
||||||
pet.SummonMaster = null;
|
|
||||||
|
|
||||||
pet.IsStabled = true;
|
pet.IsStabled = true;
|
||||||
pet.StabledBy = this;
|
pet.StabledBy = this;
|
||||||
|
|
@ -3601,12 +3600,6 @@ namespace Server.Mobiles
|
||||||
if (Followers + pet.ControlSlots <= FollowersMax)
|
if (Followers + pet.ControlSlots <= FollowersMax)
|
||||||
{
|
{
|
||||||
pet.SetControlMaster(this);
|
pet.SetControlMaster(this);
|
||||||
|
|
||||||
if (pet.Summoned)
|
|
||||||
{
|
|
||||||
pet.SummonMaster = this;
|
|
||||||
}
|
|
||||||
|
|
||||||
pet.ControlTarget = this;
|
pet.ControlTarget = this;
|
||||||
pet.ControlOrder = OrderType.Follow;
|
pet.ControlOrder = OrderType.Follow;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using ModernUO.Serialization;
|
using ModernUO.Serialization;
|
||||||
using Server.Collections;
|
|
||||||
using Server.Engines.CannedEvil;
|
using Server.Engines.CannedEvil;
|
||||||
using Server.Engines.Virtues;
|
using Server.Engines.Virtues;
|
||||||
using Server.Items;
|
using Server.Items;
|
||||||
|
|
|
||||||
|
|
@ -255,7 +255,6 @@ namespace Server.Mobiles
|
||||||
pet.Internalize();
|
pet.Internalize();
|
||||||
|
|
||||||
pet.SetControlMaster(null);
|
pet.SetControlMaster(null);
|
||||||
pet.SummonMaster = null;
|
|
||||||
|
|
||||||
pet.IsStabled = true;
|
pet.IsStabled = true;
|
||||||
pet.StabledBy = from;
|
pet.StabledBy = from;
|
||||||
|
|
@ -356,12 +355,6 @@ namespace Server.Mobiles
|
||||||
private void DoClaim(Mobile from, BaseCreature pet)
|
private void DoClaim(Mobile from, BaseCreature pet)
|
||||||
{
|
{
|
||||||
pet.SetControlMaster(from);
|
pet.SetControlMaster(from);
|
||||||
|
|
||||||
if (pet.Summoned)
|
|
||||||
{
|
|
||||||
pet.SummonMaster = from;
|
|
||||||
}
|
|
||||||
|
|
||||||
pet.ControlTarget = from;
|
pet.ControlTarget = from;
|
||||||
pet.ControlOrder = OrderType.Follow;
|
pet.ControlOrder = OrderType.Follow;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -86,17 +86,11 @@ public static class IncomingPlayerPackets
|
||||||
public static void HuePickerResponse(NetState state, SpanReader reader)
|
public static void HuePickerResponse(NetState state, SpanReader reader)
|
||||||
{
|
{
|
||||||
var serial = reader.ReadUInt32();
|
var serial = reader.ReadUInt32();
|
||||||
reader.ReadInt16(); // Item ID
|
_ = reader.ReadInt16(); // Item ID
|
||||||
var hue = Utility.ClipDyedHue(reader.ReadInt16() & 0x3FFF);
|
var hue = Utility.ClipDyedHue(reader.ReadInt16() & 0x3FFF);
|
||||||
|
|
||||||
if (state.HuePickers == null)
|
foreach (var huePicker in state.HuePickers)
|
||||||
{
|
{
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (var i = 0; i < state.HuePickers.Count; i++)
|
|
||||||
{
|
|
||||||
var huePicker = state.HuePickers[i];
|
|
||||||
if (huePicker.Serial == serial)
|
if (huePicker.Serial == serial)
|
||||||
{
|
{
|
||||||
state.RemoveHuePicker(huePicker);
|
state.RemoveHuePicker(huePicker);
|
||||||
|
|
@ -293,18 +287,13 @@ public static class IncomingPlayerPackets
|
||||||
public static void MenuResponse(NetState state, SpanReader reader)
|
public static void MenuResponse(NetState state, SpanReader reader)
|
||||||
{
|
{
|
||||||
var serial = reader.ReadUInt32();
|
var serial = reader.ReadUInt32();
|
||||||
reader.ReadInt16(); // menu id
|
int menuID = reader.ReadInt16();
|
||||||
int index = reader.ReadInt16();
|
int index = reader.ReadInt16();
|
||||||
reader.ReadInt16(); // item id
|
int itemID = reader.ReadInt16();
|
||||||
reader.ReadInt16(); // hue
|
int hue = reader.ReadInt16();
|
||||||
|
|
||||||
index -= 1; // convert from 1-based to 0-based
|
index -= 1; // convert from 1-based to 0-based
|
||||||
|
|
||||||
if (state.Menus == null)
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (var i = 0; i < state.Menus.Count; i++)
|
for (var i = 0; i < state.Menus.Count; i++)
|
||||||
{
|
{
|
||||||
var menu = state.Menus[i];
|
var menu = state.Menus[i];
|
||||||
|
|
|
||||||
|
|
@ -113,7 +113,7 @@ public class BaseRegion : Region
|
||||||
m_RectBuffer2.RemoveAt(k);
|
m_RectBuffer2.RemoveAt(k);
|
||||||
|
|
||||||
var sz = rect.Start.Z;
|
var sz = rect.Start.Z;
|
||||||
var ez = rect.End.Z;
|
var ez = rect.End.X;
|
||||||
|
|
||||||
if (l1 < l2)
|
if (l1 < l2)
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -151,8 +151,8 @@ public class BloodOathSpell : NecromancerSpell, ITargetingSpell<Mobile>
|
||||||
// shared timer from either the caster or the target key, so a single call per mobile is enough.
|
// shared timer from either the caster or the target key, so a single call per mobile is enough.
|
||||||
[OnEvent(nameof(PlayerMobile.PlayerDeathEvent))]
|
[OnEvent(nameof(PlayerMobile.PlayerDeathEvent))]
|
||||||
[OnEvent(nameof(PlayerMobile.PlayerDeletedEvent))]
|
[OnEvent(nameof(PlayerMobile.PlayerDeletedEvent))]
|
||||||
[OnEvent(nameof(BaseCreature.CreatureDeathEvent))]
|
[OnEvent(nameof(CreatureEvents.CreatureDeathEvent))]
|
||||||
[OnEvent(nameof(BaseCreature.CreatureDeletedEvent))]
|
[OnEvent(nameof(CreatureEvents.CreatureDeletedEvent))]
|
||||||
public static void OnCurseEnds(Mobile m) => RemoveCurse(m);
|
public static void OnCurseEnds(Mobile m) => RemoveCurse(m);
|
||||||
|
|
||||||
private class ExpireTimer : Timer
|
private class ExpireTimer : Timer
|
||||||
|
|
|
||||||
|
|
@ -238,7 +238,10 @@ namespace Server.Mobiles
|
||||||
|
|
||||||
if (master?.Map == Mobile.Map && master?.InRange(Mobile, Mobile.RangePerception) == true)
|
if (master?.Map == Mobile.Map && master?.InRange(Mobile, Mobile.RangePerception) == true)
|
||||||
{
|
{
|
||||||
WalkMobileRange(master, 2, 0, 1);
|
var iCurrDist = (int)Mobile.GetDistanceToSqrt(master);
|
||||||
|
var bRun = iCurrDist > 5;
|
||||||
|
|
||||||
|
WalkMobileRange(master, 2, bRun, 0, 1);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -79,7 +79,7 @@ namespace Server.Spells.Spellweaving
|
||||||
Caster.Target = new SpellTarget<Mobile>(this, TargetFlags.Beneficial);
|
Caster.Target = new SpellTarget<Mobile>(this, TargetFlags.Beneficial);
|
||||||
}
|
}
|
||||||
|
|
||||||
[OnEvent(nameof(BaseCreature.CreatureDeathEvent))]
|
[OnEvent(nameof(CreatureEvents.CreatureDeathEvent))]
|
||||||
[OnEvent(nameof(PlayerMobile.PlayerDeathEvent))]
|
[OnEvent(nameof(PlayerMobile.PlayerDeathEvent))]
|
||||||
public static void OnDeathEvent(Mobile m)
|
public static void OnDeathEvent(Mobile m)
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -29,9 +29,6 @@ description: >
|
||||||
- `BaseCreature(AI, Fight, 10, 1, 0.2, 0.4)` -> `BaseCreature(AI, Fight)` (extra params default)
|
- `BaseCreature(AI, Fight, 10, 1, 0.2, 0.4)` -> `BaseCreature(AI, Fight)` (extra params default)
|
||||||
- `Name = "text"` -> `public override string DefaultName => "text";`
|
- `Name = "text"` -> `public override string DefaultName => "text";`
|
||||||
- Expression-bodied overrides: `public override int Meat { get { return 1; } }` -> `public override int Meat => 1;`
|
- Expression-bodied overrides: `public override int Meat { get { return 1; } }` -> `public override int Meat => 1;`
|
||||||
- AI movement calls lose the `run` flag: `MoveTo(m, true, range)` -> `MoveTo(m, range)` (also `WalkMobileRange`, `ApproachTarget`, `MoveToPoint`, `PathFollower.Follow`); the Running bit is derived from step pace -> `dev-docs/runuo-migration-docs/09-items-mobiles-creatures.md` § AI Movement
|
|
||||||
- `AcquireOnApproach` (bool) -> `AcquireOnApproachDelay` (TimeSpan; `Zero` = old instant behavior) -> same doc § Target Acquisition
|
|
||||||
- `DamageEntries` is an inline `ref readonly ValueLinkList<DamageEntry>`, not a `List`: indexer/`Add`/`Remove`/`Clear` -> `foreach` / `.ByDescending()` (needs `using Server.Collections;`) and `ClearDamageEntries()`; `GetLootingRights` takes it by `in` -> same doc § Damage Entries
|
|
||||||
|
|
||||||
## Anti-Patterns
|
## Anti-Patterns
|
||||||
- Using `_field--` instead of `Property--` (bypasses MarkDirty tracking)
|
- Using `_field--` instead of `Property--` (bypasses MarkDirty tracking)
|
||||||
|
|
|
||||||
|
|
@ -27,18 +27,8 @@ description: >
|
||||||
(`ActiveSpeed`/`PassiveSpeed`, seconds per AI decision) and move
|
(`ActiveSpeed`/`PassiveSpeed`, seconds per AI decision) and move
|
||||||
(`ActiveMoveSpeed`/`PassiveMoveSpeed`, seconds per step; inherits think until
|
(`ActiveMoveSpeed`/`PassiveMoveSpeed`, seconds per step; inherits think until
|
||||||
overridden). Prefer `npc-speeds.json` buckets (`SpeedClass`); `SetSpeed()` sets think
|
overridden). Prefer `npc-speeds.json` buckets (`SpeedClass`); `SetSpeed()` sets think
|
||||||
AND clears move overrides, `SetMoveSpeed()` sets move only. The client `Running` bit is
|
AND clears move overrides, `SetMoveSpeed()` sets move only -- see
|
||||||
derived from the step pace (`BaseAI.ShouldRun`); movement APIs take no run argument --
|
`dev-docs/content-patterns.md` § Creature Speeds
|
||||||
see `dev-docs/content-patterns.md` § Creature Speeds. Reaction time to approaching
|
|
||||||
enemies is `AcquireOnApproachDelay` (TimeSpan gradient; `Zero` = paragon snap, 2s
|
|
||||||
default, `ReacquireDelay`-only = oblivious) -- see § Target Acquisition
|
|
||||||
8. **`OnThink` overrides must be excess-call tolerant** -- it fires more often than the
|
|
||||||
think cadence (player commands prod it; speed-ups reschedule it). Gate consequential
|
|
||||||
work on a tick-count deadline (subtraction form) or make it idempotent; bare per-call
|
|
||||||
random rolls are cosmetics-only. `MonsterAbility` is under the same contract: the
|
|
||||||
trigger cooldown is the rate limit, `ChanceToTrigger` is per-sample jitter, and a
|
|
||||||
zero-cooldown `Think`/`CombatAction` ability triggers every sampled think -- see
|
|
||||||
`dev-docs/content-patterns.md` § OnThink: the excess-call contract
|
|
||||||
|
|
||||||
## New Item Template
|
## New Item Template
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -262,8 +262,9 @@ All "speed" values are **delays in seconds** (smaller = faster). A creature runs
|
||||||
(combat decisions, target acquisition, spell timing).
|
(combat decisions, target acquisition, spell timing).
|
||||||
- **Move clock** — `ActiveMoveSpeed`/`PassiveMoveSpeed`/`CurrentMoveSpeed`: seconds per
|
- **Move clock** — `ActiveMoveSpeed`/`PassiveMoveSpeed`/`CurrentMoveSpeed`: seconds per
|
||||||
step. Inherits the matching think value until overridden, so a creature configured with
|
step. Inherits the matching think value until overridden, so a creature configured with
|
||||||
only think speeds behaves as one clock. Any value is legal — steps are scheduled
|
only think speeds behaves as one clock. The properties read the raw override (`0` =
|
||||||
independently of think ticks, so the two need not divide evenly.
|
inheriting); `CurrentMoveSpeed` is the resolved pace. Any value is legal — steps are
|
||||||
|
scheduled independently of think ticks, so the two need not divide evenly.
|
||||||
|
|
||||||
Speeds normally come from `Distribution/Data/npc-speeds.json` (via `SpeedClass` or type
|
Speeds normally come from `Distribution/Data/npc-speeds.json` (via `SpeedClass` or type
|
||||||
lists); `activeMove`/`passiveMove` are optional per bucket. Prefer data over code:
|
lists); `activeMove`/`passiveMove` are optional per bucket. Prefer data over code:
|
||||||
|
|
@ -283,91 +284,6 @@ ClearMoveSpeed(); // back to inheriting the think clock
|
||||||
All four are `[props`-tunable per instance (move values: set `0` to re-inherit); per-instance
|
All four are `[props`-tunable per instance (move values: set `0` to re-inherit); per-instance
|
||||||
move overrides serialize. Being badly hurt slows steps, never decisions (RunUO parity).
|
move overrides serialize. Being badly hurt slows steps, never decisions (RunUO parity).
|
||||||
|
|
||||||
The client's `Running` bit is derived from the step pace, never passed by callers
|
|
||||||
(`BaseAI.ShouldRun`, stamped in `DoMoveImpl`): a step shorter than the client's walk
|
|
||||||
interpolation — 400 ms on foot, 200 ms mounted/flying (`Movement.WalkFootDelay` /
|
|
||||||
`WalkMountDelay`) — is flagged as a run, or the client falls behind and snaps. An isolated
|
|
||||||
step (resuming after at least a walk interval standing) goes out as a walk regardless of
|
|
||||||
pace — the client renders each step alone, so a run-flagged single step darts — unless the
|
|
||||||
pace beats the run interpolation (a true sprinter), where a walk-rendered first step would
|
|
||||||
flood the client's step queue. Movement APIs (`MoveTo`, `WalkMobileRange`,
|
|
||||||
`ApproachTarget`, `MoveToPoint`) take no run argument; to make a creature run, make it
|
|
||||||
fast. Creatures step at most once per `CurrentMoveSpeed` period, paced from the step just
|
|
||||||
taken — a stall never banks catch-up steps, so a resumed chase restarts at full pace.
|
|
||||||
|
|
||||||
### Target Acquisition: the reaction-time gradient
|
|
||||||
|
|
||||||
Acquisition is event-driven, not polled. The periodic scan (`AcquireFocusMob`) is gated by
|
|
||||||
`ReacquireDelay` (10 s default) and every scan re-arms it in full, success or failure — it
|
|
||||||
is target stickiness plus the fallback for what movement cannot signal (reveals, doors,
|
|
||||||
summons). Reaction time comes from `BaseCreature.OnMovement`: an enemy moving inside
|
|
||||||
`AcquireOnApproachRange` (10 — on-screen; the periodic scan keeps the wider
|
|
||||||
`RangePerception`) clamps the next scan to
|
|
||||||
at most **`AcquireOnApproachDelay`** — the intelligence gradient. `TimeSpan.Zero`
|
|
||||||
(paragons) also prods the AI, so the ranked scan engages within a timer-wheel turn; the
|
|
||||||
2 s default reads as "took a beat to notice you"; larger is dumber; a creature that
|
|
||||||
overrides the delay above `ReacquireDelay` is effectively oblivious to approach. Repeated
|
|
||||||
steps cannot shorten the clamp, so an armed creature scans once per delay period, not once
|
|
||||||
per step or think. `ReacquireOnMovement` remains the broader hook (any mover, no enemy
|
|
||||||
check, scan next think). The gate self-heals: a deadline further out than `ReacquireDelay`
|
|
||||||
is illegal and reads as open, so no wedged or wrapped value can silence acquisition beyond
|
|
||||||
one delay period.
|
|
||||||
|
|
||||||
### OnThink: the excess-call contract
|
|
||||||
|
|
||||||
`OnThink()` is a scheduler pass, not an action. The AI timer calls it *at least* at the
|
|
||||||
think cadence (`CurrentSpeed`), but it can and does fire more often: a player command
|
|
||||||
wakes the AI immediately (`AITimer.Prod()`), a speed-up reschedules the pending wake, and
|
|
||||||
players run command macros that drive extra thinks deliberately (order spam is spam-safe
|
|
||||||
by design — reaction, never action). RunUO had the same property (its timer restarted
|
|
||||||
with a random delay on every speed change), so this has never been a fixed-rate callback.
|
|
||||||
|
|
||||||
**Every `OnThink` override must be excess-call tolerant.** An extra call must never grant
|
|
||||||
an extra action:
|
|
||||||
|
|
||||||
- Gate consequential work on its own deadline field, compared in subtraction form
|
|
||||||
(`Core.TickCount - _nextX >= 0` — see `tick-counts.md`), or make it idempotent.
|
|
||||||
- Never pace a consequential action with a bare per-call `Utility.RandomDouble()` roll —
|
|
||||||
its frequency then scales with think rate, which players can influence. Per-call rolls
|
|
||||||
are acceptable only for pure cosmetics (idle animations, flavor sounds).
|
|
||||||
- The engine already gates the expensive things: steps (the `NextMove` budget), weapon
|
|
||||||
swings, spell casts, detect-hidden, and the base `BaseCreature.OnThink` actions (heal,
|
|
||||||
rummage, aura) all carry their own clocks. Follow that pattern.
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
private long _nextSpecial;
|
|
||||||
|
|
||||||
public override void OnThink()
|
|
||||||
{
|
|
||||||
base.OnThink();
|
|
||||||
|
|
||||||
if (Core.TickCount - _nextSpecial >= 0)
|
|
||||||
{
|
|
||||||
DoSpecial();
|
|
||||||
_nextSpecial = Core.TickCount + 5000; // the real rate limit lives here
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### MonsterAbility: same contract
|
|
||||||
|
|
||||||
`MonsterAbility.CanTrigger` is sampled once per think for `Think`- and
|
|
||||||
`CombatAction`-triggered abilities, so abilities live under the same rule:
|
|
||||||
|
|
||||||
- **`MinTriggerCooldown`/`MaxTriggerCooldown` is the real rate limit** — the floor holds
|
|
||||||
no matter how often thinks fire. Always give a triggered ability a real cooldown.
|
|
||||||
- **`ChanceToTrigger` is a per-sample roll**: above the cooldown floor, the expected
|
|
||||||
trigger delay shrinks as think rate rises. Treat the chance as flavor jitter, never as
|
|
||||||
the rate limiter, and keep cooldowns long relative to the think interval so the jitter
|
|
||||||
stays negligible (fire breath — chance 0.5, cooldown 30–45s — varies under 1% between
|
|
||||||
natural and spammed think rates).
|
|
||||||
- A **zero-cooldown ability records no cooldown at all** and triggers on every sampled
|
|
||||||
think that passes its chance — only ever correct for passive alteration hooks, never
|
|
||||||
for `Think`/`CombatAction` triggers.
|
|
||||||
- An ability that breaks pet orders (fear-style effects) must own its duration explicitly
|
|
||||||
(a hold state, or a "refuses orders until" deadline checked in the order handlers) —
|
|
||||||
pets react to re-issued commands immediately, so think latency is not a hold.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## New Spell
|
## New Spell
|
||||||
|
|
|
||||||
|
|
@ -474,88 +474,6 @@ The extra parameters (RangePerception, RangeFight, ActiveSpeed, PassiveSpeed) ha
|
||||||
| `Name = "a creature"` in constructor | `public override string DefaultName => "a creature";` |
|
| `Name = "a creature"` in constructor | `public override string DefaultName => "a creature";` |
|
||||||
| `get { return value; }` | `=> value;` expression-bodied |
|
| `get { return value; }` | `=> value;` expression-bodied |
|
||||||
|
|
||||||
## AI Movement: No `run` Argument
|
|
||||||
|
|
||||||
RunUO's movement calls took a `run` flag that callers set inconsistently (`true` in
|
|
||||||
combat, `false` for pets, gated by `dist > 5` inside `MoveTo`). The flag only selects the
|
|
||||||
client's per-step animation time, so ModernUO derives it from the creature's step pace
|
|
||||||
(`BaseAI.ShouldRun`) and the parameter is gone:
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
// RunUO
|
|
||||||
MoveTo(combatant, true, m_Mobile.RangeFight);
|
|
||||||
WalkMobileRange(m_Mobile.ControlMaster, 1, false, 0, 1);
|
|
||||||
|
|
||||||
// ModernUO
|
|
||||||
MoveTo(combatant, Mobile.RangeFight);
|
|
||||||
WalkMobileRange(Mobile.ControlMaster, 1, 0, 1);
|
|
||||||
```
|
|
||||||
|
|
||||||
`ApproachTarget`, `MoveToPoint` and `PathFollower.Follow` lose the argument the same way.
|
|
||||||
To make a creature run, make it fast (`SetMoveSpeed` / `npc-speeds.json`), not flagged.
|
|
||||||
An isolated step (after the creature stood for at least a walk interval) goes out as a
|
|
||||||
walk regardless of pace — only a continuing cadence, or a pace faster than the run
|
|
||||||
interpolation, flags run.
|
|
||||||
|
|
||||||
## Target Acquisition: `AcquireOnApproach` Is a Delay
|
|
||||||
|
|
||||||
RunUO's `AcquireOnApproach` bool (paragon insta-aggro on approach) is now
|
|
||||||
`AcquireOnApproachDelay`, a `TimeSpan` reaction-time gradient that applies to every
|
|
||||||
creature — enemy movement inside `AcquireOnApproachRange` schedules a scan within the
|
|
||||||
delay instead of waiting out the 10 s `ReacquireDelay` poll:
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
// RunUO
|
|
||||||
public override bool AcquireOnApproach => true;
|
|
||||||
|
|
||||||
// ModernUO — Zero is the old instant behavior; larger values are dumber
|
|
||||||
public override TimeSpan AcquireOnApproachDelay => TimeSpan.Zero;
|
|
||||||
```
|
|
||||||
|
|
||||||
`AcquireOnApproachRange` stays 10 for all creatures (reactive aggro is on-screen; the
|
|
||||||
periodic `ReacquireDelay` scan still sweeps the full `RangePerception`). The
|
|
||||||
acquired target comes from the normal FightMode-ranked scan, not from whichever mobile
|
|
||||||
happened to move. See `content-patterns.md` § Target Acquisition.
|
|
||||||
|
|
||||||
## Damage Entries: Inline `ValueLinkList`, Not `List<DamageEntry>`
|
|
||||||
|
|
||||||
RunUO's `Mobile.DamageEntries` was a `List<DamageEntry>` allocated for every mobile.
|
|
||||||
ModernUO keeps damage entries in an inline `ValueLinkList<DamageEntry>` struct held by
|
|
||||||
the mobile itself, ordered least recent → most recent, so a mobile that never takes
|
|
||||||
damage owns no list object and `RegisterDamage` relinks in O(1). The property is
|
|
||||||
`ref readonly`; expired entries are pruned when it is read.
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
// RunUO
|
|
||||||
for (var i = m.DamageEntries.Count - 1; i >= 0; --i)
|
|
||||||
{
|
|
||||||
var de = m.DamageEntries[i]; // indexer
|
|
||||||
...
|
|
||||||
}
|
|
||||||
m.DamageEntries.Clear();
|
|
||||||
var rights = BaseCreature.GetLootingRights(m.DamageEntries, m.HitsMax); // List<DamageEntry>
|
|
||||||
|
|
||||||
// ModernUO — needs `using Server.Collections;` for the enumerator extensions
|
|
||||||
foreach (var de in m.DamageEntries.ByDescending()) // most recent first
|
|
||||||
{
|
|
||||||
...
|
|
||||||
}
|
|
||||||
foreach (var de in m.DamageEntries) // least recent first
|
|
||||||
{
|
|
||||||
...
|
|
||||||
}
|
|
||||||
m.ClearDamageEntries();
|
|
||||||
var rights = BaseCreature.GetLootingRights(m.DamageEntries, m.HitsMax); // in ValueLinkList<DamageEntry>
|
|
||||||
```
|
|
||||||
|
|
||||||
What no longer compiles: the indexer, `.Add`, `.Remove`, `.RemoveAt`, `.Clear`, and
|
|
||||||
passing the property where a `List<DamageEntry>` is expected. `.Count`,
|
|
||||||
`FindDamageEntryFor`, `FindMostRecentDamager` and the other `Find*` methods, and
|
|
||||||
`RegisterDamage` are unchanged. `DamageEntry` now carries `Next`/`Previous`/`OnLinkList`
|
|
||||||
link fields; never set them yourself, and never call a `ValueLinkList` mutator on the
|
|
||||||
`ref readonly` property — it compiles against a copy and corrupts the node's link state.
|
|
||||||
Mutate only through `RegisterDamage` and `ClearDamageEntries`.
|
|
||||||
|
|
||||||
## Item Name Changes
|
## Item Name Changes
|
||||||
|
|
||||||
```csharp
|
```csharp
|
||||||
|
|
|
||||||
|
|
@ -130,13 +130,6 @@ Alphabetical by RunUO API name. Use Ctrl+F / Cmd+F to search.
|
||||||
| `writer.WriteEncodedInt(value)` | `writer.WriteEncodedInt(value)` | Same |
|
| `writer.WriteEncodedInt(value)` | `writer.WriteEncodedInt(value)` | Same |
|
||||||
| `InvalidateProperties()` | `InvalidateProperties()` | Same, or use `[InvalidateProperties]` |
|
| `InvalidateProperties()` | `InvalidateProperties()` | Same, or use `[InvalidateProperties]` |
|
||||||
| `this.MarkDirty()` | `this.MarkDirty()` | NEW — required in custom setters |
|
| `this.MarkDirty()` | `this.MarkDirty()` | NEW — required in custom setters |
|
||||||
| `MoveTo(m, run, range)` | `MoveTo(m, range)` | `run` removed; the Running bit is derived from the step pace (`BaseAI.ShouldRun`) |
|
|
||||||
| `WalkMobileRange(m, steps, run, min, max)` | `WalkMobileRange(m, steps, min, max)` | Same |
|
|
||||||
| `PathFollower.Follow(run, range)` | `Follow(range)` | Same |
|
|
||||||
| `AcquireOnApproach` (bool) | `AcquireOnApproachDelay` (TimeSpan) | Reaction-time gradient; `Zero` = old instant behavior |
|
|
||||||
| `m.DamageEntries` (`List<DamageEntry>`) | `m.DamageEntries` (`ref readonly ValueLinkList<DamageEntry>`) | Inline, least→most recent; `foreach` / `.ByDescending()` only, needs `using Server.Collections;`; no indexer, `Add`, `Remove`, `Clear` |
|
|
||||||
| `m.DamageEntries.Clear()` | `m.ClearDamageEntries()` | |
|
|
||||||
| `GetLootingRights(List<DamageEntry>, int)` | `GetLootingRights(in ValueLinkList<DamageEntry>, int)` | Callers passing `m.DamageEntries` compile unchanged |
|
|
||||||
|
|
||||||
## Networking
|
## Networking
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue