From 392c4e16d586c54b2e2d56cded3ab16144dac584 Mon Sep 17 00:00:00 2001 From: Kamron Batman <3953314+kamronbatman@users.noreply.github.com> Date: Sun, 6 Sep 2026 14:11:51 -0700 Subject: [PATCH] refactor: Changes BaseCreature to the SerializationGenerator (delta save requirement) (#2611) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pure SerializationGenerator conversion of `BaseCreature`, split out of #2592 so it can serve as the reference for converting every other large hand-written class in the Delta Saves project (#7, phase 3). Two behaviour changes from #2592 are deliberately not here and follow in their own PRs on top of this one: `SpeedClass`, and the collapse of `ControlMaster`/`SummonMaster` into one reference (a creature can lose or keep either independently: Blade Spirits and Energy Vortexes are summoned but never controlled, EnragedCreature and talisman summons keep a summon master with neither flag set). ## What this is - `BaseCreature` becomes `[SerializationGenerator(23, false)]` with a `[SerializableField]` per serialized slot and `[SaveFlag]` elision on nearly every field, so a stock creature serializes to its version plus flags. Field orders run 0..53 with no gaps (54 fields). - The hand-written reader stays as `private void Deserialize(IGenericReader reader, int version)` for every pre-codegen version (0..22); post-codegen bumps use `MigrateFrom` from here on. `[AfterDeserialization]` carries the post-load fixups main did after reading (stat timers, AI type, followers, reacquire seeding). - `ControlMaster` and `SummonMaster` stay two independent fields with main's semantics, each elided when null. - `DamageMin`/`DamageMax`/`ActiveSpeed`/`PassiveSpeed` are no longer virtual (nothing in the tree overrode them); comments swept to the constraints that matter. - Direct writes to serialized backing fields outside the generated setters (`SetDamage`, `SetResistance`, the move-speed helpers, the loot flag, feed loyalty, the delete timer) call `this.MarkDirty()`, matching the #2609 standard, so the class is ready for delta saves once `Mobile` is audited. - Schema `Server.Mobiles.BaseCreature.v23.json` regenerated by the tool (a second run produces no diff). ## Deferred to follow-up PRs SpeedClass: the serialized `_speedClass` field, `DefaultSpeedClass` replacing the type constant, `ApplySpeedClass`/`OnSpeedClassChange`, "None means custom", the four-speeds-as-one-block elision, `NPCSpeeds.FindEntry(SpeedLevel)`, the constructor fallback to Medium, and their tests. Master references: serializing one `Master` with a `Controlled`/`Summoned` fan-out and the `SetControlMaster` lockstep. ## Tests UOContent.Tests 776 / Server.Tests 855 green. `BaseCreatureSerializationTests` covers: a default creature elides to version + flags; a populated creature round-trips with exact byte consumption; back-to-back saves are byte-identical; an uncontrolled summon keeps its SummonMaster; byte-authentic v22 legacy streams (replicas of main's `Serialize`) load through the legacy reader for a wild tamable, a controlled pet, a controlled summon with an anchored `SummonEnd`, and a summon-master-only creature (the EnragedCreature shape); a running delete timer round-trips through `[DeserializeTimer]`; `Friends`, `CurrentWayPoint` and `HomeMap` round-trip; a `BaseVendor` stub round-trips the generated BaseVendor v2 → generated BaseCreature v23 chain. ## Behaviour notes for reviewers - `ActiveMoveSpeed`/`PassiveMoveSpeed` getters return the raw override (0 = inherit); `CurrentMoveSpeed` is the resolved pace. - Speeds elided as table defaults re-snap to the current `npc-speeds.json` on load, so table edits reach unmodified spawns on restart. - `GetSpeeds` no longer throws on the save/load path when the table has no entry for the type: saves elide against the creature's own values and loads keep the stream. Construction still throws (`InvalidOperationException`, was `KeyNotFoundException`). An elided load with no table entry would otherwise resume at speed 0, so `[AfterDeserialization]` logs once and paces it at Medium. - `virtual` removed from `ActiveSpeed`, `PassiveSpeed`, `DamageMin`, `DamageMax` (no overrides in the tree; forks may have some). - `ControlMaster`, `SummonMaster`, `ControlOrder`, `Tamable`, `IsParagon` are `[SerializableProperty]` over hand-written setters because follower bookkeeping must run before the assignment, which a `fieldChanged` hook cannot express; the wire format is identical. ## Prerequisites for cherry-picking #2609 (BaseVendor is already generated on top of BaseCreature) and SerializationGenerator 4.1.0. --- .../Tests/Mobiles/AI/MoveSpeedTests.cs | 25 +- .../Mobiles/BaseCreatureSerializationTests.cs | 455 +++++ .../Spells/Necromancy/BloodOathSpellTests.cs | 2 +- .../Engines/CannedEvil/ChampionTitleSystem.cs | 1 - .../PlayerMurderSystem.cs | 1 - .../UOContent/Engines/Virtues/VirtueSystem.cs | 1 - .../UOContent/Items/Misc/ProjectedItem.cs | 1 - .../Items/Weapons/BaseWeapon.Migrations.cs | 1 - .../Server.Mobiles.BaseCreature.v23.json | 471 ++++++ .../Mobiles/Abilities/MonsterAbility.cs | 4 +- Projects/UOContent/Mobiles/BaseCreature.cs | 1490 +++++++++-------- Projects/UOContent/Mobiles/CreatureEvents.cs | 13 + .../Mobiles/Monsters/LBR/Meers/MeerMage.cs | 2 +- Projects/UOContent/Mobiles/NPCSpeeds.cs | 24 +- Projects/UOContent/Skills/AntiMacroSystem.cs | 1 - Projects/UOContent/Skills/DetectHidden.cs | 1 - .../Spells/Necromancy/BloodOathSpell.cs | 4 +- .../Spells/Spellweaving/GiftOfLife.cs | 2 +- dev-docs/content-patterns.md | 5 +- 19 files changed, 1763 insertions(+), 741 deletions(-) create mode 100644 Projects/UOContent.Tests/Tests/Mobiles/BaseCreatureSerializationTests.cs create mode 100644 Projects/UOContent/Migrations/Server.Mobiles.BaseCreature.v23.json create mode 100644 Projects/UOContent/Mobiles/CreatureEvents.cs diff --git a/Projects/UOContent.Tests/Tests/Mobiles/AI/MoveSpeedTests.cs b/Projects/UOContent.Tests/Tests/Mobiles/AI/MoveSpeedTests.cs index 7d9243a29..8637c5e59 100644 --- a/Projects/UOContent.Tests/Tests/Mobiles/AI/MoveSpeedTests.cs +++ b/Projects/UOContent.Tests/Tests/Mobiles/AI/MoveSpeedTests.cs @@ -57,8 +57,9 @@ public class MoveSpeedTests : IDisposable { var bc = NewCreature(); - Assert.Equal(0.3, bc.ActiveMoveSpeed); - Assert.Equal(0.6, bc.PassiveMoveSpeed); + // 0 = no override; the resolved pace comes from CurrentMoveSpeed. + Assert.Equal(0, bc.ActiveMoveSpeed); + Assert.Equal(0, bc.PassiveMoveSpeed); Assert.Equal(bc.CurrentSpeed, bc.CurrentMoveSpeed); } @@ -96,8 +97,8 @@ public class MoveSpeedTests : IDisposable bc.SetSpeed(0.2, 0.4); - Assert.Equal(0.2, bc.ActiveMoveSpeed); - Assert.Equal(0.4, bc.PassiveMoveSpeed); + Assert.Equal(0, bc.ActiveMoveSpeed); + Assert.Equal(0, bc.PassiveMoveSpeed); } [Fact] @@ -108,8 +109,10 @@ public class MoveSpeedTests : IDisposable 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 + bc.SetCurrentSpeedToActive(); + Assert.Equal(0.3, bc.CurrentMoveSpeed); // resolves to the think clock } [Fact] @@ -121,7 +124,7 @@ public class MoveSpeedTests : IDisposable bc.ScaleMoveSpeed(1.0 / 1.2); 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] @@ -185,8 +188,8 @@ public class MoveSpeedTests : IDisposable bc.MigrateMoveSpeeds(); - Assert.Equal(0.35, bc.ActiveMoveSpeed); - Assert.Equal(0.6, bc.PassiveMoveSpeed); + Assert.Equal(0, bc.ActiveMoveSpeed); // still inheriting the (tuned) think clock + Assert.Equal(0, bc.PassiveMoveSpeed); } [Theory] @@ -211,9 +214,9 @@ public class MoveSpeedTests : IDisposable var reader = new BufferReader(buffer); copy.Deserialize(reader); - // The v22 tail is the last block; exact consumption catches any offset mistake. + // The BaseCreature tail is the last block; exact consumption catches any offset mistake. Assert.Equal(buffer.Length, reader.Position); - Assert.Equal(overridden ? 0.45 : 0.3, copy.ActiveMoveSpeed); - Assert.Equal(overridden ? 0.9 : 0.6, copy.PassiveMoveSpeed); + Assert.Equal(overridden ? 0.45 : 0, copy.ActiveMoveSpeed); + Assert.Equal(overridden ? 0.9 : 0, copy.PassiveMoveSpeed); } } diff --git a/Projects/UOContent.Tests/Tests/Mobiles/BaseCreatureSerializationTests.cs b/Projects/UOContent.Tests/Tests/Mobiles/BaseCreatureSerializationTests.cs new file mode 100644 index 000000000..51a4de573 --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Mobiles/BaseCreatureSerializationTests.cs @@ -0,0 +1,455 @@ +using System; +using System.Collections.Generic; +using Server; +using Server.Items; +using Server.Mobiles; +using Xunit; + +namespace UOContent.Tests.Mobiles; + +// BaseCreature's move to the SerializationGenerator (v23) is guarded three ways: the new +// SaveFlag format round-trips both a default and a fully-populated creature with exact +// byte consumption, back-to-back saves are byte-identical (freeze-time stability), and a +// byte-authentic pre-codegen v22 stream (written by a fossilized replica of the old +// Serialize) loads through the legacy path with the table-speed migration applied. +[Collection("Sequential UOContent Tests")] +public class BaseCreatureSerializationTests : IDisposable +{ + private readonly List _created = new(); + private readonly List _createdItems = new(); + + public void Dispose() + { + for (var i = 0; i < _created.Count; i++) + { + _created[i].Delete(); + } + + for (var i = 0; i < _createdItems.Count; i++) + { + _createdItems[i].Delete(); + } + } + + private class CreatureStub : BaseCreature + { + public CreatureStub() : base(AIType.AI_Melee) => Body = 0xC9; + + public CreatureStub(Serial serial) : base(serial) => Body = 0xC9; + + public DateTime SummonEndValue => SummonEnd; + + // 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; + } + + // ReadEntity resolves references through the world table, so the master must be registered. + private PlayerMobile NewMaster() + { + var master = new PlayerMobile(World.NewMobile); + master.DefaultMobileInit(); + World.AddEntity(master); + _created.Add(master); + return master; + } + + 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); // pulled from the table, not 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 = NewMaster(); + + 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 = NewMaster(); + + // 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); + } + + [Fact] + public void ReferenceFields_RoundTrip() + { + var bc = NewCreature(); + var friend = NewMaster(); + var wayPoint = new WayPoint(); + _createdItems.Add(wayPoint); + + bc.AddPetFriend(friend); + bc.CurrentWayPoint = wayPoint; + bc.HomeMap = Map.Felucca; + + var copy = Load(Snapshot(bc)); + + Assert.Equal(friend, Assert.Single(copy.Friends)); + Assert.Equal(wayPoint, copy.CurrentWayPoint); + Assert.Equal(Map.Felucca, copy.HomeMap); + } + + [Fact] + public void RunningDeleteTimer_RoundTrips() + { + var bc = NewCreature(); + bc.BeginDeleteTimer(); + Assert.True(bc.DeleteTimeLeft > TimeSpan.Zero); + + var copy = Load(Snapshot(bc)); + + // Anchored: the remaining countdown survives, not the absolute deadline. + Assert.InRange(copy.DeleteTimeLeft, TimeSpan.FromDays(3.0) - TimeSpan.FromSeconds(5), TimeSpan.FromDays(3.0)); + } + + private sealed class VendorStub : BaseVendor + { + private static readonly List _sbInfos = []; + + public VendorStub() : base("the stub") + { + } + + public VendorStub(Serial serial) : base(serial) + { + } + + protected override List SBInfos => _sbInfos; + + public override void InitSBInfo() + { + } + + public override void InitOutfit() + { + } + + public override void GetSpeeds(out double activeSpeed, out double passiveSpeed) + { + activeSpeed = 0.3; + passiveSpeed = 0.6; + } + } + + // BaseVendor is generated on top of the generated BaseCreature; the chain must write + // and read both sections in order with exact consumption. + [Fact] + public void GeneratedVendorChain_RoundTrips() + { + var vendor = new VendorStub(); + _created.Add(vendor); + vendor.Home = new Point3D(1500, 1600, 0); + vendor.RangeHome = 2; + + var buffer = Snapshot(vendor); + + var copy = new VendorStub(World.NewMobile); + _created.Add(copy); + var reader = new BufferReader(buffer); + copy.Deserialize(reader); + + Assert.Equal(buffer.Length, reader.Position); + Assert.Equal(AIType.AI_Vendor, copy.AI); + Assert.Equal(FightMode.None, copy.FightMode); + Assert.Equal(new Point3D(1500, 1600, 0), copy.Home); + Assert.Equal(2, copy.RangeHome); + } + + private sealed class MobileStub : Mobile + { + public MobileStub() => Body = 0xC9; + } + + // Byte-authentic replica of the pre-codegen v22 tail — fossilized so the legacy + // upgrade path stays covered without an old save binary. The full stream is a plain + // Mobile section (identical layout for every Mobile subclass) followed by this tail. + private static void WriteLegacyV22Tail( + IGenericWriter writer, + bool controlled = false, + Mobile controlMaster = null, + bool summoned = false, + Mobile summonMaster = null, + DateTime summonEnd = default + ) + { + writer.Write(22); // version + writer.Write((int)AIType.AI_Melee); // current AI + writer.Write((int)AIType.AI_Melee); // default AI + writer.Write(10); // RangePerception + writer.Write(1); // RangeFight + writer.Write(0); // Team + writer.Write(0.3); // active (matches the stub table) + writer.Write(0.6); // passive + writer.Write(0.6); // current + writer.Write(2000); // Home X + writer.Write(2100); // Home Y + writer.Write(7); // Home Z + writer.Write(6); // RangeHome + writer.Write((int)FightMode.Closest); + writer.Write(controlled); + writer.Write(controlMaster); + writer.Write((Mobile)null); // control target + writer.Write(Point3D.Zero); // control dest + writer.Write((int)OrderType.None); + writer.Write(0.0); // min tame skill + writer.Write(true); // tamable + writer.Write(summoned); + if (summoned) + { + writer.WriteAnchoredTime(summonEnd); + } + + writer.Write(2); // control slots + writer.Write(73); // loyalty + writer.Write((Item)null); // waypoint + writer.Write(summonMaster); + writer.Write(180); // hits seed + writer.Write(-1); // stam seed + writer.Write(-1); // mana seed + writer.Write(7); // damage min + writer.Write(14); // damage max + writer.Write(30); // phys resist + writer.Write(100); // phys damage + writer.Write(10); // fire resist + writer.Write(0); // fire damage + writer.Write(0); // cold resist + writer.Write(0); // cold damage + writer.Write(0); // poison resist + writer.Write(0); // poison damage + writer.Write(0); // energy resist + writer.Write(0); // energy damage + writer.Write(new List()); // owners + writer.Write(false); // dead pet + writer.Write(false); // bonded + writer.Write(DateTime.MinValue); // bonding begin + writer.Write(DateTime.MinValue); // abandon time + writer.Write(true); // has generated loot + writer.Write(false); // paragon + writer.Write(false); // has friends + writer.Write(false); // remove if untamed + writer.Write(0); // remove step + writer.Write(TimeSpan.Zero); // delete time left + writer.Write((string)null); // corpse name override + writer.Write((Map)null); // home map + writer.Write(0.0); // active move speed (v22) + writer.Write(0.0); // passive move speed (v22) + } + + private CreatureStub LoadLegacyV22( + bool controlled = false, + Mobile controlMaster = null, + bool summoned = false, + Mobile summonMaster = null, + DateTime summonEnd = default + ) + { + // Every serialized BaseCreature starts with the Mobile base section; a plain + // Mobile donor produces a byte-authentic one. + var donor = new MobileStub(); + donor.DefaultMobileInit(); + _created.Add(donor); + + var writer = new BufferWriter(true); + donor.Serialize(writer); + WriteLegacyV22Tail(writer, controlled, controlMaster, summoned, summonMaster, summonEnd); + + var buffer = new byte[writer.Position]; + writer.Buffer.AsSpan(0, (int)writer.Position).CopyTo(buffer); + + var copy = new CreatureStub(World.NewMobile); + _created.Add(copy); + var reader = new BufferReader(buffer); + copy.Deserialize(reader); + + Assert.Equal(buffer.Length, reader.Position); + return copy; + } + + [Fact] + public void LegacyV22Stream_LoadsThroughLegacyPath() + { + var copy = LoadLegacyV22(); + + Assert.Equal(10, copy.RangePerception); + Assert.Equal(new Point3D(2000, 2100, 7), copy.Home); + Assert.Equal(6, copy.RangeHome); + Assert.True(copy.Tamable); + Assert.Equal(2, copy.ControlSlots); + Assert.Equal(73, copy.Loyalty); + Assert.Equal(180, copy.HitsMaxSeed); + Assert.Equal(7, copy.DamageMin); + Assert.Equal(14, copy.DamageMax); + Assert.Equal(30, copy.PhysicalResistanceSeed); + Assert.Equal(10, copy.FireResistSeed); + Assert.Equal(0.3, copy.ActiveSpeed); + // v22 wrote explicit zeros for the move overrides ("inherit"), so the resolved + // pace falls back to the think clock. + Assert.Equal(0, copy.ActiveMoveSpeed); + Assert.Equal(0.6, copy.CurrentMoveSpeed); // passive mode, inheriting + } + + // ControlMaster and SummonMaster are independent references: a summon master can + // exist without Summoned (EnragedCreature), and a controlled summon carries both. + [Theory] + [InlineData(true, true, false, false)] // controlled pet + [InlineData(true, true, true, true)] // controlled summon, SummonEnd on the wire + [InlineData(false, false, false, true)] // EnragedCreature shape: SummonMaster only + public void LegacyV22Stream_KeepsBothMasterReferences( + bool controlled, + bool hasControlMaster, + bool summoned, + bool hasSummonMaster + ) + { + var master = NewMaster(); + var summonEnd = Core.Now + TimeSpan.FromMinutes(5); + + var copy = LoadLegacyV22( + controlled, + hasControlMaster ? master : null, + summoned, + hasSummonMaster ? master : null, + summonEnd + ); + + Assert.Equal(controlled, copy.Controlled); + Assert.Equal(summoned, copy.Summoned); + Assert.Equal(hasControlMaster ? master : null, copy.ControlMaster); + Assert.Equal(hasSummonMaster ? master : null, copy.SummonMaster); + + if (summoned) + { + Assert.InRange(copy.SummonEndValue, summonEnd - TimeSpan.FromSeconds(1), summonEnd + TimeSpan.FromSeconds(1)); + } + } +} diff --git a/Projects/UOContent.Tests/Tests/Spells/Necromancy/BloodOathSpellTests.cs b/Projects/UOContent.Tests/Tests/Spells/Necromancy/BloodOathSpellTests.cs index 0264a282e..08658b1a1 100644 --- a/Projects/UOContent.Tests/Tests/Spells/Necromancy/BloodOathSpellTests.cs +++ b/Projects/UOContent.Tests/Tests/Spells/Necromancy/BloodOathSpellTests.cs @@ -121,7 +121,7 @@ public class BloodOathSpellTests 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.False(BloodOathSpell.RemoveCurse(caster)); diff --git a/Projects/UOContent/Engines/CannedEvil/ChampionTitleSystem.cs b/Projects/UOContent/Engines/CannedEvil/ChampionTitleSystem.cs index 6160c4595..4c74ee832 100644 --- a/Projects/UOContent/Engines/CannedEvil/ChampionTitleSystem.cs +++ b/Projects/UOContent/Engines/CannedEvil/ChampionTitleSystem.cs @@ -2,7 +2,6 @@ using System; using System.Collections.Generic; using System.Runtime.InteropServices; using ModernUO.CodeGeneratedEvents; -using Server.Collections; using Server.Mobiles; namespace Server.Engines.CannedEvil; diff --git a/Projects/UOContent/Engines/Player Murder System/PlayerMurderSystem.cs b/Projects/UOContent/Engines/Player Murder System/PlayerMurderSystem.cs index d1be82aa9..80ba553fc 100644 --- a/Projects/UOContent/Engines/Player Murder System/PlayerMurderSystem.cs +++ b/Projects/UOContent/Engines/Player Murder System/PlayerMurderSystem.cs @@ -3,7 +3,6 @@ using System.Collections.Generic; using System.Diagnostics; using System.Runtime.InteropServices; using ModernUO.CodeGeneratedEvents; -using Server.Collections; using Server.Logging; using Server.Misc; using Server.Mobiles; diff --git a/Projects/UOContent/Engines/Virtues/VirtueSystem.cs b/Projects/UOContent/Engines/Virtues/VirtueSystem.cs index 457899b91..9a0fd7148 100644 --- a/Projects/UOContent/Engines/Virtues/VirtueSystem.cs +++ b/Projects/UOContent/Engines/Virtues/VirtueSystem.cs @@ -2,7 +2,6 @@ using System; using System.Collections.Generic; using System.Runtime.InteropServices; using ModernUO.CodeGeneratedEvents; -using Server.Collections; using Server.Logging; using Server.Mobiles; diff --git a/Projects/UOContent/Items/Misc/ProjectedItem.cs b/Projects/UOContent/Items/Misc/ProjectedItem.cs index 03e93d0ea..71e339074 100644 --- a/Projects/UOContent/Items/Misc/ProjectedItem.cs +++ b/Projects/UOContent/Items/Misc/ProjectedItem.cs @@ -1,7 +1,6 @@ using System; using System.Collections.Generic; using ModernUO.Serialization; -using Server.Collections; using Server.Network; namespace Server.Items; diff --git a/Projects/UOContent/Items/Weapons/BaseWeapon.Migrations.cs b/Projects/UOContent/Items/Weapons/BaseWeapon.Migrations.cs index 3385598d9..78937f06f 100644 --- a/Projects/UOContent/Items/Weapons/BaseWeapon.Migrations.cs +++ b/Projects/UOContent/Items/Weapons/BaseWeapon.Migrations.cs @@ -1,5 +1,4 @@ using System; -using Server.Engines.Craft; namespace Server.Items; diff --git a/Projects/UOContent/Migrations/Server.Mobiles.BaseCreature.v23.json b/Projects/UOContent/Migrations/Server.Mobiles.BaseCreature.v23.json new file mode 100644 index 000000000..b700154ab --- /dev/null +++ b/Projects/UOContent/Migrations/Server.Mobiles.BaseCreature.v23.json @@ -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": "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": "ControlMaster", + "type": "Server.Mobile", + "usesSaveFlag": true, + "rule": "SerializableInterfaceMigrationRule" + }, + { + "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": "SummonMaster", + "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": [ + "" + ] + } + ] +} \ No newline at end of file diff --git a/Projects/UOContent/Mobiles/Abilities/MonsterAbility.cs b/Projects/UOContent/Mobiles/Abilities/MonsterAbility.cs index fc227507e..75c6fc3dc 100644 --- a/Projects/UOContent/Mobiles/Abilities/MonsterAbility.cs +++ b/Projects/UOContent/Mobiles/Abilities/MonsterAbility.cs @@ -99,8 +99,8 @@ public abstract class MonsterAbility { } - [OnEvent(nameof(BaseCreature.CreatureDeathEvent))] - [OnEvent(nameof(BaseCreature.CreatureDeletedEvent))] + [OnEvent(nameof(CreatureEvents.CreatureDeathEvent))] + [OnEvent(nameof(CreatureEvents.CreatureDeletedEvent))] public static void InvalidateNextAbilityTriggers(BaseCreature source) { var abilities = source.GetMonsterAbilities(); diff --git a/Projects/UOContent/Mobiles/BaseCreature.cs b/Projects/UOContent/Mobiles/BaseCreature.cs index 404aaede8..5ea68cb13 100644 --- a/Projects/UOContent/Mobiles/BaseCreature.cs +++ b/Projects/UOContent/Mobiles/BaseCreature.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Generic; using System.Runtime.CompilerServices; using ModernUO.CodeGeneratedEvents; +using ModernUO.Serialization; using Server.Collections; using Server.ContextMenus; using Server.Engines.ConPVP; @@ -13,6 +14,7 @@ using Server.Engines.Virtues; using Server.Ethics; using Server.Factions; using Server.Items; +using Server.Logging; using Server.Misc; using Server.Multis; using Server.Network; @@ -133,8 +135,17 @@ namespace Server.Mobiles public int CompareTo(DamageStore ds) => (ds?.m_Damage ?? 0).CompareTo(m_Damage); } + [SerializationGenerator(23, false)] public abstract partial class BaseCreature : Mobile, IHonorTarget, IQuestGiver { + private static readonly ILogger logger = LogFactory.GetLogger(typeof(BaseCreature)); + + // Medium bucket; used when an elided load finds no table entry (a 0-delay AI timer would spin). + private const double FallbackActiveSpeed = 0.25; + private const double FallbackPassiveSpeed = 0.5; + + private static bool _loggedMissingSpeeds; + public enum Allegiance { None, @@ -160,7 +171,7 @@ namespace Server.Mobiles public const int DefaultRangePerception = 16; - private const double ChanceToRummage = 0.5; // 50% + private const double ChanceToRummage = 0.5; private const double MinutesToNextRummageMin = 1.0; private const double MinutesToNextRummageMax = 4.0; @@ -227,7 +238,7 @@ namespace Server.Mobiles private static readonly Type[] _gold = { - // white wyrms eat gold.. + // White wyrms eat gold. typeof(Gold) }; @@ -250,54 +261,536 @@ namespace Server.Mobiles typeof(AncientSmithyHammer), typeof(Scorp) }; - private bool _summoned; + // --- Serialized state --------------------------------------------------------- + // Fields matching their defaults (including npc-speeds table values) are elided by [SaveFlag]. - private bool m_bTamable; - private int m_ColdResistance; + [SerializableField(0, setter: "private")] + private AIType _defaultAI; - private bool _controlled; // Is controlled - private Mobile m_ControlMaster; // My master - private OrderType m_ControlOrder; // My order + [SerializableField(1, setter: "private")] + [SaveFlag(nameof(ShouldSerializeCurrentAI), nameof(CurrentAIDefaultValue))] + private AIType _currentAI; - private AIType m_CurrentAI; // The current AI + private bool ShouldSerializeCurrentAI() => _currentAI != _defaultAI; + private AIType CurrentAIDefaultValue() => _defaultAI; + + [EncodedInt] + [SerializableField(2)] + [SaveFlag(nameof(ShouldSerializeRangePerception), nameof(RangePerceptionDefaultValue))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private int _rangePerception; + + private bool ShouldSerializeRangePerception() => _rangePerception != DefaultRangePerception; + + private int RangePerceptionDefaultValue() => DefaultRangePerception; + + [EncodedInt] + [SerializableField(3)] + [SaveFlag(nameof(ShouldSerializeRangeFight), nameof(RangeFightDefaultValue))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private int _rangeFight; + + private bool ShouldSerializeRangeFight() => _rangeFight != 1; + + private int RangeFightDefaultValue() => 1; + + [EncodedInt] + [SerializableField(4)] + [SaveFlag(nameof(ShouldSerializeRangeHome), nameof(RangeHomeDefaultValue))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private int _rangeHome = 10; + + private bool ShouldSerializeRangeHome() => _rangeHome != 10; + + private int RangeHomeDefaultValue() => 10; + + [EncodedInt] + [SerializableField(5, fieldChanged: nameof(OnTeamChange))] + [SaveFlag(nameof(ShouldSerializeTeam))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private int _team; + + private bool ShouldSerializeTeam() => _team != 0; + + private void OnTeamChange(int oldValue, int newValue) => OnTeamChange(); + + [SerializableField(6)] + [SaveFlag(nameof(ShouldSerializeFightMode), nameof(FightModeDefaultValue))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private FightMode _fightMode; + + private bool ShouldSerializeFightMode() => _fightMode != FightMode.Closest; + + private FightMode FightModeDefaultValue() => FightMode.Closest; + + /// Seconds per AI decision while engaged; see for movement pace. + [SerializableField(7)] + [SaveFlag(nameof(ShouldSerializeActiveSpeed), nameof(ActiveSpeedDefaultValue))] + [SerializedCommandProperty(AccessLevel.GameMaster)] private double _activeSpeed; + + private bool ShouldSerializeActiveSpeed() + { + GetSpeeds(out var activeSpeed, out _); + return _activeSpeed != activeSpeed; + } + + private double ActiveSpeedDefaultValue() + { + GetSpeeds(out var activeSpeed, out _); + return activeSpeed; + } + + /// Seconds per AI decision while idle; see for movement pace. + [SerializableField(8)] + [SaveFlag(nameof(ShouldSerializePassiveSpeed), nameof(PassiveSpeedDefaultValue))] + [SerializedCommandProperty(AccessLevel.GameMaster)] private double _passiveSpeed; + + private bool ShouldSerializePassiveSpeed() + { + GetSpeeds(out _, out var passiveSpeed); + return _passiveSpeed != passiveSpeed; + } + + private double PassiveSpeedDefaultValue() + { + GetSpeeds(out _, out var passiveSpeed); + return passiveSpeed; + } + + [SerializableField(9, fieldChanged: nameof(OnCurrentSpeedChange))] + [SaveFlag(nameof(ShouldSerializeCurrentSpeed), nameof(CurrentSpeedDefaultValue))] + [SerializedCommandProperty(AccessLevel.GameMaster)] private double _currentSpeed; - // Movement clock (seconds per step); 0 = inherit the matching think value. + private bool ShouldSerializeCurrentSpeed() => _currentSpeed != _passiveSpeed; + + private double CurrentSpeedDefaultValue() => _passiveSpeed; + + private void OnCurrentSpeedChange(double oldValue, double newValue) => AIObject?.OnCurrentSpeedChanged(); + + /// + /// Movement clock (seconds per step) while engaged; 0 = inherit + /// . resolves the pace. + /// + [SerializableField(10, allowFieldChange: nameof(CoerceMoveSpeed))] + [SaveFlag(nameof(ShouldSerializeActiveMoveSpeed), nameof(ActiveMoveSpeedDefaultValue))] + [SerializedCommandProperty(AccessLevel.GameMaster)] private double _activeMoveSpeed; + + /// + /// Movement clock (seconds per step) while idle; 0 = inherit + /// . resolves the pace. + /// + [SerializableField(11, allowFieldChange: nameof(CoerceMoveSpeed))] + [SaveFlag(nameof(ShouldSerializePassiveMoveSpeed), nameof(PassiveMoveSpeedDefaultValue))] + [SerializedCommandProperty(AccessLevel.GameMaster)] private double _passiveMoveSpeed; + private bool CoerceMoveSpeed(ref double value) + { + value = Math.Max(0, value); // anything non-positive means "inherit" + return true; + } + + private bool ShouldSerializeActiveMoveSpeed() + { + GetMoveSpeeds(out var activeMoveSpeed, out _); + return _activeMoveSpeed != activeMoveSpeed; + } + + private double ActiveMoveSpeedDefaultValue() + { + GetMoveSpeeds(out var activeMoveSpeed, out _); + return activeMoveSpeed; + } + + private bool ShouldSerializePassiveMoveSpeed() + { + GetMoveSpeeds(out _, out var passiveMoveSpeed); + return _passiveMoveSpeed != passiveMoveSpeed; + } + + private double PassiveMoveSpeedDefaultValue() + { + GetMoveSpeeds(out _, out var passiveMoveSpeed); + return passiveMoveSpeed; + } + + [SerializableField(12)] + [SaveFlag(nameof(ShouldSerializeHome))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private Point3D _home; + + private bool ShouldSerializeHome() => _home != Point3D.Zero; + + [SerializableField(13)] + [SaveFlag(nameof(ShouldSerializeHomeMap))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private Map _homeMap; + + private bool ShouldSerializeHomeMap() => _homeMap != null; + + [SerializableField(14, fieldChanged: nameof(OnControlledChange))] + [SaveFlag(nameof(ShouldSerializeControlled))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private bool _controlled; + + private bool ShouldSerializeControlled() => _controlled; + + private void OnControlledChange(bool oldValue, bool newValue) + { + Delta(MobileDelta.Noto); + InvalidateProperties(); + } + + // Follower bookkeeping brackets the assignment, so the property is hand-written. + private Mobile _controlMaster; + + private bool ShouldSerializeControlMaster() => _controlMaster != null; + + [SerializableField(16)] + [SaveFlag(nameof(ShouldSerializeControlTarget))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private Mobile _controlTarget; + + private bool ShouldSerializeControlTarget() => _controlTarget != null; + + [SerializableField(17)] + [SaveFlag(nameof(ShouldSerializeControlDest))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private Point3D _controlDest; + + private bool ShouldSerializeControlDest() => _controlDest != Point3D.Zero; + + // Order logic must run on equal re-assignment, so the property is hand-written. + private OrderType _controlOrder; + + private bool ShouldSerializeControlOrder() => _controlOrder != OrderType.None; + + [SerializableField(19)] + [SaveFlag(nameof(ShouldSerializeMinTameSkill))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private double _minTameSkill; + + private bool ShouldSerializeMinTameSkill() => _minTameSkill != 0; + + // The getter masks paragons, so the property is hand-written. + private bool _tamable; + + private bool ShouldSerializeTamable() => _tamable; + + [SerializableField(21, fieldChanged: nameof(OnSummonedChange))] + [SaveFlag(nameof(ShouldSerializeSummoned))] + [SerializedCommandProperty(AccessLevel.Administrator)] + private bool _summoned; + + private bool ShouldSerializeSummoned() => _summoned; + + private void OnSummonedChange(bool oldValue, bool newValue) + { + NextReacquireTime = Core.TickCount; + Delta(MobileDelta.Noto); + InvalidateProperties(); + } + + [AnchoredDateTime] + [SerializableField(22, getter: "protected", setter: "protected")] + [SaveFlag(nameof(ShouldSerializeSummonEnd))] + private DateTime _summonEnd; + + private bool ShouldSerializeSummonEnd() => _summoned; + + // Follower bookkeeping brackets the assignment, so the property is hand-written. + private Mobile _summonMaster; + + private bool ShouldSerializeSummonMaster() => _summonMaster != null; + + [EncodedInt] + [SerializableField(24)] + [SaveFlag(nameof(ShouldSerializeControlSlots), nameof(ControlSlotsDefaultValue))] + [SerializedCommandProperty(AccessLevel.Administrator)] + private int _controlSlots = 1; + + private bool ShouldSerializeControlSlots() => _controlSlots != 1; + + private int ControlSlotsDefaultValue() => 1; + + [EncodedInt] + [SerializableField(25, allowFieldChange: nameof(ClampLoyalty))] + [SaveFlag(nameof(ShouldSerializeLoyalty), nameof(LoyaltyDefaultValue))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private int _loyalty; + + private bool ShouldSerializeLoyalty() => _loyalty != MaxLoyalty; + + private int LoyaltyDefaultValue() => MaxLoyalty; + + private bool ClampLoyalty(ref int value) + { + value = Math.Clamp(value, 0, MaxLoyalty); + return true; + } + + [SerializableField(26)] + [SaveFlag(nameof(ShouldSerializeCurrentWayPoint))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private WayPoint _currentWayPoint; + + private bool ShouldSerializeCurrentWayPoint() => _currentWayPoint != null; + + [EncodedInt] + [SerializableField(27)] + [SaveFlag(nameof(ShouldSerializeHitsMaxSeed), nameof(HitsMaxSeedDefaultValue))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private int _hitsMaxSeed = -1; + + private bool ShouldSerializeHitsMaxSeed() => _hitsMaxSeed != -1; + + private int HitsMaxSeedDefaultValue() => -1; + + [EncodedInt] + [SerializableField(28)] + [SaveFlag(nameof(ShouldSerializeStamMaxSeed), nameof(StamMaxSeedDefaultValue))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private int _stamMaxSeed = -1; + + private bool ShouldSerializeStamMaxSeed() => _stamMaxSeed != -1; + + private int StamMaxSeedDefaultValue() => -1; + + [EncodedInt] + [SerializableField(29)] + [SaveFlag(nameof(ShouldSerializeManaMaxSeed), nameof(ManaMaxSeedDefaultValue))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private int _manaMaxSeed = -1; + + private bool ShouldSerializeManaMaxSeed() => _manaMaxSeed != -1; + + private int ManaMaxSeedDefaultValue() => -1; + + [EncodedInt] + [SerializableField(30)] + [SaveFlag(nameof(ShouldSerializeDamageMin), nameof(DamageMinDefaultValue))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private int _damageMin = -1; + + private bool ShouldSerializeDamageMin() => _damageMin != -1; + + private int DamageMinDefaultValue() => -1; + + [EncodedInt] + [SerializableField(31)] + [SaveFlag(nameof(ShouldSerializeDamageMax), nameof(DamageMaxDefaultValue))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private int _damageMax = -1; + + private bool ShouldSerializeDamageMax() => _damageMax != -1; + + private int DamageMaxDefaultValue() => -1; + + [EncodedInt] + [SerializableField(32, fieldChanged: nameof(OnResistanceSeedChange))] + [SaveFlag(nameof(ShouldSerializePhysicalResistanceSeed))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private int _physicalResistanceSeed; + + private bool ShouldSerializePhysicalResistanceSeed() => _physicalResistanceSeed != 0; + + private void OnResistanceSeedChange(int oldValue, int newValue) => UpdateResistances(); + + [EncodedInt] + [SerializableField(33, fieldChanged: nameof(OnResistanceSeedChange))] + [SaveFlag(nameof(ShouldSerializeFireResistSeed))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private int _fireResistSeed; + + private bool ShouldSerializeFireResistSeed() => _fireResistSeed != 0; + + [EncodedInt] + [SerializableField(34, fieldChanged: nameof(OnResistanceSeedChange))] + [SaveFlag(nameof(ShouldSerializeColdResistSeed))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private int _coldResistSeed; + + private bool ShouldSerializeColdResistSeed() => _coldResistSeed != 0; + + [EncodedInt] + [SerializableField(35, fieldChanged: nameof(OnResistanceSeedChange))] + [SaveFlag(nameof(ShouldSerializePoisonResistSeed))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private int _poisonResistSeed; + + private bool ShouldSerializePoisonResistSeed() => _poisonResistSeed != 0; + + [EncodedInt] + [SerializableField(36, fieldChanged: nameof(OnResistanceSeedChange))] + [SaveFlag(nameof(ShouldSerializeEnergyResistSeed))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private int _energyResistSeed; + + private bool ShouldSerializeEnergyResistSeed() => _energyResistSeed != 0; + + [EncodedInt] + [SerializableField(37)] + [SaveFlag(nameof(ShouldSerializePhysicalDamage), nameof(PhysicalDamageDefaultValue))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private int _physicalDamage = 100; + + private bool ShouldSerializePhysicalDamage() => _physicalDamage != 100; + + private int PhysicalDamageDefaultValue() => 100; + + [EncodedInt] + [SerializableField(38)] + [SaveFlag(nameof(ShouldSerializeFireDamage))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private int _fireDamage; + + private bool ShouldSerializeFireDamage() => _fireDamage != 0; + + [EncodedInt] + [SerializableField(39)] + [SaveFlag(nameof(ShouldSerializeColdDamage))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private int _coldDamage; + + private bool ShouldSerializeColdDamage() => _coldDamage != 0; + + [EncodedInt] + [SerializableField(40)] + [SaveFlag(nameof(ShouldSerializePoisonDamage))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private int _poisonDamage; + + private bool ShouldSerializePoisonDamage() => _poisonDamage != 0; + + [EncodedInt] + [SerializableField(41)] + [SaveFlag(nameof(ShouldSerializeEnergyDamage))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private int _energyDamage; + + private bool ShouldSerializeEnergyDamage() => _energyDamage != 0; + + [Tidy] + [SerializableField(42, setter: "private")] + [SaveFlag(nameof(ShouldSerializeOwners), nameof(OwnersDefaultValue))] + private List _owners; + + private bool ShouldSerializeOwners() + { + _owners?.Tidy(); + return _owners?.Count > 0; + } + + private List OwnersDefaultValue() => new(); + + [SerializableField(43)] + [SaveFlag(nameof(ShouldSerializeIsDeadPet))] + private bool _isDeadPet; + + private bool ShouldSerializeIsDeadPet() => _isDeadPet; + + [SerializableField(44, fieldChanged: nameof(OnBondedChange))] + [SaveFlag(nameof(ShouldSerializeIsBonded))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private bool _isBonded; + + private bool ShouldSerializeIsBonded() => _isBonded; + + private void OnBondedChange(bool oldValue, bool newValue) => InvalidateProperties(); + + [SerializableField(45)] + [SaveFlag(nameof(ShouldSerializeBondingBegin))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private DateTime _bondingBegin; + + private bool ShouldSerializeBondingBegin() => _bondingBegin != DateTime.MinValue; + + [SerializableField(46)] + [SaveFlag(nameof(ShouldSerializeOwnerAbandonTime))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private DateTime _ownerAbandonTime; + + private bool ShouldSerializeOwnerAbandonTime() => _ownerAbandonTime != DateTime.MinValue; + + [SerializableField(47)] + [SaveFlag(nameof(ShouldSerializeHasGeneratedLoot))] + private bool _hasGeneratedLoot; + + private bool ShouldSerializeHasGeneratedLoot() => _hasGeneratedLoot; + + // The setter converts the creature, which must not run at load, so the property is hand-written. + private bool _isParagon; + + private bool ShouldSerializeIsParagon() => _isParagon; + + [Tidy] + [SerializableField(49, setter: "private")] + [SaveFlag(nameof(ShouldSerializeFriends))] + private List _friends; + + private bool ShouldSerializeFriends() + { + _friends?.Tidy(); + return _friends?.Count > 0; + } + + [SerializableField(50)] + [SaveFlag(nameof(ShouldSerializeRemoveIfUntamed))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private bool _removeIfUntamed; + + private bool ShouldSerializeRemoveIfUntamed() => _removeIfUntamed; + + [EncodedInt] + [SerializableField(51)] + [SaveFlag(nameof(ShouldSerializeRemoveStep))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private int _removeStep; + + private bool ShouldSerializeRemoveStep() => _removeStep != 0; + + [SerializableField(52, setter: "private")] + [SaveFlag(nameof(ShouldSerializePendingDeleteTimer))] + [DeserializeTimer(nameof(DeserializePendingDeleteTimer))] + private Timer _pendingDeleteTimer; + + // Stabled and controlled pets never resume a delete countdown. + private bool ShouldSerializePendingDeleteTimer() => + _pendingDeleteTimer?.Running == true && !IsStabled && !(_controlled && _controlMaster != null); + + private void DeserializePendingDeleteTimer(TimeSpan delay) + { + _pendingDeleteTimer = new DeleteTimer(this, delay); + _pendingDeleteTimer.Start(); + } + + [SerializableField(53)] + [SaveFlag(nameof(ShouldSerializeCorpseNameOverride))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private string _corpseNameOverride; + + private bool ShouldSerializeCorpseNameOverride() => _corpseNameOverride != null; + + // --- Non-serialized state ------------------------------------------------------- + // Herding - forces the mob to walk to a specific location, paced by the movement // clock at HerdingMoveSpeed. Thinking is unaffected. private IPoint2D _targetLocation; - private int m_DamageMax = -1; - - private int m_DamageMin = -1; - private AIType m_DefaultAI; // The default AI - - private DeleteTimer m_DeleteTimer; - private int m_EnergyResistance; - private int m_FailedReturnHome; /* return to home failure counter */ - private int m_FireResistance; - private bool m_HasGeneratedLoot; // have we generated our loot yet? private TimerExecutionToken _healTimerToken; - private Point3D m_Home; // The home position of the creature, used by some AI - private DateTime m_IdleReleaseTime; - private bool m_IsBonded; - private bool m_IsStabled; protected int m_KillersLuck; - private int m_Loyalty; - private DateTime m_MLNextShout; private List m_MLQuests; @@ -310,24 +803,13 @@ namespace Server.Mobiles private long m_NextRummageTime; - private bool m_Paragon; - - private int m_PhysicalResistance; - private int m_PoisonResistance; - - /* until we are sure about who should be getting deleted, move them instead */ - /* On OSI, they despawn */ - + // On OSI these despawn; we queue a return home instead of deleting. private bool m_ReturnQueued; protected bool m_Spawning; - private Mobile m_SummonMaster; - private SkillName m_Teaching = (SkillName)(-1); - private int m_Team; // Monster Team - public BaseCreature( AIType ai, FightMode mode = FightMode.Closest, @@ -335,10 +817,10 @@ namespace Server.Mobiles int iRangeFight = 1 ) { - m_Loyalty = MaxLoyalty; // Wonderfully Happy + _loyalty = MaxLoyalty; - m_CurrentAI = ai; - m_DefaultAI = ai; + _currentAI = ai; + _defaultAI = ai; RangePerception = iRangePerception; RangeFight = iRangeFight; @@ -348,20 +830,28 @@ namespace Server.Mobiles GetSpeeds(out var activeSpeed, out var passiveSpeed); GetMoveSpeeds(out _activeMoveSpeed, out _passiveMoveSpeed); + if (activeSpeed <= 0 || passiveSpeed <= 0) + { + // A 0-delay creature would spin its AI timer; only construction refuses. + throw new InvalidOperationException( + $"{GetType()} constructed without speeds - is Data/npc-speeds.json missing?" + ); + } + ActiveSpeed = activeSpeed; PassiveSpeed = passiveSpeed; CurrentSpeed = passiveSpeed; - m_Team = 0; + _team = 0; Debug = false; _controlled = false; - m_ControlMaster = null; + _controlMaster = null; ControlTarget = null; - m_ControlOrder = OrderType.None; + _controlOrder = OrderType.None; - m_bTamable = false; + _tamable = false; Owners = new List(); @@ -406,14 +896,10 @@ namespace Server.Mobiles public virtual InhumanSpeech SpeechType => null; - /* Do not serialize this till the code is finalized */ - + // Deliberately not serialized until the feature is finalized. [CommandProperty(AccessLevel.GameMaster)] public bool SeeksHome { get; set; } - [CommandProperty(AccessLevel.GameMaster)] - public string CorpseNameOverride { get; set; } - [CommandProperty(AccessLevel.GameMaster, AccessLevel.Administrator)] public bool IsStabled { @@ -436,20 +922,20 @@ namespace Server.Mobiles public virtual bool FollowsAcquireRules => true; - protected DateTime SummonEnd { get; set; } - public virtual Faction FactionAllegiance => null; public virtual int FactionSilverWorth => 30; public virtual double WeaponAbilityChance => 0.4; + [SerializableProperty(48, useField: nameof(_isParagon))] + [SaveFlag(nameof(ShouldSerializeIsParagon))] [CommandProperty(AccessLevel.GameMaster)] public bool IsParagon { - get => m_Paragon; + get => _isParagon; set { - if (m_Paragon == value) + if (_isParagon == value) { return; } @@ -463,9 +949,10 @@ namespace Server.Mobiles Paragon.UnConvert(this); } - m_Paragon = value; + _isParagon = value; InvalidateProperties(); + this.MarkDirty(); } } @@ -474,8 +961,6 @@ namespace Server.Mobiles public virtual FoodType FavoriteFood => FoodType.Meat; public virtual PackInstinct PackInstinct => PackInstinct.None; - public List Owners { get; private set; } - public virtual bool AllowMaleTamer => true; public virtual bool AllowFemaleTamer => true; public virtual bool SubdueBeforeTame => false; @@ -499,11 +984,11 @@ namespace Server.Mobiles public virtual bool DeathAdderCharmable => false; - // TODO: Find the pub 31 tweaks to the DispelDifficulty and apply them of course. - // at this skill level we dispel 50% chance + //TODO Apply the pub 31 DispelDifficulty tweaks + // Skill level at which dispel succeeds 50% of the time. public virtual double DispelDifficulty => 0.0; - // at difficulty - focus we have 0%, at difficulty + focus we have 100% + // 0% at difficulty - focus, 100% at difficulty + focus. public virtual double DispelFocus => 20.0; public virtual bool DisplayWeight => Backpack is StrongBackpack; @@ -539,21 +1024,11 @@ namespace Server.Mobiles } public virtual bool IsNecroFamiliar => - Summoned && m_ControlMaster != null && - SummonFamiliarSpell.Table.TryGetValue(m_ControlMaster, out var bc) && bc == this; + Summoned && _controlMaster != null && + SummonFamiliarSpell.Table.TryGetValue(_controlMaster, out var bc) && bc == this; public virtual bool DeleteCorpseOnDeath => !Core.AOS && _summoned; - [CommandProperty(AccessLevel.GameMaster)] - public int Loyalty - { - get => m_Loyalty; - set => m_Loyalty = Math.Clamp(value, 0, MaxLoyalty); - } - - [CommandProperty(AccessLevel.GameMaster)] - public WayPoint CurrentWayPoint { get; set; } - public virtual Mobile ConstantFocus => null; public virtual bool DisallowAllMoves => false; @@ -566,57 +1041,25 @@ namespace Server.Mobiles public virtual bool AlwaysAttackable => false; - [CommandProperty(AccessLevel.GameMaster)] - public virtual int DamageMin - { - get => m_DamageMin; - set => m_DamageMin = value; - } - - [CommandProperty(AccessLevel.GameMaster)] - public virtual int DamageMax - { - get => m_DamageMax; - set => m_DamageMax = value; - } - [CommandProperty(AccessLevel.GameMaster)] public override int HitsMax => HitsMaxSeed <= 0 ? Str : Math.Clamp(HitsMaxSeed + GetStatOffset(StatType.Str), 1, 65000); - [CommandProperty(AccessLevel.GameMaster)] - public int HitsMaxSeed { get; set; } = -1; - [CommandProperty(AccessLevel.GameMaster)] public override int StamMax => StamMaxSeed <= 0 ? Dex : Math.Clamp(StamMaxSeed + GetStatOffset(StatType.Dex), 1, 65000); - [CommandProperty(AccessLevel.GameMaster)] - public int StamMaxSeed { get; set; } = -1; - [CommandProperty(AccessLevel.GameMaster)] public override int ManaMax => ManaMaxSeed <= 0 ? Int : Math.Clamp(ManaMaxSeed + GetStatOffset(StatType.Int), 1, 65000); - [CommandProperty(AccessLevel.GameMaster)] - public int ManaMaxSeed { get; set; } = -1; - public virtual bool CanOpenDoors => !Body.IsAnimal && !Body.IsSea; public virtual bool CanMoveOverObstacles => Core.AOS || Body.IsMonster; public virtual bool CanDestroyObstacles => false; - /* - Seems this actually was removed on OSI somewhere between the original bug report and now. - We will call it ML, until we can get better information. I suspect it was on the OSI TC when - originally it taken out of RunUO, and not implemented on OSIs production shards until more - recently. Either way, this is, or was, accurate OSI behavior, and just entirely - removing it was incorrect. OSI followers were distracted by being attacked well into - AoS, at very least. - - */ - + // OSI followers were distracted by attacks well into AoS; removed around ML. public virtual bool CanBeDistracted => !Core.ML; public override bool ShouldCheckStatTimers => false; @@ -628,43 +1071,27 @@ namespace Server.Mobiles [CommandProperty(AccessLevel.GameMaster)] public AIType AI { - get => m_CurrentAI; + get => _currentAI; set { - m_CurrentAI = value; + _currentAI = value; - if (m_CurrentAI == AIType.AI_Use_Default) + if (_currentAI == AIType.AI_Use_Default) { - m_CurrentAI = m_DefaultAI; + _currentAI = _defaultAI; } - ChangeAIType(m_CurrentAI); + this.MarkDirty(); + ChangeAIType(_currentAI); } } [CommandProperty(AccessLevel.Administrator)] public bool Debug { get; set; } - [CommandProperty(AccessLevel.GameMaster)] - public int Team - { - get => m_Team; - set - { - m_Team = value; - OnTeamChange(); - } - } - [CommandProperty(AccessLevel.GameMaster)] public Mobile FocusMob { get; set; } - [CommandProperty(AccessLevel.GameMaster)] - public FightMode FightMode { get; set; } - - [CommandProperty(AccessLevel.GameMaster)] - public int RangePerception { get; set; } - /// /// How far a chase may stretch before the creature gives up its combatant. Between /// RangePerception and this leash it keeps chasing but may switch to closer targets. @@ -672,57 +1099,6 @@ namespace Server.Mobiles [CommandProperty(AccessLevel.GameMaster)] public virtual int ChaseLeashRange => RangePerception * 2; - [CommandProperty(AccessLevel.GameMaster)] - public int RangeFight { get; set; } - - [CommandProperty(AccessLevel.GameMaster)] - public int RangeHome { get; set; } = 10; - - /// Seconds per AI decision while engaged; see for movement pace. - [CommandProperty(AccessLevel.GameMaster)] - public virtual double ActiveSpeed - { - get => _activeSpeed; - set - { - if (Math.Abs(_activeSpeed - value) > .0001) - { - _activeSpeed = value; - } - } - } - - /// Seconds per AI decision while idle; see for movement pace. - [CommandProperty(AccessLevel.GameMaster)] - public virtual double PassiveSpeed - { - get => _passiveSpeed; - set - { - _passiveSpeed = value; - if (Math.Abs(_passiveSpeed - value) > .0001) - { - _passiveSpeed = value; - } - } - } - - /// Seconds per step while engaged. Inherits ; set 0 to re-inherit. - [CommandProperty(AccessLevel.GameMaster)] - public virtual double ActiveMoveSpeed - { - get => _activeMoveSpeed > 0 ? _activeMoveSpeed : _activeSpeed; - set => _activeMoveSpeed = value > 0 ? value : 0; - } - - /// Seconds per step while idle. Inherits ; set 0 to re-inherit. - [CommandProperty(AccessLevel.GameMaster)] - public virtual double PassiveMoveSpeed - { - get => _passiveMoveSpeed > 0 ? _passiveMoveSpeed : _passiveSpeed; - set => _passiveMoveSpeed = value > 0 ? value : 0; - } - // Herded creatures walk at a fixed standard pace regardless of their own speed // (RunUO's forced 0.3, without its TransformMoveDelay inflation to 0.6). private const double HerdingMoveSpeed = 0.3; @@ -734,20 +1110,6 @@ namespace Server.Mobiles set => _targetLocation = value; } - [CommandProperty(AccessLevel.GameMaster)] - public double CurrentSpeed - { - get => _currentSpeed; - set - { - if (Math.Abs(_currentSpeed - value) > 0.0001) - { - _currentSpeed = value; - AIObject?.OnCurrentSpeedChanged(); - } - } - } - /// /// Resolved seconds per step: a verbatim active/passive /// maps to the matching movement value; a bespoke pace (e.g. the pet-order 0.1 sprint) @@ -764,104 +1126,87 @@ namespace Server.Mobiles return HerdingMoveSpeed; } - return _currentSpeed == _activeSpeed ? ActiveMoveSpeed - : _currentSpeed == _passiveSpeed ? PassiveMoveSpeed - : _currentSpeed; - } - } - - [CommandProperty(AccessLevel.GameMaster)] - public Point3D Home - { - get => m_Home; - set => m_Home = value; - } - - [CommandProperty(AccessLevel.GameMaster)] - public Map HomeMap { get; set; } - - [CommandProperty(AccessLevel.GameMaster)] - public bool Controlled - { - get => _controlled; - set - { - if (_controlled == value) + if (_currentSpeed == _activeSpeed) { - return; + return _activeMoveSpeed > 0 ? _activeMoveSpeed : _activeSpeed; } - _controlled = value; - Delta(MobileDelta.Noto); + if (_currentSpeed == _passiveSpeed) + { + return _passiveMoveSpeed > 0 ? _passiveMoveSpeed : _passiveSpeed; + } - InvalidateProperties(); + return _currentSpeed; } } + [SerializableProperty(15, useField: nameof(_controlMaster))] + [SaveFlag(nameof(ShouldSerializeControlMaster))] [CommandProperty(AccessLevel.GameMaster)] public Mobile ControlMaster { - get => m_ControlMaster; + get => _controlMaster; set { - if (m_ControlMaster == value || this == value) + if (_controlMaster == value || this == value) { return; } RemoveFollowers(); - m_ControlMaster = value; + _controlMaster = value; AddFollowers(); - if (m_ControlMaster != null) + if (_controlMaster != null) { StopDeleteTimer(); } Delta(MobileDelta.Noto); + this.MarkDirty(); } } + [SerializableProperty(23, useField: nameof(_summonMaster))] + [SaveFlag(nameof(ShouldSerializeSummonMaster))] [CommandProperty(AccessLevel.GameMaster)] public Mobile SummonMaster { - get => m_SummonMaster; + get => _summonMaster; set { - if (m_SummonMaster == value || this == value) + if (_summonMaster == value || this == value) { return; } RemoveFollowers(); - m_SummonMaster = value; + _summonMaster = value; AddFollowers(); Delta(MobileDelta.Noto); + this.MarkDirty(); } } - [CommandProperty(AccessLevel.GameMaster)] - public Mobile ControlTarget { get; set; } - - [CommandProperty(AccessLevel.GameMaster)] - public Point3D ControlDest { get; set; } - // Fires on every assignment, not only changes: a reissued order is a command // (retarget, break off combat, re-anchor Home). Handlers receive the previous order. + [SerializableProperty(18, useField: nameof(_controlOrder))] + [SaveFlag(nameof(ShouldSerializeControlOrder))] [CommandProperty(AccessLevel.GameMaster)] public OrderType ControlOrder { - get => m_ControlOrder; + get => _controlOrder; set { - var previous = m_ControlOrder; - m_ControlOrder = value; + var previous = _controlOrder; + _controlOrder = value; AIObject?.OnCurrentOrderChanged(previous); InvalidateProperties(); - m_ControlMaster?.InvalidateProperties(); + _controlMaster?.InvalidateProperties(); + this.MarkDirty(); } } @@ -880,39 +1225,19 @@ namespace Server.Mobiles [CommandProperty(AccessLevel.GameMaster)] public DateTime BardEndTime { get; set; } - [CommandProperty(AccessLevel.GameMaster)] - public double MinTameSkill { get; set; } - + [SerializableProperty(20, useField: nameof(_tamable))] + [SaveFlag(nameof(ShouldSerializeTamable))] [CommandProperty(AccessLevel.GameMaster)] public bool Tamable { - get => m_bTamable && !m_Paragon; - set => m_bTamable = value; - } - - [CommandProperty(AccessLevel.Administrator)] - public bool Summoned - { - get => _summoned; + get => _tamable && !_isParagon; set { - if (_summoned == value) - { - return; - } - - NextReacquireTime = Core.TickCount; - - _summoned = value; - Delta(MobileDelta.Noto); - - InvalidateProperties(); + _tamable = value; + this.MarkDirty(); } } - [CommandProperty(AccessLevel.Administrator)] - public int ControlSlots { get; set; } = 1; - public virtual bool NoHouseRestrictions => false; public virtual bool IsHouseSummonable => false; @@ -943,7 +1268,7 @@ namespace Server.Mobiles // Reaction-time gradient: an enemy moving inside AcquireOnApproachRange pulls the // next scan to at most this far away. Zero (paragons) scans on the very next // think; larger is dumber; pure ReacquireDelay is the oblivious floor. - public virtual TimeSpan AcquireOnApproachDelay => m_Paragon ? TimeSpan.Zero : TimeSpan.FromSeconds(2.0); + public virtual TimeSpan AcquireOnApproachDelay => _isParagon ? TimeSpan.Zero : TimeSpan.FromSeconds(2.0); // Reactive range is tighter than the periodic scan's RangePerception: approach // aggro starts on-screen; the ReacquireDelay poll keeps the wide ambient sweep. @@ -990,13 +1315,6 @@ namespace Server.Mobiles public virtual bool ReturnsToHome => SeeksHome && Home != Point3D.Zero && !m_ReturnQueued && !Controlled && !Summoned; - // used for deleting untamed creatures [in houses] - [CommandProperty(AccessLevel.GameMaster)] - public bool RemoveIfUntamed { get; set; } - - [CommandProperty(AccessLevel.GameMaster)] - public int RemoveStep { get; set; } - public virtual bool CanGiveMLQuest => MLQuests.Count != 0; public virtual bool StaticMLQuester => true; @@ -1031,114 +1349,25 @@ namespace Server.Mobiles } } - [CommandProperty(AccessLevel.GameMaster)] - public bool IsBonded - { - get => m_IsBonded; - set - { - m_IsBonded = value; - InvalidateProperties(); - } - } - - public bool IsDeadPet { get; set; } - - [CommandProperty(AccessLevel.GameMaster)] - public DateTime BondingBegin { get; set; } - - [CommandProperty(AccessLevel.GameMaster)] - public DateTime OwnerAbandonTime { get; set; } - [CommandProperty(AccessLevel.GameMaster)] public TimeSpan DeleteTimeLeft { get { - if (m_DeleteTimer?.Running == true) + if (_pendingDeleteTimer?.Running == true) { - return m_DeleteTimer.Next - Core.Now; + return _pendingDeleteTimer.Next - Core.Now; } return TimeSpan.Zero; } } - public override int BasePhysicalResistance => m_PhysicalResistance; - public override int BaseFireResistance => m_FireResistance; - public override int BaseColdResistance => m_ColdResistance; - public override int BasePoisonResistance => m_PoisonResistance; - public override int BaseEnergyResistance => m_EnergyResistance; - - [CommandProperty(AccessLevel.GameMaster)] - public int PhysicalResistanceSeed - { - get => m_PhysicalResistance; - set - { - m_PhysicalResistance = value; - UpdateResistances(); - } - } - - [CommandProperty(AccessLevel.GameMaster)] - public int FireResistSeed - { - get => m_FireResistance; - set - { - m_FireResistance = value; - UpdateResistances(); - } - } - - [CommandProperty(AccessLevel.GameMaster)] - public int ColdResistSeed - { - get => m_ColdResistance; - set - { - m_ColdResistance = value; - UpdateResistances(); - } - } - - [CommandProperty(AccessLevel.GameMaster)] - public int PoisonResistSeed - { - get => m_PoisonResistance; - set - { - m_PoisonResistance = value; - UpdateResistances(); - } - } - - [CommandProperty(AccessLevel.GameMaster)] - public int EnergyResistSeed - { - get => m_EnergyResistance; - set - { - m_EnergyResistance = value; - UpdateResistances(); - } - } - - [CommandProperty(AccessLevel.GameMaster)] - public int PhysicalDamage { get; set; } = 100; - - [CommandProperty(AccessLevel.GameMaster)] - public int FireDamage { get; set; } - - [CommandProperty(AccessLevel.GameMaster)] - public int ColdDamage { get; set; } - - [CommandProperty(AccessLevel.GameMaster)] - public int PoisonDamage { get; set; } - - [CommandProperty(AccessLevel.GameMaster)] - public int EnergyDamage { get; set; } + public override int BasePhysicalResistance => _physicalResistanceSeed; + public override int BaseFireResistance => _fireResistSeed; + public override int BaseColdResistance => _coldResistSeed; + public override int BasePoisonResistance => _poisonResistSeed; + public override int BaseEnergyResistance => _energyResistSeed; [CommandProperty(AccessLevel.GameMaster)] public int ChaosDamage { get; set; } @@ -1146,15 +1375,12 @@ namespace Server.Mobiles [CommandProperty(AccessLevel.GameMaster)] public int DirectDamage { get; set; } - // Is immune to breath damages public virtual bool BreathImmune => false; - public virtual bool CanFlee => !m_Paragon; + public virtual bool CanFlee => !_isParagon; public DateTime EndFleeTime { get; set; } - public List Friends { get; private set; } - public virtual bool AllowNewPetFriend => Friends == null || Friends.Count < 5; public virtual Ethic EthicAllegiance => null; @@ -1206,7 +1432,6 @@ namespace Server.Mobiles public HonorContext ReceivedHonorContext { get; set; } public List MLQuests => - // Assign the quests if we don't have one, and if it is still null, return an empty list (m_MLQuests ??= StaticMLQuester ? MLQuestSystem.FindQuestList(GetType()) : ConstructQuestList()) ?? MLQuestSystem.EmptyList; public virtual MonsterAbility[] GetMonsterAbilities() => null; @@ -1432,7 +1657,7 @@ namespace Server.Mobiles return false; } - if (m_Team != c.Team || FightMode == FightMode.Evil && m.Karma < 0 || c.FightMode == FightMode.Evil && Karma < 0) + if (_team != c.Team || FightMode == FightMode.Evil && m.Karma < 0 || c.FightMode == FightMode.Evil && Karma < 0) { return true; } @@ -1550,7 +1775,7 @@ namespace Server.Mobiles var chance = Math.Clamp(700 + bonus, 220, 990); - chance -= (MaxLoyalty - m_Loyalty) * 10; + chance -= (MaxLoyalty - _loyalty) * 10; return chance / 1000.0; } @@ -1642,7 +1867,7 @@ namespace Server.Mobiles public override bool CheckPoisonImmunity(Mobile from, Poison poison) => base.CheckPoisonImmunity(from, poison) || - (m_Paragon ? PoisonImpl.IncreaseLevel(PoisonImmune) : PoisonImmune)?.Level >= poison.Level; + (_isParagon ? PoisonImpl.IncreaseLevel(PoisonImmune) : PoisonImmune)?.Level >= poison.Level; public void Unpacify() { @@ -1669,7 +1894,6 @@ namespace Server.Mobiles } int disruptThreshold; - // NPCs can use bandages too! if (!Core.AOS) { disruptThreshold = 0; @@ -1919,163 +2143,28 @@ namespace Server.Mobiles } } - public override void Serialize(IGenericWriter writer) + // Pre-codegen loads only (versions 0-22); post-codegen bumps use MigrateFrom. + private void Deserialize(IGenericReader reader, int version) { - base.Serialize(writer); + _currentAI = (AIType)reader.ReadInt(); + _defaultAI = (AIType)reader.ReadInt(); - writer.Write(22); // version + _rangePerception = reader.ReadInt(); + _rangeFight = reader.ReadInt(); - writer.Write((int)m_CurrentAI); - writer.Write((int)m_DefaultAI); - - writer.Write(RangePerception); - writer.Write(RangeFight); - - writer.Write(m_Team); - - writer.Write(_activeSpeed); - writer.Write(_passiveSpeed); - writer.Write(_currentSpeed); - - writer.Write(m_Home.X); - writer.Write(m_Home.Y); - writer.Write(m_Home.Z); - - // Version 1 - writer.Write(RangeHome); - - // Version 2 - writer.Write((int)FightMode); - - writer.Write(_controlled); - writer.Write(m_ControlMaster); - writer.Write(ControlTarget); - writer.Write(ControlDest); - writer.Write((int)m_ControlOrder); - writer.Write(MinTameSkill); - // Removed in version 9 - // writer.Write( (double) m_dMaxTameSkill ); - writer.Write(m_bTamable); - writer.Write(_summoned); - - if (_summoned) - { - writer.WriteAnchoredTime(SummonEnd); - } - - writer.Write(ControlSlots); - - // Version 3 - writer.Write(m_Loyalty); - - // Version 4 - writer.Write(CurrentWayPoint); - - // Verison 5 - writer.Write(m_SummonMaster); - - // Version 6 - writer.Write(HitsMaxSeed); - writer.Write(StamMaxSeed); - writer.Write(ManaMaxSeed); - writer.Write(m_DamageMin); - writer.Write(m_DamageMax); - - // Version 7 - writer.Write(m_PhysicalResistance); - writer.Write(PhysicalDamage); - - writer.Write(m_FireResistance); - writer.Write(FireDamage); - - writer.Write(m_ColdResistance); - writer.Write(ColdDamage); - - writer.Write(m_PoisonResistance); - writer.Write(PoisonDamage); - - writer.Write(m_EnergyResistance); - writer.Write(EnergyDamage); - - // Version 8 - Owners.Tidy(); - writer.Write(Owners); - - // Version 10 - writer.Write(IsDeadPet); - writer.Write(m_IsBonded); - writer.Write(BondingBegin); - writer.Write(OwnerAbandonTime); - - // Version 11 - writer.Write(m_HasGeneratedLoot); - - // Version 12 - writer.Write(m_Paragon); - - var hasFriends = Friends?.Count > 0; - - // Version 13 - writer.Write(hasFriends); - - if (hasFriends) - { - Friends.Tidy(); - writer.Write(Friends); - } - - // Version 14 - writer.Write(RemoveIfUntamed); - writer.Write(RemoveStep); - - // Version 17 - if (IsStabled || Controlled && ControlMaster != null) - { - writer.Write(TimeSpan.Zero); - } - else - { - writer.Write(DeleteTimeLeft); - } - - // Version 18 - writer.Write(CorpseNameOverride); - - // Version 19 - writer.Write(HomeMap); - - // Version 22 (0 = inherit the matching think value) - writer.Write(_activeMoveSpeed); - writer.Write(_passiveMoveSpeed); - } - - public override void Deserialize(IGenericReader reader) - { - base.Deserialize(reader); - - NextReacquireTime = Core.TickCount; - - var version = reader.ReadInt(); - - m_CurrentAI = (AIType)reader.ReadInt(); - m_DefaultAI = (AIType)reader.ReadInt(); - - RangePerception = reader.ReadInt(); - RangeFight = reader.ReadInt(); - - m_Team = reader.ReadInt(); + _team = reader.ReadInt(); _activeSpeed = reader.ReadDouble(); _passiveSpeed = reader.ReadDouble(); _currentSpeed = reader.ReadDouble(); - m_Home.X = reader.ReadInt(); - m_Home.Y = reader.ReadInt(); - m_Home.Z = reader.ReadInt(); + _home.X = reader.ReadInt(); + _home.Y = reader.ReadInt(); + _home.Z = reader.ReadInt(); if (version >= 1) { - RangeHome = reader.ReadInt(); + _rangeHome = reader.ReadInt(); if (version < 20) { @@ -2096,121 +2185,121 @@ namespace Server.Mobiles } else { - RangeHome = 0; + _rangeHome = 0; } if (version >= 2) { - FightMode = (FightMode)reader.ReadInt(); + _fightMode = (FightMode)reader.ReadInt(); _controlled = reader.ReadBool(); - m_ControlMaster = reader.ReadEntity(); - ControlTarget = reader.ReadEntity(); - ControlDest = reader.ReadPoint3D(); - m_ControlOrder = (OrderType)reader.ReadInt(); + _controlMaster = reader.ReadEntity(); + _controlTarget = reader.ReadEntity(); + _controlDest = reader.ReadPoint3D(); + _controlOrder = (OrderType)reader.ReadInt(); - MinTameSkill = reader.ReadDouble(); + _minTameSkill = reader.ReadDouble(); if (version < 9) { reader.ReadDouble(); } - m_bTamable = reader.ReadBool(); + _tamable = reader.ReadBool(); _summoned = reader.ReadBool(); if (_summoned) { - SummonEnd = version >= 21 ? reader.ReadAnchoredTime() : reader.ReadDeltaTime(); - new UnsummonTimer(this, SummonEnd - Core.Now).Start(); + // The UnsummonTimer is restarted in AfterDeserialization. + _summonEnd = version >= 21 ? reader.ReadAnchoredTime() : reader.ReadDeltaTime(); } - ControlSlots = reader.ReadInt(); + _controlSlots = reader.ReadInt(); } else { - FightMode = FightMode.Closest; + _fightMode = FightMode.Closest; _controlled = false; - m_ControlMaster = null; - ControlTarget = null; - m_ControlOrder = OrderType.None; + _controlMaster = null; + _controlTarget = null; + _controlOrder = OrderType.None; } if (version >= 3) { - m_Loyalty = reader.ReadInt(); + _loyalty = reader.ReadInt(); } else { - m_Loyalty = MaxLoyalty; // Wonderfully Happy + _loyalty = MaxLoyalty; } if (version >= 4) { - CurrentWayPoint = reader.ReadEntity(); + _currentWayPoint = reader.ReadEntity(); } if (version >= 5) { - m_SummonMaster = reader.ReadEntity(); + _summonMaster = reader.ReadEntity(); } if (version >= 6) { - HitsMaxSeed = reader.ReadInt(); - StamMaxSeed = reader.ReadInt(); - ManaMaxSeed = reader.ReadInt(); - m_DamageMin = reader.ReadInt(); - m_DamageMax = reader.ReadInt(); + _hitsMaxSeed = reader.ReadInt(); + _stamMaxSeed = reader.ReadInt(); + _manaMaxSeed = reader.ReadInt(); + _damageMin = reader.ReadInt(); + _damageMax = reader.ReadInt(); } if (version >= 7) { - m_PhysicalResistance = reader.ReadInt(); - PhysicalDamage = reader.ReadInt(); + _physicalResistanceSeed = reader.ReadInt(); + _physicalDamage = reader.ReadInt(); - m_FireResistance = reader.ReadInt(); - FireDamage = reader.ReadInt(); + _fireResistSeed = reader.ReadInt(); + _fireDamage = reader.ReadInt(); - m_ColdResistance = reader.ReadInt(); - ColdDamage = reader.ReadInt(); + _coldResistSeed = reader.ReadInt(); + _coldDamage = reader.ReadInt(); - m_PoisonResistance = reader.ReadInt(); - PoisonDamage = reader.ReadInt(); + _poisonResistSeed = reader.ReadInt(); + _poisonDamage = reader.ReadInt(); - m_EnergyResistance = reader.ReadInt(); - EnergyDamage = reader.ReadInt(); + _energyResistSeed = reader.ReadInt(); + _energyDamage = reader.ReadInt(); } if (version >= 8) { - Owners = reader.ReadEntityList(); + _owners = reader.ReadEntityList(); } else { - Owners = new List(); + _owners = new List(); } if (version >= 10) { - IsDeadPet = reader.ReadBool(); - m_IsBonded = reader.ReadBool(); - BondingBegin = reader.ReadDateTime(); - OwnerAbandonTime = reader.ReadDateTime(); + _isDeadPet = reader.ReadBool(); + _isBonded = reader.ReadBool(); + _bondingBegin = reader.ReadDateTime(); + _ownerAbandonTime = reader.ReadDateTime(); } - m_HasGeneratedLoot = version < 11 || reader.ReadBool(); + _hasGeneratedLoot = version < 11 || reader.ReadBool(); - m_Paragon = version >= 12 && reader.ReadBool(); + _isParagon = version >= 12 && reader.ReadBool(); if (version >= 13 && reader.ReadBool()) { - Friends = reader.ReadEntityList(); + _friends = reader.ReadEntityList(); } - else if (version < 13 && m_ControlOrder >= OrderType.Unfriend) + else if (version < 13 && _controlOrder >= OrderType.Unfriend) { - ++m_ControlOrder; + ++_controlOrder; } if (version < 16 && Loyalty != MaxLoyalty) @@ -2220,8 +2309,8 @@ namespace Server.Mobiles if (version >= 14) { - RemoveIfUntamed = reader.ReadBool(); - RemoveStep = reader.ReadInt(); + _removeIfUntamed = reader.ReadBool(); + _removeStep = reader.ReadInt(); } var deleteTime = TimeSpan.Zero; @@ -2238,18 +2327,18 @@ namespace Server.Mobiles deleteTime = TimeSpan.FromDays(3.0); } - m_DeleteTimer = new DeleteTimer(this, deleteTime); - m_DeleteTimer.Start(); + _pendingDeleteTimer = new DeleteTimer(this, deleteTime); + _pendingDeleteTimer.Start(); } if (version >= 18) { - CorpseNameOverride = reader.ReadString(); + _corpseNameOverride = reader.ReadString(); } if (version >= 19) { - HomeMap = reader.ReadMap(); + _homeMap = reader.ReadMap(); } if (version >= 22) @@ -2262,25 +2351,61 @@ namespace Server.Mobiles MigrateMoveSpeeds(); } - if (version <= 14 && m_Paragon && Hue == 0x31) + if (version <= 14 && _isParagon && Hue == 0x31) { Hue = Paragon.Hue; // Paragon hue fixed, should now be 0x501. } + } + + [AfterDeserialization] + private void AfterDeserialization() + { + NextReacquireTime = Core.TickCount; + + if (_activeSpeed <= 0 || _passiveSpeed <= 0) + { + if (!_loggedMissingSpeeds) + { + _loggedMissingSpeeds = true; + logger.Error( + "{Type} loaded without speeds - is Data/npc-speeds.json missing or changed? Pacing at {Active}/{Passive}.", + GetType(), + FallbackActiveSpeed, + FallbackPassiveSpeed + ); + } + + _activeSpeed = FallbackActiveSpeed; + _passiveSpeed = FallbackPassiveSpeed; + _currentSpeed = _passiveSpeed; + } if (Core.AOS && NameHue == 0x35) { NameHue = -1; } + if (_summoned) + { + new UnsummonTimer(this, _summonEnd - Core.Now).Start(); + } + + // An abandoned pet with no persisted countdown still despawns. + if (_pendingDeleteTimer == null && LastOwner != null && !_controlled && !IsStabled) + { + _pendingDeleteTimer = new DeleteTimer(this, TimeSpan.FromDays(3.0)); + _pendingDeleteTimer.Start(); + } + CheckStatTimers(); - ChangeAIType(m_CurrentAI); + ChangeAIType(_currentAI); AddFollowers(); if (IsAnimatedDead) { - AnimateDeadSpell.Register(m_SummonMaster, this); + AnimateDeadSpell.Register(_summonMaster, this); } } @@ -2335,8 +2460,7 @@ namespace Server.Mobiles return true; } - // Note: Yes, this happens for all questers (regardless of type, e.g. escorts), - // even if they can't offer you anything at the moment + // Happens for all questers, even those with nothing to offer right now. if (MLQuestSystem.Enabled && CanGiveMLQuest && from is PlayerMobile mobile) { // You need to mark your quest items so I don't take the wrong object. Then speak to me. @@ -2367,7 +2491,7 @@ namespace Server.Mobiles AIType.AI_Vendor => new VendorAI(this), AIType.AI_Mage => new MageAI(this), AIType.AI_Predator => - // m_AI = new PredatorAI(this); + //TODO Implement PredatorAI new MeleeAI(this), AIType.AI_Thief => new ThiefAI(this), _ => null @@ -2387,7 +2511,7 @@ namespace Server.Mobiles public void RemoveFollowers() { - var master = m_ControlMaster ?? m_SummonMaster; + var master = _controlMaster ?? _summonMaster; if (master != null) { master.Followers -= Math.Min(ControlSlots, master.Followers); @@ -2401,7 +2525,7 @@ namespace Server.Mobiles public void AddFollowers() { - var master = m_ControlMaster ?? m_SummonMaster; + var master = _controlMaster ?? _summonMaster; if (master != null) { master.Followers += ControlSlots; @@ -2447,7 +2571,7 @@ namespace Server.Mobiles public virtual void OnGaveMeleeAttack(Mobile defender, int damage) { - var p = m_Paragon ? PoisonImpl.IncreaseLevel(HitPoison) : HitPoison; + var p = _isParagon ? PoisonImpl.IncreaseLevel(HitPoison) : HitPoison; if (p != null && HitPoisonChance >= Utility.RandomDouble()) { @@ -2476,17 +2600,13 @@ namespace Server.Mobiles AIObject = null; } - if (m_DeleteTimer != null) - { - m_DeleteTimer.Stop(); - m_DeleteTimer = null; - } + StopPendingDeleteTimer(); FocusMob = null; if (IsAnimatedDead) { - AnimateDeadSpell.Unregister(m_SummonMaster, this); + AnimateDeadSpell.Unregister(_summonMaster, this); } if (Summoned && SummonMaster != null) @@ -2505,13 +2625,6 @@ namespace Server.Mobiles base.OnAfterDelete(); } - /* - * This function can be overridden.. so a "Strongest" mobile, can have a different definition depending - * on who check for value - * -Could add a FightMode.Preferred - * - */ - public virtual double GetFightModeRanking(Mobile m, FightMode acqType, bool bPlayerOnly) { if (bPlayerOnly && !m.Player) @@ -2527,8 +2640,7 @@ namespace Server.Mobiles }; } - // Turn, - for left, + for right - // Basic for now, needs work + // Turn: negative = left, positive = right. public virtual void Turn(int iTurnSteps) { var v = (int)Direction; @@ -2545,7 +2657,7 @@ namespace Server.Mobiles public bool IsHurt() => Hits != HitsMax; - public double GetHomeDistance() => this.GetDistanceToSqrt(m_Home); + public double GetHomeDistance() => this.GetDistanceToSqrt(_home); public virtual int GetTeamSize(int iRange) { @@ -2572,7 +2684,7 @@ namespace Server.Mobiles aggressor.Aggressors.Add(AggressorInfo.Create(this, aggressor, true)); } - var ct = m_ControlOrder; + var ct = _controlOrder; if (AIObject != null) { @@ -2646,7 +2758,7 @@ namespace Server.Mobiles AIObject?.GetContextMenuEntries(from, ref list); } - if (m_bTamable && !_controlled && from.Alive) + if (_tamable && !_controlled && from.Alive) { list.Add(new TameEntry(from.Female ? AllowFemaleTamer : AllowMaleTamer)); } @@ -2697,7 +2809,7 @@ namespace Server.Mobiles } public override bool IsHarmfulCriminal(Mobile target) => - (!Controlled || target != m_ControlMaster) && (!Summoned || target != m_SummonMaster) && + (!Controlled || target != _controlMaster) && (!Summoned || target != _summonMaster) && (target is not BaseCreature { InitialInnocent: true } creature || creature.Controlled) && (target is not PlayerMobile mobile || mobile.PermaFlags.Count <= 0) && base.IsHarmfulCriminal(target); @@ -2707,13 +2819,13 @@ namespace Server.Mobiles if (Controlled || Summoned) { - if (m_ControlMaster?.Player == true) + if (_controlMaster?.Player == true) { - m_ControlMaster.CriminalAction(false); + _controlMaster.CriminalAction(false); } - else if (m_SummonMaster?.Player == true) + else if (_summonMaster?.Player == true) { - m_SummonMaster.CriminalAction(false); + _summonMaster.CriminalAction(false); } } } @@ -2722,7 +2834,7 @@ namespace Server.Mobiles { base.DoHarmful(target, indirect); - if (target == this || target == m_ControlMaster || target == m_SummonMaster || !Controlled && !Summoned) + if (target == this || target == _controlMaster || target == _summonMaster || !Controlled && !Summoned) { return; } @@ -2772,12 +2884,11 @@ namespace Server.Mobiles { if (Combatant != null) { - return false; // in combat.. not idling + return false; // in combat, not idling } if (m_IdleReleaseTime > DateTime.MinValue) { - // idling... if (Core.Now >= m_IdleReleaseTime) { m_IdleReleaseTime = DateTime.MinValue; @@ -2789,7 +2900,7 @@ namespace Server.Mobiles if (Utility.Random(100) < 95) { - return false; // not idling, but don't want to enter idle state + return false; // chose not to enter the idle state } var idleSeconds = Utility.RandomMinMax(NPCSpeeds.MinIdleSeconds, NPCSpeeds.MaxIdleSeconds); @@ -2829,12 +2940,6 @@ namespace Server.Mobiles return true; // entered idle state } - /* - this way, due to the huge number of locations this will have to be changed - Perhaps we can change this in the future when fixing game play is not the - major issue. - */ - public virtual void CheckedAnimate(int action, int frameCount, int repeatCount, bool forward, bool repeat, int delay) { if (!Mounted) @@ -2897,7 +3002,7 @@ namespace Server.Mobiles SpeechType?.OnMovement(this, m, oldLocation); - /* Begin notice sound */ + // Notice sound if ((!m.Hidden || m.AccessLevel == AccessLevel.Player) && m.Player && FightMode != FightMode.Aggressor && FightMode != FightMode.None && Combatant == null && !Controlled && !Summoned && !BardPacified && InRange(m.Location, 18) && !InRange(oldLocation, 18)) @@ -2909,7 +3014,6 @@ namespace Server.Mobiles PlaySound(GetAngerSound()); } - /* End notice sound */ if (MLQuestSystem.Enabled && CanShout && m is PlayerMobile mobile) { @@ -2983,7 +3087,7 @@ namespace Server.Mobiles list.Add(TotalWeight == 1 ? 1072788 : 1072789, TotalWeight); // Weight: ~1_WEIGHT~ stones } - if (m_ControlOrder == OrderType.Guard) + if (_controlOrder == OrderType.Guard) { list.Add(1080078); // guarding } @@ -2995,7 +3099,7 @@ namespace Server.Mobiles } else if (Controlled && Commandable) { - // Intentional difference (showing ONLY bonded when bonded instead of bonded & tame) + // Deliberate: show only (bonded), never (bonded) and (tame) together. if (IsBonded) { list.Add(1049608); // (bonded) @@ -3057,7 +3161,7 @@ namespace Server.Mobiles { if (treasureLevel >= 0) { - if (m_Paragon && Paragon.ChestChance > Utility.RandomDouble()) + if (_isParagon && Paragon.ChestChance > Utility.RandomDouble()) { PackItem(new ParagonChest(Name, treasureLevel)); } @@ -3067,7 +3171,7 @@ namespace Server.Mobiles } } - if (m_Paragon && Paragon.ChocolateIngredientChance > Utility.RandomDouble()) + if (_isParagon && Paragon.ChocolateIngredientChance > Utility.RandomDouble()) { switch (Utility.Random(4)) { @@ -3095,9 +3199,10 @@ namespace Server.Mobiles } } - if (!Summoned && !NoKillAwards && !m_HasGeneratedLoot) + if (!Summoned && !NoKillAwards && !_hasGeneratedLoot) { - m_HasGeneratedLoot = true; + _hasGeneratedLoot = true; + this.MarkDirty(); GenerateLoot(false); } @@ -3289,7 +3394,7 @@ namespace Server.Mobiles MondainsLegacy.GiveArtifactTo(mob); } } - else if (m_Paragon) + else if (_isParagon) { if (Paragon.CheckArtifactChance(mob, this)) { @@ -3298,9 +3403,6 @@ namespace Server.Mobiles } } - [GeneratedEvent(nameof(CreatureDeathEvent))] - public static partial void CreatureDeathEvent(BaseCreature bc); - public override void OnDeath(Container c) { if (IsBonded) @@ -3323,7 +3425,6 @@ namespace Server.Mobiles ProcessDelta(); SendIncomingPacket(); - // TODO: This can be done in Parallel if there are lots of them. var aggressors = Aggressors; for (var i = 0; i < aggressors.Count; ++i) @@ -3363,7 +3464,7 @@ namespace Server.Mobiles OwnerAbandonTime = DateTime.MinValue; } - CreatureDeathEvent(this); + CreatureEvents.CreatureDeathEvent(this); CheckStatTimers(); return; @@ -3397,7 +3498,6 @@ namespace Server.Mobiles if (ds.m_Mobile == killer) { - // If the titles system gets feature flagged, it will be supported titles.Add(ds.m_Mobile); fame.Add(totalFame); karma.Add(totalKarma); @@ -3493,17 +3593,14 @@ namespace Server.Mobiles c.Delete(); } - CreatureDeathEvent(this); + CreatureEvents.CreatureDeathEvent(this); } - [GeneratedEvent(nameof(CreatureDeletedEvent))] - public static partial void CreatureDeletedEvent(BaseCreature bc); - public override void OnDelete() { - CreatureDeletedEvent(this); + CreatureEvents.CreatureDeletedEvent(this); - var m = m_ControlMaster; + var m = _controlMaster; SetControlMaster(null); SummonMaster = null; @@ -3576,12 +3673,7 @@ namespace Server.Mobiles ControlTarget = null; ControlOrder = OrderType.Come; - - if (m_DeleteTimer != null) - { - m_DeleteTimer.Stop(); - m_DeleteTimer = null; - } + StopPendingDeleteTimer(); } Guild = null; @@ -3774,7 +3866,7 @@ namespace Server.Mobiles { // *rummages through a corpse and takes an item* PublicOverheadMessage(MessageType.Emote, 0x3B2, 1008086); - // TODO: Instancing of Rummaged stuff. + //TODO Instance rummaged loot return true; } } @@ -3795,14 +3887,14 @@ namespace Server.Mobiles return BardMaster; } - if (_controlled && m_ControlMaster != null) + if (_controlled && _controlMaster != null) { - return m_ControlMaster; + return _controlMaster; } - if (_summoned && m_SummonMaster != null) + if (_summoned && _summonMaster != null) { - return m_SummonMaster; + return _summonMaster; } return base.GetDamageMaster(damagee); @@ -4074,19 +4166,13 @@ namespace Server.Mobiles if (this is not BaseEscortable && !Summoned && !Deleted && !IsStabled) { StopDeleteTimer(); - m_DeleteTimer = new DeleteTimer(this, TimeSpan.FromDays(3.0)); - m_DeleteTimer.Start(); + _pendingDeleteTimer = new DeleteTimer(this, TimeSpan.FromDays(3.0)); + _pendingDeleteTimer.Start(); + this.MarkDirty(); } } - public void StopDeleteTimer() - { - if (m_DeleteTimer != null) - { - m_DeleteTimer.Stop(); - m_DeleteTimer = null; - } - } + public void StopDeleteTimer() => StopPendingDeleteTimer(); public void SpillAcid(int amount) { @@ -4120,11 +4206,7 @@ namespace Server.Mobiles } } - /* - Solen Style, override me for other mobiles/items: - kappa+acidslime, grizzles+whatever, etc. - */ - + // Solen-style acid; override for other harmful drops (kappa slime, etc.). public virtual Item NewHarmfulItem() => new Acid(TimeSpan.FromSeconds(10), 30, 30); public virtual void StopFlee() @@ -4165,7 +4247,7 @@ namespace Server.Mobiles public virtual void RemovePetFriend(Mobile m) => Friends?.Remove(m); public virtual bool IsFriend(Mobile m) => - OppositionGroup?.IsEnemy(this, m) != true && m is BaseCreature c && m_Team == c.m_Team + OppositionGroup?.IsEnemy(this, m) != true && m is BaseCreature c && _team == c._team && (_summoned || _controlled) == (c._summoned || c._controlled); public virtual Allegiance GetFactionAllegiance(Mobile mob) @@ -4357,16 +4439,17 @@ namespace Server.Mobiles if (Core.SE) { - m_Loyalty = MaxLoyalty; + _loyalty = MaxLoyalty; + this.MarkDirty(); } - else if (m_Loyalty < MaxLoyalty) + else if (_loyalty < MaxLoyalty) { - // Calculate the loyalty increase var loyaltyIncrease = Utility.CoinFlips(amount, MaxLoyaltyIncrease) * 10; - if (loyaltyIncrease > 0) // Only update if there's an actual increase + if (loyaltyIncrease > 0) { - m_Loyalty = Math.Min(MaxLoyalty, m_Loyalty + loyaltyIncrease); + _loyalty = Math.Min(MaxLoyalty, _loyalty + loyaltyIncrease); + this.MarkDirty(); SayTo(from, 502060); // Your pet looks happier. } } @@ -4382,7 +4465,7 @@ namespace Server.Mobiles if (IsBondable && !IsBonded) { - var master = m_ControlMaster; + var master = _controlMaster; if (master != null && master == from) // So friends can't start the bonding process { @@ -4569,7 +4652,6 @@ namespace Server.Mobiles } } - /* Sanity check */ if (baseToSet > theirSkill.CapFixedPoint || m.Skills.Total - theirSkill.BaseFixedPoint + baseToSet > m.Skills.Cap) { @@ -4680,6 +4762,7 @@ namespace Server.Mobiles { _activeMoveSpeed = 0; _passiveMoveSpeed = 0; + this.MarkDirty(); } /// @@ -4697,6 +4780,8 @@ namespace Server.Mobiles { _passiveMoveSpeed *= scalar; } + + this.MarkDirty(); } /// @@ -4726,6 +4811,8 @@ namespace Server.Mobiles { _passiveMoveSpeed = passiveMoveSpeed; } + + this.MarkDirty(); } [MethodImpl(MethodImplOptions.AggressiveInlining)] @@ -4734,16 +4821,13 @@ namespace Server.Mobiles [MethodImpl(MethodImplOptions.AggressiveInlining)] public void SetCurrentSpeedToPassive() => CurrentSpeed = PassiveSpeed; - public void SetDamage(int val) - { - m_DamageMin = val; - m_DamageMax = val; - } + public void SetDamage(int val) => SetDamage(val, val); public void SetDamage(int min, int max) { - m_DamageMin = min; - m_DamageMax = max; + _damageMin = min; + _damageMax = max; + this.MarkDirty(); } public void SetHits(int val) @@ -4877,31 +4961,32 @@ namespace Server.Mobiles { case ResistanceType.Physical: { - m_PhysicalResistance = val; + _physicalResistanceSeed = val; break; } case ResistanceType.Fire: { - m_FireResistance = val; + _fireResistSeed = val; break; } case ResistanceType.Cold: { - m_ColdResistance = val; + _coldResistSeed = val; break; } case ResistanceType.Poison: { - m_PoisonResistance = val; + _poisonResistSeed = val; break; } case ResistanceType.Energy: { - m_EnergyResistance = val; + _energyResistSeed = val; break; } } + this.MarkDirty(); UpdateResistances(); } @@ -5036,20 +5121,39 @@ namespace Server.Mobiles // If this needs to be serialized, recommend creating a hash or registry id. Don't serialize strings. public virtual SpeedLevel SpeedClass => SpeedLevel.None; + // Cached: the speed SaveFlags consult this on every save and elided load. + private NPCSpeeds.SpeedClassEntry _speedEntry; + + private NPCSpeeds.SpeedClassEntry SpeedEntry => _speedEntry ??= NPCSpeeds.FindEntry(this); + + // Never throws: the speed SaveFlags call this on every save and elided load. Without a + // table entry the serialized speeds stand; only the constructor refuses. public virtual void GetSpeeds(out double activeSpeed, out double passiveSpeed) { - NPCSpeeds.GetSpeeds(this, out activeSpeed, out passiveSpeed); + var entry = SpeedEntry; + + if (entry == null) + { + activeSpeed = _activeSpeed; + passiveSpeed = _passiveSpeed; + return; + } + + activeSpeed = entry.ActiveSpeed; + passiveSpeed = entry.PassiveSpeed; } + // Move speeds are optional (0 = inherit), so this tolerates an unloaded table. public virtual void GetMoveSpeeds(out double activeMoveSpeed, out double passiveMoveSpeed) { - NPCSpeeds.GetMoveSpeeds(this, out activeMoveSpeed, out passiveMoveSpeed); + var entry = SpeedEntry; + + activeMoveSpeed = entry?.ActiveMoveSpeed ?? 0; + passiveMoveSpeed = entry?.PassiveMoveSpeed ?? 0; } - // Pre-v22 saves carry no movement clock. A creature whose serialized think speeds - // still match what it would spawn with today was never hand-tuned: adopt today's - // move values so existing worlds (and pets) pick up npc-speeds pacing without a - // respawn. Tuned creatures keep movement inheriting their think clock. + // Pre-v22 saves have no movement clock: untuned creatures adopt the table's move + // values, hand-tuned ones keep inheriting. internal void MigrateMoveSpeeds() { GetSpeeds(out var activeSpeed, out var passiveSpeed); @@ -5098,7 +5202,7 @@ namespace Server.Mobiles GenerateLoot(); - if (m_Paragon) + if (_isParagon) { if (Fame < 1250) { @@ -5498,8 +5602,6 @@ namespace Server.Mobiles var onSelf = patient == this; - // DoBeneficial( patient ); - RevealingAction(); if (!onSelf) @@ -5542,7 +5644,7 @@ namespace Server.Mobiles { patient.SendLocalizedMessage(1010059); // You have been cured of all poisons. - CheckSkill(SkillName.Healing, 0.0, 60.0 + poisonLevel * 10.0); // TODO: Verify formula + CheckSkill(SkillName.Healing, 0.0, 60.0 + poisonLevel * 10.0); //TODO Verify formula CheckSkill(SkillName.Anatomy, 0.0, 100.0); } } @@ -5741,7 +5843,6 @@ namespace Server.Mobiles using var toRelease = PooledRefQueue.Create(); - // added array for wild creatures in house regions to be removed using var toRemove = PooledRefQueue.Create(); foreach (var m in World.Mobiles.Values) @@ -5799,7 +5900,7 @@ namespace Server.Mobiles } } - // added lines to check if a wild creature in a house region has to be removed or not + // Wild creatures squatting in houses are removed outright. if (!c.Controlled && !c.IsStabled && (c.Region.IsPartOf() && c.CanBeDamaged() || c.RemoveIfUntamed && c.Spawner == null)) { @@ -5821,12 +5922,13 @@ namespace Server.Mobiles var c = toRelease.Dequeue(); c.Say(1043255, c.Name); // ~1_NAME~ appears to have decided that is better off without a master! - c.Loyalty = BaseCreature.MaxLoyalty; // Wonderfully Happy + c.Loyalty = BaseCreature.MaxLoyalty; c.IsBonded = false; c.BondingBegin = DateTime.MinValue; c.OwnerAbandonTime = DateTime.MinValue; c.ControlTarget = null; - // This will prevent no release of creatures left alone with AI disabled (and consequent bug of Followers) + // Release directly: a creature left alone with its AI disabled would + // otherwise never release and permanently hold its owner's follower slots. c.AIObject.DoOrderRelease(); c.DropBackpack(); } diff --git a/Projects/UOContent/Mobiles/CreatureEvents.cs b/Projects/UOContent/Mobiles/CreatureEvents.cs new file mode 100644 index 000000000..848e9de2c --- /dev/null +++ b/Projects/UOContent/Mobiles/CreatureEvents.cs @@ -0,0 +1,13 @@ +using ModernUO.CodeGeneratedEvents; + +namespace Server.Mobiles; + +// Hosts BaseCreature's generated events: two generators cannot both emit [GeneratedCode] on one type (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); +} diff --git a/Projects/UOContent/Mobiles/Monsters/LBR/Meers/MeerMage.cs b/Projects/UOContent/Mobiles/Monsters/LBR/Meers/MeerMage.cs index 178436fb9..fca41ec25 100644 --- a/Projects/UOContent/Mobiles/Monsters/LBR/Meers/MeerMage.cs +++ b/Projects/UOContent/Mobiles/Monsters/LBR/Meers/MeerMage.cs @@ -154,7 +154,7 @@ namespace Server.Mobiles public static bool UnderEffect(Mobile m) => m_Table.ContainsKey(m); [OnEvent(nameof(PlayerMobile.PlayerDeathEvent))] - [OnEvent(nameof(CreatureDeathEvent))] + [OnEvent(nameof(CreatureEvents.CreatureDeathEvent))] public static void StopEffect(Mobile m, bool message = false) { if (m_Table.Remove(m, out var timer)) diff --git a/Projects/UOContent/Mobiles/NPCSpeeds.cs b/Projects/UOContent/Mobiles/NPCSpeeds.cs index 8f5e908bb..44489ba6b 100644 --- a/Projects/UOContent/Mobiles/NPCSpeeds.cs +++ b/Projects/UOContent/Mobiles/NPCSpeeds.cs @@ -26,32 +26,16 @@ public static class NPCSpeeds public static int MinIdleSeconds { get; private set; } public static int MaxIdleSeconds { get; private set; } - public static void GetSpeeds(BaseCreature bc, out double activeSpeed, out double passiveSpeed) + // Null when the table is unloaded (test fixtures). Immutable after Configure, so creatures cache it. + public static SpeedClassEntry FindEntry(BaseCreature bc) { if ((bc.SpeedClass == SpeedLevel.None || !_speedsByLevel.TryGetValue(bc.SpeedClass, out var sp)) && !_speedsByType.TryGetValue(bc.GetType(), out sp)) { - sp = _speedsByLevel[SpeedLevel.Medium]; + _speedsByLevel.TryGetValue(SpeedLevel.Medium, out sp); } - activeSpeed = sp.ActiveSpeed; - 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; - passiveMoveSpeed = 0; - return; - } - - activeMoveSpeed = sp.ActiveMoveSpeed; - passiveMoveSpeed = sp.PassiveMoveSpeed; + return sp; } public static void RegisterSpeed(SpeedClassEntry entry) diff --git a/Projects/UOContent/Skills/AntiMacroSystem.cs b/Projects/UOContent/Skills/AntiMacroSystem.cs index e37431ddd..e930af423 100644 --- a/Projects/UOContent/Skills/AntiMacroSystem.cs +++ b/Projects/UOContent/Skills/AntiMacroSystem.cs @@ -5,7 +5,6 @@ using System.IO; using System.Runtime.InteropServices; using System.Text.Json.Serialization; using ModernUO.CodeGeneratedEvents; -using Server.Collections; using Server.Json; using Server.Mobiles; diff --git a/Projects/UOContent/Skills/DetectHidden.cs b/Projects/UOContent/Skills/DetectHidden.cs index 487eda693..27ba888cf 100644 --- a/Projects/UOContent/Skills/DetectHidden.cs +++ b/Projects/UOContent/Skills/DetectHidden.cs @@ -1,6 +1,5 @@ using System; using System.Collections.Generic; -using Server.Collections; using Server.Engines.PartySystem; using Server.Factions; using Server.Guilds; diff --git a/Projects/UOContent/Spells/Necromancy/BloodOathSpell.cs b/Projects/UOContent/Spells/Necromancy/BloodOathSpell.cs index c0f3c3ed0..578bdc482 100644 --- a/Projects/UOContent/Spells/Necromancy/BloodOathSpell.cs +++ b/Projects/UOContent/Spells/Necromancy/BloodOathSpell.cs @@ -151,8 +151,8 @@ public class BloodOathSpell : NecromancerSpell, ITargetingSpell // 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.PlayerDeletedEvent))] - [OnEvent(nameof(BaseCreature.CreatureDeathEvent))] - [OnEvent(nameof(BaseCreature.CreatureDeletedEvent))] + [OnEvent(nameof(CreatureEvents.CreatureDeathEvent))] + [OnEvent(nameof(CreatureEvents.CreatureDeletedEvent))] public static void OnCurseEnds(Mobile m) => RemoveCurse(m); private class ExpireTimer : Timer diff --git a/Projects/UOContent/Spells/Spellweaving/GiftOfLife.cs b/Projects/UOContent/Spells/Spellweaving/GiftOfLife.cs index 585d37284..6a5d4850a 100644 --- a/Projects/UOContent/Spells/Spellweaving/GiftOfLife.cs +++ b/Projects/UOContent/Spells/Spellweaving/GiftOfLife.cs @@ -79,7 +79,7 @@ namespace Server.Spells.Spellweaving Caster.Target = new SpellTarget(this, TargetFlags.Beneficial); } - [OnEvent(nameof(BaseCreature.CreatureDeathEvent))] + [OnEvent(nameof(CreatureEvents.CreatureDeathEvent))] [OnEvent(nameof(PlayerMobile.PlayerDeathEvent))] public static void OnDeathEvent(Mobile m) { diff --git a/dev-docs/content-patterns.md b/dev-docs/content-patterns.md index 6205ec2d4..f1df32d42 100644 --- a/dev-docs/content-patterns.md +++ b/dev-docs/content-patterns.md @@ -262,8 +262,9 @@ All "speed" values are **delays in seconds** (smaller = faster). A creature runs (combat decisions, target acquisition, spell timing). - **Move clock** — `ActiveMoveSpeed`/`PassiveMoveSpeed`/`CurrentMoveSpeed`: seconds per 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 - independently of think ticks, so the two need not divide evenly. + only think speeds behaves as one clock. The properties read the raw override (`0` = + 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 lists); `activeMove`/`passiveMove` are optional per bucket. Prefer data over code: