diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json index da66d8d0c..f9f5fbe15 100644 --- a/.config/dotnet-tools.json +++ b/.config/dotnet-tools.json @@ -3,7 +3,7 @@ "isRoot": true, "tools": { "modernuoschemagenerator": { - "version": "2.14.3", + "version": "4.0.0", "commands": [ "ModernUOSchemaGenerator" ] diff --git a/.gitignore b/.gitignore index d5b26e268..d29ee313a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ # Distribution Files +/Distribution/Data/Files /Distribution/Logger /Distribution/Logger.* /Distribution/ModernUO diff --git a/CLAUDE.md b/CLAUDE.md index 849ff3175..c77d39e19 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -18,7 +18,7 @@ Apply these when writing or reviewing `.cs` files under `Projects/`. 6. **Cancel timers in `OnDelete()`/`OnAfterDelete()`** — call `_token.Cancel()` or `_timer?.Stop()` 7. **`STArrayPool.Shared`** not `ArrayPool.Shared` — single-threaded optimized, no locks 8. **`PooledRefList`** not `new List()` on hot paths — zero GC pressure, stack-allocated ref struct -9. **Serialization** — class must be `partial`, constructor needs `[Constructible]`, `TimerExecutionToken` must NOT have `[SerializableField]`. New classes: use `[SerializationGenerator(version)]` (omit `encoded`). When bumping versions, add `MigrateFrom(VXContent)` (X = previous version). Never modify `Deserialize(reader, version)` for version bumps — that method is only for pre-codegen legacy saves. When migrating from pre-codegen Serialize/Deserialize: pass `false` if old code used `reader.ReadInt()`, bump version +1, and keep old logic as `private void Deserialize(IGenericReader reader, int version)` → `dev-docs/runuo-migration-docs/02-serialization.md` +9. **Serialization** — class must be `partial`, constructor needs `[Constructible]`, `TimerExecutionToken` must NOT have `[SerializableField]`. New classes: use `[SerializationGenerator(version)]` (omit `encoded`). Setters that coerce/veto/run side effects: use `[SerializableField]` args `allowFieldChange: nameof(BoolRefMethod)` / `fieldChanged: nameof(OldNewMethod)` — reserve `[SerializableProperty]` for custom getters. Serializable `Timer` members declare `[DeserializeTimer(nameof(Method))]` on the field (anchored by default — downtime preserves remaining delay; `wallClock: true` = absolute; method runs only when a timer was running at save). Conditional writes: `[SaveFlag(nameof(Should), nameof(Default))]` on the field. When bumping versions, add `MigrateFrom(VXContent)` (X = previous version). Never modify `Deserialize(reader, version)` for version bumps — that method is only for pre-codegen legacy saves. When migrating from pre-codegen Serialize/Deserialize: pass `false` if old code used `reader.ReadInt()`, bump version +1, and keep old logic as `private void Deserialize(IGenericReader reader, int version)` → `dev-docs/serialization.md`, `dev-docs/runuo-migration-docs/02-serialization.md` 10. **No `Task.Run`/`new Thread()` for game logic** (tandem with rule #3) — game logic is the single-threaded event loop. Backgrounding is allowed only for work that does not itself touch game state (external service calls, large-file parse). **Prove the need before adding a thread**: measure **on-loop** time, not wall-clock (frozen world is the cost, player latency is not), and gate on `Environment.ProcessorCount` — off-loading creates no CPU and buys nothing on 1–2 cores. New workers go in the vetted table in `dev-docs/threading-model.md` with their measurement. When such work must *feed* game logic: run the heavy/I/O part off-loop and `ConfigureAwait(false)` its awaits so a continuation never resumes on the loop and silently foregrounds heavy work; then hand the result back **explicitly** — publish an immutable snapshot swapped via a `volatile` reference (the loop reads it lock-free), or marshal the apply step with `Core.LoopContext.Post(() => …)`, re-validating in the continuation whatever may have changed while it ran. Never touch game state off-thread; never let the scheduler decide where the heavy work runs → `dev-docs/threading-model.md` 11. **Never assume era** — if code uses `Core.AOS`/`Core.SE`/etc., ask which expansion to target 12. **Naming** — `_camelCase` private fields, `PascalCase` properties/methods/classes; don't flag legacy `m_` but use `_` for new code diff --git a/Directory.Build.props b/Directory.Build.props index 30bf85099..c832f1006 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -71,7 +71,7 @@ $(DefineConstants);EVENT_LOOP_PROFILING - + diff --git a/Distribution/Data/npc-speeds.json b/Distribution/Data/npc-speeds.json index 6e07859d5..707f61e4a 100644 --- a/Distribution/Data/npc-speeds.json +++ b/Distribution/Data/npc-speeds.json @@ -3,12 +3,16 @@ "level": "VerySlow", "active": 0.4, "passive": 0.8, + "activeMove": 0.9, + "passiveMove": 1.5, "types": [] }, { "level": "Slow", "active": 0.3, "passive": 0.6, + "activeMove": 0.6, + "passiveMove": 1.2, "types": [ "AntLion", "ArcticOgreLord", "BogThing", "Bogle", "BoneKnight", "EarthElemental", @@ -28,6 +32,8 @@ "level": "Medium", "active": 0.25, "passive": 0.5, + "activeMove": 0.45, + "passiveMove": 1.05, "types": [ "AcidElemental", "AgapiteElemental", "Alligator", "AncientLich", "Betrayer", "Bird", @@ -108,6 +114,8 @@ "level": "Fast", "active": 0.2, "passive": 0.4, + "activeMove": 0.3, + "passiveMove": 0.9, "types": [ "LordOaks", "Silvani", "AirElemental", "AncientWyrm", "Balron", "BladeSpirits", @@ -139,6 +147,8 @@ "level": "VeryFast", "active": 0.125, "passive": 0.30, + "activeMove": 0.125, + "passiveMove": 0.6, "types": [ "Barracoon", "Mephitis", "Neira", "Rikktor", "Semidar", "EnergyVortex", diff --git a/Projects/BuildTool/BuildTool.csproj b/Projects/BuildTool/BuildTool.csproj index b3f37cf7a..983000240 100644 --- a/Projects/BuildTool/BuildTool.csproj +++ b/Projects/BuildTool/BuildTool.csproj @@ -17,6 +17,5 @@ - diff --git a/Projects/Server.Tests/Server.Tests.csproj b/Projects/Server.Tests/Server.Tests.csproj index 1be789258..4bae245cb 100644 --- a/Projects/Server.Tests/Server.Tests.csproj +++ b/Projects/Server.Tests/Server.Tests.csproj @@ -5,17 +5,16 @@ Server.Tests - + - + all runtime; build; native; contentfiles; analyzers; buildtransitive - diff --git a/Projects/Server.Tests/Tests/Items/DecayRegistrationTests.cs b/Projects/Server.Tests/Tests/Items/DecayRegistrationTests.cs index 0e48977ef..59d736c6f 100644 --- a/Projects/Server.Tests/Tests/Items/DecayRegistrationTests.cs +++ b/Projects/Server.Tests/Tests/Items/DecayRegistrationTests.cs @@ -208,6 +208,272 @@ public class DecayRegistrationTests item.Delete(); } + // Unfreezing an item with a stale LastMoved must grant a fresh decay window, + // not delete it on the next tick. + [Fact] + public void StaleImmovableItemMadeMovable_GetsAFreshDecayWindow() + { + var start = Core._now; + + try + { + var item = new Item(0x1234); + item.MoveToWorld(new Point3D(107, 100, 0), Map.Felucca); + + item.Movable = false; + Assert.False(DecayScheduler.IsRegistered(item), "A frozen item must not be tracked for decay."); + + Core._now = start + TimeSpan.FromDays(30); + var flipped = Core._now; + + item.Movable = true; + + Assert.True(DecayScheduler.IsRegistered(item), "An unfrozen item must be tracked for decay."); + + AdvanceDecay(flipped, item.DecayTime - TimeSpan.FromMinutes(2), item); + Assert.False(item.Deleted, "An unfrozen item must get a full decay window, not vanish immediately."); + + AdvanceDecay(Core._now, TimeSpan.FromMinutes(4), item); + Assert.True(item.Deleted, "An unfrozen item must still decay once the fresh window elapses."); + } + finally + { + Core._now = start; + } + } + + // Same transition through the Visible setter: unhiding a long-hidden item. + [Fact] + public void StaleHiddenItemMadeVisible_GetsAFreshDecayWindow() + { + var start = Core._now; + + try + { + var item = new Item(0x1234); + item.MoveToWorld(new Point3D(109, 100, 0), Map.Felucca); + + item.Visible = false; + Assert.False(DecayScheduler.IsRegistered(item), "A hidden item must not be tracked for decay."); + + Core._now = start + TimeSpan.FromDays(30); + var flipped = Core._now; + + item.Visible = true; + + Assert.True(DecayScheduler.IsRegistered(item), "An unhidden item must be tracked for decay."); + + AdvanceDecay(flipped, item.DecayTime - TimeSpan.FromMinutes(2), item); + Assert.False(item.Deleted, "An unhidden item must get a full decay window, not vanish immediately."); + + AdvanceDecay(Core._now, TimeSpan.FromMinutes(4), item); + Assert.True(item.Deleted, "An unhidden item must still decay once the fresh window elapses."); + } + finally + { + Core._now = start; + } + } + + // A refusal restarts the countdown without rewriting LastMoved. + [Fact] + public void RefusedDecay_DoesNotRewriteLastMoved() + { + var start = Core._now; + + try + { + var item = new RefusesDecayItem(); + item.MoveToWorld(new Point3D(110, 100, 0), Map.Felucca); + var lastMoved = item.LastMoved; + + AdvanceDecay(start, item.DecayTime + TimeSpan.FromMinutes(2), item); + + Assert.False(item.Deleted, "A refused decay must not delete the item."); + Assert.True(DecayScheduler.IsRegistered(item), "A refused decay must leave the item tracked."); + Assert.Equal(lastMoved, item.LastMoved); + + item.Delete(); + } + finally + { + Core._now = start; + } + } + + // The fresh window must survive a save/load cycle, or a restart mid-window deletes the item. + [Fact] + public void FreshDecayWindow_SurvivesSerialization() + { + var start = Core._now; + + try + { + var item = new Item(0x1234); + item.MoveToWorld(new Point3D(111, 100, 0), Map.Felucca); + + item.Movable = false; + Core._now = start + TimeSpan.FromDays(30); + item.Movable = true; + + var expected = item.ScheduledDecayTime; + + var writer = new BufferWriter(new byte[512], true); + item.Serialize(writer); + + var copy = new Item(item.Serial); + copy.Deserialize(new BufferReader(writer.Buffer)); + + // The stamp is stored as a delta, so it ages only by the real time between + // write and read - milliseconds here, the downtime in production. + Assert.True( + (copy.ScheduledDecayTime - expected).Duration() <= TimeSpan.FromSeconds(5), + "The restarted decay window must survive a save/load cycle." + ); + + item.Delete(); + copy.Delete(); + } + finally + { + Core._now = start; + } + } + + // A real move supersedes the reset stamp; it must be dropped so the CompactInfo can collapse. + [Fact] + public void MovingAnItem_ClearsASupersededDecayResetStamp() + { + var start = Core._now; + + try + { + var item = new Item(0x1234); + item.MoveToWorld(new Point3D(112, 100, 0), Map.Felucca); + + item.Movable = false; + Core._now = start + TimeSpan.FromDays(30); + item.Movable = true; + + Assert.NotEqual(default, item.DecayResetTime); + + Core._now += TimeSpan.FromMinutes(1); + item.MoveToWorld(new Point3D(113, 100, 0), Map.Felucca); + + Assert.Equal(default, item.DecayResetTime); + Assert.Equal(item.LastMoved + item.DecayTime, item.ScheduledDecayTime); + Assert.True(DecayScheduler.IsRegistered(item)); + + item.Delete(); + } + finally + { + Core._now = start; + } + } + + // Losing decay eligibility makes the stamp meaningless; it must be dropped so the + // CompactInfo is not held for as long as the item stays ineligible. + [Fact] + public void ItemBecomingIneligible_DropsTheDecayResetStamp() + { + var start = Core._now; + + try + { + var item = new Item(0x1234); + item.MoveToWorld(new Point3D(115, 100, 0), Map.Felucca); + + item.Movable = false; + Core._now = start + TimeSpan.FromDays(30); + item.Movable = true; + + Assert.NotEqual(default, item.DecayResetTime); + + item.Movable = false; + + Assert.Equal(default, item.DecayResetTime); + + item.Delete(); + } + finally + { + Core._now = start; + } + } + + // Moving a stamped item into a container programmatically (no drop, no SetLastMoved) + // must also drop the stamp. + [Fact] + public void StampedItemAddedToContainer_DropsTheDecayResetStamp() + { + var start = Core._now; + + try + { + var pack = new Container(0xE75); + pack.MoveToWorld(new Point3D(116, 100, 0), Map.Felucca); + + var item = new Item(0x1234); + item.MoveToWorld(new Point3D(117, 100, 0), Map.Felucca); + + item.Movable = false; + Core._now = start + TimeSpan.FromDays(30); + item.Movable = true; + + Assert.NotEqual(default, item.DecayResetTime); + + pack.AddItem(item); + + Assert.Equal(default, item.DecayResetTime); + + pack.Delete(); + } + finally + { + Core._now = start; + } + } + + // A raw Map assignment (e.g. a GM changing Map through props) is a move: it must + // enroll an untracked item for decay. + [Fact] + public void ItemMovedToRealMapViaMapSetter_IsRegisteredForDecay() + { + var item = new Item(0x1234); + Assert.False(DecayScheduler.IsRegistered(item)); + + item.Map = Map.Felucca; + + Assert.True(item.CanDecay()); + Assert.True(DecayScheduler.IsRegistered(item), "Item placed on a map via the Map setter must be tracked."); + + item.Delete(); + } + + // LiftItemDupe places the remainder of a partially lifted ground stack via raw + // Location/Map assignments, with no MoveToWorld fallback: it must still be tracked. + [Fact] + public void PartialLiftOfGroundStack_LeavesRemainderRegisteredForDecay() + { + var stack = new Item(0x1234) { Stackable = true, Amount = 10 }; + stack.MoveToWorld(new Point3D(114, 100, 0), Map.Felucca); + + var remainder = Mobile.LiftItemDupe(stack, 3); + + Assert.NotNull(remainder); + Assert.Equal(7, remainder.Amount); + Assert.Null(remainder.Parent); + Assert.Equal(Map.Felucca, remainder.Map); + Assert.True( + DecayScheduler.IsRegistered(remainder), + "The remainder of a partially lifted ground stack must be tracked for decay." + ); + + stack.Delete(); + remainder.Delete(); + } + // Dropping into a container must untrack; taking it back out to the ground must re-track. [Fact] public void ItemMovedIntoContainerThenBackToGround_IsRegisteredForDecay() diff --git a/Projects/Server.Tests/Tests/Items/PlayerConstructedStackingTests.cs b/Projects/Server.Tests/Tests/Items/PlayerConstructedStackingTests.cs index 05595068a..955a87293 100644 --- a/Projects/Server.Tests/Tests/Items/PlayerConstructedStackingTests.cs +++ b/Projects/Server.Tests/Tests/Items/PlayerConstructedStackingTests.cs @@ -24,26 +24,6 @@ public class PlayerConstructedStackingTests private static StackableItem MakeStack(Serial serial, int amount, bool playerConstructed) => new(serial) { Amount = amount, PlayerConstructed = playerConstructed }; - [Fact] - public void CanStackWith_IsFalseWhenProvenanceDiffers() - { - var bought = MakeStack((Serial)0x1, 5, false); - var crafted = MakeStack((Serial)0x2, 5, true); - - try - { - // Both orders must fail. Whichever is the receiver decides the merged pile's flag, - // so allowing either one means the result is decided by drag direction. - Assert.False(bought.CanStackWith(crafted)); - Assert.False(crafted.CanStackWith(bought)); - } - finally - { - bought.Delete(); - crafted.Delete(); - } - } - [Theory] [InlineData(false)] [InlineData(true)] @@ -63,27 +43,6 @@ public class PlayerConstructedStackingTests } } - [Fact] - public void StackWith_RefusesToMergeAcrossProvenance() - { - var bought = MakeStack((Serial)0x1, 5, false); - var crafted = MakeStack((Serial)0x2, 5, true); - - try - { - Assert.False(bought.StackWith(null, crafted, false)); - Assert.Equal(5, bought.Amount); - Assert.Equal(5, crafted.Amount); - Assert.False(bought.PlayerConstructed); - Assert.False(crafted.Deleted); - } - finally - { - bought.Delete(); - crafted.Delete(); - } - } - [Theory] [InlineData(false)] [InlineData(true)] diff --git a/Projects/Server.Tests/Tests/Mobiles/DamageEntryTests.cs b/Projects/Server.Tests/Tests/Mobiles/DamageEntryTests.cs new file mode 100644 index 000000000..9c3cdfd14 --- /dev/null +++ b/Projects/Server.Tests/Tests/Mobiles/DamageEntryTests.cs @@ -0,0 +1,311 @@ +using System; +using System.Collections.Generic; +using Server.Collections; +using Xunit; + +namespace Server.Tests; + +[Collection("Sequential Server Tests")] +public class DamageEntryTests +{ + private class TestMobile : Mobile + { + } + + private class PetMobile : Mobile + { + public Mobile Master { get; set; } + + public override Mobile GetDamageMaster(Mobile damagee) => Master; + } + + private static List Damagers(Mobile victim) + { + var result = new List(); + foreach (var de in victim.DamageEntries) + { + result.Add(de.Damager); + } + + return result; + } + + [Fact] + public void FreshMobile_HasNoEntries() + { + var m = new TestMobile(); + + try + { + Assert.Equal(0, m.DamageEntries.Count); + Assert.Null(m.FindMostRecentDamageEntry(true)); + Assert.Null(m.FindLeastRecentDamageEntry(true)); + Assert.Null(m.FindMostTotalDamageEntry(true)); + Assert.Null(m.FindLeastTotalDamageEntry(true)); + Assert.Null(m.FindDamageEntryFor(m)); + } + finally + { + m.Delete(); + } + } + + [Fact] + public void RegisterDamage_OrdersLeastRecentToMostRecent() + { + var victim = new TestMobile(); + var a = new TestMobile(); + var b = new TestMobile(); + + try + { + victim.RegisterDamage(10, a); + victim.RegisterDamage(20, b); + victim.RegisterDamage(5, a); // a becomes most recent again + + Assert.Equal(2, victim.DamageEntries.Count); + Assert.Equal(new[] { b, a }, Damagers(victim)); + Assert.Equal(15, victim.FindDamageEntryFor(a).DamageGiven); + Assert.Same(a, victim.FindMostRecentDamager(true)); + Assert.Same(b, victim.FindLeastRecentDamager(true)); + } + finally + { + victim.Delete(); + a.Delete(); + b.Delete(); + } + } + + [Fact] + public void FindRecent_HonorsAllowSelf() + { + var victim = new TestMobile(); + var a = new TestMobile(); + + try + { + victim.RegisterDamage(10, a); + victim.RegisterDamage(10, victim); // self is most recent + + Assert.Same(victim, victim.FindMostRecentDamager(true)); + Assert.Same(a, victim.FindMostRecentDamager(false)); + Assert.Same(a, victim.FindLeastRecentDamager(false)); + } + finally + { + victim.Delete(); + a.Delete(); + } + } + + [Fact] + public void FindLeastRecent_HonorsAllowSelf() + { + var victim = new TestMobile(); + var a = new TestMobile(); + + try + { + victim.RegisterDamage(10, victim); // self is least recent, so the head is the one to skip + victim.RegisterDamage(10, a); + + Assert.Same(victim, victim.FindLeastRecentDamager(true)); + Assert.Same(a, victim.FindLeastRecentDamager(false)); + } + finally + { + victim.Delete(); + a.Delete(); + } + } + + [Fact] + public void FindTotal_PicksByDamage_MostRecentWinsTies() + { + var victim = new TestMobile(); + var a = new TestMobile(); + var b = new TestMobile(); + var c = new TestMobile(); + + try + { + victim.RegisterDamage(30, a); + victim.RegisterDamage(30, b); // ties a; b is more recent + victim.RegisterDamage(1, c); + + Assert.Same(b, victim.FindMostTotalDamager(true)); + Assert.Same(c, victim.FindLeastTotalDamager(true)); + } + finally + { + victim.Delete(); + a.Delete(); + b.Delete(); + c.Delete(); + } + } + + [Fact] + public void FindLeastTotal_MostRecentWinsTies() + { + var victim = new TestMobile(); + var a = new TestMobile(); + var b = new TestMobile(); + var c = new TestMobile(); + + try + { + victim.RegisterDamage(30, a); + victim.RegisterDamage(5, b); + victim.RegisterDamage(5, c); // ties b for the minimum; c is more recent + + Assert.Same(a, victim.FindMostTotalDamager(true)); + Assert.Same(c, victim.FindLeastTotalDamager(true)); + } + finally + { + victim.Delete(); + a.Delete(); + b.Delete(); + c.Delete(); + } + } + + [Fact] + public void Prune_RemovesExpiredPrefix_KeepsOrder() + { + var start = Core._now; + var victim = new TestMobile(); + var a = new TestMobile(); + var b = new TestMobile(); + + try + { + victim.RegisterDamage(10, a); + + Core._now = start + DamageEntry.ExpireDelay + TimeSpan.FromSeconds(1); + victim.RegisterDamage(10, b); // a is now expired, b is live + + Assert.Equal(new[] { b }, Damagers(victim)); + Assert.Null(victim.FindDamageEntryFor(a)); + } + finally + { + Core._now = start; + victim.Delete(); + a.Delete(); + b.Delete(); + } + } + + [Fact] + public void Prune_AllExpired_EmptiesList() + { + var start = Core._now; + var victim = new TestMobile(); + var a = new TestMobile(); + var b = new TestMobile(); + + try + { + victim.RegisterDamage(10, a); + victim.RegisterDamage(10, b); + + Core._now = start + DamageEntry.ExpireDelay + TimeSpan.FromSeconds(1); + + Assert.Equal(0, victim.DamageEntries.Count); + Assert.Null(victim.FindMostRecentDamageEntry(true)); + } + finally + { + Core._now = start; + victim.Delete(); + a.Delete(); + b.Delete(); + } + } + + [Fact] + public void ClearDamageEntries_UnlinksEveryNode() + { + var victim = new TestMobile(); + var a = new TestMobile(); + var b = new TestMobile(); + + try + { + var ea = victim.RegisterDamage(10, a); + var eb = victim.RegisterDamage(10, b); + + victim.ClearDamageEntries(); + + Assert.Equal(0, victim.DamageEntries.Count); + Assert.False(ea.OnLinkList); + Assert.False(eb.OnLinkList); + Assert.Null(ea.Next); + Assert.Null(ea.Previous); + Assert.Null(eb.Next); + Assert.Null(eb.Previous); + } + finally + { + victim.Delete(); + a.Delete(); + b.Delete(); + } + } + + [Fact] + public void FullHitPoints_ClearsEntries() + { + var victim = new TestMobile(); + var a = new TestMobile(); + + try + { + victim.RawStr = 50; // HitsMax follows Str for a base Mobile + victim.Hits = 10; + victim.RegisterDamage(10, a); + Assert.Equal(1, victim.DamageEntries.Count); + + // Also stops the HitsTimer the Hits = 10 write started, so the test leaves no timer behind. + victim.Hits = victim.HitsMax; + + Assert.Equal(0, victim.DamageEntries.Count); + } + finally + { + victim.Delete(); + a.Delete(); + } + } + + [Fact] + public void RegisterDamage_AccumulatesResponsibleMaster() + { + var victim = new TestMobile(); + var master = new TestMobile(); + var pet = new PetMobile { Master = master }; + + try + { + victim.RegisterDamage(10, pet); + var entry = victim.RegisterDamage(5, pet); + + Assert.Same(pet, entry.Damager); + Assert.Equal(15, entry.DamageGiven); + Assert.NotNull(entry.Responsible); + Assert.Single(entry.Responsible); + Assert.Same(master, entry.Responsible[0].Damager); + Assert.Equal(15, entry.Responsible[0].DamageGiven); + Assert.False(entry.Responsible[0].OnLinkList); // sub-entries never join the main list + } + finally + { + victim.Delete(); + master.Delete(); + pet.Delete(); + } + } +} diff --git a/Projects/Server.Tests/Tests/Serialization/AnchoredItemSerializationTests.cs b/Projects/Server.Tests/Tests/Serialization/AnchoredItemSerializationTests.cs new file mode 100644 index 000000000..7889c57c8 --- /dev/null +++ b/Projects/Server.Tests/Tests/Serialization/AnchoredItemSerializationTests.cs @@ -0,0 +1,76 @@ +using System; +using Xunit; + +namespace Server.Tests; + +[Collection("Sequential Server Tests")] +public class AnchoredItemSerializationTests +{ + private static byte[] SerializeItem(Item item) + { + var writer = new BufferWriter(new byte[256], true); + item.Serialize(writer); + return writer.Buffer[..(int)writer.Position]; + } + + /// + /// Item v11 stores LastMoved and DecayResetTime as anchored time: the serialized bytes + /// are a function of item state only, not of when the save runs. Pre-v11 stored + /// minutes-since-moved and delta time, which rewrote the bytes on every save. + /// + [Fact] + public void ItemBytes_AreStable_AcrossSavesAtDifferentTimes() + { + var start = Core._now; + + try + { + var item = new Item(0x1F13); + item.MoveToWorld(new Point3D(120, 100, 0), Map.Felucca); + item.RestartDecay(); + + var first = SerializeItem(item); + + // A save hours later, with no state change, must produce identical bytes. + Core._now = start + TimeSpan.FromHours(5); + var second = SerializeItem(item); + + Assert.Equal(first, second); + + item.Delete(); + } + finally + { + Core._now = start; + } + } + + /// + /// Pre-v11 LastMoved was stored at whole-minute precision relative to the save time and + /// could never round-trip exactly. Anchored storage is absolute and exact. + /// + [Fact] + public void LastMovedAndDecayReset_RoundTripExactly() + { + var item = new Item(0x1F13); + item.MoveToWorld(new Point3D(121, 100, 0), Map.Felucca); + + // Sub-minute precision that the old minutes encoding would have destroyed. + var moved = Core.Now - TimeSpan.FromSeconds(90.5) - TimeSpan.FromMilliseconds(123); + item.LastMoved = moved; + + item.RestartDecay(); + var decayReset = item.DecayResetTime; + Assert.NotEqual(default(DateTime), decayReset); + + var bytes = SerializeItem(item); + + var restored = new Item((Serial)0x7ffff123u); + restored.Deserialize(new BufferReader(bytes)); + + Assert.Equal(moved, restored.LastMoved); + Assert.Equal(decayReset, restored.DecayResetTime); + + item.Delete(); + } +} diff --git a/Projects/Server.Tests/Tests/Serialization/AnchoredTimeTests.cs b/Projects/Server.Tests/Tests/Serialization/AnchoredTimeTests.cs new file mode 100644 index 000000000..a99b9850c --- /dev/null +++ b/Projects/Server.Tests/Tests/Serialization/AnchoredTimeTests.cs @@ -0,0 +1,189 @@ +using System; +using System.Collections.Generic; +using System.IO; +using Xunit; + +namespace Server.Tests; + +public class AnchoredTimeTests +{ + private static (BufferWriter Writer, Func Read) CreateRoundTrip() + { + var writer = new BufferWriter(new byte[64], true); + return (writer, shift => new BufferReader(writer.Buffer) { AnchoredTimeShift = shift }); + } + + [Fact] + public void AnchoredTime_RoundTripsExactly_WithZeroShift() + { + var (writer, read) = CreateRoundTrip(); + var value = new DateTime(2026, 8, 22, 12, 30, 0, DateTimeKind.Utc); + + writer.WriteAnchoredTime(value); + + Assert.Equal(value, read(TimeSpan.Zero).ReadAnchoredTime()); + } + + [Fact] + public void AnchoredTime_AppliesShiftOnRead() + { + var (writer, read) = CreateRoundTrip(); + var value = new DateTime(2026, 8, 22, 12, 30, 0, DateTimeKind.Utc); + var shift = TimeSpan.FromHours(3); + + writer.WriteAnchoredTime(value); + + Assert.Equal(value + shift, read(shift).ReadAnchoredTime()); + } + + [Fact] + public void AnchoredTime_SentinelsPassThroughUnshifted() + { + var (writer, read) = CreateRoundTrip(); + + writer.WriteAnchoredTime(DateTime.MinValue); + writer.WriteAnchoredTime(DateTime.MaxValue); + + var reader = read(TimeSpan.FromDays(2)); + Assert.Equal(DateTime.MinValue, reader.ReadAnchoredTime()); + Assert.Equal(DateTime.MaxValue, reader.ReadAnchoredTime()); + } + + [Fact] + public void AnchoredTime_SaturatesInsteadOfOverflowing() + { + var (writer, read) = CreateRoundTrip(); + + writer.WriteAnchoredTime(DateTime.MaxValue - TimeSpan.FromMinutes(1)); + + Assert.Equal(DateTime.MaxValue, read(TimeSpan.FromDays(1)).ReadAnchoredTime()); + } + + [Fact] + public void AnchoredTime_NormalizesLocalKindOnWrite() + { + var (writer, read) = CreateRoundTrip(); + var local = new DateTime(2026, 8, 22, 12, 30, 0, DateTimeKind.Local); + + writer.WriteAnchoredTime(local); + + Assert.Equal(local.ToUniversalTime(), read(TimeSpan.Zero).ReadAnchoredTime()); + } +} + +internal class AnchoredEntity : ISerializable +{ + public AnchoredEntity(Serial serial) => Serial = serial; + + public Serial Serial { get; } + public DateTime Created { get; set; } = DateTime.UtcNow; + public bool Deleted => false; + + public DateTime LastRested { get; set; } + + public void Delete() + { + } + + public void Serialize(IGenericWriter writer) => writer.WriteAnchoredTime(LastRested); + + public void Deserialize(IGenericReader reader) => LastRested = reader.ReadAnchoredTime(); +} + +[Collection("Sequential Server Tests")] +public class AnchoredTimePersistenceTests +{ + private class AnchoredPersistence : GenericEntityPersistence + { + public AnchoredPersistence(int priority) : base("AnchoredTrip", priority, 1, 0x7FFFFFFF) + { + } + } + + /// + /// The idx v5 header carries the save-start anchor; loading re-bases anchored timestamps + /// by the elapsed time since the save started, so downtime does not age them. + /// + [Fact] + public void SaveStartAnchor_RebasesAnchoredTimestampsAtLoad() + { + var previousAssemblies = AssemblyHandler.Assemblies; + AssemblyHandler.Assemblies = [.. previousAssemblies ?? [], typeof(AnchoredEntity).Assembly]; + + var source = new SerializationChunkSource(); + var workers = new SerializationThreadWorker[2]; + for (var i = 0; i < workers.Length; i++) + { + workers[i] = new SerializationThreadWorker(i, source); + workers[i].AllocateHeap(); + } + + var previousWorkers = World._threadWorkers; + World._threadWorkers = workers; + + var previousSaveStart = World.SaveStartTime; + + var persistence = new AnchoredPersistence(2100); + AnchoredPersistence loaded = null; + + var dir = Path.Combine(Path.GetTempPath(), $"muo-anchored-{Guid.NewGuid():N}"); + Directory.CreateDirectory(dir); + + try + { + var lastRested = Core.Now - TimeSpan.FromMinutes(10); + var serial = (Serial)1u; + persistence.EntitiesBySerial[serial] = new AnchoredEntity(serial) { LastRested = lastRested }; + persistence.RegisterType(typeof(AnchoredEntity)); + + // Pretend the save started two hours ago, as if the server had been down since. + var downtime = TimeSpan.FromHours(2); + World.SaveStartTime = Core.Now - downtime; + + foreach (var worker in workers) + { + worker.Wake(); + } + + source.SetOwner(persistence); + Assert.True(persistence.TrySnapshotEntries(out var slotCount)); + source.PushSlotRanges(persistence, slotCount); + + source.Flush(); + foreach (var worker in workers) + { + worker.Sleep(); + } + + persistence.WriteSnapshot(dir); + persistence.PostWorldSave(); + + loaded = new AnchoredPersistence(2101); + loaded.DeserializeIndexes(dir, null); + loaded.Deserialize(dir, null); + + var entity = loaded.EntitiesBySerial[serial]; + var expected = lastRested + downtime; + + Assert.True( + (entity.LastRested - expected).Duration() <= TimeSpan.FromSeconds(30), + $"Anchored timestamp must re-base by the downtime; expected ~{expected}, got {entity.LastRested}." + ); + } + finally + { + World.SaveStartTime = previousSaveStart; + persistence.Unregister(); + loaded?.Unregister(); + + foreach (var worker in workers) + { + worker.Exit(); + } + + World._threadWorkers = previousWorkers; + AssemblyHandler.Assemblies = previousAssemblies; + Directory.Delete(dir, true); + } + } +} diff --git a/Projects/Server/Items/Container.cs b/Projects/Server/Items/Container.cs index f139da24b..06c676b25 100644 --- a/Projects/Server/Items/Container.cs +++ b/Projects/Server/Items/Container.cs @@ -44,10 +44,10 @@ public partial class Container : Item internal int _version; [SerializableField(3)] + [SaveFlag(nameof(ShouldSerializeLiftOverride))] [SerializedCommandProperty(AccessLevel.GameMaster)] private bool _liftOverride; - [SerializableFieldSaveFlag(3)] private bool ShouldSerializeLiftOverride() => _liftOverride; public Container(int itemID) : base(itemID) @@ -84,6 +84,7 @@ public partial class Container : Item [EncodedInt] [SerializableProperty(0)] + [SaveFlag(nameof(ShouldSerializeMaxItems), nameof(MaxItemsDefaultValue))] [CommandProperty(AccessLevel.GameMaster)] public int MaxItems { @@ -96,14 +97,13 @@ public partial class Container : Item } } - [SerializableFieldSaveFlag(0)] private bool ShouldSerializeMaxItems() => _maxItems != -1; - [SerializableFieldDefault(0)] private int MaxItemsDefaultValue() => -1; [EncodedInt] [SerializableProperty(1)] + [SaveFlag(nameof(ShouldSerializeGumpId), nameof(GumpIDDefaultValue))] [CommandProperty(AccessLevel.GameMaster)] public int GumpID { @@ -115,14 +115,13 @@ public partial class Container : Item } } - [SerializableFieldSaveFlag(1)] private bool ShouldSerializeGumpId() => _gumpID != -1; - [SerializableFieldDefault(1)] private int GumpIDDefaultValue() => -1; [EncodedInt] [SerializableProperty(2)] + [SaveFlag(nameof(ShouldSerializeDropSound), nameof(DropSoundDefaultValue))] [CommandProperty(AccessLevel.GameMaster)] public int DropSound { @@ -134,10 +133,8 @@ public partial class Container : Item } } - [SerializableFieldSaveFlag(2)] private bool ShouldSerializeDropSound() => _dropSound != -1; - [SerializableFieldDefault(2)] private int DropSoundDefaultValue() => -1; [CommandProperty(AccessLevel.GameMaster)] diff --git a/Projects/Server/Items/DecayScheduler.cs b/Projects/Server/Items/DecayScheduler.cs index 694d9b6b2..70dc1ef27 100644 --- a/Projects/Server/Items/DecayScheduler.cs +++ b/Projects/Server/Items/DecayScheduler.cs @@ -311,7 +311,7 @@ public class DecayScheduler : Timer if (timeUntilDecay > _bucketInterval) { - // Item was moved (SetLastMoved called) - re-bucket or move to overflow + // Deadline was pushed out (SetLastMoved/RestartDecay) - re-bucket or move to overflow if (timeUntilDecay > _totalBucketSpan) { // Extended beyond total span - move to overflow @@ -429,7 +429,7 @@ public class DecayScheduler : Timer { // Refused by the region. Restart the clock rather than dropping the item, which has // already left the queue; re-registering as-is would spin on a due time in the past. - item.SetLastMoved(); + item.RestartDecay(); } } } diff --git a/Projects/Server/Items/Item.cs b/Projects/Server/Items/Item.cs index 889be4619..13f249f2f 100644 --- a/Projects/Server/Items/Item.cs +++ b/Projects/Server/Items/Item.cs @@ -335,7 +335,25 @@ public partial class Item : IHued, IComparable, ISpawnable, IObjectPropert [CommandProperty(AccessLevel.GameMaster)] public virtual bool Decays => Movable && Visible && Spawner == null; - public DateTime LastMoved { get; set; } + private DateTime _lastMoved; + + public DateTime LastMoved + { + get => _lastMoved; + set + { + _lastMoved = value; + + // A move at or past the reset stamp supersedes it; drop it so the CompactInfo can collapse. + var info = LookupCompactInfo(); + + if (info != null && info.m_DecayReset != default && info.m_DecayReset <= value) + { + info.m_DecayReset = default; + VerifyCompactInfo(); + } + } + } [CommandProperty(AccessLevel.GameMaster)] public bool Stackable @@ -373,7 +391,7 @@ public partial class Item : IHued, IComparable, ISpawnable, IObjectPropert } Delta(ItemDelta.Update); - UpdateDecayRegistration(); + RestartDecay(); } } } @@ -389,7 +407,7 @@ public partial class Item : IHued, IComparable, ISpawnable, IObjectPropert SetFlag(ImplFlag.Movable, value); Delta(ItemDelta.Update); - UpdateDecayRegistration(); + RestartDecay(); } } } @@ -845,7 +863,7 @@ public partial class Item : IHued, IComparable, ISpawnable, IObjectPropert public virtual void Serialize(IGenericWriter writer) { - writer.Write(9); // version + writer.Write(11); // version var flags = SaveFlag.None; @@ -955,6 +973,11 @@ public partial class Item : IHued, IComparable, ISpawnable, IObjectPropert { flags |= SaveFlag.SavedFlags; } + + if (info.m_DecayReset > LastMoved) + { + flags |= SaveFlag.DecayReset; + } } if (info == null || info.m_Weight < 0) @@ -992,14 +1015,14 @@ public partial class Item : IHued, IComparable, ISpawnable, IObjectPropert writer.Write((int)flags); - /* begin last moved time optimization */ - var ticks = LastMoved.Ticks; - var now = Core.Now.Ticks; + // Anchored: shifted by downtime at load, so time-since-moved is preserved and the + // bytes are stable across saves while the item does not move. + writer.WriteAnchoredTime(LastMoved); - var minutes = new TimeSpan(now - ticks).TotalMinutes; - - writer.WriteEncodedInt((int)Math.Clamp(minutes, int.MinValue, int.MaxValue)); - /* end */ + if (GetSaveFlag(flags, SaveFlag.DecayReset)) + { + writer.WriteAnchoredTime(info.m_DecayReset); + } if (GetSaveFlag(flags, SaveFlag.Direction)) { @@ -1318,6 +1341,12 @@ public partial class Item : IHued, IComparable, ISpawnable, IObjectPropert OnMapChange(); + if (m_Parent == null) + { + // A map change is a move; nothing else updates decay registration for a raw Map change. + SetLastMoved(); + } + if (old == null || old == Map.Internal) { InvalidateProperties(); @@ -1548,7 +1577,7 @@ public partial class Item : IHued, IComparable, ISpawnable, IObjectPropert if (oldValue != value) { - UpdateDecayRegistration(); + RestartDecay(); } } } @@ -1742,6 +1771,7 @@ public partial class Item : IHued, IComparable, ISpawnable, IObjectPropert || info.m_HeldBy != null || info.m_BlessedFor != null || info.m_Spawner != null + || info.m_DecayReset != default || info.m_TempFlags != 0 || info.m_SavedFlags != 0 || info.m_Weight >= 0; @@ -2326,7 +2356,64 @@ public partial class Item : IHued, IComparable, ISpawnable, IObjectPropert public virtual bool OnDecay() => CanDecay() && Region.Find(Location, Map).OnDecay(this); - public DateTime ScheduledDecayTime => LastMoved + DecayTime; + public DateTime ScheduledDecayTime + { + get + { + var reset = DecayResetTime; + var lastMoved = LastMoved; + + return (reset > lastMoved ? reset : lastMoved) + DecayTime; + } + } + + /// + /// When decay eligibility was last restored without the item moving, e.g. a GM unfreezing it. + /// The decay countdown runs from the later of this and . + /// + public DateTime DecayResetTime + { + get => LookupCompactInfo()?.m_DecayReset ?? default; + private set + { + if (value == default) + { + var info = LookupCompactInfo(); + + if (info != null && info.m_DecayReset != default) + { + info.m_DecayReset = default; + VerifyCompactInfo(); + } + } + else + { + AcquireCompactInfo().m_DecayReset = value; + } + } + } + + /// + /// Restarts the decay countdown without touching : call when decay + /// eligibility changes state (Movable/Visible/Spawner) or a region refuses a decay, where a + /// stale would otherwise decay the item on the next tick. + /// Stamps only when that extends the current deadline, then + /// updates the scheduler registration. + /// + public void RestartDecay() + { + if (CanDecay()) + { + var now = Core.Now; + + if (ScheduledDecayTime < now + DecayTime) + { + DecayResetTime = now; + } + } + + UpdateDecayRegistration(); + } public void UpdateDecayRegistration() { @@ -2336,6 +2423,12 @@ public partial class Item : IHued, IComparable, ISpawnable, IObjectPropert { DecayScheduler.Register(this); } + else + { + // No countdown to anchor while ineligible; drop the stamp so the CompactInfo + // can collapse. Re-eligibility always re-anchors. + DecayResetTime = default; + } } public void SetLastMoved() @@ -2350,7 +2443,6 @@ public partial class Item : IHued, IComparable, ISpawnable, IObjectPropert dropped.ItemID == ItemID && dropped.Hue == Hue && dropped.Name == Name && - dropped.PlayerConstructed == PlayerConstructed && dropped.Amount + Amount <= 60000 && dropped != this; @@ -2369,6 +2461,11 @@ public partial class Item : IHued, IComparable, ISpawnable, IObjectPropert } Amount += dropped.Amount; + if (PlayerConstructed != dropped.PlayerConstructed) + { + PlayerConstructed = false; + } + dropped.Delete(); if (playSound && from != null) @@ -2669,6 +2766,8 @@ public partial class Item : IHued, IComparable, ISpawnable, IObjectPropert switch (version) { + case 11: + case 10: case 9: case 8: case 7: @@ -2676,7 +2775,11 @@ public partial class Item : IHued, IComparable, ISpawnable, IObjectPropert { var flags = (SaveFlag)reader.ReadInt(); - if (version < 7) + if (version >= 11) + { + LastMoved = reader.ReadAnchoredTime(); + } + else if (version < 7) { LastMoved = reader.ReadDeltaTime(); } @@ -2694,6 +2797,18 @@ public partial class Item : IHued, IComparable, ISpawnable, IObjectPropert } } + if (version >= 10 && GetSaveFlag(flags, SaveFlag.DecayReset)) + { + var reset = version >= 11 ? reader.ReadAnchoredTime() : reader.ReadDeltaTime(); + + // Pre-v11 LastMoved was stored at whole-minute precision; keep the + // stamp only while it still extends the deadline. + if (reset > LastMoved) + { + DecayResetTime = reset; + } + } + if (GetSaveFlag(flags, SaveFlag.Direction)) { m_Direction = (Direction)reader.ReadByte(); @@ -3451,7 +3566,7 @@ public partial class Item : IHued, IComparable, ISpawnable, IObjectPropert for (var i = 0; i < props.Length; i++) { var p = props[i]; - if (p.GetCustomAttribute(typeof(IgnoreDupeAttribute), true) != null || !p.CanRead || !p.CanWrite) + if (p.GetCustomAttribute(true) != null || !p.CanRead || !p.CanWrite) { continue; } @@ -4357,6 +4472,8 @@ public partial class Item : IHued, IComparable, ISpawnable, IObjectPropert public ISpawner m_Spawner; + public DateTime m_DecayReset; + public int m_TempFlags; public double m_Weight = -1; @@ -4395,6 +4512,7 @@ public partial class Item : IHued, IComparable, ISpawnable, IObjectPropert IntWeight = 0x01000000, SavedFlags = 0x02000000, NullWeight = 0x04000000, - PlayerConstructed = 0x08000000 + PlayerConstructed = 0x08000000, + DecayReset = 0x10000000 } } diff --git a/Projects/Server/Mobiles/Mobile.cs b/Projects/Server/Mobiles/Mobile.cs index 6755c244b..d1db8113b 100644 --- a/Projects/Server/Mobiles/Mobile.cs +++ b/Projects/Server/Mobiles/Mobile.cs @@ -42,7 +42,7 @@ public delegate void PromptCallback(Mobile from, string text); public delegate void PromptStateCallback(Mobile from, string text, T state); -public class DamageEntry +public class DamageEntry : IValueLinkListNode { public DamageEntry(Mobile damager) => Damager = damager; @@ -57,6 +57,11 @@ public class DamageEntry public List Responsible { get; set; } public static TimeSpan ExpireDelay { get; set; } = TimeSpan.FromMinutes(2.0); + + // Intrusive links for Mobile._damageEntries. Sub-entries in Responsible never join a list. + public DamageEntry Next { get; set; } + public DamageEntry Previous { get; set; } + public bool OnLinkList { get; set; } } [Flags] @@ -377,7 +382,6 @@ public partial class Mobile : IHued, IComparable, ISpawnable, IObjectPro Aggressors = new List(); Aggressed = new List(); NextSkillTime = Core.TickCount; - DamageEntries = new List(); } // Sectors @@ -958,7 +962,23 @@ public partial class Mobile : IHued, IComparable, ISpawnable, IObjectPro public static VisibleDamageType VisibleDamageType { get; set; } - public List DamageEntries { get; private set; } + private ValueLinkList _damageEntries; + + /// + /// Damage entries ordered least recent (head) to most recent (tail). Expired entries are + /// pruned on access. Enumerate with foreach (ascending) or .ByDescending(). + /// Mutate only through and . + /// Calling a ValueLinkList mutator on this reference compiles, but operates on a defensive copy + /// while still unlinking the real nodes — it silently corrupts the list. + /// + public ref readonly ValueLinkList DamageEntries + { + get + { + PruneExpiredDamageEntries(); + return ref _damageEntries; + } + } [CommandProperty(AccessLevel.GameMaster)] public Mobile LastKiller { get; set; } @@ -1627,7 +1647,7 @@ public partial class Mobile : IHued, IComparable, ISpawnable, IObjectPro public virtual bool KeepsItemsOnDeath => m_AccessLevel > AccessLevel.Player; - public bool HasTrade => m_NetState?.Trades.Count > 0; + public bool HasTrade => m_NetState?.Trades?.Count > 0; public bool NoMoveHS { get; set; } @@ -2020,10 +2040,7 @@ public partial class Mobile : IHued, IComparable, ISpawnable, IObjectPro Aggressors[i].CanReportMurder = false; } - if (DamageEntries.Count > 0) - { - DamageEntries.Clear(); // reset damage entries on full HP - } + ClearDamageEntries(); // reset damage entries on full HP } else if (CanRegenHits) { @@ -2324,11 +2341,11 @@ public partial class Mobile : IHued, IComparable, ISpawnable, IObjectPro public virtual void Serialize(IGenericWriter writer) { - writer.Write(37); // version + writer.Write(38); // version - writer.WriteDeltaTime(LastStrGain); - writer.WriteDeltaTime(LastIntGain); - writer.WriteDeltaTime(LastDexGain); + writer.WriteAnchoredTime(LastStrGain); + writer.WriteAnchoredTime(LastIntGain); + writer.WriteAnchoredTime(LastDexGain); byte hairflag = 0x00; @@ -5250,7 +5267,13 @@ public partial class Mobile : IHued, IComparable, ISpawnable, IObjectPro item.PlayerConstructed = oldItem.PlayerConstructed; item.Amount = oldAmount - amount; - item.Map = oldItem.Map; + + // A parented remainder gets its map from AddItem (parent first, then map), keeping the + // split off the decay scheduler; a ground remainder is placed and enrolled here. + if (oldItem.Parent == null) + { + item.Map = oldItem.Map; + } oldItem.OnAfterDuped(item); @@ -5739,24 +5762,54 @@ public partial class Mobile : IHued, IComparable, ISpawnable, IObjectPro } } + // Entries are kept in LastDamage order, so expired entries are always a head prefix. + private void PruneExpiredDamageEntries() + { +#if DEBUG + for (var node = _damageEntries._first; node != null; node = node.Next) + { + Debug.Assert( + node.Next == null || node.Next.LastDamage >= node.LastDamage, + "Damage entries must be ordered by LastDamage ascending." + ); + } +#endif + + var first = _damageEntries._first; + + if (first?.HasExpired != true) + { + return; + } + + var firstLive = first.Next; + + while (firstLive?.HasExpired == true) + { + firstLive = firstLive.Next; + } + + if (firstLive == null) + { + _damageEntries.RemoveAll(); + } + else + { + _damageEntries.RemoveAllBefore(firstLive); + } + } + + public void ClearDamageEntries() => _damageEntries.RemoveAll(); + public Mobile FindMostRecentDamager(bool allowSelf) => FindMostRecentDamageEntry(allowSelf)?.Damager; public DamageEntry FindMostRecentDamageEntry(bool allowSelf) { - for (var i = DamageEntries.Count - 1; i >= 0; --i) + PruneExpiredDamageEntries(); + + for (var de = _damageEntries._last; de != null; de = de.Previous) { - if (i >= DamageEntries.Count) - { - continue; - } - - var de = DamageEntries[i]; - - if (de.HasExpired) - { - DamageEntries.RemoveAt(i); - } - else if (allowSelf || de.Damager != this) + if (allowSelf || de.Damager != this) { return de; } @@ -5769,21 +5822,11 @@ public partial class Mobile : IHued, IComparable, ISpawnable, IObjectPro public DamageEntry FindLeastRecentDamageEntry(bool allowSelf) { - for (var i = 0; i < DamageEntries.Count; ++i) + PruneExpiredDamageEntries(); + + for (var de = _damageEntries._first; de != null; de = de.Next) { - if (i < 0) - { - continue; - } - - var de = DamageEntries[i]; - - if (de.HasExpired) - { - DamageEntries.RemoveAt(i); - --i; - } - else if (allowSelf || de.Damager != this) + if (allowSelf || de.Damager != this) { return de; } @@ -5794,24 +5837,17 @@ public partial class Mobile : IHued, IComparable, ISpawnable, IObjectPro public Mobile FindMostTotalDamager(bool allowSelf) => FindMostTotalDamageEntry(allowSelf)?.Damager; + // Walks most recent first with a strict comparison so the most recent entry wins ties, + // matching the previous reverse-indexed loop. public DamageEntry FindMostTotalDamageEntry(bool allowSelf) { + PruneExpiredDamageEntries(); + DamageEntry mostTotal = null; - for (var i = DamageEntries.Count - 1; i >= 0; --i) + for (var de = _damageEntries._last; de != null; de = de.Previous) { - if (i >= DamageEntries.Count) - { - continue; - } - - var de = DamageEntries[i]; - - if (de.HasExpired) - { - DamageEntries.RemoveAt(i); - } - else if ((allowSelf || de.Damager != this) && (mostTotal == null || de.DamageGiven > mostTotal.DamageGiven)) + if ((allowSelf || de.Damager != this) && (mostTotal == null || de.DamageGiven > mostTotal.DamageGiven)) { mostTotal = de; } @@ -5824,46 +5860,28 @@ public partial class Mobile : IHued, IComparable, ISpawnable, IObjectPro public DamageEntry FindLeastTotalDamageEntry(bool allowSelf) { - DamageEntry mostTotal = null; + PruneExpiredDamageEntries(); - for (var i = DamageEntries.Count - 1; i >= 0; --i) + DamageEntry leastTotal = null; + + for (var de = _damageEntries._last; de != null; de = de.Previous) { - if (i >= DamageEntries.Count) + if ((allowSelf || de.Damager != this) && (leastTotal == null || de.DamageGiven < leastTotal.DamageGiven)) { - continue; - } - - var de = DamageEntries[i]; - - if (de.HasExpired) - { - DamageEntries.RemoveAt(i); - } - else if ((allowSelf || de.Damager != this) && (mostTotal == null || de.DamageGiven < mostTotal.DamageGiven)) - { - mostTotal = de; + leastTotal = de; } } - return mostTotal; + return leastTotal; } public DamageEntry FindDamageEntryFor(Mobile m) { - for (var i = DamageEntries.Count - 1; i >= 0; --i) + PruneExpiredDamageEntries(); + + for (var de = _damageEntries._last; de != null; de = de.Previous) { - if (i >= DamageEntries.Count) - { - continue; - } - - var de = DamageEntries[i]; - - if (de.HasExpired) - { - DamageEntries.RemoveAt(i); - } - else if (de.Damager == m) + if (de.Damager == m) { return de; } @@ -5881,8 +5899,13 @@ public partial class Mobile : IHued, IComparable, ISpawnable, IObjectPro de.DamageGiven += amount; de.LastDamage = Core.Now; - DamageEntries.Remove(de); - DamageEntries.Add(de); + // Move to the tail so the list stays in LastDamage order. + if (de.OnLinkList) + { + _damageEntries.Remove(de); + } + + _damageEntries.AddLast(de); var master = from.GetDamageMaster(this); @@ -6144,6 +6167,7 @@ public partial class Mobile : IHued, IComparable, ISpawnable, IObjectPro switch (version) { + case 38: // Stat-gain stamps moved from delta time to anchored time case 37: // Decomposed hair into inline item id/hue (dropped the VirtualHairInfo object) case 36: // Moved virtues to VirtueSystem case 35: // Moved short term murders to PlayerMurderSystem @@ -6152,9 +6176,18 @@ public partial class Mobile : IHued, IComparable, ISpawnable, IObjectPro case 32: // Removed StuckMenu case 31: { - LastStrGain = reader.ReadDeltaTime(); - LastIntGain = reader.ReadDeltaTime(); - LastDexGain = reader.ReadDeltaTime(); + if (version >= 38) + { + LastStrGain = reader.ReadAnchoredTime(); + LastIntGain = reader.ReadAnchoredTime(); + LastDexGain = reader.ReadAnchoredTime(); + } + else + { + LastStrGain = reader.ReadDeltaTime(); + LastIntGain = reader.ReadDeltaTime(); + LastDexGain = reader.ReadDeltaTime(); + } goto case 30; } @@ -6462,9 +6495,6 @@ public partial class Mobile : IHued, IComparable, ISpawnable, IObjectPro m_DexLock = (StatLockType)reader.ReadByte(); m_IntLock = (StatLockType)reader.ReadByte(); - _statMods = new List(); - _skillMods = new List(); - if (version < 32) { if (reader.ReadBool()) @@ -7797,13 +7827,10 @@ public partial class Mobile : IHued, IComparable, ISpawnable, IObjectPro m_FollowersMax = 5; Skills = new Skills(this); Items = new List(); - _statMods = new List(); - _skillMods = new List(); Map = Map.Internal; AutoPageNotify = true; Aggressors = new List(); Aggressed = new List(); - DamageEntries = new List(); NextSkillTime = Core.TickCount; } diff --git a/Projects/Server/Mobiles/Mods/ResistanceMod.cs b/Projects/Server/Mobiles/Mods/ResistanceMod.cs index bd569f20e..a5423e039 100644 --- a/Projects/Server/Mobiles/Mods/ResistanceMod.cs +++ b/Projects/Server/Mobiles/Mods/ResistanceMod.cs @@ -21,17 +21,15 @@ namespace Server; [SerializationGenerator(0)] public partial class ResistanceMod : MobileMod { - [SerializableField(0)] + [SerializableField(0, fieldChanged: nameof(OnTypeChanged))] private ResistanceType _type; - [SerializableFieldChanged(0)] [MethodImpl(MethodImplOptions.AggressiveInlining)] private void OnTypeChanged(ResistanceType oldValue, ResistanceType newValue) => Owner?.UpdateResistances(); - [SerializableField(1)] + [SerializableField(1, fieldChanged: nameof(OnOffsetChanged))] private int _offset; - [SerializableFieldChanged(1)] [MethodImpl(MethodImplOptions.AggressiveInlining)] private void OnOffsetChanged(int oldValue, int newValue) => Owner?.UpdateResistances(); diff --git a/Projects/Server/Mobiles/Mods/SkillMod.cs b/Projects/Server/Mobiles/Mods/SkillMod.cs index a78ec5ce6..cfe96c0c4 100644 --- a/Projects/Server/Mobiles/Mods/SkillMod.cs +++ b/Projects/Server/Mobiles/Mods/SkillMod.cs @@ -21,33 +21,29 @@ namespace Server; [SerializationGenerator(0)] public abstract partial class SkillMod : MobileMod { - [SerializableField(0)] + [SerializableField(0, fieldChanged: nameof(OnObeyCapChanged))] private bool _obeyCap; - [SerializableFieldChanged(0)] [MethodImpl(MethodImplOptions.AggressiveInlining)] - private void OnObeCapChanged(bool oldValue, bool newValue) => Owner?.Skills[_skill]?.Update(); + private void OnObeyCapChanged(bool oldValue, bool newValue) => Owner?.Skills[_skill]?.Update(); - [SerializableField(1)] + [SerializableField(1, fieldChanged: nameof(OnSkillChanged))] private SkillName _skill; - [SerializableFieldChanged(1)] private void OnSkillChanged(SkillName oldValue, SkillName newValue) { Owner?.Skills[newValue]?.Update(); Owner?.Skills[oldValue]?.Update(); } - [SerializableField(2)] + [SerializableField(2, fieldChanged: nameof(OnRelativeChanged))] private bool _relative; - [SerializableFieldChanged(2)] private void OnRelativeChanged(bool oldValue, bool newValue) => Owner?.Skills[_skill]?.Update(); - [SerializableField(3)] + [SerializableField(3, fieldChanged: nameof(OnValueChanged))] private double _value; - [SerializableFieldChanged(3)] private void OnValueChanged(double oldValue, double newValue) => Owner?.Skills[_skill]?.Update(); public SkillMod(Mobile owner) : base(owner) diff --git a/Projects/Server/Network/MovementThrottle.cs b/Projects/Server/Network/MovementThrottle.cs index c28ef2228..33ee318fc 100644 --- a/Projects/Server/Network/MovementThrottle.cs +++ b/Projects/Server/Network/MovementThrottle.cs @@ -50,9 +50,6 @@ public static class MovementThrottle private const int ClientMaxUnackedMovements = 5; private const int MaxQueueWithUnmodifiedClient = ClientMaxUnackedMovements - 1; // 4 - // Debug logging - enable for testing speed hack detection - private static bool _debugLogging = false; - // Track NetStates with queued movements for efficient processing private static readonly HashSet _netStatesWithQueuedMovements = new(256); @@ -83,15 +80,9 @@ public static class MovementThrottle public static void Configure() { - _maxCredit = ServerConfiguration.GetOrUpdateSetting( - "movementThrottle.maxCredit", - _maxCredit - ); - - _hardQueueLimit = ServerConfiguration.GetOrUpdateSetting( - "movementThrottle.hardQueueLimit", - _hardQueueLimit - ); + _maxCredit = ServerConfiguration.GetOrUpdateSetting("movementThrottle.maxCredit", _maxCredit); + _maxRttBonus = ServerConfiguration.GetOrUpdateSetting("movementThrottle.maxRttBonus", _maxRttBonus); + _hardQueueLimit = ServerConfiguration.GetOrUpdateSetting("movementThrottle.hardQueueLimit", _hardQueueLimit); _movementHistorySize = ServerConfiguration.GetOrUpdateSetting( "movementThrottle.movementHistorySize", @@ -103,6 +94,13 @@ public static class MovementThrottle _minSamplesForRate ); + _maxChainGap = ServerConfiguration.GetOrUpdateSetting("movementThrottle.maxChainGap", _maxChainGap); + + _speedHackNotificationCooldown = ServerConfiguration.GetOrUpdateSetting( + "movementThrottle.speedHackNotificationCooldown", + _speedHackNotificationCooldown + ); + _suspiciousRateThreshold = (float)ServerConfiguration.GetOrUpdateSetting( "movementThrottle.suspiciousRateThreshold", _suspiciousRateThreshold @@ -112,11 +110,6 @@ public static class MovementThrottle "movementThrottle.definiteRateThreshold", _definiteRateThreshold ); - - _debugLogging = ServerConfiguration.GetOrUpdateSetting( - "movementThrottle.debugLogging", - _debugLogging - ); } /// @@ -191,15 +184,16 @@ public static class MovementThrottle // Credit can go negative up to -dynamicCredit (debt limit) if (ns._movementCredit - earlyAmount >= -dynamicCredit) { - var prevCredit = ns._movementCredit; // Use credit to cover early arrival ns._movementCredit -= earlyAmount; - if (_debugLogging && ns._movementLogging) + if (ns._movementLogging) { + var prevCredit = ns._movementCredit + earlyAmount; + logger.Debug( "[Credit] {Name}: delta={Delta}ms early={Early}ms credit={PrevCredit}->{Credit}/{MaxCredit} action=execute", - mobile.RawName, delta, earlyAmount, prevCredit, ns._movementCredit, dynamicCredit + mobile, delta, earlyAmount, prevCredit, ns._movementCredit, dynamicCredit ); } @@ -208,11 +202,11 @@ public static class MovementThrottle return; } - if (_debugLogging && ns._movementLogging) + if (ns._movementLogging) { logger.Debug( "[Credit] {Name}: delta={Delta}ms early={Early}ms credit={Credit}/{MaxCredit} EXHAUSTED -> queue", - mobile.RawName, delta, earlyAmount, ns._movementCredit, dynamicCredit + mobile, delta, earlyAmount, ns._movementCredit, dynamicCredit ); } @@ -227,11 +221,11 @@ public static class MovementThrottle var prevCredit = ns._movementCredit; ns._movementCredit = Math.Min(ns._movementCredit + delta, dynamicCredit); - if (_debugLogging && ns._movementLogging && ns._movementCredit != prevCredit) + if (ns._movementLogging && ns._movementCredit != prevCredit) { logger.Debug( "[Credit] {Name}: delta=+{Delta}ms credit={PrevCredit}->{Credit}/{MaxCredit} action=execute", - mobile.RawName, delta, prevCredit, ns._movementCredit, dynamicCredit + mobile, delta, prevCredit, ns._movementCredit, dynamicCredit ); } } @@ -247,12 +241,9 @@ public static class MovementThrottle { if (!mobile.Move(dir)) { - if (_debugLogging && ns._movementLogging) + if (ns._movementLogging) { - logger.Debug( - "[Execute] {Name}: Move FAILED dir={Dir} seq={Seq} -> reject+reset", - mobile.RawName, dir, seq - ); + logger.Debug("[Execute] {Name}: Move FAILED dir={Dir} seq={Seq} -> reject+reset", mobile, dir, seq); } // Movement failed (blocked, paralyzed, frozen, etc.) @@ -260,11 +251,11 @@ public static class MovementThrottle return; } - if (_debugLogging && ns._movementLogging) + if (ns._movementLogging) { logger.Debug( "[Execute] {Name}: Move OK dir={Dir} seq={Seq} nextMove={NextMove}ms", - mobile.RawName, dir, seq, ns._nextMovementTime - Core.TickCount + mobile, dir, seq, ns._nextMovementTime - Core.TickCount ); } @@ -304,11 +295,11 @@ public static class MovementThrottle ns._hasQueuedMovements = true; _netStatesWithQueuedMovements.Add(ns); - if (_debugLogging && ns._movementLogging) + if (ns._movementLogging) { logger.Debug( "[Queue] {Name}: enqueued dir={Dir} seq={Seq} (depth={Depth})", - ns.Mobile?.RawName, dir, seq, ns._movementQueue.Count + ns.Mobile, dir, seq, ns._movementQueue.Count ); } } @@ -320,7 +311,6 @@ public static class MovementThrottle { ns.SendMovementRej(seq, mobile); ns.ResetMovementState(); - _netStatesWithQueuedMovements.Remove(ns); } /// @@ -333,20 +323,18 @@ public static class MovementThrottle return; } - // Process each NetState with queued movements - // Use a snapshot to avoid modification during iteration - var toProcess = new List(_netStatesWithQueuedMovements); - - for (var i = 0; i < toProcess.Count; i++) + foreach (var ns in _netStatesWithQueuedMovements) { - var ns = toProcess[i]; - if (!ns.Running) + if (ns.Running) { - _netStatesWithQueuedMovements.Remove(ns); - continue; + ProcessMovementQueue(ns); + if (ns._hasQueuedMovements) + { + continue; + } } - ProcessMovementQueue(ns); + _netStatesWithQueuedMovements.Remove(ns); } } @@ -356,6 +344,7 @@ public static class MovementThrottle public static void ProcessMovementQueue(NetState ns) { var mobile = ns.Mobile; + if (mobile?.Deleted != false) { ClearQueue(ns); @@ -374,7 +363,7 @@ public static class MovementThrottle while (ns._movementQueue?.Count > 0) { // Check if it's time to execute - if (now < ns._nextMovementTime) + if (now - ns._nextMovementTime < 0) { // Not yet - leave remaining items in queue for next Slice break; @@ -394,11 +383,11 @@ public static class MovementThrottle // Execute the move if (!mobile.Move(movement.Direction)) { - if (_debugLogging && ns._movementLogging) + if (ns._movementLogging) { logger.Debug( "[Queue] {Name}: dequeued FAILED dir={Dir} (remaining={Remaining})", - mobile.RawName, movement.Direction, remaining + mobile, movement.Direction, remaining ); } @@ -407,12 +396,12 @@ public static class MovementThrottle return; } - if (_debugLogging && ns._movementLogging) + if (ns._movementLogging) { var waited = now - ns._nextMovementTime; logger.Debug( "[Queue] {Name}: dequeued OK dir={Dir} (remaining={Remaining}, waited={Waited}ms)", - mobile.RawName, movement.Direction, remaining, waited >= 0 ? waited : 0 + mobile, movement.Direction, remaining, waited >= 0 ? waited : 0 ); } @@ -430,10 +419,6 @@ public static class MovementThrottle // Update tracking ns._hasQueuedMovements = ns._movementQueue?.Count > 0; - if (!ns._hasQueuedMovements) - { - _netStatesWithQueuedMovements.Remove(ns); - } } /// @@ -469,7 +454,6 @@ public static class MovementThrottle { ns._movementQueue?.Clear(); ns._hasQueuedMovements = false; - _netStatesWithQueuedMovements.Remove(ns); } // Maximum expected packets per second (mounted running = 100ms = 10/sec, plus tolerance) @@ -484,7 +468,7 @@ public static class MovementThrottle logger.Information( "Movement queue overflow: {Character} ({Account}) | " + "Queue reached hard limit: {Limit} | IP: {IP}", - mobile?.RawName ?? "Unknown", + mobile, ns.Account?.Username ?? "Unknown", _hardQueueLimit, ns.Address @@ -516,7 +500,7 @@ public static class MovementThrottle private static void RecordMovement(NetState ns, long now, int cost, Direction dir, Mobile mobile) { // Calculate interval since last movement - var interval = ns._lastMovementRecordTime > 0 + var interval = ns._hasMovementRecord ? (int)(now - ns._lastMovementRecordTime) : -1; // -1 indicates first movement (no previous time) @@ -525,6 +509,7 @@ public static class MovementThrottle if (interval <= 0 || interval > _maxChainGap) { ns._lastMovementRecordTime = now; + ns._hasMovementRecord = true; // Use RTT to distinguish "stopped moving" vs "lagged" // - Stable low-latency connection with gap >> RTT → player stopped, reset history @@ -544,19 +529,19 @@ public static class MovementThrottle // A large gap followed by a burst of packets = likely lag recovery, not speed hack ns._lastGapDuration = interval; - if (_debugLogging && mobile?.RawName != null) + if (ns._movementLogging) { var action = shouldReset ? "history reset" : "history preserved (possible lag)"; logger.Debug( "[Movement] {Name}: SKIP recording (gap {Gap}ms > {MaxGap}ms, " + "RTT={RTT}ms stable={Stable} → {Action})", - mobile.RawName, interval, _maxChainGap, avgRtt, ns.HasStableConnection, action + mobile, interval, _maxChainGap, avgRtt, ns.HasStableConnection, action ); } } - else if (_debugLogging && mobile?.RawName != null) + else if (ns._movementLogging) { - logger.Debug("[Movement] {Name}: SKIP recording (first in chain)", mobile.RawName); + logger.Debug("[Movement] {Name}: SKIP recording (first in chain)", mobile); } return; @@ -572,12 +557,9 @@ public static class MovementThrottle // the next real move's interval artificially short, inflating rate. if (cost == 0) { - if (_debugLogging && mobile?.RawName != null) + if (ns._movementLogging) { - logger.Debug( - "[Movement] {Name}: SKIP direction-only change (preserves interval measurement)", - mobile.RawName - ); + logger.Debug("[Movement] {Name}: SKIP direction-only change (preserves interval measurement)", mobile); } return; } @@ -613,15 +595,16 @@ public static class MovementThrottle } ns._lastMovementRecordTime = now; + ns._hasMovementRecord = true; // Debug logging - if (_debugLogging && mobile?.RawName != null) + if (ns._movementLogging) { var historyCount = ns._movementHistoryFull ? _movementHistorySize : ns._movementHistoryIndex; logger.Debug( "[Movement] {Name}: interval={Interval}ms target={Target}ms queue={Queue} " + "flags={Flags} history={History}/{MaxHistory} RTT={RTT}ms", - mobile.RawName, interval, cost, record.QueueDepth, + mobile, interval, cost, record.QueueDepth, flags, historyCount, _movementHistorySize, ns.AverageRtt ); } @@ -814,7 +797,7 @@ public static class MovementThrottle var averageRtt = ns.AverageRtt; // Detailed rate breakdown for debugging - if (_debugLogging) + if (ns._movementLogging) { logger.Debug("[MovementAnalysis] Rate={Rate:F3}, Samples={Samples}, RTT={RTT}ms", rate, sampleCount, averageRtt); @@ -977,19 +960,19 @@ public static class MovementThrottle var verdict = AnalyzeMovement(ns, out var rate, out var sampleCount, out var confidence); // Debug logging - if (_debugLogging && ns.Mobile?.RawName != null) + if (ns._movementLogging) { var (burstSize, _) = DetectRecentBurst(ns); - var probeStatus = ns._rttProbeTime > 0 ? "pending" : "idle"; + var probeStatus = ns._rttProbePending ? "pending" : "idle"; var queueDepth = ns._movementQueue?.Count ?? 0; logger.Debug( "[RateCheck] {Name}: rate={Rate:F3} samples={Samples} verdict={Verdict} " + "confidence={Confidence:P0} queue={Queue} burst={Burst} sustained={Sustained}s", - ns.Mobile.RawName, rate, sampleCount, verdict, confidence, queueDepth, burstSize, ns._consecutiveHighRateSeconds + ns.Mobile, rate, sampleCount, verdict, confidence, queueDepth, burstSize, ns._consecutiveHighRateSeconds ); logger.Debug( " RTT: avg={Avg}ms last={Last}ms var={Var} samples={RttSamples} stable={Stable} probe={Probe}", - ns.AverageRtt, ns._lastRtt, ns._rttVariance, ns._rttSampleCount, ns.HasStableConnection, probeStatus + ns.AverageRtt, ns.LastRtt, ns._rttVariance, ns._rttSampleCount, ns.HasStableConnection, probeStatus ); } @@ -1025,11 +1008,11 @@ public static class MovementThrottle if (shouldNotify) { - if (_debugLogging) + if (ns._movementLogging) { logger.Debug( "[ALERT] {Urgency} - {Name}: rate={Rate:F3} verdict={Verdict} confidence={Confidence:P0}", - urgency, ns.Mobile?.RawName, rate, verdict, confidence + urgency, ns.Mobile, rate, verdict, confidence ); } NotifyStaff(ns, rate, sampleCount, confidence, verdict, urgency); @@ -1054,11 +1037,12 @@ public static class MovementThrottle var now = Core.TickCount; // Rate-limit notifications per player - if (now - ns._lastSpeedHackNotification < _speedHackNotificationCooldown) + if (ns._speedHackNotified && now - ns._lastSpeedHackNotification < _speedHackNotificationCooldown) { return; } + ns._speedHackNotified = true; ns._lastSpeedHackNotification = now; var mobile = ns.Mobile; @@ -1070,7 +1054,7 @@ public static class MovementThrottle "PacketRate: {PacketRate}/s (peak: {PeakRate}/s) | RTT: {Rtt}ms (stable: {Stable}) | " + "Sustained: {Sustained}s | Queue: {Queue} | Location: {Location} Map: {Map} | IP: {IP}", urgency, - mobile?.RawName ?? "Unknown", + mobile, ns.Account?.Username ?? "Unknown", rate, sampleCount, @@ -1138,7 +1122,7 @@ public static class MovementThrottle Verdict = verdict, Confidence = confidence, AverageRtt = ns.AverageRtt, - LastRtt = ns._lastRtt, + LastRtt = ns.LastRtt, RttVariance = ns._rttVariance, StableConnection = ns.HasStableConnection, RttSampleCount = ns._rttSampleCount, diff --git a/Projects/Server/Network/NetState/NetState.Movement.cs b/Projects/Server/Network/NetState/NetState.Movement.cs index 1cc79429e..c6f28fcd1 100644 --- a/Projects/Server/Network/NetState/NetState.Movement.cs +++ b/Projects/Server/Network/NetState/NetState.Movement.cs @@ -15,7 +15,6 @@ using System; using System.Collections.Generic; -using System.Diagnostics; using System.Runtime.InteropServices; using Server.Logging; @@ -70,23 +69,24 @@ public partial class NetState internal Queue _movementQueue; // Lazy initialized internal long _movementCredit; // Credit buffer for timing jitter internal long _nextMovementTime = Core.TickCount; // When next movement is allowed - internal int _sustainedQueueDepth; // Tracks sustained high queue depth - internal long _lastQueueDepthCheck; // Throttle depth check frequency + internal long _lastQueueDepthCheck = Core.TickCount; // Throttle depth check frequency internal bool _hasQueuedMovements; // Fast check for Slice() // Movement history for rate-based speed hack detection (lazy initialized) internal MovementRecord[] _movementHistory; // Circular buffer internal int _movementHistoryIndex; // Next write position (also serves as count until full) internal bool _movementHistoryFull; // True once buffer has wrapped - internal long _lastMovementRecordTime; // For calculating intervals + internal long _lastMovementRecordTime; // For calculating intervals (valid only when _hasMovementRecord) + internal bool _hasMovementRecord; // False until the first movement in a chain is seen // Detection state internal int _consecutiveHighRateSeconds; // Sustained detection counter - internal long _lastSpeedHackNotification; // Rate-limit notifications + internal long _lastSpeedHackNotification; // Rate-limit notifications (valid only when _speedHackNotified) + internal bool _speedHackNotified; // False until the first notification is sent internal int _lastGapDuration; // Duration of last gap > maxChainGap (for burst forgiveness) // Movement packet rate tracking (for speed hack detection) - internal long _movementWindowStart; // Start of current 1-second window + internal long _movementWindowStart = Core.TickCount; // Start of current 1-second window internal int _movementsInWindow; // Count in current window internal int _peakMovementRate; // Highest rate seen (packets/sec) @@ -100,10 +100,9 @@ public partial class NetState _nextMovementTime = Core.TickCount; _movementCredit = 0; _hasQueuedMovements = false; - _sustainedQueueDepth = 0; // Reset movement history - next movement starts a new chain - _lastMovementRecordTime = 0; + _hasMovementRecord = false; _movementHistoryIndex = 0; _movementHistoryFull = false; @@ -113,7 +112,7 @@ public partial class NetState _rttProbeInterval = RttProbeIntervalNormal; // Reset packet rate window - _movementWindowStart = 0; + _movementWindowStart = Core.TickCount; _movementsInWindow = 0; } @@ -165,17 +164,19 @@ public partial class NetState private const long MaxStableLatency = 200; // Max RTT (ms) for "stable" connection // RTT state - internal long _rttProbeTime; // When we sent the probe (0 = not waiting) - internal long _lastRtt; // Most recent RTT measurement + internal bool _rttProbePending; // True while waiting for a probe response + internal long _rttProbeTime; // When we sent the probe (valid only when _rttProbePending) internal long[] _rttHistory; // Rolling history (lazy init) internal int _rttHistoryIndex; // Current position in history internal int _rttSampleCount; // Number of samples collected (saturates at RttHistorySize) internal long _rttVariance; // Calculated variance for stability - internal long _nextRttProbe; // When to send next probe + internal long _nextRttProbe = Core.TickCount; // When to send next probe internal int _rttProbeInterval = RttProbeIntervalNormal; // Current probe interval - // High-resolution timestamp for RTT measurement (Stopwatch ticks, not game loop ticks) - private long _rttProbeTimestampHiRes; + /// + /// Gets the most recent RTT measurement, or 0 if none has been recorded. + /// + public long LastRtt => _rttSampleCount > 0 ? _rttHistory[(_rttHistoryIndex - 1) & (RttHistorySize - 1)] : 0; /// /// Sets the RTT probe interval based on suspicion level. @@ -206,23 +207,22 @@ public partial class NetState var now = Core.TickCount; // Don't send if we're still waiting for a response - if (_rttProbeTime > 0) + if (_rttProbePending) { // Timeout after 10 seconds - connection is probably dead or very laggy if (now - _rttProbeTime > 10000) { - _rttProbeTime = 0; - _rttProbeTimestampHiRes = 0; + _rttProbePending = false; } return; } // First probe: send immediately when player starts moving // Subsequent probes: send when interval has passed - if (_nextRttProbe == 0 || now >= _nextRttProbe) + if (now - _nextRttProbe >= 0) { + _rttProbePending = true; _rttProbeTime = now; - _rttProbeTimestampHiRes = Stopwatch.GetTimestamp(); _nextRttProbe = now + _rttProbeInterval + Utility.Random(RttProbeJitter); if (_movementLogging) @@ -242,10 +242,9 @@ public partial class NetState /// public void RecordRttMeasurement() { - var nowHiRes = Stopwatch.GetTimestamp(); var now = Core.TickCount; - if (_rttProbeTime <= 0) + if (!_rttProbePending) { // Not expecting a response (client-initiated version send) - ignore silently return; @@ -253,19 +252,15 @@ public partial class NetState var rtt = now - _rttProbeTime; - // High-resolution RTT in microseconds - var rttHiResUs = (nowHiRes - _rttProbeTimestampHiRes) * 1_000_000 / Stopwatch.Frequency; - if (_movementLogging) { movementLogger.Debug( - "[RTT-Response] {Account}: {Rtt}ms (HiRes: {RttHiRes:F2}ms)", - Account?.Username ?? _toString, rtt, rttHiResUs / 1000.0 + "[RTT-Response] {Account}: {Rtt}ms", + Account?.Username ?? _toString, rtt ); } - _rttProbeTime = 0; - _rttProbeTimestampHiRes = 0; + _rttProbePending = false; // Sanity check - RTT should be positive and reasonable if (rtt is <= 0 or > 10000) @@ -285,7 +280,6 @@ public partial class NetState // Update history _rttHistory[_rttHistoryIndex++ & (RttHistorySize - 1)] = rtt; - _lastRtt = rtt; // Track sample count (saturates at buffer size) if (_rttSampleCount < RttHistorySize) diff --git a/Projects/Server/Network/NetState/NetState.cs b/Projects/Server/Network/NetState/NetState.cs index 7312603f7..04f1a5a76 100755 --- a/Projects/Server/Network/NetState/NetState.cs +++ b/Projects/Server/Network/NetState/NetState.cs @@ -44,7 +44,7 @@ public partial class NetState : IComparable, IValueLinkListNode _connectingQueue = new(2048); private static readonly HashSet _instances = new(2048); - public static IReadOnlySet Instances => _instances; + public static HashSet Instances => _instances; private readonly string _toString; private ClientVersion _version; @@ -109,9 +109,6 @@ public partial class NetState : IComparable, IValueLinkListNode, IValueLinkListNode Trades { get; } + public List Trades { get; private set; } public bool Seeded { get; set; } @@ -260,8 +257,18 @@ public partial class NetState : IComparable, IValueLinkListNode= 0; --i) { + if (Trades == null) + { + break; + } + if (i >= Trades.Count) { continue; @@ -280,8 +287,18 @@ public partial class NetState : IComparable, IValueLinkListNode= 0; --i) { + if (Trades != null) + { + break; + } + if (i < Trades.Count) { Trades[i].Cancel(); @@ -291,11 +308,21 @@ public partial class NetState : IComparable, IValueLinkListNode, IValueLinkListNode, IValueLinkListNode, IValueLinkListNode public long Position => _reader.Position; + public TimeSpan AnchoredTimeShift + { + get => _reader.AnchoredTimeShift; + set => _reader.AnchoredTimeShift = value; + } + public void Dispose() { _accessor?.SafeMemoryMappedViewHandle.ReleasePointer(); diff --git a/Projects/Server/Serialization/BufferReader.cs b/Projects/Server/Serialization/BufferReader.cs index 846e9e5dc..bd37f4ff6 100644 --- a/Projects/Server/Serialization/BufferReader.cs +++ b/Projects/Server/Serialization/BufferReader.cs @@ -37,6 +37,8 @@ public class BufferReader : IGenericReader public long Position => _position; public long BufferSize => _buffer.Length; + public TimeSpan AnchoredTimeShift { get; set; } + public BufferReader(byte[] buffer, Dictionary typesDb = null, Encoding encoding = null) { _buffer = buffer; diff --git a/Projects/Server/Serialization/BufferWriter.cs b/Projects/Server/Serialization/BufferWriter.cs index b811d68cb..ab53e2f2f 100644 --- a/Projects/Server/Serialization/BufferWriter.cs +++ b/Projects/Server/Serialization/BufferWriter.cs @@ -384,6 +384,7 @@ public class BufferWriter : IGenericWriter } [MethodImpl(MethodImplOptions.AggressiveInlining)] + [Obsolete("Delta time rewrites its bytes on every save. Write anchored time instead (WriteAnchoredTime, or [AnchoredDateTime] on generated fields); bump the containing type's version, as the wire format changes. Existing delta payloads remain readable through ReadDeltaTime in old-version fallbacks.")] public void WriteDeltaTime(DateTime value) { if (value == DateTime.MinValue) @@ -407,6 +408,21 @@ public class BufferWriter : IGenericWriter Write(value.Ticks - DateTime.UtcNow.Ticks); } + /// + /// Writes the absolute value; re-bases it + /// by the elapsed time since the save started, so downtime does not age it and an + /// unchanged value serializes to identical bytes. + /// + public void WriteAnchoredTime(DateTime value) + { + if (value.Kind == DateTimeKind.Local) + { + value = value.ToUniversalTime(); + } + + Write(value.Ticks); + } + [MethodImpl(MethodImplOptions.AggressiveInlining)] public void Write(IPAddress value) { diff --git a/Projects/Server/Serialization/GenericEntityPersistence.cs b/Projects/Server/Serialization/GenericEntityPersistence.cs index a49f213eb..c7323329f 100644 --- a/Projects/Server/Serialization/GenericEntityPersistence.cs +++ b/Projects/Server/Serialization/GenericEntityPersistence.cs @@ -114,9 +114,10 @@ public class GenericEntityPersistence : GenericPersistence, IGenericEntityPer using var binFs = new FileStream( Path.Combine(dir, $"{Name}.bin"), FileMode.Create, FileAccess.Write, FileShare.None, 1024 * 1024 ); - // v4 records are fixed-width 26 bytes; the header carries the type table - // (name lengths vary — 64 bytes per entry is a staging hint, not a contract). - var expectedIdxSize = 12 + 26L * EntitiesBySerial.Count + 64L * _typeTable.Count; + // v4 records are fixed-width 26 bytes; the v5 header carries the save-start anchor + // and the type table (name lengths vary — 64 bytes per entry is a staging hint, not + // a contract). + var expectedIdxSize = 20 + 26L * EntitiesBySerial.Count + 64L * _typeTable.Count; using var idx = new FileBufferWriter(Path.Combine(dir, $"{Name}.idx"), expectedIdxSize); var binPosition = 0L; @@ -142,7 +143,10 @@ public class GenericEntityPersistence : GenericPersistence, IGenericEntityPer binPosition += _selfLength; } - idx.Write(4); // Version + idx.Write(5); // Version + + // One anchor for the whole save: the world is frozen from the moment it is stamped. + idx.Write(World.SaveStartTime.Ticks); // The type table is fully known at freeze (AddEntity diverts to the pending // queues while saving) and is written before the records so the loader can @@ -494,6 +498,18 @@ public class GenericEntityPersistence : GenericPersistence, IGenericEntityPer var version = dataReader.ReadInt(); + if (version >= 5) + { + // Re-base anchored timestamps by the elapsed time since the save started. + var anchor = new DateTime(dataReader.ReadLong(), DateTimeKind.Utc); + var shift = Core.Now - anchor; + _anchoredTimeShift = anchor.Ticks > 0 && shift > TimeSpan.Zero ? shift : TimeSpan.Zero; + + // The whole save shares one anchor. Publish it so payloads without their own + // (GenericPersistence bins) can shift too; indexes load before any of them. + World.LoadTimeShift = _anchoredTimeShift; + } + if (version >= 4) { DeserializeIndexesV4(dataReader, entities); @@ -660,6 +676,9 @@ public class GenericEntityPersistence : GenericPersistence, IGenericEntityPer private static List _toDelete; + // From the loaded idx (v5+); zero when the save predates the anchor. + private TimeSpan _anchoredTimeShift; + private unsafe void InternalDeserialize(string filePath, int index, Dictionary typesDb) { using var mmf = MemoryMappedFile.CreateFromFile(filePath, FileMode.Open); @@ -667,7 +686,10 @@ public class GenericEntityPersistence : GenericPersistence, IGenericEntityPer byte* ptr = null; accessor.SafeMemoryMappedViewHandle.AcquirePointer(ref ptr); - var dataReader = new UnmanagedDataReader(ptr, accessor.Length, typesDb); + var dataReader = new UnmanagedDataReader(ptr, accessor.Length, typesDb) + { + AnchoredTimeShift = _anchoredTimeShift + }; Deserialize(dataReader); diff --git a/Projects/Server/Serialization/GenericPersistence.cs b/Projects/Server/Serialization/GenericPersistence.cs index 5b8e79c29..2268a86c6 100644 --- a/Projects/Server/Serialization/GenericPersistence.cs +++ b/Projects/Server/Serialization/GenericPersistence.cs @@ -98,7 +98,13 @@ public abstract class GenericPersistence : Persistence, IGenericSerializable byte* ptr = null; accessor.SafeMemoryMappedViewHandle.AcquirePointer(ref ptr); - var dataReader = new UnmanagedDataReader(ptr, accessor.Length, typesDb); + var dataReader = new UnmanagedDataReader(ptr, accessor.Length, typesDb) + { + // These payloads carry no anchor of their own; they inherit the save-wide + // shift stamped while the entity indexes were read (indexes always load + // before persistence payloads — see Persistence.Load). + AnchoredTimeShift = World.LoadTimeShift + }; Deserialize(dataReader); error = dataReader.Position != fileLength diff --git a/Projects/Server/Serialization/IGenericReader.cs b/Projects/Server/Serialization/IGenericReader.cs index 0244a5166..bfa302b39 100644 --- a/Projects/Server/Serialization/IGenericReader.cs +++ b/Projects/Server/Serialization/IGenericReader.cs @@ -43,6 +43,12 @@ public interface IGenericReader DateTime ReadDateTime() => new(ReadLong(), DateTimeKind.Utc); TimeSpan ReadTimeSpan() => new(ReadLong()); + /// + /// Decodes a legacy delta-time value. Only for reading old-version payloads (version + /// fallbacks and migration replays) — current formats store anchored time and read it + /// with . is + /// obsolete: no current-version format may write delta time. + /// DateTime ReadDeltaTime() { return ReadLong() switch @@ -52,6 +58,37 @@ public interface IGenericReader var delta => new DateTime(delta + DateTime.UtcNow.Ticks, DateTimeKind.Utc) }; } + + /// + /// Elapsed time between the loaded save starting and this load, applied by + /// . Zero when the source carries no anchor. + /// + TimeSpan AnchoredTimeShift => TimeSpan.Zero; + + DateTime ReadAnchoredTime() + { + var value = ReadDateTime(); + + if (value == DateTime.MinValue || value == DateTime.MaxValue) + { + return value; + } + + var shift = AnchoredTimeShift; + if (shift == TimeSpan.Zero) + { + return value; + } + + var ticks = value.Ticks + shift.Ticks; + + if (ticks >= DateTime.MaxValue.Ticks) + { + return DateTime.MaxValue; + } + + return ticks <= 0 ? DateTime.MinValue : new DateTime(ticks, DateTimeKind.Utc); + } decimal ReadDecimal() => new([ReadInt(), ReadInt(), ReadInt(), ReadInt()]); int ReadEncodedInt() { diff --git a/Projects/Server/Serialization/IGenericWriter.cs b/Projects/Server/Serialization/IGenericWriter.cs index 9c9563139..4162655de 100644 --- a/Projects/Server/Serialization/IGenericWriter.cs +++ b/Projects/Server/Serialization/IGenericWriter.cs @@ -40,7 +40,11 @@ public interface IGenericWriter void Write(decimal value); void WriteEncodedInt(int value); void Write(DateTime value); + + [Obsolete("Delta time rewrites its bytes on every save. Write anchored time instead (WriteAnchoredTime, or [AnchoredDateTime] on generated fields); bump the containing type's version, as the wire format changes. Existing delta payloads remain readable through ReadDeltaTime in old-version fallbacks.")] void WriteDeltaTime(DateTime value); + + void WriteAnchoredTime(DateTime value); void Write(IPAddress value); void Write(TimeSpan value); void Write(Point3D value); diff --git a/Projects/Server/Serialization/UnmanagedDataReader.cs b/Projects/Server/Serialization/UnmanagedDataReader.cs index 7c8b5da1e..aa3916ee9 100644 --- a/Projects/Server/Serialization/UnmanagedDataReader.cs +++ b/Projects/Server/Serialization/UnmanagedDataReader.cs @@ -43,6 +43,8 @@ public unsafe class UnmanagedDataReader : IGenericReader /// public long Position { get; private set; } + public TimeSpan AnchoredTimeShift { get; set; } + /// /// Read bits of data raw from a serialized file using Little-endian. /// diff --git a/Projects/Server/Server.csproj b/Projects/Server/Server.csproj index 49d8d496a..fd69557b7 100644 --- a/Projects/Server/Server.csproj +++ b/Projects/Server/Server.csproj @@ -37,11 +37,10 @@ - + - - - + + diff --git a/Projects/Server/World/World.cs b/Projects/Server/World/World.cs index 2c9e6d216..c00fc85f6 100644 --- a/Projects/Server/World/World.cs +++ b/Projects/Server/World/World.cs @@ -93,6 +93,21 @@ public static class World public static string SavePath { get; private set; } public static WorldState WorldState { get; private set; } public static bool Saving => WorldState == WorldState.Saving; + + /// + /// UTC time the current or most recent world save started. Written into save indexes so + /// anchored timestamps can be re-based by the downtime at load. + /// + public static DateTime SaveStartTime { get; internal set; } + + /// + /// The anchored-time shift for the save currently being loaded: the downtime between the + /// save's start and this load. Stamped while entity indexes are read (they all carry the + /// same anchor, since the whole save shares one ) and applied + /// to every reader of that save's files — including + /// payloads, which carry no anchor of their own. Zero for saves that predate the anchor. + /// + public static TimeSpan LoadTimeShift { get; internal set; } public static bool Running => WorldState is not WorldState.Loading and not WorldState.Initial; public static bool Loading => WorldState == WorldState.Loading; @@ -287,6 +302,10 @@ public static class World WorldState = WorldState.Saving; + // The world is frozen from here: one anchor for the whole save. Written into save + // indexes so anchored timestamps can be re-based by the downtime at load. + SaveStartTime = Core.Now; + Broadcast(0x35, true, "The world is saving, please wait."); logger.Information("Saving world"); diff --git a/Projects/UOContent.Tests/Fixtures/TestServerInitializer.cs b/Projects/UOContent.Tests/Fixtures/TestServerInitializer.cs index a38af9c3d..5e4230b7c 100644 --- a/Projects/UOContent.Tests/Fixtures/TestServerInitializer.cs +++ b/Projects/UOContent.Tests/Fixtures/TestServerInitializer.cs @@ -103,6 +103,7 @@ internal static class TestServerInitializer // Registers the Accounts entity persistence; without it no test can construct an Account. Server.Accounting.Accounts.Configure(); RaceDefinitions.Configure(); + Server.Movement.Movement.Configure(); MovementImpl.Configure(); PathFollower.Configure(); World.Load(); diff --git a/Projects/UOContent.Tests/Tests/Mobiles/AI/AcquisitionTests.cs b/Projects/UOContent.Tests/Tests/Mobiles/AI/AcquisitionTests.cs new file mode 100644 index 000000000..3ee516dcf --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Mobiles/AI/AcquisitionTests.cs @@ -0,0 +1,211 @@ +using System; +using System.Collections.Generic; +using Server; +using Server.Mobiles; +using Xunit; + +namespace UOContent.Tests.Mobiles.AI; + +// Pins the reacquire gate and the AcquireOnApproachDelay gradient: every scan re-arms the +// full ReacquireDelay; enemy movement clamps the deadline to the approach delay (Zero = +// prodded scan); an illegal deadline self-heals. +[Collection("Sequential Pathfinding Tests")] +public class AcquisitionTests : IDisposable +{ + private readonly List _created = new(); + + public void Dispose() + { + foreach (var m in _created) + { + m?.Delete(); + } + + _created.Clear(); + } + + private sealed class WildStub : BaseCreature + { + public WildStub() : base(AIType.AI_Melee, FightMode.Closest, 16, 1) => Body = 0xC9; + + public override void GetSpeeds(out double activeSpeed, out double passiveSpeed) + { + activeSpeed = 0.3; + passiveSpeed = 0.6; + } + } + + private sealed class TargetStub : Mobile + { + public TargetStub() => Body = 0x190; + } + + private WildStub Spawn(Map map, Point3D loc) + { + var bc = new WildStub(); + bc.MoveToWorld(loc, map); + bc.AIObject.AITimer?.Stop(); + _created.Add(bc); + return bc; + } + + [Fact] + public void EmptyScan_HonorsReacquireDelay() + { + var map = Map.Maps[1]; + Assert.NotNull(map); + map.GetAverageZ(1500, 1600, out _, out var z, out _); + + var bc = Spawn(map, new Point3D(1500, 1600, (sbyte)z)); + bc.NextReacquireTime = Core.TickCount; + + Assert.False(bc.AIObject.AcquireFocusMob(bc.RangePerception, FightMode.Closest, false, false, true)); + Assert.InRange(bc.NextReacquireTime - Core.TickCount, 5000, 10000); + } + + [Fact] + public void WedgedGate_SelfHeals() + { + var map = Map.Maps[1]; + Assert.NotNull(map); + map.GetAverageZ(1500, 1600, out _, out var z, out _); + + var bc = Spawn(map, new Point3D(1500, 1600, (sbyte)z)); + + var target = new TargetStub(); + target.DefaultMobileInit(); + target.MoveToWorld(new Point3D(1497, 1600, (sbyte)z), map); + _created.Add(target); + + // Illegal deadline (beyond ReacquireDelay): must read as open, not block forever. + bc.NextReacquireTime = Core.TickCount + 60000; + + Assert.True(bc.AIObject.AcquireFocusMob(bc.RangePerception, FightMode.Closest, false, false, true)); + Assert.Equal(target, bc.FocusMob); + } + + [Theory] + [InlineData(false, 5, true)] // an enemy moving inside approach range (10) clamps the deadline + [InlineData(true, 5, false)] // a same-team wild creature is not an enemy — ignored + [InlineData(false, 12, false)] // inside RangePerception but outside approach range — poll only + [InlineData(false, 20, false)] // outside approach range (10) is ignored + public void MovementClampsScanDeadlineOnlyForEnemiesInRange(bool wildMover, int distance, bool notices) + { + var map = Map.Maps[1]; + Assert.NotNull(map); + map.GetAverageZ(1500, 1600, out _, out var z, out _); + + var bc = Spawn(map, new Point3D(1500, 1600, (sbyte)z)); + bc.NextReacquireTime = Core.TickCount + 8000; + + Mobile mover; + if (wildMover) + { + mover = Spawn(map, new Point3D(1500 - distance, 1600, (sbyte)z)); + } + else + { + mover = new TargetStub { Player = true }; + mover.DefaultMobileInit(); + mover.MoveToWorld(new Point3D(1500 - distance, 1600, (sbyte)z), map); + _created.Add(mover); + } + + bc.OnMovement(mover, new Point3D(1400, 1600, (sbyte)z)); + + var remaining = bc.NextReacquireTime - Core.TickCount; + + if (notices) + { + // Clamped to the approach delay (2s), never opened outright. + Assert.InRange(remaining, 1, (long)bc.AcquireOnApproachDelay.TotalMilliseconds); + } + else + { + Assert.True(remaining > 5000); + } + } + + private sealed class InstantStub : BaseCreature + { + public InstantStub() : base(AIType.AI_Melee, FightMode.Closest, 16, 1) => Body = 0xC9; + + public override TimeSpan AcquireOnApproachDelay => TimeSpan.Zero; + + public override void GetSpeeds(out double activeSpeed, out double passiveSpeed) + { + activeSpeed = 0.3; + passiveSpeed = 0.6; + } + } + + [Fact] + public void ZeroApproachDelay_OpensGateImmediately() + { + var map = Map.Maps[1]; + Assert.NotNull(map); + map.GetAverageZ(1500, 1600, out _, out var z, out _); + + var bc = new InstantStub(); + bc.MoveToWorld(new Point3D(1500, 1600, (sbyte)z), map); + bc.AIObject.AITimer?.Stop(); + _created.Add(bc); + bc.NextReacquireTime = Core.TickCount + 8000; + + var mover = new TargetStub { Player = true }; + mover.DefaultMobileInit(); + mover.MoveToWorld(new Point3D(1495, 1600, (sbyte)z), map); + _created.Add(mover); + + bc.OnMovement(mover, new Point3D(1400, 1600, (sbyte)z)); + + // Zero = the gate opens and the AI is prodded to think now; no direct engage. + Assert.True(Core.TickCount - bc.NextReacquireTime >= 0); + Assert.Null(bc.Combatant); + Assert.True(bc.AIObject.AITimer.Running); + } + + [Fact] + public void RepeatedMovement_DoesNotShortenBelowApproachDelay() + { + var map = Map.Maps[1]; + Assert.NotNull(map); + map.GetAverageZ(1500, 1600, out _, out var z, out _); + + var bc = Spawn(map, new Point3D(1500, 1600, (sbyte)z)); + bc.NextReacquireTime = Core.TickCount + 8000; + + var mover = new TargetStub { Player = true }; + mover.DefaultMobileInit(); + mover.MoveToWorld(new Point3D(1495, 1600, (sbyte)z), map); + _created.Add(mover); + + bc.OnMovement(mover, new Point3D(1400, 1600, (sbyte)z)); + var afterFirst = bc.NextReacquireTime; + + bc.OnMovement(mover, new Point3D(1496, 1600, (sbyte)z)); + + Assert.Equal(afterFirst, bc.NextReacquireTime); + } + + [Fact] + public void SuccessfulAcquire_HoldsFullDelay() + { + var map = Map.Maps[1]; + Assert.NotNull(map); + map.GetAverageZ(1500, 1600, out _, out var z, out _); + + var bc = Spawn(map, new Point3D(1500, 1600, (sbyte)z)); + + var target = new TargetStub(); + target.DefaultMobileInit(); + target.MoveToWorld(new Point3D(1497, 1600, (sbyte)z), map); + _created.Add(target); + + bc.NextReacquireTime = Core.TickCount; + + Assert.True(bc.AIObject.AcquireFocusMob(bc.RangePerception, FightMode.Closest, false, false, true)); + Assert.Equal(target, bc.FocusMob); + Assert.True(bc.NextReacquireTime - Core.TickCount > 5000); + } +} diff --git a/Projects/UOContent.Tests/Tests/Mobiles/AI/ApproachTargetTests.cs b/Projects/UOContent.Tests/Tests/Mobiles/AI/ApproachTargetTests.cs index 923f4aff9..a952252a6 100644 --- a/Projects/UOContent.Tests/Tests/Mobiles/AI/ApproachTargetTests.cs +++ b/Projects/UOContent.Tests/Tests/Mobiles/AI/ApproachTargetTests.cs @@ -40,7 +40,7 @@ public class ApproachTargetTests for (var i = 0; i < maxTicks; i++) { ai.NextMove = 0; - ai.WalkMobileRange(target, 1, false, 1, 2); + ai.WalkMobileRange(target, 1, 1, 2); if (bc.InRange(target, arriveDist)) { return true; @@ -123,7 +123,7 @@ public class ApproachTargetTests for (var i = 0; i < 200; i++) { ai.NextMove = 0; - ai.MoveTo(target, false, 1); + ai.MoveTo(target, 1); if (bc.InRange(target, 1)) { arrived = true; @@ -154,7 +154,7 @@ public class ApproachTargetTests for (var i = 0; i < 60; i++) { ai.NextMove = 0; - ai.MoveTo(target, true, 1); + ai.MoveTo(target, 1); // Target walks west every other tick for its first several steps, then stops, // so a same-speed chaser eventually closes the gap. @@ -214,7 +214,7 @@ public class ApproachTargetTests for (var i = 0; i < 120; i++) { ai.NextMove = 0; - ai.MoveTo(target, false, 1); + ai.MoveTo(target, 1); } // After giving up, the creature must idle (not oscillate) while the goal is still. @@ -223,7 +223,7 @@ public class ApproachTargetTests for (var i = 0; i < 20; i++) { ai.NextMove = 0; - ai.MoveTo(target, false, 1); + ai.MoveTo(target, 1); if (bc.Location != idleStart) { stayedIdle = false; diff --git a/Projects/UOContent.Tests/Tests/Mobiles/AI/GuardFollowTests.cs b/Projects/UOContent.Tests/Tests/Mobiles/AI/GuardFollowTests.cs new file mode 100644 index 000000000..db5225359 --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Mobiles/AI/GuardFollowTests.cs @@ -0,0 +1,96 @@ +using System.Collections.Generic; +using Server; +using Server.Mobiles; +using Xunit; + +namespace UOContent.Tests.Mobiles.AI; + +// Guard-following may pathfind, so this shares the pathfinding collection. +[Collection("Sequential Pathfinding Tests")] +public class GuardFollowTests +{ + [Fact] + public void GuardFollow_StepsTowardMaster_AndRegistersMoveIntent() + { + var map = Map.Maps[1]; + Assert.NotNull(map); + map.GetAverageZ(1500, 1600, out _, out var z, out _); + + var master = new PlayerMobile(World.NewMobile); + master.DefaultMobileInit(); + master.MoveToWorld(new Point3D(1494, 1600, (sbyte)z), map); + + var pet = new PetTestStub(); + pet.MoveToWorld(new Point3D(1500, 1600, (sbyte)z), map); // 6 tiles east, open terrain + pet.SetControlMaster(master); + + var ai = pet.AIObject; + ai.AITimer?.Stop(); // drive manually + pet.ControlOrder = OrderType.Guard; + ai.AITimer?.Stop(); // the order change may restart the timer + + var start = pet.Location; + ai.NextMove = 0; + ai.Obey(); + + var moved = pet.Location != start; + var hasIntent = ai.TryGetMoveWake(out _); + var currentSpeed = pet.CurrentSpeed; + var currentMoveSpeed = pet.CurrentMoveSpeed; + + pet.Delete(); + master.Delete(); + + Assert.True(moved, "a guarding pet beyond guard range must step toward its master"); + // Without a move intent, guard-following only steps on the think grid. + Assert.True(hasIntent, "guard-following must register a move intent"); + + // AOS return sprint on both clocks; the per-step speed flip must not undo it. + Assert.Equal(0.1, currentSpeed); + Assert.Equal(0.1, currentMoveSpeed); + } + + [Fact] + public void GuardReturn_PreAOS_RunsActive() + { + var previous = Core.Expansion; + + try + { + Core.Expansion = Expansion.UOR; + + var map = Map.Maps[1]; + Assert.NotNull(map); + map.GetAverageZ(1500, 1600, out _, out var z, out _); + + var master = new PlayerMobile(World.NewMobile); + master.DefaultMobileInit(); + master.MoveToWorld(new Point3D(1494, 1600, (sbyte)z), map); + + var pet = new PetTestStub(); + pet.MoveToWorld(new Point3D(1500, 1600, (sbyte)z), map); + pet.SetControlMaster(master); + + var ai = pet.AIObject; + ai.AITimer?.Stop(); + pet.ControlOrder = OrderType.Guard; + ai.AITimer?.Stop(); + pet.SetCurrentSpeedToPassive(); // a stale passive state must not persist + + ai.NextMove = 0; + ai.Obey(); + + var currentSpeed = pet.CurrentSpeed; + + pet.Delete(); + master.Delete(); + + // No sprint pre-AOS: the return runs active. + Assert.Equal(0.2, currentSpeed); + } + finally + { + Core.Expansion = previous; + } + } +} diff --git a/Projects/UOContent.Tests/Tests/Mobiles/AI/GuardOrderTests.cs b/Projects/UOContent.Tests/Tests/Mobiles/AI/GuardOrderTests.cs new file mode 100644 index 000000000..7d572786a --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Mobiles/AI/GuardOrderTests.cs @@ -0,0 +1,137 @@ +using System; +using System.Collections.Generic; +using Server; +using Server.Mobiles; +using Xunit; + +namespace UOContent.Tests.Mobiles.AI; + +// A guarding pet fights without leaving the Guard order, retargets toward the master's +// closest aggressor, and stands down when nothing threatens. Scene: the open +// (1495..1500, 1600) Trammel segment; targets are adjacent so no pathfinding runs. +[Collection("Sequential UOContent Tests")] +public class GuardOrderTests : IDisposable +{ + private readonly List _created = new(); + + private sealed class AggressorStub : Mobile + { + public AggressorStub() => Body = 0xC9; + } + + public void Dispose() + { + foreach (var m in _created) + { + m?.Delete(); + } + + _created.Clear(); + } + + private (PlayerMobile master, PetTestStub pet) SpawnGuardingPet(out Map map, out int z) + { + map = Map.Maps[1]; + Assert.NotNull(map); + map.GetAverageZ(1500, 1600, out _, out z, out _); + + var master = new PlayerMobile(World.NewMobile); + master.DefaultMobileInit(); + master.MoveToWorld(new Point3D(1500, 1600, (sbyte)z), map); + _created.Add(master); + + var pet = new PetTestStub(); + pet.MoveToWorld(new Point3D(1499, 1600, (sbyte)z), map); + pet.SetControlMaster(master); + _created.Add(pet); + + pet.AIObject.AITimer?.Stop(); // drive manually + pet.ControlOrder = OrderType.Guard; + pet.AIObject.AITimer?.Stop(); // the order change restarts the timer + + return (master, pet); + } + + private AggressorStub SpawnAggressor(PetTestStub pet, Point3D loc, Mobile attacking) + { + var aggr = new AggressorStub(); + aggr.MoveToWorld(loc, pet.Map); + _created.Add(aggr); + + // Setup guard: the scene must stay LOS-clear and the combatant must not be vetoed. + Assert.True(pet.InLOS(aggr), $"no LOS from pet to aggressor at {loc}"); + + if (attacking != null) + { + aggr.Combatant = attacking; + Assert.Same(attacking, aggr.Combatant); + } + + return aggr; + } + + [Fact] + public void GuardEngage_KeepsGuardOrder() + { + var (master, pet) = SpawnGuardingPet(out _, out var z); + var aggr = SpawnAggressor(pet, new Point3D(1498, 1600, (sbyte)z), master); + + pet.AIObject.Obey(); + + Assert.Same(aggr, pet.Combatant); + Assert.Equal(OrderType.Guard, pet.ControlOrder); + Assert.Equal(OrderType.Guard, pet.AIObject.PersistentOrder); + } + + [Fact] + public void Guard_RetargetsToAggressorClosestToMaster() + { + var (master, pet) = SpawnGuardingPet(out _, out var z); + var far = SpawnAggressor(pet, new Point3D(1495, 1600, (sbyte)z), master); + var near = SpawnAggressor(pet, new Point3D(1498, 1600, (sbyte)z), master); + + pet.Combatant = far; // already fighting the far aggressor + + pet.AIObject.Obey(); + + Assert.Same(near, pet.Combatant); // defends the master, not the current fight + Assert.Equal(OrderType.Guard, pet.ControlOrder); + } + + [Fact] + public void ExplicitAttack_ResumesGuard_WithoutChainingIntoAttack() + { + var (master, pet) = SpawnGuardingPet(out _, out var z); + + // Explicit kill order on a target that then becomes invalid. + var victim = SpawnAggressor(pet, new Point3D(1498, 1600, (sbyte)z), null); + pet.ControlTarget = victim; + pet.ControlOrder = OrderType.Attack; + victim.Hidden = true; + + // A second aggressor is still after the master; FightMode.Closest would chain it. + var aggr2 = SpawnAggressor(pet, new Point3D(1497, 1600, (sbyte)z), master); + + pet.AIObject.Obey(); // attack completes -> resume the persistent Guard + + Assert.Equal(OrderType.Guard, pet.ControlOrder); + + pet.AIObject.Obey(); // the guard scan engages the remaining aggressor in-order + + Assert.Same(aggr2, pet.Combatant); + Assert.Equal(OrderType.Guard, pet.ControlOrder); + } + + [Fact] + public void PeacefulGuard_StandsDown() + { + var (_, pet) = SpawnGuardingPet(out _, out _); + Assert.True(pet.Warmode); // the guard order opens in war stance + + pet.AIObject.Obey(); // nothing to guard against + + Assert.False(pet.Warmode); + Assert.Null(pet.Combatant); + Assert.Null(pet.FocusMob); + } +} diff --git a/Projects/UOContent.Tests/Tests/Mobiles/AI/MoveSpeedTests.cs b/Projects/UOContent.Tests/Tests/Mobiles/AI/MoveSpeedTests.cs new file mode 100644 index 000000000..7d9243a29 --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Mobiles/AI/MoveSpeedTests.cs @@ -0,0 +1,219 @@ +using System; +using System.Collections.Generic; +using Server; +using Server.Mobiles; +using Xunit; + +namespace UOContent.Tests.Mobiles.AI; + +// Pins the CurrentMoveSpeed classification (verbatim active/passive maps to the matching +// move value; bespoke stays fused), SetSpeed's one-clock guarantee, and the v22 tail. +[Collection("Sequential UOContent Tests")] +public class MoveSpeedTests : IDisposable +{ + // Delete spawned stubs so they don't linger in the shared static World. + private readonly List _created = new(); + + public void Dispose() + { + for (var i = 0; i < _created.Count; i++) + { + _created[i].Delete(); + } + } + + private sealed class SpeedStub : BaseCreature + { + // Stands in for the npc-speeds table (unconfigured in the test fixture). + public double TableActiveMove; + public double TablePassiveMove; + + public SpeedStub() : base(AIType.AI_Animal) => Body = 0xC9; + + public SpeedStub(Serial serial) : base(serial) => Body = 0xC9; + + 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 = TableActiveMove; + passiveMoveSpeed = TablePassiveMove; + } + } + + private SpeedStub NewCreature() + { + var bc = new SpeedStub(); + _created.Add(bc); + return bc; + } + + [Fact] + public void MoveSpeeds_InheritThinkValues_ByDefault() + { + var bc = NewCreature(); + + Assert.Equal(0.3, bc.ActiveMoveSpeed); + Assert.Equal(0.6, bc.PassiveMoveSpeed); + Assert.Equal(bc.CurrentSpeed, bc.CurrentMoveSpeed); + } + + [Fact] + public void CurrentMoveSpeed_ResolvesPerMode_WhenOverridden() + { + var bc = NewCreature(); + bc.SetMoveSpeed(0.45, 0.9); + + // SetSpeed left the creature passive; the think clock is untouched. + Assert.Equal(0.6, bc.CurrentSpeed); + Assert.Equal(0.9, bc.CurrentMoveSpeed); + + bc.SetCurrentSpeedToActive(); + Assert.Equal(0.3, bc.CurrentSpeed); + Assert.Equal(0.45, bc.CurrentMoveSpeed); + } + + [Fact] + public void CurrentMoveSpeed_BespokePace_StaysFused() + { + var bc = NewCreature(); + bc.SetMoveSpeed(0.45, 0.9); + + // Neither think value verbatim, so both clocks run it. + bc.CurrentSpeed = 0.11; + Assert.Equal(0.11, bc.CurrentMoveSpeed); + } + + [Fact] + public void SetSpeed_ClearsMoveOverrides() + { + var bc = NewCreature(); + bc.SetMoveSpeed(0.45, 0.9); + + bc.SetSpeed(0.2, 0.4); + + Assert.Equal(0.2, bc.ActiveMoveSpeed); + Assert.Equal(0.4, bc.PassiveMoveSpeed); + } + + [Fact] + public void NonPositiveMoveSpeed_ClearsThatOverride() + { + var bc = NewCreature(); + bc.SetMoveSpeed(0.45, 0.9); + + bc.ActiveMoveSpeed = 0; + + Assert.Equal(0.3, bc.ActiveMoveSpeed); // inheriting again + Assert.Equal(0.9, bc.PassiveMoveSpeed); // other override untouched + } + + [Fact] + public void ScaleMoveSpeed_ScalesOverrides_LeavesInheritAlone() + { + var bc = NewCreature(); + bc.ActiveMoveSpeed = 0.6; // passive left inheriting + + bc.ScaleMoveSpeed(1.0 / 1.2); + + Assert.Equal(0.5, bc.ActiveMoveSpeed); + Assert.Equal(bc.PassiveSpeed, bc.PassiveMoveSpeed); // still inheriting, not 0 * scalar + } + + [Fact] + public void Herding_DrivesMoveClock_ThinkUntouched() + { + var bc = NewCreature(); // think 0.3/0.6, passive + bc.SetMoveSpeed(0.45, 1.05); + + bc.TargetLocation = new Point2D(10, 10); + + Assert.Equal(0.6, bc.CurrentSpeed); // think clock unaffected by herding + Assert.Equal(0.3, bc.CurrentMoveSpeed); // fixed herding pace, not 1.05 + + bc.TargetLocation = null; + Assert.Equal(1.05, bc.CurrentMoveSpeed); + } + + [Fact] + public void SnapSpeedsToTable_UndoesScalingDrift_KeepsTunedValues() + { + var bc = NewCreature(); + bc.TableActiveMove = 0.45; + bc.TablePassiveMove = 0.9; + bc.SetMoveSpeed(0.45, 0.9); + + // 0.45 and 0.9 do not survive /1.2 then *1.2 bit-exactly. + bc.ScaleMoveSpeed(1.0 / 1.2); + bc.ScaleMoveSpeed(1.2); + Assert.NotEqual(0.45, bc.ActiveMoveSpeed); + + bc.SnapSpeedsToTable(); + Assert.Equal(0.45, bc.ActiveMoveSpeed); + Assert.Equal(0.9, bc.PassiveMoveSpeed); + + // A hand-tuned value is nowhere near the epsilon and must keep. + bc.SetMoveSpeed(0.7, 0.9); + bc.SnapSpeedsToTable(); + Assert.Equal(0.7, bc.ActiveMoveSpeed); + } + + [Fact] + public void Migration_MatchingThinkSpeeds_AdoptTableMoveValues() + { + var bc = NewCreature(); // think 0.3/0.6, matching its table entry + bc.TableActiveMove = 0.45; + bc.TablePassiveMove = 0.9; + + bc.MigrateMoveSpeeds(); + + Assert.Equal(0.45, bc.ActiveMoveSpeed); + Assert.Equal(0.9, bc.PassiveMoveSpeed); + } + + [Fact] + public void Migration_TunedThinkSpeeds_KeepInheriting() + { + var bc = NewCreature(); + bc.SetSpeed(0.35, 0.6); // hand-tuned: no longer matches the table entry + bc.TableActiveMove = 0.45; + bc.TablePassiveMove = 0.9; + + bc.MigrateMoveSpeeds(); + + Assert.Equal(0.35, bc.ActiveMoveSpeed); + Assert.Equal(0.6, bc.PassiveMoveSpeed); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void MoveSpeedOverrides_SurviveSerialization(bool overridden) + { + var bc = NewCreature(); + if (overridden) + { + bc.SetMoveSpeed(0.45, 0.9); + } + + var writer = new BufferWriter(true); + bc.Serialize(writer); + + var buffer = new byte[writer.Position]; + writer.Buffer.AsSpan(0, (int)writer.Position).CopyTo(buffer); + + var copy = new SpeedStub(World.NewMobile); + _created.Add(copy); + var reader = new BufferReader(buffer); + copy.Deserialize(reader); + + // The v22 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); + } +} diff --git a/Projects/UOContent.Tests/Tests/Mobiles/AI/PetPacingTests.cs b/Projects/UOContent.Tests/Tests/Mobiles/AI/PetPacingTests.cs new file mode 100644 index 000000000..217049b3d --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Mobiles/AI/PetPacingTests.cs @@ -0,0 +1,222 @@ +using System; +using System.Collections.Generic; +using Server; +using Server.Mobiles; +using Xunit; + +namespace UOContent.Tests.Mobiles.AI; + +// Pet order handlers own the speed clocks; combat chases and herding keep their own pacing. +[Collection("Sequential UOContent Tests")] +public class PetPacingTests : IDisposable +{ + private readonly List _created = new(); + + private (PlayerMobile master, PetTestStub pet) Spawn(Point3D masterLoc, Point3D petLoc) + { + var pair = PetTestSetup.SpawnControlledPet(masterLoc, petLoc); + _created.Add(pair.master); + _created.Add(pair.pet); + return pair; + } + + public void Dispose() + { + foreach (var m in _created) + { + m?.Delete(); + } + + _created.Clear(); + } + + // Movement orders run active, resting orders run passive; the move clock follows. + [Fact] + public void OrderIssue_SetsThinkClock() + { + var (master, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1001, 1000, 0)); + pet.SetMoveSpeed(0.3, 0.9); + pet.SetCurrentSpeedToPassive(); + + pet.ControlOrder = OrderType.Come; + Assert.Equal(0.2, pet.CurrentSpeed); + Assert.Equal(0.3, pet.CurrentMoveSpeed); // verbatim active -> activeMove + + pet.ControlOrder = OrderType.Stay; + Assert.Equal(0.4, pet.CurrentSpeed); + Assert.Equal(0.9, pet.CurrentMoveSpeed); + + pet.ControlTarget = master; + pet.ControlOrder = OrderType.Follow; + Assert.Equal(0.2, pet.CurrentSpeed); + + pet.ControlOrder = OrderType.Guard; + Assert.Equal(0.2, pet.CurrentSpeed); + } + + // AOS: following the master sprints at a bespoke 0.1 on both clocks. + [Fact] + public void FollowMaster_ObeySprints() + { + var (master, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1001, 1000, 0)); + pet.SetMoveSpeed(0.3, 0.9); + pet.AIObject.AITimer?.Stop(); + + pet.ControlTarget = master; + pet.ControlOrder = OrderType.Follow; // fixture era is EJ + pet.AIObject.Obey(); + + Assert.Equal(0.1, pet.CurrentSpeed); + Assert.Equal(0.1, pet.CurrentMoveSpeed); + } + + // At the master's side a guarding pet stays active: no stale-warmode passive, no sprint. + [Fact] + public void GuardAtMastersSide_IsActive() + { + var (_, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1001, 1000, 0)); + pet.SetMoveSpeed(0.3, 0.9); + pet.AIObject.AITimer?.Stop(); + pet.SetCurrentSpeedToPassive(); + + pet.ControlOrder = OrderType.Guard; + pet.AIObject.Obey(); // nothing to guard against, master adjacent + + Assert.Equal(0.2, pet.CurrentSpeed); + Assert.Equal(0.3, pet.CurrentMoveSpeed); + } + + // A pet chasing a combatant keeps the move table. + [Fact] + public void CombatChasingPet_KeepsMoveTable() + { + var (_, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1001, 1000, 0)); + var target = new PetTestStub(); + target.MoveToWorld(new Point3D(1003, 1000, 0), Map.Felucca); + _created.Add(target); + + pet.SetMoveSpeed(0.3, 0.9); + pet.ControlOrder = OrderType.Guard; + pet.Combatant = target; + pet.SetCurrentSpeedToActive(); + + Assert.Equal(0.3, pet.CurrentMoveSpeed); + } + + // Herding overrides order pacing. + [Fact] + public void HerdedObeyingPet_KeepsHerdingPace() + { + var (_, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1001, 1000, 0)); + pet.SetMoveSpeed(0.45, 0.9); + pet.SetCurrentSpeedToPassive(); + + pet.TargetLocation = new Point2D(1010, 1010); + + Assert.Equal(0.3, pet.CurrentMoveSpeed); // fixed herding pace + } + + private sealed class ThinkProbe : PetTestStub + { + public int Thinks; + + public override void OnThink() + { + Thinks++; + base.OnThink(); + } + } + + private (PlayerMobile master, ThinkProbe pet) SpawnProbe() + { + var master = new PlayerMobile(World.NewMobile); + master.DefaultMobileInit(); + master.MoveToWorld(new Point3D(1000, 1000, 0), Map.Felucca); + _created.Add(master); + + var pet = new ThinkProbe(); + pet.MoveToWorld(new Point3D(1001, 1000, 0), Map.Felucca); + pet.SetControlMaster(master); + _created.Add(pet); + + return (master, pet); + } + + // Advances time in 8ms lockstep so the wheel and Core.TickCount stay in sync. + private static void RunFor(long ms) + { + var deadline = Core._tickCount + ms; + + while (Core._tickCount < deadline) + { + Core._tickCount += 8; + Timer.Slice(Core._tickCount); + } + } + + private static bool RunUntil(Func condition, long maxMs) + { + var deadline = Core._tickCount + maxMs; + + while (Core._tickCount < deadline) + { + if (condition()) + { + return true; + } + + Core._tickCount += 8; + Timer.Slice(Core._tickCount); + } + + return condition(); + } + + // Runs past the spawn stagger; returns right after a think with the next 0.4s away. + private ThinkProbe SettledProbe(out PlayerMobile master) + { + Core._tickCount = 0; + Timer.Init(0); + + var (m, pet) = SpawnProbe(); + master = m; + pet.ForceIdle = true; // no wandering; pure cadence + pet.ControlOrder = OrderType.Stay; + + var settled = RunUntil(() => pet.Thinks >= 2, 8000); + Assert.True(settled, "the AI must reach a steady think cadence"); + + return pet; + } + + [Fact] + public void OrderChange_WakesStaleThinkTimer() + { + var pet = SettledProbe(out var master); + var thinksBefore = pet.Thinks; + + RunFor(200); // mid-wait, next think ~200ms out + Assert.Equal(thinksBefore, pet.Thinks); + + pet.ControlTarget = master; + pet.ControlOrder = OrderType.Follow; + + RunFor(80); + Assert.True(pet.Thinks > thinksBefore, "a fresh order must wake the AI promptly"); + } + + [Fact] + public void SpeedUp_ReschedulesPendingWake() + { + var pet = SettledProbe(out _); + var thinksBefore = pet.Thinks; + + RunFor(200); // mid-wait, next think ~200ms out + Assert.Equal(thinksBefore, pet.Thinks); + + pet.CurrentSpeed = 0.1; + + RunFor(120); + Assert.True(pet.Thinks > thinksBefore, "a speed-up must reschedule the pending wake"); + } +} diff --git a/Projects/UOContent.Tests/Tests/Mobiles/AI/RunFlagTests.cs b/Projects/UOContent.Tests/Tests/Mobiles/AI/RunFlagTests.cs new file mode 100644 index 000000000..a421aaba2 --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Mobiles/AI/RunFlagTests.cs @@ -0,0 +1,162 @@ +using System.Collections.Generic; +using Server; +using Server.Mobiles; +using Xunit; + +namespace UOContent.Tests.Mobiles.AI; + +// The Running bit is derived from the step pace: a step shorter than the client's walk +// interpolation (400ms on foot, 200ms mounted/flying) is flagged as a run. +[Collection("Sequential Pathfinding Tests")] +public class RunFlagTests : System.IDisposable +{ + private readonly List _created = new(); + + private PetTestStub Spawn(double activeMove) + { + var pet = new PetTestStub(); + pet.MoveToWorld(new Point3D(1000, 1000, 0), Map.Felucca); + pet.AIObject.AITimer?.Stop(); + pet.SetMoveSpeed(activeMove, activeMove * 3); + pet.SetCurrentSpeedToActive(); + pet.LastMoveTime = Core.TickCount; // mid-cadence unless a test says otherwise + _created.Add(pet); + return pet; + } + + public void Dispose() + { + foreach (var m in _created) + { + m?.Delete(); + } + + _created.Clear(); + } + + [Theory] + [InlineData(0.3, true)] + [InlineData(0.125, true)] + [InlineData(0.4, false)] + [InlineData(0.45, false)] + [InlineData(1.05, false)] + public void FootCreature_RunsOnlyWhenFasterThanWalk(double activeMove, bool expected) + { + var pet = Spawn(activeMove); + + Assert.Equal(activeMove, pet.CurrentMoveSpeed); + Assert.Equal(expected, pet.AIObject.ShouldRun()); + } + + [Theory] + [InlineData(0.3, false)] + [InlineData(0.15, true)] + public void FlyingCreature_UsesMountThresholds(double activeMove, bool expected) + { + var pet = Spawn(activeMove); + pet.Flying = true; + + Assert.Equal(expected, pet.AIObject.ShouldRun()); + } + + [Fact] + public void BadlyHurt_SlowsBelowWalk_DropsToWalk() + { + var pet = Spawn(0.35); + Assert.True(pet.AIObject.ShouldRun()); + + // The hurt inflation is on the observed step pace, so the flag follows it. + pet.SetHits(100); + pet.Hits = 5; + pet.SetStam(100); + pet.Stam = 5; + + Assert.False(pet.AIObject.ShouldRun()); + } + + [Theory] + [InlineData(0.3, true)] + [InlineData(0.45, false)] + public void DoMove_StampsRunningBit(double activeMove, bool expected) + { + var map = Map.Maps[1]; + Assert.NotNull(map); + map.GetAverageZ(1500, 1600, out _, out var z, out _); + + var pet = Spawn(activeMove); + pet.MoveToWorld(new Point3D(1500, 1600, (sbyte)z), map); + + var ai = pet.AIObject; + ai.NextMove = 0; + var start = pet.Location; + + Assert.True(ai.DoMove(Direction.West)); + Assert.NotEqual(start, pet.Location); + Assert.Equal(expected, (pet.Direction & Direction.Running) != 0); + } + + // An isolated step (after standing at least a walk interval) renders alone and darts + // if run-flagged, so it walks; continuing cadences and true sprinters keep the flag. + [Fact] + public void IsolatedStep_DropsToWalk() + { + var pet = Spawn(0.3); + pet.LastMoveTime = Core.TickCount - 1000; + + Assert.False(pet.AIObject.ShouldRun()); + } + + [Fact] + public void IsolatedStep_SprinterStillRuns() + { + var pet = Spawn(0.125); + pet.LastMoveTime = Core.TickCount - 1000; + + Assert.True(pet.AIObject.ShouldRun()); + } + + [Fact] + public void StallDoesNotBankCatchUpSteps() + { + var map = Map.Maps[1]; + Assert.NotNull(map); + map.GetAverageZ(1500, 1600, out _, out var z, out _); + + var pet = Spawn(0.3); + pet.MoveToWorld(new Point3D(1500, 1600, (sbyte)z), map); + + var ai = pet.AIObject; + ai.NextMove = Core.TickCount - 1000; + + Assert.True(ai.DoMove(Direction.West)); + + // A stall must restart the cadence at full pace: banked catch-up steps + // release as a burst the client renders as a sprint/teleport. + Assert.False(ai.CanMoveNow(out _)); + Assert.True(ai.NextMove - Core.TickCount > 250); + } + + [Fact] + public void LateStepDoesNotEarnAQuickerFollowUp() + { + var map = Map.Maps[1]; + Assert.NotNull(map); + map.GetAverageZ(1500, 1600, out _, out var z, out _); + + var pet = Spawn(0.3); + pet.MoveToWorld(new Point3D(1500, 1600, (sbyte)z), map); + + pet.Warmode = true; // keep the active move clock through the step + + var ai = pet.AIObject; + // The step lands 200ms past the budget — under one period, the reactive + // mirroring case (think grid vs budget deadline misalignment). + ai.NextMove = Core.TickCount - 200; + + Assert.True(ai.DoMove(Direction.West)); + + // The debt must not be repaid: a sub-period catch-up step follows ~100ms + // behind and renders as a dart pair beside the player. + Assert.True(ai.NextMove - Core.TickCount > 250); + } +} diff --git a/Projects/UOContent.Tests/Tests/Mobiles/LootingRightsTests.cs b/Projects/UOContent.Tests/Tests/Mobiles/LootingRightsTests.cs new file mode 100644 index 000000000..e02abece7 --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Mobiles/LootingRightsTests.cs @@ -0,0 +1,146 @@ +using System.Collections.Generic; +using Server.Mobiles; +using Xunit; + +namespace Server.Tests; + +/// +/// Pins the looting-rights rules that the inline damage entry list has to keep producing: the +/// returned stores are sorted by damage descending, the first (least recent) damager takes the +/// 1.25x bonus, the hitsMax band decides who clears the threshold, and a pet's damage is credited +/// to its damage master rather than to the pet. +/// +[Collection("Sequential UOContent Tests")] +public class LootingRightsTests +{ + private class TestMobile : Mobile + { + } + + private class PetMobile : Mobile + { + public Mobile Master { get; set; } + + public override Mobile GetDamageMaster(Mobile damagee) => Master; + } + + // GetLootingRights only ever credits mobiles flagged as players. + private static TestMobile NewPlayer() => new() { Player = true }; + + private static DamageStore FindStore(List rights, Mobile m) + { + for (var i = 0; i < rights.Count; i++) + { + if (rights[i].m_Mobile == m) + { + return rights[i]; + } + } + + return null; + } + + [Fact] + public void TwoPlayerDamagers_SortDescending_AndTheFirstDamagerTakesTheBonus() + { + var victim = new TestMobile(); + var first = NewPlayer(); + var second = NewPlayer(); + + try + { + victim.RegisterDamage(100, first); + victim.RegisterDamage(40, second); // second is the most recent, first is the "first damager" + + // hitsMax < 200 puts the bar at topDamage / 2. + var rights = BaseCreature.GetLootingRights(victim.DamageEntries, 100); + + Assert.Equal(2, rights.Count); + + // Sorted by damage descending. + Assert.True(rights[0].m_Damage >= rights[1].m_Damage); + Assert.Same(first, rights[0].m_Mobile); + Assert.Same(second, rights[1].m_Mobile); + + // The first damager - the least recent entry - gets the 1.25x bonus; nobody else does. + Assert.Equal(125, rights[0].m_Damage); + Assert.Equal(40, rights[1].m_Damage); + + // topDamage 125 / 2 = 62, so 40 is below the bar. + Assert.True(rights[0].m_HasRight); + Assert.False(rights[1].m_HasRight); + } + finally + { + victim.Delete(); + first.Delete(); + second.Delete(); + } + } + + [Fact] + public void HitsMaxBand_MovesTheRightsThreshold() + { + var victim = new TestMobile(); + var first = NewPlayer(); + var second = NewPlayer(); + + try + { + victim.RegisterDamage(100, first); + victim.RegisterDamage(40, second); + + // hitsMax >= 200 drops the bar to topDamage / 4 = 31, which 40 clears. + var rights = BaseCreature.GetLootingRights(victim.DamageEntries, 200); + + Assert.Equal(2, rights.Count); + Assert.True(rights[0].m_HasRight); + Assert.True(rights[1].m_HasRight); + Assert.Same(second, rights[1].m_Mobile); + } + finally + { + victim.Delete(); + first.Delete(); + second.Delete(); + } + } + + [Fact] + public void PetDamage_CreditsTheMaster_NotThePet() + { + var victim = new TestMobile(); + var master = NewPlayer(); + var pet = new PetMobile { Master = master }; + var wild = new TestMobile(); // no damage master, and not a player + + try + { + victim.RegisterDamage(50, pet); + victim.RegisterDamage(20, wild); + + var rights = BaseCreature.GetLootingRights(victim.DamageEntries, 100); + + // The master is credited through the entry's Responsible sub-entry, and is the only one. + Assert.Single(rights); + + var masterStore = FindStore(rights, master); + Assert.NotNull(masterStore); + Assert.Equal(62, masterStore.m_Damage); // 50, then the first-damager 1.25x bonus + Assert.True(masterStore.m_HasRight); + + // The pet's own damage was fully handed to the master, so it earns no store. + Assert.Null(FindStore(rights, pet)); + + // A non-player damager earns nothing even when its damage was never reassigned. + Assert.Null(FindStore(rights, wild)); + } + finally + { + victim.Delete(); + master.Delete(); + pet.Delete(); + wild.Delete(); + } + } +} diff --git a/Projects/UOContent.Tests/UOContent.Tests.csproj b/Projects/UOContent.Tests/UOContent.Tests.csproj index 712246f72..1c73f6eb4 100644 --- a/Projects/UOContent.Tests/UOContent.Tests.csproj +++ b/Projects/UOContent.Tests/UOContent.Tests.csproj @@ -4,9 +4,9 @@ Debug;Release;Analyze - + - + all runtime; build; native; contentfiles; analyzers; buildtransitive @@ -15,7 +15,6 @@ - diff --git a/Projects/UOContent/Engines/Bulk Orders/Books/BOBFilter.cs b/Projects/UOContent/Engines/Bulk Orders/Books/BOBFilter.cs index 962c3d8e3..f63e4f8d4 100644 --- a/Projects/UOContent/Engines/Bulk Orders/Books/BOBFilter.cs +++ b/Projects/UOContent/Engines/Bulk Orders/Books/BOBFilter.cs @@ -6,27 +6,27 @@ namespace Server.Engines.BulkOrders; public partial class BOBFilter { [SerializableField(0)] + [SaveFlag(nameof(ShouldSerializeType))] private int _type; - [SerializableFieldSaveFlag(0)] private bool ShouldSerializeType() => _type != 0; [SerializableField(1)] + [SaveFlag(nameof(ShouldSerializeQuality))] private int _quality; - [SerializableFieldSaveFlag(1)] private bool ShouldSerializeQuality() => _quality != 0; [SerializableField(2)] + [SaveFlag(nameof(ShouldSerializeMaterial))] private int _material; - [SerializableFieldSaveFlag(2)] private bool ShouldSerializeMaterial() => _material != 0; [SerializableField(3)] + [SaveFlag(nameof(ShouldSerializeQuantity))] private int _quantity; - [SerializableFieldSaveFlag(3)] private bool ShouldSerializeQuantity() => _quantity != 0; private void Deserialize(IGenericReader reader, int version) diff --git a/Projects/UOContent/Engines/Bulk Orders/SmallBOD.cs b/Projects/UOContent/Engines/Bulk Orders/SmallBOD.cs index 746da8687..6356780de 100644 --- a/Projects/UOContent/Engines/Bulk Orders/SmallBOD.cs +++ b/Projects/UOContent/Engines/Bulk Orders/SmallBOD.cs @@ -137,11 +137,7 @@ public abstract partial class SmallBOD : BaseBOD { var material = GetMaterial(armor?.Resource ?? clothing?.Resource ?? CraftResource.None); - if (!item.PlayerConstructed) - { - from.SendLocalizedMessage(1045169); // The item is not in the request. - } - else if (Material >= BulkMaterialType.DullCopper && Material <= BulkMaterialType.Valorite && material != Material) + if (Material >= BulkMaterialType.DullCopper && Material <= BulkMaterialType.Valorite && material != Material) { from.SendLocalizedMessage(1045168); // The item is not made from the requested ore. } diff --git a/Projects/UOContent/Engines/CannedEvil/ChampionSkullBrazier.cs b/Projects/UOContent/Engines/CannedEvil/ChampionSkullBrazier.cs index 9ff8dc633..2632fd954 100755 --- a/Projects/UOContent/Engines/CannedEvil/ChampionSkullBrazier.cs +++ b/Projects/UOContent/Engines/CannedEvil/ChampionSkullBrazier.cs @@ -33,17 +33,13 @@ public partial class ChampionSkullBrazier : AddonComponent [SerializedCommandProperty(AccessLevel.GameMaster)] private ChampionSkullPlatform _platform; - [SerializableProperty(2)] - [CommandProperty(AccessLevel.GameMaster)] - public Item Skull + [SerializableField(2, fieldChanged: nameof(OnSkullChanged))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private Item _skull; + + private void OnSkullChanged(Item oldValue, Item newValue) { - get => _skull; - set - { - _skull = value; - this.MarkDirty(); - _platform?.Validate(); - } + _platform?.Validate(); } public override int LabelNumber => 1049489 + (int)_type; diff --git a/Projects/UOContent/Engines/CannedEvil/ChampionSpawn.cs b/Projects/UOContent/Engines/CannedEvil/ChampionSpawn.cs index a26bcc0f9..3a024576b 100755 --- a/Projects/UOContent/Engines/CannedEvil/ChampionSpawn.cs +++ b/Projects/UOContent/Engines/CannedEvil/ChampionSpawn.cs @@ -18,6 +18,7 @@ using System.Net; using System.Collections.Generic; using System.Runtime.InteropServices; using ModernUO.Serialization; +using Server.Collections; using Server.Engines.Virtues; using Server.Gumps; using Server.Items; @@ -27,16 +28,44 @@ using Server.Logging; namespace Server.Engines.CannedEvil; -[SerializationGenerator(10, false)] +[SerializationGenerator(11, false)] public partial class ChampionSpawn : Item { + private void MigrateFrom(V10Content content) + { + _level = content.Level; + _activatedByProximity = content.ActivatedByProximity; + _nextProximityTime = content.NextProximityTime; + _maxLevel = content.MaxLevel; + _activatedByValor = content.ActivatedByValor; + _damageEntries = content.DamageEntries; + _confinedRoaming = content.ConfinedRoaming; + _idol = content.Idol; + _hasBeenAdvanced = content.HasBeenAdvanced; + _spawnArea = content.SpawnArea; + _randomizeType = content.RandomizeType; + _kills = content.Kills; + _active = content.Active; + _type = content.Type; + _creatures = content.Creatures; + _redSkulls = content.RedSkulls; + _whiteSkulls = content.WhiteSkulls; + _platform = content.Platform; + _altar = content.Altar; + _expireDelay = content.ExpireDelay; + _expireTime = content.ExpireTime; + _champion = content.Champion; + _restartDelay = content.RestartDelay; + _restartTime = content.RestartTime; + } + private static readonly ILogger logger = LogFactory.GetLogger(typeof(ChampionSpawn)); [SerializableField(1)] [SerializedCommandProperty(AccessLevel.GameMaster)] private bool _activatedByProximity; - [DeltaDateTime] + [AnchoredDateTime] [SerializableField(2)] [SerializedCommandProperty(AccessLevel.GameMaster)] private DateTime _nextProximityTime; @@ -96,7 +125,7 @@ public partial class ChampionSpawn : Item [SerializedCommandProperty(AccessLevel.GameMaster)] private TimeSpan _expireDelay; - [DeltaDateTime] + [AnchoredDateTime] [SerializableField(20)] [SerializedCommandProperty(AccessLevel.GameMaster)] private DateTime _expireTime; @@ -109,7 +138,7 @@ public partial class ChampionSpawn : Item [SerializedCommandProperty(AccessLevel.GameMaster)] private TimeSpan _restartDelay; - [DeltaDateTime] + [AnchoredDateTime] [SerializableField(23, setter: "private")] [SerializedCommandProperty(AccessLevel.GameMaster)] private DateTime _restartTime; @@ -203,47 +232,38 @@ public partial class ChampionSpawn : Item } } - [SerializableProperty(3)] - [CommandProperty(AccessLevel.GameMaster, AccessLevel.Administrator)] - public int MaxLevel + [SerializableField(3, allowFieldChange: nameof(AllowMaxLevelChange))] + [SerializedCommandProperty(AccessLevel.GameMaster, AccessLevel.Administrator)] + private int _maxLevel; + + private bool AllowMaxLevelChange(ref int value) { - get => _maxLevel; - set => _maxLevel = Math.Clamp(value, 0, 18); + value = Math.Clamp(value, 0, 18); + return true; } - [SerializableProperty(9)] - [CommandProperty(AccessLevel.GameMaster)] - public Rectangle2D SpawnArea + [SerializableField(9, fieldChanged: nameof(OnSpawnAreaChanged))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + [InvalidateProperties] + private Rectangle2D _spawnArea; + + private void OnSpawnAreaChanged(Rectangle2D oldValue, Rectangle2D newValue) { - get => _spawnArea; - set - { - _spawnArea = value; - this.MarkDirty(); - InvalidateProperties(); - UpdateRegion(); - } + UpdateRegion(); } - [SerializableProperty(11)] - [CommandProperty(AccessLevel.GameMaster)] - public int Kills + [SerializableField(11, fieldChanged: nameof(OnKillsChanged))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + [InvalidateProperties] + private int _kills; + + private void OnKillsChanged(int oldValue, int newValue) { - get => _kills; - set + var n = _kills / (double)MaxKills; + var p = (int)(n * 100); + if (p < 90) { - _kills = value; - this.MarkDirty(); - - var n = _kills / (double)MaxKills; - var p = (int)(n * 100); - - if (p < 90) - { - SetWhiteSkullCount(p / 20); - } - - InvalidateProperties(); + SetWhiteSkullCount(p / 20); } } @@ -1162,11 +1182,6 @@ public partial class ChampionSpawn : Item foreach (var de in m.DamageEntries) { - if (de.HasExpired) - { - continue; - } - var damager = de.Damager; var master = damager.GetDamageMaster(m); diff --git a/Projects/UOContent/Engines/CannedEvil/ChampionTitleContext.cs b/Projects/UOContent/Engines/CannedEvil/ChampionTitleContext.cs index bfd00c0bf..644e2b965 100644 --- a/Projects/UOContent/Engines/CannedEvil/ChampionTitleContext.cs +++ b/Projects/UOContent/Engines/CannedEvil/ChampionTitleContext.cs @@ -51,9 +51,9 @@ public partial class ChampionTitleContext } [SerializableField(1)] + [SaveFlag(nameof(ShouldSerializeAbyss))] private ChampionTitle _abyss; - [SerializableFieldSaveFlag(1)] private bool ShouldSerializeAbyss() => _abyss != null; [CommandProperty(AccessLevel.GameMaster)] @@ -71,9 +71,9 @@ public partial class ChampionTitleContext } [SerializableField(2)] + [SaveFlag(nameof(ShouldSerializeArachnid))] private ChampionTitle _arachnid; - [SerializableFieldSaveFlag(2)] private bool ShouldSerializeArachnid() => _arachnid != null; [CommandProperty(AccessLevel.GameMaster)] @@ -91,9 +91,9 @@ public partial class ChampionTitleContext } [SerializableField(3)] + [SaveFlag(nameof(ShouldSerializeColdBlood))] private ChampionTitle _coldBlood; - [SerializableFieldSaveFlag(3)] private bool ShouldSerializeColdBlood() => _coldBlood != null; [CommandProperty(AccessLevel.GameMaster)] @@ -111,9 +111,9 @@ public partial class ChampionTitleContext } [SerializableField(4)] + [SaveFlag(nameof(ShouldSerializeForestLord))] private ChampionTitle _forestLord; - [SerializableFieldSaveFlag(4)] private bool ShouldSerializeForestLord() => _forestLord != null; [CommandProperty(AccessLevel.GameMaster)] @@ -131,9 +131,9 @@ public partial class ChampionTitleContext } [SerializableField(5)] + [SaveFlag(nameof(ShouldSerializeVerminHorde))] private ChampionTitle _verminHorde; - [SerializableFieldSaveFlag(5)] private bool ShouldSerializeVerminHorde() => _verminHorde != null; [CommandProperty(AccessLevel.GameMaster)] @@ -151,9 +151,9 @@ public partial class ChampionTitleContext } [SerializableField(6)] + [SaveFlag(nameof(ShouldSerializeUnholyTerror))] private ChampionTitle _unholyTerror; - [SerializableFieldSaveFlag(6)] private bool ShouldSerializeUnholyTerror() => _unholyTerror != null; [CommandProperty(AccessLevel.GameMaster)] @@ -171,9 +171,9 @@ public partial class ChampionTitleContext } [SerializableField(7)] + [SaveFlag(nameof(ShouldSerializeSleepingDragon))] private ChampionTitle _sleepingDragon; - [SerializableFieldSaveFlag(7)] private bool ShouldSerializeSleepingDragon() => _sleepingDragon != null; [CommandProperty(AccessLevel.GameMaster)] @@ -191,9 +191,9 @@ public partial class ChampionTitleContext } [SerializableField(8)] + [SaveFlag(nameof(ShouldSerializeCorrupt))] private ChampionTitle _corrupt; - [SerializableFieldSaveFlag(8)] private bool ShouldSerializeCorrupt() => _corrupt != null; [CommandProperty(AccessLevel.GameMaster)] @@ -211,9 +211,9 @@ public partial class ChampionTitleContext } [SerializableField(9)] + [SaveFlag(nameof(ShouldSerializeGlade))] private ChampionTitle _glade; - [SerializableFieldSaveFlag(9)] private bool ShouldSerializeGlade() => _glade != null; [CommandProperty(AccessLevel.GameMaster)] diff --git a/Projects/UOContent/Engines/CannedEvil/StarRoomGate.cs b/Projects/UOContent/Engines/CannedEvil/StarRoomGate.cs index a5426e7a5..88acd587e 100644 --- a/Projects/UOContent/Engines/CannedEvil/StarRoomGate.cs +++ b/Projects/UOContent/Engines/CannedEvil/StarRoomGate.cs @@ -3,15 +3,21 @@ using ModernUO.Serialization; namespace Server.Items; -[SerializationGenerator(1, false)] +[SerializationGenerator(2, false)] public partial class StarRoomGate : Moongate { + private void MigrateFrom(V1Content content) + { + _decays = content.Decays; + _decayTime = content.DecayTime; + } + private static TimeSpan GateDuration = TimeSpan.FromMinutes(2.0); [SerializableField(0)] private bool _decays; - [DeltaDateTime] + [AnchoredDateTime] [SerializableField(1)] private DateTime _decayTime; diff --git a/Projects/UOContent/Engines/ConPVP/Games/BombingRun.cs b/Projects/UOContent/Engines/ConPVP/Games/BombingRun.cs index f32168cbf..6ab4fffea 100644 --- a/Projects/UOContent/Engines/ConPVP/Games/BombingRun.cs +++ b/Projects/UOContent/Engines/ConPVP/Games/BombingRun.cs @@ -830,10 +830,9 @@ public partial class BRBomb : Item [SerializationGenerator(0, false)] public partial class BRGoal : BaseAddon { - [SerializableField(0)] + [SerializableField(0, fieldChanged: nameof(OnNorthChanged))] private bool _north; - [SerializableFieldChanged(0)] [MethodImpl(MethodImplOptions.AggressiveInlining)] private void OnNorthChanged(bool oldValue, bool newValue) => Remake(); diff --git a/Projects/UOContent/Engines/ConPVP/Games/KingOfTheHill.cs b/Projects/UOContent/Engines/ConPVP/Games/KingOfTheHill.cs index 6f73a0949..6aec6f328 100644 --- a/Projects/UOContent/Engines/ConPVP/Games/KingOfTheHill.cs +++ b/Projects/UOContent/Engines/ConPVP/Games/KingOfTheHill.cs @@ -252,10 +252,9 @@ public partial class HillOfTheKing : Item public partial class KHBoard : Item { [SerializedCommandProperty(AccessLevel.GameMaster)] - [SerializableField(0)] + [SerializableField(0, fieldChanged: nameof(OnControllerChanged))] private KHController _controller; - [SerializableFieldChanged(0)] private void OnControllerChanged(KHController oldValue, KHController newValue) { oldValue?.RemoveBoard(this); diff --git a/Projects/UOContent/Engines/ConPVP/Trophy.cs b/Projects/UOContent/Engines/ConPVP/Trophy.cs index fa712de33..603b52ef3 100644 --- a/Projects/UOContent/Engines/ConPVP/Trophy.cs +++ b/Projects/UOContent/Engines/ConPVP/Trophy.cs @@ -64,17 +64,13 @@ public partial class Trophy : Item UpdateStyle(); } - [SerializableProperty(1)] - [CommandProperty(AccessLevel.GameMaster)] - public TrophyRank Rank + [SerializableField(1, fieldChanged: nameof(OnRankChanged))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private TrophyRank _rank; + + private void OnRankChanged(TrophyRank oldValue, TrophyRank newValue) { - get => _rank; - set - { - _rank = value; - UpdateStyle(); - this.MarkDirty(); - } + UpdateStyle(); } private void Deserialize(IGenericReader reader, int version) diff --git a/Projects/UOContent/Engines/Ethics/Core/Player.cs b/Projects/UOContent/Engines/Ethics/Core/Player.cs index f21fe9eb9..d8aad9eb3 100644 --- a/Projects/UOContent/Engines/Ethics/Core/Player.cs +++ b/Projects/UOContent/Engines/Ethics/Core/Player.cs @@ -5,9 +5,20 @@ using Server.Mobiles; namespace Server.Ethics; [PropertyObject] -[SerializationGenerator(1)] +[SerializationGenerator(2)] public partial class Player : EthicsEntity { + private void MigrateFrom(V1Content content) + { + _mobile = content.Mobile; + _power = content.Power; + _history = content.History; + _steed = content.Steed; + _familiar = content.Familiar; + _shield = content.Shield; + _ethic = content.Ethic; + } + [SerializableField(0, setter: "private")] private Mobile _mobile; @@ -27,7 +38,7 @@ public partial class Player : EthicsEntity [SerializedCommandProperty(AccessLevel.GameMaster, AccessLevel.Administrator)] private Mobile _familiar; - [DeltaDateTime] + [AnchoredDateTime] [SerializableField(5, setter: "private")] private DateTime _shield; diff --git a/Projects/UOContent/Engines/Factions/Mobiles/Guards/GuardAI.cs b/Projects/UOContent/Engines/Factions/Mobiles/Guards/GuardAI.cs index 32e8566cb..f0fb2386f 100644 --- a/Projects/UOContent/Engines/Factions/Mobiles/Guards/GuardAI.cs +++ b/Projects/UOContent/Engines/Factions/Mobiles/Guards/GuardAI.cs @@ -359,14 +359,14 @@ namespace Server.Factions { if (m_Mobile.InRange( m, 1 )) RunFrom( m ); - else if (!m_Mobile.InRange( m, m_Mobile.RangeFight > 2 ? m_Mobile.RangeFight : 2 ) && !MoveTo( m, true, 1 )) + else if (!m_Mobile.InRange( m, m_Mobile.RangeFight > 2 ? m_Mobile.RangeFight : 2 ) && !MoveTo(m, 1)) OnFailedMove(); } else {*/ if (!Mobile.InRange(m, Mobile.RangeFight)) { - if (!MoveTo(m, true, 1)) + if (!MoveTo(m, 1)) { OnFailedMove(); } diff --git a/Projects/UOContent/Engines/Harvest/Core/HarvestSystem.cs b/Projects/UOContent/Engines/Harvest/Core/HarvestSystem.cs index b82040134..b67665d04 100644 --- a/Projects/UOContent/Engines/Harvest/Core/HarvestSystem.cs +++ b/Projects/UOContent/Engines/Harvest/Core/HarvestSystem.cs @@ -219,7 +219,7 @@ namespace Server.Engines.Harvest } else { - item.Delete(); + bonusItem?.Delete(); } } diff --git a/Projects/UOContent/Engines/Khaldun/PuzzleChest.cs b/Projects/UOContent/Engines/Khaldun/PuzzleChest.cs index 4548522b4..53932c8f0 100644 --- a/Projects/UOContent/Engines/Khaldun/PuzzleChest.cs +++ b/Projects/UOContent/Engines/Khaldun/PuzzleChest.cs @@ -162,10 +162,15 @@ namespace Server.Items } } - [SerializationGenerator(0)] + [SerializationGenerator(1)] public partial class PuzzleChestSolutionAndTime : PuzzleChestSolution { - [DeltaDateTime] + private void MigrateFrom(V0Content content) + { + _when = content.When; + } + + [AnchoredDateTime] [SerializableField(0)] private DateTime _when; @@ -237,16 +242,12 @@ namespace Server.Items } } - [SerializableProperty(0)] - public PuzzleChestSolution Solution + [SerializableField(0, fieldChanged: nameof(OnSolutionChanged))] + private PuzzleChestSolution _solution; + + private void OnSolutionChanged(PuzzleChestSolution oldValue, PuzzleChestSolution newValue) { - get => _solution; - set - { - _solution = value; - InitHints(); - this.MarkDirty(); - } + InitHints(); } public PuzzleChestCylinder FirstHint diff --git a/Projects/UOContent/Engines/ML Quests/MLQuestPersistence.cs b/Projects/UOContent/Engines/ML Quests/MLQuestPersistence.cs index 5d846d595..d3e39c09f 100644 --- a/Projects/UOContent/Engines/ML Quests/MLQuestPersistence.cs +++ b/Projects/UOContent/Engines/ML Quests/MLQuestPersistence.cs @@ -19,7 +19,7 @@ namespace Server.Engines.MLQuests { base.Serialize(writer); - writer.Write(2); // version + writer.Write(3); // version writer.Write(MLQuestSystem.Contexts.Count); foreach (var context in MLQuestSystem.Contexts.Values) diff --git a/Projects/UOContent/Engines/ML Quests/Objectives/BaseObjective.cs b/Projects/UOContent/Engines/ML Quests/Objectives/BaseObjective.cs index 2838c7736..77c3350f5 100644 --- a/Projects/UOContent/Engines/ML Quests/Objectives/BaseObjective.cs +++ b/Projects/UOContent/Engines/ML Quests/Objectives/BaseObjective.cs @@ -119,7 +119,7 @@ namespace Server.Engines.MLQuests.Objectives if (IsTimed) { writer.Write(true); - writer.WriteDeltaTime(EndTime); + writer.WriteAnchoredTime(EndTime); } else { @@ -135,7 +135,7 @@ namespace Server.Engines.MLQuests.Objectives { if (reader.ReadBool()) { - var endTime = reader.ReadDeltaTime(); + var endTime = version >= 3 ? reader.ReadAnchoredTime() : reader.ReadDeltaTime(); if (objInstance != null) { diff --git a/Projects/UOContent/Engines/Pathing/PathFollower.cs b/Projects/UOContent/Engines/Pathing/PathFollower.cs index 3a0a56fa5..04aaf0148 100644 --- a/Projects/UOContent/Engines/Pathing/PathFollower.cs +++ b/Projects/UOContent/Engines/Pathing/PathFollower.cs @@ -83,7 +83,7 @@ public class PathFollower public static bool Check(Point3D loc, Point3D goal, int range) => Utility.InRange(loc, goal, range) && (range > 1 || (loc.Z - goal.Z).Abs() < 16); - public bool Follow(bool run, int range) + public bool Follow(int range) { var goal = GetGoalLocation(); Direction d; @@ -97,13 +97,13 @@ public class PathFollower if (!(Enabled && m_Path.Success)) { - d = m_From.GetDirectionTo(goal, run); + d = m_From.GetDirectionTo(goal); m_From.SetDirection(d); return Move(d) is MoveResult.Success or MoveResult.SuccessAutoTurn && Check(m_From.Location, goal, range); } - d = m_From.GetDirectionTo(m_Next, run); + d = m_From.GetDirectionTo(m_Next); m_From.SetDirection(d); var res = Move(d); diff --git a/Projects/UOContent/Engines/Plants/PlantItem.cs b/Projects/UOContent/Engines/Plants/PlantItem.cs index 60451f53b..5f66b1a7a 100644 --- a/Projects/UOContent/Engines/Plants/PlantItem.cs +++ b/Projects/UOContent/Engines/Plants/PlantItem.cs @@ -37,17 +37,17 @@ public partial class PlantItem : Item, ISecurable [SerializedIgnoreDupe] [SerializableField(0)] + [SaveFlag(nameof(ShouldSerializeSecureLevel))] [SerializedCommandProperty(AccessLevel.GameMaster)] private SecureLevel _level; - [SerializableFieldSaveFlag(0)] private bool ShouldSerializeSecureLevel() => (int)_level != 0; [SerializedIgnoreDupe] [SerializableField(5, setter: "private")] + [SaveFlag(nameof(ShouldSerializePlantSystem))] private PlantSystem _plantSystem; - [SerializableFieldSaveFlag(5)] private bool ShouldSerializePlantSystem() => _plantStatus < PlantStatus.DecorativePlant; // For clients older than 7.0.12.0 @@ -82,6 +82,7 @@ public partial class PlantItem : Item, ISecurable [CommandProperty(AccessLevel.GameMaster)] [SerializableProperty(1)] + [SaveFlag(nameof(ShouldSerializePlantStatus))] public PlantStatus PlantStatus { get => _plantStatus; @@ -120,53 +121,38 @@ public partial class PlantItem : Item, ISecurable } } - [SerializableFieldSaveFlag(1)] private bool ShouldSerializePlantStatus() => _plantStatus != PlantStatus.BowlOfDirt; - [SerializableProperty(2)] - [CommandProperty(AccessLevel.GameMaster)] - public PlantType PlantType + [SerializableField(2, fieldChanged: nameof(OnPlantTypeChanged))] + [SaveFlag(nameof(ShouldSerializePlantType))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private PlantType _plantType; + + private void OnPlantTypeChanged(PlantType oldValue, PlantType newValue) { - get => _plantType; - set - { - _plantType = value; - Update(); - } + Update(); } - [SerializableFieldSaveFlag(2)] private bool ShouldSerializePlantType() => (int)_plantType != 0; - [SerializableProperty(3)] - [CommandProperty(AccessLevel.GameMaster)] - public PlantHue PlantHue + [SerializableField(3, fieldChanged: nameof(OnPlantHueChanged))] + [SaveFlag(nameof(ShouldSerializePlantHue))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private PlantHue _plantHue; + + private void OnPlantHueChanged(PlantHue oldValue, PlantHue newValue) { - get => _plantHue; - set - { - _plantHue = value; - Update(); - } + Update(); } - [SerializableFieldSaveFlag(3)] private bool ShouldSerializePlantHue() => _plantHue != PlantHue.None; - [SerializableProperty(4)] - [CommandProperty(AccessLevel.GameMaster)] - public bool ShowType - { - get => _showType; - set - { - _showType = value; - InvalidateProperties(); - this.MarkDirty(); - } - } + [SerializableField(4)] + [SaveFlag(nameof(ShouldSerializeShowType))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + [InvalidateProperties] + private bool _showType; - [SerializableFieldSaveFlag(4)] private bool ShouldSerializeShowType() => _showType; [CommandProperty(AccessLevel.GameMaster)] diff --git a/Projects/UOContent/Engines/Plants/PlantSystem.cs b/Projects/UOContent/Engines/Plants/PlantSystem.cs index 29b9f48be..4b202ef63 100644 --- a/Projects/UOContent/Engines/Plants/PlantSystem.cs +++ b/Projects/UOContent/Engines/Plants/PlantSystem.cs @@ -33,24 +33,24 @@ namespace Server.Engines.Plants private PlantItem _plant; [SerializableField(0)] + [SaveFlag(nameof(ShouldSerializeFertileDirt))] private bool _fertileDirt; - [SerializableFieldSaveFlag(0)] private bool ShouldSerializeFertileDirt() => _fertileDirt; [SerializableField(1)] private DateTime _nextGrowth; [SerializableField(2, setter: "private")] + [SaveFlag(nameof(ShouldSerializeGrowthIndicator))] private PlantGrowthIndicator _growthIndicator; - [SerializableFieldSaveFlag(2)] private bool ShouldSerializeGrowthIndicator() => _growthIndicator != PlantGrowthIndicator.None; [SerializableField(13)] + [SaveFlag(nameof(ShouldSerializePollinated))] private bool _pollinated; - [SerializableFieldSaveFlag(13)] private bool ShouldSerializePollinated() => _pollinated; public PlantSystem(PlantItem plant) @@ -97,45 +97,42 @@ namespace Server.Engines.Plants public bool IsFullWater => _water >= 4; - [SerializableProperty(3)] - public int Water + [SerializableField(3, fieldChanged: nameof(OnWaterChanged), allowFieldChange: nameof(AllowWaterChange))] + [SaveFlag(nameof(ShouldSerializeWater))] + private int _water; + + private bool AllowWaterChange(ref int value) { - get => _water; - set - { - _water = Math.Clamp(value, 0, 4); - Plant.InvalidateProperties(); - MarkDirty(); - } + value = Math.Clamp(value, 0, 4); + return true; + } + + private void OnWaterChanged(int oldValue, int newValue) + { + Plant.InvalidateProperties(); } - [SerializableFieldSaveFlag(3)] private bool ShouldSerializeWater() => _water != 0; - [SerializableProperty(4)] - public int Hits + [SerializableField(4, fieldChanged: nameof(OnHitsChanged), allowFieldChange: nameof(AllowHitsChange))] + [SaveFlag(nameof(ShouldSerializeHits))] + private int _hits; + + private bool AllowHitsChange(ref int value) { - get => _hits; - set - { - if (_hits == value) - { - return; - } - - _hits = Math.Clamp(value, 0, MaxHits); - - if (_hits == 0) - { - Plant.Die(); - } - - Plant.InvalidateProperties(); - MarkDirty(); - } + value = Math.Clamp(value, 0, MaxHits); + return true; + } + + private void OnHitsChanged(int oldValue, int newValue) + { + if (_hits == 0) + { + Plant.Die(); + } + Plant.InvalidateProperties(); } - [SerializableFieldSaveFlag(4)] private bool ShouldSerializeHits() => _hits != 0; public int MaxHits => 10 + (int)Plant.PlantStatus * 2; @@ -149,124 +146,108 @@ namespace Server.Engines.Plants _ => PlantHealth.Vibrant }; - [SerializableProperty(5)] - public int Infestation + [SerializableField(5, allowFieldChange: nameof(AllowInfestationChange))] + [SaveFlag(nameof(ShouldSerializeInfestation))] + private int _infestation; + + private bool AllowInfestationChange(ref int value) { - get => _infestation; - set - { - _infestation = Math.Clamp(value, 0, 2); - MarkDirty(); - } + value = Math.Clamp(value, 0, 2); + return true; } - [SerializableFieldSaveFlag(5)] private bool ShouldSerializeInfestation() => _infestation != 0; - [SerializableProperty(6)] - public int Fungus + [SerializableField(6, allowFieldChange: nameof(AllowFungusChange))] + [SaveFlag(nameof(ShouldSerializeFungus))] + private int _fungus; + + private bool AllowFungusChange(ref int value) { - get => _fungus; - set - { - _fungus = Math.Clamp(value, 0, 2); - MarkDirty(); - } + value = Math.Clamp(value, 0, 2); + return true; } - [SerializableFieldSaveFlag(6)] private bool ShouldSerializeFungus() => _fungus != 0; - [SerializableProperty(7)] - public int Poison + [SerializableField(7, allowFieldChange: nameof(AllowPoisonChange))] + [SaveFlag(nameof(ShouldSerializePoison))] + private int _poison; + + private bool AllowPoisonChange(ref int value) { - get => _poison; - set - { - _poison = Math.Clamp(value, 0, 2); - MarkDirty(); - } + value = Math.Clamp(value, 0, 2); + return true; } - [SerializableFieldSaveFlag(7)] private bool ShouldSerializePoison() => _poison != 0; - [SerializableProperty(8)] - public int Disease + [SerializableField(8, allowFieldChange: nameof(AllowDiseaseChange))] + [SaveFlag(nameof(ShouldSerializeDisease))] + private int _disease; + + private bool AllowDiseaseChange(ref int value) { - get => _disease; - set - { - _disease = Math.Clamp(value, 0, 2); - MarkDirty(); - } + value = Math.Clamp(value, 0, 2); + return true; } - [SerializableFieldSaveFlag(8)] private bool ShouldSerializeDisease() => _disease != 0; public bool IsFullPoisonPotion => _poisonPotion >= 2; - [SerializableProperty(9)] - public int PoisonPotion + [SerializableField(9, allowFieldChange: nameof(AllowPoisonPotionChange))] + [SaveFlag(nameof(ShouldSerializePoisonPotion))] + private int _poisonPotion; + + private bool AllowPoisonPotionChange(ref int value) { - get => _poisonPotion; - set - { - _poisonPotion = Math.Clamp(value, 0, 2); - MarkDirty(); - } + value = Math.Clamp(value, 0, 2); + return true; } - [SerializableFieldSaveFlag(9)] private bool ShouldSerializePoisonPotion() => _poisonPotion != 0; public bool IsFullCurePotion => _curePotion >= 2; - [SerializableProperty(10)] - public int CurePotion + [SerializableField(10, allowFieldChange: nameof(AllowCurePotionChange))] + [SaveFlag(nameof(ShouldSerializeCurePotion))] + private int _curePotion; + + private bool AllowCurePotionChange(ref int value) { - get => _curePotion; - set - { - _curePotion = Math.Clamp(value, 0, 2); - MarkDirty(); - } + value = Math.Clamp(value, 0, 2); + return true; } - [SerializableFieldSaveFlag(10)] private bool ShouldSerializeCurePotion() => _curePotion != 0; public bool IsFullHealPotion => _healPotion >= 2; - [SerializableProperty(11)] - public int HealPotion + [SerializableField(11, allowFieldChange: nameof(AllowHealPotionChange))] + [SaveFlag(nameof(ShouldSerializeHealPotion))] + private int _healPotion; + + private bool AllowHealPotionChange(ref int value) { - get => _healPotion; - set - { - _healPotion = Math.Clamp(value, 0, 2); - MarkDirty(); - } + value = Math.Clamp(value, 0, 2); + return true; } - [SerializableFieldSaveFlag(11)] private bool ShouldSerializeHealPotion() => _healPotion != 0; public bool IsFullStrengthPotion => _strengthPotion >= 2; - [SerializableProperty(12)] - public int StrengthPotion + [SerializableField(12, allowFieldChange: nameof(AllowStrengthPotionChange))] + [SaveFlag(nameof(ShouldSerializeStrengthPotion))] + private int _strengthPotion; + + private bool AllowStrengthPotionChange(ref int value) { - get => _strengthPotion; - set - { - _strengthPotion = Math.Clamp(value, 0, 2); - MarkDirty(); - } + value = Math.Clamp(value, 0, 2); + return true; } - [SerializableFieldSaveFlag(12)] private bool ShouldSerializeStrengthPotion() => _strengthPotion != 0; public bool HasMaladies => Infestation > 0 || Fungus > 0 || Poison > 0 || Disease > 0 || Water != 2; @@ -274,6 +255,7 @@ namespace Server.Engines.Plants public bool PollenProducing => Plant.IsCrossable && Plant.PlantStatus >= PlantStatus.FullGrownPlant; [SerializableProperty(14)] + [SaveFlag(nameof(ShouldSerializeSeedType))] public PlantType SeedType { get => Pollinated ? _seedType : Plant.PlantType; @@ -284,10 +266,10 @@ namespace Server.Engines.Plants } } - [SerializableFieldSaveFlag(14)] private bool ShouldSerializeSeedType() => _pollinated; [SerializableProperty(15)] + [SaveFlag(nameof(ShouldSerializeSeedHue))] public PlantHue SeedHue { get => Pollinated ? _seedHue : Plant.PlantHue; @@ -298,53 +280,58 @@ namespace Server.Engines.Plants } } - [SerializableFieldSaveFlag(15)] private bool ShouldSerializeSeedHue() => _pollinated; - [SerializableProperty(16)] - public int AvailableSeeds + [SerializableField(16, allowFieldChange: nameof(AllowAvailableSeedsChange))] + [SaveFlag(nameof(ShouldSerializeAvailableSeeds))] + private int _availableSeeds; + + private bool AllowAvailableSeedsChange(ref int value) { - get => _availableSeeds; - set => _availableSeeds = Math.Max(value, 0); + value = Math.Max(value, 0); + return true; } - [SerializableFieldSaveFlag(16)] private bool ShouldSerializeAvailableSeeds() => _availableSeeds != 0; - [SerializableProperty(17)] - public int LeftSeeds + [SerializableField(17, allowFieldChange: nameof(AllowLeftSeedsChange))] + [SaveFlag(nameof(ShouldSerializeLeftSeeds), nameof(LeftSeedsDefaultValue))] + private int _leftSeeds; + + private bool AllowLeftSeedsChange(ref int value) { - get => _leftSeeds; - set => _leftSeeds = Math.Max(value, 0); + value = Math.Max(value, 0); + return true; } - [SerializableFieldSaveFlag(17)] private bool ShouldSerializeLeftSeeds() => _leftSeeds != 8; - [SerializableFieldDefault(17)] private int LeftSeedsDefaultValue() => 8; - [SerializableProperty(18)] - public int AvailableResources + [SerializableField(18, allowFieldChange: nameof(AllowAvailableResourcesChange))] + [SaveFlag(nameof(ShouldSerializeAvailableResources))] + private int _availableResources; + + private bool AllowAvailableResourcesChange(ref int value) { - get => _availableResources; - set => _availableResources = Math.Max(value, 0); + value = Math.Max(value, 0); + return true; } - [SerializableFieldSaveFlag(18)] private bool ShouldSerializeAvailableResources() => _availableResources != 0; - [SerializableProperty(19)] - public int LeftResources + [SerializableField(19, allowFieldChange: nameof(AllowLeftResourcesChange))] + [SaveFlag(nameof(ShouldSerializeLeftResources), nameof(LeftResourcesDefaultValue))] + private int _leftResources; + + private bool AllowLeftResourcesChange(ref int value) { - get => _leftResources; - set => _leftResources = Math.Max(value, 0); + value = Math.Max(value, 0); + return true; } - [SerializableFieldSaveFlag(19)] private bool ShouldSerializeLeftResources() => _leftResources != 8; - [SerializableFieldDefault(19)] private int LeftResourcesDefaultValue() => 8; public void Reset(bool potions) diff --git a/Projects/UOContent/Engines/Plants/Seed.cs b/Projects/UOContent/Engines/Plants/Seed.cs index 24a290f0b..ed6f13b7b 100644 --- a/Projects/UOContent/Engines/Plants/Seed.cs +++ b/Projects/UOContent/Engines/Plants/Seed.cs @@ -35,18 +35,14 @@ public partial class Seed : Item public override double DefaultWeight => 1.0; - [CommandProperty(AccessLevel.GameMaster)] - [SerializableProperty(1)] - public PlantHue PlantHue + [SerializableField(1, fieldChanged: nameof(OnPlantHueChanged))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + [InvalidateProperties] + private PlantHue _plantHue; + + private void OnPlantHueChanged(PlantHue oldValue, PlantHue newValue) { - get => _plantHue; - set - { - _plantHue = value; - Hue = PlantHueInfo.GetInfo(value).Hue; - InvalidateProperties(); - this.MarkDirty(); - } + Hue = PlantHueInfo.GetInfo(newValue).Hue; } public override int LabelNumber => 1060810; // seed diff --git a/Projects/UOContent/Engines/Player Murder System/MurderContext.cs b/Projects/UOContent/Engines/Player Murder System/MurderContext.cs index cecd7bf7e..384c91c98 100644 --- a/Projects/UOContent/Engines/Player Murder System/MurderContext.cs +++ b/Projects/UOContent/Engines/Player Murder System/MurderContext.cs @@ -16,12 +16,14 @@ public partial class MurderContext [SerializedCommandProperty(AccessLevel.GameMaster)] private TimeSpan _longTermElapse; - [SerializableProperty(2)] - [CommandProperty(AccessLevel.GameMaster)] - public int ShortTermMurders + [SerializableField(2, allowFieldChange: nameof(AllowShortTermMurdersChange))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private int _shortTermMurders; + + private bool AllowShortTermMurdersChange(ref int value) { - get => _shortTermMurders; - set => _shortTermMurders = Math.Max(value, 0); + value = Math.Max(value, 0); + return true; } [SerializableField(3)] diff --git a/Projects/UOContent/Engines/Quests/The Summoning/Items/SummoningAltar.cs b/Projects/UOContent/Engines/Quests/The Summoning/Items/SummoningAltar.cs index d5ec6f24a..4e3e5e237 100644 --- a/Projects/UOContent/Engines/Quests/The Summoning/Items/SummoningAltar.cs +++ b/Projects/UOContent/Engines/Quests/The Summoning/Items/SummoningAltar.cs @@ -12,16 +12,12 @@ public partial class SummoningAltar : AbbatoirAddon { } - [SerializableProperty(0)] - public BoneDemon Daemon + [SerializableField(0, fieldChanged: nameof(OnDaemonChanged))] + private BoneDemon _daemon; + + private void OnDaemonChanged(BoneDemon oldValue, BoneDemon newValue) { - get => _daemon; - set - { - _daemon = value; - CheckDaemon(); - this.MarkDirty(); - } + CheckDaemon(); } public void CheckDaemon() diff --git a/Projects/UOContent/Engines/Spawners/BaseSpawner.cs b/Projects/UOContent/Engines/Spawners/BaseSpawner.cs index 7ceeb00f6..8efc54b8c 100644 --- a/Projects/UOContent/Engines/Spawners/BaseSpawner.cs +++ b/Projects/UOContent/Engines/Spawners/BaseSpawner.cs @@ -54,10 +54,10 @@ public abstract partial class BaseSpawner : Item, ISpawner [SerializedCommandProperty(AccessLevel.Developer)] private Guid _guid; - [SerializableFieldSaveFlag(1)] private bool ShouldSerializeReturnOnDeactivate() => _returnOnDeactivate; [SerializableField(1)] + [SaveFlag(nameof(ShouldSerializeReturnOnDeactivate))] [SerializedCommandProperty(AccessLevel.Developer)] private bool _returnOnDeactivate; @@ -67,48 +67,46 @@ public abstract partial class BaseSpawner : Item, ISpawner private int _walkingRange = -1; - [SerializableFieldSaveFlag(4)] private bool ShouldSerializeWayPoint() => _wayPoint != null; [SerializableField(4)] + [SaveFlag(nameof(ShouldSerializeWayPoint))] [SerializedCommandProperty(AccessLevel.Developer)] private WayPoint _wayPoint; - [SerializableFieldSaveFlag(5)] private bool ShouldSerializeGroup() => _group; [InvalidateProperties] [SerializableField(5)] + [SaveFlag(nameof(ShouldSerializeGroup))] [SerializedCommandProperty(AccessLevel.Developer)] private bool _group; - [SerializableFieldSaveFlag(6)] private bool ShouldSerializeMinDelay() => _minDelay != DefaultMinDelay; - [SerializableFieldDefault(6)] private TimeSpan MinDelayDefault() => DefaultMinDelay; [InvalidateProperties] [SerializableField(6)] + [SaveFlag(nameof(ShouldSerializeMinDelay), nameof(MinDelayDefault))] [SerializedCommandProperty(AccessLevel.Developer)] private TimeSpan _minDelay; - [SerializableFieldSaveFlag(7)] private bool ShouldSerializeMaxDelay() => _maxDelay != DefaultMaxDelay; - [SerializableFieldDefault(7)] private TimeSpan MaxDelayDefault() => DefaultMaxDelay; [InvalidateProperties] [SerializableField(7)] + [SaveFlag(nameof(ShouldSerializeMaxDelay), nameof(MaxDelayDefault))] [SerializedCommandProperty(AccessLevel.Developer)] private TimeSpan _maxDelay; - [SerializableFieldSaveFlag(9)] private bool ShouldSerializeTeam() => _team != 0; [InvalidateProperties] [SerializableField(9)] + [SaveFlag(nameof(ShouldSerializeTeam))] [SerializedCommandProperty(AccessLevel.Developer)] private int _team; @@ -125,29 +123,29 @@ public abstract partial class BaseSpawner : Item, ISpawner /// If true, the home location of the spawn is the location where it spawned /// If false, the home location of the spawn is the location of the spawner /// - [SerializableFieldSaveFlag(11)] private bool ShouldSerializeSpawnLocationIsHome() => _spawnLocationIsHome; [InvalidateProperties] [SerializableField(11)] + [SaveFlag(nameof(ShouldSerializeSpawnLocationIsHome))] [SerializedCommandProperty(AccessLevel.Developer)] private bool _spawnLocationIsHome; - [SerializableFieldSaveFlag(12)] private bool ShouldSerializeEnd() => _end != default; [SerializableField(12)] + [SaveFlag(nameof(ShouldSerializeEnd))] [SerializedCommandProperty(AccessLevel.Developer)] private DateTime _end; /// /// Controls how spawn position optimization is handled. /// - [SerializableFieldSaveFlag(13)] private bool ShouldSerializeSpawnPositionMode() => _spawnPositionMode is not SpawnPositionMode.Automatic and not SpawnPositionMode.Abandoned; [SerializableField(13)] + [SaveFlag(nameof(ShouldSerializeSpawnPositionMode))] [SerializedCommandProperty(AccessLevel.Developer)] private SpawnPositionMode _spawnPositionMode; @@ -156,13 +154,12 @@ public abstract partial class BaseSpawner : Item, ISpawner /// /// Maximum number of random position attempts before engaging optimization. /// - [SerializableFieldSaveFlag(14)] private bool ShouldSerializeMaxSpawnAttempts() => _maxSpawnAttempts != DefaultMaxSpawnAttempts; - [SerializableFieldDefault(14)] private int MaxSpawnAttemptsDefault() => DefaultMaxSpawnAttempts; [SerializableField(14)] + [SaveFlag(nameof(ShouldSerializeMaxSpawnAttempts), nameof(MaxSpawnAttemptsDefault))] [SerializedCommandProperty(AccessLevel.Developer)] private int _maxSpawnAttempts; @@ -314,26 +311,20 @@ public abstract partial class BaseSpawner : Item, ISpawner } } - [SerializableProperty(8)] - [CommandProperty(AccessLevel.Developer)] - public int Count + [SerializableField(8, fieldChanged: nameof(OnCountChanged))] + [SerializedCommandProperty(AccessLevel.Developer)] + [InvalidateProperties] + private int _count; + + private void OnCountChanged(int oldValue, int newValue) { - get => _count; - set + if (IsFull) { - _count = value; - - if (IsFull) - { - _timer?.Stop(); - } - else if (_timer?.Running != true) - { - DoTimer(); - } - - InvalidateProperties(); - this.MarkDirty(); + _timer?.Stop(); + } + else if (_timer?.Running != true) + { + DoTimer(); } } diff --git a/Projects/UOContent/Engines/Spawners/Spawner.cs b/Projects/UOContent/Engines/Spawners/Spawner.cs index 7c07d2681..db25cab55 100644 --- a/Projects/UOContent/Engines/Spawners/Spawner.cs +++ b/Projects/UOContent/Engines/Spawners/Spawner.cs @@ -10,17 +10,17 @@ public partial class Spawner : BaseSpawner /// When true, enables proactive spiral scanning to find valid spawn positions. /// Only relevant when SpawnPositionMode is Automatic or Enabled. /// - [SerializableFieldSaveFlag(0)] private bool ShouldSerializeUseSpiralScan() => _useSpiralScan; [SerializableField(0)] + [SaveFlag(nameof(ShouldSerializeUseSpiralScan))] [SerializedCommandProperty(AccessLevel.Developer)] private bool _useSpiralScan; - [SerializableFieldSaveFlag(1)] private bool ShouldSerializeSpawnBounds() => _spawnBounds != default; [SerializableProperty(1)] + [SaveFlag(nameof(ShouldSerializeSpawnBounds))] [CommandProperty(AccessLevel.Developer)] public override Rectangle3D SpawnBounds { diff --git a/Projects/UOContent/Engines/Stealables/StealableArtifacts.cs b/Projects/UOContent/Engines/Stealables/StealableArtifacts.cs index 396b87dcd..4cd8d8c63 100644 --- a/Projects/UOContent/Engines/Stealables/StealableArtifacts.cs +++ b/Projects/UOContent/Engines/Stealables/StealableArtifacts.cs @@ -248,7 +248,7 @@ public class StealableArtifacts : GenericPersistence public override void Serialize(IGenericWriter writer) { - writer.WriteEncodedInt(1); // version + writer.WriteEncodedInt(2); // version writer.Write(_enabled); @@ -261,7 +261,7 @@ public class StealableArtifacts : GenericPersistence var si = _artifacts[i]; writer.Write(si.Item); - writer.WriteDeltaTime(si.NextRespawn); + writer.WriteAnchoredTime(si.NextRespawn); } } } @@ -282,7 +282,7 @@ public class StealableArtifacts : GenericPersistence for (var i = 0; i < length; i++) { var item = reader.ReadEntity(); - var nextRespawn = reader.ReadDeltaTime(); + var nextRespawn = version >= 2 ? reader.ReadAnchoredTime() : reader.ReadDeltaTime(); if (i < _artifacts.Length) { diff --git a/Projects/UOContent/Engines/Treasures of Tokuno/GreaterArtifacts.cs b/Projects/UOContent/Engines/Treasures of Tokuno/GreaterArtifacts.cs index 005688e1e..c42cfae4e 100644 --- a/Projects/UOContent/Engines/Treasures of Tokuno/GreaterArtifacts.cs +++ b/Projects/UOContent/Engines/Treasures of Tokuno/GreaterArtifacts.cs @@ -332,27 +332,22 @@ public partial class PigmentsOfTokuno : BasePigmentsOfTokuno [Constructible] public PigmentsOfTokuno(PigmentType type, int uses) : base(uses) => Type = type; - [SerializableProperty(0)] - [CommandProperty(AccessLevel.GameMaster)] - public PigmentType Type + [SerializableField(0, fieldChanged: nameof(OnTypeChanged))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private PigmentType _type; + + private void OnTypeChanged(PigmentType oldValue, PigmentType newValue) { - get => _type; - set + var v = (int)_type; + if (v >= 0 && v < _table.Length) { - _type = value; - - var v = (int)_type; - - if (v >= 0 && v < _table.Length) - { - Hue = _table[v][0]; - Label = _table[v][1]; - } - else - { - Hue = 0; - Label = -1; - } + Hue = _table[v][0]; + Label = _table[v][1]; + } + else + { + Hue = 0; + Label = -1; } } diff --git a/Projects/UOContent/Engines/Treasures of Tokuno/LesserArtifacts.cs b/Projects/UOContent/Engines/Treasures of Tokuno/LesserArtifacts.cs index 54d026df4..22c6af344 100644 --- a/Projects/UOContent/Engines/Treasures of Tokuno/LesserArtifacts.cs +++ b/Projects/UOContent/Engines/Treasures of Tokuno/LesserArtifacts.cs @@ -617,27 +617,22 @@ public partial class LesserPigmentsOfTokuno : BasePigmentsOfTokuno [Constructible] public LesserPigmentsOfTokuno(LesserPigmentType type) : base(1) => Type = type; - [SerializableProperty(0)] - [CommandProperty(AccessLevel.GameMaster)] - public LesserPigmentType Type + [SerializableField(0, fieldChanged: nameof(OnTypeChanged))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private LesserPigmentType _type; + + private void OnTypeChanged(LesserPigmentType oldValue, LesserPigmentType newValue) { - get => _type; - set + var v = (int)_type; + if (v >= 0 && v < _table.Length) { - _type = value; - - var v = (int)_type; - - if (v >= 0 && v < _table.Length) - { - Hue = _table[v][0]; - Label = _table[v][1]; - } - else - { - Hue = 0; - Label = -1; - } + Hue = _table[v][0]; + Label = _table[v][1]; + } + else + { + Hue = 0; + Label = -1; } } diff --git a/Projects/UOContent/Engines/Veteran Rewards/Character Statue Maker/CharacterStatue.cs b/Projects/UOContent/Engines/Veteran Rewards/Character Statue Maker/CharacterStatue.cs index ff665ec53..7a62cc735 100644 --- a/Projects/UOContent/Engines/Veteran Rewards/Character Statue Maker/CharacterStatue.cs +++ b/Projects/UOContent/Engines/Veteran Rewards/Character Statue Maker/CharacterStatue.cs @@ -79,45 +79,33 @@ public partial class CharacterStatue : Mobile, IRewardItem InvalidateHues(); } - [SerializableProperty(0)] - [CommandProperty(AccessLevel.GameMaster)] - public StatueType StatueType + [SerializableField(0, fieldChanged: nameof(OnStatueTypeChanged))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private StatueType _statueType; + + private void OnStatueTypeChanged(StatueType oldValue, StatueType newValue) { - get => _statueType; - set - { - _statueType = value; - InvalidateHues(); - InvalidatePose(); - this.MarkDirty(); - } + InvalidateHues(); + InvalidatePose(); } - [SerializableProperty(1)] - [CommandProperty(AccessLevel.GameMaster)] - public StatuePose Pose + [SerializableField(1, fieldChanged: nameof(OnPoseChanged))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private StatuePose _pose; + + private void OnPoseChanged(StatuePose oldValue, StatuePose newValue) { - get => _pose; - set - { - _pose = value; - InvalidatePose(); - this.MarkDirty(); - } + InvalidatePose(); } - [SerializableProperty(2)] - [CommandProperty(AccessLevel.GameMaster)] - public StatueMaterial Material + [SerializableField(2, fieldChanged: nameof(OnMaterialChanged))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private StatueMaterial _material; + + private void OnMaterialChanged(StatueMaterial oldValue, StatueMaterial newValue) { - get => _material; - set - { - _material = value; - InvalidateHues(); - InvalidatePose(); - this.MarkDirty(); - } + InvalidateHues(); + InvalidatePose(); } public override void OnDoubleClick(Mobile from) diff --git a/Projects/UOContent/Engines/Veteran Rewards/Character Statue Maker/CharacterStatueMaker.cs b/Projects/UOContent/Engines/Veteran Rewards/Character Statue Maker/CharacterStatueMaker.cs index a028b1c85..48ae30ae9 100644 --- a/Projects/UOContent/Engines/Veteran Rewards/Character Statue Maker/CharacterStatueMaker.cs +++ b/Projects/UOContent/Engines/Veteran Rewards/Character Statue Maker/CharacterStatueMaker.cs @@ -25,17 +25,13 @@ public partial class CharacterStatueMaker : Item, IRewardItem public override int LabelNumber => 1076173; // Character Statue Maker - [SerializableProperty(1)] - [CommandProperty(AccessLevel.GameMaster)] - public StatueType StatueType + [SerializableField(1, fieldChanged: nameof(OnStatueTypeChanged))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private StatueType _statueType; + + private void OnStatueTypeChanged(StatueType oldValue, StatueType newValue) { - get => _statueType; - set - { - _statueType = value; - InvalidateHue(); - this.MarkDirty(); - } + InvalidateHue(); } public override void OnDoubleClick(Mobile from) diff --git a/Projects/UOContent/Engines/Virtues/VirtueContext.cs b/Projects/UOContent/Engines/Virtues/VirtueContext.cs index 3b26b5294..7524f77ed 100644 --- a/Projects/UOContent/Engines/Virtues/VirtueContext.cs +++ b/Projects/UOContent/Engines/Virtues/VirtueContext.cs @@ -5,102 +5,121 @@ using Server.Mobiles; namespace Server.Engines.Virtues; [PropertyObject] -[SerializationGenerator(0)] +[SerializationGenerator(1)] public partial class VirtueContext { - [DeltaDateTime] + private void MigrateFrom(V0Content content) + { + // Save-flagged values arrive as nullables; unset flags fall back to the same + // defaults the old deserialize left in place. + _lastSacrificeGain = content.LastSacrificeGain ?? default; + _lastSacrificeLoss = content.LastSacrificeLoss ?? default; + _availableResurrects = content.AvailableResurrects ?? 0; + _lastJusticeLoss = content.LastJusticeLoss ?? default; + _lastCompassionLoss = content.LastCompassionLoss ?? default; + _nextCompassionDay = content.NextCompassionDay ?? default; + _compassionGains = content.CompassionGains ?? 0; + _lastValorLoss = content.LastValorLoss ?? default; + _lastHonorUse = content.LastHonorUse ?? default; + _honorActive = content.HonorActive; + _justiceProtection = content.JusticeProtection; + _justiceStatus = content.JusticeStatus ?? JusticeProtectorStatus.None; + _values = content.Values; + } + + [AnchoredDateTime] [SerializableField(0)] + [SaveFlag(nameof(ShouldSerializeLastSacrificeGain))] [SerializedCommandProperty(AccessLevel.GameMaster, AccessLevel.Administrator)] private DateTime _lastSacrificeGain; - [SerializableFieldSaveFlag(0)] private bool ShouldSerializeLastSacrificeGain() => !SacrificeVirtue.CanGain(this); - [DeltaDateTime] + [AnchoredDateTime] [SerializableField(1)] + [SaveFlag(nameof(ShouldSerializeLastSacrificeLoss))] [SerializedCommandProperty(AccessLevel.GameMaster, AccessLevel.Administrator)] private DateTime _lastSacrificeLoss; - [SerializableFieldSaveFlag(1)] private bool ShouldSerializeLastSacrificeLoss() => !SacrificeVirtue.CanAtrophy(this); [SerializableField(2)] + [SaveFlag(nameof(ShouldSerializeAvailableResurrects))] [SerializedCommandProperty(AccessLevel.GameMaster)] private int _availableResurrects; - [SerializableFieldSaveFlag(2)] private bool ShouldSerializeAvailableResurrects() => _availableResurrects > 0; - [DeltaDateTime] + [AnchoredDateTime] [SerializableField(3)] + [SaveFlag(nameof(ShouldSerializeLastJusticeLoss))] [SerializedCommandProperty(AccessLevel.GameMaster, AccessLevel.Administrator)] private DateTime _lastJusticeLoss; - [SerializableFieldSaveFlag(3)] private bool ShouldSerializeLastJusticeLoss() => !JusticeVirtue.CanAtrophy(this); - [DeltaDateTime] + [AnchoredDateTime] [SerializableField(4)] + [SaveFlag(nameof(ShouldSerializeLastCompassionLoss))] [SerializedCommandProperty(AccessLevel.GameMaster, AccessLevel.Administrator)] private DateTime _lastCompassionLoss; - [SerializableFieldSaveFlag(4)] private bool ShouldSerializeLastCompassionLoss() => !CompassionVirtue.CanAtrophy(this); - [DeltaDateTime] + [AnchoredDateTime] [SerializableField(5)] + [SaveFlag(nameof(ShouldSerializeNextCompassionDay))] [SerializedCommandProperty(AccessLevel.GameMaster)] private DateTime _nextCompassionDay; - [SerializableFieldSaveFlag(5)] private bool ShouldSerializeNextCompassionDay() => _nextCompassionDay > Core.Now; [SerializableField(6)] + [SaveFlag(nameof(ShouldSerializeCompassionGains))] [SerializedCommandProperty(AccessLevel.GameMaster)] private int _compassionGains; - [SerializableFieldSaveFlag(6)] private bool ShouldSerializeCompassionGains() => _compassionGains > 0; - [DeltaDateTime] + [AnchoredDateTime] [SerializableField(7)] + [SaveFlag(nameof(ShouldSerializeValorLoss))] [SerializedCommandProperty(AccessLevel.GameMaster, AccessLevel.Administrator)] private DateTime _lastValorLoss; - [SerializableFieldSaveFlag(7)] private bool ShouldSerializeValorLoss() => !ValorVirtue.CanAtrophy(this); - [DeltaDateTime] + [AnchoredDateTime] [SerializableField(8)] + [SaveFlag(nameof(ShouldSerializeLastHonorUse))] [SerializedCommandProperty(AccessLevel.GameMaster)] private DateTime _lastHonorUse; - [SerializableFieldSaveFlag(8)] private bool ShouldSerializeLastHonorUse() => !HonorVirtue.CanUse(this); [SerializableField(9)] + [SaveFlag(nameof(ShouldSerializeHonorActive))] [SerializedCommandProperty(AccessLevel.GameMaster, AccessLevel.Administrator)] private bool _honorActive; - [SerializableFieldSaveFlag(9)] private bool ShouldSerializeHonorActive() => _honorActive; [SerializableField(10)] + [SaveFlag(nameof(ShouldSerializeJusticeProtection))] private PlayerMobile _justiceProtection; - [SerializableFieldSaveFlag(10)] private bool ShouldSerializeJusticeProtection() => _justiceProtection != null && _justiceStatus != JusticeProtectorStatus.None; [SerializableField(11)] + [SaveFlag(nameof(ShouldSerializeJusticeStatus))] private JusticeProtectorStatus _justiceStatus; - [SerializableFieldSaveFlag(11)] private bool ShouldSerializeJusticeStatus() => _justiceProtection != null && _justiceStatus != JusticeProtectorStatus.None; [SerializableField(12, setter: "private")] + [SaveFlag(nameof(ShouldSerializeValues))] private int[] _values; - [SerializableFieldSaveFlag(12)] private bool ShouldSerializeValues() { if (_values == null) diff --git a/Projects/UOContent/Items/Addons/BaseAddon.cs b/Projects/UOContent/Items/Addons/BaseAddon.cs index 6b2abbc66..f2a662f6a 100644 --- a/Projects/UOContent/Items/Addons/BaseAddon.cs +++ b/Projects/UOContent/Items/Addons/BaseAddon.cs @@ -63,22 +63,14 @@ namespace Server.Items } } - [SerializableProperty(1)] - [CommandProperty(AccessLevel.GameMaster)] - public CraftResource Resource - { - get => _resource; - set - { - if (_resource != value) - { - _resource = value; - Hue = CraftResources.GetHue(_resource); + [SerializableField(1, fieldChanged: nameof(OnResourceChanged))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + [InvalidateProperties] + private CraftResource _resource; - InvalidateProperties(); - this.MarkDirty(); - } - } + private void OnResourceChanged(CraftResource oldValue, CraftResource newValue) + { + Hue = CraftResources.GetHue(_resource); } Item IAddon.Deed => Deed; diff --git a/Projects/UOContent/Items/Addons/BaseAddonContainer.cs b/Projects/UOContent/Items/Addons/BaseAddonContainer.cs index ad655c24e..8554ad4e5 100644 --- a/Projects/UOContent/Items/Addons/BaseAddonContainer.cs +++ b/Projects/UOContent/Items/Addons/BaseAddonContainer.cs @@ -41,22 +41,14 @@ namespace Server.Items } } - [SerializableProperty(1)] - [CommandProperty(AccessLevel.GameMaster)] - public CraftResource Resource - { - get => _resource; - set - { - if (_resource != value) - { - _resource = value; - Hue = CraftResources.GetHue(_resource); + [SerializableField(1, fieldChanged: nameof(OnResourceChanged))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + [InvalidateProperties] + private CraftResource _resource; - InvalidateProperties(); - this.MarkDirty(); - } - } + private void OnResourceChanged(CraftResource oldValue, CraftResource newValue) + { + Hue = CraftResources.GetHue(_resource); } public virtual bool RetainDeedHue => false; diff --git a/Projects/UOContent/Items/Addons/BaseAddonContainerDeed.cs b/Projects/UOContent/Items/Addons/BaseAddonContainerDeed.cs index c7e08e955..7d5014dee 100644 --- a/Projects/UOContent/Items/Addons/BaseAddonContainerDeed.cs +++ b/Projects/UOContent/Items/Addons/BaseAddonContainerDeed.cs @@ -23,22 +23,14 @@ public abstract partial class BaseAddonContainerDeed : Item, ICraftable public abstract BaseAddonContainer Addon { get; } - [SerializableProperty(0)] - [CommandProperty(AccessLevel.GameMaster)] - public CraftResource Resource - { - get => _resource; - set - { - if (_resource != value) - { - _resource = value; - Hue = CraftResources.GetHue(_resource); + [SerializableField(0, fieldChanged: nameof(OnResourceChanged))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + [InvalidateProperties] + private CraftResource _resource; - InvalidateProperties(); - this.MarkDirty(); - } - } + private void OnResourceChanged(CraftResource oldValue, CraftResource newValue) + { + Hue = CraftResources.GetHue(_resource); } public virtual int OnCraft( diff --git a/Projects/UOContent/Items/Addons/FlourMillEastAddon.cs b/Projects/UOContent/Items/Addons/FlourMillEastAddon.cs index f84c959cf..decf3ca65 100644 --- a/Projects/UOContent/Items/Addons/FlourMillEastAddon.cs +++ b/Projects/UOContent/Items/Addons/FlourMillEastAddon.cs @@ -48,17 +48,19 @@ namespace Server.Items [CommandProperty(AccessLevel.GameMaster)] public int MaxFlour => 2; - [SerializableProperty(0)] - [CommandProperty(AccessLevel.GameMaster)] - public int CurFlour + [SerializableField(0, fieldChanged: nameof(OnCurFlourChanged), allowFieldChange: nameof(AllowCurFlourChange))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private int _curFlour; + + private bool AllowCurFlourChange(ref int value) { - get => _curFlour; - set - { - _curFlour = Math.Clamp(value, 0, MaxFlour); - UpdateStage(); - this.MarkDirty(); - } + value = Math.Clamp(value, 0, MaxFlour); + return true; + } + + private void OnCurFlourChanged(int oldValue, int newValue) + { + UpdateStage(); } public void StartWorking(Mobile from) diff --git a/Projects/UOContent/Items/Addons/FlourMillSouthAddon.cs b/Projects/UOContent/Items/Addons/FlourMillSouthAddon.cs index cbd0e2cc3..fa48aa83c 100644 --- a/Projects/UOContent/Items/Addons/FlourMillSouthAddon.cs +++ b/Projects/UOContent/Items/Addons/FlourMillSouthAddon.cs @@ -35,16 +35,19 @@ namespace Server.Items [CommandProperty(AccessLevel.GameMaster)] public int MaxFlour => 2; - [SerializableProperty(0)] - [CommandProperty(AccessLevel.GameMaster)] - public int CurFlour + [SerializableField(0, fieldChanged: nameof(OnCurFlourChanged), allowFieldChange: nameof(AllowCurFlourChange))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private int _curFlour; + + private bool AllowCurFlourChange(ref int value) { - get => _curFlour; - set - { - _curFlour = Math.Max(0, Math.Min(value, MaxFlour)); - UpdateStage(); - } + value = Math.Max(0, Math.Min(value, MaxFlour)); + return true; + } + + private void OnCurFlourChanged(int oldValue, int newValue) + { + UpdateStage(); } public void StartWorking(Mobile from) diff --git a/Projects/UOContent/Items/Addons/SHTeleporter.cs b/Projects/UOContent/Items/Addons/SHTeleporter.cs index f34f9cb85..e6ce612b4 100644 --- a/Projects/UOContent/Items/Addons/SHTeleporter.cs +++ b/Projects/UOContent/Items/Addons/SHTeleporter.cs @@ -25,35 +25,27 @@ namespace Server.Items _teleOffset = offset; } - [SerializableProperty(0)] - [CommandProperty(AccessLevel.GameMaster)] - public bool Active - { - get => _active; - set - { - _active = value; + [SerializableField(0, fieldChanged: nameof(OnActiveChanged))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private bool _active; - if (Addon is SHTeleporter sourceAddon) - { - sourceAddon.ChangeActive(value); - } + private void OnActiveChanged(bool oldValue, bool newValue) + { + if (Addon is SHTeleporter sourceAddon) + { + sourceAddon.ChangeActive(newValue); } } - [SerializableProperty(1)] - [CommandProperty(AccessLevel.GameMaster)] - public SHTeleComponent TeleDest - { - get => _teleDest; - set - { - _teleDest = value; + [SerializableField(1, fieldChanged: nameof(OnTeleDestChanged))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private SHTeleComponent _teleDest; - if (Addon is SHTeleporter sourceAddon) - { - sourceAddon.ChangeDest(value); - } + private void OnTeleDestChanged(SHTeleComponent oldValue, SHTeleComponent newValue) + { + if (Addon is SHTeleporter sourceAddon) + { + sourceAddon.ChangeDest(newValue); } } diff --git a/Projects/UOContent/Items/Aquarium/Aquarium.cs b/Projects/UOContent/Items/Aquarium/Aquarium.cs index 749c677c2..054fea588 100644 --- a/Projects/UOContent/Items/Aquarium/Aquarium.cs +++ b/Projects/UOContent/Items/Aquarium/Aquarium.cs @@ -31,9 +31,9 @@ namespace Server.Items private bool m_EvaluateDay; [SerializableField(0, setter: "private")] + [DeserializeTimer(nameof(DeserializeEvaluateTimer), wallClock: true)] private Timer _evaluateTimer; - [DeserializeTimerField(0)] private void DeserializeEvaluateTimer(TimeSpan delay) { _evaluateTimer = Timer.DelayCall(delay, EvaluationInterval, Evaluate); diff --git a/Projects/UOContent/Items/Aquarium/AquariumState.cs b/Projects/UOContent/Items/Aquarium/AquariumState.cs index cdd59d9d9..75fe6fc6f 100644 --- a/Projects/UOContent/Items/Aquarium/AquariumState.cs +++ b/Projects/UOContent/Items/Aquarium/AquariumState.cs @@ -30,19 +30,14 @@ namespace Server.Items public AquariumState(Aquarium parent) => _aquarium = parent; - [SerializableProperty(0)] - [CommandProperty(AccessLevel.GameMaster)] - public int State + [SerializableField(0, allowFieldChange: nameof(AllowStateChange))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private int _state; + + private bool AllowStateChange(ref int value) { - get => _state; - set - { - if (_state != value) - { - _state = Math.Clamp(value, 0, 4); - MarkDirty(); - } - } + value = Math.Clamp(value, 0, 4); + return true; } [SerializableField(1)] diff --git a/Projects/UOContent/Items/Armor/BaseArmor.cs b/Projects/UOContent/Items/Armor/BaseArmor.cs index 6f91f3a71..f4062326d 100644 --- a/Projects/UOContent/Items/Armor/BaseArmor.cs +++ b/Projects/UOContent/Items/Armor/BaseArmor.cs @@ -18,95 +18,92 @@ namespace Server.Items { [SerializedIgnoreDupe] [SerializableField(0, setter: "private")] + [SaveFlag(nameof(ShouldSerializeAosAttributes), nameof(AttributesDefaultValue))] [SerializedCommandProperty(AccessLevel.GameMaster, canModify: true)] private AosAttributes _attributes; - [SerializableFieldSaveFlag(0)] private bool ShouldSerializeAosAttributes() => !_attributes.IsEmpty; - [SerializableFieldDefault(0)] private AosAttributes AttributesDefaultValue() => new(this); [SerializedIgnoreDupe] [SerializableField(1, setter: "private")] + [SaveFlag(nameof(ShouldSerializeArmorAttributes), nameof(ArmorAttributesDefaultValue))] [SerializedCommandProperty(AccessLevel.GameMaster, canModify: true)] private AosArmorAttributes _armorAttributes; - [SerializableFieldSaveFlag(1)] private bool ShouldSerializeArmorAttributes() => !_armorAttributes.IsEmpty; - [SerializableFieldDefault(1)] private AosArmorAttributes ArmorAttributesDefaultValue() => new(this); [EncodedInt] [InvalidateProperties] [SerializableField(2)] + [SaveFlag(nameof(ShouldSerializePhysicalBonus))] [SerializedCommandProperty(AccessLevel.GameMaster)] private int _physicalBonus; - [SerializableFieldSaveFlag(2)] private bool ShouldSerializePhysicalBonus() => _physicalBonus != 0; [EncodedInt] [InvalidateProperties] [SerializableField(3)] + [SaveFlag(nameof(ShouldSerializeFireBonus))] [SerializedCommandProperty(AccessLevel.GameMaster)] private int _fireBonus; - [SerializableFieldSaveFlag(3)] private bool ShouldSerializeFireBonus() => _fireBonus != 0; [EncodedInt] [InvalidateProperties] [SerializableField(4)] + [SaveFlag(nameof(ShouldSerializeColdBonus))] [SerializedCommandProperty(AccessLevel.GameMaster)] private int _coldBonus; - [SerializableFieldSaveFlag(4)] private bool ShouldSerializeColdBonus() => _coldBonus != 0; [EncodedInt] [InvalidateProperties] [SerializableField(5)] + [SaveFlag(nameof(ShouldSerializePoisonBonus))] [SerializedCommandProperty(AccessLevel.GameMaster)] private int _poisonBonus; - [SerializableFieldSaveFlag(5)] private bool ShouldSerializePoisonBonus() => _poisonBonus != 0; [EncodedInt] [InvalidateProperties] [SerializableField(6)] + [SaveFlag(nameof(ShouldSerializeEnergyBonus))] [SerializedCommandProperty(AccessLevel.GameMaster)] private int _energyBonus; - [SerializableFieldSaveFlag(6)] private bool ShouldSerializeEnergyBonus() => _energyBonus != 0; [SerializableField(7)] + [SaveFlag(nameof(ShouldSerializeIdentified))] [SerializedCommandProperty(AccessLevel.GameMaster)] private bool _identified; - [SerializableFieldSaveFlag(7)] private bool ShouldSerializeIdentified() => _identified; [EncodedInt] [SerializableField(8)] + [SaveFlag(nameof(ShouldSerializeMaxHitPoints))] [SerializedCommandProperty(AccessLevel.GameMaster)] private int _maxHitPoints; - [SerializableFieldSaveFlag(8)] private bool ShouldSerializeMaxHitPoints() => _maxHitPoints != 0; [InvalidateProperties] [SerializableField(10)] + [SaveFlag(nameof(ShouldSerializeCrafter))] [SerializedCommandProperty(AccessLevel.GameMaster)] private string _crafter; - [SerializableFieldSaveFlag(10)] private bool ShouldSerializeCrafter() => !string.IsNullOrEmpty(_crafter); - [SerializableFieldSaveFlag(14)] private bool ShouldSerializeResource() => _resource != DefaultResource; // Field 15 @@ -135,13 +132,12 @@ namespace Server.Items [SerializedIgnoreDupe] [SerializableField(23, setter: "private")] + [SaveFlag(nameof(ShouldSerializeSkillBonuses), nameof(SkillBonusesDefaultValue))] [SerializedCommandProperty(AccessLevel.GameMaster, canModify: true)] public AosSkillBonuses _skillBonuses; - [SerializableFieldSaveFlag(23)] private bool ShouldSerializeSkillBonuses() => !_skillBonuses.IsEmpty; - [SerializableFieldDefault(23)] private AosSkillBonuses SkillBonusesDefaultValue() => new(this); private FactionItem m_FactionState; @@ -190,6 +186,7 @@ namespace Server.Items public virtual int OldIntReq => 0; [SerializableProperty(11)] + [SaveFlag(nameof(ShouldSerializeArmorQuality), nameof(QualityDefaultValue))] [CommandProperty(AccessLevel.GameMaster)] public ArmorQuality Quality { @@ -202,13 +199,12 @@ namespace Server.Items } } - [SerializableFieldSaveFlag(11)] private bool ShouldSerializeArmorQuality() => _quality != ArmorQuality.Regular; - [SerializableFieldDefault(11)] private ArmorQuality QualityDefaultValue() => ArmorQuality.Regular; [SerializableProperty(12)] + [SaveFlag(nameof(ShouldSerializeDurability))] [CommandProperty(AccessLevel.GameMaster)] public ArmorDurabilityLevel Durability { @@ -221,33 +217,24 @@ namespace Server.Items } } - [SerializableFieldSaveFlag(12)] private bool ShouldSerializeDurability() => _durability != ArmorDurabilityLevel.Regular; - [SerializableProperty(13)] - [CommandProperty(AccessLevel.GameMaster)] - public ArmorProtectionLevel ProtectionLevel + [SerializableField(13, fieldChanged: nameof(OnProtectionLevelChanged))] + [SaveFlag(nameof(ShouldSerializeProtectionLevel))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + [InvalidateProperties] + private ArmorProtectionLevel _protectionLevel; + + private void OnProtectionLevelChanged(ArmorProtectionLevel oldValue, ArmorProtectionLevel newValue) { - get => _protectionLevel; - set - { - if (_protectionLevel != value) - { - _protectionLevel = value; - - Invalidate(); - InvalidateProperties(); - - (Parent as Mobile)?.UpdateResistances(); - this.MarkDirty(); - } - } + Invalidate(); + (Parent as Mobile)?.UpdateResistances(); } - [SerializableFieldSaveFlag(13)] private bool ShouldSerializeProtectionLevel() => _protectionLevel != ArmorProtectionLevel.Regular; [SerializableProperty(14)] + [SaveFlag(nameof(ShouldSerializeResource), nameof(ResourceDefaultValue))] [CommandProperty(AccessLevel.GameMaster)] public CraftResource Resource { @@ -273,11 +260,11 @@ namespace Server.Items } } - [SerializableFieldDefault(14)] private CraftResource ResourceDefaultValue() => DefaultResource; [EncodedInt] [SerializableProperty(15, useField: nameof(_armorBase))] + [SaveFlag(nameof(ShouldSerializeArmorBase), nameof(ArmorBaseDefaultValue))] [CommandProperty(AccessLevel.GameMaster)] public int BaseArmorRating { @@ -290,10 +277,8 @@ namespace Server.Items } } - [SerializableFieldSaveFlag(15)] private bool ShouldSerializeArmorBase() => _armorBase != -1; - [SerializableFieldDefault(15)] private int ArmorBaseDefaultValue() => -1; public double BaseArmorRatingScaled => BaseArmorRating * ArmorScalar; @@ -343,6 +328,7 @@ namespace Server.Items [EncodedInt] [SerializableProperty(16, useField: nameof(_strBonus))] + [SaveFlag(nameof(ShouldSerializeStrBonus), nameof(StrBonusDefaultValue))] [CommandProperty(AccessLevel.GameMaster)] public int StrBonus { @@ -355,14 +341,13 @@ namespace Server.Items } } - [SerializableFieldSaveFlag(16)] private bool ShouldSerializeStrBonus() => _strBonus != -1; - [SerializableFieldDefault(16)] private int StrBonusDefaultValue() => -1; [EncodedInt] [SerializableProperty(17, useField: nameof(_dexBonus))] + [SaveFlag(nameof(ShouldSerializeDexBonus), nameof(DexBonusDefaultValue))] [CommandProperty(AccessLevel.GameMaster)] public int DexBonus { @@ -375,14 +360,13 @@ namespace Server.Items } } - [SerializableFieldSaveFlag(17)] private bool ShouldSerializeDexBonus() => _dexBonus != -1; - [SerializableFieldDefault(17)] private int DexBonusDefaultValue() => -1; [EncodedInt] [SerializableProperty(18, useField: nameof(_intBonus))] + [SaveFlag(nameof(ShouldSerializeIntBonus), nameof(IntBonusDefaultValue))] [CommandProperty(AccessLevel.GameMaster)] public int IntBonus { @@ -395,14 +379,13 @@ namespace Server.Items } } - [SerializableFieldSaveFlag(18)] private bool ShouldSerializeIntBonus() => _intBonus != -1; - [SerializableFieldDefault(18)] private int IntBonusDefaultValue() => -1; [EncodedInt] [SerializableProperty(19, useField: nameof(_strReq))] + [SaveFlag(nameof(ShouldSerializeStrReq), nameof(StrReqDefaultValue))] [CommandProperty(AccessLevel.GameMaster)] public int StrRequirement { @@ -415,14 +398,13 @@ namespace Server.Items } } - [SerializableFieldSaveFlag(19)] private bool ShouldSerializeStrReq() => _strReq != -1; - [SerializableFieldDefault(19)] private int StrReqDefaultValue() => -1; [EncodedInt] [SerializableProperty(20, useField: nameof(_dexReq))] + [SaveFlag(nameof(ShouldSerializeDexReq), nameof(DexReqDefaultValue))] [CommandProperty(AccessLevel.GameMaster)] public int DexRequirement { @@ -435,14 +417,13 @@ namespace Server.Items } } - [SerializableFieldSaveFlag(20)] private bool ShouldSerializeDexReq() => _dexReq != -1; - [SerializableFieldDefault(20)] private int DexReqDefaultValue() => -1; [EncodedInt] [SerializableProperty(21, useField: nameof(_intReq))] + [SaveFlag(nameof(ShouldSerializeIntReq), nameof(IntReqDefaultValue))] [CommandProperty(AccessLevel.GameMaster)] public int IntRequirement { @@ -455,13 +436,12 @@ namespace Server.Items } } - [SerializableFieldSaveFlag(21)] private bool ShouldSerializeIntReq() => _intReq != -1; - [SerializableFieldDefault(21)] private int IntReqDefaultValue() => -1; [SerializableProperty(22, useField: nameof(_meditate))] + [SaveFlag(nameof(ShouldSerializeMeditationAllowance))] [CommandProperty(AccessLevel.GameMaster)] public AMA MeditationAllowance { @@ -473,7 +453,6 @@ namespace Server.Items } } - [SerializableFieldSaveFlag(22)] private bool ShouldSerializeMeditationAllowance() => _meditate >= AMA.All; public virtual double ArmorScalar @@ -689,6 +668,7 @@ namespace Server.Items [EncodedInt] [SerializableProperty(9)] + [SaveFlag(nameof(ShouldSerializeHitPoints))] [CommandProperty(AccessLevel.GameMaster)] public int HitPoints { @@ -716,7 +696,6 @@ namespace Server.Items } } - [SerializableFieldSaveFlag(9)] private bool ShouldSerializeHitPoints() => _hitPoints != 0; public virtual int InitMinHits => 0; diff --git a/Projects/UOContent/Items/Armor/Glasses/ElvenGlasses.cs b/Projects/UOContent/Items/Armor/Glasses/ElvenGlasses.cs index 4b0fb791f..e44967f0b 100644 --- a/Projects/UOContent/Items/Armor/Glasses/ElvenGlasses.cs +++ b/Projects/UOContent/Items/Armor/Glasses/ElvenGlasses.cs @@ -31,13 +31,12 @@ namespace Server.Items public override ArmorMeditationAllowance DefMedAllowance => ArmorMeditationAllowance.All; [SerializableField(0, setter: "private")] + [SaveFlag(nameof(ShouldSerializeWeaponAttributes), nameof(WeaponAttributesDefaultValue))] [SerializedCommandProperty(AccessLevel.GameMaster, canModify: true)] public AosWeaponAttributes _weaponAttributes; - [SerializableFieldSaveFlag(0)] private bool ShouldSerializeWeaponAttributes() => !_weaponAttributes.IsEmpty; - [SerializableFieldDefault(0)] private AosWeaponAttributes WeaponAttributesDefaultValue() => new(this); public override void AppendChildNameProperties(IPropertyList list) diff --git a/Projects/UOContent/Items/Armor/Leather/LeafGloves.cs b/Projects/UOContent/Items/Armor/Leather/LeafGloves.cs index f9abd485b..aa023cdc3 100644 --- a/Projects/UOContent/Items/Armor/Leather/LeafGloves.cs +++ b/Projects/UOContent/Items/Armor/Leather/LeafGloves.cs @@ -33,32 +33,26 @@ namespace Server.Items public override ArmorMeditationAllowance DefMedAllowance => ArmorMeditationAllowance.All; + [SerializableField(0, fieldChanged: nameof(OnCurArcaneChargesChanged))] [EncodedInt] - [SerializableProperty(0)] - [CommandProperty(AccessLevel.GameMaster)] - public int CurArcaneCharges + [SerializedCommandProperty(AccessLevel.GameMaster)] + [InvalidateProperties] + private int _curArcaneCharges; + + private void OnCurArcaneChargesChanged(int oldValue, int newValue) { - get => _curArcaneCharges; - set - { - _curArcaneCharges = value; - InvalidateProperties(); - Update(); - } + Update(); } + [SerializableField(1, fieldChanged: nameof(OnMaxArcaneChargesChanged))] [EncodedInt] - [SerializableProperty(1)] - [CommandProperty(AccessLevel.GameMaster)] - public int MaxArcaneCharges + [SerializedCommandProperty(AccessLevel.GameMaster)] + [InvalidateProperties] + private int _maxArcaneCharges; + + private void OnMaxArcaneChargesChanged(int oldValue, int newValue) { - get => _maxArcaneCharges; - set - { - _maxArcaneCharges = value; - InvalidateProperties(); - Update(); - } + Update(); } [CommandProperty(AccessLevel.GameMaster)] diff --git a/Projects/UOContent/Items/Armor/Leather/LeatherGloves.cs b/Projects/UOContent/Items/Armor/Leather/LeatherGloves.cs index a1ded151b..297bd8ed1 100644 --- a/Projects/UOContent/Items/Armor/Leather/LeatherGloves.cs +++ b/Projects/UOContent/Items/Armor/Leather/LeatherGloves.cs @@ -32,32 +32,26 @@ namespace Server.Items public override ArmorMeditationAllowance DefMedAllowance => ArmorMeditationAllowance.All; + [SerializableField(0, fieldChanged: nameof(OnCurArcaneChargesChanged))] [EncodedInt] - [SerializableProperty(0)] - [CommandProperty(AccessLevel.GameMaster)] - public int CurArcaneCharges + [SerializedCommandProperty(AccessLevel.GameMaster)] + [InvalidateProperties] + private int _curArcaneCharges; + + private void OnCurArcaneChargesChanged(int oldValue, int newValue) { - get => _curArcaneCharges; - set - { - _curArcaneCharges = value; - InvalidateProperties(); - Update(); - } + Update(); } + [SerializableField(1, fieldChanged: nameof(OnMaxArcaneChargesChanged))] [EncodedInt] - [SerializableProperty(1)] - [CommandProperty(AccessLevel.GameMaster)] - public int MaxArcaneCharges + [SerializedCommandProperty(AccessLevel.GameMaster)] + [InvalidateProperties] + private int _maxArcaneCharges; + + private void OnMaxArcaneChargesChanged(int oldValue, int newValue) { - get => _maxArcaneCharges; - set - { - _maxArcaneCharges = value; - InvalidateProperties(); - Update(); - } + Update(); } [CommandProperty(AccessLevel.GameMaster)] diff --git a/Projects/UOContent/Items/Books/BaseBook.cs b/Projects/UOContent/Items/Books/BaseBook.cs index 47f368754..3d9b9f7b8 100644 --- a/Projects/UOContent/Items/Books/BaseBook.cs +++ b/Projects/UOContent/Items/Books/BaseBook.cs @@ -19,41 +19,38 @@ namespace Server.Items [InternString] [InvalidateProperties] [SerializableField(1)] + [SaveFlag(nameof(ShouldSerializeTitle), nameof(TitleDefaultValue))] [SerializedCommandProperty(AccessLevel.GameMaster)] private string _title; - [SerializableFieldSaveFlag(1)] private bool ShouldSerializeTitle() => _title != DefaultContent?.Title; - [SerializableFieldDefault(1)] private string TitleDefaultValue() => DefaultContent?.Title; [InvalidateProperties] [SerializableField(2)] + [SaveFlag(nameof(ShouldSerializeAuthor), nameof(AuthorDefaultValue))] [SerializedCommandProperty(AccessLevel.GameMaster)] private string _author; - [SerializableFieldSaveFlag(2)] private bool ShouldSerializeAuthor() => _author != DefaultContent?.Author; - [SerializableFieldDefault(2)] private string AuthorDefaultValue() => DefaultContent?.Author; [SerializableField(3)] + [SaveFlag(nameof(ShouldSerializeWritable))] [SerializedCommandProperty(AccessLevel.GameMaster)] private bool _writable; - [SerializableFieldSaveFlag(3)] private bool ShouldSerializeWritable() => _writable; [SerializedIgnoreDupe] [SerializableField(4, setter: "protected")] + [SaveFlag(nameof(ShouldSerializePages), nameof(PagesDefaultValue))] private BookPageInfo[] _pages; - [SerializableFieldSaveFlag(4)] private bool ShouldSerializePages() => DefaultContent?.IsMatch(_pages) != true; - [SerializableFieldDefault(4)] private BookPageInfo[] PagesDefaultValue() => DefaultContent?.Copy() ?? Array.Empty(); [Constructible] diff --git a/Projects/UOContent/Items/Clothing/BaseClothing.cs b/Projects/UOContent/Items/Clothing/BaseClothing.cs index 8246c8c46..983568d2d 100644 --- a/Projects/UOContent/Items/Clothing/BaseClothing.cs +++ b/Projects/UOContent/Items/Clothing/BaseClothing.cs @@ -26,76 +26,71 @@ namespace Server.Items public abstract partial class BaseClothing : Item, IDyable, IScissorable, IFactionItem, ICraftable, IWearableDurability, IAosItem { - [SerializableFieldSaveFlag(0)] private bool ShouldSerializeResource() => _resource != DefaultResource; [SerializedIgnoreDupe] [SerializableField(1, setter: "private")] + [SaveFlag(nameof(ShouldSerializeAttributes), nameof(AttributesDefaultValue))] [SerializedCommandProperty(AccessLevel.GameMaster, canModify: true)] private AosAttributes _attributes; - [SerializableFieldSaveFlag(1)] private bool ShouldSerializeAttributes() => !_attributes.IsEmpty; - [SerializableFieldDefault(1)] private AosAttributes AttributesDefaultValue() => new(this); [SerializedIgnoreDupe] [SerializableField(2, setter: "private")] + [SaveFlag(nameof(ShouldSerializeClothingAttributes), nameof(ClothingAttributesDefaultValue))] [SerializedCommandProperty(AccessLevel.GameMaster, canModify: true)] private AosArmorAttributes _clothingAttributes; - [SerializableFieldSaveFlag(2)] private bool ShouldSerializeClothingAttributes() => !_clothingAttributes.IsEmpty; - [SerializableFieldDefault(2)] private AosArmorAttributes ClothingAttributesDefaultValue() => new(this); [SerializedIgnoreDupe] [SerializableField(3, setter: "private")] + [SaveFlag(nameof(ShouldSerializeSkillBonuses), nameof(SkillBonusesDefaultValue))] [SerializedCommandProperty(AccessLevel.GameMaster, canModify: true)] private AosSkillBonuses _skillBonuses; - [SerializableFieldSaveFlag(3)] private bool ShouldSerializeSkillBonuses() => !_skillBonuses.IsEmpty; - [SerializableFieldDefault(3)] private AosSkillBonuses SkillBonusesDefaultValue() => new(this); [SerializedIgnoreDupe] [SerializableField(4, setter: "private")] + [SaveFlag(nameof(ShouldSerializeResistances), nameof(ResistancesDefaultValue))] [SerializedCommandProperty(AccessLevel.GameMaster, canModify: true)] private AosElementAttributes _resistances; - [SerializableFieldSaveFlag(4)] private bool ShouldSerializeResistances() => !_resistances.IsEmpty; - [SerializableFieldDefault(4)] private AosElementAttributes ResistancesDefaultValue() => new(this); [EncodedInt] [InvalidateProperties] [SerializableField(5)] + [SaveFlag(nameof(ShouldSerializeMaxHitPoints))] [SerializedCommandProperty(AccessLevel.GameMaster)] private int _maxHitPoints; - [SerializableFieldSaveFlag(5)] private bool ShouldSerializeMaxHitPoints() => _maxHitPoints != 0; [InvalidateProperties] [SerializableField(7)] + [SaveFlag(nameof(ShouldSerializeCrafter))] [SerializedCommandProperty(AccessLevel.GameMaster)] private string _crafter; - [SerializableFieldSaveFlag(7)] private bool ShouldSerializeCrafter() => !string.IsNullOrEmpty(_crafter); [InvalidateProperties] [SerializableField(8)] + [SaveFlag(nameof(ShouldSerializeQuality))] [SerializedCommandProperty(AccessLevel.GameMaster)] private ClothingQuality _quality = ClothingQuality.Regular; - [SerializableFieldSaveFlag(8)] private bool ShouldSerializeQuality() => _quality != ClothingQuality.Regular; // Field 9 @@ -118,21 +113,19 @@ namespace Server.Items Resistances = new AosElementAttributes(this); } - [SerializableProperty(0)] - [CommandProperty(AccessLevel.GameMaster)] - public CraftResource Resource + [SerializableField(0, fieldChanged: nameof(OnResourceChanged))] + [SaveFlag(nameof(ShouldSerializeResource))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + [InvalidateProperties] + private CraftResource _resource; + + private void OnResourceChanged(CraftResource oldValue, CraftResource newValue) { - get => _resource; - set - { - _resource = value; - Hue = CraftResources.GetHue(_resource); - InvalidateProperties(); - this.MarkDirty(); - } + Hue = CraftResources.GetHue(_resource); } [SerializableProperty(9, useField: nameof(_strReq))] + [SaveFlag(nameof(ShouldSerializeStrReq))] [CommandProperty(AccessLevel.GameMaster)] public int StrRequirement { @@ -145,7 +138,6 @@ namespace Server.Items } } - [SerializableFieldSaveFlag(9)] private bool ShouldSerializeStrReq() => _strReq != -1; public virtual CraftResource DefaultResource => CraftResource.None; @@ -299,6 +291,7 @@ namespace Server.Items [EncodedInt] [SerializableProperty(6)] + [SaveFlag(nameof(ShouldSerializeHitPoints))] [CommandProperty(AccessLevel.GameMaster)] public int HitPoints { @@ -324,7 +317,6 @@ namespace Server.Items } } - [SerializableFieldSaveFlag(6)] private bool ShouldSerializeHitPoints() => _hitPoints != 0; public virtual int InitMinHits => 0; diff --git a/Projects/UOContent/Items/Clothing/Cloaks.cs b/Projects/UOContent/Items/Clothing/Cloaks.cs index 67f572d9d..a92e8fc8c 100644 --- a/Projects/UOContent/Items/Clothing/Cloaks.cs +++ b/Projects/UOContent/Items/Clothing/Cloaks.cs @@ -22,34 +22,26 @@ namespace Server.Items public override double DefaultWeight => 5.0; + [SerializableField(0, fieldChanged: nameof(OnCurArcaneChargesChanged))] [EncodedInt] - [SerializableProperty(0)] - [CommandProperty(AccessLevel.GameMaster)] - public int CurArcaneCharges + [SerializedCommandProperty(AccessLevel.GameMaster)] + [InvalidateProperties] + private int _curArcaneCharges; + + private void OnCurArcaneChargesChanged(int oldValue, int newValue) { - get => _curArcaneCharges; - set - { - _curArcaneCharges = value; - this.MarkDirty(); - InvalidateProperties(); - Update(); - } + Update(); } + [SerializableField(1, fieldChanged: nameof(OnMaxArcaneChargesChanged))] [EncodedInt] - [SerializableProperty(1)] - [CommandProperty(AccessLevel.GameMaster)] - public int MaxArcaneCharges + [SerializedCommandProperty(AccessLevel.GameMaster)] + [InvalidateProperties] + private int _maxArcaneCharges; + + private void OnMaxArcaneChargesChanged(int oldValue, int newValue) { - get => _maxArcaneCharges; - set - { - _maxArcaneCharges = value; - this.MarkDirty(); - InvalidateProperties(); - Update(); - } + Update(); } [CommandProperty(AccessLevel.GameMaster)] diff --git a/Projects/UOContent/Items/Clothing/OuterTorso.cs b/Projects/UOContent/Items/Clothing/OuterTorso.cs index 4fda45bb5..f3b0b077f 100644 --- a/Projects/UOContent/Items/Clothing/OuterTorso.cs +++ b/Projects/UOContent/Items/Clothing/OuterTorso.cs @@ -37,21 +37,22 @@ namespace Server.Items public override double DefaultWeight => 3.0; } - [SerializationGenerator(3, false)] + [SerializationGenerator(4, false)] public partial class DeathRobe : Robe { private static readonly TimeSpan m_DefaultDecayTime = TimeSpan.FromMinutes(1.0); - [TimerDrift] [SerializableField(0)] + [DeserializeTimer(nameof(DeserializeDecayTimer))] private Timer _decayTimer; - [DeserializeTimerField(0)] - private void DeserializeDecayTimer(TimeSpan delay) + private void DeserializeDecayTimer(TimeSpan delay) => BeginDecay(delay); + + private void MigrateFrom(V3Content content) { - if (delay != TimeSpan.MinValue) + if (content.DecayTimerDelay != TimeSpan.MinValue) { - BeginDecay(delay); + DeserializeDecayTimer(content.DecayTimerDelay); } } @@ -324,34 +325,26 @@ namespace Server.Items public override double DefaultWeight => 3.0; + [SerializableField(0, fieldChanged: nameof(OnCurArcaneChargesChanged))] [EncodedInt] - [SerializableProperty(0)] - [CommandProperty(AccessLevel.GameMaster)] - public int CurArcaneCharges + [SerializedCommandProperty(AccessLevel.GameMaster)] + [InvalidateProperties] + private int _curArcaneCharges; + + private void OnCurArcaneChargesChanged(int oldValue, int newValue) { - get => _curArcaneCharges; - set - { - _curArcaneCharges = value; - InvalidateProperties(); - Update(); - this.MarkDirty(); - } + Update(); } + [SerializableField(1, fieldChanged: nameof(OnMaxArcaneChargesChanged))] [EncodedInt] - [SerializableProperty(1)] - [CommandProperty(AccessLevel.GameMaster)] - public int MaxArcaneCharges + [SerializedCommandProperty(AccessLevel.GameMaster)] + [InvalidateProperties] + private int _maxArcaneCharges; + + private void OnMaxArcaneChargesChanged(int oldValue, int newValue) { - get => _maxArcaneCharges; - set - { - _maxArcaneCharges = value; - InvalidateProperties(); - Update(); - this.MarkDirty(); - } + Update(); } [CommandProperty(AccessLevel.GameMaster)] diff --git a/Projects/UOContent/Items/Clothing/Shoes.cs b/Projects/UOContent/Items/Clothing/Shoes.cs index 4d80b4699..f791b3a62 100644 --- a/Projects/UOContent/Items/Clothing/Shoes.cs +++ b/Projects/UOContent/Items/Clothing/Shoes.cs @@ -54,11 +54,10 @@ namespace Server.Items { [EncodedInt] [InvalidateProperties] - [SerializableField(0)] + [SerializableField(0, fieldChanged: nameof(OnCurArcaneChargesChanged))] [SerializedCommandProperty(AccessLevel.GameMaster)] private int _curArcaneCharges; - [SerializableFieldChanged(0)] [MethodImpl(MethodImplOptions.AggressiveInlining)] private void OnCurArcaneChargesChanged(int oldValue, int newValue) => Update(); @@ -71,19 +70,15 @@ namespace Server.Items public override CraftResource DefaultResource => CraftResource.RegularLeather; + [SerializableField(1, fieldChanged: nameof(OnMaxArcaneChargesChanged))] [EncodedInt] - [SerializableProperty(1)] - [CommandProperty(AccessLevel.GameMaster)] - public int MaxArcaneCharges + [SerializedCommandProperty(AccessLevel.GameMaster)] + [InvalidateProperties] + private int _maxArcaneCharges; + + private void OnMaxArcaneChargesChanged(int oldValue, int newValue) { - get => _maxArcaneCharges; - set - { - _maxArcaneCharges = value; - InvalidateProperties(); - Update(); - this.MarkDirty(); - } + Update(); } [CommandProperty(AccessLevel.GameMaster)] diff --git a/Projects/UOContent/Items/Construction/Doors/BaseDoor.cs b/Projects/UOContent/Items/Construction/Doors/BaseDoor.cs index dc3449c24..ef779760b 100644 --- a/Projects/UOContent/Items/Construction/Doors/BaseDoor.cs +++ b/Projects/UOContent/Items/Construction/Doors/BaseDoor.cs @@ -74,43 +74,31 @@ public abstract partial class BaseDoor : Item, ILockable, ITelekinesisable Movable = false; } - [SerializableProperty(1)] - [CommandProperty(AccessLevel.GameMaster)] - public bool Open + [SerializableField(1, fieldChanged: nameof(OnOpenChanged))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private bool _open; + + private void OnOpenChanged(bool oldValue, bool newValue) { - get => _open; - set + ItemID = _open ? _openedId : _closedId; + if (_open) { - if (_open != value) - { - _open = value; - - ItemID = _open ? _openedId : _closedId; - - if (_open) - { - Location = new Point3D(X + _offset.X, Y + _offset.Y, Z + _offset.Z); - } - else - { - Location = new Point3D(X - _offset.X, Y - _offset.Y, Z - _offset.Z); - } - - Effects.PlaySound(this, _open ? OpenedSound : ClosedSound); - - if (_open) - { - _timer ??= new InternalTimer(this); - _timer.Start(); - } - else - { - _timer.Stop(); - _timer = null; - } - - this.MarkDirty(); - } + Location = new Point3D(X + _offset.X, Y + _offset.Y, Z + _offset.Z); + } + else + { + Location = new Point3D(X - _offset.X, Y - _offset.Y, Z - _offset.Z); + } + Effects.PlaySound(this, _open ? OpenedSound : ClosedSound); + if (_open) + { + _timer ??= new InternalTimer(this); + _timer.Start(); + } + else + { + _timer.Stop(); + _timer = null; } } diff --git a/Projects/UOContent/Items/Containers/Fillable Containers/FillableContainer.cs b/Projects/UOContent/Items/Containers/Fillable Containers/FillableContainer.cs index a9da67dbb..da4d8b77f 100644 --- a/Projects/UOContent/Items/Containers/Fillable Containers/FillableContainer.cs +++ b/Projects/UOContent/Items/Containers/Fillable Containers/FillableContainer.cs @@ -3,19 +3,22 @@ using ModernUO.Serialization; namespace Server.Items; -[SerializationGenerator(2, false)] +[SerializationGenerator(3, false)] public abstract partial class FillableContainer : LockableContainer { - [TimerDrift] [SerializableField(1)] + [DeserializeTimer(nameof(DeserializeRespawnTimer))] private Timer _respawnTimer; - [DeserializeTimerField(1)] - private void DeserializeRespawnTimer(TimeSpan delay) + private void DeserializeRespawnTimer(TimeSpan delay) => _respawnTimer = Timer.DelayCall(delay, Respawn); + + private void MigrateFrom(V2Content content) { - if (delay > TimeSpan.MinValue) + _contentType = content.ContentType; + + if (content.RespawnTimerDelay != TimeSpan.MinValue) { - _respawnTimer = Timer.DelayCall(delay, Respawn); + DeserializeRespawnTimer(content.RespawnTimerDelay); } } diff --git a/Projects/UOContent/Items/Containers/MarkContainer.cs b/Projects/UOContent/Items/Containers/MarkContainer.cs index fa3f7e440..04e7c89ea 100644 --- a/Projects/UOContent/Items/Containers/MarkContainer.cs +++ b/Projects/UOContent/Items/Containers/MarkContainer.cs @@ -3,14 +3,13 @@ using ModernUO.Serialization; namespace Server.Items; -[SerializationGenerator(0, false)] +[SerializationGenerator(1, false)] public partial class MarkContainer : LockableContainer { - [TimerDrift] [SerializableField(1, getter: "private", setter: "private")] + [DeserializeTimer(nameof(DeserializeRelockTimer))] private InternalTimer _relockTimer; - [DeserializeTimerField(1)] private void DeserializeRelockTimer(TimeSpan delay) { if (!Locked && _autoLock) @@ -19,6 +18,19 @@ public partial class MarkContainer : LockableContainer } } + private void MigrateFrom(V0Content content) + { + _autoLock = content.AutoLock; + _targetMap = content.TargetMap; + _target = content.Target; + _description = content.Description; + + if (content.RelockTimerDelay != TimeSpan.MinValue) + { + DeserializeRelockTimer(content.RelockTimerDelay); + } + } + [SerializableField(2)] [SerializedCommandProperty(AccessLevel.GameMaster)] private Map _targetMap; @@ -50,23 +62,19 @@ public partial class MarkContainer : LockableContainer } } - [SerializableProperty(0)] - [CommandProperty(AccessLevel.GameMaster)] - public bool AutoLock - { - get => _autoLock; - set - { - _autoLock = value; + [SerializableField(0, fieldChanged: nameof(OnAutoLockChanged))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private bool _autoLock; - if (!_autoLock) - { - StopTimer(); - } - else if (!Locked) - { - _relockTimer ??= new InternalTimer(this); - } + private void OnAutoLockChanged(bool oldValue, bool newValue) + { + if (!_autoLock) + { + StopTimer(); + } + else if (!Locked) + { + _relockTimer ??= new InternalTimer(this); } } diff --git a/Projects/UOContent/Items/Containers/TreasureMapChest.cs b/Projects/UOContent/Items/Containers/TreasureMapChest.cs index e0486182d..a1aafa0b3 100644 --- a/Projects/UOContent/Items/Containers/TreasureMapChest.cs +++ b/Projects/UOContent/Items/Containers/TreasureMapChest.cs @@ -9,7 +9,7 @@ using Server.Network; namespace Server.Items; -[SerializationGenerator(3, false)] +[SerializationGenerator(4, false)] public partial class TreasureMapChest : LockableContainer { [Tidy] @@ -29,12 +29,11 @@ public partial class TreasureMapChest : LockableContainer [SerializedCommandProperty(AccessLevel.GameMaster)] private int _level; - [TimerDrift] [SerializableField(4)] [SerializedCommandProperty(AccessLevel.GameMaster)] + [DeserializeTimer(nameof(DeserializeExpireTimer))] private Timer _expireTimer; - [DeserializeTimerField(4)] private void DeserializeExpireTimer(TimeSpan delay) { if (!_temporary) @@ -43,6 +42,20 @@ public partial class TreasureMapChest : LockableContainer } } + private void MigrateFrom(V3Content content) + { + _guardians = content.Guardians; + _temporary = content.Temporary; + _owner = content.Owner; + _level = content.Level; + _lifted = content.Lifted; + + if (content.ExpireTimerDelay != TimeSpan.MinValue) + { + DeserializeExpireTimer(content.ExpireTimerDelay); + } + } + [Tidy] [CanBeNull] [SerializableField(5, setter: "private")] diff --git a/Projects/UOContent/Items/Deeds/DragonBardingDeed.cs b/Projects/UOContent/Items/Deeds/DragonBardingDeed.cs index 6599e6c56..5ed370153 100644 --- a/Projects/UOContent/Items/Deeds/DragonBardingDeed.cs +++ b/Projects/UOContent/Items/Deeds/DragonBardingDeed.cs @@ -28,17 +28,14 @@ public partial class DragonBardingDeed : Item, ICraftable public override int LabelNumber => _exceptional ? 1053181 : 1053012; // dragon barding deed - [SerializableProperty(2)] - [CommandProperty(AccessLevel.GameMaster)] - public CraftResource Resource + [SerializableField(2, fieldChanged: nameof(OnResourceChanged))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + [InvalidateProperties] + private CraftResource _resource; + + private void OnResourceChanged(CraftResource oldValue, CraftResource newValue) { - get => _resource; - set - { - _resource = value; - Hue = CraftResources.GetHue(value); - InvalidateProperties(); - } + Hue = CraftResources.GetHue(newValue); } public int OnCraft( diff --git a/Projects/UOContent/Items/Food/Beverage.cs b/Projects/UOContent/Items/Food/Beverage.cs index 3a9f50297..85eecf17f 100644 --- a/Projects/UOContent/Items/Food/Beverage.cs +++ b/Projects/UOContent/Items/Food/Beverage.cs @@ -324,27 +324,21 @@ public abstract partial class BaseBeverage : Item, IHasQuantity [CommandProperty(AccessLevel.GameMaster)] public bool IsFull => _quantity >= MaxQuantity; - [SerializableProperty(2)] - [CommandProperty(AccessLevel.GameMaster)] - public BeverageType Content + [SerializableField(2, fieldChanged: nameof(OnContentChanged))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + [InvalidateProperties] + private BeverageType _content; + + private void OnContentChanged(BeverageType oldValue, BeverageType newValue) { - get => _content; - set + var itemID = ComputeItemID(); + if (itemID > 0) { - _content = value; - - InvalidateProperties(); - - var itemID = ComputeItemID(); - - if (itemID > 0) - { - ItemID = itemID; - } - else - { - Delete(); - } + ItemID = itemID; + } + else + { + Delete(); } } diff --git a/Projects/UOContent/Items/Food/Cooking.cs b/Projects/UOContent/Items/Food/Cooking.cs index 6cff16d72..cd010432d 100644 --- a/Projects/UOContent/Items/Food/Cooking.cs +++ b/Projects/UOContent/Items/Food/Cooking.cs @@ -76,25 +76,25 @@ public partial class SackFlour : Item, IHasQuantity public override double DefaultWeight => 5.0; - [SerializableProperty(0)] - [CommandProperty(AccessLevel.GameMaster)] - public int Quantity + [SerializableField(0, fieldChanged: nameof(OnQuantityChanged), allowFieldChange: nameof(AllowQuantityChange))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private int _quantity; + + private bool AllowQuantityChange(ref int value) { - get => _quantity; - set + value = Math.Min(20, Math.Max(0, value)); + return true; + } + + private void OnQuantityChanged(int oldValue, int newValue) + { + if (_quantity == 0) { - _quantity = Math.Min(20, Math.Max(0, value)); - - if (_quantity == 0) - { - Delete(); - } - else if (_quantity < 20 && ItemID is 0x1039 or 0x1045) - { - ++ItemID; - } - - this.MarkDirty(); + Delete(); + } + else if (_quantity < 20 && ItemID is 0x1039 or 0x1045) + { + ++ItemID; } } diff --git a/Projects/UOContent/Items/Games/Mahjong/MahjongGame.cs b/Projects/UOContent/Items/Games/Mahjong/MahjongGame.cs index de7e663b7..89e4d6f8f 100644 --- a/Projects/UOContent/Items/Games/Mahjong/MahjongGame.cs +++ b/Projects/UOContent/Items/Games/Mahjong/MahjongGame.cs @@ -55,55 +55,33 @@ public partial class MahjongGame : Item, ISecurable public override double DefaultWeight => 5.0; - [CommandProperty(AccessLevel.GameMaster)] - [SerializableProperty(6)] - public bool ShowScores + [SerializableField(6, fieldChanged: nameof(OnShowScoresChanged))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private bool _showScores; + + private void OnShowScoresChanged(bool oldValue, bool newValue) { - get => _showScores; - set + if (newValue) { - if (_showScores == value) - { - return; - } - - _showScores = value; - - if (value) - { - _players.SendPlayersPacket(true, true); - } - - _players.SendGeneralPacket(true, true); - _players.SendLocalizedMessage(value ? 1062777 : 1062778); // The dealer has enabled/disabled score display. - this.MarkDirty(); + _players.SendPlayersPacket(true, true); } + _players.SendGeneralPacket(true, true); + _players.SendLocalizedMessage(newValue ? 1062777 : 1062778); // The dealer has enabled/disabled score display. } - [CommandProperty(AccessLevel.GameMaster)] - [SerializableProperty(7)] - public bool SpectatorVision + [SerializableField(7, fieldChanged: nameof(OnSpectatorVisionChanged))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + [InvalidateProperties] + private bool _spectatorVision; + + private void OnSpectatorVisionChanged(bool oldValue, bool newValue) { - get => _spectatorVision; - set + if (_players.IsInGamePlayer(_players.DealerPosition)) { - if (_spectatorVision == value) - { - return; - } - - _spectatorVision = value; - - if (_players.IsInGamePlayer(_players.DealerPosition)) - { - _players.Dealer.NetState.SendMahjongGeneralInfo(this); - } - - _players.SendTilesPacket(false, true); - _players.SendLocalizedMessage(value ? 1062715 : 1062716); // The dealer has enabled/disabled Spectator Vision. - InvalidateProperties(); - this.MarkDirty(); + _players.Dealer.NetState.SendMahjongGeneralInfo(this); } + _players.SendTilesPacket(false, true); + _players.SendLocalizedMessage(newValue ? 1062715 : 1062716); // The dealer has enabled/disabled Spectator Vision. } private void BuildHorizontalWall( diff --git a/Projects/UOContent/Items/Jewels/BaseJewel.cs b/Projects/UOContent/Items/Jewels/BaseJewel.cs index 4eb01cbe0..e693c0aaa 100644 --- a/Projects/UOContent/Items/Jewels/BaseJewel.cs +++ b/Projects/UOContent/Items/Jewels/BaseJewel.cs @@ -93,16 +93,13 @@ public abstract partial class BaseJewel : Item, ICraftable, IAosItem } } - [SerializableProperty(2)] - [CommandProperty(AccessLevel.GameMaster)] - public CraftResource Resource + [SerializableField(2, fieldChanged: nameof(OnResourceChanged))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private CraftResource _resource; + + private void OnResourceChanged(CraftResource oldValue, CraftResource newValue) { - get => _resource; - set - { - _resource = value; - Hue = CraftResources.GetHue(_resource); - } + Hue = CraftResources.GetHue(_resource); } public override int PhysicalResistance => Resistances.Physical; diff --git a/Projects/UOContent/Items/Lights/BaseLight.cs b/Projects/UOContent/Items/Lights/BaseLight.cs index d3bff4af5..4bd63ba63 100644 --- a/Projects/UOContent/Items/Lights/BaseLight.cs +++ b/Projects/UOContent/Items/Lights/BaseLight.cs @@ -3,7 +3,7 @@ using ModernUO.Serialization; namespace Server.Items; -[SerializationGenerator(1, false)] +[SerializationGenerator(2, false)] public abstract partial class BaseLight : Item { public static readonly bool Burnout = false; @@ -16,11 +16,10 @@ public abstract partial class BaseLight : Item [SerializedCommandProperty(AccessLevel.GameMaster)] private bool _protected; - [TimerDrift] [SerializableField(4, getter: "private", setter: "private")] + [DeserializeTimer(nameof(DeserializeTimer))] private Timer _burnTimer; - [DeserializeTimerField(4)] private void DeserializeTimer(TimeSpan delay) { if (_burning && _duration != TimeSpan.Zero) @@ -29,6 +28,19 @@ public abstract partial class BaseLight : Item } } + private void MigrateFrom(V1Content content) + { + _burntOut = content.BurntOut; + _burning = content.Burning; + _duration = content.Duration; + _protected = content.Protected; + + if (content.BurnTimerDelay != TimeSpan.MinValue) + { + DeserializeTimer(content.BurnTimerDelay); + } + } + [Constructible] public BaseLight(int itemID) : base(itemID) { diff --git a/Projects/UOContent/Items/Misc/CommunicationCrystals.cs b/Projects/UOContent/Items/Misc/CommunicationCrystals.cs index a52b1a12d..07df233e9 100644 --- a/Projects/UOContent/Items/Misc/CommunicationCrystals.cs +++ b/Projects/UOContent/Items/Misc/CommunicationCrystals.cs @@ -275,8 +275,16 @@ public partial class BroadcastCrystal : Item [SerializationGenerator(0)] public partial class ReceiverCrystal : Item { + [SerializableField(0, fieldChanged: nameof(OnSenderChanged))] + [SerializedCommandProperty(AccessLevel.GameMaster)] private BroadcastCrystal _sender; + private void OnSenderChanged(BroadcastCrystal oldValue, BroadcastCrystal newValue) + { + oldValue?.RemoveReceiver(this); + newValue?.AddReceiver(this); + } + [Constructible] public ReceiverCrystal() : base(0x1ED0) => Light = LightType.Circle150; @@ -297,20 +305,6 @@ public partial class ReceiverCrystal : Item } } - [SerializableProperty(0, useField: nameof(_sender))] - [CommandProperty(AccessLevel.GameMaster)] - public BroadcastCrystal Sender - { - get => _sender; - set - { - _sender?.RemoveReceiver(this); - _sender = value; - value?.AddReceiver(this); - this.MarkDirty(); - } - } - public override void GetProperties(IPropertyList list) { base.GetProperties(list); diff --git a/Projects/UOContent/Items/Misc/Corpses/Corpse.Migrations.cs b/Projects/UOContent/Items/Misc/Corpses/Corpse.Migrations.cs new file mode 100644 index 000000000..bc9ce047e --- /dev/null +++ b/Projects/UOContent/Items/Misc/Corpses/Corpse.Migrations.cs @@ -0,0 +1,179 @@ +using System; + +namespace Server.Items; + +public partial class Corpse +{ + // Decay timer and TimeOfDeath moved from delta time to anchored time + private void MigrateFrom(V18Content content) + { + _restoreEquip = content.RestoreEquip; + _flags = content.Flags; + _timeOfDeath = content.TimeOfDeath; + _restoreTable = content.RestoreTable; + _looters = content.Looters; + _killer = content.Killer; + _aggressors = content.Aggressors; + _owner = content.Owner; + _corpseName = content.CorpseName; + _accessLevel = content.AccessLevel; + _guild = content.Guild; + _equipItems = content.EquipItems; + _hairItemId = content.HairItemId; + _hairHue = content.HairHue; + _facialHairItemId = content.FacialHairItemId; + _facialHairHue = content.FacialHairHue; + + if (content.DecayTimerDelay != TimeSpan.MinValue) + { + DeserializeDecayTimer(content.DecayTimerDelay); + } + } + + // Decay timer moved from [TimerDrift]/[DeserializeTimerField] to [DeserializeTimer] + private void MigrateFrom(V17Content content) + { + _restoreEquip = content.RestoreEquip; + _flags = content.Flags; + _timeOfDeath = content.TimeOfDeath; + _restoreTable = content.RestoreTable; + _looters = content.Looters; + _killer = content.Killer; + _aggressors = content.Aggressors; + _owner = content.Owner; + _corpseName = content.CorpseName; + _accessLevel = content.AccessLevel; + _guild = content.Guild; + _equipItems = content.EquipItems; + _hairItemId = content.HairItemId; + _hairHue = content.HairHue; + _facialHairItemId = content.FacialHairItemId; + _facialHairHue = content.FacialHairHue; + + if (content.DecayTimerDelay != TimeSpan.MinValue) + { + DeserializeDecayTimer(content.DecayTimerDelay); + } + } + + // Decomposed VirtualHairInfo into discrete int fields (hair/facial hair item id + hue) + private void MigrateFrom(V16Content content) + { + _restoreEquip = content.RestoreEquip; + _flags = content.Flags; + _timeOfDeath = content.TimeOfDeath; + _restoreTable = content.RestoreTable; + _decayTimer = new InternalTimer(this, content.DecayTimerDelay); + _decayTimer.Start(); + _looters = content.Looters; + _killer = content.Killer; + _aggressors = content.Aggressors; + _owner = content.Owner; + _corpseName = content.CorpseName; + _accessLevel = content.AccessLevel; + _guild = content.Guild; + _equipItems = content.EquipItems; + if (content.Hair != null) + { + _hairItemId = content.Hair.ItemId; + _hairHue = content.Hair.Hue; + } + + if (content.FacialHair != null) + { + _facialHairItemId = content.FacialHair.ItemId; + _facialHairHue = content.FacialHair.Hue; + } + } + + // Folded Murderer bool field into CorpseFlag.Murderer + private void MigrateFrom(V15Content content) + { + _restoreEquip = content.RestoreEquip; + _flags = content.Flags; + if (content.Murderer) + { + _flags |= CorpseFlag.Murderer; + } + _timeOfDeath = content.TimeOfDeath; + _restoreTable = content.RestoreTable; + _decayTimer = new InternalTimer(this, content.DecayTimerDelay); + _decayTimer.Start(); + _looters = content.Looters; + _killer = content.Killer; + _aggressors = content.Aggressors; + _owner = content.Owner; + _corpseName = content.CorpseName; + _accessLevel = content.AccessLevel; + _guild = content.Guild; + _equipItems = content.EquipItems; + if (content.Hair != null) + { + _hairItemId = content.Hair.ItemId; + _hairHue = content.Hair.Hue; + } + + if (content.FacialHair != null) + { + _facialHairItemId = content.FacialHair.ItemId; + _facialHairHue = content.FacialHair.Hue; + } + } + + // Replaced int Kills snapshot with bool Murderer snapshot + private void MigrateFrom(V14Content content) + { + _restoreEquip = content.RestoreEquip; + _flags = content.Flags; + if (content.Kills >= 5) + { + _flags |= CorpseFlag.Murderer; + } + _timeOfDeath = content.TimeOfDeath; + _restoreTable = content.RestoreTable; + _decayTimer = new InternalTimer(this, content.DecayTimerDelay); + _decayTimer.Start(); + _looters = content.Looters; + _killer = content.Killer; + _aggressors = content.Aggressors; + _owner = content.Owner; + _corpseName = content.CorpseName; + _accessLevel = content.AccessLevel; + _guild = content.Guild; + _equipItems = content.EquipItems; + if (content.Hair != null) + { + _hairItemId = content.Hair.ItemId; + _hairHue = content.Hair.Hue; + } + + if (content.FacialHair != null) + { + _facialHairItemId = content.FacialHair.ItemId; + _facialHairHue = content.FacialHair.Hue; + } + } + + // Added corpse hair and corpse facial hair + private void MigrateFrom(V13Content content) + { + _restoreEquip = content.RestoreEquip; + _flags = content.Flags; + if (content.Kills >= 5) + { + _flags |= CorpseFlag.Murderer; + } + _timeOfDeath = content.TimeOfDeath; + _restoreTable = content.RestoreTable; + _decayTimer = new InternalTimer(this, content.DecayTimerDelay); + _decayTimer.Start(); + _looters = content.Looters; + _killer = content.Killer; + _aggressors = content.Aggressors; + _owner = content.Owner; + _corpseName = content.CorpseName; + _accessLevel = content.AccessLevel; + _guild = content.Guild; + _equipItems = content.EquipItems; + } +} diff --git a/Projects/UOContent/Items/Misc/Corpses/Corpse.cs b/Projects/UOContent/Items/Misc/Corpses/Corpse.cs index 1ee04170c..04e9806c9 100644 --- a/Projects/UOContent/Items/Misc/Corpses/Corpse.cs +++ b/Projects/UOContent/Items/Misc/Corpses/Corpse.cs @@ -86,7 +86,7 @@ public enum CorpseFlag OwnerWasAnimatedDead = 0x00000800 } -[SerializationGenerator(17, false)] +[SerializationGenerator(19, false)] public partial class Corpse : Container, ICarvable { public static readonly TimeSpan MonsterLootRightSacrifice = TimeSpan.FromMinutes(2.0); @@ -106,7 +106,7 @@ public partial class Corpse : Container, ICarvable [SerializableField(1)] private CorpseFlag _flags; - [DeltaDateTime] + [AnchoredDateTime] [SerializableField(2)] [SerializedCommandProperty(AccessLevel.GameMaster)] private DateTime _timeOfDeath; @@ -114,11 +114,10 @@ public partial class Corpse : Container, ICarvable [SerializableField(3, getter: "private", setter: "private")] private Dictionary _restoreTable; - [TimerDrift] [SerializableField(4, getter: "private", setter: "private")] + [DeserializeTimer(nameof(DeserializeDecayTimer))] private Timer _decayTimer; - [DeserializeTimerField(4)] private void DeserializeDecayTimer(TimeSpan delay) => BeginDecay(delay); [SerializableField(5, setter: "private")] @@ -318,127 +317,6 @@ public partial class Corpse : Container, ICarvable DevourCorpse(); } - // Decomposed VirtualHairInfo into discrete int fields (hair/facial hair item id + hue) - private void MigrateFrom(V16Content content) - { - _restoreEquip = content.RestoreEquip; - _flags = content.Flags; - _timeOfDeath = content.TimeOfDeath; - _restoreTable = content.RestoreTable; - _decayTimer = new InternalTimer(this, content.DecayTimerDelay); - _decayTimer.Start(); - _looters = content.Looters; - _killer = content.Killer; - _aggressors = content.Aggressors; - _owner = content.Owner; - _corpseName = content.CorpseName; - _accessLevel = content.AccessLevel; - _guild = content.Guild; - _equipItems = content.EquipItems; - if (content.Hair != null) - { - _hairItemId = content.Hair.ItemId; - _hairHue = content.Hair.Hue; - } - - if (content.FacialHair != null) - { - _facialHairItemId = content.FacialHair.ItemId; - _facialHairHue = content.FacialHair.Hue; - } - } - - // Folded Murderer bool field into CorpseFlag.Murderer - private void MigrateFrom(V15Content content) - { - _restoreEquip = content.RestoreEquip; - _flags = content.Flags; - if (content.Murderer) - { - _flags |= CorpseFlag.Murderer; - } - _timeOfDeath = content.TimeOfDeath; - _restoreTable = content.RestoreTable; - _decayTimer = new InternalTimer(this, content.DecayTimerDelay); - _decayTimer.Start(); - _looters = content.Looters; - _killer = content.Killer; - _aggressors = content.Aggressors; - _owner = content.Owner; - _corpseName = content.CorpseName; - _accessLevel = content.AccessLevel; - _guild = content.Guild; - _equipItems = content.EquipItems; - if (content.Hair != null) - { - _hairItemId = content.Hair.ItemId; - _hairHue = content.Hair.Hue; - } - - if (content.FacialHair != null) - { - _facialHairItemId = content.FacialHair.ItemId; - _facialHairHue = content.FacialHair.Hue; - } - } - - // Replaced int Kills snapshot with bool Murderer snapshot - private void MigrateFrom(V14Content content) - { - _restoreEquip = content.RestoreEquip; - _flags = content.Flags; - if (content.Kills >= 5) - { - _flags |= CorpseFlag.Murderer; - } - _timeOfDeath = content.TimeOfDeath; - _restoreTable = content.RestoreTable; - _decayTimer = new InternalTimer(this, content.DecayTimerDelay); - _decayTimer.Start(); - _looters = content.Looters; - _killer = content.Killer; - _aggressors = content.Aggressors; - _owner = content.Owner; - _corpseName = content.CorpseName; - _accessLevel = content.AccessLevel; - _guild = content.Guild; - _equipItems = content.EquipItems; - if (content.Hair != null) - { - _hairItemId = content.Hair.ItemId; - _hairHue = content.Hair.Hue; - } - - if (content.FacialHair != null) - { - _facialHairItemId = content.FacialHair.ItemId; - _facialHairHue = content.FacialHair.Hue; - } - } - - // Added corpse hair and corpse facial hair - private void MigrateFrom(V13Content content) - { - _restoreEquip = content.RestoreEquip; - _flags = content.Flags; - if (content.Kills >= 5) - { - _flags |= CorpseFlag.Murderer; - } - _timeOfDeath = content.TimeOfDeath; - _restoreTable = content.RestoreTable; - _decayTimer = new InternalTimer(this, content.DecayTimerDelay); - _decayTimer.Start(); - _looters = content.Looters; - _killer = content.Killer; - _aggressors = content.Aggressors; - _owner = content.Owner; - _corpseName = content.CorpseName; - _accessLevel = content.AccessLevel; - _guild = content.Guild; - _equipItems = content.EquipItems; - } - [CommandProperty(AccessLevel.GameMaster)] public virtual bool InstancedCorpse => Core.SE && Core.Now < TimeOfDeath + InstancedCorpseTime; diff --git a/Projects/UOContent/Items/Misc/Corpses/DecayedCorpse.cs b/Projects/UOContent/Items/Misc/Corpses/DecayedCorpse.cs index ead812e2f..17267a6cc 100644 --- a/Projects/UOContent/Items/Misc/Corpses/DecayedCorpse.cs +++ b/Projects/UOContent/Items/Misc/Corpses/DecayedCorpse.cs @@ -3,18 +3,25 @@ using ModernUO.Serialization; namespace Server.Items; -[SerializationGenerator(2, false)] +[SerializationGenerator(3, false)] public partial class DecayedCorpse : Container { private static TimeSpan _defaultDecayTime = TimeSpan.FromMinutes(7.0); - [TimerDrift] [SerializableField(0, getter: "private", setter: "private")] + [DeserializeTimer(nameof(DeserializeDecayTimer))] private Timer _decayTimer; - [DeserializeTimerField(0)] private void DeserializeDecayTimer(TimeSpan delay) => BeginDecay(delay); + private void MigrateFrom(V2Content content) + { + if (content.DecayTimerDelay != TimeSpan.MinValue) + { + DeserializeDecayTimer(content.DecayTimerDelay); + } + } + public DecayedCorpse(string name) : base(Utility.Random(0xECA, 9)) { Movable = false; diff --git a/Projects/UOContent/Items/Misc/MorphItem.cs b/Projects/UOContent/Items/Misc/MorphItem.cs index c5dd9c0de..7e5a74d8b 100644 --- a/Projects/UOContent/Items/Misc/MorphItem.cs +++ b/Projects/UOContent/Items/Misc/MorphItem.cs @@ -30,20 +30,24 @@ public partial class MorphItem : Item _outsideRange = outRange; } - [SerializableProperty(0)] - [CommandProperty(AccessLevel.GameMaster)] - public int OutsideRange + [SerializableField(0, allowFieldChange: nameof(AllowOutsideRangeChange))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private int _outsideRange; + + private bool AllowOutsideRangeChange(ref int value) { - get => _outsideRange; - set => _outsideRange = Math.Clamp(value, 0, 18); + value = Math.Clamp(value, 0, 18); + return true; } - [SerializableProperty(3)] - [CommandProperty(AccessLevel.GameMaster)] - public int InsideRange + [SerializableField(3, allowFieldChange: nameof(AllowInsideRangeChange))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private int _insideRange; + + private bool AllowInsideRangeChange(ref int value) { - get => _insideRange; - set => _insideRange = Math.Clamp(value, 0, 18); + value = Math.Clamp(value, 0, 18); + return true; } [CommandProperty(AccessLevel.GameMaster)] diff --git a/Projects/UOContent/Items/Misc/WarningItem.cs b/Projects/UOContent/Items/Misc/WarningItem.cs index 5d0b491a4..3779e4900 100644 --- a/Projects/UOContent/Items/Misc/WarningItem.cs +++ b/Projects/UOContent/Items/Misc/WarningItem.cs @@ -14,8 +14,16 @@ public partial class WarningItem : Item private TextDefinition _warningMessage; // Field 1 + [SerializableField(1, allowFieldChange: nameof(AllowRangeChange))] + [SerializedCommandProperty(AccessLevel.GameMaster)] private int _range; + private bool AllowRangeChange(ref int value) + { + value = Math.Min(value, 18); + return true; + } + [SerializableField(2)] private TimeSpan _resetDelay; @@ -39,18 +47,6 @@ public partial class WarningItem : Item _range = Math.Min(range, 18); } - [CommandProperty(AccessLevel.GameMaster)] - [SerializableProperty(1, useField: nameof(_range))] - public int Range - { - get => _range; - set - { - _range = Math.Min(value, 18); - this.MarkDirty(); - } - } - public virtual bool OnlyToTriggerer => false; public virtual int NeighborRange => 5; diff --git a/Projects/UOContent/Items/Quivers/BaseQuiver.cs b/Projects/UOContent/Items/Quivers/BaseQuiver.cs index 427871f16..70824c9f6 100644 --- a/Projects/UOContent/Items/Quivers/BaseQuiver.cs +++ b/Projects/UOContent/Items/Quivers/BaseQuiver.cs @@ -11,64 +11,62 @@ public partial class BaseQuiver : Container, ICraftable, IAosItem [SerializedIgnoreDupe] [SerializableField(0, setter: "private")] + [SaveFlag(nameof(ShouldSerializeAosAttributes), nameof(AttributesDefaultValue))] [SerializedCommandProperty(AccessLevel.GameMaster, canModify: true)] private AosAttributes _attributes; - [SerializableFieldSaveFlag(0)] private bool ShouldSerializeAosAttributes() => !_attributes.IsEmpty; - [SerializableFieldDefault(0)] private AosAttributes AttributesDefaultValue() => new(this); [InvalidateProperties] [SerializableField(1)] + [SaveFlag(nameof(ShouldSerializeLowerAmmoCost))] [SerializedCommandProperty(AccessLevel.GameMaster)] private int _lowerAmmoCost; - [SerializableFieldSaveFlag(1)] private bool ShouldSerializeLowerAmmoCost() => _lowerAmmoCost != 0; [InvalidateProperties] [SerializableField(2)] + [SaveFlag(nameof(ShouldSerializeWeightReduction))] [SerializedCommandProperty(AccessLevel.GameMaster)] private int _weightReduction; - [SerializableFieldSaveFlag(2)] private bool ShouldSerializeWeightReduction() => _weightReduction != 0; [InvalidateProperties] [SerializableField(3)] + [SaveFlag(nameof(ShouldSerializeDamageIncrease))] [SerializedCommandProperty(AccessLevel.GameMaster)] private int _damageIncrease; - [SerializableFieldSaveFlag(3)] private bool ShouldSerializeDamageIncrease() => _damageIncrease != 0; [InvalidateProperties] [SerializableField(4)] + [SaveFlag(nameof(ShouldSerializeCrafter))] [SerializedCommandProperty(AccessLevel.GameMaster)] private string _crafter; - [SerializableFieldSaveFlag(4)] private bool ShouldSerializeCrafter() => !string.IsNullOrEmpty(_crafter); [InvalidateProperties] [SerializableField(5)] + [SaveFlag(nameof(ShouldSerializeQuality), nameof(QualityDefaultValue))] [SerializedCommandProperty(AccessLevel.GameMaster)] private ClothingQuality _quality; - [SerializableFieldSaveFlag(5)] private bool ShouldSerializeQuality() => _quality != ClothingQuality.Regular; - [SerializableFieldDefault(5)] private ClothingQuality QualityDefaultValue() => ClothingQuality.Regular; [InvalidateProperties] [SerializableField(6)] + [SaveFlag(nameof(ShouldSerializeCapacity))] [SerializedCommandProperty(AccessLevel.GameMaster)] private int _capacity; - [SerializableFieldSaveFlag(6)] private bool ShouldSerializeCapacity() => _capacity != 0; public BaseQuiver(int itemID = 0x2FB7) : base(itemID) diff --git a/Projects/UOContent/Items/Resources/Blacksmithing/Ingots.cs b/Projects/UOContent/Items/Resources/Blacksmithing/Ingots.cs index ce772da40..8170b286d 100644 --- a/Projects/UOContent/Items/Resources/Blacksmithing/Ingots.cs +++ b/Projects/UOContent/Items/Resources/Blacksmithing/Ingots.cs @@ -16,22 +16,14 @@ public abstract partial class BaseIngot : Item, ICommodity public override double DefaultWeight => 0.1; - [SerializableProperty(0)] - [CommandProperty(AccessLevel.GameMaster)] - public CraftResource Resource - { - get => _resource; - set - { - if (_resource != value) - { - _resource = value; - Hue = CraftResources.GetHue(value); + [SerializableField(0, fieldChanged: nameof(OnResourceChanged))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + [InvalidateProperties] + private CraftResource _resource; - InvalidateProperties(); - this.MarkDirty(); - } - } + private void OnResourceChanged(CraftResource oldValue, CraftResource newValue) + { + Hue = CraftResources.GetHue(newValue); } public override int LabelNumber diff --git a/Projects/UOContent/Items/Resources/Blacksmithing/Ore.cs b/Projects/UOContent/Items/Resources/Blacksmithing/Ore.cs index 3116d7136..6ae0c71c3 100644 --- a/Projects/UOContent/Items/Resources/Blacksmithing/Ore.cs +++ b/Projects/UOContent/Items/Resources/Blacksmithing/Ore.cs @@ -17,22 +17,14 @@ public abstract partial class BaseOre : Item _resource = resource; } - [SerializableProperty(0)] - [CommandProperty(AccessLevel.GameMaster)] - public CraftResource Resource - { - get => _resource; - set - { - if (_resource != value) - { - _resource = value; - Hue = CraftResources.GetHue(value); + [SerializableField(0, fieldChanged: nameof(OnResourceChanged))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + [InvalidateProperties] + private CraftResource _resource; - InvalidateProperties(); - this.MarkDirty(); - } - } + private void OnResourceChanged(CraftResource oldValue, CraftResource newValue) + { + Hue = CraftResources.GetHue(newValue); } public override int LabelNumber diff --git a/Projects/UOContent/Items/Resources/Blacksmithing/Scales.cs b/Projects/UOContent/Items/Resources/Blacksmithing/Scales.cs index fa63d902c..277ed569d 100644 --- a/Projects/UOContent/Items/Resources/Blacksmithing/Scales.cs +++ b/Projects/UOContent/Items/Resources/Blacksmithing/Scales.cs @@ -14,22 +14,14 @@ public abstract partial class BaseScales : Item, ICommodity _resource = resource; } - [SerializableProperty(0)] - [CommandProperty(AccessLevel.GameMaster)] - public CraftResource Resource - { - get => _resource; - set - { - if (_resource != value) - { - _resource = value; - Hue = CraftResources.GetHue(value); + [SerializableField(0, fieldChanged: nameof(OnResourceChanged))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + [InvalidateProperties] + private CraftResource _resource; - InvalidateProperties(); - this.MarkDirty(); - } - } + private void OnResourceChanged(CraftResource oldValue, CraftResource newValue) + { + Hue = CraftResources.GetHue(newValue); } public override int LabelNumber => 1053139; // dragon scales diff --git a/Projects/UOContent/Items/Resources/Masonry/Granite.cs b/Projects/UOContent/Items/Resources/Masonry/Granite.cs index ae4f215d7..089181ebe 100644 --- a/Projects/UOContent/Items/Resources/Masonry/Granite.cs +++ b/Projects/UOContent/Items/Resources/Masonry/Granite.cs @@ -15,22 +15,14 @@ public abstract partial class BaseGranite : Item public override double DefaultWeight => Core.ML ? 1.0 : 10.0; - [SerializableProperty(0)] - [CommandProperty(AccessLevel.GameMaster)] - public CraftResource Resource - { - get => _resource; - set - { - if (_resource != value) - { - _resource = value; - Hue = CraftResources.GetHue(value); + [SerializableField(0, fieldChanged: nameof(OnResourceChanged))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + [InvalidateProperties] + private CraftResource _resource; - InvalidateProperties(); - this.MarkDirty(); - } - } + private void OnResourceChanged(CraftResource oldValue, CraftResource newValue) + { + Hue = CraftResources.GetHue(newValue); } public override int LabelNumber => 1044607; // high quality granite diff --git a/Projects/UOContent/Items/Resources/Tailor/Hides.cs b/Projects/UOContent/Items/Resources/Tailor/Hides.cs index df6423fb2..d64eb2f0b 100644 --- a/Projects/UOContent/Items/Resources/Tailor/Hides.cs +++ b/Projects/UOContent/Items/Resources/Tailor/Hides.cs @@ -15,22 +15,14 @@ public abstract partial class BaseHides : Item, ICommodity public override double DefaultWeight => 5.0; - [SerializableProperty(0)] - [CommandProperty(AccessLevel.GameMaster)] - public CraftResource Resource - { - get => _resource; - set - { - if (_resource != value) - { - _resource = value; - Hue = CraftResources.GetHue(value); + [SerializableField(0, fieldChanged: nameof(OnResourceChanged))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + [InvalidateProperties] + private CraftResource _resource; - InvalidateProperties(); - this.MarkDirty(); - } - } + private void OnResourceChanged(CraftResource oldValue, CraftResource newValue) + { + Hue = CraftResources.GetHue(newValue); } public override int LabelNumber diff --git a/Projects/UOContent/Items/Resources/Tailor/Leathers.cs b/Projects/UOContent/Items/Resources/Tailor/Leathers.cs index 173e2dae0..afb757e1f 100644 --- a/Projects/UOContent/Items/Resources/Tailor/Leathers.cs +++ b/Projects/UOContent/Items/Resources/Tailor/Leathers.cs @@ -15,22 +15,14 @@ public abstract partial class BaseLeather : Item, ICommodity public override double DefaultWeight => 1.0; - [SerializableProperty(0)] - [CommandProperty(AccessLevel.GameMaster)] - public CraftResource Resource - { - get => _resource; - set - { - if (_resource != value) - { - _resource = value; - Hue = CraftResources.GetHue(value); + [SerializableField(0, fieldChanged: nameof(OnResourceChanged))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + [InvalidateProperties] + private CraftResource _resource; - InvalidateProperties(); - this.MarkDirty(); - } - } + private void OnResourceChanged(CraftResource oldValue, CraftResource newValue) + { + Hue = CraftResources.GetHue(newValue); } public override int LabelNumber diff --git a/Projects/UOContent/Items/Skill Items/Carpenter Items/Board.cs b/Projects/UOContent/Items/Skill Items/Carpenter Items/Board.cs index 9ff0d8386..53c8077f1 100644 --- a/Projects/UOContent/Items/Skill Items/Carpenter Items/Board.cs +++ b/Projects/UOContent/Items/Skill Items/Carpenter Items/Board.cs @@ -21,22 +21,14 @@ public partial class Board : Item, ICommodity Hue = CraftResources.GetHue(resource); } - [SerializableProperty(0)] - [CommandProperty(AccessLevel.GameMaster)] - public CraftResource Resource - { - get => _resource; - set - { - if (_resource != value) - { - _resource = value; - Hue = CraftResources.GetHue(value); + [SerializableField(0, fieldChanged: nameof(OnResourceChanged))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + [InvalidateProperties] + private CraftResource _resource; - InvalidateProperties(); - this.MarkDirty(); - } - } + private void OnResourceChanged(CraftResource oldValue, CraftResource newValue) + { + Hue = CraftResources.GetHue(newValue); } int ICommodity.DescriptionNumber diff --git a/Projects/UOContent/Items/Skill Items/Fishing/Misc/MessageInABottle.cs b/Projects/UOContent/Items/Skill Items/Fishing/Misc/MessageInABottle.cs index 5d6062099..bf626c2fd 100644 --- a/Projects/UOContent/Items/Skill Items/Fishing/Misc/MessageInABottle.cs +++ b/Projects/UOContent/Items/Skill Items/Fishing/Misc/MessageInABottle.cs @@ -25,16 +25,14 @@ public partial class MessageInABottle : Item public override int LabelNumber => 1041080; // a message in a bottle - [SerializableProperty(0)] - [CommandProperty(AccessLevel.GameMaster)] - public int Level + [SerializableField(0, allowFieldChange: nameof(AllowLevelChange))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private int _level; + + private bool AllowLevelChange(ref int value) { - get => _level; - set - { - _level = Math.Max(1, Math.Min(value, 4)); - this.MarkDirty(); - } + value = Math.Max(1, Math.Min(value, 4)); + return true; } public static int GetRandomLevel() diff --git a/Projects/UOContent/Items/Skill Items/Fishing/Misc/SOS.cs b/Projects/UOContent/Items/Skill Items/Fishing/Misc/SOS.cs index f8450e63f..5374f8716 100644 --- a/Projects/UOContent/Items/Skill Items/Fishing/Misc/SOS.cs +++ b/Projects/UOContent/Items/Skill Items/Fishing/Misc/SOS.cs @@ -103,18 +103,20 @@ public partial class SOS : Item [CommandProperty(AccessLevel.GameMaster)] public bool IsAncient => _level >= 4; - [SerializableProperty(0)] - [CommandProperty(AccessLevel.GameMaster)] - public int Level + [SerializableField(0, fieldChanged: nameof(OnLevelChanged), allowFieldChange: nameof(AllowLevelChange))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + [InvalidateProperties] + private int _level; + + private bool AllowLevelChange(ref int value) { - get => _level; - set - { - _level = Math.Max(1, Math.Min(value, 4)); - UpdateHue(); - InvalidateProperties(); - this.MarkDirty(); - } + value = Math.Max(1, Math.Min(value, 4)); + return true; + } + + private void OnLevelChanged(int oldValue, int newValue) + { + UpdateHue(); } public void UpdateHue() => Hue = IsAncient ? 0x481 : 0; diff --git a/Projects/UOContent/Items/Skill Items/Lumberjack/Log.cs b/Projects/UOContent/Items/Skill Items/Lumberjack/Log.cs index 2873957d5..2a06f535a 100644 --- a/Projects/UOContent/Items/Skill Items/Lumberjack/Log.cs +++ b/Projects/UOContent/Items/Skill Items/Lumberjack/Log.cs @@ -28,22 +28,14 @@ public partial class Log : Item, ICommodity, IAxe public override double DefaultWeight => 2.0; - [SerializableProperty(0)] - [CommandProperty(AccessLevel.GameMaster)] - public CraftResource Resource - { - get => _resource; - set - { - if (_resource != value) - { - _resource = value; - Hue = CraftResources.GetHue(value); + [SerializableField(0, fieldChanged: nameof(OnResourceChanged))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + [InvalidateProperties] + private CraftResource _resource; - InvalidateProperties(); - this.MarkDirty(); - } - } + private void OnResourceChanged(CraftResource oldValue, CraftResource newValue) + { + Hue = CraftResources.GetHue(newValue); } public virtual bool Axe(Mobile from, BaseAxe axe) => TryCreateBoards(from, 0, new Board()); diff --git a/Projects/UOContent/Items/Skill Items/Magical/Misc/RecallRune.cs b/Projects/UOContent/Items/Skill Items/Magical/Misc/RecallRune.cs index 68b6f324a..14db7ac1e 100644 --- a/Projects/UOContent/Items/Skill Items/Magical/Misc/RecallRune.cs +++ b/Projects/UOContent/Items/Skill Items/Magical/Misc/RecallRune.cs @@ -47,36 +47,24 @@ public partial class RecallRune : Item } } - [SerializableProperty(2)] - [CommandProperty(AccessLevel.Counselor, AccessLevel.GameMaster)] - public bool Marked + [SerializableField(2, fieldChanged: nameof(OnMarkedChanged))] + [SerializedCommandProperty(AccessLevel.Counselor, AccessLevel.GameMaster)] + [InvalidateProperties] + private bool _marked; + + private void OnMarkedChanged(bool oldValue, bool newValue) { - get => _marked; - set - { - if (_marked != value) - { - _marked = value; - CalculateHue(); - InvalidateProperties(); - } - } + CalculateHue(); } - [SerializableProperty(4)] - [CommandProperty(AccessLevel.Counselor, AccessLevel.GameMaster)] - public Map TargetMap + [SerializableField(4, fieldChanged: nameof(OnTargetMapChanged))] + [SerializedCommandProperty(AccessLevel.Counselor, AccessLevel.GameMaster)] + [InvalidateProperties] + private Map _targetMap; + + private void OnTargetMapChanged(Map oldValue, Map newValue) { - get => _targetMap; - set - { - if (_targetMap != value) - { - _targetMap = value; - CalculateHue(); - InvalidateProperties(); - } - } + CalculateHue(); } private void Deserialize(IGenericReader reader, int version) diff --git a/Projects/UOContent/Items/Skill Items/Magical/Runebook.cs b/Projects/UOContent/Items/Skill Items/Magical/Runebook.cs index 0a5a0f308..f9ecdfa22 100644 --- a/Projects/UOContent/Items/Skill Items/Magical/Runebook.cs +++ b/Projects/UOContent/Items/Skill Items/Magical/Runebook.cs @@ -371,27 +371,27 @@ public partial class RunebookEntry private Runebook _runebook; [SerializableField(0)] + [SaveFlag(nameof(ShouldSerializeHouse))] private BaseHouse _house; - [SerializableFieldSaveFlag(0)] public bool ShouldSerializeHouse() => _house?.Deleted == false; [SerializableField(1)] + [SaveFlag(nameof(ShouldSerializeLocation))] private Point3D _location; - [SerializableFieldSaveFlag(1)] public bool ShouldSerializeLocation() => _house?.Deleted != false; [SerializableField(2)] + [SaveFlag(nameof(ShouldSerializeMap))] private Map _map; - [SerializableFieldSaveFlag(2)] public bool ShouldSerializeMap() => _house?.Deleted != false; [SerializableField(3)] + [SaveFlag(nameof(ShouldSerializeDesc))] private string _description; - [SerializableFieldSaveFlag(3)] public bool ShouldSerializeDesc() => _house?.Deleted != false; public RunebookEntry( diff --git a/Projects/UOContent/Items/Skill Items/Magical/Spellbook.cs b/Projects/UOContent/Items/Skill Items/Magical/Spellbook.cs index f1ced7308..9447fddfa 100644 --- a/Projects/UOContent/Items/Skill Items/Magical/Spellbook.cs +++ b/Projects/UOContent/Items/Skill Items/Magical/Spellbook.cs @@ -133,28 +133,19 @@ public partial class Spellbook : Item, ICraftable, ISlayer, IAosItem public virtual int BookOffset => 0; public virtual int BookCount => 64; - [CommandProperty(AccessLevel.GameMaster)] - [SerializableProperty(7)] - public ulong Content + [SerializableField(7, fieldChanged: nameof(OnContentChanged))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + [InvalidateProperties] + private ulong _content; + + private void OnContentChanged(ulong oldValue, ulong newValue) { - get => _content; - set + // This assignment will mark it as dirty + SpellCount = 0; + while (newValue > 0) { - if (_content != value) - { - _content = value; - - // This assignment will mark it as dirty - SpellCount = 0; - - while (value > 0) - { - _spellCount += (int)(value & 0x1); - value >>= 1; - } - - InvalidateProperties(); - } + _spellCount += (int)(newValue & 0x1); + newValue >>= 1; } } diff --git a/Projects/UOContent/Items/Skill Items/Misc/RepairDeed.cs b/Projects/UOContent/Items/Skill Items/Misc/RepairDeed.cs index dbb8df68c..e4d486d10 100644 --- a/Projects/UOContent/Items/Skill Items/Misc/RepairDeed.cs +++ b/Projects/UOContent/Items/Skill Items/Misc/RepairDeed.cs @@ -62,17 +62,15 @@ public partial class RepairDeed : Item public override bool DisplayLootType => false; - [CommandProperty(AccessLevel.GameMaster)] - [SerializableProperty(1)] - public double SkillLevel + [SerializableField(1, allowFieldChange: nameof(AllowSkillLevelChange))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + [InvalidateProperties] + private double _skillLevel; + + private bool AllowSkillLevelChange(ref double value) { - get => _skillLevel; - set - { - _skillLevel = Math.Clamp(value, 0, 120.0); - InvalidateProperties(); - this.MarkDirty(); - } + value = Math.Clamp(value, 0, 120.0); + return true; } public override void AddNameProperty(IPropertyList list) diff --git a/Projects/UOContent/Items/Skill Items/Musical Instruments/BaseInstrument.cs b/Projects/UOContent/Items/Skill Items/Musical Instruments/BaseInstrument.cs index b01416211..f7716a4a1 100644 --- a/Projects/UOContent/Items/Skill Items/Musical Instruments/BaseInstrument.cs +++ b/Projects/UOContent/Items/Skill Items/Musical Instruments/BaseInstrument.cs @@ -74,16 +74,13 @@ public abstract partial class BaseInstrument : Item, ICraftable, ISlayer } } - [SerializableProperty(1)] - [CommandProperty(AccessLevel.GameMaster)] - public DateTime LastReplenished + [SerializableField(1, fieldChanged: nameof(OnLastReplenishedChanged))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private DateTime _lastReplenished; + + private void OnLastReplenishedChanged(DateTime oldValue, DateTime newValue) { - get => _lastReplenished; - set - { - _lastReplenished = value; - CheckReplenishUses(); - } + CheckReplenishUses(); } [SerializableProperty(3)] diff --git a/Projects/UOContent/Items/Skill Items/Tailor Items/Dyetubs/DyeTub.cs b/Projects/UOContent/Items/Skill Items/Tailor Items/Dyetubs/DyeTub.cs index 6b557b7be..e35cca471 100644 --- a/Projects/UOContent/Items/Skill Items/Tailor Items/Dyetubs/DyeTub.cs +++ b/Projects/UOContent/Items/Skill Items/Tailor Items/Dyetubs/DyeTub.cs @@ -41,20 +41,13 @@ namespace Server.Items public virtual bool AllowDyables => true; - [SerializableProperty(2)] - [CommandProperty(AccessLevel.GameMaster)] - public int DyedHue - { - get => _dyedHue; - set - { - if (_redyable) - { - _dyedHue = value; - Hue = value; - } - } - } + [SerializableField(2, allowFieldChange: nameof(AllowDyedHueChange), fieldChanged: nameof(OnDyedHueChanged))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private int _dyedHue; + + private bool AllowDyedHueChange(ref int value) => _redyable; + + private void OnDyedHueChanged(int oldValue, int newValue) => Hue = newValue; // Three metallic tubs now. public virtual bool MetallicHues => false; diff --git a/Projects/UOContent/Items/Skill Items/Tools/BaseRunicTool.cs b/Projects/UOContent/Items/Skill Items/Tools/BaseRunicTool.cs index 6010ec376..595b6b11d 100644 --- a/Projects/UOContent/Items/Skill Items/Tools/BaseRunicTool.cs +++ b/Projects/UOContent/Items/Skill Items/Tools/BaseRunicTool.cs @@ -60,17 +60,14 @@ public abstract partial class BaseRunicTool : BaseTool public BaseRunicTool(CraftResource resource, int uses, int itemID) : base(uses, itemID) => _resource = resource; - [SerializableProperty(0)] - [CommandProperty(AccessLevel.GameMaster)] - public CraftResource Resource + [SerializableField(0, fieldChanged: nameof(OnResourceChanged))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + [InvalidateProperties] + private CraftResource _resource; + + private void OnResourceChanged(CraftResource oldValue, CraftResource newValue) { - get => _resource; - set - { - _resource = value; - Hue = CraftResources.GetHue(_resource); - InvalidateProperties(); - } + Hue = CraftResources.GetHue(_resource); } private void Deserialize(IGenericReader reader, int version) diff --git a/Projects/UOContent/Items/Special/8th Anniversary Items/FountainOfLife.cs b/Projects/UOContent/Items/Special/8th Anniversary Items/FountainOfLife.cs index 3bdfd00fa..404fc9012 100644 --- a/Projects/UOContent/Items/Special/8th Anniversary Items/FountainOfLife.cs +++ b/Projects/UOContent/Items/Special/8th Anniversary Items/FountainOfLife.cs @@ -31,6 +31,7 @@ public partial class FountainOfLife : BaseAddonContainer public const int MaxCharges = 10; [SerializableField(1)] + [DeserializeTimer(nameof(DeserializeTimer), wallClock: true)] private Timer _timer; [Constructible] @@ -39,7 +40,6 @@ public partial class FountainOfLife : BaseAddonContainer _charges = charges; } - [DeserializeTimerField(1)] private void DeserializeTimer(TimeSpan delay) { _timer = Timer.DelayCall(Utility.Max(delay, TimeSpan.Zero), RechargeTime, Recharge); @@ -54,17 +54,15 @@ public partial class FountainOfLife : BaseAddonContainer public override int DefaultDropSound => 66; public override int DefaultMaxItems => 125; - [SerializableProperty(0)] - [CommandProperty(AccessLevel.GameMaster)] - public int Charges + [SerializableField(0, allowFieldChange: nameof(AllowChargesChange))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + [InvalidateProperties] + private int _charges; + + private bool AllowChargesChange(ref int value) { - get => _charges; - set - { - _charges = Math.Min(value, MaxCharges); - InvalidateProperties(); - this.MarkDirty(); - } + value = Math.Min(value, MaxCharges); + return true; } public override bool OnDragLift(Mobile from) => false; @@ -183,16 +181,14 @@ public partial class FountainOfLifeDeed : BaseAddonContainerDeed public override int LabelNumber => 1075197; // Fountain of Life public override BaseAddonContainer Addon => new FountainOfLife(_charges); - [SerializableProperty(0)] - [CommandProperty(AccessLevel.GameMaster)] - public int Charges + [SerializableField(0, allowFieldChange: nameof(AllowChargesChange))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + [InvalidateProperties] + private int _charges; + + private bool AllowChargesChange(ref int value) { - get => _charges; - set - { - _charges = Math.Min(value, FountainOfLife.MaxCharges); - InvalidateProperties(); - this.MarkDirty(); - } + value = Math.Min(value, FountainOfLife.MaxCharges); + return true; } } diff --git a/Projects/UOContent/Items/Special/Heritage Items/FruitTrees.cs b/Projects/UOContent/Items/Special/Heritage Items/FruitTrees.cs index 6a7e5af6f..fabb756d8 100644 --- a/Projects/UOContent/Items/Special/Heritage Items/FruitTrees.cs +++ b/Projects/UOContent/Items/Special/Heritage Items/FruitTrees.cs @@ -14,12 +14,14 @@ public abstract partial class BaseFruitTreeAddon : BaseAddon public abstract override BaseAddonDeed Deed { get; } public abstract Item Fruit { get; } - [SerializableProperty(0)] - [CommandProperty(AccessLevel.GameMaster)] - public int Fruits + [SerializableField(0, allowFieldChange: nameof(AllowFruitsChange))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private int _fruits; + + private bool AllowFruitsChange(ref int value) { - get => _fruits; - set => _fruits = Math.Max(value, 0); + value = Math.Max(value, 0); + return true; } public override void OnComponentUsed(AddonComponent c, Mobile from) diff --git a/Projects/UOContent/Items/Special/House Raffle/HouseRaffleStone.cs b/Projects/UOContent/Items/Special/House Raffle/HouseRaffleStone.cs index c24985f9f..59e2b8277 100644 --- a/Projects/UOContent/Items/Special/House Raffle/HouseRaffleStone.cs +++ b/Projects/UOContent/Items/Special/House Raffle/HouseRaffleStone.cs @@ -146,34 +146,24 @@ public partial class HouseRaffleStone : Item } } - [SerializableProperty(3)] - [CommandProperty(AccessLevel.GameMaster, AccessLevel.Seer)] - public Rectangle2D PlotBounds - { - get => _plotBounds; - set - { - _plotBounds = value; + [SerializableField(3, fieldChanged: nameof(OnPlotBoundsChanged))] + [SerializedCommandProperty(AccessLevel.GameMaster, AccessLevel.Seer)] + [InvalidateProperties] + private Rectangle2D _plotBounds; - InvalidateRegion(); - InvalidateProperties(); - this.MarkDirty(); - } + private void OnPlotBoundsChanged(Rectangle2D oldValue, Rectangle2D newValue) + { + InvalidateRegion(); } - [SerializableProperty(4)] - [CommandProperty(AccessLevel.GameMaster, AccessLevel.Seer)] - public Map PlotFacet - { - get => _plotFacet; - set - { - _plotFacet = value; + [SerializableField(4, fieldChanged: nameof(OnPlotFacetChanged))] + [SerializedCommandProperty(AccessLevel.GameMaster, AccessLevel.Seer)] + [InvalidateProperties] + private Map _plotFacet; - InvalidateRegion(); - InvalidateProperties(); - this.MarkDirty(); - } + private void OnPlotFacetChanged(Map oldValue, Map newValue) + { + InvalidateRegion(); } [CommandProperty(AccessLevel.GameMaster)] @@ -190,17 +180,15 @@ public partial class HouseRaffleStone : Item } } - [SerializableProperty(6)] - [CommandProperty(AccessLevel.GameMaster, AccessLevel.Seer)] - public int TicketPrice + [SerializableField(6, allowFieldChange: nameof(AllowTicketPriceChange))] + [SerializedCommandProperty(AccessLevel.GameMaster, AccessLevel.Seer)] + [InvalidateProperties] + private int _ticketPrice; + + private bool AllowTicketPriceChange(ref int value) { - get => _ticketPrice; - set - { - _ticketPrice = Math.Max(0, value); - InvalidateProperties(); - this.MarkDirty(); - } + value = Math.Max(0, value); + return true; } public override string DefaultName => "a house raffle stone"; diff --git a/Projects/UOContent/Items/Special/MonsterStatuette.cs b/Projects/UOContent/Items/Special/MonsterStatuette.cs index 8d49dd0e4..fd26a0060 100644 --- a/Projects/UOContent/Items/Special/MonsterStatuette.cs +++ b/Projects/UOContent/Items/Special/MonsterStatuette.cs @@ -162,21 +162,15 @@ public partial class MonsterStatuette : Item, IRewardItem, IGumpToggleItem _ => fallback }; - [SerializableProperty(0)] - [CommandProperty(AccessLevel.GameMaster)] - public MonsterStatuetteType Type + [SerializableField(0, fieldChanged: nameof(OnTypeChanged))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + [InvalidateProperties] + private MonsterStatuetteType _type; + + private void OnTypeChanged(MonsterStatuetteType oldValue, MonsterStatuetteType newValue) { - get => _type; - set - { - _type = value; - ItemID = MonsterStatuetteInfo.GetInfo(_type).ItemID; - - Hue = GetStatuetteHue(_type, Hue); - - InvalidateProperties(); - this.MarkDirty(); - } + ItemID = MonsterStatuetteInfo.GetInfo(_type).ItemID; + Hue = GetStatuetteHue(_type, Hue); } public override int LabelNumber => MonsterStatuetteInfo.GetInfo(_type).LabelNumber; diff --git a/Projects/UOContent/Items/Special/Solen Items/BagOfSending.cs b/Projects/UOContent/Items/Special/Solen Items/BagOfSending.cs index f2772b342..9441fd5c7 100644 --- a/Projects/UOContent/Items/Special/Solen Items/BagOfSending.cs +++ b/Projects/UOContent/Items/Special/Solen Items/BagOfSending.cs @@ -36,50 +36,41 @@ public partial class BagOfSending : Item, TranslocationItem public override int LabelNumber => 1054104; // a bag of sending - [SerializableProperty(0)] - [CommandProperty(AccessLevel.GameMaster)] - public BagOfSendingHue BagOfSendingHue - { - get => _bagOfSendingHue; - set - { - _bagOfSendingHue = value; + [SerializableField(0, fieldChanged: nameof(OnBagOfSendingHueChanged))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private BagOfSendingHue _bagOfSendingHue; - Hue = value switch - { - BagOfSendingHue.Yellow => 0x8A5, - BagOfSendingHue.Blue => 0x8AD, - BagOfSendingHue.Red => 0x89B, - _ => Hue - }; - this.MarkDirty(); - } + private void OnBagOfSendingHueChanged(BagOfSendingHue oldValue, BagOfSendingHue newValue) + { + Hue = newValue switch + { + BagOfSendingHue.Yellow => 0x8A5, + BagOfSendingHue.Blue => 0x8AD, + BagOfSendingHue.Red => 0x89B, + _ => Hue + }; } - [SerializableProperty(1)] - [CommandProperty(AccessLevel.GameMaster)] - public int Charges + [SerializableField(1, allowFieldChange: nameof(AllowChargesChange))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + [InvalidateProperties] + private int _charges; + + private bool AllowChargesChange(ref int value) { - get => _charges; - set - { - _charges = Math.Clamp(value, 0, MaxCharges); - InvalidateProperties(); - this.MarkDirty(); - } + value = Math.Clamp(value, 0, MaxCharges); + return true; } - [SerializableProperty(2)] - [CommandProperty(AccessLevel.GameMaster)] - public int Recharges + [SerializableField(2, allowFieldChange: nameof(AllowRechargesChange))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + [InvalidateProperties] + private int _recharges; + + private bool AllowRechargesChange(ref int value) { - get => _recharges; - set - { - _recharges = Math.Clamp(value, 0, MaxRecharges); - InvalidateProperties(); - this.MarkDirty(); - } + value = Math.Clamp(value, 0, MaxRecharges); + return true; } [CommandProperty(AccessLevel.GameMaster)] diff --git a/Projects/UOContent/Items/Special/Solen Items/BallOfSummoning.cs b/Projects/UOContent/Items/Special/Solen Items/BallOfSummoning.cs index a76833e30..070a802b1 100644 --- a/Projects/UOContent/Items/Special/Solen Items/BallOfSummoning.cs +++ b/Projects/UOContent/Items/Special/Solen Items/BallOfSummoning.cs @@ -29,30 +29,26 @@ public partial class BallOfSummoning : Item, TranslocationItem public override double DefaultWeight => 10.0; - [SerializableProperty(0)] - [CommandProperty(AccessLevel.GameMaster)] - public int Recharges + [SerializableField(0, allowFieldChange: nameof(AllowRechargesChange))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + [InvalidateProperties] + private int _recharges; + + private bool AllowRechargesChange(ref int value) { - get => _recharges; - set - { - _recharges = Math.Clamp(value, 0, MaxRecharges); - InvalidateProperties(); - this.MarkDirty(); - } + value = Math.Clamp(value, 0, MaxRecharges); + return true; } - [SerializableProperty(1)] - [CommandProperty(AccessLevel.GameMaster)] - public int Charges + [SerializableField(1, allowFieldChange: nameof(AllowChargesChange))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + [InvalidateProperties] + private int _charges; + + private bool AllowChargesChange(ref int value) { - get => _charges; - set - { - _charges = Math.Clamp(value, 0, MaxCharges); - InvalidateProperties(); - this.MarkDirty(); - } + value = Math.Clamp(value, 0, MaxCharges); + return true; } [SerializableProperty(2)] diff --git a/Projects/UOContent/Items/Special/Solen Items/BraceletOfBinding.cs b/Projects/UOContent/Items/Special/Solen Items/BraceletOfBinding.cs index 2f82791c2..1d508c4ac 100644 --- a/Projects/UOContent/Items/Special/Solen Items/BraceletOfBinding.cs +++ b/Projects/UOContent/Items/Special/Solen Items/BraceletOfBinding.cs @@ -27,30 +27,26 @@ public partial class BraceletOfBinding : BaseBracelet, TranslocationItem public override double DefaultWeight => 1.0; - [SerializableProperty(0)] - [CommandProperty(AccessLevel.GameMaster)] - public int Recharges + [SerializableField(0, allowFieldChange: nameof(AllowRechargesChange))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + [InvalidateProperties] + private int _recharges; + + private bool AllowRechargesChange(ref int value) { - get => _recharges; - set - { - _recharges = Math.Clamp(value, 0, MaxRecharges); - InvalidateProperties(); - this.MarkDirty(); - } + value = Math.Clamp(value, 0, MaxRecharges); + return true; } - [SerializableProperty(1)] - [CommandProperty(AccessLevel.GameMaster)] - public int Charges + [SerializableField(1, allowFieldChange: nameof(AllowChargesChange))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + [InvalidateProperties] + private int _charges; + + private bool AllowChargesChange(ref int value) { - get => _charges; - set - { - _charges = Math.Clamp(value, 0, MaxCharges); - InvalidateProperties(); - this.MarkDirty(); - } + value = Math.Clamp(value, 0, MaxCharges); + return true; } [SerializableProperty(3)] diff --git a/Projects/UOContent/Items/Special/SoulStone.cs b/Projects/UOContent/Items/Special/SoulStone.cs index bcad4a749..d26f6792c 100644 --- a/Projects/UOContent/Items/Special/SoulStone.cs +++ b/Projects/UOContent/Items/Special/SoulStone.cs @@ -83,20 +83,14 @@ public partial class SoulStone : Item, ISecurable } } - [SerializableProperty(6)] - [CommandProperty(AccessLevel.GameMaster)] - public double SkillValue + [SerializableField(6, fieldChanged: nameof(OnSkillValueChanged))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + [InvalidateProperties] + private double _skillValue; + + private void OnSkillValueChanged(double oldValue, double newValue) { - get => _skillValue; - set - { - _skillValue = value; - - ItemID = IsEmpty ? _inactiveItemID : _activeItemID; - - InvalidateProperties(); - this.MarkDirty(); - } + ItemID = IsEmpty ? _inactiveItemID : _activeItemID; } [CommandProperty(AccessLevel.GameMaster)] diff --git a/Projects/UOContent/Items/Suits/BaseSuit.cs b/Projects/UOContent/Items/Suits/BaseSuit.cs index 018c010a2..dd1eb00f6 100644 --- a/Projects/UOContent/Items/Suits/BaseSuit.cs +++ b/Projects/UOContent/Items/Suits/BaseSuit.cs @@ -17,20 +17,9 @@ public abstract partial class BaseSuit : Item public override double DefaultWeight => 1.0; - [SerializableProperty(0)] - public AccessLevel AccessLevel - { - get => _accessLevel; - set - { - var oldAccessLevel = _accessLevel; - _accessLevel = value; - InvalidateProperties(); - this.MarkDirty(); - - OnAccessLevelChanged(oldAccessLevel, _accessLevel); - } - } + [SerializableField(0, fieldChanged: nameof(OnAccessLevelChanged))] + [InvalidateProperties] + private AccessLevel _accessLevel; public virtual void OnAccessLevelChanged(AccessLevel oldAccessLevel, AccessLevel accessLevel) { diff --git a/Projects/UOContent/Items/Talismans/BaseTalisman.cs b/Projects/UOContent/Items/Talismans/BaseTalisman.cs index 632587523..b3f0c2ed4 100644 --- a/Projects/UOContent/Items/Talismans/BaseTalisman.cs +++ b/Projects/UOContent/Items/Talismans/BaseTalisman.cs @@ -128,136 +128,131 @@ public partial class BaseTalisman : Item, IAosItem [SerializedIgnoreDupe] [SerializableField(0, setter: "private")] + [SaveFlag(nameof(ShouldSerializeAttributes), nameof(AttributesDefaultValue))] [SerializedCommandProperty(AccessLevel.GameMaster, canModify: true)] private AosAttributes _attributes; - [SerializableFieldSaveFlag(0)] public bool ShouldSerializeAttributes() => !_attributes.IsEmpty; - [SerializableFieldDefault(0)] private AosAttributes AttributesDefaultValue() => new(this); [SerializedIgnoreDupe] [SerializableField(1, setter: "private")] + [SaveFlag(nameof(ShouldSerializeSkillBonuses), nameof(SkillBonusesDefaultValue))] [SerializedCommandProperty(AccessLevel.GameMaster, canModify: true)] private AosSkillBonuses _skillBonuses; - [SerializableFieldSaveFlag(1)] public bool ShouldSerializeSkillBonuses() => !_skillBonuses.IsEmpty; - [SerializableFieldDefault(1)] private AosSkillBonuses SkillBonusesDefaultValue() => new(this); [SerializedIgnoreDupe] [InvalidateProperties] [SerializableField(2)] + [SaveFlag(nameof(ShouldSerializeProtection), nameof(ProtectionDefaultValue))] [SerializedCommandProperty(AccessLevel.GameMaster, canModify: true)] private TalismanAttribute _protection; - [SerializableFieldSaveFlag(2)] public bool ShouldSerializeProtection() => !_protection.IsEmpty; - [SerializableFieldDefault(2)] private TalismanAttribute ProtectionDefaultValue() => new(); [SerializedIgnoreDupe] [InvalidateProperties] [SerializableField(3)] + [SaveFlag(nameof(ShouldSerializeKiller), nameof(KillerDefaultValue))] [SerializedCommandProperty(AccessLevel.GameMaster, canModify: true)] private TalismanAttribute _killer; - [SerializableFieldSaveFlag(3)] public bool ShouldSerializeKiller() => !_killer.IsEmpty; - [SerializableFieldDefault(3)] private TalismanAttribute KillerDefaultValue() => new(); [SerializedIgnoreDupe] [InvalidateProperties] [SerializableField(4)] + [SaveFlag(nameof(ShouldSerializeSummoner), nameof(SummonerDefaultValue))] [SerializedCommandProperty(AccessLevel.GameMaster, canModify: true)] private TalismanAttribute _summoner; - [SerializableFieldSaveFlag(4)] public bool ShouldSerializeSummoner() => !_summoner.IsEmpty; - [SerializableFieldDefault(4)] private TalismanAttribute SummonerDefaultValue() => new(); [InvalidateProperties] [SerializableField(5)] + [SaveFlag(nameof(ShouldSerializeRemoval))] [SerializedCommandProperty(AccessLevel.GameMaster)] private TalismanRemoval _removal; - [SerializableFieldSaveFlag(5)] public bool ShouldSerializeRemoval() => _removal != TalismanRemoval.None; [InvalidateProperties] [SerializableField(6)] + [SaveFlag(nameof(ShouldSerializeSkill))] [SerializedCommandProperty(AccessLevel.GameMaster)] private SkillName _skill; - [SerializableFieldSaveFlag(6)] public bool ShouldSerializeSkill() => (int)_skill != 0; [EncodedInt] [InvalidateProperties] [SerializableField(7)] + [SaveFlag(nameof(ShouldSerializeSuccessBonus))] [SerializedCommandProperty(AccessLevel.GameMaster)] private int _successBonus; - [SerializableFieldSaveFlag(7)] public bool ShouldSerializeSuccessBonus() => _successBonus != 0; [EncodedInt] [InvalidateProperties] [SerializableField(8)] + [SaveFlag(nameof(ShouldSerializeExceptionalBonus))] [SerializedCommandProperty(AccessLevel.GameMaster)] private int _exceptionalBonus; - [SerializableFieldSaveFlag(8)] public bool ShouldSerializeExceptionalBonus() => _exceptionalBonus != 0; [EncodedInt] [InvalidateProperties] [SerializableField(9)] + [SaveFlag(nameof(ShouldSerializeMaxCharges))] [SerializedCommandProperty(AccessLevel.GameMaster)] private int _maxCharges; - [SerializableFieldSaveFlag(9)] public bool ShouldSerializeMaxCharges() => _maxCharges != 0; [EncodedInt] [InvalidateProperties] [SerializableField(11)] + [SaveFlag(nameof(ShouldSerializeMaxChargeTime))] [SerializedCommandProperty(AccessLevel.GameMaster)] private int _maxChargeTime; - [SerializableFieldSaveFlag(11)] public bool ShouldSerializeMaxChargeTime() => _maxChargeTime != 0; [EncodedInt] [InvalidateProperties] [SerializableField(12)] + [SaveFlag(nameof(ShouldSerializeChargeTime))] private int _chargeTime; - [SerializableFieldSaveFlag(12)] public bool ShouldSerializeChargeTime() => _chargeTime != 0; [InvalidateProperties] [SerializableField(13)] + [SaveFlag(nameof(ShouldSerializeBlessed))] [SerializedCommandProperty(AccessLevel.GameMaster)] private bool _blessed; - [SerializableFieldSaveFlag(13)] public bool ShouldSerializeBlessed() => _blessed; [InvalidateProperties] [SerializableField(14)] + [SaveFlag(nameof(ShouldSerializeSlayer))] [SerializedCommandProperty(AccessLevel.GameMaster)] private TalismanSlayerName _slayer; - [SerializableFieldSaveFlag(14)] public bool ShouldSerializeSlayer() => _slayer != TalismanSlayerName.None; private BaseCreature _creature; @@ -284,26 +279,20 @@ public partial class BaseTalisman : Item, IAosItem public override int LabelNumber => 1071023; // Talisman public virtual bool ForceShowName => false; // used to override default summoner/removal name - [SerializableProperty(10)] - [CommandProperty(AccessLevel.GameMaster)] - public int Charges + [SerializableField(10, fieldChanged: nameof(OnChargesChanged))] + [SaveFlag(nameof(ShouldSerializeCharges))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + [InvalidateProperties] + private int _charges; + + private void OnChargesChanged(int oldValue, int newValue) { - get => _charges; - set + if (_chargeTime > 0) { - _charges = value; - - if (_chargeTime > 0) - { - StartTimer(); - } - - InvalidateProperties(); - this.MarkDirty(); + StartTimer(); } } - [SerializableFieldSaveFlag(10)] public bool ShouldSerializeCharges() => _charges != 0; public static void Configure() diff --git a/Projects/UOContent/Items/Weapons/BaseWeapon.cs b/Projects/UOContent/Items/Weapons/BaseWeapon.cs index 1f03daf63..d48824670 100644 --- a/Projects/UOContent/Items/Weapons/BaseWeapon.cs +++ b/Projects/UOContent/Items/Weapons/BaseWeapon.cs @@ -56,130 +56,126 @@ public abstract partial class BaseWeapon [InvalidateProperties] [SerializableField(0)] + [SaveFlag(nameof(ShouldSerializeDamageLevel))] [SerializedCommandProperty(AccessLevel.GameMaster)] private WeaponDamageLevel _damageLevel; - [SerializableFieldSaveFlag(0)] [MethodImpl(MethodImplOptions.AggressiveInlining)] private bool ShouldSerializeDamageLevel() => _damageLevel != WeaponDamageLevel.Regular; [InvalidateProperties] [SerializableField(5)] + [SaveFlag(nameof(ShouldSerializeMaxHitPoints))] [SerializedCommandProperty(AccessLevel.GameMaster)] private int _maxHitPoints; - [SerializableFieldSaveFlag(5)] [MethodImpl(MethodImplOptions.AggressiveInlining)] private bool ShouldSerializeMaxHitPoints() => _maxHitPoints != 0; [InvalidateProperties] [SerializableField(6)] + [SaveFlag(nameof(ShouldSerializeSlayer))] [SerializedCommandProperty(AccessLevel.GameMaster)] private SlayerName _slayer; - [SerializableFieldSaveFlag(6)] [MethodImpl(MethodImplOptions.AggressiveInlining)] private bool ShouldSerializeSlayer() => _slayer != SlayerName.None; [InvalidateProperties] [SerializableField(7)] + [SaveFlag(nameof(ShouldSerializePoison))] [SerializedCommandProperty(AccessLevel.GameMaster)] private Poison _poison; - [SerializableFieldSaveFlag(7)] [MethodImpl(MethodImplOptions.AggressiveInlining)] private bool ShouldSerializePoison() => _poison != null; [InvalidateProperties] [SerializableField(8)] + [SaveFlag(nameof(ShouldSerializePoisonCharges))] [SerializedCommandProperty(AccessLevel.GameMaster)] private int _poisonCharges; - [SerializableFieldSaveFlag(8)] [MethodImpl(MethodImplOptions.AggressiveInlining)] private bool ShouldSerializePoisonCharges() => _poisonCharges > 0; [InvalidateProperties] [SerializableField(9)] + [SaveFlag(nameof(ShouldSerializeCrafter))] [SerializedCommandProperty(AccessLevel.GameMaster)] private string _crafter; - [SerializableFieldSaveFlag(9)] [MethodImpl(MethodImplOptions.AggressiveInlining)] private bool ShouldSerializeCrafter() => !string.IsNullOrEmpty(_crafter); [InvalidateProperties] [SerializableField(10)] + [SaveFlag(nameof(ShouldSerializeIdentified))] [SerializedCommandProperty(AccessLevel.GameMaster)] private bool _identified; - [SerializableFieldSaveFlag(10)] [MethodImpl(MethodImplOptions.AggressiveInlining)] private bool ShouldSerializeIdentified() => _identified; [SerializedIgnoreDupe] [SerializableField(24, setter: "private")] + [SaveFlag(nameof(ShouldSerializeAttributes), nameof(AttributesDefaultValue))] [SerializedCommandProperty(AccessLevel.GameMaster, canModify: true)] private AosAttributes _attributes; - [SerializableFieldSaveFlag(24)] [MethodImpl(MethodImplOptions.AggressiveInlining)] private bool ShouldSerializeAttributes() => !_attributes.IsEmpty; - [SerializableFieldDefault(24)] private AosAttributes AttributesDefaultValue() => new(this); [SerializedIgnoreDupe] [SerializableField(25, setter: "private")] + [SaveFlag(nameof(ShouldSerializeWeaponAttributes), nameof(WeaponAttributesDefaultValue))] [SerializedCommandProperty(AccessLevel.GameMaster, canModify: true)] private AosWeaponAttributes _weaponAttributes; - [SerializableFieldSaveFlag(25)] [MethodImpl(MethodImplOptions.AggressiveInlining)] private bool ShouldSerializeWeaponAttributes() => !_weaponAttributes.IsEmpty; - [SerializableFieldDefault(25)] private AosWeaponAttributes WeaponAttributesDefaultValue() => new(this); [SerializedIgnoreDupe] [SerializableField(26, setter: "private")] + [SaveFlag(nameof(ShouldSerializeSkillBonuses), nameof(SkillBonusesDefaultValue))] [SerializedCommandProperty(AccessLevel.GameMaster, canModify: true)] private AosSkillBonuses _skillBonuses; - [SerializableFieldSaveFlag(26)] [MethodImpl(MethodImplOptions.AggressiveInlining)] private bool ShouldSerializeSkillBonuses() => !_skillBonuses.IsEmpty; - [SerializableFieldDefault(26)] private AosSkillBonuses SkillBonusesDefaultValue() => new(this); [InvalidateProperties] [SerializableField(27)] + [SaveFlag(nameof(ShouldSerializeSlayer2))] [SerializedCommandProperty(AccessLevel.GameMaster)] private SlayerName _slayer2; - [SerializableFieldSaveFlag(27)] [MethodImpl(MethodImplOptions.AggressiveInlining)] private bool ShouldSerializeSlayer2() => _slayer2 != SlayerName.None; [SerializedIgnoreDupe] [SerializableField(28, setter: "private")] + [SaveFlag(nameof(ShouldSerializeElementAttributes), nameof(AosElementAttributesDefaultValue))] [SerializedCommandProperty(AccessLevel.GameMaster, canModify: true)] private AosElementAttributes _aosElementDamages; - [SerializableFieldSaveFlag(28)] [MethodImpl(MethodImplOptions.AggressiveInlining)] private bool ShouldSerializeElementAttributes() => !_aosElementDamages.IsEmpty; - [SerializableFieldDefault(28)] private AosElementAttributes AosElementAttributesDefaultValue() => new(this); [InvalidateProperties] [SerializableField(29)] + [SaveFlag(nameof(ShouldSerializeEngravedText))] [SerializedCommandProperty(AccessLevel.GameMaster)] private string _engravedText; - [SerializableFieldSaveFlag(29)] [MethodImpl(MethodImplOptions.AggressiveInlining)] private bool ShouldSerializeEngravedText() => !string.IsNullOrEmpty(_engravedText); @@ -286,6 +282,7 @@ public abstract partial class BaseWeapon public bool Consecrated { get; set; } [SerializableProperty(1)] + [SaveFlag(nameof(ShouldSerializeWeaponAccuracy))] [CommandProperty(AccessLevel.GameMaster)] public WeaponAccuracyLevel AccuracyLevel { @@ -321,10 +318,10 @@ public abstract partial class BaseWeapon } } - [SerializableFieldSaveFlag(1)] private bool ShouldSerializeWeaponAccuracy() => _accuracyLevel != WeaponAccuracyLevel.Regular; [SerializableProperty(2)] + [SaveFlag(nameof(ShouldSerializeDurabilityLevel))] [CommandProperty(AccessLevel.GameMaster)] public WeaponDurabilityLevel DurabilityLevel { @@ -339,10 +336,10 @@ public abstract partial class BaseWeapon } } - [SerializableFieldSaveFlag(2)] private bool ShouldSerializeDurabilityLevel() => _durabilityLevel != WeaponDurabilityLevel.Regular; [SerializableProperty(3)] + [SaveFlag(nameof(ShouldSerializeQuality), nameof(QualityDefaultValue))] [CommandProperty(AccessLevel.GameMaster)] public WeaponQuality Quality { @@ -357,13 +354,12 @@ public abstract partial class BaseWeapon } } - [SerializableFieldSaveFlag(3)] private bool ShouldSerializeQuality() => _quality != WeaponQuality.Regular; - [SerializableFieldDefault(3)] private WeaponQuality QualityDefaultValue() => WeaponQuality.Regular; [SerializableProperty(4)] + [SaveFlag(nameof(ShouldSerializeHitPoints))] [CommandProperty(AccessLevel.GameMaster)] public int HitPoints { @@ -387,10 +383,10 @@ public abstract partial class BaseWeapon } } - [SerializableFieldSaveFlag(4)] private bool ShouldSerializeHitPoints() => _hitPoints > 0; [SerializableProperty(11)] + [SaveFlag(nameof(ShouldSerializeStrReq), nameof(StrReqDefaultValue))] [CommandProperty(AccessLevel.GameMaster)] public int StrRequirement { @@ -403,13 +399,12 @@ public abstract partial class BaseWeapon } } - [SerializableFieldSaveFlag(11)] private bool ShouldSerializeStrReq() => _strRequirement != -1; - [SerializableFieldDefault(11)] private int StrReqDefaultValue() => -1; [SerializableProperty(12)] + [SaveFlag(nameof(ShouldSerializeDexReq), nameof(DexReqDefaultValue))] [CommandProperty(AccessLevel.GameMaster)] public int DexRequirement { @@ -422,13 +417,12 @@ public abstract partial class BaseWeapon } } - [SerializableFieldSaveFlag(12)] private bool ShouldSerializeDexReq() => _dexRequirement != -1; - [SerializableFieldDefault(12)] private int DexReqDefaultValue() => -1; [SerializableProperty(13)] + [SaveFlag(nameof(ShouldSerializeIntReq), nameof(IntReqDefaultValue))] [CommandProperty(AccessLevel.GameMaster)] public int IntRequirement { @@ -441,13 +435,12 @@ public abstract partial class BaseWeapon } } - [SerializableFieldSaveFlag(13)] private bool ShouldSerializeIntReq() => _intRequirement != -1; - [SerializableFieldDefault(13)] private int IntReqDefaultValue() => -1; [SerializableProperty(14)] + [SaveFlag(nameof(ShouldSerializeMinDamage), nameof(MinDamageDefaultValue))] [CommandProperty(AccessLevel.GameMaster)] public int MinDamage { @@ -460,13 +453,12 @@ public abstract partial class BaseWeapon } } - [SerializableFieldSaveFlag(14)] private bool ShouldSerializeMinDamage() => _minDamage != -1; - [SerializableFieldDefault(14)] private int MinDamageDefaultValue() => -1; [SerializableProperty(15)] + [SaveFlag(nameof(ShouldSerializeMaxDamage), nameof(MaxDamageDefaultValue))] [CommandProperty(AccessLevel.GameMaster)] public int MaxDamage { @@ -479,13 +471,12 @@ public abstract partial class BaseWeapon } } - [SerializableFieldSaveFlag(15)] private bool ShouldSerializeMaxDamage() => _maxDamage != -1; - [SerializableFieldDefault(15)] private int MaxDamageDefaultValue() => -1; [SerializableProperty(16)] + [SaveFlag(nameof(ShouldSerializeHitSound), nameof(HitSoundDefaultValue))] [CommandProperty(AccessLevel.GameMaster)] public int HitSound { @@ -497,13 +488,12 @@ public abstract partial class BaseWeapon } } - [SerializableFieldSaveFlag(16)] private bool ShouldSerializeHitSound() => _hitSound != -1; - [SerializableFieldDefault(16)] private int HitSoundDefaultValue() => -1; [SerializableProperty(17)] + [SaveFlag(nameof(ShouldSerializeMissSound), nameof(MissSoundDefaultValue))] [CommandProperty(AccessLevel.GameMaster)] public int MissSound { @@ -515,13 +505,12 @@ public abstract partial class BaseWeapon } } - [SerializableFieldSaveFlag(17)] private bool ShouldSerializeMissSound() => _missSound != -1; - [SerializableFieldDefault(17)] private int MissSoundDefaultValue() => -1; [SerializableProperty(18)] + [SaveFlag(nameof(ShouldSerializeSpeed), nameof(SpeedDefaultValue))] [CommandProperty(AccessLevel.GameMaster)] public float Speed { @@ -552,13 +541,12 @@ public abstract partial class BaseWeapon } } - [SerializableFieldSaveFlag(18)] private bool ShouldSerializeSpeed() => _speed != -1; - [SerializableFieldDefault(18)] private float SpeedDefaultValue() => -1; [SerializableProperty(19)] + [SaveFlag(nameof(ShouldSerializeMaxRange), nameof(MaxRangeDefaultValue))] [CommandProperty(AccessLevel.GameMaster)] public int MaxRange { @@ -571,13 +559,12 @@ public abstract partial class BaseWeapon } } - [SerializableFieldSaveFlag(19)] private bool ShouldSerializeMaxRange() => _maxRange != -1; - [SerializableFieldDefault(19)] private int MaxRangeDefaultValue() => -1; [SerializableProperty(20)] + [SaveFlag(nameof(ShouldSerializeSkill), nameof(SkillNameDefaultValue))] [CommandProperty(AccessLevel.GameMaster)] public SkillName Skill { @@ -590,13 +577,12 @@ public abstract partial class BaseWeapon } } - [SerializableFieldSaveFlag(20)] private bool ShouldSerializeSkill() => _skill != (SkillName)(-1); - [SerializableFieldDefault(20)] private SkillName SkillNameDefaultValue() => (SkillName)(-1); [SerializableProperty(21)] + [SaveFlag(nameof(ShouldSerializeType), nameof(TypeDefaultValue))] [CommandProperty(AccessLevel.GameMaster)] public WeaponType Type { @@ -608,13 +594,12 @@ public abstract partial class BaseWeapon } } - [SerializableFieldSaveFlag(21)] private bool ShouldSerializeType() => _type != (WeaponType)(-1); - [SerializableFieldDefault(21)] private WeaponType TypeDefaultValue() => (WeaponType)(-1); [SerializableProperty(22)] + [SaveFlag(nameof(ShouldSerializeAnimation), nameof(AnimationDefaultValue))] [CommandProperty(AccessLevel.GameMaster)] public WeaponAnimation Animation { @@ -626,13 +611,12 @@ public abstract partial class BaseWeapon } } - [SerializableFieldSaveFlag(22)] private bool ShouldSerializeAnimation() => _animation != (WeaponAnimation)(-1); - [SerializableFieldDefault(22)] private WeaponAnimation AnimationDefaultValue() => (WeaponAnimation)(-1); [SerializableProperty(23)] + [SaveFlag(nameof(ShouldSerializeResource), nameof(ResourceDefaultValue))] [CommandProperty(AccessLevel.GameMaster)] public CraftResource Resource { @@ -648,10 +632,8 @@ public abstract partial class BaseWeapon } } - [SerializableFieldSaveFlag(23)] private bool ShouldSerializeResource() => _resource != CraftResource.Iron; - [SerializableFieldDefault(23)] private CraftResource ResourceDefaultValue() => CraftResource.Iron; public virtual int OnCraft( diff --git a/Projects/UOContent/Migrations/Server.Engines.CannedEvil.ChampionSpawn.v11.json b/Projects/UOContent/Migrations/Server.Engines.CannedEvil.ChampionSpawn.v11.json new file mode 100644 index 000000000..defcbe100 --- /dev/null +++ b/Projects/UOContent/Migrations/Server.Engines.CannedEvil.ChampionSpawn.v11.json @@ -0,0 +1,189 @@ +{ + "version": 11, + "type": "Server.Engines.CannedEvil.ChampionSpawn", + "properties": [ + { + "name": "Level", + "type": "int", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "ActivatedByProximity", + "type": "bool", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "NextProximityTime", + "type": "System.DateTime", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "AnchoredTime" + ] + }, + { + "name": "MaxLevel", + "type": "int", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "ActivatedByValor", + "type": "bool", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "DamageEntries", + "type": "System.Collections.Generic.Dictionary\u003CServer.Mobile, int\u003E", + "rule": "DictionaryMigrationRule", + "ruleArguments": [ + "Server.Mobile", + "SerializableInterfaceMigrationRule", + "0", + "int", + "PrimitiveTypeMigrationRule", + "1", + "" + ] + }, + { + "name": "ConfinedRoaming", + "type": "bool", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "Idol", + "type": "Server.Engines.CannedEvil.IdolOfTheChampion", + "rule": "SerializableInterfaceMigrationRule" + }, + { + "name": "HasBeenAdvanced", + "type": "bool", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "SpawnArea", + "type": "Server.Rectangle2D", + "rule": "PrimitiveUOTypeMigrationRule", + "ruleArguments": [ + "Rect2D" + ] + }, + { + "name": "RandomizeType", + "type": "bool", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "Kills", + "type": "int", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "Active", + "type": "bool", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "Type", + "type": "Server.Engines.CannedEvil.ChampionSpawnType", + "rule": "EnumMigrationRule" + }, + { + "name": "Creatures", + "type": "System.Collections.Generic.List\u003CServer.Mobile\u003E", + "rule": "ListMigrationRule", + "ruleArguments": [ + "@Tidy", + "Server.Mobile", + "SerializableInterfaceMigrationRule" + ] + }, + { + "name": "RedSkulls", + "type": "System.Collections.Generic.List\u003CServer.Item\u003E", + "rule": "ListMigrationRule", + "ruleArguments": [ + "@Tidy", + "Server.Item", + "SerializableInterfaceMigrationRule" + ] + }, + { + "name": "WhiteSkulls", + "type": "System.Collections.Generic.List\u003CServer.Item\u003E", + "rule": "ListMigrationRule", + "ruleArguments": [ + "@Tidy", + "Server.Item", + "SerializableInterfaceMigrationRule" + ] + }, + { + "name": "Platform", + "type": "Server.Engines.CannedEvil.ChampionPlatform", + "rule": "SerializableInterfaceMigrationRule" + }, + { + "name": "Altar", + "type": "Server.Engines.CannedEvil.ChampionAltar", + "rule": "SerializableInterfaceMigrationRule" + }, + { + "name": "ExpireDelay", + "type": "System.TimeSpan", + "rule": "PrimitiveTypeMigrationRule" + }, + { + "name": "ExpireTime", + "type": "System.DateTime", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "AnchoredTime" + ] + }, + { + "name": "Champion", + "type": "Server.Mobile", + "rule": "SerializableInterfaceMigrationRule" + }, + { + "name": "RestartDelay", + "type": "System.TimeSpan", + "rule": "PrimitiveTypeMigrationRule" + }, + { + "name": "RestartTime", + "type": "System.DateTime", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "AnchoredTime" + ] + } + ] +} \ No newline at end of file diff --git a/Projects/UOContent/Migrations/Server.Engines.Virtues.VirtueContext.v1.json b/Projects/UOContent/Migrations/Server.Engines.Virtues.VirtueContext.v1.json new file mode 100644 index 000000000..956de91db --- /dev/null +++ b/Projects/UOContent/Migrations/Server.Engines.Virtues.VirtueContext.v1.json @@ -0,0 +1,119 @@ +{ + "version": 1, + "type": "Server.Engines.Virtues.VirtueContext", + "properties": [ + { + "name": "LastSacrificeGain", + "type": "System.DateTime", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "AnchoredTime" + ] + }, + { + "name": "LastSacrificeLoss", + "type": "System.DateTime", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "AnchoredTime" + ] + }, + { + "name": "AvailableResurrects", + "type": "int", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "LastJusticeLoss", + "type": "System.DateTime", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "AnchoredTime" + ] + }, + { + "name": "LastCompassionLoss", + "type": "System.DateTime", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "AnchoredTime" + ] + }, + { + "name": "NextCompassionDay", + "type": "System.DateTime", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "AnchoredTime" + ] + }, + { + "name": "CompassionGains", + "type": "int", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "LastValorLoss", + "type": "System.DateTime", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "AnchoredTime" + ] + }, + { + "name": "LastHonorUse", + "type": "System.DateTime", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "AnchoredTime" + ] + }, + { + "name": "HonorActive", + "type": "bool", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "JusticeProtection", + "type": "Server.Mobiles.PlayerMobile", + "usesSaveFlag": true, + "rule": "SerializableInterfaceMigrationRule" + }, + { + "name": "JusticeStatus", + "type": "Server.Engines.Virtues.JusticeProtectorStatus", + "usesSaveFlag": true, + "rule": "EnumMigrationRule" + }, + { + "name": "Values", + "type": "int[]", + "usesSaveFlag": true, + "rule": "ArrayMigrationRule", + "ruleArguments": [ + "int", + "PrimitiveTypeMigrationRule", + "" + ] + } + ] +} \ No newline at end of file diff --git a/Projects/UOContent/Migrations/Server.Ethics.Player.v2.json b/Projects/UOContent/Migrations/Server.Ethics.Player.v2.json new file mode 100644 index 000000000..04f4a0199 --- /dev/null +++ b/Projects/UOContent/Migrations/Server.Ethics.Player.v2.json @@ -0,0 +1,50 @@ +{ + "version": 2, + "type": "Server.Ethics.Player", + "properties": [ + { + "name": "Mobile", + "type": "Server.Mobile", + "rule": "SerializableInterfaceMigrationRule" + }, + { + "name": "Power", + "type": "int", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "History", + "type": "int", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "Steed", + "type": "Server.Mobile", + "rule": "SerializableInterfaceMigrationRule" + }, + { + "name": "Familiar", + "type": "Server.Mobile", + "rule": "SerializableInterfaceMigrationRule" + }, + { + "name": "Shield", + "type": "System.DateTime", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "AnchoredTime" + ] + }, + { + "name": "Ethic", + "type": "Server.Ethics.Ethic", + "rule": "SerializableInterfaceMigrationRule" + } + ] +} \ No newline at end of file diff --git a/Projects/UOContent/Migrations/Server.Items.BaseLight.v2.json b/Projects/UOContent/Migrations/Server.Items.BaseLight.v2.json new file mode 100644 index 000000000..c04a03f3b --- /dev/null +++ b/Projects/UOContent/Migrations/Server.Items.BaseLight.v2.json @@ -0,0 +1,43 @@ +{ + "version": 2, + "type": "Server.Items.BaseLight", + "properties": [ + { + "name": "BurntOut", + "type": "bool", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "Burning", + "type": "bool", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "Duration", + "type": "System.TimeSpan", + "rule": "PrimitiveTypeMigrationRule" + }, + { + "name": "Protected", + "type": "bool", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "BurnTimer", + "type": "Server.Timer", + "rule": "TimerMigrationRule", + "ruleArguments": [ + "@AnchoredTimer" + ] + } + ] +} \ No newline at end of file diff --git a/Projects/UOContent/Migrations/Server.Items.Corpse.v18.json b/Projects/UOContent/Migrations/Server.Items.Corpse.v18.json new file mode 100644 index 000000000..452e3ba7c --- /dev/null +++ b/Projects/UOContent/Migrations/Server.Items.Corpse.v18.json @@ -0,0 +1,137 @@ +{ + "version": 18, + "type": "Server.Items.Corpse", + "properties": [ + { + "name": "RestoreEquip", + "type": "System.Collections.Generic.List\u003CServer.Item\u003E", + "rule": "ListMigrationRule", + "ruleArguments": [ + "Server.Item", + "SerializableInterfaceMigrationRule" + ] + }, + { + "name": "Flags", + "type": "Server.Items.CorpseFlag", + "rule": "EnumMigrationRule" + }, + { + "name": "TimeOfDeath", + "type": "System.DateTime", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "DeltaTime" + ] + }, + { + "name": "RestoreTable", + "type": "System.Collections.Generic.Dictionary\u003CServer.Item, Server.Point3D\u003E", + "rule": "DictionaryMigrationRule", + "ruleArguments": [ + "Server.Item", + "SerializableInterfaceMigrationRule", + "0", + "Server.Point3D", + "PrimitiveUOTypeMigrationRule", + "1", + "Point3D" + ] + }, + { + "name": "DecayTimer", + "type": "Server.Timer", + "rule": "TimerMigrationRule", + "ruleArguments": [ + "@AnchoredTimer" + ] + }, + { + "name": "Looters", + "type": "System.Collections.Generic.HashSet\u003CServer.Mobile\u003E", + "rule": "HashSetMigrationRule", + "ruleArguments": [ + "Server.Mobile", + "SerializableInterfaceMigrationRule" + ] + }, + { + "name": "Killer", + "type": "Server.Mobile", + "rule": "SerializableInterfaceMigrationRule" + }, + { + "name": "Aggressors", + "type": "System.Collections.Generic.List\u003CServer.Mobile\u003E", + "rule": "ListMigrationRule", + "ruleArguments": [ + "Server.Mobile", + "SerializableInterfaceMigrationRule" + ] + }, + { + "name": "Owner", + "type": "Server.Mobile", + "rule": "SerializableInterfaceMigrationRule" + }, + { + "name": "CorpseName", + "type": "string", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "AccessLevel", + "type": "Server.AccessLevel", + "rule": "EnumMigrationRule" + }, + { + "name": "Guild", + "type": "Server.Guilds.Guild", + "rule": "SerializableInterfaceMigrationRule" + }, + { + "name": "EquipItems", + "type": "System.Collections.Generic.List\u003CServer.Item\u003E", + "rule": "ListMigrationRule", + "ruleArguments": [ + "Server.Item", + "SerializableInterfaceMigrationRule" + ] + }, + { + "name": "HairItemId", + "type": "int", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "HairHue", + "type": "int", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "FacialHairItemId", + "type": "int", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "FacialHairHue", + "type": "int", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + } + ] +} \ No newline at end of file diff --git a/Projects/UOContent/Migrations/Server.Items.Corpse.v19.json b/Projects/UOContent/Migrations/Server.Items.Corpse.v19.json new file mode 100644 index 000000000..2cccb267a --- /dev/null +++ b/Projects/UOContent/Migrations/Server.Items.Corpse.v19.json @@ -0,0 +1,137 @@ +{ + "version": 19, + "type": "Server.Items.Corpse", + "properties": [ + { + "name": "RestoreEquip", + "type": "System.Collections.Generic.List\u003CServer.Item\u003E", + "rule": "ListMigrationRule", + "ruleArguments": [ + "Server.Item", + "SerializableInterfaceMigrationRule" + ] + }, + { + "name": "Flags", + "type": "Server.Items.CorpseFlag", + "rule": "EnumMigrationRule" + }, + { + "name": "TimeOfDeath", + "type": "System.DateTime", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "AnchoredTime" + ] + }, + { + "name": "RestoreTable", + "type": "System.Collections.Generic.Dictionary\u003CServer.Item, Server.Point3D\u003E", + "rule": "DictionaryMigrationRule", + "ruleArguments": [ + "Server.Item", + "SerializableInterfaceMigrationRule", + "0", + "Server.Point3D", + "PrimitiveUOTypeMigrationRule", + "1", + "Point3D" + ] + }, + { + "name": "DecayTimer", + "type": "Server.Timer", + "rule": "TimerMigrationRule", + "ruleArguments": [ + "@AnchoredTimer" + ] + }, + { + "name": "Looters", + "type": "System.Collections.Generic.HashSet\u003CServer.Mobile\u003E", + "rule": "HashSetMigrationRule", + "ruleArguments": [ + "Server.Mobile", + "SerializableInterfaceMigrationRule" + ] + }, + { + "name": "Killer", + "type": "Server.Mobile", + "rule": "SerializableInterfaceMigrationRule" + }, + { + "name": "Aggressors", + "type": "System.Collections.Generic.List\u003CServer.Mobile\u003E", + "rule": "ListMigrationRule", + "ruleArguments": [ + "Server.Mobile", + "SerializableInterfaceMigrationRule" + ] + }, + { + "name": "Owner", + "type": "Server.Mobile", + "rule": "SerializableInterfaceMigrationRule" + }, + { + "name": "CorpseName", + "type": "string", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "AccessLevel", + "type": "Server.AccessLevel", + "rule": "EnumMigrationRule" + }, + { + "name": "Guild", + "type": "Server.Guilds.Guild", + "rule": "SerializableInterfaceMigrationRule" + }, + { + "name": "EquipItems", + "type": "System.Collections.Generic.List\u003CServer.Item\u003E", + "rule": "ListMigrationRule", + "ruleArguments": [ + "Server.Item", + "SerializableInterfaceMigrationRule" + ] + }, + { + "name": "HairItemId", + "type": "int", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "HairHue", + "type": "int", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "FacialHairItemId", + "type": "int", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "FacialHairHue", + "type": "int", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + } + ] +} \ No newline at end of file diff --git a/Projects/UOContent/Migrations/Server.Items.DeathRobe.v4.json b/Projects/UOContent/Migrations/Server.Items.DeathRobe.v4.json new file mode 100644 index 000000000..ef81d79cd --- /dev/null +++ b/Projects/UOContent/Migrations/Server.Items.DeathRobe.v4.json @@ -0,0 +1,14 @@ +{ + "version": 4, + "type": "Server.Items.DeathRobe", + "properties": [ + { + "name": "DecayTimer", + "type": "Server.Timer", + "rule": "TimerMigrationRule", + "ruleArguments": [ + "@AnchoredTimer" + ] + } + ] +} \ No newline at end of file diff --git a/Projects/UOContent/Migrations/Server.Items.DecayedCorpse.v3.json b/Projects/UOContent/Migrations/Server.Items.DecayedCorpse.v3.json new file mode 100644 index 000000000..adcf02ff6 --- /dev/null +++ b/Projects/UOContent/Migrations/Server.Items.DecayedCorpse.v3.json @@ -0,0 +1,14 @@ +{ + "version": 3, + "type": "Server.Items.DecayedCorpse", + "properties": [ + { + "name": "DecayTimer", + "type": "Server.Timer", + "rule": "TimerMigrationRule", + "ruleArguments": [ + "@AnchoredTimer" + ] + } + ] +} \ No newline at end of file diff --git a/Projects/UOContent/Migrations/Server.Items.FillableContainer.v3.json b/Projects/UOContent/Migrations/Server.Items.FillableContainer.v3.json new file mode 100644 index 000000000..2384676a3 --- /dev/null +++ b/Projects/UOContent/Migrations/Server.Items.FillableContainer.v3.json @@ -0,0 +1,19 @@ +{ + "version": 3, + "type": "Server.Items.FillableContainer", + "properties": [ + { + "name": "ContentType", + "type": "Server.Items.FillableContentType", + "rule": "EnumMigrationRule" + }, + { + "name": "RespawnTimer", + "type": "Server.Timer", + "rule": "TimerMigrationRule", + "ruleArguments": [ + "@AnchoredTimer" + ] + } + ] +} \ No newline at end of file diff --git a/Projects/UOContent/Migrations/Server.Items.MarkContainer.v1.json b/Projects/UOContent/Migrations/Server.Items.MarkContainer.v1.json new file mode 100644 index 000000000..267ca4709 --- /dev/null +++ b/Projects/UOContent/Migrations/Server.Items.MarkContainer.v1.json @@ -0,0 +1,46 @@ +{ + "version": 1, + "type": "Server.Items.MarkContainer", + "properties": [ + { + "name": "AutoLock", + "type": "bool", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "RelockTimer", + "type": "Server.Items.MarkContainer.InternalTimer", + "rule": "TimerMigrationRule", + "ruleArguments": [ + "@AnchoredTimer" + ] + }, + { + "name": "TargetMap", + "type": "Server.Map", + "rule": "PrimitiveUOTypeMigrationRule", + "ruleArguments": [ + "Map" + ] + }, + { + "name": "Target", + "type": "Server.Point3D", + "rule": "PrimitiveUOTypeMigrationRule", + "ruleArguments": [ + "Point3D" + ] + }, + { + "name": "Description", + "type": "string", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + } + ] +} \ No newline at end of file diff --git a/Projects/UOContent/Migrations/Server.Items.PuzzleChestSolutionAndTime.v1.json b/Projects/UOContent/Migrations/Server.Items.PuzzleChestSolutionAndTime.v1.json new file mode 100644 index 000000000..8424276f6 --- /dev/null +++ b/Projects/UOContent/Migrations/Server.Items.PuzzleChestSolutionAndTime.v1.json @@ -0,0 +1,14 @@ +{ + "version": 1, + "type": "Server.Items.PuzzleChestSolutionAndTime", + "properties": [ + { + "name": "When", + "type": "System.DateTime", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "AnchoredTime" + ] + } + ] +} \ No newline at end of file diff --git a/Projects/UOContent/Migrations/Server.Items.StarRoomGate.v2.json b/Projects/UOContent/Migrations/Server.Items.StarRoomGate.v2.json new file mode 100644 index 000000000..0a61f7bfe --- /dev/null +++ b/Projects/UOContent/Migrations/Server.Items.StarRoomGate.v2.json @@ -0,0 +1,22 @@ +{ + "version": 2, + "type": "Server.Items.StarRoomGate", + "properties": [ + { + "name": "Decays", + "type": "bool", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "DecayTime", + "type": "System.DateTime", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "AnchoredTime" + ] + } + ] +} \ No newline at end of file diff --git a/Projects/UOContent/Migrations/Server.Items.TransientItem.v2.json b/Projects/UOContent/Migrations/Server.Items.TransientItem.v2.json new file mode 100644 index 000000000..3745c4428 --- /dev/null +++ b/Projects/UOContent/Migrations/Server.Items.TransientItem.v2.json @@ -0,0 +1,14 @@ +{ + "version": 2, + "type": "Server.Items.TransientItem", + "properties": [ + { + "name": "Expiration", + "type": "System.DateTime", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "AnchoredTime" + ] + } + ] +} \ No newline at end of file diff --git a/Projects/UOContent/Migrations/Server.Items.TreasureMapChest.v4.json b/Projects/UOContent/Migrations/Server.Items.TreasureMapChest.v4.json new file mode 100644 index 000000000..b4d1f85d4 --- /dev/null +++ b/Projects/UOContent/Migrations/Server.Items.TreasureMapChest.v4.json @@ -0,0 +1,57 @@ +{ + "version": 4, + "type": "Server.Items.TreasureMapChest", + "properties": [ + { + "name": "Guardians", + "type": "System.Collections.Generic.List\u003CServer.Mobile\u003E", + "rule": "ListMigrationRule", + "ruleArguments": [ + "@Tidy", + "@CanBeNull", + "Server.Mobile", + "SerializableInterfaceMigrationRule" + ] + }, + { + "name": "Temporary", + "type": "bool", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "Owner", + "type": "Server.Mobile", + "rule": "SerializableInterfaceMigrationRule" + }, + { + "name": "Level", + "type": "int", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "ExpireTimer", + "type": "Server.Timer", + "rule": "TimerMigrationRule", + "ruleArguments": [ + "@AnchoredTimer" + ] + }, + { + "name": "Lifted", + "type": "System.Collections.Generic.HashSet\u003CServer.Item\u003E", + "rule": "HashSetMigrationRule", + "ruleArguments": [ + "@Tidy", + "@CanBeNull", + "Server.Item", + "SerializableInterfaceMigrationRule" + ] + } + ] +} \ No newline at end of file diff --git a/Projects/UOContent/Migrations/Server.Mobiles.BaseEscortable.v3.json b/Projects/UOContent/Migrations/Server.Mobiles.BaseEscortable.v3.json new file mode 100644 index 000000000..5149f4347 --- /dev/null +++ b/Projects/UOContent/Migrations/Server.Mobiles.BaseEscortable.v3.json @@ -0,0 +1,43 @@ +{ + "version": 3, + "type": "Server.Mobiles.BaseEscortable", + "properties": [ + { + "name": "DestinationString", + "type": "string", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "DeleteTimer", + "type": "Server.Timer", + "rule": "TimerMigrationRule", + "ruleArguments": [ + "@AnchoredTimer" + ] + }, + { + "name": "MlQuestType", + "type": "System.Type", + "rule": "PrimitiveTypeMigrationRule" + }, + { + "name": "MlQuestDestinationMessage", + "type": "Server.TextDefinition", + "rule": "PrimitiveUOTypeMigrationRule", + "ruleArguments": [ + "TextDefinition" + ] + }, + { + "name": "MlQuestPaymentMessage", + "type": "Server.TextDefinition", + "rule": "PrimitiveUOTypeMigrationRule", + "ruleArguments": [ + "TextDefinition" + ] + } + ] +} \ No newline at end of file diff --git a/Projects/UOContent/Migrations/Server.Mobiles.PlayerVendor.v4.json b/Projects/UOContent/Migrations/Server.Mobiles.PlayerVendor.v4.json new file mode 100644 index 000000000..6b9c9eb1c --- /dev/null +++ b/Projects/UOContent/Migrations/Server.Mobiles.PlayerVendor.v4.json @@ -0,0 +1,62 @@ +{ + "version": 4, + "type": "Server.Mobiles.PlayerVendor", + "properties": [ + { + "name": "ShopName", + "type": "string", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "NextPayTime", + "type": "System.DateTime", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "AnchoredTime" + ] + }, + { + "name": "House", + "type": "Server.Multis.BaseHouse", + "rule": "SerializableInterfaceMigrationRule" + }, + { + "name": "Owner", + "type": "Server.Mobile", + "rule": "SerializableInterfaceMigrationRule" + }, + { + "name": "BankAccount", + "type": "int", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "HoldGold", + "type": "int", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "SellItems", + "type": "System.Collections.Generic.Dictionary\u003CServer.Item, Server.Mobiles.VendorItem\u003E", + "rule": "DictionaryMigrationRule", + "ruleArguments": [ + "Server.Item", + "SerializableInterfaceMigrationRule", + "0", + "Server.Mobiles.VendorItem", + "RawSerializableMigrationRule", + "1", + "" + ] + } + ] +} \ No newline at end of file diff --git a/Projects/UOContent/Migrations/Server.Mobiles.RentedVendor.v1.json b/Projects/UOContent/Migrations/Server.Mobiles.RentedVendor.v1.json new file mode 100644 index 000000000..b42fa33eb --- /dev/null +++ b/Projects/UOContent/Migrations/Server.Mobiles.RentedVendor.v1.json @@ -0,0 +1,62 @@ +{ + "version": 1, + "type": "Server.Mobiles.RentedVendor", + "properties": [ + { + "name": "RentalDurationId", + "type": "int", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "RentalPrice", + "type": "int", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "LandlordRenew", + "type": "bool", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "RenterRenew", + "type": "bool", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "RenewalPrice", + "type": "int", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "RentalGold", + "type": "int", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "RentalExpireTime", + "type": "System.DateTime", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "AnchoredTime" + ] + } + ] +} \ No newline at end of file diff --git a/Projects/UOContent/Migrations/Server.Mobiles.Sheep.v1.json b/Projects/UOContent/Migrations/Server.Mobiles.Sheep.v1.json new file mode 100644 index 000000000..bd224f77f --- /dev/null +++ b/Projects/UOContent/Migrations/Server.Mobiles.Sheep.v1.json @@ -0,0 +1,14 @@ +{ + "version": 1, + "type": "Server.Mobiles.Sheep", + "properties": [ + { + "name": "NextWoolTime", + "type": "System.DateTime", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "AnchoredTime" + ] + } + ] +} \ No newline at end of file diff --git a/Projects/UOContent/Migrations/Server.Multis.BaseBoat.v5.json b/Projects/UOContent/Migrations/Server.Multis.BaseBoat.v5.json new file mode 100644 index 000000000..140d3dd64 --- /dev/null +++ b/Projects/UOContent/Migrations/Server.Multis.BaseBoat.v5.json @@ -0,0 +1,73 @@ +{ + "version": 5, + "type": "Server.Multis.BaseBoat", + "properties": [ + { + "name": "MapItem", + "type": "Server.Items.MapItem", + "rule": "SerializableInterfaceMigrationRule" + }, + { + "name": "NextNavPoint", + "type": "int", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "Facing", + "type": "Server.Direction", + "rule": "EnumMigrationRule" + }, + { + "name": "TimeOfDecay", + "type": "System.DateTime", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "AnchoredTime" + ] + }, + { + "name": "Owner", + "type": "Server.Mobile", + "rule": "SerializableInterfaceMigrationRule" + }, + { + "name": "PPlank", + "type": "Server.Items.Plank", + "rule": "SerializableInterfaceMigrationRule" + }, + { + "name": "SPlank", + "type": "Server.Items.Plank", + "rule": "SerializableInterfaceMigrationRule" + }, + { + "name": "TillerMan", + "type": "Server.Items.TillerMan", + "rule": "SerializableInterfaceMigrationRule" + }, + { + "name": "Hold", + "type": "Server.Items.Hold", + "rule": "SerializableInterfaceMigrationRule" + }, + { + "name": "Anchored", + "type": "bool", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "ShipName", + "type": "string", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + } + ] +} \ No newline at end of file diff --git a/Projects/UOContent/Migrations/Server.Multis.BaseCamp.v2.json b/Projects/UOContent/Migrations/Server.Multis.BaseCamp.v2.json new file mode 100644 index 000000000..471c645f1 --- /dev/null +++ b/Projects/UOContent/Migrations/Server.Multis.BaseCamp.v2.json @@ -0,0 +1,34 @@ +{ + "version": 2, + "type": "Server.Multis.BaseCamp", + "properties": [ + { + "name": "Items", + "type": "System.Collections.Generic.List\u003CServer.Item\u003E", + "rule": "ListMigrationRule", + "ruleArguments": [ + "@Tidy", + "Server.Item", + "SerializableInterfaceMigrationRule" + ] + }, + { + "name": "Mobiles", + "type": "System.Collections.Generic.List\u003CServer.Mobile\u003E", + "rule": "ListMigrationRule", + "ruleArguments": [ + "@Tidy", + "Server.Mobile", + "SerializableInterfaceMigrationRule" + ] + }, + { + "name": "DecayTime", + "type": "System.DateTime", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "AnchoredTime" + ] + } + ] +} \ No newline at end of file diff --git a/Projects/UOContent/Migrations/Server.Spells.Fifth.PoisonField.v1.json b/Projects/UOContent/Migrations/Server.Spells.Fifth.PoisonField.v1.json new file mode 100644 index 000000000..1f16a4d7f --- /dev/null +++ b/Projects/UOContent/Migrations/Server.Spells.Fifth.PoisonField.v1.json @@ -0,0 +1,19 @@ +{ + "version": 1, + "type": "Server.Spells.Fifth.PoisonField", + "properties": [ + { + "name": "Caster", + "type": "Server.Mobile", + "rule": "SerializableInterfaceMigrationRule" + }, + { + "name": "End", + "type": "System.DateTime", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "AnchoredTime" + ] + } + ] +} \ No newline at end of file diff --git a/Projects/UOContent/Migrations/Server.Spells.Fourth.FireFieldItem.v1.json b/Projects/UOContent/Migrations/Server.Spells.Fourth.FireFieldItem.v1.json new file mode 100644 index 000000000..3e91c7a4d --- /dev/null +++ b/Projects/UOContent/Migrations/Server.Spells.Fourth.FireFieldItem.v1.json @@ -0,0 +1,27 @@ +{ + "version": 1, + "type": "Server.Spells.Fourth.FireFieldItem", + "properties": [ + { + "name": "Damage", + "type": "int", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "Caster", + "type": "Server.Mobile", + "rule": "SerializableInterfaceMigrationRule" + }, + { + "name": "End", + "type": "System.DateTime", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "AnchoredTime" + ] + } + ] +} \ No newline at end of file diff --git a/Projects/UOContent/Migrations/Server.Spells.Seventh.EnergyField.v2.json b/Projects/UOContent/Migrations/Server.Spells.Seventh.EnergyField.v2.json new file mode 100644 index 000000000..dd202da03 --- /dev/null +++ b/Projects/UOContent/Migrations/Server.Spells.Seventh.EnergyField.v2.json @@ -0,0 +1,19 @@ +{ + "version": 2, + "type": "Server.Spells.Seventh.EnergyField", + "properties": [ + { + "name": "Caster", + "type": "Server.Mobile", + "rule": "SerializableInterfaceMigrationRule" + }, + { + "name": "End", + "type": "System.DateTime", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "AnchoredTime" + ] + } + ] +} \ No newline at end of file diff --git a/Projects/UOContent/Migrations/Server.Spells.Sixth.ParalyzeField.v1.json b/Projects/UOContent/Migrations/Server.Spells.Sixth.ParalyzeField.v1.json new file mode 100644 index 000000000..3e834a6b1 --- /dev/null +++ b/Projects/UOContent/Migrations/Server.Spells.Sixth.ParalyzeField.v1.json @@ -0,0 +1,19 @@ +{ + "version": 1, + "type": "Server.Spells.Sixth.ParalyzeField", + "properties": [ + { + "name": "Caster", + "type": "Server.Mobile", + "rule": "SerializableInterfaceMigrationRule" + }, + { + "name": "End", + "type": "System.DateTime", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "AnchoredTime" + ] + } + ] +} \ No newline at end of file diff --git a/Projects/UOContent/Migrations/Server.Spells.Third.WallOfStone.v1.json b/Projects/UOContent/Migrations/Server.Spells.Third.WallOfStone.v1.json new file mode 100644 index 000000000..eafcc5945 --- /dev/null +++ b/Projects/UOContent/Migrations/Server.Spells.Third.WallOfStone.v1.json @@ -0,0 +1,19 @@ +{ + "version": 1, + "type": "Server.Spells.Third.WallOfStone", + "properties": [ + { + "name": "Caster", + "type": "Server.Mobile", + "rule": "SerializableInterfaceMigrationRule" + }, + { + "name": "End", + "type": "System.DateTime", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "AnchoredTime" + ] + } + ] +} \ No newline at end of file diff --git a/Projects/UOContent/Misc/ShardPoller.cs b/Projects/UOContent/Misc/ShardPoller.cs index 762a0be9c..8516e6678 100644 --- a/Projects/UOContent/Misc/ShardPoller.cs +++ b/Projects/UOContent/Misc/ShardPoller.cs @@ -36,16 +36,14 @@ public partial class ShardPoller : Item Movable = false; } - [SerializableProperty(0)] - [CommandProperty(AccessLevel.GameMaster, AccessLevel.Administrator)] - public string Title + [SerializableField(0, allowFieldChange: nameof(AllowTitleChange))] + [SerializedCommandProperty(AccessLevel.GameMaster, AccessLevel.Administrator)] + private string _title; + + private bool AllowTitleChange(ref string value) { - get => _title; - set - { - _title = ShardPollPrompt.UrlToHref(value); - this.MarkDirty(); - } + value = ShardPollPrompt.UrlToHref(value); + return true; } [CommandProperty(AccessLevel.GameMaster, AccessLevel.Administrator)] @@ -54,31 +52,20 @@ public partial class ShardPoller : Item ? TimeSpan.Zero : Utility.Max(StartTime + Duration - Core.Now, TimeSpan.Zero); - [SerializableProperty(3)] - [CommandProperty(AccessLevel.GameMaster, AccessLevel.Administrator)] - public bool Active + [SerializableField(3, fieldChanged: nameof(OnActiveChanged))] + [SerializedCommandProperty(AccessLevel.GameMaster, AccessLevel.Administrator)] + private bool _active; + + private void OnActiveChanged(bool oldValue, bool newValue) { - get => _active; - set + if (_active) { - if (_active == value) - { - return; - } - - _active = value; - - if (_active) - { - StartTime = Core.Now; - _activePollers.Add(this); - } - else - { - _activePollers.Remove(this); - } - - this.MarkDirty(); + StartTime = Core.Now; + _activePollers.Add(this); + } + else + { + _activePollers.Remove(this); } } @@ -250,15 +237,12 @@ public partial class ShardPollOption } } - [SerializableProperty(0)] - public string Title + [SerializableField(0, fieldChanged: nameof(OnTitleChanged))] + private string _title; + + private void OnTitleChanged(string oldValue, string newValue) { - get => _title; - set - { - _title = value; - _lineBreaks = -1; - } + _lineBreaks = -1; } public int Votes => Voters.Length; diff --git a/Projects/UOContent/Mobiles/AI/AnimalAI.cs b/Projects/UOContent/Mobiles/AI/AnimalAI.cs index a9d410483..e5a1670e3 100644 --- a/Projects/UOContent/Mobiles/AI/AnimalAI.cs +++ b/Projects/UOContent/Mobiles/AI/AnimalAI.cs @@ -38,7 +38,7 @@ public class AnimalAI : BaseAI return true; } - if (!WalkMobileRange(combatant, 1, false, Mobile.RangeFight, Mobile.RangeFight)) + if (!WalkMobileRange(combatant, 1, Mobile.RangeFight, Mobile.RangeFight)) { if (Mobile.GetDistanceToSqrt(combatant) > Mobile.RangePerception + 1) { diff --git a/Projects/UOContent/Mobiles/AI/ArcherAI.cs b/Projects/UOContent/Mobiles/AI/ArcherAI.cs index dd461a03f..803587ceb 100644 --- a/Projects/UOContent/Mobiles/AI/ArcherAI.cs +++ b/Projects/UOContent/Mobiles/AI/ArcherAI.cs @@ -17,7 +17,7 @@ public class ArcherAI : BaseAI if (AcquireFocusMob(Mobile.RangePerception, Mobile.FightMode, false, false, true)) { - this.DebugSayFormatted($"I have detected {Mobile.FocusMob.Name} and I will attack"); + this.DebugSayFormatted($"I have detected {Mobile.FocusMob.Name}, attacking"); Mobile.Combatant = Mobile.FocusMob; Action = ActionType.Combat; @@ -43,11 +43,11 @@ public class ArcherAI : BaseAI return true; } - if (!WalkMobileRange(combatant, 1, false, Mobile.RangeFight, Mobile.Weapon.MaxRange)) + if (!WalkMobileRange(combatant, 1, Mobile.RangeFight, Mobile.Weapon.MaxRange)) { this.DebugSayFormatted($"I am still not in range of {combatant.Name}"); - if ((int)Mobile.GetDistanceToSqrt(combatant) > Mobile.RangePerception + 1) + if (!Mobile.InRange(combatant, Mobile.ChaseLeashRange)) { this.DebugSayFormatted($"I have lost {combatant.Name}"); diff --git a/Projects/UOContent/Mobiles/AI/BaseAI/AIGroupMovement.cs b/Projects/UOContent/Mobiles/AI/BaseAI/AIGroupMovement.cs index 08a745296..4463bf4de 100644 --- a/Projects/UOContent/Mobiles/AI/BaseAI/AIGroupMovement.cs +++ b/Projects/UOContent/Mobiles/AI/BaseAI/AIGroupMovement.cs @@ -36,12 +36,34 @@ public abstract partial class BaseAI } } - private bool UseGroupMovement(Mobile target) => + /// + /// Crowding refinement for the final approach: engages only near the target when allies + /// contest the ring, so creatures spread instead of stacking. Chasing any real distance + /// always uses the pathfinding approach primitive. + /// + private bool UseGroupMovement(Mobile target, int range) => Mobile.Combatant == target && !Mobile.Controlled - && CountNearbyAllies(target) > 0; + && Mobile.InRange(target, range + 2) + && CountCrowdingAllies(target, range) > 0; - public static bool MoveToWithGroup(BaseAI ai, Mobile target, bool run, int range) + private int CountCrowdingAllies(Mobile target, int range) + { + var crowding = 0; + + foreach (var m in target.GetMobilesInRange(range + 1)) + { + if (m != Mobile && m.Combatant == target && m is BaseCreature { Controlled: false } bc + && bc.Team == Mobile.Team) + { + crowding++; + } + } + + return crowding; + } + + public static bool MoveToWithGroup(BaseAI ai, Mobile target, int range) { if (Core.TickCount - _lastGroupUpdateTime > 1000) { @@ -56,7 +78,8 @@ public abstract partial class BaseAI { if (optimalPosition == Point3D.Zero) { - return ai.MoveToWithCollisionAvoidance(target, run, range); + + return ai.MoveToWithCollisionAvoidance(target, range); } _reservedPositions[mobile] = optimalPosition; @@ -68,7 +91,15 @@ public abstract partial class BaseAI direction = GetAdjustedDirection(direction); } - return ai.DoMove(direction, true); + var res = ai.DoMoveImpl(direction, true); + + if (res is MoveResult.Success or MoveResult.BadState) + { + return true; + } + + // A blocked or wall-slid step is not progress — route around the obstacle. + return ai.ApproachTarget(target, range); } finally { @@ -76,21 +107,6 @@ public abstract partial class BaseAI } } - private int CountNearbyAllies(Mobile target) - { - var allies = 0; - foreach (var m in Mobile.GetMobilesInRange(8)) - { - if (m != Mobile && m.Combatant == target && m is BaseCreature { Controlled: false } bc - && bc.Team == Mobile.Team) - { - allies++; - } - } - - return allies; - } - private PooledRefList GetNearbyAllies(Mobile target) { var allies = PooledRefList.Create(); diff --git a/Projects/UOContent/Mobiles/AI/BaseAI/AIMovement.cs b/Projects/UOContent/Mobiles/AI/BaseAI/AIMovement.cs index 1063dd70a..8b2a16cf2 100644 --- a/Projects/UOContent/Mobiles/AI/BaseAI/AIMovement.cs +++ b/Projects/UOContent/Mobiles/AI/BaseAI/AIMovement.cs @@ -13,10 +13,12 @@ * along with this program. If not, see . * ************************************************************************/ +using System; using System.Runtime.CompilerServices; using Server.Collections; using Server.Items; using MoveImpl = Server.Movement.MovementImpl; +using Moves = Server.Movement.Movement; namespace Server.Mobiles; @@ -37,7 +39,17 @@ public abstract partial class BaseAI private bool _approachGaveUp; private Point3D _approachGaveUpGoalLoc; - public static double BadlyHurtMoveDelay(BaseCreature bc) + // --- Move intent (see ContinueMove) ------------------------------------------------ + // Durable movement goal renewed by en-route ApproachTarget/MoveToPoint calls; while + // live, the AITimer wakes at NextMove between think ticks to advance the step. + private Mobile _moveIntentTarget; + private IPoint3D _moveIntentPoint; + private int _moveIntentRange; + private long _moveIntentExpire; + + // Inflates a step delay while badly hurt; computed from the passed base so it cannot + // compound across steps. Damage slows steps, never decisions. + public static double BadlyHurtMoveDelay(BaseCreature bc, double delay) { var statMin = Core.HS ? bc.Stam : bc.Hits; var statMax = Core.HS ? bc.StamMax : bc.HitsMax; @@ -45,20 +57,59 @@ public abstract partial class BaseAI if (!bc.IsDeadPet && (bc.ReduceSpeedWithDamage || bc.IsSubdued) && statMax > 0 && statMin < statMax * 0.3) { - var hits = (double)statMin / statMax; + var stat = (double)statMin / statMax; - if (hits < 0.1) { return bc.CurrentSpeed + 0.15; } - if (hits < 0.2) { return bc.CurrentSpeed + 0.1; } - if (hits < 0.3) { return bc.CurrentSpeed + 0.05; } + if (stat < 0.1) { return delay + 0.15; } + if (stat < 0.2) { return delay + 0.1; } + + return delay + 0.05; } - return bc.CurrentSpeed; + return delay; } public bool CanMoveNow(out double delay) { delay = 0.0; - return Core.TickCount >= NextMove; + return Core.TickCount - NextMove >= 0; + } + + // Seconds per step as the client observes it: the move clock plus the hurt inflation. + private double EffectiveStepDelay() + { + var stepDelay = Mobile.CurrentMoveSpeed; + + return Core.AOS && IsFollowingMaster() ? stepDelay : BadlyHurtMoveDelay(Mobile, stepDelay); + } + + // The Running bit only selects the client's per-step interpolation (walk 400ms / run + // 200ms on foot, 200/100 mounted). A step shorter than the walk time must run or the + // client falls behind and snaps — but an isolated step (after standing at least a walk + // interval) renders alone and darts if run-flagged, so it goes out as a walk. A true + // sprinter always runs: a walk-rendered first step would flood the client's queue. + public bool ShouldRun() + { + var mounted = Mobile.Mounted || Mobile.Flying; + var walkDelay = mounted ? Moves.WalkMountDelay : Moves.WalkFootDelay; + var pace = EffectiveStepDelay() * 1000; + + if (pace >= walkDelay) + { + return false; + } + + var runDelay = mounted ? Moves.RunMountDelay : Moves.RunFootDelay; + + return pace < runDelay || Core.TickCount - Mobile.LastMoveTime < walkDelay; + } + + // One step per period, paced from the step just taken — no debt accrual: repaying a + // late step with a quicker follow-up puts two steps ~100ms apart, which renders as a + // dart. In continuous pursuit the move-wake lands within wheel resolution of this + // deadline, so the only cost is single-digit-ms drift per step. + private void ConsumeMoveBudget() + { + NextMove = Core.TickCount + Math.Max(50, (long)(EffectiveStepDelay() * 1000)); } public virtual bool CheckMove() => !(Mobile.Deleted || Mobile.DisallowAllMoves); @@ -76,6 +127,8 @@ public abstract partial class BaseAI return MoveResult.BadState; } + d = (d & Direction.Mask) | (ShouldRun() ? Direction.Running : 0); + if ((Mobile.Direction & Direction.Mask) != (d & Direction.Mask)) { Mobile.Direction = d; @@ -86,23 +139,21 @@ public abstract partial class BaseAI if (TryMove(d)) { - if (Core.AOS && IsFollowingMaster()) + // Obeying pets are paced by their order handlers. + if (!IsObeyingMoveOrder()) { - Mobile.CurrentSpeed = 0.1; - } - else if (Mobile.Hits < Mobile.HitsMax * 0.3) - { - Mobile.CurrentSpeed = BadlyHurtMoveDelay(Mobile); - } - else if (Mobile.Warmode || Mobile.Combatant != null) - { - Mobile.CurrentSpeed = Mobile.ActiveSpeed; - } - else - { - Mobile.CurrentSpeed = Mobile.PassiveSpeed; + if (Mobile.Warmode || Mobile.Combatant != null) + { + Mobile.SetCurrentSpeedToActive(); + } + else + { + Mobile.SetCurrentSpeedToPassive(); + } } + ConsumeMoveBudget(); + return MoveResult.Success; } @@ -151,6 +202,7 @@ public abstract partial class BaseAI if (Mobile.Move(Mobile.Direction)) { + ConsumeMoveBudget(); return MoveResult.SuccessAutoTurn; } } @@ -303,16 +355,18 @@ public abstract partial class BaseAI /// best-distance stall counter idles the creature if an in-range goal is genuinely /// unreachable, without ever abandoning a real chase or detour. /// - protected bool ApproachTarget(Mobile target, bool run, int range) + protected bool ApproachTarget(Mobile target, int range) { if (Mobile.Deleted || Mobile.DisallowAllMoves || target?.Deleted != false) { + ClearMoveIntent(); return false; } if (Mobile.InRange(target, range)) { ResetApproach(); + ClearMoveIntent(); return true; } @@ -321,12 +375,15 @@ public abstract partial class BaseAI { if (target.Location == _approachGaveUpGoalLoc) { + ClearMoveIntent(); return false; } ResetApproach(); // target moved — try again fresh } + RenewMoveIntent(target, null, range); + // FAST PATH: greedy step toward the target, counted as success ONLY when the move // fully succeeded (not an auto-turn sidestep) and actually got us closer. An // auto-turn sidestep can reduce Euclidean distance while moving in the wrong @@ -337,35 +394,87 @@ public abstract partial class BaseAI if (Path == null && Mobile.InLOS(target)) { var distBefore = Mobile.GetDistanceToSqrt(target); - var res = DoMoveImpl(Mobile.GetDirectionTo(target, run), true); + var res = DoMoveImpl(Mobile.GetDirectionTo(target), true); if (res == MoveResult.BadState) { - return false; // not allowed to move this tick; not a stall + return true; // not allowed to move this tick (frozen/casting/throttled); not a failure } if (res == MoveResult.Success && Mobile.GetDistanceToSqrt(target) < distBefore) { + ResetApproach(); - return Mobile.InRange(target, range); + return true; // healthy en-route progress } + // else: fall through; let the PathFollower route around the obstacle. } // PLANNING PATH: a persistent PathFollower, never discarded by a greedy step. if (Path == null || Path.Goal != target) { + Path = new PathFollower(Mobile, target) { Mover = DoMoveImpl }; } - if (Path.Follow(run, range)) + // Sample move-eligibility BEFORE the attempt: a successful step consumes the move + // budget, which would mask stall accounting and the progress signal. + var couldMove = CanMoveNow(out _) && !IsInBadState(); + var locBefore = Mobile.Location; + + if (Path.Follow(range)) { ResetApproach(); return true; } - TrackApproachProgress(target); - return false; + TrackApproachProgress(target, couldMove); + + // En-route progress is success; failure only when a move-eligible tick took no step + // (no working path), or the approach has given up. + var progressed = !_approachGaveUp && (Mobile.Location != locBefore || !couldMove); + + return progressed; + } + + /// + /// Walks toward a fixed point (e.g. a target's last-known position), pathfinding around + /// obstacles. Returns false on arrival or when genuinely unable to make progress. + /// + public bool MoveToPoint(IPoint3D goal) + { + if (Mobile.Deleted || Mobile.DisallowAllMoves || goal == null) + { + ClearMoveIntent(); + return false; + } + + if (Path?.Goal != goal) + { + Path = new PathFollower(Mobile, goal) { Mover = DoMoveImpl }; + } + + RenewMoveIntent(null, goal, 1); + + var couldMove = CanMoveNow(out _) && !IsInBadState(); + var locBefore = Mobile.Location; + + if (Path.Follow(1)) + { + Path = null; + ClearMoveIntent(); + return false; // arrived + } + + var progressed = Mobile.Location != locBefore || !couldMove; + + if (!progressed) + { + ClearMoveIntent(); + } + + return progressed; } /// @@ -376,11 +485,11 @@ public abstract partial class BaseAI /// gives up and idles. A MOVING goal (an active chase) resets the baseline every tick, /// so chases never give up even when the gap holds constant. /// - private void TrackApproachProgress(Mobile target) + private void TrackApproachProgress(Mobile target, bool couldMove) { - if (!CanMoveNow(out _)) + if (!couldMove) { - return; // a not-yet-due move (stun) is not a stall + return; // a tick that was never allowed to move (stun, stall) is not a stall } var dist = Mobile.GetDistanceToSqrt(target); @@ -411,6 +520,7 @@ public abstract partial class BaseAI _approachGaveUp = true; _approachGaveUpGoalLoc = goalLoc; Path = null; + ClearMoveIntent(); } } @@ -426,30 +536,73 @@ public abstract partial class BaseAI _approachGaveUp = false; } - public virtual bool MoveTo(Mobile m, bool run, int range) + private void RenewMoveIntent(Mobile target, IPoint3D point, int range) + { + _moveIntentTarget = target; + _moveIntentPoint = point; + _moveIntentRange = range; + + // A live pursuit renews every think tick; unrenewed intent dies on its own. + _moveIntentExpire = Core.TickCount + (long)(Mobile.CurrentSpeed * 2000) + 250; + } + + public void ClearMoveIntent() + { + _moveIntentTarget = null; + _moveIntentPoint = null; + } + + /// + /// True while a durable movement goal is live; is the tick + /// the movement budget elapses. + /// + public bool TryGetMoveWake(out long nextMove) + { + nextMove = NextMove; + + return (_moveIntentTarget != null || _moveIntentPoint != null) && Core.TickCount - _moveIntentExpire < 0; + } + + /// + /// Advances the current pursuit/investigation by one step on a movement-clock wake; + /// no decisions run. + /// + public void ContinueMove() + { + if (!TryGetMoveWake(out var nextMove) || Core.TickCount - nextMove < 0) + { + return; + } + + if (_moveIntentTarget != null) + { + ApproachTarget(_moveIntentTarget, _moveIntentRange); + } + else + { + MoveToPoint(_moveIntentPoint); + } + } + + public virtual bool MoveTo(Mobile m, int range) { if (Mobile.Deleted || Mobile.DisallowAllMoves || m?.Deleted != false) { return false; } - var distance = (int)Mobile.GetDistanceToSqrt(m); - var distanceThreshold = Core.AOS && IsFollowingMaster() ? 1 : 5; - - var shouldRun = run && distance > distanceThreshold; - if (Mobile.InRange(m, range)) { ResetApproach(); return true; } - if (UseGroupMovement(m)) + if (UseGroupMovement(m, range)) { - return MoveToWithGroup(this, m, shouldRun, range); + return MoveToWithGroup(this, m, range); } - return ApproachTarget(m, shouldRun, range); + return ApproachTarget(m, range); } [MethodImpl(MethodImplOptions.AggressiveInlining)] @@ -459,15 +612,22 @@ public abstract partial class BaseAI Mobile.ControlTarget == Mobile.ControlMaster && Mobile.Combatant == null; - private bool MoveToWithCollisionAvoidance(Mobile target, bool run, int range) + // A pet executing a movement order outside combat; its order handler owns its speed. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public bool IsObeyingMoveOrder() => + Mobile.Controlled && + Mobile.Combatant == null && + Mobile.ControlOrder is OrderType.Come or OrderType.Follow or OrderType.Guard; + + private bool MoveToWithCollisionAvoidance(Mobile target, int range) { - var distance = (int)Mobile.GetDistanceToSqrt(target); - - var shouldRun = run && distance > 5; - var direction = Mobile.GetDirectionTo(target); - if (DoMove(direction, true)) + // Wall-slide auto-turns must not count as progress, or a creature pinned on + // geometry reports success forever. + var res = DoMoveImpl(direction, true); + + if (res is MoveResult.Success or MoveResult.BadState) { return true; } @@ -476,14 +636,14 @@ public abstract partial class BaseAI { var clockwise = (Direction)(((int)direction + i) % 8); - if (DoMove(clockwise, true)) + if (DoMoveImpl(clockwise, true) == MoveResult.Success) { return true; } var counterclockwise = (Direction)(((int)direction - i + 8) % 8); - if (DoMove(counterclockwise, true)) + if (DoMoveImpl(counterclockwise, true) == MoveResult.Success) { return true; } @@ -491,10 +651,10 @@ public abstract partial class BaseAI // Tactical sidesteps exhausted — route around the obstacle via the centralized // approach primitive (persistent PathFollower, no oscillation). - return ApproachTarget(target, shouldRun, range); + return ApproachTarget(target, range); } - public virtual bool WalkMobileRange(Mobile m, int iSteps, bool run, int iWantDistMin, int iWantDistMax) + public virtual bool WalkMobileRange(Mobile m, int iSteps, int iWantDistMin, int iWantDistMax) { if (Mobile.Deleted || Mobile.DisallowAllMoves || m == null) { @@ -505,14 +665,12 @@ public abstract partial class BaseAI { var iCurrDist = (int)Mobile.GetDistanceToSqrt(m); - var shouldRun = run && iCurrDist > 5; - if (iCurrDist >= iWantDistMin && iCurrDist <= iWantDistMax) { return true; } - if (!MoveTowardsOrAwayFrom(m, shouldRun, iCurrDist, iWantDistMax)) + if (!MoveTowardsOrAwayFrom(m, iCurrDist, iWantDistMax)) { return false; } @@ -523,18 +681,16 @@ public abstract partial class BaseAI return dist >= iWantDistMin && dist <= iWantDistMax; } - private bool MoveTowardsOrAwayFrom(Mobile m, bool run, int iCurrDist, int iWantDistMax) + private bool MoveTowardsOrAwayFrom(Mobile m, int iCurrDist, int iWantDistMax) { - var shouldRun = run && iCurrDist > 5; - if (iCurrDist > iWantDistMax) { // Too far: approach via the centralized progress-based primitive. - return ApproachTarget(m, shouldRun, iWantDistMax); + return ApproachTarget(m, iWantDistMax); } // Too close: back away. Retreat keeps the simple greedy behavior (out of scope). - if (DoMove(m.GetDirectionTo(Mobile, shouldRun), true)) + if (DoMove(m.GetDirectionTo(Mobile), true)) { Path = null; return true; diff --git a/Projects/UOContent/Mobiles/AI/BaseAI/AITimer.cs b/Projects/UOContent/Mobiles/AI/BaseAI/AITimer.cs index 3c08d944b..e11268e87 100644 --- a/Projects/UOContent/Mobiles/AI/BaseAI/AITimer.cs +++ b/Projects/UOContent/Mobiles/AI/BaseAI/AITimer.cs @@ -17,26 +17,116 @@ using System; namespace Server.Mobiles; +/// +/// Drives an AI on two clocks: decisions at , plus +/// move-only wakes at while a pursuit is live. Each tick +/// schedules the earlier of the two deadlines. +/// public sealed class AITimer : Timer { private readonly BaseAI _owner; + private long _nextThink; + private long _nextWake; // when the pending wheel entry fires + private bool _inTick; private int _detectHiddenMinDelay; private int _detectHiddenMaxDelay; - public AITimer(BaseAI owner) : base(TimeSpan.FromMilliseconds(Utility.Random(3000)), - TimeSpan.FromSeconds(owner.Mobile.CurrentSpeed)) + // The initial delay is irrelevant: Activate is the only start path and sets its own. + public AITimer(BaseAI owner) : base(TimeSpan.Zero, TimeSpan.FromSeconds(owner.Mobile.CurrentSpeed)) { _owner = owner; _owner._nextDetectHidden = Core.TickCount; + _nextThink = Core.TickCount; } public void Activate() { - Interval = TimeSpan.FromSeconds(_owner.Mobile.CurrentSpeed); + _nextThink = Core.TickCount; + + if (Running) + { + return; + } + + // Short random spread: the creature responds within a think while a sector's + // worth of timers avoids a same-tick burst; the idle think jitter keeps the + // cohort apart from there. + Delay = TimeSpan.FromMilliseconds(Utility.Random(256)); Start(); + _nextWake = Core.TickCount + (long)Delay.TotalMilliseconds; + } + + // Think now. A think grants no action: steps, swings, casts, and abilities keep their own gates. + public void Prod() + { + _nextThink = Core.TickCount; + + if (Running) + { + Reschedule(); + return; + } + + Delay = TimeSpan.Zero; + Start(); + _nextWake = Core.TickCount + (long)Delay.TotalMilliseconds; + } + + // A speed-up must not wait out a stale, longer think deadline. + public void OnSpeedChanged() + { + var candidate = Core.TickCount + (long)(_owner.Mobile.CurrentSpeed * 1000); + + if (candidate - _nextThink < 0) + { + _nextThink = candidate; + Reschedule(); + } + } + + // Moves the pending wake earlier. Interval is only read after the next fire, + // so this needs Stop, Delay = remaining, Start. + private void Reschedule() + { + if (_inTick || !Running) + { + return; // ScheduleNext handles it at tick end + } + + var now = Core.TickCount; + var deadline = _nextThink; + + if (_owner.TryGetMoveWake(out var nextMove) && nextMove - now > 0 && nextMove - deadline < 0) + { + deadline = nextMove; + } + + if (deadline - _nextWake >= 0) + { + return; // pending wake is already early enough + } + + Stop(); + Delay = TimeSpan.FromMilliseconds(Math.Max(0, deadline - now)); + Start(); + _nextWake = now + (long)Delay.TotalMilliseconds; } protected override void OnTick() + { + _inTick = true; + + try + { + OnTickCore(); + } + finally + { + _inTick = false; + } + } + + private void OnTickCore() { if (ShouldStop()) { @@ -44,23 +134,64 @@ public sealed class AITimer : Timer return; } - _owner.Mobile.OnThink(); - - if (ShouldStop()) + if (Core.TickCount - _nextThink >= 0) { - Stop(); - return; + _owner.Mobile.OnThink(); + + if (ShouldStop()) + { + Stop(); + return; + } + + HandleBardEffects(); + + if (_owner.Mobile.Controlled ? _owner.Obey() : _owner.Think()) + { + HandleDetectHidden(); + } + + // Cadence from the post-decision speed (decisions may flip active/passive). + var period = (long)(_owner.Mobile.CurrentSpeed * 1000); + _nextThink = Core.TickCount + period; + + // Idle cadence drifts: a zero-mean jitter random-walks think phases apart, so + // creatures spawned or woken together cannot stay in lock-step (a one-shot + // spread can collide and identical periods never separate). Engaged cadence + // stays exact — pursuit timing anchors to real step times. + if (_owner.Mobile.CurrentSpeed == _owner.Mobile.PassiveSpeed) + { + var jitter = (int)(period >> 3); + _nextThink += Utility.RandomMinMax(-jitter, jitter); + } + } + else + { + _owner.ContinueMove(); } - Interval = TimeSpan.FromSeconds(_owner.Mobile.CurrentSpeed); - HandleBardEffects(); + ScheduleNext(); + } - if (_owner.Mobile.Controlled ? !_owner.Obey() : !_owner.Think()) + private void ScheduleNext() + { + var now = Core.TickCount; + var delay = _nextThink - now; + + if (_owner.TryGetMoveWake(out var nextMove)) { - return; + var moveDelay = nextMove - now; + + // Only a future budget is a wake — a blocked creature must not spin the timer. + if (moveDelay > 0 && moveDelay < delay) + { + delay = moveDelay; + } } - HandleDetectHidden(); + // The wheel rounds up to its 8ms resolution; a non-positive delay becomes one turn. + Interval = TimeSpan.FromMilliseconds(delay); + _nextWake = now + (long)Interval.TotalMilliseconds; } private bool ShouldStop() diff --git a/Projects/UOContent/Mobiles/AI/BaseAI/BaseAI.cs b/Projects/UOContent/Mobiles/AI/BaseAI/BaseAI.cs index 57b6f6a25..4b8753d10 100644 --- a/Projects/UOContent/Mobiles/AI/BaseAI/BaseAI.cs +++ b/Projects/UOContent/Mobiles/AI/BaseAI/BaseAI.cs @@ -26,11 +26,26 @@ namespace Server.Mobiles; public abstract partial class BaseAI { + // Last-known-position tracking: recorded while the combatant is in LOS; drives the + // guard-time investigation and the instant re-engage. + private const int GuardGraceDuration = 10_000; + private const int LkpFreshDuration = 30_000; + private const int InvestigateDuration = 15_000; + private ActionType _action; public long _nextDetectHidden; public DateTime _lastOrder = DateTime.MinValue; public Mobile _commandIssuer; + private Mobile _lkpTarget; + private Point3D _lkpLocation; + private IPoint3D _lkpGoal; // boxed _lkpLocation handed to the PathFollower + private IPoint3D _herdGoal; // boxed herding goal handed to the PathFollower + private long _lkpExpireTick; + private long _guardStopTick; + private long _investigateStopTick; + private bool _investigating; + public PathFollower Path { get; protected set; } public AITimer AITimer { get; } public long NextMove { get; set; } @@ -44,11 +59,12 @@ public abstract partial class BaseAI public BaseAI(BaseCreature m) { Mobile = m; + NextMove = Core.TickCount; AITimer = new AITimer(this); if (!m.PlayerRangeSensitive || !World.Loading && m.Map != null && m.Map != Map.Internal && m.Map.GetSector(m.Location).Active) { - AITimer.Start(); + AITimer.Activate(); } if (Action != ActionType.Wander) @@ -233,6 +249,15 @@ public abstract partial class BaseAI return true; } + if (_action == ActionType.Combat) + { + UpdateLastKnownLocation(); + } + else if (_action is ActionType.Wander or ActionType.Guard) + { + TryReengageLastKnown(); + } + switch (Action) { case ActionType.Wander: @@ -272,6 +297,9 @@ public abstract partial class BaseAI public virtual void OnActionChanged() { + // A change of course invalidates between-think movement continuation. + ClearMoveIntent(); + switch (Action) { case ActionType.Wander: @@ -323,6 +351,15 @@ public abstract partial class BaseAI { Mobile.Warmode = true; Mobile.Combatant = null; + + // Investigate a fresh last-seen position that is not already in view; the guard + // grace period begins once the investigation ends. + _investigating = _lkpTarget != null && Core.TickCount - _lkpExpireTick < 0 && + !(Mobile.InRange(_lkpLocation, 1) || + Mobile.InLOS(_lkpLocation) && Mobile.InRange(_lkpLocation, Mobile.RangePerception)); + _investigateStopTick = Core.TickCount + InvestigateDuration; + _guardStopTick = Core.TickCount + GuardGraceDuration; + _lkpGoal = null; } private void HandleFleeAction() @@ -405,7 +442,7 @@ public abstract partial class BaseAI var master = Mobile.SummonMaster; if (master != null && master.Map == Mobile.Map && master.InRange(Mobile, Mobile.RangePerception)) { - MoveTo(master, false, 1); + MoveTo(master, 1); } } @@ -453,18 +490,118 @@ public abstract partial class BaseAI public virtual bool DoActionGuard() { - if (Mobile.Combatant == null) + if (_investigating) { - DebugSay("No threats found. Going home..."); - Action = ActionType.Wander; + if (InvestigateLastKnown()) + { + return true; + } + + _investigating = false; + _guardStopTick = Core.TickCount + GuardGraceDuration; } - DebugSay("I stopped being on guard."); + if (Core.TickCount - _guardStopTick < 0) + { + DebugSay("I am on guard."); + + if (Utility.Random(8) == 0) + { + Mobile.Direction = (Direction)Utility.Random(8); + } + + return true; + } + + DebugSay("I stopped being on guard. Going home..."); Action = ActionType.Wander; return true; } + /// + /// Records the combatant's position while it is visible and in line of sight. + /// + private void UpdateLastKnownLocation() + { + var combatant = Mobile.Combatant; + + if (combatant?.Deleted == false && combatant.Map == Mobile.Map && + Mobile.CanSee(combatant) && Mobile.InLOS(combatant)) + { + _lkpTarget = combatant; + _lkpLocation = combatant.Location; + _lkpExpireTick = Core.TickCount + LkpFreshDuration; + } + } + + /// + /// Re-engages the last-seen target when it returns to view within perception range, + /// bypassing the reacquire throttle. + /// + private bool TryReengageLastKnown() + { + var target = _lkpTarget; + + if (target == null) + { + return false; + } + + if (target.Deleted || !target.Alive || target.Map != Mobile.Map || + target is BaseCreature { IsDeadPet: true } || Core.TickCount - _lkpExpireTick >= 0) + { + ClearLastKnown(); + return false; + } + + if (Mobile.Controlled || Mobile.BardPacified || Mobile.BardProvoked || Mobile.FightMode == FightMode.None) + { + return false; + } + + if (!Mobile.InRange(target, Mobile.RangePerception) || !Mobile.CanSee(target) || + !Mobile.InLOS(target) || !Mobile.CanBeHarmful(target, false)) + { + return false; + } + + DebugSay("There you are!"); + Mobile.Combatant = target; + Mobile.FocusMob = null; + Action = ActionType.Combat; + return true; + } + + /// + /// Walks toward the last-seen position until it is in view, reached, timed out, or + /// unreachable. Returns false when the investigation is finished. + /// + private bool InvestigateLastKnown() + { + if (_lkpTarget == null || Core.TickCount - _investigateStopTick >= 0) + { + return false; + } + + if (Mobile.InRange(_lkpLocation, 1) || + Mobile.InLOS(_lkpLocation) && Mobile.InRange(_lkpLocation, Mobile.RangePerception)) + { + DebugSay("They truly disappeared..."); + return false; + } + + _lkpGoal ??= _lkpLocation; + return MoveToPoint(_lkpGoal); + } + + private void ClearLastKnown() + { + _lkpTarget = null; + _lkpGoal = null; + _investigating = false; + } + public virtual bool DoActionFlee() { var from = Mobile.Combatant; @@ -491,6 +628,7 @@ public abstract partial class BaseAI if (target == null) { + _herdGoal = null; return false; } @@ -498,7 +636,15 @@ public abstract partial class BaseAI if (distance >= 1 && distance <= 15) { - DoMove(Mobile.GetDirectionTo(target)); + // A cached boxed goal keeps the PathFollower persistent across ticks; walking + // through MoveToPoint paces herding on the movement clock and paths around + // obstacles. + if (_herdGoal == null || _herdGoal.X != target.X || _herdGoal.Y != target.Y) + { + _herdGoal = new Point3D(target.X, target.Y, Mobile.Map?.GetAverageZ(target.X, target.Y) ?? Mobile.Z); + } + + MoveToPoint(_herdGoal); return true; } @@ -508,6 +654,7 @@ public abstract partial class BaseAI } Mobile.TargetLocation = null; + _herdGoal = null; return false; } @@ -651,7 +798,7 @@ public abstract partial class BaseAI { if (AcquireFocusMob(Mobile.RangePerception * 2, FightMode.Closest, true, false, true)) { - if (WalkMobileRange(Mobile.FocusMob, 1, false, Mobile.RangePerception, Mobile.RangePerception * 2)) + if (WalkMobileRange(Mobile.FocusMob, 1, Mobile.RangePerception, Mobile.RangePerception * 2)) { DebugSay("I backed off to safety. Wandering..."); @@ -703,22 +850,24 @@ public abstract partial class BaseAI return false; } - if (Core.TickCount - Mobile.NextReacquireTime < 0) + var reacquireDelay = (long)Mobile.ReacquireDelay.TotalMilliseconds; + var gateRemaining = Mobile.NextReacquireTime - Core.TickCount; + + if (gateRemaining > 0 && gateRemaining <= reacquireDelay) { Mobile.FocusMob = null; return false; } - Mobile.NextReacquireTime = Core.TickCount + (int)Mobile.ReacquireDelay.TotalMilliseconds; + DebugSay("Acquiring new target...", 0); - DebugSay("Acquiring new target..."); + var acquired = AcquireNewFocusMob(Mobile.Map, iRange, acqType, bPlayerOnly, bFacFriend, bFacFoe); - if (Mobile.Map == null) - { - return Mobile.FocusMob != null; - } + // Reaction time is the approach path (BaseCreature.ScheduleAcquireOnApproach), + // not this poll — every scan honors the full delay. + Mobile.NextReacquireTime = Core.TickCount + reacquireDelay; - return AcquireNewFocusMob(Mobile.Map, iRange, acqType, bPlayerOnly, bFacFriend, bFacFoe); + return acquired; } private bool HandleBardProvoked() @@ -794,8 +943,10 @@ public abstract partial class BaseAI private bool AcquireNewFocusMob(Map map, int iRange, FightMode acqType, bool bPlayerOnly, bool bFacFriend, bool bFacFoe) { - Mobile newFocusMob = null, enemySummonMob = null; - double val = double.MinValue, enemySummonVal = double.MinValue; + Mobile newFocusMob = null; + Mobile enemySummonMob = null; + var val = double.MinValue; + var enemySummonVal = double.MinValue; foreach (var m in map.GetMobilesInRange(Mobile.Location, iRange)) { @@ -999,6 +1150,6 @@ public abstract partial class BaseAI public virtual void OnCurrentSpeedChanged() { - AITimer.Interval = TimeSpan.FromSeconds(Mobile.CurrentSpeed); + AITimer.OnSpeedChanged(); } } diff --git a/Projects/UOContent/Mobiles/AI/BaseAI/PetOrderHandlers.cs b/Projects/UOContent/Mobiles/AI/BaseAI/PetOrderHandlers.cs index ecb456d2e..862c78649 100644 --- a/Projects/UOContent/Mobiles/AI/BaseAI/PetOrderHandlers.cs +++ b/Projects/UOContent/Mobiles/AI/BaseAI/PetOrderHandlers.cs @@ -26,7 +26,7 @@ public abstract partial class BaseAI return; } - Activate(); + AITimer.Prod(); switch (Mobile.ControlOrder) { @@ -36,6 +36,10 @@ public abstract partial class BaseAI break; } case OrderType.Come: + { + Mobile.SetCurrentSpeedToActive(); + break; + } case OrderType.Drop: case OrderType.Friend: case OrderType.Unfriend: @@ -135,6 +139,7 @@ public abstract partial class BaseAI Mobile.FocusMob = null; Mobile.Warmode = false; Mobile.Combatant = null; + Mobile.SetCurrentSpeedToPassive(); } private void HandleTransferOrder() @@ -148,6 +153,7 @@ public abstract partial class BaseAI Mobile.FocusMob = null; Mobile.Warmode = false; Mobile.Combatant = null; + Mobile.SetCurrentSpeedToPassive(); Mobile.PlaySound(Mobile.GetIdleSound()); _commandIssuer = null; } @@ -162,9 +168,16 @@ public abstract partial class BaseAI _commandIssuer?.RevealingAction(); Mobile.FocusMob = null; Mobile.Warmode = true; - Mobile.PlaySound(Mobile.GetAttackSound()); - Mobile.ControlMaster?.SendLocalizedMessage(1049671, Mobile.Name); - // ~1_NAME~ is now guarding you. + Mobile.SetCurrentSpeedToActive(); + + // Resuming the persistent order must not replay the flourish. + if (!_resolvingOrder) + { + Mobile.PlaySound(Mobile.GetAttackSound()); + Mobile.ControlMaster?.SendLocalizedMessage(1049671, Mobile.Name); + // ~1_NAME~ is now guarding you. + } + _commandIssuer = null; } @@ -191,6 +204,7 @@ public abstract partial class BaseAI } Mobile.Warmode = true; + Mobile.SetCurrentSpeedToActive(); Mobile.PlaySound(Mobile.GetAttackSound()); _commandIssuer = null; } @@ -206,6 +220,7 @@ public abstract partial class BaseAI Mobile.FocusMob = null; Mobile.Warmode = false; Mobile.Combatant = null; + Mobile.SetCurrentSpeedToActive(); Mobile.PlaySound(Mobile.GetIdleSound()); _commandIssuer = null; } @@ -221,6 +236,7 @@ public abstract partial class BaseAI Mobile.FocusMob = null; Mobile.Warmode = false; Mobile.Combatant = null; + Mobile.SetCurrentSpeedToPassive(); Mobile.PlaySound(Mobile.GetIdleSound()); _commandIssuer = null; // Home (the stay anchor) is owned by SetPersistentOrder, not this handler. diff --git a/Projects/UOContent/Mobiles/AI/BaseAI/PetOrders.cs b/Projects/UOContent/Mobiles/AI/BaseAI/PetOrders.cs index 1b7139a96..e6c48850d 100644 --- a/Projects/UOContent/Mobiles/AI/BaseAI/PetOrders.cs +++ b/Projects/UOContent/Mobiles/AI/BaseAI/PetOrders.cs @@ -84,7 +84,7 @@ public abstract partial class BaseAI return true; } - WalkMobileRange(Mobile.ControlMaster, 1, false, 1, 2); + WalkMobileRange(Mobile.ControlMaster, 1, 1, 2); if (Mobile.GetDistanceToSqrt(Mobile.ControlMaster) <= 2) { @@ -128,9 +128,15 @@ public abstract partial class BaseAI this.DebugSayFormatted($"I am ordered to follow {Mobile.ControlTarget?.Name}."); + // AOS: sprint after the master (bespoke 0.1 paces both clocks). + if (Core.AOS && Mobile.ControlTarget == Mobile.ControlMaster && Mobile.Combatant == null) + { + Mobile.CurrentSpeed = 0.1; + } + if (currentDistance > 1) { - WalkMobileRange(Mobile.ControlTarget, 1, currentDistance > 2, 1, 2); + WalkMobileRange(Mobile.ControlTarget, 1, 1, 2); } } @@ -291,14 +297,13 @@ public abstract partial class BaseAI return true; } - FindCombatant(); + var combatant = FindGuardTarget(); - if (IsValidCombatant(Mobile.Combatant)) + if (combatant != null) { - var combatant = Mobile.Combatant; - this.DebugSayFormatted($"Attacking target: {combatant.Name}"); + // Engage without leaving the Guard order so tags, recall handling, and retargeting persist. Mobile.Combatant = combatant; Mobile.FocusMob = combatant; Action = ActionType.Combat; @@ -309,16 +314,30 @@ public abstract partial class BaseAI { this.DebugSayFormatted($"Guarding my master, {controlMaster.Name}."); - var guardLocation = controlMaster.Location; + // Stand down; a stale Warmode would skew the return pace. + Mobile.FocusMob = null; + Mobile.Warmode = false; + Mobile.Combatant = null; - var distance = (int)Mobile.GetDistanceToSqrt(guardLocation); + var distance = (int)Mobile.GetDistanceToSqrt(controlMaster); if (distance > 3) { - DoMove(Mobile.GetDirectionTo(guardLocation)); + // AOS: sprint back (bespoke 0.1 paces both clocks); earlier eras run active. + if (Core.AOS) + { + Mobile.CurrentSpeed = 0.1; + } + else + { + Mobile.SetCurrentSpeedToActive(); + } + + WalkMobileRange(controlMaster, 1, 1, 3); } else { + Mobile.SetCurrentSpeedToActive(); // alert at the master's side WalkRandom(3, 1, 1); } } @@ -359,67 +378,83 @@ public abstract partial class BaseAI Mobile.ControlTarget = Mobile.ControlMaster; ResumePersistentOrder(); - if (Mobile.FightMode is FightMode.Closest or FightMode.Aggressor) + // A resumed Guard engages through its own scan; other fallbacks chain an explicit Attack. + if (Mobile.ControlOrder == OrderType.Guard || + Mobile.FightMode is not (FightMode.Closest or FightMode.Aggressor)) { - FindCombatant(); + return; + } + + var next = FindGuardTarget(); + + if (next != null) + { + Mobile.ControlTarget = next; + Mobile.ControlOrder = OrderType.Attack; + Mobile.Combatant = next; + + this.DebugSayFormatted($"{next.Name} is still hostile! Engaging..."); + + Think(); } } - private void FindCombatant() + /// + /// Selects the aggressor closest to the master. The current combatant is kept + /// unless a strictly closer one exists. Never mutates order state. + /// + private Mobile FindGuardTarget() { var controlMaster = Mobile.ControlMaster; + var anchor = controlMaster ?? Mobile; + + var current = Mobile.Combatant; + var best = current != controlMaster && IsValidCombatant(current) ? current : null; + var bestDist = best?.GetDistanceToSqrt(anchor) ?? double.MaxValue; foreach (var aggr in Mobile.GetMobilesInRange(Mobile.RangePerception)) { - if (!Mobile.CanSee(aggr) || aggr.IsDeadBondedPet || !aggr.Alive) + if (aggr == best || aggr == Mobile || aggr == controlMaster || + aggr.IsDeadBondedPet || !aggr.Alive || + aggr.Combatant != Mobile && (controlMaster == null || aggr.Combatant != controlMaster)) { continue; } - var isAttackingPet = aggr.Combatant == Mobile; - var isAttackingMaster = controlMaster != null && aggr.Combatant == controlMaster; + var dist = aggr.GetDistanceToSqrt(anchor); - if (isAttackingPet || isAttackingMaster) + if (dist < bestDist && Mobile.CanSee(aggr) && Mobile.InLOS(aggr)) { - if (Mobile.InLOS(aggr)) - { - Mobile.ControlTarget = aggr; - Mobile.ControlOrder = OrderType.Attack; - Mobile.Combatant = aggr; - - var target = isAttackingMaster ? "master" : "me"; - this.DebugSayFormatted($"{aggr.Name} is attacking my {target}! Engaging..."); - - Think(); - return; - } + best = aggr; + bestDist = dist; } } - if (controlMaster?.Aggressors != null) - { - for (var i = 0; i < controlMaster.Aggressors.Count; i++) - { - var aggressor = controlMaster.Aggressors[i].Attacker; + var aggressors = controlMaster?.Aggressors; - if (aggressor?.Deleted != false || !aggressor.Alive || aggressor.IsDeadBondedPet) + if (aggressors != null) + { + for (var i = 0; i < aggressors.Count; i++) + { + var aggressor = aggressors[i].Attacker; + + if (aggressor == best || aggressor?.Deleted != false || !aggressor.Alive || + aggressor.IsDeadBondedPet || !Mobile.InRange(aggressor, Mobile.RangePerception)) { continue; } - if (Mobile.InRange(aggressor, Mobile.RangePerception) && Mobile.CanSee(aggressor) && Mobile.InLOS(aggressor)) + var dist = aggressor.GetDistanceToSqrt(anchor); + + if (dist < bestDist && Mobile.CanSee(aggressor) && Mobile.InLOS(aggressor)) { - Mobile.ControlTarget = aggressor; - Mobile.ControlOrder = OrderType.Attack; - Mobile.Combatant = aggressor; - - this.DebugSayFormatted($"{aggressor.Name} recently attacked my master! Retaliating..."); - - Think(); - return; + best = aggressor; + bestDist = dist; } } } + + return best; } public virtual bool DoOrderRelease() diff --git a/Projects/UOContent/Mobiles/AI/BerserkAI.cs b/Projects/UOContent/Mobiles/AI/BerserkAI.cs index 4663ae8d0..8a2015f1a 100644 --- a/Projects/UOContent/Mobiles/AI/BerserkAI.cs +++ b/Projects/UOContent/Mobiles/AI/BerserkAI.cs @@ -12,7 +12,7 @@ public class BerserkAI : BaseAI if (AcquireFocusMob(Mobile.RangePerception, FightMode.Closest, false, true, true)) { - this.DebugSayFormatted($"I have detected {Mobile.FocusMob.Name} and I will attack"); + this.DebugSayFormatted($"I have detected {Mobile.FocusMob.Name}, attacking"); Mobile.Combatant = Mobile.FocusMob; Action = ActionType.Combat; @@ -38,7 +38,7 @@ public class BerserkAI : BaseAI return true; } - if (!WalkMobileRange(combatant, 1, false, Mobile.RangeFight, Mobile.RangeFight)) + if (!WalkMobileRange(combatant, 1, Mobile.RangeFight, Mobile.RangeFight)) { this.DebugSayFormatted($"I am still not in range of {combatant.Name}"); diff --git a/Projects/UOContent/Mobiles/AI/HealerAI.cs b/Projects/UOContent/Mobiles/AI/HealerAI.cs index 2a1127b43..cc54c73c3 100644 --- a/Projects/UOContent/Mobiles/AI/HealerAI.cs +++ b/Projects/UOContent/Mobiles/AI/HealerAI.cs @@ -81,7 +81,7 @@ public class HealerAI : BaseAI return true; } - WalkMobileRange(Mobile.FocusMob, 1, false, 4, 7); + WalkMobileRange(Mobile.FocusMob, 1, 4, 7); // TODO: Should it be able to do this? if (Mobile.TriggerAbility(MonsterAbilityTrigger.CombatAction, Mobile.Combatant)) diff --git a/Projects/UOContent/Mobiles/AI/MageAI.cs b/Projects/UOContent/Mobiles/AI/MageAI.cs index e9450db98..1ed8cbdda 100644 --- a/Projects/UOContent/Mobiles/AI/MageAI.cs +++ b/Projects/UOContent/Mobiles/AI/MageAI.cs @@ -171,7 +171,7 @@ public class MageAI : BaseAI { if (!SmartAI) { - if (!MoveTo(m, false, Mobile.RangeFight)) + if (!MoveTo(m, Mobile.RangeFight)) { OnFailedMove(); } @@ -185,14 +185,14 @@ public class MageAI : BaseAI { RunFrom(m); } - else if (!Mobile.InRange(m, Math.Max(Mobile.RangeFight, 2)) && !MoveTo(m, false, 1)) + else if (!Mobile.InRange(m, Math.Max(Mobile.RangeFight, 2)) && !MoveTo(m, 1)) { OnFailedMove(); } } else if (!Mobile.InRange(m, Mobile.RangeFight)) { - if (!MoveTo(m, false, 1)) + if (!MoveTo(m, 1)) { OnFailedMove(); } @@ -679,7 +679,7 @@ public class MageAI : BaseAI Mobile.Combatant = Mobile.FocusMob; Mobile.FocusMob = null; } - else if (!Mobile.InRange(c, Mobile.RangePerception * 3)) + else if (!Mobile.InRange(c, Mobile.ChaseLeashRange)) { Mobile.Combatant = null; } @@ -695,6 +695,23 @@ public class MageAI : BaseAI } } + // Geometry (not hiding — CanSee passed above) is blocking the shot: close in until + // line of sight returns. Poisoned mages still fall through to cure. + if (!Mobile.Poisoned && Mobile.Spell?.IsCasting != true && !Mobile.InLOS(c)) + { + DebugSay("I cannot see my target, moving to regain line of sight"); + + if (!MoveTo(c, 1)) + { + OnFailedMove(); + } + + _lastTarget = c; + _lastTargetLoc = c.Location; + + return true; + } + if (Mobile.TriggerAbility(MonsterAbilityTrigger.CombatAction, c)) { DebugSay("I used my abilities!"); @@ -1018,7 +1035,16 @@ public class MageAI : BaseAI if (toTarget != null) { - RunTo(toTarget); + // Without line of sight the stand-off is pointless — close in so the held + // target can be invoked. + if (!Mobile.InLOS(toTarget)) + { + MoveTo(toTarget, 1); + } + else + { + RunTo(toTarget); + } } } diff --git a/Projects/UOContent/Mobiles/AI/MeleeAI.cs b/Projects/UOContent/Mobiles/AI/MeleeAI.cs index aa262caee..a6d0a9bf9 100644 --- a/Projects/UOContent/Mobiles/AI/MeleeAI.cs +++ b/Projects/UOContent/Mobiles/AI/MeleeAI.cs @@ -1,3 +1,5 @@ +using System.Runtime.CompilerServices; + namespace Server.Mobiles; public class MeleeAI : BaseAI @@ -14,6 +16,7 @@ public class MeleeAI : BaseAI if (AcquireFocusMob(Mobile.RangePerception, Mobile.FightMode, false, false, true)) { this.DebugSayFormatted($"I have detected {Mobile.FocusMob.Name}, attacking"); + Mobile.Combatant = Mobile.FocusMob; Action = ActionType.Combat; } @@ -65,13 +68,9 @@ public class MeleeAI : BaseAI return true; } - private bool IsValidCombatant(Mobile combatant) - { - return combatant?.Deleted == false - && combatant.Map == Mobile.Map - && combatant.Alive - && !combatant.IsDeadBondedPet; - } + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private bool IsValidCombatant(Mobile combatant) => + combatant?.Deleted == false && combatant.Map == Mobile.Map && combatant.Alive && !combatant.IsDeadBondedPet; private bool HandleOutOfRangeCombatant(Mobile combatant) { @@ -82,7 +81,7 @@ public class MeleeAI : BaseAI return true; } - if (!Mobile.InRange(combatant, Mobile.RangePerception * 3)) + if (!Mobile.InRange(combatant, Mobile.ChaseLeashRange)) { Mobile.Combatant = null; } @@ -99,7 +98,7 @@ public class MeleeAI : BaseAI private bool AttemptMoveToCombatant(Mobile combatant) { - if (MoveTo(combatant, false, Mobile.RangeFight)) + if (MoveTo(combatant, Mobile.RangeFight)) { return true; } @@ -127,7 +126,8 @@ public class MeleeAI : BaseAI { if (AcquireFocusMob(Mobile.RangePerception, Mobile.FightMode, false, false, true)) { - this.DebugSayFormatted($"I have detected {Mobile.FocusMob.Name}, attacking."); + this.DebugSayFormatted($"I have detected {Mobile.FocusMob.Name}, attacking"); + Mobile.Combatant = Mobile.FocusMob; Action = ActionType.Combat; } diff --git a/Projects/UOContent/Mobiles/AI/PredatorAI.cs b/Projects/UOContent/Mobiles/AI/PredatorAI.cs index 5e0e01520..b1ea3a638 100644 --- a/Projects/UOContent/Mobiles/AI/PredatorAI.cs +++ b/Projects/UOContent/Mobiles/AI/PredatorAI.cs @@ -41,7 +41,7 @@ public class PredatorAI : BaseAI return true; } - if (!WalkMobileRange(combatant, 1, false, Mobile.RangeFight, Mobile.RangeFight)) + if (!WalkMobileRange(combatant, 1, Mobile.RangeFight, Mobile.RangeFight)) { if (Mobile.GetDistanceToSqrt(combatant) > Mobile.RangePerception + 1) { @@ -70,7 +70,7 @@ public class PredatorAI : BaseAI } else if (AcquireFocusMob(Mobile.RangePerception * 2, FightMode.Closest, true, false, true)) { - if (WalkMobileRange(Mobile.FocusMob, 1, false, Mobile.RangePerception, Mobile.RangePerception * 2)) + if (WalkMobileRange(Mobile.FocusMob, 1, Mobile.RangePerception, Mobile.RangePerception * 2)) { DebugSay("Well, here I am safe"); diff --git a/Projects/UOContent/Mobiles/AI/ThiefAI.cs b/Projects/UOContent/Mobiles/AI/ThiefAI.cs index 0fa37bbc9..a9209dfbb 100644 --- a/Projects/UOContent/Mobiles/AI/ThiefAI.cs +++ b/Projects/UOContent/Mobiles/AI/ThiefAI.cs @@ -43,7 +43,7 @@ public class ThiefAI : BaseAI return true; } - if (!WalkMobileRange(combatant, 1, false, Mobile.RangeFight, Mobile.RangeFight)) + if (!WalkMobileRange(combatant, 1, Mobile.RangeFight, Mobile.RangeFight)) { this.DebugSayFormatted($"I should be closer to {combatant.Name}"); } diff --git a/Projects/UOContent/Mobiles/Animals/Misc/Sheep.cs b/Projects/UOContent/Mobiles/Animals/Misc/Sheep.cs index b43c34272..741093110 100644 --- a/Projects/UOContent/Mobiles/Animals/Misc/Sheep.cs +++ b/Projects/UOContent/Mobiles/Animals/Misc/Sheep.cs @@ -5,9 +5,14 @@ using System.Runtime.CompilerServices; namespace Server.Mobiles { - [SerializationGenerator(0, false)] + [SerializationGenerator(1, false)] public partial class Sheep : BaseCreature, ICarvable { + private void MigrateFrom(V0Content content) + { + _nextWoolTime = content.NextWoolTime; + } + [Constructible] public Sheep() : base(AIType.AI_Animal, FightMode.Aggressor) { @@ -43,18 +48,14 @@ namespace Server.Mobiles public override string CorpseName => "a sheep corpse"; - [DeltaDateTime] - [SerializableProperty(0)] - [CommandProperty(AccessLevel.GameMaster)] - public DateTime NextWoolTime + [SerializableField(0, fieldChanged: nameof(OnNextWoolTimeChanged))] + [AnchoredDateTime] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private DateTime _nextWoolTime; + + private void OnNextWoolTimeChanged(DateTime oldValue, DateTime newValue) { - get => _nextWoolTime; - set - { - _nextWoolTime = value; - SheepBody(); - this.MarkDirty(); - } + SheepBody(); } [MethodImpl(MethodImplOptions.AggressiveInlining)] diff --git a/Projects/UOContent/Mobiles/Animals/Mounts/Ethereals.cs b/Projects/UOContent/Mobiles/Animals/Mounts/Ethereals.cs index f13cebcbe..f6dc13b40 100644 --- a/Projects/UOContent/Mobiles/Animals/Mounts/Ethereals.cs +++ b/Projects/UOContent/Mobiles/Animals/Mounts/Ethereals.cs @@ -11,19 +11,19 @@ namespace Server.Mobiles public partial class EtherealMount : Item, IMount, IMountItem, IRewardItem { [SerializableField(0)] + [SaveFlag(nameof(ShouldSerializeIsDonationItem))] [SerializedCommandProperty(AccessLevel.GameMaster, AccessLevel.Administrator)] public bool _isDonationItem; [MethodImpl(MethodImplOptions.AggressiveInlining)] - [SerializableFieldSaveFlag(0)] public bool ShouldSerializeIsDonationItem() => _isDonationItem; [SerializableField(1)] + [SaveFlag(nameof(ShouldSerializeIsRewardItem))] [SerializedCommandProperty(AccessLevel.GameMaster)] public bool _isRewardItem; [MethodImpl(MethodImplOptions.AggressiveInlining)] - [SerializableFieldSaveFlag(1)] public bool ShouldSerializeIsRewardItem() => _isRewardItem; [Constructible] @@ -40,43 +40,27 @@ namespace Server.Mobiles public override double DefaultWeight => 1.0; - [SerializableProperty(2)] - [CommandProperty(AccessLevel.GameMaster)] - public int MountedID - { - get => _mountedID; - set - { - if (_mountedID != value) - { - _mountedID = value; + [SerializableField(2, fieldChanged: nameof(OnMountedIDChanged))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private int _mountedID; - if (_rider != null) - { - ItemID = value; - } - this.MarkDirty(); - } + private void OnMountedIDChanged(int oldValue, int newValue) + { + if (_rider != null) + { + ItemID = newValue; } } - [SerializableProperty(3)] - [CommandProperty(AccessLevel.GameMaster)] - public int RegularID - { - get => _regularID; - set - { - if (_regularID != value) - { - _regularID = value; + [SerializableField(3, fieldChanged: nameof(OnRegularIDChanged))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private int _regularID; - if (_rider == null) - { - ItemID = value; - } - this.MarkDirty(); - } + private void OnRegularIDChanged(int oldValue, int newValue) + { + if (_rider == null) + { + ItemID = newValue; } } @@ -87,6 +71,7 @@ namespace Server.Mobiles public virtual int EtherealHue => 0x4001; [SerializableProperty(4)] + [SaveFlag(nameof(ShouldSerializeRider))] [CommandProperty(AccessLevel.GameMaster)] public Mobile Rider { @@ -124,22 +109,19 @@ namespace Server.Mobiles } } - [SerializableFieldSaveFlag(4)] private bool ShouldSerializeRider() => _rider != null; - [CommandProperty(AccessLevel.GameMaster)] - [SerializableProperty(5)] - public int Steps + [SerializableField(5, allowFieldChange: nameof(AllowStepsChange))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + [SaveFlag(nameof(ShouldSerializeSteps))] + private int _steps; + + private bool AllowStepsChange(ref int value) { - get => _steps; - set - { - _steps = Math.Clamp(value, 0, StepsMax); - this.MarkDirty(); - } + value = Math.Clamp(value, 0, StepsMax); + return true; } - [SerializableFieldSaveFlag(5)] private bool ShouldSerializeSteps() => _steps != StepsMax; public virtual int StepsMax => 3840; // Should be same as horse diff --git a/Projects/UOContent/Mobiles/Animals/Mounts/SwampDragon.cs b/Projects/UOContent/Mobiles/Animals/Mounts/SwampDragon.cs index 69bd9ce56..19a061af2 100644 --- a/Projects/UOContent/Mobiles/Animals/Mounts/SwampDragon.cs +++ b/Projects/UOContent/Mobiles/Animals/Mounts/SwampDragon.cs @@ -62,48 +62,37 @@ namespace Server.Mobiles [SerializableField(3)] private int _bardingHP; - [CommandProperty(AccessLevel.GameMaster)] - [SerializableProperty(2)] - public bool HasBarding - { - get => _hasBarding; - set - { - _hasBarding = value; + [SerializableField(2, fieldChanged: nameof(OnHasBardingChanged))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + [InvalidateProperties] + private bool _hasBarding; - if (_hasBarding) - { - Hue = CraftResources.GetHue(_bardingResource); - Body = 0x31F; - ItemID = 0x3EBE; - } - else - { - Hue = 0x851; - Body = 0x31A; - ItemID = 0x3EBD; - } - InvalidateProperties(); - this.MarkDirty(); + private void OnHasBardingChanged(bool oldValue, bool newValue) + { + if (_hasBarding) + { + Hue = CraftResources.GetHue(_bardingResource); + Body = 0x31F; + ItemID = 0x3EBE; + } + else + { + Hue = 0x851; + Body = 0x31A; + ItemID = 0x3EBD; } } - [CommandProperty(AccessLevel.GameMaster)] - [SerializableProperty(4)] - public CraftResource BardingResource + [SerializableField(4, fieldChanged: nameof(OnBardingResourceChanged))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + [InvalidateProperties] + private CraftResource _bardingResource; + + private void OnBardingResourceChanged(CraftResource oldValue, CraftResource newValue) { - get => _bardingResource; - set + if (_hasBarding) { - _bardingResource = value; - - if (_hasBarding) - { - Hue = CraftResources.GetHue(value); - } - - InvalidateProperties(); - this.MarkDirty(); + Hue = CraftResources.GetHue(newValue); } } diff --git a/Projects/UOContent/Mobiles/BaseCreature.cs b/Projects/UOContent/Mobiles/BaseCreature.cs index af85a195a..404aaede8 100644 --- a/Projects/UOContent/Mobiles/BaseCreature.cs +++ b/Projects/UOContent/Mobiles/BaseCreature.cs @@ -265,8 +265,12 @@ namespace Server.Mobiles private double _passiveSpeed; private double _currentSpeed; - // Herding - Overrides the AI to force the mob to move to a specific location - // Thinking: 0.3s, Movement: 0.6s. + // Movement clock (seconds per step); 0 = inherit the matching think value. + private double _activeMoveSpeed; + private double _passiveMoveSpeed; + + // 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; @@ -342,6 +346,7 @@ namespace Server.Mobiles FightMode = mode; GetSpeeds(out var activeSpeed, out var passiveSpeed); + GetMoveSpeeds(out _activeMoveSpeed, out _passiveMoveSpeed); ActiveSpeed = activeSpeed; PassiveSpeed = passiveSpeed; @@ -660,12 +665,20 @@ namespace Server.Mobiles [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. + /// + [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 { @@ -679,6 +692,7 @@ namespace Server.Mobiles } } + /// Seconds per AI decision while idle; see for movement pace. [CommandProperty(AccessLevel.GameMaster)] public virtual double PassiveSpeed { @@ -693,21 +707,37 @@ namespace Server.Mobiles } } + /// 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; + [CommandProperty(AccessLevel.GameMaster)] public IPoint2D TargetLocation { get => _targetLocation; - set - { - _targetLocation = value; - AIObject?.OnCurrentSpeedChanged(); - } + set => _targetLocation = value; } [CommandProperty(AccessLevel.GameMaster)] public double CurrentSpeed { - get => _targetLocation != null ? 0.3 : _currentSpeed; + get => _currentSpeed; set { if (Math.Abs(_currentSpeed - value) > 0.0001) @@ -718,8 +748,27 @@ namespace Server.Mobiles } } + /// + /// 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) + /// stays fused to both clocks. A herded creature is always driven at + /// . + /// [CommandProperty(AccessLevel.GameMaster)] - public double MoveSpeedMod { get; set; } + public double CurrentMoveSpeed + { + get + { + if (_targetLocation != null) + { + return HerdingMoveSpeed; + } + + return _currentSpeed == _activeSpeed ? ActiveMoveSpeed + : _currentSpeed == _passiveSpeed ? PassiveMoveSpeed + : _currentSpeed; + } + } [CommandProperty(AccessLevel.GameMaster)] public Point3D Home @@ -797,6 +846,8 @@ namespace Server.Mobiles [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. [CommandProperty(AccessLevel.GameMaster)] public OrderType ControlOrder { @@ -885,21 +936,50 @@ namespace Server.Mobiles public virtual bool GivesMLMinorArtifact => false; - /* To save on cpu usage, RunUO creatures only reacquire creatures under the following circumstances: - * - 10 seconds have elapsed since the last time it tried - * - The creature was attacked - * - Some creatures, like dragons, will reacquire when they see someone move - * - * This functionality appears to be implemented on OSI as well - */ - public long NextReacquireTime { get; set; } public virtual TimeSpan ReacquireDelay => TimeSpan.FromSeconds(10.0); - public virtual bool ReacquireOnMovement => false; - public virtual bool AcquireOnApproach => m_Paragon; + + // 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); + + // Reactive range is tighter than the periodic scan's RangePerception: approach + // aggro starts on-screen; the ReacquireDelay poll keeps the wide ambient sweep. public virtual int AcquireOnApproachRange => 10; + // Clamps the scan deadline rather than opening the gate: repeated steps cannot + // shorten it further, so an armed creature scans once per delay period. + private void ScheduleAcquireOnApproach() + { + var delay = (long)AcquireOnApproachDelay.TotalMilliseconds; + var deadline = Core.TickCount + delay; + + if (deadline - NextReacquireTime < 0) + { + NextReacquireTime = deadline; + } + + if (delay <= 0) + { + // Zero: think now — the ranked scan engages within a wheel turn. Prod is + // spam-safe; the Combatant == null guard stops the prods once engaged. + AIObject?.AITimer?.Prod(); + } + } + + // IsEnemy first — it cheaply rejects the common case (a same-team wild creature + // wandering past); CanBeHarmful covers hidden movers via CanSee. + private bool ShouldAcquireOnApproach(Mobile m) => + Combatant == null && + !Controlled && !Summoned && !BardPacified && + FightMode != FightMode.None && FightMode != FightMode.Aggressor && + InRange(m.Location, AcquireOnApproachRange) && + IsEnemy(m) && CanBeHarmful(m, false); + + public virtual bool ReacquireOnMovement => false; + public static bool Summoning { get; set; } public virtual bool IsDispellable => Summoned && !IsAnimatedDead; @@ -1843,7 +1923,7 @@ namespace Server.Mobiles { base.Serialize(writer); - writer.Write(20); // version + writer.Write(22); // version writer.Write((int)m_CurrentAI); writer.Write((int)m_DefaultAI); @@ -1880,7 +1960,7 @@ namespace Server.Mobiles if (_summoned) { - writer.WriteDeltaTime(SummonEnd); + writer.WriteAnchoredTime(SummonEnd); } writer.Write(ControlSlots); @@ -1963,12 +2043,18 @@ namespace Server.Mobiles // 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(); @@ -2035,7 +2121,7 @@ namespace Server.Mobiles if (_summoned) { - SummonEnd = reader.ReadDeltaTime(); + SummonEnd = version >= 21 ? reader.ReadAnchoredTime() : reader.ReadDeltaTime(); new UnsummonTimer(this, SummonEnd - Core.Now).Start(); } @@ -2166,6 +2252,16 @@ namespace Server.Mobiles HomeMap = reader.ReadMap(); } + if (version >= 22) + { + _activeMoveSpeed = reader.ReadDouble(); + _passiveMoveSpeed = reader.ReadDouble(); + } + else + { + MigrateMoveSpeeds(); + } + if (version <= 14 && m_Paragon && Hue == 0x31) { Hue = Paragon.Hue; // Paragon hue fixed, should now be 0x501. @@ -2790,15 +2886,9 @@ namespace Server.Mobiles public override void OnMovement(Mobile m, Point3D oldLocation) { - if (AcquireOnApproach && !Controlled && !Summoned && !BardPacified && FightMode != FightMode.Aggressor) + if (ShouldAcquireOnApproach(m)) { - if (InRange(m.Location, AcquireOnApproachRange) && !InRange(oldLocation, AcquireOnApproachRange) && - CanBeHarmful(m) && IsEnemy(m)) - { - Combatant = FocusMob = m; - AIObject?.MoveTo(m, true, 1); - DoHarmful(m); - } + ScheduleAcquireOnApproach(); } else if (ReacquireOnMovement) { @@ -3033,14 +3123,12 @@ namespace Server.Mobiles return base.OnBeforeDeath(); } - public int ComputeBonusDamage(List list, Mobile m) + public int ComputeBonusDamage(in ValueLinkList list, Mobile m) { var bonus = 0; - for (var i = list.Count - 1; i >= 0; --i) + foreach (var de in list.ByDescending()) { - var de = list[i]; - if (de.Damager == m || de.Damager is not BaseCreature bc) { continue; @@ -3077,26 +3165,15 @@ namespace Server.Mobiles Combatant is PlayerMobile || Combatant is BaseCreature { Controlled: true } bc && bc.GetMaster() is PlayerMobile; - public static List GetLootingRights(List damageEntries, int hitsMax) + // Iterates most recent first, matching the previous reverse-indexed loop. The list is + // already pruned of expired entries by the Mobile.DamageEntries getter. + public static List GetLootingRights(in ValueLinkList damageEntries, int hitsMax) { var rights = new List(); DamageStore firstDamager = null; - for (var i = damageEntries.Count - 1; i >= 0; --i) + foreach (var de in damageEntries.ByDescending()) { - if (i >= damageEntries.Count) - { - continue; - } - - var de = damageEntries[i]; - - if (de.HasExpired) - { - damageEntries.RemoveAt(i); - continue; - } - var damage = de.DamageGiven; var respList = de.Responsible; @@ -4579,13 +4656,78 @@ namespace Server.Mobiles return false; } + /// + /// Sets the think clock and clears movement overrides (legacy one-clock semantics); + /// use for an independent movement pace. + /// public void SetSpeed(double active, double passive, bool isPassive = true) { ActiveSpeed = active; PassiveSpeed = passive; + ClearMoveSpeed(); CurrentSpeed = isPassive ? PassiveSpeed : ActiveSpeed; } + /// Sets only the movement clock (seconds per step). + public void SetMoveSpeed(double active, double passive) + { + ActiveMoveSpeed = active; + PassiveMoveSpeed = passive; + } + + /// Clears movement overrides; steps pace off the think clock again. + public void ClearMoveSpeed() + { + _activeMoveSpeed = 0; + _passiveMoveSpeed = 0; + } + + /// + /// Scales movement overrides (paragon and similar buffs). Inheriting values stay + /// inheriting — they already follow the scaled think clock. + /// + public void ScaleMoveSpeed(double scalar) + { + if (_activeMoveSpeed > 0) + { + _activeMoveSpeed *= scalar; + } + + if (_passiveMoveSpeed > 0) + { + _passiveMoveSpeed *= scalar; + } + } + + /// + /// Snaps speeds within rounding distance of the creature's table values back to + /// exact. A scaling buff that divides then multiplies can drift by an ulp (e.g. + /// 0.9 and 0.45 through 1.2), which would read as hand-tuned; call after undoing + /// such a buff. Genuinely tuned speeds are nowhere near the epsilon and keep. + /// + public void SnapSpeedsToTable() + { + GetSpeeds(out var activeSpeed, out var passiveSpeed); + + if (Math.Abs(_activeSpeed - activeSpeed) < 0.0001 && Math.Abs(_passiveSpeed - passiveSpeed) < 0.0001) + { + _activeSpeed = activeSpeed; + _passiveSpeed = passiveSpeed; + } + + GetMoveSpeeds(out var activeMoveSpeed, out var passiveMoveSpeed); + + if (activeMoveSpeed > 0 && Math.Abs(_activeMoveSpeed - activeMoveSpeed) < 0.0001) + { + _activeMoveSpeed = activeMoveSpeed; + } + + if (passiveMoveSpeed > 0 && Math.Abs(_passiveMoveSpeed - passiveMoveSpeed) < 0.0001) + { + _passiveMoveSpeed = passiveMoveSpeed; + } + } + [MethodImpl(MethodImplOptions.AggressiveInlining)] public void SetCurrentSpeedToActive() => CurrentSpeed = ActiveSpeed; @@ -4899,6 +5041,25 @@ namespace Server.Mobiles NPCSpeeds.GetSpeeds(this, out activeSpeed, out passiveSpeed); } + public virtual void GetMoveSpeeds(out double activeMoveSpeed, out double passiveMoveSpeed) + { + NPCSpeeds.GetMoveSpeeds(this, out activeMoveSpeed, out passiveMoveSpeed); + } + + // 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. + internal void MigrateMoveSpeeds() + { + GetSpeeds(out var activeSpeed, out var passiveSpeed); + + if (_activeSpeed == activeSpeed && _passiveSpeed == passiveSpeed) + { + GetMoveSpeeds(out _activeMoveSpeed, out _passiveMoveSpeed); + } + } + public virtual void DropBackpack() { var backpack = Backpack; diff --git a/Projects/UOContent/Mobiles/Familiars/BaseFamiliar.cs b/Projects/UOContent/Mobiles/Familiars/BaseFamiliar.cs index d3b119523..46d939f3b 100644 --- a/Projects/UOContent/Mobiles/Familiars/BaseFamiliar.cs +++ b/Projects/UOContent/Mobiles/Familiars/BaseFamiliar.cs @@ -92,7 +92,7 @@ public abstract partial class BaseFamiliar : BaseCreature Hidden = m_LastHidden = master.Hidden; } - if (AIObject?.WalkMobileRange(master, 5, false, 1, 1) == true) + if (AIObject?.WalkMobileRange(master, 5, 1, 1) == true) { Warmode = master.Warmode; Combatant = master.Combatant; diff --git a/Projects/UOContent/Mobiles/Hireables/BaseHire.cs b/Projects/UOContent/Mobiles/Hireables/BaseHire.cs index e3fbadd97..3f96e5d36 100644 --- a/Projects/UOContent/Mobiles/Hireables/BaseHire.cs +++ b/Projects/UOContent/Mobiles/Hireables/BaseHire.cs @@ -23,19 +23,14 @@ public partial class BaseHire : BaseCreature public int GoldOnDeath { get; set; } - [SerializableProperty(1)] - [CommandProperty(AccessLevel.GameMaster)] - public bool IsHired - { - get => _isHired; - set - { - _isHired = value; + [SerializableField(1, fieldChanged: nameof(OnIsHiredChanged))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + [InvalidateProperties] + private bool _isHired; - Delta(MobileDelta.Noto); - InvalidateProperties(); - this.MarkDirty(); - } + private void OnIsHiredChanged(bool oldValue, bool newValue) + { + Delta(MobileDelta.Noto); } public BaseHire(AIType AI) : base(AI, FightMode.Aggressor) diff --git a/Projects/UOContent/Mobiles/Monsters/LBR/Meers/EnragedCreatures.cs b/Projects/UOContent/Mobiles/Monsters/LBR/Meers/EnragedCreatures.cs index 91367a5f0..ce4f3526d 100644 --- a/Projects/UOContent/Mobiles/Monsters/LBR/Meers/EnragedCreatures.cs +++ b/Projects/UOContent/Mobiles/Monsters/LBR/Meers/EnragedCreatures.cs @@ -108,7 +108,7 @@ namespace Server.Mobiles */ else if (!Combat(this)) { - AIObject?.MoveTo(SummonMaster, false, 5); + AIObject?.MoveTo(SummonMaster, 5); } /* On OSI, if the summon attacks a mobile, the summoner meer also diff --git a/Projects/UOContent/Mobiles/NPCSpeeds.cs b/Projects/UOContent/Mobiles/NPCSpeeds.cs index a61ce70c6..8f5e908bb 100644 --- a/Projects/UOContent/Mobiles/NPCSpeeds.cs +++ b/Projects/UOContent/Mobiles/NPCSpeeds.cs @@ -38,6 +38,22 @@ public static class NPCSpeeds 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; + } + public static void RegisterSpeed(SpeedClassEntry entry) { _speedsByLevel[entry.Level] = entry; @@ -78,6 +94,13 @@ public static class NPCSpeeds [JsonPropertyName("passive")] public double PassiveSpeed { get; init; } + // Movement clock (seconds per step); absent/0 = inherit the matching think value. + [JsonPropertyName("activeMove")] + public double ActiveMoveSpeed { get; init; } + + [JsonPropertyName("passiveMove")] + public double PassiveMoveSpeed { get; init; } + [JsonPropertyName("types")] public HashSet Types { get; init; } } diff --git a/Projects/UOContent/Mobiles/Special/Harrower.cs b/Projects/UOContent/Mobiles/Special/Harrower.cs index ee3fa1873..8d87943bc 100644 --- a/Projects/UOContent/Mobiles/Special/Harrower.cs +++ b/Projects/UOContent/Mobiles/Special/Harrower.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using ModernUO.Serialization; +using Server.Collections; using Server.Engines.CannedEvil; using Server.Engines.Virtues; using Server.Items; diff --git a/Projects/UOContent/Mobiles/Special/Paragon.cs b/Projects/UOContent/Mobiles/Special/Paragon.cs index 825609e22..cbd0a30c7 100644 --- a/Projects/UOContent/Mobiles/Special/Paragon.cs +++ b/Projects/UOContent/Mobiles/Special/Paragon.cs @@ -79,6 +79,7 @@ public static class Paragon bc.PassiveSpeed /= SpeedBuff; bc.ActiveSpeed /= SpeedBuff; + bc.ScaleMoveSpeed(1.0 / SpeedBuff); bc.CurrentSpeed = bc.PassiveSpeed; bc.DamageMin += DamageBuff; @@ -143,6 +144,8 @@ public static class Paragon bc.PassiveSpeed *= SpeedBuff; bc.ActiveSpeed *= SpeedBuff; + bc.ScaleMoveSpeed(SpeedBuff); + bc.SnapSpeedsToTable(); // an ulp of scaling drift must not read as hand-tuned bc.CurrentSpeed = bc.PassiveSpeed; bc.DamageMin -= DamageBuff; diff --git a/Projects/UOContent/Mobiles/Townfolk/BaseEscortable.cs b/Projects/UOContent/Mobiles/Townfolk/BaseEscortable.cs index 462307be3..cf439a76c 100644 --- a/Projects/UOContent/Mobiles/Townfolk/BaseEscortable.cs +++ b/Projects/UOContent/Mobiles/Townfolk/BaseEscortable.cs @@ -17,7 +17,7 @@ using EDI = Server.Mobiles.EscortDestinationInfo; namespace Server.Mobiles; -[SerializationGenerator(2, false)] +[SerializationGenerator(3, false)] public partial class BaseEscortable : BaseCreature { private static readonly ILogger logger = LogFactory.GetLogger(typeof(BaseEscortable)); @@ -158,16 +158,22 @@ public partial class BaseEscortable : BaseCreature [SerializableField(0, setter: "private")] private string _destinationString; - [TimerDrift] [SerializableField(1)] + [DeserializeTimer(nameof(DeserializeDeleteTimer))] private Timer _deleteTimer; - [DeserializeTimerField(1)] - private void DeserializeDeleteTimer(TimeSpan delay) + private void DeserializeDeleteTimer(TimeSpan delay) => Timer.DelayCall(delay, Delete); + + private void MigrateFrom(V2Content content) { - if (delay >= TimeSpan.Zero) + _destinationString = content.DestinationString; + _mlQuestType = content.MlQuestType; + _mlQuestDestinationMessage = content.MlQuestDestinationMessage; + _mlQuestPaymentMessage = content.MlQuestPaymentMessage; + + if (content.DeleteTimerDelay != TimeSpan.MinValue) { - Timer.DelayCall(delay, Delete); + DeserializeDeleteTimer(content.DeleteTimerDelay); } } diff --git a/Projects/UOContent/Mobiles/Vendors/Barkeeper/PlayerBarkeeper.cs b/Projects/UOContent/Mobiles/Vendors/Barkeeper/PlayerBarkeeper.cs index d80c60dcc..bbbc3ff6f 100644 --- a/Projects/UOContent/Mobiles/Vendors/Barkeeper/PlayerBarkeeper.cs +++ b/Projects/UOContent/Mobiles/Vendors/Barkeeper/PlayerBarkeeper.cs @@ -148,18 +148,13 @@ public partial class PlayerBarkeeper : BaseVendor LoadSBInfo(); } - [SerializableProperty(0)] - public BaseHouse House - { - get => _house; - set - { - _house?.PlayerBarkeepers.Remove(this); - value?.PlayerBarkeepers.Add(this); + [SerializableField(0, fieldChanged: nameof(OnHouseChanged))] + private BaseHouse _house; - _house = value; - this.MarkDirty(); - } + private void OnHouseChanged(BaseHouse oldValue, BaseHouse newValue) + { + oldValue?.PlayerBarkeepers.Remove(this); + newValue?.PlayerBarkeepers.Add(this); } public override bool IsActiveBuyer => false; diff --git a/Projects/UOContent/Mobiles/Vendors/PlayerVendor.cs b/Projects/UOContent/Mobiles/Vendors/PlayerVendor.cs index 2ff6eb812..9b8f5a2f3 100644 --- a/Projects/UOContent/Mobiles/Vendors/PlayerVendor.cs +++ b/Projects/UOContent/Mobiles/Vendors/PlayerVendor.cs @@ -22,9 +22,20 @@ public class PlayerVendorTargetAttribute : Attribute; * Next, uncomment the MigrateFrom function and change the `V3Content` type to match the serialization version * before it was bumped. Then run publish.cmd to generate the migration file. */ -[SerializationGenerator(3, false)] +[SerializationGenerator(4, false)] public partial class PlayerVendor : Mobile { + private void MigrateFrom(V3Content content) + { + _shopName = content.ShopName; + _nextPayTime = content.NextPayTime; + _house = content.House; + _owner = content.Owner; + _bankAccount = content.BankAccount; + _holdGold = content.HoldGold; + _sellItems = content.SellItems; + } + private Timer _payTimer; [InvalidateProperties] @@ -32,7 +43,7 @@ public partial class PlayerVendor : Mobile [SerializedCommandProperty(AccessLevel.GameMaster)] private string _shopName; - [DeltaDateTime] + [AnchoredDateTime] [SerializableField(1, setter: "private")] [SerializedCommandProperty(AccessLevel.GameMaster)] private DateTime _nextPayTime; @@ -94,18 +105,13 @@ public partial class PlayerVendor : Mobile public PlayerVendorPlaceholder Placeholder { get; set; } - [SerializableProperty(2)] - public BaseHouse House - { - get => _house; - set - { - _house?.PlayerVendors.Remove(this); - value?.PlayerVendors.Add(this); + [SerializableField(2, fieldChanged: nameof(OnHouseChanged))] + private BaseHouse _house; - _house = value; - this.MarkDirty(); - } + private void OnHouseChanged(BaseHouse oldValue, BaseHouse newValue) + { + oldValue?.PlayerVendors.Remove(this); + newValue?.PlayerVendors.Add(this); } public int ChargePerDay diff --git a/Projects/UOContent/Mobiles/Vendors/RentedVendor.cs b/Projects/UOContent/Mobiles/Vendors/RentedVendor.cs index 99d818826..d7d0a3ab6 100644 --- a/Projects/UOContent/Mobiles/Vendors/RentedVendor.cs +++ b/Projects/UOContent/Mobiles/Vendors/RentedVendor.cs @@ -46,9 +46,20 @@ public class VendorRentalDuration } } -[SerializationGenerator(0)] +[SerializationGenerator(1)] public partial class RentedVendor : PlayerVendor { + private void MigrateFrom(V0Content content) + { + _rentalDurationId = content.RentalDurationId; + _rentalPrice = content.RentalPrice; + _landlordRenew = content.LandlordRenew; + _renterRenew = content.RenterRenew; + _renewalPrice = content.RenewalPrice; + _rentalGold = content.RentalGold; + _rentalExpireTime = content.RentalExpireTime; + } + private Timer _rentalExpireTimer; public RentedVendor( @@ -93,7 +104,7 @@ public partial class RentedVendor : PlayerVendor [SerializedCommandProperty(AccessLevel.GameMaster)] private int _rentalGold; - [DeltaDateTime] + [AnchoredDateTime] [SerializableField(6)] [SerializedCommandProperty(AccessLevel.GameMaster)] private DateTime _rentalExpireTime; diff --git a/Projects/UOContent/Mobiles/Vendors/VendorInventory.cs b/Projects/UOContent/Mobiles/Vendors/VendorInventory.cs index d4d5ce4bd..8f0a1fba8 100644 --- a/Projects/UOContent/Mobiles/Vendors/VendorInventory.cs +++ b/Projects/UOContent/Mobiles/Vendors/VendorInventory.cs @@ -37,7 +37,7 @@ namespace Server.Mobiles Items = reader.ReadEntityList(); Gold = reader.ReadInt(); - ExpireTime = reader.ReadDeltaTime(); + ExpireTime = version >= 1 ? reader.ReadAnchoredTime() : reader.ReadDeltaTime(); if (Items.Count == 0 && Gold == 0) { @@ -88,7 +88,7 @@ namespace Server.Mobiles public void Serialize(IGenericWriter writer) { - writer.WriteEncodedInt(0); // version + writer.WriteEncodedInt(1); // version writer.Write(Owner); writer.Write(VendorName); @@ -98,7 +98,7 @@ namespace Server.Mobiles writer.Write(Items); writer.Write(Gold); - writer.WriteDeltaTime(ExpireTime); + writer.WriteAnchoredTime(ExpireTime); } private class ExpireTimer : Timer diff --git a/Projects/UOContent/Mobiles/Vendors/VendorItem.cs b/Projects/UOContent/Mobiles/Vendors/VendorItem.cs index 14af3656a..99d1446e6 100644 --- a/Projects/UOContent/Mobiles/Vendors/VendorItem.cs +++ b/Projects/UOContent/Mobiles/Vendors/VendorItem.cs @@ -32,18 +32,20 @@ public partial class VendorItem public string FormattedPrice => Core.ML ? Price.ToString("N0", CultureInfo.GetCultureInfo("en-US")) : Price.ToString(); - [SerializableProperty(2)] - public string Description - { - get => _description; - set - { - _description = value ?? ""; + [SerializableField(2, fieldChanged: nameof(OnDescriptionChanged), allowFieldChange: nameof(AllowDescriptionChange))] + private string _description; - if (Valid) - { - Item.InvalidateProperties(); - } + private bool AllowDescriptionChange(ref string value) + { + value = value ?? ""; + return true; + } + + private void OnDescriptionChanged(string oldValue, string newValue) + { + if (Valid) + { + Item.InvalidateProperties(); } } diff --git a/Projects/UOContent/Multis/Boats/BaseBoat.cs b/Projects/UOContent/Multis/Boats/BaseBoat.cs index 2082056da..ed618b91a 100644 --- a/Projects/UOContent/Multis/Boats/BaseBoat.cs +++ b/Projects/UOContent/Multis/Boats/BaseBoat.cs @@ -19,9 +19,24 @@ namespace Server.Multis Single } - [SerializationGenerator(4, false)] + [SerializationGenerator(5, false)] public abstract partial class BaseBoat : BaseMulti { + private void MigrateFrom(V4Content content) + { + _mapItem = content.MapItem; + _nextNavPoint = content.NextNavPoint; + _facing = content.Facing; + _timeOfDecay = content.TimeOfDecay; + _owner = content.Owner; + _pPlank = content.PPlank; + _sPlank = content.SPlank; + _tillerMan = content.TillerMan; + _hold = content.Hold; + _anchored = content.Anchored; + _shipName = content.ShipName; + } + public enum DryDockResult { Valid, @@ -136,31 +151,23 @@ namespace Server.Multis } } - [DeltaDateTime] - [SerializableProperty(3)] - [CommandProperty(AccessLevel.GameMaster)] - public DateTime TimeOfDecay + [SerializableField(3, fieldChanged: nameof(OnTimeOfDecayChanged))] + [AnchoredDateTime] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private DateTime _timeOfDecay; + + private void OnTimeOfDecayChanged(DateTime oldValue, DateTime newValue) { - get => _timeOfDecay; - set - { - _timeOfDecay = value; - TillerMan?.InvalidateProperties(); - this.MarkDirty(); - } + TillerMan?.InvalidateProperties(); } - [SerializableProperty(10)] - [CommandProperty(AccessLevel.GameMaster)] - public string ShipName + [SerializableField(10, fieldChanged: nameof(OnShipNameChanged))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private string _shipName; + + private void OnShipNameChanged(string oldValue, string newValue) { - get => _shipName; - set - { - _shipName = value; - TillerMan?.InvalidateProperties(); - this.MarkDirty(); - } + TillerMan?.InvalidateProperties(); } [CommandProperty(AccessLevel.GameMaster)] diff --git a/Projects/UOContent/Multis/Camps/BaseCamp.cs b/Projects/UOContent/Multis/Camps/BaseCamp.cs index e3d10dd0b..e60e3e46e 100644 --- a/Projects/UOContent/Multis/Camps/BaseCamp.cs +++ b/Projects/UOContent/Multis/Camps/BaseCamp.cs @@ -6,9 +6,16 @@ using Server.Mobiles; namespace Server.Multis; -[SerializationGenerator(1, false)] +[SerializationGenerator(2, false)] public abstract partial class BaseCamp : BaseMulti { + private void MigrateFrom(V1Content content) + { + _items = content.Items; + _mobiles = content.Mobiles; + _decayTime = content.DecayTime; + } + [Tidy] [SerializableField(0, setter: "private")] private List _items; @@ -17,7 +24,7 @@ public abstract partial class BaseCamp : BaseMulti [SerializableField(1, setter: "private")] private List _mobiles; - [DeltaDateTime] + [AnchoredDateTime] [SerializableField(2, setter: "private")] private DateTime _decayTime; diff --git a/Projects/UOContent/Network/Packets/IncomingPlayerPackets.cs b/Projects/UOContent/Network/Packets/IncomingPlayerPackets.cs index a6297837d..384e468b9 100644 --- a/Projects/UOContent/Network/Packets/IncomingPlayerPackets.cs +++ b/Projects/UOContent/Network/Packets/IncomingPlayerPackets.cs @@ -86,11 +86,17 @@ public static class IncomingPlayerPackets public static void HuePickerResponse(NetState state, SpanReader reader) { var serial = reader.ReadUInt32(); - _ = reader.ReadInt16(); // Item ID + reader.ReadInt16(); // Item ID var hue = Utility.ClipDyedHue(reader.ReadInt16() & 0x3FFF); - foreach (var huePicker in state.HuePickers) + if (state.HuePickers == null) { + return; + } + + for (var i = 0; i < state.HuePickers.Count; i++) + { + var huePicker = state.HuePickers[i]; if (huePicker.Serial == serial) { state.RemoveHuePicker(huePicker); @@ -287,13 +293,18 @@ public static class IncomingPlayerPackets public static void MenuResponse(NetState state, SpanReader reader) { var serial = reader.ReadUInt32(); - int menuID = reader.ReadInt16(); + reader.ReadInt16(); // menu id int index = reader.ReadInt16(); - int itemID = reader.ReadInt16(); - int hue = reader.ReadInt16(); + reader.ReadInt16(); // item id + reader.ReadInt16(); // hue index -= 1; // convert from 1-based to 0-based + if (state.Menus == null) + { + return; + } + for (var i = 0; i < state.Menus.Count; i++) { var menu = state.Menus[i]; diff --git a/Projects/UOContent/Regions/BaseRegion.cs b/Projects/UOContent/Regions/BaseRegion.cs index b18c7fa18..776ec269d 100644 --- a/Projects/UOContent/Regions/BaseRegion.cs +++ b/Projects/UOContent/Regions/BaseRegion.cs @@ -113,7 +113,7 @@ public class BaseRegion : Region m_RectBuffer2.RemoveAt(k); var sz = rect.Start.Z; - var ez = rect.End.X; + var ez = rect.End.Z; if (l1 < l2) { diff --git a/Projects/UOContent/Spells/Fifth/PoisonField.cs b/Projects/UOContent/Spells/Fifth/PoisonField.cs index 3c5d2f609..92a797f19 100644 --- a/Projects/UOContent/Spells/Fifth/PoisonField.cs +++ b/Projects/UOContent/Spells/Fifth/PoisonField.cs @@ -64,13 +64,19 @@ public class PoisonFieldSpell : MagerySpell, ITargetingSpell } [DispellableField] -[SerializationGenerator(0, false)] +[SerializationGenerator(1, false)] public partial class PoisonField : Item { + private void MigrateFrom(V0Content content) + { + _caster = content.Caster; + _end = content.End; + } + [SerializableField(0)] private Mobile _caster; - [DeltaDateTime] + [AnchoredDateTime] [SerializableField(1)] private DateTime _end; diff --git a/Projects/UOContent/Spells/Fourth/FireField.cs b/Projects/UOContent/Spells/Fourth/FireField.cs index 0b0a22129..8f20f995b 100644 --- a/Projects/UOContent/Spells/Fourth/FireField.cs +++ b/Projects/UOContent/Spells/Fourth/FireField.cs @@ -67,16 +67,23 @@ public class FireFieldSpell : MagerySpell, ITargetingSpell } [DispellableField] -[SerializationGenerator(0, false)] +[SerializationGenerator(1, false)] public partial class FireFieldItem : Item { + private void MigrateFrom(V0Content content) + { + _damage = content.Damage; + _caster = content.Caster; + _end = content.End; + } + [SerializableField(0)] private int _damage; [SerializableField(1)] private Mobile _caster; - [DeltaDateTime] + [AnchoredDateTime] [SerializableField(2)] private DateTime _end; private Timer _timer; diff --git a/Projects/UOContent/Spells/Ninjitsu/MirrorImage.cs b/Projects/UOContent/Spells/Ninjitsu/MirrorImage.cs index 6d85b9d73..63788aa69 100644 --- a/Projects/UOContent/Spells/Ninjitsu/MirrorImage.cs +++ b/Projects/UOContent/Spells/Ninjitsu/MirrorImage.cs @@ -238,10 +238,7 @@ namespace Server.Mobiles if (master?.Map == Mobile.Map && master?.InRange(Mobile, Mobile.RangePerception) == true) { - var iCurrDist = (int)Mobile.GetDistanceToSqrt(master); - var bRun = iCurrDist > 5; - - WalkMobileRange(master, 2, bRun, 0, 1); + WalkMobileRange(master, 2, 0, 1); } else { diff --git a/Projects/UOContent/Spells/Seventh/EnergyField.cs b/Projects/UOContent/Spells/Seventh/EnergyField.cs index 5e3ea1149..dbfece36d 100644 --- a/Projects/UOContent/Spells/Seventh/EnergyField.cs +++ b/Projects/UOContent/Spells/Seventh/EnergyField.cs @@ -77,13 +77,19 @@ public class EnergyFieldSpell : MagerySpell, ITargetingSpell } [DispellableField] -[SerializationGenerator(1, false)] +[SerializationGenerator(2, false)] public partial class EnergyField : Item { + private void MigrateFrom(V1Content content) + { + _caster = content.Caster; + _end = content.End; + } + [SerializableField(0)] private Mobile _caster; - [DeltaDateTime] + [AnchoredDateTime] [SerializableField(1)] private DateTime _end; diff --git a/Projects/UOContent/Spells/Sixth/ParalyzeField.cs b/Projects/UOContent/Spells/Sixth/ParalyzeField.cs index 830f16dfe..301cee507 100644 --- a/Projects/UOContent/Spells/Sixth/ParalyzeField.cs +++ b/Projects/UOContent/Spells/Sixth/ParalyzeField.cs @@ -77,13 +77,19 @@ public class ParalyzeFieldSpell : MagerySpell, ITargetingSpell } [DispellableField] -[SerializationGenerator(0, false)] +[SerializationGenerator(1, false)] public partial class ParalyzeField : Item { + private void MigrateFrom(V0Content content) + { + _caster = content.Caster; + _end = content.End; + } + [SerializableField(0)] private Mobile _caster; - [DeltaDateTime] + [AnchoredDateTime] [SerializableField(1)] private DateTime _end; diff --git a/Projects/UOContent/Spells/Spellweaving/Items/TransientItem.cs b/Projects/UOContent/Spells/Spellweaving/Items/TransientItem.cs index 54ac52030..49894cc95 100644 --- a/Projects/UOContent/Spells/Spellweaving/Items/TransientItem.cs +++ b/Projects/UOContent/Spells/Spellweaving/Items/TransientItem.cs @@ -3,12 +3,17 @@ using ModernUO.Serialization; namespace Server.Items; -[SerializationGenerator(1, false)] +[SerializationGenerator(2, false)] public partial class TransientItem : Item { + private void MigrateFrom(V1Content content) + { + _expiration = content.Expiration; + } + private TimerExecutionToken _timerToken; - [DeltaDateTime] + [AnchoredDateTime] [SerializableField(0)] [SerializedCommandProperty(AccessLevel.GameMaster)] private DateTime _expiration; diff --git a/Projects/UOContent/Spells/Third/WallOfStone.cs b/Projects/UOContent/Spells/Third/WallOfStone.cs index 7cae3b848..1b1dd04d3 100644 --- a/Projects/UOContent/Spells/Third/WallOfStone.cs +++ b/Projects/UOContent/Spells/Third/WallOfStone.cs @@ -63,13 +63,19 @@ public class WallOfStoneSpell : MagerySpell, ITargetingSpell } [DispellableField] -[SerializationGenerator(0, false)] +[SerializationGenerator(1, false)] public partial class WallOfStone : Item { + private void MigrateFrom(V0Content content) + { + _caster = content.Caster; + _end = content.End; + } + [SerializableField(0)] private Mobile _caster; - [DeltaDateTime] + [AnchoredDateTime] [SerializableField(1)] private DateTime _end; diff --git a/Projects/UOContent/UOContent.csproj b/Projects/UOContent/UOContent.csproj index d9961f38d..60f28ac6f 100644 --- a/Projects/UOContent/UOContent.csproj +++ b/Projects/UOContent/UOContent.csproj @@ -41,18 +41,17 @@ false - + - + - - - + + diff --git a/README.md b/README.md index b8d49f544..01a368804 100644 --- a/README.md +++ b/README.md @@ -46,13 +46,7 @@ ModernUO [![Discord](https://img.shields.io/discord/751317910504603701?logo=disc | Medium (50–200) | 4–8 | 8 GB | NVMe | | Large (200+) | 8+, high clock | 16 GB+ | NVMe | -Game logic is single-threaded, so **single-core clock speed matters more than core count**, and -**dedicated vCPU matters more than either** — burstable or shared plans throttle once credits run -out, which is the most common cause of unexplained lag spikes. Save size drives RAM more than -player count does. - -See [dev-docs/server-requirements.md](dev-docs/server-requirements.md) for the reasoning and tuning -options. +See [dev-docs/server-requirements.md](dev-docs/server-requirements.md) for more information. #### Development [![git](https://img.shields.io/badge/-git-F05032?logo=git&logoColor=F05032&labelColor=222222)](https://git-scm.com/downloads) diff --git a/dev-docs/claude-skills/migrate-from-runuo/migrate-items-mobiles.md b/dev-docs/claude-skills/migrate-from-runuo/migrate-items-mobiles.md index 8edf70a72..7310c104e 100644 --- a/dev-docs/claude-skills/migrate-from-runuo/migrate-items-mobiles.md +++ b/dev-docs/claude-skills/migrate-from-runuo/migrate-items-mobiles.md @@ -29,6 +29,9 @@ description: > - `BaseCreature(AI, Fight, 10, 1, 0.2, 0.4)` -> `BaseCreature(AI, Fight)` (extra params default) - `Name = "text"` -> `public override string DefaultName => "text";` - Expression-bodied overrides: `public override int Meat { get { return 1; } }` -> `public override int Meat => 1;` +- AI movement calls lose the `run` flag: `MoveTo(m, true, range)` -> `MoveTo(m, range)` (also `WalkMobileRange`, `ApproachTarget`, `MoveToPoint`, `PathFollower.Follow`); the Running bit is derived from step pace -> `dev-docs/runuo-migration-docs/09-items-mobiles-creatures.md` § AI Movement +- `AcquireOnApproach` (bool) -> `AcquireOnApproachDelay` (TimeSpan; `Zero` = old instant behavior) -> same doc § Target Acquisition +- `DamageEntries` is an inline `ref readonly ValueLinkList`, not a `List`: indexer/`Add`/`Remove`/`Clear` -> `foreach` / `.ByDescending()` (needs `using Server.Collections;`) and `ClearDamageEntries()`; `GetLootingRights` takes it by `in` -> same doc § Damage Entries ## Anti-Patterns - Using `_field--` instead of `Property--` (bypasses MarkDirty tracking) diff --git a/dev-docs/claude-skills/modernuo-content-patterns.md b/dev-docs/claude-skills/modernuo-content-patterns.md index 12c9515ef..6e0902b64 100644 --- a/dev-docs/claude-skills/modernuo-content-patterns.md +++ b/dev-docs/claude-skills/modernuo-content-patterns.md @@ -23,6 +23,22 @@ description: > 4. **Clean up timers and references in `OnDelete()`/`OnAfterDelete()`** 5. **No LINQ** in game logic -- use loops and `PooledRefList` 6. **File placement** matters -- follow the directory conventions below +7. **Creature speeds are delays in seconds, on two clocks** -- think + (`ActiveSpeed`/`PassiveSpeed`, seconds per AI decision) and move + (`ActiveMoveSpeed`/`PassiveMoveSpeed`, seconds per step; inherits think until + overridden). Prefer `npc-speeds.json` buckets (`SpeedClass`); `SetSpeed()` sets think + AND clears move overrides, `SetMoveSpeed()` sets move only. The client `Running` bit is + derived from the step pace (`BaseAI.ShouldRun`); movement APIs take no run argument -- + see `dev-docs/content-patterns.md` § Creature Speeds. Reaction time to approaching + enemies is `AcquireOnApproachDelay` (TimeSpan gradient; `Zero` = paragon snap, 2s + default, `ReacquireDelay`-only = oblivious) -- see § Target Acquisition +8. **`OnThink` overrides must be excess-call tolerant** -- it fires more often than the + think cadence (player commands prod it; speed-ups reschedule it). Gate consequential + work on a tick-count deadline (subtraction form) or make it idempotent; bare per-call + random rolls are cosmetics-only. `MonsterAbility` is under the same contract: the + trigger cooldown is the rate limit, `ChanceToTrigger` is per-sample jitter, and a + zero-cooldown `Think`/`CombatAction` ability triggers every sampled think -- see + `dev-docs/content-patterns.md` § OnThink: the excess-call contract ## New Item Template diff --git a/dev-docs/claude-skills/modernuo-serialization.md b/dev-docs/claude-skills/modernuo-serialization.md index c96244656..91e3d5e95 100644 --- a/dev-docs/claude-skills/modernuo-serialization.md +++ b/dev-docs/claude-skills/modernuo-serialization.md @@ -40,21 +40,31 @@ public partial class MyItem : Item { } public partial class MigratedItem : Item { } ``` -### [SerializableField(index, setter, saveIf)] +### [SerializableField(index, getter, setter, isVirtual, fieldChanged, allowFieldChange)] Applied to `_camelCase` private fields. Generates `PascalCase` property. - `index`: Serialization order (0+) -- `setter`: Access level -- `"private"`, `"internal"`, or omit for public -- `saveIf`: Condition method name for conditional serialization +- `getter`/`setter`: Access level -- `"private"`, `"internal"`, or omit for public +- `isVirtual`: Generate a virtual property +- `fieldChanged`: `nameof` of `void Method(T oldValue, T newValue)`, invoked by the generated setter after assignment +- `allowFieldChange`: `nameof` of `bool Method(ref T value)`, invoked before assignment -- coerce through the `ref` parameter or return `false` to reject + +Generated setter pipeline: equality check → `allowFieldChange` → assignment → `MarkDirty` → `InvalidateProperties` (if declared) → `fieldChanged`. The field still holds the old value while the gate runs. Hooks require a generated setter (SG3018 on readonly/setterless fields); a missing or wrong-shaped named method is SG3015. ```csharp -[SerializableField(0)] +[SerializableField(0, allowFieldChange: nameof(AllowChargesChange))] [SerializedCommandProperty(AccessLevel.GameMaster)] +[InvalidateProperties] private int _charges; -// Generates: public int Charges { get; set; } + +private bool AllowChargesChange(ref int value) +{ + value = Math.Clamp(value, 0, MaxCharges); + return true; +} ``` ### [SerializableProperty(index, useField)] -Applied to properties with custom get/set logic. +Applied to properties with **custom getters** (fallback defaults, lazy/self-healing reads) or setter semantics the field hooks cannot express. For setters that only coerce, veto, or run post-change side effects, use `[SerializableField]` with `allowFieldChange`/`fieldChanged` instead. - `index`: Serialization order - `useField`: Backing field name if auto-detection fails @@ -63,12 +73,12 @@ Applied to properties with custom get/set logic. [CommandProperty(AccessLevel.GameMaster)] public int MaxItems { - get => _maxItems == -1 ? DefaultMaxItems : _maxItems; + get => _maxItems == -1 ? DefaultMaxItems : _maxItems; // custom getter: the reason this is a property set { _maxItems = value; InvalidateProperties(); - this.MarkDirty(); + this.MarkDirty(); // REQUIRED in custom setters } } ``` @@ -89,8 +99,11 @@ Exposes field to `[Props` gump for in-game editing. ### [EncodedInt] Variable-length int encoding (saves space for small values). +### [AnchoredDateTime] +Stores the absolute UTC instant; shifted by downtime at load so remaining time is preserved. Byte-stable across idle saves. Prefer for deadlines/elapsed-while-running values. + ### [DeltaDateTime] -Stores DateTime as offset from current time (handles server restarts). +Stores DateTime as offset from current time (handles server restarts). Legacy: rewrites bytes every save; prefer `[AnchoredDateTime]` for new fields. Converting between the two changes the wire format (version bump). ### [InternString] Interns strings to reduce memory for repeated values. @@ -128,28 +141,32 @@ private void AfterDeserialization() } ``` -### [DeserializeTimerField(fieldIndex)] -Custom timer deserialization. Timer is saved as remaining TimeSpan. +### [DeserializeTimer(nameof(Method), wallClock)] +Required on every serializable `Timer` member (SG3008 otherwise). By default the next tick is stored as **anchored time** (downtime does not consume the remaining delay; idle saves byte-stable); `wallClock: true` stores an absolute deadline instead (delay negative if it passed during downtime). The method -- `void Method(TimeSpan delay)` -- is invoked **only when a timer was running at save**; there is no sentinel to check. ```csharp [SerializableField(0, setter: "private")] +[DeserializeTimer(nameof(DeserializeEvaluateTimer), wallClock: true)] private Timer _evaluateTimer; -[DeserializeTimerField(0)] private void DeserializeEvaluateTimer(TimeSpan delay) { _evaluateTimer = Timer.DelayCall(delay, EvaluationInterval, Evaluate); } ``` -### [SerializableFieldSaveFlag(fieldIndex)] / [SerializableFieldDefault(fieldIndex)] -Conditional serialization -- skip fields with default values. +Switching a timer between drifting and `wallClock` changes the wire format: bump the class version and add `MigrateFrom` -- the old content struct exposes `XxxNext` (`DateTime`) and `XxxDelay` (`TimeSpan`, `TimeSpan.MinValue` when no timer ran). + +### [SaveFlag(nameof(ShouldSerializeMethod), nameof(DefaultValueMethod))] +On the serializable field/property itself. Conditional serialization -- skip fields with default values. Second method optional; when omitted, the field keeps its default at load. ```csharp -[SerializableFieldSaveFlag(0)] +[SerializableField(0)] +[SaveFlag(nameof(ShouldSerializeMaxItems), nameof(MaxItemsDefaultValue))] +private int _maxItems; + private bool ShouldSerializeMaxItems() => _maxItems != -1; -[SerializableFieldDefault(0)] private int MaxItemsDefaultValue() => -1; ``` @@ -216,28 +233,35 @@ public partial class ChargedItem : Item } ``` -### Item with Custom Properties +### Item with Setter Hooks (coerce + side effects) ```csharp [SerializationGenerator(2)] public partial class BagOfSending : Item { - [SerializableProperty(0)] - [CommandProperty(AccessLevel.GameMaster)] - public BagOfSendingHue BagOfSendingHue + [SerializableField(0, fieldChanged: nameof(OnBagOfSendingHueChanged))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private BagOfSendingHue _bagOfSendingHue; + + private void OnBagOfSendingHueChanged(BagOfSendingHue oldValue, BagOfSendingHue newValue) { - get => _bagOfSendingHue; - set + Hue = newValue switch { - _bagOfSendingHue = value; - Hue = value switch - { - BagOfSendingHue.Yellow => 0x8A5, - BagOfSendingHue.Blue => 0x8AD, - BagOfSendingHue.Red => 0x89B, - _ => Hue - }; - this.MarkDirty(); - } + BagOfSendingHue.Yellow => 0x8A5, + BagOfSendingHue.Blue => 0x8AD, + BagOfSendingHue.Red => 0x89B, + _ => Hue + }; + } + + [SerializableField(1, allowFieldChange: nameof(AllowChargesChange))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + [InvalidateProperties] + private int _charges; + + private bool AllowChargesChange(ref int value) + { + value = Math.Clamp(value, 0, MaxCharges); + return true; } } ``` @@ -328,11 +352,13 @@ public partial class MagicGem ## Real Examples - Simple creature: `Projects/UOContent/Mobiles/Animals/Bears/BlackBear.cs` - Serialized fields + timer: `Projects/UOContent/Items/Weapons/Ranged/BaseRanged.cs` -- Custom properties: `Projects/UOContent/Items/Special/Solen Items/BagOfSending.cs` +- Setter hooks (allowFieldChange + fieldChanged): `Projects/UOContent/Items/Special/Solen Items/BagOfSending.cs` +- Custom getters (era fallbacks, the [SerializableProperty] use case): `Projects/UOContent/Items/Weapons/BaseWeapon.cs` - Complex with AfterDeserialization: `Projects/UOContent/Accounting/Account.cs` -- Timer deserialization: `Projects/UOContent/Items/Aquarium/Aquarium.cs` +- Timer deserialization (wall-clock): `Projects/UOContent/Items/Aquarium/Aquarium.cs` +- Timer deserialization (drifting/anchored + timer MigrateFrom): `Projects/UOContent/Items/Lights/BaseLight.cs` - Tidy + DeltaDateTime: `Projects/UOContent/Engines/CannedEvil/ChampionSpawn.cs` -- Conditional serialization: `Projects/Server/Items/Container.cs` +- Conditional serialization ([SaveFlag]): `Projects/Server/Items/Container.cs` ## Version Migration Migration schemas are JSON files in `Projects/Server/Migrations/` and `Projects/UOContent/Migrations/`: diff --git a/dev-docs/claude-skills/modernuo-timers.md b/dev-docs/claude-skills/modernuo-timers.md index 2c5c8168e..f366b3569 100644 --- a/dev-docs/claude-skills/modernuo-timers.md +++ b/dev-docs/claude-skills/modernuo-timers.md @@ -147,18 +147,27 @@ public partial class DecayingItem : Item } ``` -### [DeserializeTimerField] Pattern (for Timer fields) +### [DeserializeTimer] Pattern (for Timer fields) +Required on every serializable `Timer` member. Drifting by default: the next tick is stored +as anchored time, so server downtime does not consume the remaining delay. Use +`wallClock: true` for absolute deadlines (delay is negative if it passed during downtime). +The method is invoked **only when a timer was running at save** — no sentinel to check. + ```csharp [SerializableField(0, setter: "private")] +[DeserializeTimer(nameof(DeserializeEvaluateTimer), wallClock: true)] private Timer _evaluateTimer; -[DeserializeTimerField(0)] private void DeserializeEvaluateTimer(TimeSpan delay) { _evaluateTimer = Timer.DelayCall(delay, EvaluationInterval, Evaluate); } ``` +Switching an existing timer between drifting and `wallClock` changes the wire format — bump +the class's `[SerializationGenerator]` version and add a `MigrateFrom` (the old content +struct exposes `XxxDelay`, `TimeSpan.MinValue` when no timer ran). + ### Custom Timer Class (When You Need Complex Logic) ```csharp private class DecayTimer : Timer diff --git a/dev-docs/content-patterns.md b/dev-docs/content-patterns.md index 30bb942f3..6205ec2d4 100644 --- a/dev-docs/content-patterns.md +++ b/dev-docs/content-patterns.md @@ -254,6 +254,120 @@ public override int TreasureMapLevel => 3; // Drops treasure map public override double WeaponAbilityChance => 0.4; // Weapon ability chance ``` +### Creature Speeds (think vs move clocks) + +All "speed" values are **delays in seconds** (smaller = faster). A creature runs two clocks: + +- **Think clock** — `ActiveSpeed`/`PassiveSpeed`/`CurrentSpeed`: seconds per AI decision + (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. + +Speeds normally come from `Distribution/Data/npc-speeds.json` (via `SpeedClass` or type +lists); `activeMove`/`passiveMove` are optional per bucket. Prefer data over code: + +```csharp +public override SpeedLevel SpeedClass => SpeedLevel.Slow; // bucket in npc-speeds.json +``` + +Code-level overrides for special cases: + +```csharp +SetSpeed(0.5, 2.0); // think clock; ALSO clears move overrides (one-clock legacy semantics) +SetMoveSpeed(0.45, 0.9); // move clock only — call after SetSpeed if both are wanted +ClearMoveSpeed(); // back to inheriting the think clock +``` + +All four are `[props`-tunable per instance (move values: set `0` to re-inherit); per-instance +move overrides serialize. Being badly hurt slows steps, never decisions (RunUO parity). + +The client's `Running` bit is derived from the step pace, never passed by callers +(`BaseAI.ShouldRun`, stamped in `DoMoveImpl`): a step shorter than the client's walk +interpolation — 400 ms on foot, 200 ms mounted/flying (`Movement.WalkFootDelay` / +`WalkMountDelay`) — is flagged as a run, or the client falls behind and snaps. An isolated +step (resuming after at least a walk interval standing) goes out as a walk regardless of +pace — the client renders each step alone, so a run-flagged single step darts — unless the +pace beats the run interpolation (a true sprinter), where a walk-rendered first step would +flood the client's step queue. Movement APIs (`MoveTo`, `WalkMobileRange`, +`ApproachTarget`, `MoveToPoint`) take no run argument; to make a creature run, make it +fast. Creatures step at most once per `CurrentMoveSpeed` period, paced from the step just +taken — a stall never banks catch-up steps, so a resumed chase restarts at full pace. + +### Target Acquisition: the reaction-time gradient + +Acquisition is event-driven, not polled. The periodic scan (`AcquireFocusMob`) is gated by +`ReacquireDelay` (10 s default) and every scan re-arms it in full, success or failure — it +is target stickiness plus the fallback for what movement cannot signal (reveals, doors, +summons). Reaction time comes from `BaseCreature.OnMovement`: an enemy moving inside +`AcquireOnApproachRange` (10 — on-screen; the periodic scan keeps the wider +`RangePerception`) clamps the next scan to +at most **`AcquireOnApproachDelay`** — the intelligence gradient. `TimeSpan.Zero` +(paragons) also prods the AI, so the ranked scan engages within a timer-wheel turn; the +2 s default reads as "took a beat to notice you"; larger is dumber; a creature that +overrides the delay above `ReacquireDelay` is effectively oblivious to approach. Repeated +steps cannot shorten the clamp, so an armed creature scans once per delay period, not once +per step or think. `ReacquireOnMovement` remains the broader hook (any mover, no enemy +check, scan next think). The gate self-heals: a deadline further out than `ReacquireDelay` +is illegal and reads as open, so no wedged or wrapped value can silence acquisition beyond +one delay period. + +### OnThink: the excess-call contract + +`OnThink()` is a scheduler pass, not an action. The AI timer calls it *at least* at the +think cadence (`CurrentSpeed`), but it can and does fire more often: a player command +wakes the AI immediately (`AITimer.Prod()`), a speed-up reschedules the pending wake, and +players run command macros that drive extra thinks deliberately (order spam is spam-safe +by design — reaction, never action). RunUO had the same property (its timer restarted +with a random delay on every speed change), so this has never been a fixed-rate callback. + +**Every `OnThink` override must be excess-call tolerant.** An extra call must never grant +an extra action: + +- Gate consequential work on its own deadline field, compared in subtraction form + (`Core.TickCount - _nextX >= 0` — see `tick-counts.md`), or make it idempotent. +- Never pace a consequential action with a bare per-call `Utility.RandomDouble()` roll — + its frequency then scales with think rate, which players can influence. Per-call rolls + are acceptable only for pure cosmetics (idle animations, flavor sounds). +- The engine already gates the expensive things: steps (the `NextMove` budget), weapon + swings, spell casts, detect-hidden, and the base `BaseCreature.OnThink` actions (heal, + rummage, aura) all carry their own clocks. Follow that pattern. + +```csharp +private long _nextSpecial; + +public override void OnThink() +{ + base.OnThink(); + + if (Core.TickCount - _nextSpecial >= 0) + { + DoSpecial(); + _nextSpecial = Core.TickCount + 5000; // the real rate limit lives here + } +} +``` + +### MonsterAbility: same contract + +`MonsterAbility.CanTrigger` is sampled once per think for `Think`- and +`CombatAction`-triggered abilities, so abilities live under the same rule: + +- **`MinTriggerCooldown`/`MaxTriggerCooldown` is the real rate limit** — the floor holds + no matter how often thinks fire. Always give a triggered ability a real cooldown. +- **`ChanceToTrigger` is a per-sample roll**: above the cooldown floor, the expected + trigger delay shrinks as think rate rises. Treat the chance as flavor jitter, never as + the rate limiter, and keep cooldowns long relative to the think interval so the jitter + stays negligible (fire breath — chance 0.5, cooldown 30–45s — varies under 1% between + natural and spammed think rates). +- A **zero-cooldown ability records no cooldown at all** and triggers on every sampled + think that passes its chance — only ever correct for passive alteration hooks, never + for `Think`/`CombatAction` triggers. +- An ability that breaks pet orders (fear-style effects) must own its duration explicitly + (a hold state, or a "refuses orders until" deadline checked in the order handlers) — + pets react to re-issued commands immediately, so think latency is not a hold. + --- ## New Spell diff --git a/dev-docs/runuo-migration-docs/02-serialization.md b/dev-docs/runuo-migration-docs/02-serialization.md index 1470792af..666ebfdb4 100644 --- a/dev-docs/runuo-migration-docs/02-serialization.md +++ b/dev-docs/runuo-migration-docs/02-serialization.md @@ -457,16 +457,24 @@ set ``` Without this, changes won't be saved. +Most RunUO custom setters only clamp the value or run side effects after assignment. Those +convert to a plain `[SerializableField]` with the `allowFieldChange`/`fieldChanged` hooks, +which handle the equality check and `MarkDirty()` for you -- reserve `[SerializableProperty]` +for custom getters (see `dev-docs/serialization.md`). + ### 3. Field Ordering The `[SerializableField(N)]` index determines serialization order. Choose a logical order and don't change it after the first save — or increment the version. ### 4. Conditional Serialization -Use `[SerializableFieldSaveFlag]` and `[SerializableFieldDefault]` to skip default values: +Use `[SaveFlag]` on the serializable field to skip default values (the second method is +optional -- omit it and the field keeps its default at load): ```csharp -[SerializableFieldSaveFlag(0)] +[SerializableField(0)] +[SaveFlag(nameof(ShouldSerializeMaxItems), nameof(MaxItemsDefaultValue))] +private int _maxItems; + private bool ShouldSerializeMaxItems() => _maxItems != -1; -[SerializableFieldDefault(0)] private int MaxItemsDefaultValue() => -1; ``` @@ -479,12 +487,15 @@ private List _followers; ``` ### 6. DateTime Fields -Use `[DeltaDateTime]` to survive server restarts: +Use `[AnchoredDateTime]` to survive server restarts -- the value is shifted by downtime at +load, so the remaining time is preserved and idle saves stay byte-stable: ```csharp -[DeltaDateTime] +[AnchoredDateTime] [SerializableField(0)] private DateTime _expireTime; ``` +(`[DeltaDateTime]` is the legacy equivalent; it rewrites bytes on every save. Converting an +existing field between the two changes the wire format and requires a version bump.) ### 7. Keeping Manual Serialization (Rare) Some edge cases still need manual serialization. If a type has complex conditional logic that can't be expressed with attributes, you can implement `ISerializable` manually. But this is rare — try attributes first. diff --git a/dev-docs/runuo-migration-docs/03-timers.md b/dev-docs/runuo-migration-docs/03-timers.md index 29f3d1282..13596a079 100644 --- a/dev-docs/runuo-migration-docs/03-timers.md +++ b/dev-docs/runuo-migration-docs/03-timers.md @@ -305,7 +305,7 @@ In RunUO, timers are commonly started in `Deserialize()`. In ModernUO, use `[Aft `_token.Cancel()` can be called on a default token, a stopped token, or an already-cancelled token. No null checks needed. ### 4. Timer.DelayCall Still Exists -`Timer.DelayCall()` is still available and returns a `Timer` object. Use it when you need the `Timer` reference (e.g., for `[DeserializeTimerField]`) or state-carrying overloads. +`Timer.DelayCall()` is still available and returns a `Timer` object. Use it when you need the `Timer` reference (e.g., for a serialized timer field with `[DeserializeTimer]`) or state-carrying overloads. ### 5. Custom Timer Classes Are Still Possible For complex timer logic (e.g., `Corpse.DecayTimer`), you can still subclass `Timer` with `OnTick()`. But prefer the fire-and-forget pattern for simple cases. diff --git a/dev-docs/runuo-migration-docs/09-items-mobiles-creatures.md b/dev-docs/runuo-migration-docs/09-items-mobiles-creatures.md index c5029131c..def2ecf48 100644 --- a/dev-docs/runuo-migration-docs/09-items-mobiles-creatures.md +++ b/dev-docs/runuo-migration-docs/09-items-mobiles-creatures.md @@ -474,6 +474,88 @@ The extra parameters (RangePerception, RangeFight, ActiveSpeed, PassiveSpeed) ha | `Name = "a creature"` in constructor | `public override string DefaultName => "a creature";` | | `get { return value; }` | `=> value;` expression-bodied | +## AI Movement: No `run` Argument + +RunUO's movement calls took a `run` flag that callers set inconsistently (`true` in +combat, `false` for pets, gated by `dist > 5` inside `MoveTo`). The flag only selects the +client's per-step animation time, so ModernUO derives it from the creature's step pace +(`BaseAI.ShouldRun`) and the parameter is gone: + +```csharp +// RunUO +MoveTo(combatant, true, m_Mobile.RangeFight); +WalkMobileRange(m_Mobile.ControlMaster, 1, false, 0, 1); + +// ModernUO +MoveTo(combatant, Mobile.RangeFight); +WalkMobileRange(Mobile.ControlMaster, 1, 0, 1); +``` + +`ApproachTarget`, `MoveToPoint` and `PathFollower.Follow` lose the argument the same way. +To make a creature run, make it fast (`SetMoveSpeed` / `npc-speeds.json`), not flagged. +An isolated step (after the creature stood for at least a walk interval) goes out as a +walk regardless of pace — only a continuing cadence, or a pace faster than the run +interpolation, flags run. + +## Target Acquisition: `AcquireOnApproach` Is a Delay + +RunUO's `AcquireOnApproach` bool (paragon insta-aggro on approach) is now +`AcquireOnApproachDelay`, a `TimeSpan` reaction-time gradient that applies to every +creature — enemy movement inside `AcquireOnApproachRange` schedules a scan within the +delay instead of waiting out the 10 s `ReacquireDelay` poll: + +```csharp +// RunUO +public override bool AcquireOnApproach => true; + +// ModernUO — Zero is the old instant behavior; larger values are dumber +public override TimeSpan AcquireOnApproachDelay => TimeSpan.Zero; +``` + +`AcquireOnApproachRange` stays 10 for all creatures (reactive aggro is on-screen; the +periodic `ReacquireDelay` scan still sweeps the full `RangePerception`). The +acquired target comes from the normal FightMode-ranked scan, not from whichever mobile +happened to move. See `content-patterns.md` § Target Acquisition. + +## Damage Entries: Inline `ValueLinkList`, Not `List` + +RunUO's `Mobile.DamageEntries` was a `List` allocated for every mobile. +ModernUO keeps damage entries in an inline `ValueLinkList` struct held by +the mobile itself, ordered least recent → most recent, so a mobile that never takes +damage owns no list object and `RegisterDamage` relinks in O(1). The property is +`ref readonly`; expired entries are pruned when it is read. + +```csharp +// RunUO +for (var i = m.DamageEntries.Count - 1; i >= 0; --i) +{ + var de = m.DamageEntries[i]; // indexer + ... +} +m.DamageEntries.Clear(); +var rights = BaseCreature.GetLootingRights(m.DamageEntries, m.HitsMax); // List + +// ModernUO — needs `using Server.Collections;` for the enumerator extensions +foreach (var de in m.DamageEntries.ByDescending()) // most recent first +{ + ... +} +foreach (var de in m.DamageEntries) // least recent first +{ + ... +} +m.ClearDamageEntries(); +var rights = BaseCreature.GetLootingRights(m.DamageEntries, m.HitsMax); // in ValueLinkList +``` + +What no longer compiles: the indexer, `.Add`, `.Remove`, `.RemoveAt`, `.Clear`, and +passing the property where a `List` is expected. `.Count`, +`FindDamageEntryFor`, `FindMostRecentDamager` and the other `Find*` methods, and +`RegisterDamage` are unchanged. `DamageEntry` now carries `Next`/`Previous`/`OnLinkList` +link fields; never set them yourself, and never call a `ValueLinkList` mutator on the +`ref readonly` property — it compiles against a copy and corrupts the node's link state. +Mutate only through `RegisterDamage` and `ClearDamageEntries`. + ## Item Name Changes ```csharp diff --git a/dev-docs/runuo-migration-docs/11-api-reference.md b/dev-docs/runuo-migration-docs/11-api-reference.md index 215dd3b06..b6c12bfe8 100644 --- a/dev-docs/runuo-migration-docs/11-api-reference.md +++ b/dev-docs/runuo-migration-docs/11-api-reference.md @@ -130,6 +130,13 @@ Alphabetical by RunUO API name. Use Ctrl+F / Cmd+F to search. | `writer.WriteEncodedInt(value)` | `writer.WriteEncodedInt(value)` | Same | | `InvalidateProperties()` | `InvalidateProperties()` | Same, or use `[InvalidateProperties]` | | `this.MarkDirty()` | `this.MarkDirty()` | NEW — required in custom setters | +| `MoveTo(m, run, range)` | `MoveTo(m, range)` | `run` removed; the Running bit is derived from the step pace (`BaseAI.ShouldRun`) | +| `WalkMobileRange(m, steps, run, min, max)` | `WalkMobileRange(m, steps, min, max)` | Same | +| `PathFollower.Follow(run, range)` | `Follow(range)` | Same | +| `AcquireOnApproach` (bool) | `AcquireOnApproachDelay` (TimeSpan) | Reaction-time gradient; `Zero` = old instant behavior | +| `m.DamageEntries` (`List`) | `m.DamageEntries` (`ref readonly ValueLinkList`) | Inline, least→most recent; `foreach` / `.ByDescending()` only, needs `using Server.Collections;`; no indexer, `Add`, `Remove`, `Clear` | +| `m.DamageEntries.Clear()` | `m.ClearDamageEntries()` | | +| `GetLootingRights(List, int)` | `GetLootingRights(in ValueLinkList, int)` | Callers passing `m.DamageEntries` compile unchanged | ## Networking diff --git a/dev-docs/serialization.md b/dev-docs/serialization.md index 151d9d717..059fbff69 100644 --- a/dev-docs/serialization.md +++ b/dev-docs/serialization.md @@ -117,7 +117,7 @@ public partial class MyItem : Item { } See `dev-docs/runuo-migration-docs/02-serialization.md` for complete migration guidance. -### [SerializableField(index, setter, saveIf)] +### [SerializableField(index, getter, setter, isVirtual, fieldChanged, allowFieldChange)] **Target**: Private field (`_camelCase`) **Generates**: Public `PascalCase` property with get/set @@ -125,8 +125,11 @@ See `dev-docs/runuo-migration-docs/02-serialization.md` for complete migration g | Parameter | Type | Default | Description | |---|---|---|---| | `index` | `int` | Required | Serialization order (0-based) | -| `setter` | `string` | `null` (public) | `"private"` or `"internal"` to restrict setter | -| `saveIf` | `string` | `null` | Method name returning bool for conditional save | +| `getter` | `string` | `"public"` | Getter accessibility | +| `setter` | `string` | `"public"` | `"private"` or `"internal"` to restrict setter | +| `isVirtual` | `bool` | `false` | Generate a `virtual` property | +| `fieldChanged` | `string` | `null` | `nameof` of a `void Method(T oldValue, T newValue)` invoked by the generated setter after assignment | +| `allowFieldChange` | `string` | `null` | `nameof` of a `bool Method(ref T value)` invoked before assignment; coerce the value through the `ref` parameter, or return `false` to reject the change | ```csharp [SerializableField(0)] // Public property @@ -144,14 +147,57 @@ The generated property for `_charges` would be: public int Charges { get => _charges; - set { _charges = value; this.MarkDirty(); } + set + { + if (value != _charges) + { + _charges = value; + this.MarkDirty(); + } + } } ``` +**Setter hooks** replace most hand-written `[SerializableProperty]` setters. The generated +pipeline is: equality check → `allowFieldChange` (coerce/veto) → assignment → `MarkDirty` → +`InvalidateProperties` (if declared) → `fieldChanged`. The gate runs before assignment, so +the field itself still holds the old value inside it. + +```csharp +[SerializableField(0, allowFieldChange: nameof(AllowChargesChange))] +[SerializedCommandProperty(AccessLevel.GameMaster)] +[InvalidateProperties] +private int _charges; + +private bool AllowChargesChange(ref int value) +{ + value = Math.Clamp(value, 0, MaxCharges); // coerce, or return false to veto + return true; +} + +[SerializableField(1, fieldChanged: nameof(OnOwnerChanged))] +private Mobile _owner; + +// oldValue makes unsubscribe/resubscribe patterns trivial +private void OnOwnerChanged(Mobile oldValue, Mobile newValue) +{ + oldValue?.Followers.Remove(this); + newValue?.Followers.Add(this); +} +``` + +Both hooks require a generated setter — declaring one on a `readonly` field or with +`setter: null` is a compile-time error (SG3018), and a named method that is missing or has +the wrong signature is too (SG3015). + ### [SerializableProperty(index, useField)] **Target**: Property with custom get/set logic -**Use when**: You need non-trivial getter/setter logic +**Use when**: You need a **custom getter** (fallback defaults, lazy or self-healing reads) +or setter semantics the field hooks cannot express (work that must run on *equal* +assignment, pre-assignment state capture). For setters that only coerce, veto, or run +post-change side effects, prefer `[SerializableField]` with `allowFieldChange`/`fieldChanged` +instead — the generated setter handles equality, `MarkDirty`, and ordering for you. | Parameter | Type | Default | Description | |---|---|---|---| @@ -163,7 +209,7 @@ public int Charges [CommandProperty(AccessLevel.GameMaster)] public int MaxItems { - get => _maxItems == -1 ? DefaultMaxItems : _maxItems; + get => _maxItems == -1 ? DefaultMaxItems : _maxItems; // custom getter: the reason this is a property set { _maxItems = value; @@ -173,6 +219,10 @@ public int MaxItems } ``` +Note: the `fieldChanged`/`allowFieldChange` hooks are `[SerializableField]` arguments and +cannot be declared on a `[SerializableProperty]` — its setter is your own code, so call your +methods from the setter directly. + ### [InvalidateProperties] **Target**: `[SerializableField]`-decorated field @@ -208,12 +258,32 @@ Overloads: Best for fields that are usually small values (counts, IDs, indexes). +### [AnchoredDateTime] + +**Target**: `DateTime` field +**Effect**: Stores the absolute UTC instant; at load it is shifted forward by the downtime +between the save and the load (using the save-start anchor in the save's index file), so +server downtime does not consume the remaining time. `DateTime.MinValue`/`MaxValue` +sentinels pass through unshifted. + +Prefer this for deadlines and "elapsed while running" values. Unlike `[DeltaDateTime]`, the +stored bytes do not change on every save when the value is unchanged, keeping idle saves +byte-stable. + +```csharp +[AnchoredDateTime] +[SerializableField(0)] +private DateTime _expireTime; +``` + ### [DeltaDateTime] **Target**: `DateTime` field **Effect**: Stores as offset from current time rather than absolute timestamp. -This ensures timers and expiration dates survive server restarts correctly. +Legacy encoding for surviving restarts: it rewrites the bytes on every save even when the +value has not changed. Prefer `[AnchoredDateTime]` for new fields; converting an existing +field between the two changes the wire format and requires a version bump. ```csharp [DeltaDateTime] @@ -289,40 +359,76 @@ private void AfterDeserialization() } ``` -### [DeserializeTimerField(fieldIndex)] +### [DeserializeTimer(nameof(Method), wallClock)] -**Target**: Method taking `TimeSpan` parameter -**Effect**: Custom deserialization for Timer fields. The timer is saved as remaining delay. +**Target**: `Timer`-typed `[SerializableField]` or `[SerializableProperty]` member +**Effect**: Declares how the timer is stored and restored. Required on every serializable +timer (SG3008 otherwise). + +By default the timer's next tick is stored as **anchored time**: server downtime does not +consume the remaining delay, and idle saves are byte-stable. Pass `wallClock: true` to store +an absolute deadline instead (the delay is then negative when the deadline passed during +downtime). + +The named method — `void Method(TimeSpan delay)` — is invoked **only when a timer was +actually running at save**, with the remaining delay. There is no sentinel value to check. ```csharp [SerializableField(0, setter: "private")] +[DeserializeTimer(nameof(DeserializeDecayTimer))] private Timer _decayTimer; -[DeserializeTimerField(0)] -private void DeserializeDecayTimer(TimeSpan delay) +private void DeserializeDecayTimer(TimeSpan delay) => _decayTimer = Timer.DelayCall(delay, Delete); +``` + +Switching an existing timer between drifting and `wallClock` changes the wire format — bump +the class version and add a `MigrateFrom`. The old-version content struct exposes the +timer's `XxxNext` (`DateTime`) and `XxxDelay` (`TimeSpan`, `TimeSpan.MinValue` when no timer +was running): + +```csharp +private void MigrateFrom(V3Content content) { - _decayTimer = Timer.DelayCall(delay, Delete); - _decayTimer.Start(); + if (content.DecayTimerDelay != TimeSpan.MinValue) + { + DeserializeDecayTimer(content.DecayTimerDelay); + } } ``` -### [SerializableFieldSaveFlag(fieldIndex)] / [SerializableFieldDefault(fieldIndex)] +### [SaveFlag(nameof(ShouldSerializeMethod), nameof(DefaultValueMethod))] +**Target**: the serializable field or property itself **Conditional serialization** -- skip fields that have their default value. +The first method (`bool Method()`) decides whether the value is written. The optional second +method (returning the field's type, no parameters) supplies the value at load when it was +not written; when omitted, the field keeps its default value. + +```csharp +[SerializableField(0)] +[SaveFlag(nameof(ShouldSerializeCharges), nameof(ChargesDefaultValue))] +private int _charges; + +private bool ShouldSerializeCharges() => _charges != -1; + +private int ChargesDefaultValue() => -1; +``` + +Works on `[SerializableProperty]` members the same way: + ```csharp [EncodedInt] [SerializableProperty(0)] +[SaveFlag(nameof(ShouldSerializeMaxItems), nameof(MaxItemsDefaultValue))] public int MaxItems { get => _maxItems == -1 ? DefaultMaxItems : _maxItems; set { _maxItems = value; this.MarkDirty(); } } -[SerializableFieldSaveFlag(0)] private bool ShouldSerializeMaxItems() => _maxItems != -1; -[SerializableFieldDefault(0)] private int MaxItemsDefaultValue() => -1; ``` diff --git a/dev-docs/timers.md b/dev-docs/timers.md index c10f31bd6..2c051cca6 100644 --- a/dev-docs/timers.md +++ b/dev-docs/timers.md @@ -188,15 +188,19 @@ public partial class TimedItem : Item ``` ### Pattern 4: Serializable Timer Field +Every serializable `Timer` member declares `[DeserializeTimer(nameof(Method))]` on the +field. By default the next tick is stored as anchored time (downtime does not consume the +remaining delay); pass `wallClock: true` for absolute deadlines. The method runs **only when +a timer was running at save**, with the remaining delay. + ```csharp [SerializableField(0, setter: "private")] +[DeserializeTimer(nameof(DeserializeDecayTimer))] private Timer _decayTimer; -[DeserializeTimerField(0)] private void DeserializeDecayTimer(TimeSpan delay) { _decayTimer = Timer.DelayCall(delay, Delete); - _decayTimer.Start(); } public void BeginDecay(TimeSpan delay)