diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json index f9f5fbe15..da66d8d0c 100644 --- a/.config/dotnet-tools.json +++ b/.config/dotnet-tools.json @@ -3,7 +3,7 @@ "isRoot": true, "tools": { "modernuoschemagenerator": { - "version": "4.0.0", + "version": "2.14.3", "commands": [ "ModernUOSchemaGenerator" ] diff --git a/.gitignore b/.gitignore index d29ee313a..3d4f21d7b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,4 @@ # Distribution Files -/Distribution/Data/Files /Distribution/Logger /Distribution/Logger.* /Distribution/ModernUO @@ -65,3 +64,6 @@ # Ignore everything under tools/ except the operator scripts checked in below. /tools/* !/tools/Export-IpBlocklist.ps1 +!/tools/Measure-EventLoop.ps1 +!/tools/HostLatencyProbe.cs +!/tools/measure-event-loop.sh diff --git a/CLAUDE.md b/CLAUDE.md index c77d39e19..07c22371c 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`). 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` +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` 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 @@ -48,6 +48,7 @@ Apply these when writing or reviewing `.cs` files under `Projects/`. | Threading model | `dev-docs/threading-model.md` | | Server hardware requirements | `dev-docs/server-requirements.md` | | Debugging event-loop performance (profiling build, decomposition, GC/RAM) | `dev-docs/debugging-event-loop.md` | +| A/B measurement harness (this branch only) | `dev-docs/measuring-event-loop.md` | | Tick-count overflow rules (subtraction comparisons; GCP pass-through counters) | `dev-docs/tick-counts.md` | | Server lifecycle & bootstrap phases (Configure/ConfigurePrompts/Initialize) | `dev-docs/server-lifecycle.md` | | Platform prerequisites (ICU, tzdata, native libs per distro) | `dev-docs/platform-prerequisites.md` | diff --git a/Directory.Build.props b/Directory.Build.props index c832f1006..30bf85099 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 707f61e4a..6e07859d5 100644 --- a/Distribution/Data/npc-speeds.json +++ b/Distribution/Data/npc-speeds.json @@ -3,16 +3,12 @@ "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", @@ -32,8 +28,6 @@ "level": "Medium", "active": 0.25, "passive": 0.5, - "activeMove": 0.45, - "passiveMove": 1.05, "types": [ "AcidElemental", "AgapiteElemental", "Alligator", "AncientLich", "Betrayer", "Bird", @@ -114,8 +108,6 @@ "level": "Fast", "active": 0.2, "passive": 0.4, - "activeMove": 0.3, - "passiveMove": 0.9, "types": [ "LordOaks", "Silvani", "AirElemental", "AncientWyrm", "Balron", "BladeSpirits", @@ -147,8 +139,6 @@ "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 983000240..b3f37cf7a 100644 --- a/Projects/BuildTool/BuildTool.csproj +++ b/Projects/BuildTool/BuildTool.csproj @@ -17,5 +17,6 @@ + diff --git a/Projects/Server.Tests/Server.Tests.csproj b/Projects/Server.Tests/Server.Tests.csproj index 4bae245cb..1be789258 100644 --- a/Projects/Server.Tests/Server.Tests.csproj +++ b/Projects/Server.Tests/Server.Tests.csproj @@ -5,16 +5,17 @@ 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 59d736c6f..0e48977ef 100644 --- a/Projects/Server.Tests/Tests/Items/DecayRegistrationTests.cs +++ b/Projects/Server.Tests/Tests/Items/DecayRegistrationTests.cs @@ -208,272 +208,6 @@ 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 deleted file mode 100644 index 955a87293..000000000 --- a/Projects/Server.Tests/Tests/Items/PlayerConstructedStackingTests.cs +++ /dev/null @@ -1,95 +0,0 @@ -using Xunit; - -namespace Server.Tests; - -[Collection("Sequential Server Tests")] -public class PlayerConstructedStackingTests -{ - // PlayerConstructed is per-instance provenance, and stack operations were written when no - // item carried any. Merging keeps the receiver's copy of a field and splitting rebuilds one - // half from a fixed list of fields, so a flag that is not accounted for in both places is - // one that ordinary stacking can launder or erase. - - // Stands in for a real stackable type. LiftItemDupe builds the remainder through the - // parameterless constructor and copies only a fixed list of fields onto it -- Stackable is - // not on that list -- so the remainder is only stackable if the type restores it the way - // every genuine stackable does. - private class StackableItem : Item - { - public StackableItem() => Stackable = true; - - public StackableItem(Serial serial) : base(serial) => Stackable = true; - } - - private static StackableItem MakeStack(Serial serial, int amount, bool playerConstructed) => - new(serial) { Amount = amount, PlayerConstructed = playerConstructed }; - - [Theory] - [InlineData(false)] - [InlineData(true)] - public void CanStackWith_IsTrueWhenProvenanceMatches(bool playerConstructed) - { - var first = MakeStack((Serial)0x1, 5, playerConstructed); - var second = MakeStack((Serial)0x2, 7, playerConstructed); - - try - { - Assert.True(first.CanStackWith(second)); - } - finally - { - first.Delete(); - second.Delete(); - } - } - - [Theory] - [InlineData(false)] - [InlineData(true)] - public void LiftItemDupe_CopiesPlayerConstructedToRemainder(bool playerConstructed) - { - var stack = MakeStack((Serial)0x1, 10, playerConstructed); - Item remainder = null; - - try - { - remainder = Mobile.LiftItemDupe(stack, 4); - - Assert.NotNull(remainder); - Assert.NotSame(stack, remainder); - Assert.Equal(4, stack.Amount); - Assert.Equal(6, remainder.Amount); - Assert.Equal(playerConstructed, remainder.PlayerConstructed); - } - finally - { - stack.Delete(); - remainder?.Delete(); - } - } - - [Fact] - public void SplitHalvesRemainStackableWithEachOther() - { - // The two halves of a split must still be one pile's worth: if the split dropped the - // flag, the remainder would no longer stack back onto what it came from. - var stack = MakeStack((Serial)0x1, 10, true); - Item remainder = null; - - try - { - remainder = Mobile.LiftItemDupe(stack, 4); - Assert.NotNull(remainder); - - Assert.True(stack.CanStackWith(remainder)); - Assert.True(stack.StackWith(null, remainder, false)); - Assert.Equal(10, stack.Amount); - Assert.True(stack.PlayerConstructed); - } - finally - { - stack.Delete(); - remainder?.Delete(); - } - } -} diff --git a/Projects/Server.Tests/Tests/Mobiles/DamageEntryTests.cs b/Projects/Server.Tests/Tests/Mobiles/DamageEntryTests.cs deleted file mode 100644 index 9c3cdfd14..000000000 --- a/Projects/Server.Tests/Tests/Mobiles/DamageEntryTests.cs +++ /dev/null @@ -1,311 +0,0 @@ -using System; -using System.Collections.Generic; -using Server.Collections; -using Xunit; - -namespace Server.Tests; - -[Collection("Sequential Server Tests")] -public class DamageEntryTests -{ - private class TestMobile : Mobile - { - } - - private class PetMobile : Mobile - { - public Mobile Master { get; set; } - - public override Mobile GetDamageMaster(Mobile damagee) => Master; - } - - private static List 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 deleted file mode 100644 index 7889c57c8..000000000 --- a/Projects/Server.Tests/Tests/Serialization/AnchoredItemSerializationTests.cs +++ /dev/null @@ -1,76 +0,0 @@ -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 deleted file mode 100644 index a99b9850c..000000000 --- a/Projects/Server.Tests/Tests/Serialization/AnchoredTimeTests.cs +++ /dev/null @@ -1,189 +0,0 @@ -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/Diagnostics/EventLoopProfiler.cs b/Projects/Server/Diagnostics/EventLoopProfiler.cs index 615a84afd..a3fd157f5 100644 --- a/Projects/Server/Diagnostics/EventLoopProfiler.cs +++ b/Projects/Server/Diagnostics/EventLoopProfiler.cs @@ -26,7 +26,6 @@ public enum LoopPhase TimerSlice, NetworkSlice, LoopTasks, - WorldSnapshot, } /// @@ -43,7 +42,7 @@ public enum LoopPhase /// public static class EventLoopProfiler { - public const int PhaseCount = 6; + public const int PhaseCount = 5; private const int RingSize = 900; private const long SampleIntervalMs = 1000; diff --git a/Projects/Server/Items/Container.cs b/Projects/Server/Items/Container.cs index 06c676b25..f139da24b 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,7 +84,6 @@ public partial class Container : Item [EncodedInt] [SerializableProperty(0)] - [SaveFlag(nameof(ShouldSerializeMaxItems), nameof(MaxItemsDefaultValue))] [CommandProperty(AccessLevel.GameMaster)] public int MaxItems { @@ -97,13 +96,14 @@ 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,13 +115,14 @@ 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 { @@ -133,8 +134,10 @@ 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 70dc1ef27..694d9b6b2 100644 --- a/Projects/Server/Items/DecayScheduler.cs +++ b/Projects/Server/Items/DecayScheduler.cs @@ -311,7 +311,7 @@ public class DecayScheduler : Timer if (timeUntilDecay > _bucketInterval) { - // Deadline was pushed out (SetLastMoved/RestartDecay) - re-bucket or move to overflow + // Item was moved (SetLastMoved called) - 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.RestartDecay(); + item.SetLastMoved(); } } } diff --git a/Projects/Server/Items/Item.cs b/Projects/Server/Items/Item.cs index 13f249f2f..64cef9526 100644 --- a/Projects/Server/Items/Item.cs +++ b/Projects/Server/Items/Item.cs @@ -335,25 +335,7 @@ public partial class Item : IHued, IComparable, ISpawnable, IObjectPropert [CommandProperty(AccessLevel.GameMaster)] public virtual bool Decays => Movable && Visible && Spawner == null; - 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(); - } - } - } + public DateTime LastMoved { get; set; } [CommandProperty(AccessLevel.GameMaster)] public bool Stackable @@ -391,7 +373,7 @@ public partial class Item : IHued, IComparable, ISpawnable, IObjectPropert } Delta(ItemDelta.Update); - RestartDecay(); + UpdateDecayRegistration(); } } } @@ -407,7 +389,7 @@ public partial class Item : IHued, IComparable, ISpawnable, IObjectPropert SetFlag(ImplFlag.Movable, value); Delta(ItemDelta.Update); - RestartDecay(); + UpdateDecayRegistration(); } } } @@ -767,12 +749,6 @@ public partial class Item : IHued, IComparable, ISpawnable, IObjectPropert public static bool ScissorCopyLootType { get; set; } - /// - /// True when the item was produced by the crafting system rather than bought or looted. - /// - [CommandProperty(AccessLevel.GameMaster)] - public bool PlayerConstructed { get; set; } - [CommandProperty(AccessLevel.GameMaster)] public bool QuestItem { @@ -863,7 +839,7 @@ public partial class Item : IHued, IComparable, ISpawnable, IObjectPropert public virtual void Serialize(IGenericWriter writer) { - writer.Write(11); // version + writer.Write(9); // version var flags = SaveFlag.None; @@ -973,11 +949,6 @@ 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) @@ -1008,21 +979,16 @@ public partial class Item : IHued, IComparable, ISpawnable, IObjectPropert flags |= SaveFlag.ImplFlags; } - if (PlayerConstructed) - { - flags |= SaveFlag.PlayerConstructed; - } - writer.Write((int)flags); - // 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); + /* begin last moved time optimization */ + var ticks = LastMoved.Ticks; + var now = Core.Now.Ticks; - if (GetSaveFlag(flags, SaveFlag.DecayReset)) - { - writer.WriteAnchoredTime(info.m_DecayReset); - } + var minutes = new TimeSpan(now - ticks).TotalMinutes; + + writer.WriteEncodedInt((int)Math.Clamp(minutes, int.MinValue, int.MaxValue)); + /* end */ if (GetSaveFlag(flags, SaveFlag.Direction)) { @@ -1341,12 +1307,6 @@ 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(); @@ -1577,7 +1537,7 @@ public partial class Item : IHued, IComparable, ISpawnable, IObjectPropert if (oldValue != value) { - RestartDecay(); + UpdateDecayRegistration(); } } } @@ -1771,7 +1731,6 @@ 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; @@ -2356,64 +2315,7 @@ public partial class Item : IHued, IComparable, ISpawnable, IObjectPropert public virtual bool OnDecay() => CanDecay() && Region.Find(Location, Map).OnDecay(this); - 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 DateTime ScheduledDecayTime => LastMoved + DecayTime; public void UpdateDecayRegistration() { @@ -2423,12 +2325,6 @@ 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() @@ -2461,11 +2357,6 @@ public partial class Item : IHued, IComparable, ISpawnable, IObjectPropert } Amount += dropped.Amount; - if (PlayerConstructed != dropped.PlayerConstructed) - { - PlayerConstructed = false; - } - dropped.Delete(); if (playSound && from != null) @@ -2766,8 +2657,6 @@ public partial class Item : IHued, IComparable, ISpawnable, IObjectPropert switch (version) { - case 11: - case 10: case 9: case 8: case 7: @@ -2775,11 +2664,7 @@ public partial class Item : IHued, IComparable, ISpawnable, IObjectPropert { var flags = (SaveFlag)reader.ReadInt(); - if (version >= 11) - { - LastMoved = reader.ReadAnchoredTime(); - } - else if (version < 7) + if (version < 7) { LastMoved = reader.ReadDeltaTime(); } @@ -2797,18 +2682,6 @@ 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(); @@ -2981,8 +2854,6 @@ public partial class Item : IHued, IComparable, ISpawnable, IObjectPropert AcquireCompactInfo().m_SavedFlags = reader.ReadEncodedInt(); } - PlayerConstructed = GetSaveFlag(flags, SaveFlag.PlayerConstructed); - if (m_Map != null && m_Parent == null) { m_Map.OnEnter(this); @@ -3566,7 +3437,7 @@ public partial class Item : IHued, IComparable, ISpawnable, IObjectPropert for (var i = 0; i < props.Length; i++) { var p = props[i]; - if (p.GetCustomAttribute(true) != null || !p.CanRead || !p.CanWrite) + if (p.GetCustomAttribute(typeof(IgnoreDupeAttribute), true) != null || !p.CanRead || !p.CanWrite) { continue; } @@ -4472,8 +4343,6 @@ 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; @@ -4511,8 +4380,6 @@ public partial class Item : IHued, IComparable, ISpawnable, IObjectPropert HeldBy = 0x00800000, IntWeight = 0x01000000, SavedFlags = 0x02000000, - NullWeight = 0x04000000, - PlayerConstructed = 0x08000000, - DecayReset = 0x10000000 + NullWeight = 0x04000000 } } diff --git a/Projects/Server/Main.cs b/Projects/Server/Main.cs index 608256651..eb95e6d4e 100644 --- a/Projects/Server/Main.cs +++ b/Projects/Server/Main.cs @@ -39,28 +39,24 @@ public static class Core { private static readonly ILogger logger = LogFactory.GetLogger(typeof(Core)); - // Written off-loop (Kill, RequestSnapshot); volatile because the loop blocks between reads. + // Written from other threads (Kill, RequestSnapshot) and read by the event loop. Volatile + // because the loop now genuinely blocks between reads rather than spinning past them. private static volatile bool _performProcessKill; private static bool _restartOnKill; private static volatile bool _performSnapshot; private static string _snapshotPath; - // A backstop, not a latency control: the wheel's tick rate already bounds the sleep. - // Measured across 1/2/4/8ms; 2 is optimal. + // A backstop, not a latency control: the wheel's tick rate bounds the sleep, so this only + // limits the damage if a wake signal is ever missed. Measured across 1/2/4/8ms, 2 is optimal. private static int _eventLoopIdleWaitMs = 2; /// - /// Longest the loop will block while idle, in milliseconds. 0 spins instead; the backoff - /// does the same temporarily when the host keeps returning waits late. + /// Longest the loop will block while idle, in milliseconds. 0 disables idle sleeping, + /// leaving the loop to spin; the adaptive backoff does the same thing temporarily when the + /// host keeps returning waits late. /// public static int EventLoopIdleWaitMs => _eventLoopIdleWaitMs; - /// - /// True when idle sleeping was disabled at startup because the host cannot honor short - /// waits, overriding whatever server.eventLoopIdleWaitMs was configured to. - /// - public static bool IdleSleepUnsupported { get; private set; } - /// /// Whether idle sleeping is currently suspended because the host returned waits late. /// @@ -72,8 +68,9 @@ public static class Core private const long HealthSampleIntervalMs = 1000; - // Doubling: a fixed suspension oscillates forever on a persistently bad host, while doubling - // converges on "stop sleeping" yet still recovers from a transient. + // Backoff escalates by doubling: a fixed suspension oscillates forever on a persistently bad + // host, while doubling converges on "stop sleeping" within minutes yet still recovers from a + // transient problem. private const long BackoffBaseMs = 5000; private const long BackoffMaxMs = 120_000; private const int BackoffMaxShift = 5; @@ -81,20 +78,16 @@ public static class Core // Clean streak that clears the escalation. private const long BackoffResetAfterCleanMs = 60_000; - // Below this a backoff is still recoverable and not actionable, so it only logs at Debug. - private const int WarnAfterConsecutiveBackoffs = 3; - - // A sleep is bounded by the next wheel turn, so only a wait returning late can cost a deadline. - // Measured per sleep, which is why server work (saves, heavy commands) cannot trip the backoff. + // A sleep is bounded by the time to the next wheel turn, so a correctly honoured sleep can + // never miss a deadline; the only way sleeping harms the wheel is the wait returning late + // (the host descheduled the process). That overshoot is measured per sleep, which is why + // server work -- saves, heavy commands, deep timer callbacks -- cannot trip this backoff. + // Loop-thread only, so plain increments are safe. private static int _lateWakes; - // Denominator for the late-wake rate. - private static int _sleepAttempts; - private static long _nextHealthSample; private static long _idleSleepSuspendedUntil; private static int _lateWakeThreshold = 1; - private static int _lateWakePercent = 10; private static long _idleSleepBackoffs; private static int _consecutiveBadSamples; private static int _consecutiveBackoffs; @@ -116,25 +109,7 @@ public static class Core _nextHealthSample = _tickCount + HealthSampleIntervalMs; var late = _lateWakes; - var sleeps = _sleepAttempts; _lateWakes = 0; - _sleepAttempts = 0; - - // A clean streak resets the escalation and re-arms the ceiling Error. Gated on the count - // rather than a "_lastBackoffAt > 0" sentinel because tick counts are not guaranteed positive. - if (_consecutiveBackoffs > 0 && _tickCount - _lastBackoffAt > BackoffResetAfterCleanMs) - { - if (_consecutiveBackoffs >= WarnAfterConsecutiveBackoffs) - { - logger.Information( - "This host has returned idle waits on time for {Duration}ms; idle sleeping is back to normal", - BackoffResetAfterCleanMs - ); - } - - _consecutiveBackoffs = 0; - _loggedBackoffCeiling = false; - } if (late <= _lateWakeThreshold) { @@ -142,17 +117,8 @@ public static class Core return; } - // Lateness is a rate: an idle loop sleeps hundreds of times a second, so a few outliers are - // normal, while a host that cannot schedule the process returns most of its waits late. The - // threshold above is the floor for windows with too few sleeps for a proportion to mean anything. - if (late * 100 < sleeps * _lateWakePercent) - { - _consecutiveBadSamples = 0; - return; - } - - // Require persistence: any host can drop one sample to unrelated load, but an oversubscribed - // one stays bad. + // Require the condition to persist: any host can drop one sample to unrelated load, and a + // host that is genuinely oversubscribed stays that way, so it trips on the second sample. if (++_consecutiveBadSamples < 2) { return; @@ -163,6 +129,20 @@ public static class Core return; } + // Already suspended: extend rather than counting a fresh backoff episode. + if (_tickCount - _idleSleepSuspendedUntil < 0) + { + _idleSleepSuspendedUntil = _tickCount + _currentBackoffMs; + return; + } + + // A long clean streak resets the escalation. Gated on the count rather than a + // "_lastBackoffAt > 0" sentinel because tick counts are not guaranteed positive. + if (_consecutiveBackoffs > 0 && _tickCount - _lastBackoffAt > BackoffResetAfterCleanMs) + { + _consecutiveBackoffs = 0; + } + _currentBackoffMs = Math.Min(BackoffBaseMs << Math.Min(_consecutiveBackoffs, BackoffMaxShift), BackoffMaxMs); _consecutiveBackoffs++; _lastBackoffAt = _tickCount; @@ -171,7 +151,7 @@ public static class Core if (_currentBackoffMs >= BackoffMaxMs) { - // Escalation has run out of room; say so once. + // Escalation has run out of room; say so once in terms the operator can act on. if (!_loggedBackoffCeiling) { _loggedBackoffCeiling = true; @@ -186,31 +166,12 @@ public static class Core return; } - // Each backoff doubles the suspension, so every line is a distinct escalation step and - // needs no further rate limiting. - if (_consecutiveBackoffs < WarnAfterConsecutiveBackoffs) - { - logger.Debug( - "This host returned a {Requested}ms idle wait at least {TickRate}ms late {Count} of {Sleeps} time(s) " + - "in the last second; idle sleeping suspended for {Duration}ms", - _eventLoopIdleWaitMs, - Timer.TickRate, - late, - sleeps, - _currentBackoffMs - ); - - return; - } - logger.Warning( - "This host returned a {Requested}ms idle wait at least {TickRate}ms late {Count} of {Sleeps} time(s) in " + - "the last second, for the {Backoffs}th time running; idle sleeping suspended for {Duration}ms", + "This host returned a {Requested}ms idle wait at least {TickRate}ms late {Count} time(s) in the last " + + "second; idle sleeping suspended for {Duration}ms", _eventLoopIdleWaitMs, Timer.TickRate, late, - sleeps, - _consecutiveBackoffs, _currentBackoffMs ); } @@ -399,8 +360,8 @@ public static class Core _restartOnKill = restart; _performProcessKill = true; - // Callers are usually off-loop (console input, signal handlers); wake so the request - // is noticed now rather than whenever the loop next surfaces. + // Callers are usually off-loop (console input, signal handlers). Without this the loop + // would not notice the request until it woke for some other reason. NetState.Wake(); } @@ -592,43 +553,11 @@ public static class Core ServerConfiguration.Load(); // 0 disables idle sleeping entirely (full-core spin, zero scheduling overhead). - var idleWaitMs = ServerConfiguration.GetSetting("server.eventLoopIdleWaitMs", 2); - if (idleWaitMs < 0) - { - logger.Warning( - "server.eventLoopIdleWaitMs {Value} is negative; using 0 (idle sleeping disabled)", - idleWaitMs - ); - } + _eventLoopIdleWaitMs = ServerConfiguration.GetSetting("server.eventLoopIdleWaitMs", 2); - _eventLoopIdleWaitMs = Math.Max(0, idleWaitMs); - - // Floor for the backoff: idle waits per second the host may return a full tick late before - // the rate test below applies at all. Set very high to disable the backoff. - var lateWakeThreshold = ServerConfiguration.GetSetting("server.lateWakeThreshold", 1); - if (lateWakeThreshold < 0) - { - logger.Warning( - "server.lateWakeThreshold {Value} is negative; using 0", - lateWakeThreshold - ); - } - - _lateWakeThreshold = Math.Max(0, lateWakeThreshold); - - // Share of a second's idle waits that must return late before the backoff trips. 0 leaves - // the threshold above in sole charge. - var lateWakePercent = ServerConfiguration.GetSetting("server.lateWakePercent", 10); - if (lateWakePercent is < 0 or > 100) - { - logger.Warning( - "server.lateWakePercent {Value} is outside 0-100; using {Clamped}", - lateWakePercent, - Math.Clamp(lateWakePercent, 0, 100) - ); - } - - _lateWakePercent = Math.Clamp(lateWakePercent, 0, 100); + // 16ms-budget misses per second before idle sleeping backs off. Raise to tolerate a + // jittery host; set very high to disable the backoff. + _lateWakeThreshold = ServerConfiguration.GetSetting("server.lateWakeThreshold", 1); var assemblyPath = Path.Join(BaseDirectory, AssembliesConfiguration); @@ -646,8 +575,10 @@ public static class Core AssemblyHandler.LoadAssemblies(assemblyFiles); - // First-boot interactive setup. After assemblies load so content can register prompts, - // before any Serilog output so prompts are not interleaved with the async console sink. + // First-boot interactive setup. Runs after assemblies are loaded (so content can + // register prompts) but before any Serilog output, so console prompts are not + // interleaved with the async console sink. Handlers self-gate on first-boot state + // (e.g. "is my setting already present?"). AssemblyHandler.Invoke("ConfigurePrompts"); logger.Information("Running on {Framework}", RuntimeInformation.FrameworkDescription); @@ -657,8 +588,9 @@ public static class Core _now = DateTime.UtcNow; _firstTick = _tickCount = GetTimestamp(); - // Seed from a real tick: tick counts need not start near zero, so a zero-initialized - // deadline compares wrong. See dev-docs/tick-counts.md. + // Seed schedule state from the first real tick: tick counts are not guaranteed to start + // anywhere near zero (hypervisor pass-through counters), so zero-initialized deadlines + // would compare wrong. See dev-docs/tick-counts.md. _nextHealthSample = _tickCount + HealthSampleIntervalMs; _idleSleepSuspendedUntil = _tickCount; @@ -679,8 +611,9 @@ public static class Core PingServer.Start(); EventSink.InvokeServerStarted(); - // Without a high-resolution wait a 2ms request quantises to 15.625ms and the loop runs a - // tick behind. Only fires when the high-res timer and the timeBeginPeriod fallback both failed. + // Without a high-resolution wait a 2ms request quantises to 15.625ms and the loop would + // quietly run a tick behind; spinning is the lesser evil and must not be silent. Only + // fires when both the ring's high-res timer and its timeBeginPeriod fallback failed. if (_eventLoopIdleWaitMs > 0 && NetState.Ring?.SupportsHighResolutionWait == false) { logger.Error( @@ -688,7 +621,6 @@ public static class Core "resolution failed). Idle sleeping is disabled. The loop will spin instead, using a full core." ); - IdleSleepUnsupported = true; _eventLoopIdleWaitMs = 0; } @@ -697,7 +629,8 @@ public static class Core /// /// True when every queue the loop drains is empty, so sleeping cannot strand pending work. - /// The drains are bounded, so leftovers are normal and must keep the loop awake. + /// The drains are bounded (ProcessDeltaQueue stops at the count seen on entry, ExecuteTasks + /// at its per-frame cap), so leftovers are normal and must keep the loop awake. /// private static bool IsIdle() => !Mobile.HasQueuedDeltas && !Item.HasQueuedDeltas && LoopContext.IsEmpty && NetState.IsIdle; @@ -739,10 +672,8 @@ public static class Core if (_performSnapshot) { - EventLoopProfiler.PhaseStart(LoopPhase.WorldSnapshot); // Return value is the offset that can be used to fix timers that should drift World.Snapshot(_snapshotPath); - EventLoopProfiler.PhaseEnd(LoopPhase.WorldSnapshot); _performSnapshot = false; } @@ -756,28 +687,21 @@ public static class Core if (_eventLoopIdleWaitMs > 0 && _tickCount - _idleSleepSuspendedUntil >= 0 && IsIdle()) { - // Re-read the clock: a stale timestamp overstates the time to the next tick - // and sleeps straight past it. + // Re-read the clock: the loop body consumed real time, and a stale timestamp + // would overstate the time to the next tick and sleep straight past it. var start = GetTimestamp(); var due = Timer.MillisecondsUntilNextTick(start); if (due > 0) { var requested = (int)Math.Min(due, _eventLoopIdleWaitMs); - - // The GC prefers to collect during idle sleeps, so its pauses land here by - // design and are not the host's fault. Gen1 and above (what - // CollectionCount(1) counts) are the only pauses long enough to reach a tick. - var collections = GC.CollectionCount(1); - NetState.WaitForCompletion(requested); var elapsed = GetTimestamp() - start; EventLoopProfiler.SleepEnd(requested, elapsed); - _sleepAttempts++; - // The second collection read sits behind the overshoot test, so the common - // path reads the counter once, not twice. - if (elapsed - requested >= Timer.TickRate && GC.CollectionCount(1) == collections) + // A sleep is bounded by the next wheel turn, so only a wait the host + // returned late can cost the wheel a deadline. + if (elapsed - requested >= Timer.TickRate) { _lateWakes++; } @@ -799,7 +723,8 @@ public static class Core _snapshotPath = snapshotPath; _performSnapshot = true; - // Save requests arrive off-loop; wake so the snapshot starts now. + // Save requests arrive off-loop. Wake so the snapshot starts now rather than after the + // loop happens to surface for another reason. NetState.Wake(); } diff --git a/Projects/Server/Mobiles/Mobile.cs b/Projects/Server/Mobiles/Mobile.cs index d1db8113b..e47f977a7 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 : IValueLinkListNode +public class DamageEntry { public DamageEntry(Mobile damager) => Damager = damager; @@ -57,11 +57,6 @@ public class DamageEntry : IValueLinkListNode 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] @@ -382,6 +377,7 @@ public partial class Mobile : IHued, IComparable, ISpawnable, IObjectPro Aggressors = new List(); Aggressed = new List(); NextSkillTime = Core.TickCount; + DamageEntries = new List(); } // Sectors @@ -962,23 +958,7 @@ public partial class Mobile : IHued, IComparable, ISpawnable, IObjectPro public static VisibleDamageType VisibleDamageType { get; 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; - } - } + public List DamageEntries { get; private set; } [CommandProperty(AccessLevel.GameMaster)] public Mobile LastKiller { get; set; } @@ -1647,7 +1627,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; } @@ -2040,7 +2020,10 @@ public partial class Mobile : IHued, IComparable, ISpawnable, IObjectPro Aggressors[i].CanReportMurder = false; } - ClearDamageEntries(); // reset damage entries on full HP + if (DamageEntries.Count > 0) + { + DamageEntries.Clear(); // reset damage entries on full HP + } } else if (CanRegenHits) { @@ -2341,11 +2324,11 @@ public partial class Mobile : IHued, IComparable, ISpawnable, IObjectPro public virtual void Serialize(IGenericWriter writer) { - writer.Write(38); // version + writer.Write(37); // version - writer.WriteAnchoredTime(LastStrGain); - writer.WriteAnchoredTime(LastIntGain); - writer.WriteAnchoredTime(LastDexGain); + writer.WriteDeltaTime(LastStrGain); + writer.WriteDeltaTime(LastIntGain); + writer.WriteDeltaTime(LastDexGain); byte hairflag = 0x00; @@ -5265,15 +5248,8 @@ public partial class Mobile : IHued, IComparable, ISpawnable, IObjectPro item.Name = oldItem.Name; item.Weight = oldItem.Weight; - item.PlayerConstructed = oldItem.PlayerConstructed; item.Amount = oldAmount - amount; - - // 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; - } + item.Map = oldItem.Map; oldItem.OnAfterDuped(item); @@ -5762,54 +5738,24 @@ 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) { - PruneExpiredDamageEntries(); - - for (var de = _damageEntries._last; de != null; de = de.Previous) + for (var i = DamageEntries.Count - 1; i >= 0; --i) { - if (allowSelf || de.Damager != this) + if (i >= DamageEntries.Count) + { + continue; + } + + var de = DamageEntries[i]; + + if (de.HasExpired) + { + DamageEntries.RemoveAt(i); + } + else if (allowSelf || de.Damager != this) { return de; } @@ -5822,11 +5768,21 @@ public partial class Mobile : IHued, IComparable, ISpawnable, IObjectPro public DamageEntry FindLeastRecentDamageEntry(bool allowSelf) { - PruneExpiredDamageEntries(); - - for (var de = _damageEntries._first; de != null; de = de.Next) + for (var i = 0; i < DamageEntries.Count; ++i) { - if (allowSelf || de.Damager != this) + if (i < 0) + { + continue; + } + + var de = DamageEntries[i]; + + if (de.HasExpired) + { + DamageEntries.RemoveAt(i); + --i; + } + else if (allowSelf || de.Damager != this) { return de; } @@ -5837,17 +5793,24 @@ 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 de = _damageEntries._last; de != null; de = de.Previous) + for (var i = DamageEntries.Count - 1; i >= 0; --i) { - if ((allowSelf || de.Damager != this) && (mostTotal == null || de.DamageGiven > mostTotal.DamageGiven)) + if (i >= DamageEntries.Count) + { + continue; + } + + var de = DamageEntries[i]; + + if (de.HasExpired) + { + DamageEntries.RemoveAt(i); + } + else if ((allowSelf || de.Damager != this) && (mostTotal == null || de.DamageGiven > mostTotal.DamageGiven)) { mostTotal = de; } @@ -5860,28 +5823,46 @@ public partial class Mobile : IHued, IComparable, ISpawnable, IObjectPro public DamageEntry FindLeastTotalDamageEntry(bool allowSelf) { - PruneExpiredDamageEntries(); + DamageEntry mostTotal = null; - DamageEntry leastTotal = null; - - for (var de = _damageEntries._last; de != null; de = de.Previous) + for (var i = DamageEntries.Count - 1; i >= 0; --i) { - if ((allowSelf || de.Damager != this) && (leastTotal == null || de.DamageGiven < leastTotal.DamageGiven)) + if (i >= DamageEntries.Count) { - leastTotal = de; + continue; + } + + var de = DamageEntries[i]; + + if (de.HasExpired) + { + DamageEntries.RemoveAt(i); + } + else if ((allowSelf || de.Damager != this) && (mostTotal == null || de.DamageGiven < mostTotal.DamageGiven)) + { + mostTotal = de; } } - return leastTotal; + return mostTotal; } public DamageEntry FindDamageEntryFor(Mobile m) { - PruneExpiredDamageEntries(); - - for (var de = _damageEntries._last; de != null; de = de.Previous) + for (var i = DamageEntries.Count - 1; i >= 0; --i) { - if (de.Damager == m) + if (i >= DamageEntries.Count) + { + continue; + } + + var de = DamageEntries[i]; + + if (de.HasExpired) + { + DamageEntries.RemoveAt(i); + } + else if (de.Damager == m) { return de; } @@ -5899,13 +5880,8 @@ public partial class Mobile : IHued, IComparable, ISpawnable, IObjectPro de.DamageGiven += amount; de.LastDamage = Core.Now; - // Move to the tail so the list stays in LastDamage order. - if (de.OnLinkList) - { - _damageEntries.Remove(de); - } - - _damageEntries.AddLast(de); + DamageEntries.Remove(de); + DamageEntries.Add(de); var master = from.GetDamageMaster(this); @@ -6167,7 +6143,6 @@ 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 @@ -6176,18 +6151,9 @@ public partial class Mobile : IHued, IComparable, ISpawnable, IObjectPro case 32: // Removed StuckMenu case 31: { - if (version >= 38) - { - LastStrGain = reader.ReadAnchoredTime(); - LastIntGain = reader.ReadAnchoredTime(); - LastDexGain = reader.ReadAnchoredTime(); - } - else - { - LastStrGain = reader.ReadDeltaTime(); - LastIntGain = reader.ReadDeltaTime(); - LastDexGain = reader.ReadDeltaTime(); - } + LastStrGain = reader.ReadDeltaTime(); + LastIntGain = reader.ReadDeltaTime(); + LastDexGain = reader.ReadDeltaTime(); goto case 30; } @@ -6495,6 +6461,9 @@ 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()) @@ -7827,10 +7796,13 @@ 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 a5423e039..bd569f20e 100644 --- a/Projects/Server/Mobiles/Mods/ResistanceMod.cs +++ b/Projects/Server/Mobiles/Mods/ResistanceMod.cs @@ -21,15 +21,17 @@ namespace Server; [SerializationGenerator(0)] public partial class ResistanceMod : MobileMod { - [SerializableField(0, fieldChanged: nameof(OnTypeChanged))] + [SerializableField(0)] private ResistanceType _type; + [SerializableFieldChanged(0)] [MethodImpl(MethodImplOptions.AggressiveInlining)] private void OnTypeChanged(ResistanceType oldValue, ResistanceType newValue) => Owner?.UpdateResistances(); - [SerializableField(1, fieldChanged: nameof(OnOffsetChanged))] + [SerializableField(1)] 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 cfe96c0c4..a78ec5ce6 100644 --- a/Projects/Server/Mobiles/Mods/SkillMod.cs +++ b/Projects/Server/Mobiles/Mods/SkillMod.cs @@ -21,29 +21,33 @@ namespace Server; [SerializationGenerator(0)] public abstract partial class SkillMod : MobileMod { - [SerializableField(0, fieldChanged: nameof(OnObeyCapChanged))] + [SerializableField(0)] private bool _obeyCap; + [SerializableFieldChanged(0)] [MethodImpl(MethodImplOptions.AggressiveInlining)] - private void OnObeyCapChanged(bool oldValue, bool newValue) => Owner?.Skills[_skill]?.Update(); + private void OnObeCapChanged(bool oldValue, bool newValue) => Owner?.Skills[_skill]?.Update(); - [SerializableField(1, fieldChanged: nameof(OnSkillChanged))] + [SerializableField(1)] private SkillName _skill; + [SerializableFieldChanged(1)] private void OnSkillChanged(SkillName oldValue, SkillName newValue) { Owner?.Skills[newValue]?.Update(); Owner?.Skills[oldValue]?.Update(); } - [SerializableField(2, fieldChanged: nameof(OnRelativeChanged))] + [SerializableField(2)] private bool _relative; + [SerializableFieldChanged(2)] private void OnRelativeChanged(bool oldValue, bool newValue) => Owner?.Skills[_skill]?.Update(); - [SerializableField(3, fieldChanged: nameof(OnValueChanged))] + [SerializableField(3)] 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 33ee318fc..c28ef2228 100644 --- a/Projects/Server/Network/MovementThrottle.cs +++ b/Projects/Server/Network/MovementThrottle.cs @@ -50,6 +50,9 @@ 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); @@ -80,9 +83,15 @@ public static class MovementThrottle public static void Configure() { - _maxCredit = ServerConfiguration.GetOrUpdateSetting("movementThrottle.maxCredit", _maxCredit); - _maxRttBonus = ServerConfiguration.GetOrUpdateSetting("movementThrottle.maxRttBonus", _maxRttBonus); - _hardQueueLimit = ServerConfiguration.GetOrUpdateSetting("movementThrottle.hardQueueLimit", _hardQueueLimit); + _maxCredit = ServerConfiguration.GetOrUpdateSetting( + "movementThrottle.maxCredit", + _maxCredit + ); + + _hardQueueLimit = ServerConfiguration.GetOrUpdateSetting( + "movementThrottle.hardQueueLimit", + _hardQueueLimit + ); _movementHistorySize = ServerConfiguration.GetOrUpdateSetting( "movementThrottle.movementHistorySize", @@ -94,13 +103,6 @@ public static class MovementThrottle _minSamplesForRate ); - _maxChainGap = ServerConfiguration.GetOrUpdateSetting("movementThrottle.maxChainGap", _maxChainGap); - - _speedHackNotificationCooldown = ServerConfiguration.GetOrUpdateSetting( - "movementThrottle.speedHackNotificationCooldown", - _speedHackNotificationCooldown - ); - _suspiciousRateThreshold = (float)ServerConfiguration.GetOrUpdateSetting( "movementThrottle.suspiciousRateThreshold", _suspiciousRateThreshold @@ -110,6 +112,11 @@ public static class MovementThrottle "movementThrottle.definiteRateThreshold", _definiteRateThreshold ); + + _debugLogging = ServerConfiguration.GetOrUpdateSetting( + "movementThrottle.debugLogging", + _debugLogging + ); } /// @@ -184,16 +191,15 @@ 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 (ns._movementLogging) + if (_debugLogging && ns._movementLogging) { - var prevCredit = ns._movementCredit + earlyAmount; - logger.Debug( "[Credit] {Name}: delta={Delta}ms early={Early}ms credit={PrevCredit}->{Credit}/{MaxCredit} action=execute", - mobile, delta, earlyAmount, prevCredit, ns._movementCredit, dynamicCredit + mobile.RawName, delta, earlyAmount, prevCredit, ns._movementCredit, dynamicCredit ); } @@ -202,11 +208,11 @@ public static class MovementThrottle return; } - if (ns._movementLogging) + if (_debugLogging && ns._movementLogging) { logger.Debug( "[Credit] {Name}: delta={Delta}ms early={Early}ms credit={Credit}/{MaxCredit} EXHAUSTED -> queue", - mobile, delta, earlyAmount, ns._movementCredit, dynamicCredit + mobile.RawName, delta, earlyAmount, ns._movementCredit, dynamicCredit ); } @@ -221,11 +227,11 @@ public static class MovementThrottle var prevCredit = ns._movementCredit; ns._movementCredit = Math.Min(ns._movementCredit + delta, dynamicCredit); - if (ns._movementLogging && ns._movementCredit != prevCredit) + if (_debugLogging && ns._movementLogging && ns._movementCredit != prevCredit) { logger.Debug( "[Credit] {Name}: delta=+{Delta}ms credit={PrevCredit}->{Credit}/{MaxCredit} action=execute", - mobile, delta, prevCredit, ns._movementCredit, dynamicCredit + mobile.RawName, delta, prevCredit, ns._movementCredit, dynamicCredit ); } } @@ -241,9 +247,12 @@ public static class MovementThrottle { if (!mobile.Move(dir)) { - if (ns._movementLogging) + if (_debugLogging && ns._movementLogging) { - logger.Debug("[Execute] {Name}: Move FAILED dir={Dir} seq={Seq} -> reject+reset", mobile, dir, seq); + logger.Debug( + "[Execute] {Name}: Move FAILED dir={Dir} seq={Seq} -> reject+reset", + mobile.RawName, dir, seq + ); } // Movement failed (blocked, paralyzed, frozen, etc.) @@ -251,11 +260,11 @@ public static class MovementThrottle return; } - if (ns._movementLogging) + if (_debugLogging && ns._movementLogging) { logger.Debug( "[Execute] {Name}: Move OK dir={Dir} seq={Seq} nextMove={NextMove}ms", - mobile, dir, seq, ns._nextMovementTime - Core.TickCount + mobile.RawName, dir, seq, ns._nextMovementTime - Core.TickCount ); } @@ -295,11 +304,11 @@ public static class MovementThrottle ns._hasQueuedMovements = true; _netStatesWithQueuedMovements.Add(ns); - if (ns._movementLogging) + if (_debugLogging && ns._movementLogging) { logger.Debug( "[Queue] {Name}: enqueued dir={Dir} seq={Seq} (depth={Depth})", - ns.Mobile, dir, seq, ns._movementQueue.Count + ns.Mobile?.RawName, dir, seq, ns._movementQueue.Count ); } } @@ -311,6 +320,7 @@ public static class MovementThrottle { ns.SendMovementRej(seq, mobile); ns.ResetMovementState(); + _netStatesWithQueuedMovements.Remove(ns); } /// @@ -323,18 +333,20 @@ public static class MovementThrottle return; } - foreach (var ns in _netStatesWithQueuedMovements) + // 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++) { - if (ns.Running) + var ns = toProcess[i]; + if (!ns.Running) { - ProcessMovementQueue(ns); - if (ns._hasQueuedMovements) - { - continue; - } + _netStatesWithQueuedMovements.Remove(ns); + continue; } - _netStatesWithQueuedMovements.Remove(ns); + ProcessMovementQueue(ns); } } @@ -344,7 +356,6 @@ public static class MovementThrottle public static void ProcessMovementQueue(NetState ns) { var mobile = ns.Mobile; - if (mobile?.Deleted != false) { ClearQueue(ns); @@ -363,7 +374,7 @@ public static class MovementThrottle while (ns._movementQueue?.Count > 0) { // Check if it's time to execute - if (now - ns._nextMovementTime < 0) + if (now < ns._nextMovementTime) { // Not yet - leave remaining items in queue for next Slice break; @@ -383,11 +394,11 @@ public static class MovementThrottle // Execute the move if (!mobile.Move(movement.Direction)) { - if (ns._movementLogging) + if (_debugLogging && ns._movementLogging) { logger.Debug( "[Queue] {Name}: dequeued FAILED dir={Dir} (remaining={Remaining})", - mobile, movement.Direction, remaining + mobile.RawName, movement.Direction, remaining ); } @@ -396,12 +407,12 @@ public static class MovementThrottle return; } - if (ns._movementLogging) + if (_debugLogging && ns._movementLogging) { var waited = now - ns._nextMovementTime; logger.Debug( "[Queue] {Name}: dequeued OK dir={Dir} (remaining={Remaining}, waited={Waited}ms)", - mobile, movement.Direction, remaining, waited >= 0 ? waited : 0 + mobile.RawName, movement.Direction, remaining, waited >= 0 ? waited : 0 ); } @@ -419,6 +430,10 @@ public static class MovementThrottle // Update tracking ns._hasQueuedMovements = ns._movementQueue?.Count > 0; + if (!ns._hasQueuedMovements) + { + _netStatesWithQueuedMovements.Remove(ns); + } } /// @@ -454,6 +469,7 @@ 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) @@ -468,7 +484,7 @@ public static class MovementThrottle logger.Information( "Movement queue overflow: {Character} ({Account}) | " + "Queue reached hard limit: {Limit} | IP: {IP}", - mobile, + mobile?.RawName ?? "Unknown", ns.Account?.Username ?? "Unknown", _hardQueueLimit, ns.Address @@ -500,7 +516,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._hasMovementRecord + var interval = ns._lastMovementRecordTime > 0 ? (int)(now - ns._lastMovementRecordTime) : -1; // -1 indicates first movement (no previous time) @@ -509,7 +525,6 @@ 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 @@ -529,19 +544,19 @@ public static class MovementThrottle // A large gap followed by a burst of packets = likely lag recovery, not speed hack ns._lastGapDuration = interval; - if (ns._movementLogging) + if (_debugLogging && mobile?.RawName != null) { 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, interval, _maxChainGap, avgRtt, ns.HasStableConnection, action + mobile.RawName, interval, _maxChainGap, avgRtt, ns.HasStableConnection, action ); } } - else if (ns._movementLogging) + else if (_debugLogging && mobile?.RawName != null) { - logger.Debug("[Movement] {Name}: SKIP recording (first in chain)", mobile); + logger.Debug("[Movement] {Name}: SKIP recording (first in chain)", mobile.RawName); } return; @@ -557,9 +572,12 @@ public static class MovementThrottle // the next real move's interval artificially short, inflating rate. if (cost == 0) { - if (ns._movementLogging) + if (_debugLogging && mobile?.RawName != null) { - logger.Debug("[Movement] {Name}: SKIP direction-only change (preserves interval measurement)", mobile); + logger.Debug( + "[Movement] {Name}: SKIP direction-only change (preserves interval measurement)", + mobile.RawName + ); } return; } @@ -595,16 +613,15 @@ public static class MovementThrottle } ns._lastMovementRecordTime = now; - ns._hasMovementRecord = true; // Debug logging - if (ns._movementLogging) + if (_debugLogging && mobile?.RawName != null) { 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, interval, cost, record.QueueDepth, + mobile.RawName, interval, cost, record.QueueDepth, flags, historyCount, _movementHistorySize, ns.AverageRtt ); } @@ -797,7 +814,7 @@ public static class MovementThrottle var averageRtt = ns.AverageRtt; // Detailed rate breakdown for debugging - if (ns._movementLogging) + if (_debugLogging) { logger.Debug("[MovementAnalysis] Rate={Rate:F3}, Samples={Samples}, RTT={RTT}ms", rate, sampleCount, averageRtt); @@ -960,19 +977,19 @@ public static class MovementThrottle var verdict = AnalyzeMovement(ns, out var rate, out var sampleCount, out var confidence); // Debug logging - if (ns._movementLogging) + if (_debugLogging && ns.Mobile?.RawName != null) { var (burstSize, _) = DetectRecentBurst(ns); - var probeStatus = ns._rttProbePending ? "pending" : "idle"; + var probeStatus = ns._rttProbeTime > 0 ? "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, rate, sampleCount, verdict, confidence, queueDepth, burstSize, ns._consecutiveHighRateSeconds + ns.Mobile.RawName, 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 ); } @@ -1008,11 +1025,11 @@ public static class MovementThrottle if (shouldNotify) { - if (ns._movementLogging) + if (_debugLogging) { logger.Debug( "[ALERT] {Urgency} - {Name}: rate={Rate:F3} verdict={Verdict} confidence={Confidence:P0}", - urgency, ns.Mobile, rate, verdict, confidence + urgency, ns.Mobile?.RawName, rate, verdict, confidence ); } NotifyStaff(ns, rate, sampleCount, confidence, verdict, urgency); @@ -1037,12 +1054,11 @@ public static class MovementThrottle var now = Core.TickCount; // Rate-limit notifications per player - if (ns._speedHackNotified && now - ns._lastSpeedHackNotification < _speedHackNotificationCooldown) + if (now - ns._lastSpeedHackNotification < _speedHackNotificationCooldown) { return; } - ns._speedHackNotified = true; ns._lastSpeedHackNotification = now; var mobile = ns.Mobile; @@ -1054,7 +1070,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, + mobile?.RawName ?? "Unknown", ns.Account?.Username ?? "Unknown", rate, sampleCount, @@ -1122,7 +1138,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 c6f28fcd1..1cc79429e 100644 --- a/Projects/Server/Network/NetState/NetState.Movement.cs +++ b/Projects/Server/Network/NetState/NetState.Movement.cs @@ -15,6 +15,7 @@ using System; using System.Collections.Generic; +using System.Diagnostics; using System.Runtime.InteropServices; using Server.Logging; @@ -69,24 +70,23 @@ 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 long _lastQueueDepthCheck = Core.TickCount; // Throttle depth check frequency + internal int _sustainedQueueDepth; // Tracks sustained high queue depth + internal long _lastQueueDepthCheck; // Throttle depth check frequency internal bool _hasQueuedMovements; // Fast check for Slice() // 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 (valid only when _hasMovementRecord) - internal bool _hasMovementRecord; // False until the first movement in a chain is seen + internal long _lastMovementRecordTime; // For calculating intervals // Detection state internal int _consecutiveHighRateSeconds; // Sustained detection counter - internal long _lastSpeedHackNotification; // Rate-limit notifications (valid only when _speedHackNotified) - internal bool _speedHackNotified; // False until the first notification is sent + internal long _lastSpeedHackNotification; // Rate-limit notifications internal int _lastGapDuration; // Duration of last gap > maxChainGap (for burst forgiveness) // Movement packet rate tracking (for speed hack detection) - internal long _movementWindowStart = Core.TickCount; // Start of current 1-second window + internal long _movementWindowStart; // Start of current 1-second window internal int _movementsInWindow; // Count in current window internal int _peakMovementRate; // Highest rate seen (packets/sec) @@ -100,9 +100,10 @@ public partial class NetState _nextMovementTime = Core.TickCount; _movementCredit = 0; _hasQueuedMovements = false; + _sustainedQueueDepth = 0; // Reset movement history - next movement starts a new chain - _hasMovementRecord = false; + _lastMovementRecordTime = 0; _movementHistoryIndex = 0; _movementHistoryFull = false; @@ -112,7 +113,7 @@ public partial class NetState _rttProbeInterval = RttProbeIntervalNormal; // Reset packet rate window - _movementWindowStart = Core.TickCount; + _movementWindowStart = 0; _movementsInWindow = 0; } @@ -164,19 +165,17 @@ public partial class NetState private const long MaxStableLatency = 200; // Max RTT (ms) for "stable" connection // RTT state - internal bool _rttProbePending; // True while waiting for a probe response - internal long _rttProbeTime; // When we sent the probe (valid only when _rttProbePending) + internal long _rttProbeTime; // When we sent the probe (0 = not waiting) + internal long _lastRtt; // Most recent RTT measurement 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 = Core.TickCount; // When to send next probe + internal long _nextRttProbe; // When to send next probe internal int _rttProbeInterval = RttProbeIntervalNormal; // Current probe interval - /// - /// Gets the most recent RTT measurement, or 0 if none has been recorded. - /// - public long LastRtt => _rttSampleCount > 0 ? _rttHistory[(_rttHistoryIndex - 1) & (RttHistorySize - 1)] : 0; + // High-resolution timestamp for RTT measurement (Stopwatch ticks, not game loop ticks) + private long _rttProbeTimestampHiRes; /// /// Sets the RTT probe interval based on suspicion level. @@ -207,22 +206,23 @@ public partial class NetState var now = Core.TickCount; // Don't send if we're still waiting for a response - if (_rttProbePending) + if (_rttProbeTime > 0) { // Timeout after 10 seconds - connection is probably dead or very laggy if (now - _rttProbeTime > 10000) { - _rttProbePending = false; + _rttProbeTime = 0; + _rttProbeTimestampHiRes = 0; } return; } // First probe: send immediately when player starts moving // Subsequent probes: send when interval has passed - if (now - _nextRttProbe >= 0) + if (_nextRttProbe == 0 || now >= _nextRttProbe) { - _rttProbePending = true; _rttProbeTime = now; + _rttProbeTimestampHiRes = Stopwatch.GetTimestamp(); _nextRttProbe = now + _rttProbeInterval + Utility.Random(RttProbeJitter); if (_movementLogging) @@ -242,9 +242,10 @@ public partial class NetState /// public void RecordRttMeasurement() { + var nowHiRes = Stopwatch.GetTimestamp(); var now = Core.TickCount; - if (!_rttProbePending) + if (_rttProbeTime <= 0) { // Not expecting a response (client-initiated version send) - ignore silently return; @@ -252,15 +253,19 @@ 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", - Account?.Username ?? _toString, rtt + "[RTT-Response] {Account}: {Rtt}ms (HiRes: {RttHiRes:F2}ms)", + Account?.Username ?? _toString, rtt, rttHiResUs / 1000.0 ); } - _rttProbePending = false; + _rttProbeTime = 0; + _rttProbeTimestampHiRes = 0; // Sanity check - RTT should be positive and reasonable if (rtt is <= 0 or > 10000) @@ -280,6 +285,7 @@ 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.Network.cs b/Projects/Server/Network/NetState/NetState.Network.cs index f69f92076..9cb7b85fb 100644 --- a/Projects/Server/Network/NetState/NetState.Network.cs +++ b/Projects/Server/Network/NetState/NetState.Network.cs @@ -87,7 +87,7 @@ public partial class NetState /// internal static bool IsIdle => _throttled.Count == 0 && _throttledPending.Count == 0 && - _flushPending.Count == 0 && _pendingDisconnects.Count == 0 && _disposed.Count == 0; + _flushPending.Count == 0 && _disposed.Count == 0; /// /// Gets the listening addresses that the server is bound to. diff --git a/Projects/Server/Network/NetState/NetState.cs b/Projects/Server/Network/NetState/NetState.cs index 04f1a5a76..7312603f7 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 HashSet Instances => _instances; + public static IReadOnlySet Instances => _instances; private readonly string _toString; private ClientVersion _version; @@ -109,6 +109,9 @@ public partial class NetState : IComparable, IValueLinkListNode, IValueLinkListNode Trades { get; private set; } + public List Trades { get; } public bool Seeded { get; set; } @@ -257,18 +260,8 @@ public partial class NetState : IComparable, IValueLinkListNode= 0; --i) { - if (Trades == null) - { - break; - } - if (i >= Trades.Count) { continue; @@ -287,18 +280,8 @@ public partial class NetState : IComparable, IValueLinkListNode= 0; --i) { - if (Trades != null) - { - break; - } - if (i < Trades.Count) { Trades[i].Cancel(); @@ -308,21 +291,11 @@ 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 bd37f4ff6..846e9e5dc 100644 --- a/Projects/Server/Serialization/BufferReader.cs +++ b/Projects/Server/Serialization/BufferReader.cs @@ -37,8 +37,6 @@ 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 ab53e2f2f..b811d68cb 100644 --- a/Projects/Server/Serialization/BufferWriter.cs +++ b/Projects/Server/Serialization/BufferWriter.cs @@ -384,7 +384,6 @@ 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) @@ -408,21 +407,6 @@ 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 c7323329f..a49f213eb 100644 --- a/Projects/Server/Serialization/GenericEntityPersistence.cs +++ b/Projects/Server/Serialization/GenericEntityPersistence.cs @@ -114,10 +114,9 @@ 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 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; + // 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; using var idx = new FileBufferWriter(Path.Combine(dir, $"{Name}.idx"), expectedIdxSize); var binPosition = 0L; @@ -143,10 +142,7 @@ public class GenericEntityPersistence : GenericPersistence, IGenericEntityPer binPosition += _selfLength; } - 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); + idx.Write(4); // Version // 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 @@ -498,18 +494,6 @@ 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); @@ -676,9 +660,6 @@ 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); @@ -686,10 +667,7 @@ public class GenericEntityPersistence : GenericPersistence, IGenericEntityPer byte* ptr = null; accessor.SafeMemoryMappedViewHandle.AcquirePointer(ref ptr); - var dataReader = new UnmanagedDataReader(ptr, accessor.Length, typesDb) - { - AnchoredTimeShift = _anchoredTimeShift - }; + var dataReader = new UnmanagedDataReader(ptr, accessor.Length, typesDb); Deserialize(dataReader); diff --git a/Projects/Server/Serialization/GenericPersistence.cs b/Projects/Server/Serialization/GenericPersistence.cs index 2268a86c6..5b8e79c29 100644 --- a/Projects/Server/Serialization/GenericPersistence.cs +++ b/Projects/Server/Serialization/GenericPersistence.cs @@ -98,13 +98,7 @@ public abstract class GenericPersistence : Persistence, IGenericSerializable byte* ptr = null; accessor.SafeMemoryMappedViewHandle.AcquirePointer(ref ptr); - 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 - }; + var dataReader = new UnmanagedDataReader(ptr, accessor.Length, typesDb); Deserialize(dataReader); error = dataReader.Position != fileLength diff --git a/Projects/Server/Serialization/IGenericReader.cs b/Projects/Server/Serialization/IGenericReader.cs index bfa302b39..0244a5166 100644 --- a/Projects/Server/Serialization/IGenericReader.cs +++ b/Projects/Server/Serialization/IGenericReader.cs @@ -43,12 +43,6 @@ 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 @@ -58,37 +52,6 @@ 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 4162655de..9c9563139 100644 --- a/Projects/Server/Serialization/IGenericWriter.cs +++ b/Projects/Server/Serialization/IGenericWriter.cs @@ -40,11 +40,7 @@ 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 aa3916ee9..7c8b5da1e 100644 --- a/Projects/Server/Serialization/UnmanagedDataReader.cs +++ b/Projects/Server/Serialization/UnmanagedDataReader.cs @@ -43,8 +43,6 @@ 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 fd69557b7..49d8d496a 100644 --- a/Projects/Server/Server.csproj +++ b/Projects/Server/Server.csproj @@ -37,10 +37,11 @@ - + - - + + + diff --git a/Projects/Server/World/World.cs b/Projects/Server/World/World.cs index c00fc85f6..2c9e6d216 100644 --- a/Projects/Server/World/World.cs +++ b/Projects/Server/World/World.cs @@ -93,21 +93,6 @@ 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; @@ -302,10 +287,6 @@ 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 5e4230b7c..a38af9c3d 100644 --- a/Projects/UOContent.Tests/Fixtures/TestServerInitializer.cs +++ b/Projects/UOContent.Tests/Fixtures/TestServerInitializer.cs @@ -103,7 +103,6 @@ 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/Items/TreasureChests/TreasureMapChestLiftTests.cs b/Projects/UOContent.Tests/Tests/Items/TreasureChests/TreasureMapChestLiftTests.cs deleted file mode 100644 index f9b8763e7..000000000 --- a/Projects/UOContent.Tests/Tests/Items/TreasureChests/TreasureMapChestLiftTests.cs +++ /dev/null @@ -1,111 +0,0 @@ -using Server; -using Server.Items; -using Server.Mobiles; -using Server.Tests; -using Xunit; - -namespace UOContent.Tests; - -[Collection("Sequential UOContent Tests")] -public class TreasureMapChestLiftTests -{ - // Coordinates chosen to avoid overlap with Tracking (1000-4000, 1000-4000) and - // DetectHidden (1000-2400, 500) test areas. - - [Fact] - public void PartialLift_MarksSplitRemainderAsLifted() - { - using var rng = new PredictableRandom(10); // RandomDouble() = 0.5, no spawn roll fires - var map = Map.Felucca; - var location = new Point3D(5000, 600, 0); - var player = CreatePlayerMobile(map, location); - var chest = new TreasureMapChest(1); - - try - { - chest.MoveToWorld(location, map); - chest.Locked = false; - - var gold = FindGold(chest, null); - Assert.NotNull(gold); - - player.Lift(gold, 1, out var rejected, out _); - Assert.False(rejected); - - // The stack split re-adds the remainder as a brand-new item. It must count as - // already lifted, otherwise every 1-coin pull grants a fresh guardian spawn roll. - var remainder = FindGold(chest, gold); - Assert.NotNull(remainder); - Assert.Contains(remainder, chest.Lifted); - Assert.Contains(gold, chest.Lifted); - } - finally - { - player.Holding?.Delete(); - player.Delete(); - chest.Delete(); - } - } - - [Fact] - public void ItemAddedAfterFill_IsMarkedLifted() - { - using var rng = new PredictableRandom(10); - var chest = new TreasureMapChest(1); - var packed = new Gold(500); - - try - { - // Anything entering the chest after the initial fill (packed-back gold, split - // remainders, GM drops) was never part of the original loot and must not - // grant spawn rolls when lifted back out. - chest.DropItem(packed); - - Assert.Contains(packed, chest.Lifted); - } - finally - { - chest.Delete(); - } - } - - [Fact] - public void OriginalFillLoot_IsNotMarkedLifted() - { - using var rng = new PredictableRandom(10); - var chest = new TreasureMapChest(1); - - try - { - // The original loot must stay roll-eligible for its first lift. - Assert.True(chest.Lifted == null || chest.Lifted.Count == 0); - } - finally - { - chest.Delete(); - } - } - - private static Gold FindGold(TreasureMapChest chest, Gold except) - { - var items = chest.Items; - - for (var i = 0; i < items.Count; i++) - { - if (items[i] is Gold gold && gold != except) - { - return gold; - } - } - - return null; - } - - private static PlayerMobile CreatePlayerMobile(Map map, Point3D location) - { - var mobile = new PlayerMobile(World.NewMobile); - mobile.DefaultMobileInit(); - mobile.MoveToWorld(location, map); - return mobile; - } -} diff --git a/Projects/UOContent.Tests/Tests/Mobiles/AI/AcquisitionTests.cs b/Projects/UOContent.Tests/Tests/Mobiles/AI/AcquisitionTests.cs deleted file mode 100644 index 3ee516dcf..000000000 --- a/Projects/UOContent.Tests/Tests/Mobiles/AI/AcquisitionTests.cs +++ /dev/null @@ -1,211 +0,0 @@ -using System; -using System.Collections.Generic; -using Server; -using Server.Mobiles; -using Xunit; - -namespace UOContent.Tests.Mobiles.AI; - -// Pins the reacquire gate and the AcquireOnApproachDelay gradient: every scan re-arms the -// full ReacquireDelay; enemy movement clamps the deadline to the approach delay (Zero = -// prodded scan); an illegal deadline self-heals. -[Collection("Sequential Pathfinding Tests")] -public class AcquisitionTests : IDisposable -{ - private readonly List _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 a952252a6..923f4aff9 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, 1, 2); + ai.WalkMobileRange(target, 1, false, 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, 1); + ai.MoveTo(target, false, 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, 1); + ai.MoveTo(target, true, 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, 1); + ai.MoveTo(target, false, 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, 1); + ai.MoveTo(target, false, 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 deleted file mode 100644 index db5225359..000000000 --- a/Projects/UOContent.Tests/Tests/Mobiles/AI/GuardFollowTests.cs +++ /dev/null @@ -1,96 +0,0 @@ -using System.Collections.Generic; -using Server; -using Server.Mobiles; -using Xunit; - -namespace UOContent.Tests.Mobiles.AI; - -// Guard-following may pathfind, so this shares the pathfinding collection. -[Collection("Sequential Pathfinding Tests")] -public class GuardFollowTests -{ - [Fact] - public void GuardFollow_StepsTowardMaster_AndRegistersMoveIntent() - { - var map = Map.Maps[1]; - Assert.NotNull(map); - map.GetAverageZ(1500, 1600, out _, out var z, out _); - - var master = new PlayerMobile(World.NewMobile); - master.DefaultMobileInit(); - master.MoveToWorld(new Point3D(1494, 1600, (sbyte)z), map); - - var pet = new PetTestStub(); - pet.MoveToWorld(new Point3D(1500, 1600, (sbyte)z), map); // 6 tiles east, open terrain - pet.SetControlMaster(master); - - var ai = pet.AIObject; - ai.AITimer?.Stop(); // drive manually - pet.ControlOrder = OrderType.Guard; - ai.AITimer?.Stop(); // the order change may restart the timer - - var start = pet.Location; - ai.NextMove = 0; - ai.Obey(); - - var moved = pet.Location != start; - var hasIntent = ai.TryGetMoveWake(out _); - var currentSpeed = pet.CurrentSpeed; - var currentMoveSpeed = pet.CurrentMoveSpeed; - - pet.Delete(); - master.Delete(); - - Assert.True(moved, "a guarding pet beyond guard range must step toward its master"); - // Without a move intent, guard-following only steps on the think grid. - Assert.True(hasIntent, "guard-following must register a move intent"); - - // AOS return sprint on both clocks; the per-step speed flip must not undo it. - Assert.Equal(0.1, currentSpeed); - Assert.Equal(0.1, currentMoveSpeed); - } - - [Fact] - public void GuardReturn_PreAOS_RunsActive() - { - var previous = Core.Expansion; - - try - { - Core.Expansion = Expansion.UOR; - - var map = Map.Maps[1]; - Assert.NotNull(map); - map.GetAverageZ(1500, 1600, out _, out var z, out _); - - var master = new PlayerMobile(World.NewMobile); - master.DefaultMobileInit(); - master.MoveToWorld(new Point3D(1494, 1600, (sbyte)z), map); - - var pet = new PetTestStub(); - pet.MoveToWorld(new Point3D(1500, 1600, (sbyte)z), map); - pet.SetControlMaster(master); - - var ai = pet.AIObject; - ai.AITimer?.Stop(); - pet.ControlOrder = OrderType.Guard; - ai.AITimer?.Stop(); - pet.SetCurrentSpeedToPassive(); // a stale passive state must not persist - - ai.NextMove = 0; - ai.Obey(); - - var currentSpeed = pet.CurrentSpeed; - - pet.Delete(); - master.Delete(); - - // No sprint pre-AOS: the return runs active. - Assert.Equal(0.2, currentSpeed); - } - finally - { - Core.Expansion = previous; - } - } -} diff --git a/Projects/UOContent.Tests/Tests/Mobiles/AI/GuardOrderTests.cs b/Projects/UOContent.Tests/Tests/Mobiles/AI/GuardOrderTests.cs deleted file mode 100644 index 7d572786a..000000000 --- a/Projects/UOContent.Tests/Tests/Mobiles/AI/GuardOrderTests.cs +++ /dev/null @@ -1,137 +0,0 @@ -using System; -using System.Collections.Generic; -using Server; -using Server.Mobiles; -using Xunit; - -namespace UOContent.Tests.Mobiles.AI; - -// A guarding pet fights without leaving the Guard order, retargets toward the master's -// closest aggressor, and stands down when nothing threatens. Scene: the open -// (1495..1500, 1600) Trammel segment; targets are adjacent so no pathfinding runs. -[Collection("Sequential UOContent Tests")] -public class GuardOrderTests : IDisposable -{ - private readonly List _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 deleted file mode 100644 index 7d9243a29..000000000 --- a/Projects/UOContent.Tests/Tests/Mobiles/AI/MoveSpeedTests.cs +++ /dev/null @@ -1,219 +0,0 @@ -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 deleted file mode 100644 index 217049b3d..000000000 --- a/Projects/UOContent.Tests/Tests/Mobiles/AI/PetPacingTests.cs +++ /dev/null @@ -1,222 +0,0 @@ -using System; -using System.Collections.Generic; -using Server; -using Server.Mobiles; -using Xunit; - -namespace UOContent.Tests.Mobiles.AI; - -// Pet order handlers own the speed clocks; combat chases and herding keep their own pacing. -[Collection("Sequential UOContent Tests")] -public class PetPacingTests : IDisposable -{ - private readonly List _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 deleted file mode 100644 index a421aaba2..000000000 --- a/Projects/UOContent.Tests/Tests/Mobiles/AI/RunFlagTests.cs +++ /dev/null @@ -1,162 +0,0 @@ -using System.Collections.Generic; -using Server; -using Server.Mobiles; -using Xunit; - -namespace UOContent.Tests.Mobiles.AI; - -// The Running bit is derived from the step pace: a step shorter than the client's walk -// interpolation (400ms on foot, 200ms mounted/flying) is flagged as a run. -[Collection("Sequential Pathfinding Tests")] -public class RunFlagTests : System.IDisposable -{ - private readonly List _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 deleted file mode 100644 index e02abece7..000000000 --- a/Projects/UOContent.Tests/Tests/Mobiles/LootingRightsTests.cs +++ /dev/null @@ -1,146 +0,0 @@ -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/Tests/Network/AutoDenylist/AutoDenylistTests.cs b/Projects/UOContent.Tests/Tests/Network/AutoDenylist/AutoDenylistTests.cs index ae926794d..3931eb3e4 100644 --- a/Projects/UOContent.Tests/Tests/Network/AutoDenylist/AutoDenylistTests.cs +++ b/Projects/UOContent.Tests/Tests/Network/AutoDenylist/AutoDenylistTests.cs @@ -20,8 +20,8 @@ using Xunit; namespace Server.Tests.Network.AutoDenylists; -// Static store, so every test resets it first and none may run alongside another. -// Addresses come from TEST-NET-2 (198.51.100.0/24). +// Static store, so every test resets it first. Addresses come from TEST-NET-2 (198.51.100.0/24). +// Sequential: the cap tests reach Sweep, which rents from STArrayPool, which is not thread-safe. [Collection("Sequential UOContent Tests")] public class AutoDenylistTests { @@ -62,11 +62,8 @@ public class AutoDenylistTests Assert.Equal(0, AutoDenylist.Count); } - // Not refreshed on purpose: it is what keeps insertion order equal to expiry order, so retiring lapsed - // entries costs the number expiring instead of the number held. A flooder whose hold lapses trips the - // rate limiter on its next attempt -- which runs ahead of the connection filters -- and is held again. [Fact] - public void Repeat_detection_does_not_extend_the_hold() + public void Repeat_detection_extends_the_hold() { Reset(); var ip = IPAddress.Parse("198.51.100.13"); @@ -74,72 +71,8 @@ public class AutoDenylistTests AutoDenylist.Hold(ip, BanReasons.SilentConnect, Now); AutoDenylist.Hold(ip, BanReasons.SilentConnect, Now + DurationMs - 1); - Assert.Equal(1, AutoDenylist.Count); // no duplicate - Assert.True(AutoDenylist.IsDenied(ip, Now + DurationMs - 1)); - Assert.False(AutoDenylist.IsDenied(ip, Now + DurationMs + 1)); // lapses from the FIRST detection - } - - // The ring carries the expiry and the set carries membership; if they ever disagree, an address is - // either denied forever or retired early. - [Fact] - public void Ring_and_set_stay_in_step() - { - Reset(maxEntries: 4); - - for (var i = 0; i < 8; i++) - { - AutoDenylist.Hold(IPAddress.Parse($"198.51.100.{70 + i}"), BanReasons.InvalidSeed, Now); - } - - Assert.Equal(4, AutoDenylist.Count); - Assert.Equal(AutoDenylist.Count, AutoDenylist.RingCount); - - AutoDenylist.Release(IPAddress.Parse("198.51.100.71")); - Assert.Equal(3, AutoDenylist.Count); - Assert.Equal(AutoDenylist.Count, AutoDenylist.RingCount); - - AutoDenylist.Drain(Now + DurationMs + 1); - Assert.Equal(0, AutoDenylist.Count); - Assert.Equal(0, AutoDenylist.RingCount); - } - - // The ring grows in doublings but is capped at maxEntries, which is not a power of two. Filling exactly - // to it must land on the last slot rather than off the end. - [Fact] - public void Ring_fills_exactly_to_a_non_power_of_two_cap() - { - Reset(maxEntries: 100); - - for (var i = 0; i < 120; i++) - { - AutoDenylist.Hold(IPAddress.Parse($"198.51.100.{i}"), BanReasons.InvalidSeed, Now); - } - - Assert.Equal(100, AutoDenylist.Count); - Assert.Equal(100, AutoDenylist.RingCount); - - // And the whole ring still drains, so no slot was stranded by a wrapped write. - AutoDenylist.Drain(Now + DurationMs + 1); - Assert.Equal(0, AutoDenylist.Count); - Assert.Equal(0, AutoDenylist.RingCount); - } - - // Releasing leaves no ring record behind, so a re-detection is not retired by the old one. - [Fact] - public void Release_then_re_hold_is_not_retired_by_the_stale_record() - { - Reset(); - var ip = IPAddress.Parse("198.51.100.15"); - - AutoDenylist.Hold(ip, BanReasons.RateLimit, Now); - AutoDenylist.Release(ip); - - var later = Now + DurationMs - 1; - AutoDenylist.Hold(ip, BanReasons.RateLimit, later); - - // The first hold's expiry has passed; the second must survive it. - Assert.True(AutoDenylist.IsDenied(ip, Now + DurationMs + 1)); - Assert.Equal(1, AutoDenylist.RingCount); + Assert.True(AutoDenylist.IsDenied(ip, Now + DurationMs + 1)); // would have lapsed without the second + Assert.Equal(1, AutoDenylist.Count); // and did not add a duplicate } [Fact] diff --git a/Projects/UOContent.Tests/Tests/Network/BanExemptionsTests.cs b/Projects/UOContent.Tests/Tests/Network/BanExemptionsTests.cs index b38364373..7e930fbdf 100644 --- a/Projects/UOContent.Tests/Tests/Network/BanExemptionsTests.cs +++ b/Projects/UOContent.Tests/Tests/Network/BanExemptionsTests.cs @@ -28,15 +28,15 @@ public class BanExemptionsTests private static readonly IPAddress _listed = IPAddress.Parse("192.0.2.10"); private static readonly IPAddress _unlisted = IPAddress.Parse("192.0.2.11"); - private static void WithManualAllowlist(string contents) => - ManualAllowlist.LoadForTesting(BlocklistSnapshot.Build(Encoding.ASCII.GetBytes(contents), out _, out _)); + private static void WithFileAllowlist(string contents) => + FileAllowlist.LoadForTesting(BlocklistSnapshot.Build(Encoding.ASCII.GetBytes(contents), out _, out _)); - private static void WithEmptyManualAllowlist() => ManualAllowlist.LoadForTesting(BlocklistSnapshot.Empty); + private static void WithEmptyFileAllowlist() => FileAllowlist.LoadForTesting(BlocklistSnapshot.Empty); [Fact] - public void Manual_allowlist_exempts_behavioral_contributions() + public void File_allowlist_exempts_behavioral_contributions() { - WithManualAllowlist("192.0.2.10"); + WithFileAllowlist("192.0.2.10"); // Subtracting from the blocklist does nothing for behavioural detections, which never consult it. Assert.True(BanExemptions.IsExempt(_listed, BanReasons.ForeignProtocol, NeverCalled)); @@ -46,10 +46,10 @@ public class BanExemptionsTests } [Fact] - public void Manual_allowlist_covers_cidr_entries() + public void File_allowlist_covers_cidr_entries() { // Carve-outs are CIDRs, so a shared-CGNAT player is only covered if ranges work here. - WithManualAllowlist("192.0.2.0/24"); + WithFileAllowlist("192.0.2.0/24"); Assert.True(BanExemptions.IsExempt(_listed, BanReasons.RateLimit, NeverCalled)); Assert.True(BanExemptions.IsExempt(IPAddress.Parse("192.0.2.254"), BanReasons.RateLimit, NeverCalled)); @@ -57,9 +57,9 @@ public class BanExemptionsTests } [Fact] - public void Manual_bans_are_never_exempt_even_when_allowlisted() + public void Manual_bans_are_never_exempt_even_when_file_allowlisted() { - WithManualAllowlist("192.0.2.10"); + WithFileAllowlist("192.0.2.10"); // An explicit decision outranks the operator's own carve-out, and must not cost a strike. Assert.False(BanExemptions.IsExempt(_listed, BanReasons.Manual, NeverCalled)); @@ -68,16 +68,16 @@ public class BanExemptionsTests [Fact] public void Unopted_reasons_are_never_exempt() { - WithManualAllowlist("192.0.2.10"); + WithFileAllowlist("192.0.2.10"); Assert.False(BanExemptions.IsExempt(_listed, BanReasons.Blocklist, NeverCalled)); Assert.False(BanExemptions.IsExempt(_listed, "some-future-reason", NeverCalled)); } [Fact] - public void Manual_allowlist_does_not_spend_the_earned_lists_strikes() + public void File_allowlist_does_not_spend_the_earned_lists_strikes() { - WithManualAllowlist("192.0.2.10"); + WithFileAllowlist("192.0.2.10"); // Unconditional, so the revocable list must not be consulted -- that would burn a strike. Assert.True(BanExemptions.IsExempt(_listed, BanReasons.RateLimit, NeverCalled)); @@ -86,7 +86,7 @@ public class BanExemptionsTests [Fact] public void Falls_through_to_the_login_allowlist_when_not_file_listed() { - WithEmptyManualAllowlist(); + WithEmptyFileAllowlist(); var consulted = 0; @@ -107,7 +107,7 @@ public class BanExemptionsTests [Fact] public void Null_address_is_never_exempt() { - WithEmptyManualAllowlist(); + WithEmptyFileAllowlist(); Assert.False(BanExemptions.IsExempt(null, BanReasons.RateLimit, NeverCalled)); } diff --git a/Projects/UOContent.Tests/Tests/Network/Bans/Blocklist/BlocklistConfigurationTests.cs b/Projects/UOContent.Tests/Tests/Network/Bans/Blocklist/BlocklistConfigurationTests.cs index 3d81c8d87..6464a1bed 100644 --- a/Projects/UOContent.Tests/Tests/Network/Bans/Blocklist/BlocklistConfigurationTests.cs +++ b/Projects/UOContent.Tests/Tests/Network/Bans/Blocklist/BlocklistConfigurationTests.cs @@ -30,7 +30,6 @@ public class BlocklistConfigurationTests { var original = new BlocklistSettings { - Enabled = true, File = "D:/shared/ip-blocklist.txt", ReloadInterval = TimeSpan.FromMinutes(5), ReportHits = false, @@ -40,7 +39,6 @@ public class BlocklistConfigurationTests var json = JsonConfig.Serialize(original); - Assert.Contains("\"enabled\"", json); Assert.Contains("\"file\"", json); Assert.Contains("\"reloadInterval\"", json); Assert.Contains("\"reportHits\"", json); @@ -50,7 +48,6 @@ public class BlocklistConfigurationTests var restored = JsonSerializer.Deserialize(json, JsonConfig.DefaultOptions); Assert.NotNull(restored); - Assert.Equal(original.Enabled, restored.Enabled); Assert.Equal(original.File, restored.File); Assert.Equal(original.ReloadInterval, restored.ReloadInterval); Assert.Equal(original.ReportHits, restored.ReportHits); @@ -58,13 +55,6 @@ public class BlocklistConfigurationTests Assert.Equal(original.PromoteSuppression, restored.PromoteSuppression); } - // The point of the flag: a shard that never opts in must not start the reload poll. - [Fact] - public void Blocklist_is_off_by_default() - { - Assert.False(new BlocklistSettings().Enabled); - } - // The generator (tools/Export-IpBlocklist.ps1) writes to this path by default; if one side moves // without the other, a shard silently enforces nothing. [Fact] diff --git a/Projects/UOContent.Tests/Tests/Network/ManualAllowlist/ManualAllowlistConfigurationTests.cs b/Projects/UOContent.Tests/Tests/Network/ManualAllowlist/ManualAllowlistConfigurationTests.cs deleted file mode 100644 index 1146414b5..000000000 --- a/Projects/UOContent.Tests/Tests/Network/ManualAllowlist/ManualAllowlistConfigurationTests.cs +++ /dev/null @@ -1,66 +0,0 @@ -/************************************************************************* - * ModernUO * - * Copyright 2019-2026 - ModernUO Development Team * - * Email: hi@modernuo.com * - * File: ManualAllowlistConfigurationTests.cs * - * * - * This program is free software: you can redistribute it and/or modify * - * it under the terms of the GNU General Public License as published by * - * the Free Software Foundation, either version 3 of the License, or * - * (at your option) any later version. * - * * - * You should have received a copy of the GNU General Public License * - * along with this program. If not, see . * - *************************************************************************/ - -using System; -using System.Text.Json; -using Server.Json; -using Server.Network.Bans; -using Xunit; - -namespace Server.Tests.Network.ManualAllowlists; - -public class ManualAllowlistConfigurationTests -{ - // Locks the JsonConfig casing contract: JsonConfig's options are case-SENSITIVE, so every settings - // member must carry an explicit [JsonPropertyName("camelCase")] or it silently binds nothing. - [Fact] - public void ManualAllowlistSettings_RoundTripsThroughJsonConfig() - { - var original = new ManualAllowlistSettings - { - Enabled = true, - Files = ["D:/shared/ip-allowlist*.txt"], - ReloadInterval = TimeSpan.FromMinutes(5) - }; - - var json = JsonConfig.Serialize(original); - - Assert.Contains("\"enabled\"", json); - Assert.Contains("\"files\"", json); - Assert.Contains("\"reloadInterval\"", json); - - var restored = JsonSerializer.Deserialize(json, JsonConfig.DefaultOptions); - - Assert.NotNull(restored); - Assert.Equal(original.Enabled, restored.Enabled); - Assert.Equal(original.Files, restored.Files); - Assert.Equal(original.ReloadInterval, restored.ReloadInterval); - } - - // The point of the flag: a shard that never opts in must not start the reload poll. - [Fact] - public void Manual_allowlist_is_off_by_default() - { - Assert.False(new ManualAllowlistSettings().Enabled); - } - - // The generator creates ip-allowlist.txt beside the blocklist; the wildcard is what picks up a - // carve-out file (-RefreshCarveouts writes ip-allowlist-starlink.txt) with no config edit. - [Fact] - public void Default_pattern_matches_the_generator_output_path() - { - Assert.Equal(["Configuration/ip-allowlist*.txt"], new ManualAllowlistSettings().Files); - } -} diff --git a/Projects/UOContent.Tests/UOContent.Tests.csproj b/Projects/UOContent.Tests/UOContent.Tests.csproj index 1c73f6eb4..712246f72 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,6 +15,7 @@ + diff --git a/Projects/UOContent/Engines/Bulk Orders/Books/BOBFilter.cs b/Projects/UOContent/Engines/Bulk Orders/Books/BOBFilter.cs index f63e4f8d4..962c3d8e3 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/CannedEvil/ChampionSkullBrazier.cs b/Projects/UOContent/Engines/CannedEvil/ChampionSkullBrazier.cs index 2632fd954..9ff8dc633 100755 --- a/Projects/UOContent/Engines/CannedEvil/ChampionSkullBrazier.cs +++ b/Projects/UOContent/Engines/CannedEvil/ChampionSkullBrazier.cs @@ -33,13 +33,17 @@ public partial class ChampionSkullBrazier : AddonComponent [SerializedCommandProperty(AccessLevel.GameMaster)] private ChampionSkullPlatform _platform; - [SerializableField(2, fieldChanged: nameof(OnSkullChanged))] - [SerializedCommandProperty(AccessLevel.GameMaster)] - private Item _skull; - - private void OnSkullChanged(Item oldValue, Item newValue) + [SerializableProperty(2)] + [CommandProperty(AccessLevel.GameMaster)] + public Item Skull { - _platform?.Validate(); + get => _skull; + set + { + _skull = value; + this.MarkDirty(); + _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 3a024576b..a26bcc0f9 100755 --- a/Projects/UOContent/Engines/CannedEvil/ChampionSpawn.cs +++ b/Projects/UOContent/Engines/CannedEvil/ChampionSpawn.cs @@ -18,7 +18,6 @@ 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; @@ -28,44 +27,16 @@ using Server.Logging; namespace Server.Engines.CannedEvil; -[SerializationGenerator(11, false)] +[SerializationGenerator(10, 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; - [AnchoredDateTime] + [DeltaDateTime] [SerializableField(2)] [SerializedCommandProperty(AccessLevel.GameMaster)] private DateTime _nextProximityTime; @@ -125,7 +96,7 @@ public partial class ChampionSpawn : Item [SerializedCommandProperty(AccessLevel.GameMaster)] private TimeSpan _expireDelay; - [AnchoredDateTime] + [DeltaDateTime] [SerializableField(20)] [SerializedCommandProperty(AccessLevel.GameMaster)] private DateTime _expireTime; @@ -138,7 +109,7 @@ public partial class ChampionSpawn : Item [SerializedCommandProperty(AccessLevel.GameMaster)] private TimeSpan _restartDelay; - [AnchoredDateTime] + [DeltaDateTime] [SerializableField(23, setter: "private")] [SerializedCommandProperty(AccessLevel.GameMaster)] private DateTime _restartTime; @@ -232,38 +203,47 @@ public partial class ChampionSpawn : Item } } - [SerializableField(3, allowFieldChange: nameof(AllowMaxLevelChange))] - [SerializedCommandProperty(AccessLevel.GameMaster, AccessLevel.Administrator)] - private int _maxLevel; - - private bool AllowMaxLevelChange(ref int value) + [SerializableProperty(3)] + [CommandProperty(AccessLevel.GameMaster, AccessLevel.Administrator)] + public int MaxLevel { - value = Math.Clamp(value, 0, 18); - return true; + get => _maxLevel; + set => _maxLevel = Math.Clamp(value, 0, 18); } - [SerializableField(9, fieldChanged: nameof(OnSpawnAreaChanged))] - [SerializedCommandProperty(AccessLevel.GameMaster)] - [InvalidateProperties] - private Rectangle2D _spawnArea; - - private void OnSpawnAreaChanged(Rectangle2D oldValue, Rectangle2D newValue) + [SerializableProperty(9)] + [CommandProperty(AccessLevel.GameMaster)] + public Rectangle2D SpawnArea { - UpdateRegion(); - } - - [SerializableField(11, fieldChanged: nameof(OnKillsChanged))] - [SerializedCommandProperty(AccessLevel.GameMaster)] - [InvalidateProperties] - private int _kills; - - private void OnKillsChanged(int oldValue, int newValue) - { - var n = _kills / (double)MaxKills; - var p = (int)(n * 100); - if (p < 90) + get => _spawnArea; + set { - SetWhiteSkullCount(p / 20); + _spawnArea = value; + this.MarkDirty(); + InvalidateProperties(); + UpdateRegion(); + } + } + + [SerializableProperty(11)] + [CommandProperty(AccessLevel.GameMaster)] + public int Kills + { + get => _kills; + set + { + _kills = value; + this.MarkDirty(); + + var n = _kills / (double)MaxKills; + var p = (int)(n * 100); + + if (p < 90) + { + SetWhiteSkullCount(p / 20); + } + + InvalidateProperties(); } } @@ -1182,6 +1162,11 @@ 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 644e2b965..bfd00c0bf 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 88acd587e..a5426e7a5 100644 --- a/Projects/UOContent/Engines/CannedEvil/StarRoomGate.cs +++ b/Projects/UOContent/Engines/CannedEvil/StarRoomGate.cs @@ -3,21 +3,15 @@ using ModernUO.Serialization; namespace Server.Items; -[SerializationGenerator(2, false)] +[SerializationGenerator(1, 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; - [AnchoredDateTime] + [DeltaDateTime] [SerializableField(1)] private DateTime _decayTime; diff --git a/Projects/UOContent/Engines/ConPVP/Games/BombingRun.cs b/Projects/UOContent/Engines/ConPVP/Games/BombingRun.cs index 6ab4fffea..f32168cbf 100644 --- a/Projects/UOContent/Engines/ConPVP/Games/BombingRun.cs +++ b/Projects/UOContent/Engines/ConPVP/Games/BombingRun.cs @@ -830,9 +830,10 @@ public partial class BRBomb : Item [SerializationGenerator(0, false)] public partial class BRGoal : BaseAddon { - [SerializableField(0, fieldChanged: nameof(OnNorthChanged))] + [SerializableField(0)] 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 6aec6f328..6f73a0949 100644 --- a/Projects/UOContent/Engines/ConPVP/Games/KingOfTheHill.cs +++ b/Projects/UOContent/Engines/ConPVP/Games/KingOfTheHill.cs @@ -252,9 +252,10 @@ public partial class HillOfTheKing : Item public partial class KHBoard : Item { [SerializedCommandProperty(AccessLevel.GameMaster)] - [SerializableField(0, fieldChanged: nameof(OnControllerChanged))] + [SerializableField(0)] 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 603b52ef3..fa712de33 100644 --- a/Projects/UOContent/Engines/ConPVP/Trophy.cs +++ b/Projects/UOContent/Engines/ConPVP/Trophy.cs @@ -64,13 +64,17 @@ public partial class Trophy : Item UpdateStyle(); } - [SerializableField(1, fieldChanged: nameof(OnRankChanged))] - [SerializedCommandProperty(AccessLevel.GameMaster)] - private TrophyRank _rank; - - private void OnRankChanged(TrophyRank oldValue, TrophyRank newValue) + [SerializableProperty(1)] + [CommandProperty(AccessLevel.GameMaster)] + public TrophyRank Rank { - UpdateStyle(); + get => _rank; + set + { + _rank = value; + UpdateStyle(); + this.MarkDirty(); + } } private void Deserialize(IGenericReader reader, int version) diff --git a/Projects/UOContent/Engines/Craft/Core/CraftItem.cs b/Projects/UOContent/Engines/Craft/Core/CraftItem.cs index e99760bbc..5a31aec3c 100644 --- a/Projects/UOContent/Engines/Craft/Core/CraftItem.cs +++ b/Projects/UOContent/Engines/Craft/Core/CraftItem.cs @@ -1451,9 +1451,6 @@ namespace Server.Engines.Craft if (item != null) { - // Stamped here, not in OnCraft: most craftables do not implement ICraftable. - item.PlayerConstructed = true; - if (item is ICraftable craftable) { endquality = craftable.OnCraft(quality, makersMark, from, craftSystem, typeRes, tool, this, resHue); @@ -1745,9 +1742,6 @@ namespace Server.Engines.Craft if (item != null) { - // Stamped here, not in OnCraft: most craftables do not implement ICraftable. - item.PlayerConstructed = true; - if (item is ICraftable craftable) { endquality = craftable.OnCraft(quality, makersMark, from, craftSystem, typeRes, tool, this, resHue); diff --git a/Projects/UOContent/Engines/Ethics/Core/Player.cs b/Projects/UOContent/Engines/Ethics/Core/Player.cs index d8aad9eb3..f21fe9eb9 100644 --- a/Projects/UOContent/Engines/Ethics/Core/Player.cs +++ b/Projects/UOContent/Engines/Ethics/Core/Player.cs @@ -5,20 +5,9 @@ using Server.Mobiles; namespace Server.Ethics; [PropertyObject] -[SerializationGenerator(2)] +[SerializationGenerator(1)] 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; @@ -38,7 +27,7 @@ public partial class Player : EthicsEntity [SerializedCommandProperty(AccessLevel.GameMaster, AccessLevel.Administrator)] private Mobile _familiar; - [AnchoredDateTime] + [DeltaDateTime] [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 f0fb2386f..32e8566cb 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, 1)) + else if (!m_Mobile.InRange( m, m_Mobile.RangeFight > 2 ? m_Mobile.RangeFight : 2 ) && !MoveTo( m, true, 1 )) OnFailedMove(); } else {*/ if (!Mobile.InRange(m, Mobile.RangeFight)) { - if (!MoveTo(m, 1)) + if (!MoveTo(m, true, 1)) { OnFailedMove(); } diff --git a/Projects/UOContent/Engines/Harvest/Core/HarvestSystem.cs b/Projects/UOContent/Engines/Harvest/Core/HarvestSystem.cs index b67665d04..b82040134 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 { - bonusItem?.Delete(); + item.Delete(); } } diff --git a/Projects/UOContent/Engines/Khaldun/PuzzleChest.cs b/Projects/UOContent/Engines/Khaldun/PuzzleChest.cs index 53932c8f0..4548522b4 100644 --- a/Projects/UOContent/Engines/Khaldun/PuzzleChest.cs +++ b/Projects/UOContent/Engines/Khaldun/PuzzleChest.cs @@ -162,15 +162,10 @@ namespace Server.Items } } - [SerializationGenerator(1)] + [SerializationGenerator(0)] public partial class PuzzleChestSolutionAndTime : PuzzleChestSolution { - private void MigrateFrom(V0Content content) - { - _when = content.When; - } - - [AnchoredDateTime] + [DeltaDateTime] [SerializableField(0)] private DateTime _when; @@ -242,12 +237,16 @@ namespace Server.Items } } - [SerializableField(0, fieldChanged: nameof(OnSolutionChanged))] - private PuzzleChestSolution _solution; - - private void OnSolutionChanged(PuzzleChestSolution oldValue, PuzzleChestSolution newValue) + [SerializableProperty(0)] + public PuzzleChestSolution Solution { - InitHints(); + get => _solution; + set + { + _solution = value; + InitHints(); + this.MarkDirty(); + } } public PuzzleChestCylinder FirstHint diff --git a/Projects/UOContent/Engines/ML Quests/MLQuestPersistence.cs b/Projects/UOContent/Engines/ML Quests/MLQuestPersistence.cs index d3e39c09f..5d846d595 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(3); // version + writer.Write(2); // 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 77c3350f5..2838c7736 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.WriteAnchoredTime(EndTime); + writer.WriteDeltaTime(EndTime); } else { @@ -135,7 +135,7 @@ namespace Server.Engines.MLQuests.Objectives { if (reader.ReadBool()) { - var endTime = version >= 3 ? reader.ReadAnchoredTime() : reader.ReadDeltaTime(); + var endTime = reader.ReadDeltaTime(); if (objInstance != null) { diff --git a/Projects/UOContent/Engines/Pathing/PathFollower.cs b/Projects/UOContent/Engines/Pathing/PathFollower.cs index 04aaf0148..3a0a56fa5 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(int range) + public bool Follow(bool run, int range) { var goal = GetGoalLocation(); Direction d; @@ -97,13 +97,13 @@ public class PathFollower if (!(Enabled && m_Path.Success)) { - d = m_From.GetDirectionTo(goal); + d = m_From.GetDirectionTo(goal, run); 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); + d = m_From.GetDirectionTo(m_Next, run); 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 5f66b1a7a..60451f53b 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,7 +82,6 @@ public partial class PlantItem : Item, ISecurable [CommandProperty(AccessLevel.GameMaster)] [SerializableProperty(1)] - [SaveFlag(nameof(ShouldSerializePlantStatus))] public PlantStatus PlantStatus { get => _plantStatus; @@ -121,38 +120,53 @@ public partial class PlantItem : Item, ISecurable } } + [SerializableFieldSaveFlag(1)] private bool ShouldSerializePlantStatus() => _plantStatus != PlantStatus.BowlOfDirt; - [SerializableField(2, fieldChanged: nameof(OnPlantTypeChanged))] - [SaveFlag(nameof(ShouldSerializePlantType))] - [SerializedCommandProperty(AccessLevel.GameMaster)] - private PlantType _plantType; - - private void OnPlantTypeChanged(PlantType oldValue, PlantType newValue) + [SerializableProperty(2)] + [CommandProperty(AccessLevel.GameMaster)] + public PlantType PlantType { - Update(); + get => _plantType; + set + { + _plantType = value; + Update(); + } } + [SerializableFieldSaveFlag(2)] private bool ShouldSerializePlantType() => (int)_plantType != 0; - [SerializableField(3, fieldChanged: nameof(OnPlantHueChanged))] - [SaveFlag(nameof(ShouldSerializePlantHue))] - [SerializedCommandProperty(AccessLevel.GameMaster)] - private PlantHue _plantHue; - - private void OnPlantHueChanged(PlantHue oldValue, PlantHue newValue) + [SerializableProperty(3)] + [CommandProperty(AccessLevel.GameMaster)] + public PlantHue PlantHue { - Update(); + get => _plantHue; + set + { + _plantHue = value; + Update(); + } } + [SerializableFieldSaveFlag(3)] private bool ShouldSerializePlantHue() => _plantHue != PlantHue.None; - [SerializableField(4)] - [SaveFlag(nameof(ShouldSerializeShowType))] - [SerializedCommandProperty(AccessLevel.GameMaster)] - [InvalidateProperties] - private bool _showType; + [SerializableProperty(4)] + [CommandProperty(AccessLevel.GameMaster)] + public bool ShowType + { + get => _showType; + set + { + _showType = value; + InvalidateProperties(); + this.MarkDirty(); + } + } + [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 4b202ef63..29b9f48be 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,42 +97,45 @@ namespace Server.Engines.Plants public bool IsFullWater => _water >= 4; - [SerializableField(3, fieldChanged: nameof(OnWaterChanged), allowFieldChange: nameof(AllowWaterChange))] - [SaveFlag(nameof(ShouldSerializeWater))] - private int _water; - - private bool AllowWaterChange(ref int value) + [SerializableProperty(3)] + public int Water { - value = Math.Clamp(value, 0, 4); - return true; - } - - private void OnWaterChanged(int oldValue, int newValue) - { - Plant.InvalidateProperties(); + get => _water; + set + { + _water = Math.Clamp(value, 0, 4); + Plant.InvalidateProperties(); + MarkDirty(); + } } + [SerializableFieldSaveFlag(3)] private bool ShouldSerializeWater() => _water != 0; - [SerializableField(4, fieldChanged: nameof(OnHitsChanged), allowFieldChange: nameof(AllowHitsChange))] - [SaveFlag(nameof(ShouldSerializeHits))] - private int _hits; - - private bool AllowHitsChange(ref int value) + [SerializableProperty(4)] + public int Hits { - value = Math.Clamp(value, 0, MaxHits); - return true; - } - - private void OnHitsChanged(int oldValue, int newValue) - { - if (_hits == 0) + get => _hits; + set { - Plant.Die(); + if (_hits == value) + { + return; + } + + _hits = Math.Clamp(value, 0, MaxHits); + + if (_hits == 0) + { + Plant.Die(); + } + + Plant.InvalidateProperties(); + MarkDirty(); } - Plant.InvalidateProperties(); } + [SerializableFieldSaveFlag(4)] private bool ShouldSerializeHits() => _hits != 0; public int MaxHits => 10 + (int)Plant.PlantStatus * 2; @@ -146,108 +149,124 @@ namespace Server.Engines.Plants _ => PlantHealth.Vibrant }; - [SerializableField(5, allowFieldChange: nameof(AllowInfestationChange))] - [SaveFlag(nameof(ShouldSerializeInfestation))] - private int _infestation; - - private bool AllowInfestationChange(ref int value) + [SerializableProperty(5)] + public int Infestation { - value = Math.Clamp(value, 0, 2); - return true; + get => _infestation; + set + { + _infestation = Math.Clamp(value, 0, 2); + MarkDirty(); + } } + [SerializableFieldSaveFlag(5)] private bool ShouldSerializeInfestation() => _infestation != 0; - [SerializableField(6, allowFieldChange: nameof(AllowFungusChange))] - [SaveFlag(nameof(ShouldSerializeFungus))] - private int _fungus; - - private bool AllowFungusChange(ref int value) + [SerializableProperty(6)] + public int Fungus { - value = Math.Clamp(value, 0, 2); - return true; + get => _fungus; + set + { + _fungus = Math.Clamp(value, 0, 2); + MarkDirty(); + } } + [SerializableFieldSaveFlag(6)] private bool ShouldSerializeFungus() => _fungus != 0; - [SerializableField(7, allowFieldChange: nameof(AllowPoisonChange))] - [SaveFlag(nameof(ShouldSerializePoison))] - private int _poison; - - private bool AllowPoisonChange(ref int value) + [SerializableProperty(7)] + public int Poison { - value = Math.Clamp(value, 0, 2); - return true; + get => _poison; + set + { + _poison = Math.Clamp(value, 0, 2); + MarkDirty(); + } } + [SerializableFieldSaveFlag(7)] private bool ShouldSerializePoison() => _poison != 0; - [SerializableField(8, allowFieldChange: nameof(AllowDiseaseChange))] - [SaveFlag(nameof(ShouldSerializeDisease))] - private int _disease; - - private bool AllowDiseaseChange(ref int value) + [SerializableProperty(8)] + public int Disease { - value = Math.Clamp(value, 0, 2); - return true; + get => _disease; + set + { + _disease = Math.Clamp(value, 0, 2); + MarkDirty(); + } } + [SerializableFieldSaveFlag(8)] private bool ShouldSerializeDisease() => _disease != 0; public bool IsFullPoisonPotion => _poisonPotion >= 2; - [SerializableField(9, allowFieldChange: nameof(AllowPoisonPotionChange))] - [SaveFlag(nameof(ShouldSerializePoisonPotion))] - private int _poisonPotion; - - private bool AllowPoisonPotionChange(ref int value) + [SerializableProperty(9)] + public int PoisonPotion { - value = Math.Clamp(value, 0, 2); - return true; + get => _poisonPotion; + set + { + _poisonPotion = Math.Clamp(value, 0, 2); + MarkDirty(); + } } + [SerializableFieldSaveFlag(9)] private bool ShouldSerializePoisonPotion() => _poisonPotion != 0; public bool IsFullCurePotion => _curePotion >= 2; - [SerializableField(10, allowFieldChange: nameof(AllowCurePotionChange))] - [SaveFlag(nameof(ShouldSerializeCurePotion))] - private int _curePotion; - - private bool AllowCurePotionChange(ref int value) + [SerializableProperty(10)] + public int CurePotion { - value = Math.Clamp(value, 0, 2); - return true; + get => _curePotion; + set + { + _curePotion = Math.Clamp(value, 0, 2); + MarkDirty(); + } } + [SerializableFieldSaveFlag(10)] private bool ShouldSerializeCurePotion() => _curePotion != 0; public bool IsFullHealPotion => _healPotion >= 2; - [SerializableField(11, allowFieldChange: nameof(AllowHealPotionChange))] - [SaveFlag(nameof(ShouldSerializeHealPotion))] - private int _healPotion; - - private bool AllowHealPotionChange(ref int value) + [SerializableProperty(11)] + public int HealPotion { - value = Math.Clamp(value, 0, 2); - return true; + get => _healPotion; + set + { + _healPotion = Math.Clamp(value, 0, 2); + MarkDirty(); + } } + [SerializableFieldSaveFlag(11)] private bool ShouldSerializeHealPotion() => _healPotion != 0; public bool IsFullStrengthPotion => _strengthPotion >= 2; - [SerializableField(12, allowFieldChange: nameof(AllowStrengthPotionChange))] - [SaveFlag(nameof(ShouldSerializeStrengthPotion))] - private int _strengthPotion; - - private bool AllowStrengthPotionChange(ref int value) + [SerializableProperty(12)] + public int StrengthPotion { - value = Math.Clamp(value, 0, 2); - return true; + get => _strengthPotion; + set + { + _strengthPotion = Math.Clamp(value, 0, 2); + MarkDirty(); + } } + [SerializableFieldSaveFlag(12)] private bool ShouldSerializeStrengthPotion() => _strengthPotion != 0; public bool HasMaladies => Infestation > 0 || Fungus > 0 || Poison > 0 || Disease > 0 || Water != 2; @@ -255,7 +274,6 @@ 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; @@ -266,10 +284,10 @@ namespace Server.Engines.Plants } } + [SerializableFieldSaveFlag(14)] private bool ShouldSerializeSeedType() => _pollinated; [SerializableProperty(15)] - [SaveFlag(nameof(ShouldSerializeSeedHue))] public PlantHue SeedHue { get => Pollinated ? _seedHue : Plant.PlantHue; @@ -280,58 +298,53 @@ namespace Server.Engines.Plants } } + [SerializableFieldSaveFlag(15)] private bool ShouldSerializeSeedHue() => _pollinated; - [SerializableField(16, allowFieldChange: nameof(AllowAvailableSeedsChange))] - [SaveFlag(nameof(ShouldSerializeAvailableSeeds))] - private int _availableSeeds; - - private bool AllowAvailableSeedsChange(ref int value) + [SerializableProperty(16)] + public int AvailableSeeds { - value = Math.Max(value, 0); - return true; + get => _availableSeeds; + set => _availableSeeds = Math.Max(value, 0); } + [SerializableFieldSaveFlag(16)] private bool ShouldSerializeAvailableSeeds() => _availableSeeds != 0; - [SerializableField(17, allowFieldChange: nameof(AllowLeftSeedsChange))] - [SaveFlag(nameof(ShouldSerializeLeftSeeds), nameof(LeftSeedsDefaultValue))] - private int _leftSeeds; - - private bool AllowLeftSeedsChange(ref int value) + [SerializableProperty(17)] + public int LeftSeeds { - value = Math.Max(value, 0); - return true; + get => _leftSeeds; + set => _leftSeeds = Math.Max(value, 0); } + [SerializableFieldSaveFlag(17)] private bool ShouldSerializeLeftSeeds() => _leftSeeds != 8; + [SerializableFieldDefault(17)] private int LeftSeedsDefaultValue() => 8; - [SerializableField(18, allowFieldChange: nameof(AllowAvailableResourcesChange))] - [SaveFlag(nameof(ShouldSerializeAvailableResources))] - private int _availableResources; - - private bool AllowAvailableResourcesChange(ref int value) + [SerializableProperty(18)] + public int AvailableResources { - value = Math.Max(value, 0); - return true; + get => _availableResources; + set => _availableResources = Math.Max(value, 0); } + [SerializableFieldSaveFlag(18)] private bool ShouldSerializeAvailableResources() => _availableResources != 0; - [SerializableField(19, allowFieldChange: nameof(AllowLeftResourcesChange))] - [SaveFlag(nameof(ShouldSerializeLeftResources), nameof(LeftResourcesDefaultValue))] - private int _leftResources; - - private bool AllowLeftResourcesChange(ref int value) + [SerializableProperty(19)] + public int LeftResources { - value = Math.Max(value, 0); - return true; + get => _leftResources; + set => _leftResources = Math.Max(value, 0); } + [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 ed6f13b7b..24a290f0b 100644 --- a/Projects/UOContent/Engines/Plants/Seed.cs +++ b/Projects/UOContent/Engines/Plants/Seed.cs @@ -35,14 +35,18 @@ public partial class Seed : Item public override double DefaultWeight => 1.0; - [SerializableField(1, fieldChanged: nameof(OnPlantHueChanged))] - [SerializedCommandProperty(AccessLevel.GameMaster)] - [InvalidateProperties] - private PlantHue _plantHue; - - private void OnPlantHueChanged(PlantHue oldValue, PlantHue newValue) + [CommandProperty(AccessLevel.GameMaster)] + [SerializableProperty(1)] + public PlantHue PlantHue { - Hue = PlantHueInfo.GetInfo(newValue).Hue; + get => _plantHue; + set + { + _plantHue = value; + Hue = PlantHueInfo.GetInfo(value).Hue; + InvalidateProperties(); + this.MarkDirty(); + } } 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 384c91c98..cecd7bf7e 100644 --- a/Projects/UOContent/Engines/Player Murder System/MurderContext.cs +++ b/Projects/UOContent/Engines/Player Murder System/MurderContext.cs @@ -16,14 +16,12 @@ public partial class MurderContext [SerializedCommandProperty(AccessLevel.GameMaster)] private TimeSpan _longTermElapse; - [SerializableField(2, allowFieldChange: nameof(AllowShortTermMurdersChange))] - [SerializedCommandProperty(AccessLevel.GameMaster)] - private int _shortTermMurders; - - private bool AllowShortTermMurdersChange(ref int value) + [SerializableProperty(2)] + [CommandProperty(AccessLevel.GameMaster)] + public int ShortTermMurders { - value = Math.Max(value, 0); - return true; + get => _shortTermMurders; + set => _shortTermMurders = Math.Max(value, 0); } [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 4e3e5e237..d5ec6f24a 100644 --- a/Projects/UOContent/Engines/Quests/The Summoning/Items/SummoningAltar.cs +++ b/Projects/UOContent/Engines/Quests/The Summoning/Items/SummoningAltar.cs @@ -12,12 +12,16 @@ public partial class SummoningAltar : AbbatoirAddon { } - [SerializableField(0, fieldChanged: nameof(OnDaemonChanged))] - private BoneDemon _daemon; - - private void OnDaemonChanged(BoneDemon oldValue, BoneDemon newValue) + [SerializableProperty(0)] + public BoneDemon Daemon { - CheckDaemon(); + get => _daemon; + set + { + _daemon = value; + CheckDaemon(); + this.MarkDirty(); + } } public void CheckDaemon() diff --git a/Projects/UOContent/Engines/Spawners/BaseSpawner.cs b/Projects/UOContent/Engines/Spawners/BaseSpawner.cs index 8efc54b8c..7ceeb00f6 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,46 +67,48 @@ 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; @@ -123,29 +125,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; @@ -154,12 +156,13 @@ 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; @@ -311,20 +314,26 @@ public abstract partial class BaseSpawner : Item, ISpawner } } - [SerializableField(8, fieldChanged: nameof(OnCountChanged))] - [SerializedCommandProperty(AccessLevel.Developer)] - [InvalidateProperties] - private int _count; - - private void OnCountChanged(int oldValue, int newValue) + [SerializableProperty(8)] + [CommandProperty(AccessLevel.Developer)] + public int Count { - if (IsFull) + get => _count; + set { - _timer?.Stop(); - } - else if (_timer?.Running != true) - { - DoTimer(); + _count = value; + + if (IsFull) + { + _timer?.Stop(); + } + else if (_timer?.Running != true) + { + DoTimer(); + } + + InvalidateProperties(); + this.MarkDirty(); } } diff --git a/Projects/UOContent/Engines/Spawners/Spawner.cs b/Projects/UOContent/Engines/Spawners/Spawner.cs index db25cab55..7c07d2681 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 4cd8d8c63..396b87dcd 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(2); // version + writer.WriteEncodedInt(1); // version writer.Write(_enabled); @@ -261,7 +261,7 @@ public class StealableArtifacts : GenericPersistence var si = _artifacts[i]; writer.Write(si.Item); - writer.WriteAnchoredTime(si.NextRespawn); + writer.WriteDeltaTime(si.NextRespawn); } } } @@ -282,7 +282,7 @@ public class StealableArtifacts : GenericPersistence for (var i = 0; i < length; i++) { var item = reader.ReadEntity(); - var nextRespawn = version >= 2 ? reader.ReadAnchoredTime() : reader.ReadDeltaTime(); + var nextRespawn = 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 c42cfae4e..005688e1e 100644 --- a/Projects/UOContent/Engines/Treasures of Tokuno/GreaterArtifacts.cs +++ b/Projects/UOContent/Engines/Treasures of Tokuno/GreaterArtifacts.cs @@ -332,22 +332,27 @@ public partial class PigmentsOfTokuno : BasePigmentsOfTokuno [Constructible] public PigmentsOfTokuno(PigmentType type, int uses) : base(uses) => Type = type; - [SerializableField(0, fieldChanged: nameof(OnTypeChanged))] - [SerializedCommandProperty(AccessLevel.GameMaster)] - private PigmentType _type; - - private void OnTypeChanged(PigmentType oldValue, PigmentType newValue) + [SerializableProperty(0)] + [CommandProperty(AccessLevel.GameMaster)] + public PigmentType Type { - var v = (int)_type; - if (v >= 0 && v < _table.Length) + get => _type; + set { - Hue = _table[v][0]; - Label = _table[v][1]; - } - else - { - Hue = 0; - Label = -1; + _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; + } } } diff --git a/Projects/UOContent/Engines/Treasures of Tokuno/LesserArtifacts.cs b/Projects/UOContent/Engines/Treasures of Tokuno/LesserArtifacts.cs index 22c6af344..54d026df4 100644 --- a/Projects/UOContent/Engines/Treasures of Tokuno/LesserArtifacts.cs +++ b/Projects/UOContent/Engines/Treasures of Tokuno/LesserArtifacts.cs @@ -617,22 +617,27 @@ public partial class LesserPigmentsOfTokuno : BasePigmentsOfTokuno [Constructible] public LesserPigmentsOfTokuno(LesserPigmentType type) : base(1) => Type = type; - [SerializableField(0, fieldChanged: nameof(OnTypeChanged))] - [SerializedCommandProperty(AccessLevel.GameMaster)] - private LesserPigmentType _type; - - private void OnTypeChanged(LesserPigmentType oldValue, LesserPigmentType newValue) + [SerializableProperty(0)] + [CommandProperty(AccessLevel.GameMaster)] + public LesserPigmentType Type { - var v = (int)_type; - if (v >= 0 && v < _table.Length) + get => _type; + set { - Hue = _table[v][0]; - Label = _table[v][1]; - } - else - { - Hue = 0; - Label = -1; + _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; + } } } 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 7a62cc735..ff665ec53 100644 --- a/Projects/UOContent/Engines/Veteran Rewards/Character Statue Maker/CharacterStatue.cs +++ b/Projects/UOContent/Engines/Veteran Rewards/Character Statue Maker/CharacterStatue.cs @@ -79,33 +79,45 @@ public partial class CharacterStatue : Mobile, IRewardItem InvalidateHues(); } - [SerializableField(0, fieldChanged: nameof(OnStatueTypeChanged))] - [SerializedCommandProperty(AccessLevel.GameMaster)] - private StatueType _statueType; - - private void OnStatueTypeChanged(StatueType oldValue, StatueType newValue) + [SerializableProperty(0)] + [CommandProperty(AccessLevel.GameMaster)] + public StatueType StatueType { - InvalidateHues(); - InvalidatePose(); + get => _statueType; + set + { + _statueType = value; + InvalidateHues(); + InvalidatePose(); + this.MarkDirty(); + } } - [SerializableField(1, fieldChanged: nameof(OnPoseChanged))] - [SerializedCommandProperty(AccessLevel.GameMaster)] - private StatuePose _pose; - - private void OnPoseChanged(StatuePose oldValue, StatuePose newValue) + [SerializableProperty(1)] + [CommandProperty(AccessLevel.GameMaster)] + public StatuePose Pose { - InvalidatePose(); + get => _pose; + set + { + _pose = value; + InvalidatePose(); + this.MarkDirty(); + } } - [SerializableField(2, fieldChanged: nameof(OnMaterialChanged))] - [SerializedCommandProperty(AccessLevel.GameMaster)] - private StatueMaterial _material; - - private void OnMaterialChanged(StatueMaterial oldValue, StatueMaterial newValue) + [SerializableProperty(2)] + [CommandProperty(AccessLevel.GameMaster)] + public StatueMaterial Material { - InvalidateHues(); - InvalidatePose(); + get => _material; + set + { + _material = value; + InvalidateHues(); + InvalidatePose(); + this.MarkDirty(); + } } 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 48ae30ae9..a028b1c85 100644 --- a/Projects/UOContent/Engines/Veteran Rewards/Character Statue Maker/CharacterStatueMaker.cs +++ b/Projects/UOContent/Engines/Veteran Rewards/Character Statue Maker/CharacterStatueMaker.cs @@ -25,13 +25,17 @@ public partial class CharacterStatueMaker : Item, IRewardItem public override int LabelNumber => 1076173; // Character Statue Maker - [SerializableField(1, fieldChanged: nameof(OnStatueTypeChanged))] - [SerializedCommandProperty(AccessLevel.GameMaster)] - private StatueType _statueType; - - private void OnStatueTypeChanged(StatueType oldValue, StatueType newValue) + [SerializableProperty(1)] + [CommandProperty(AccessLevel.GameMaster)] + public StatueType StatueType { - InvalidateHue(); + get => _statueType; + set + { + _statueType = value; + InvalidateHue(); + this.MarkDirty(); + } } public override void OnDoubleClick(Mobile from) diff --git a/Projects/UOContent/Engines/Virtues/VirtueContext.cs b/Projects/UOContent/Engines/Virtues/VirtueContext.cs index 7524f77ed..3b26b5294 100644 --- a/Projects/UOContent/Engines/Virtues/VirtueContext.cs +++ b/Projects/UOContent/Engines/Virtues/VirtueContext.cs @@ -5,121 +5,102 @@ using Server.Mobiles; namespace Server.Engines.Virtues; [PropertyObject] -[SerializationGenerator(1)] +[SerializationGenerator(0)] public partial class VirtueContext { - 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] + [DeltaDateTime] [SerializableField(0)] - [SaveFlag(nameof(ShouldSerializeLastSacrificeGain))] [SerializedCommandProperty(AccessLevel.GameMaster, AccessLevel.Administrator)] private DateTime _lastSacrificeGain; + [SerializableFieldSaveFlag(0)] private bool ShouldSerializeLastSacrificeGain() => !SacrificeVirtue.CanGain(this); - [AnchoredDateTime] + [DeltaDateTime] [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; - [AnchoredDateTime] + [DeltaDateTime] [SerializableField(3)] - [SaveFlag(nameof(ShouldSerializeLastJusticeLoss))] [SerializedCommandProperty(AccessLevel.GameMaster, AccessLevel.Administrator)] private DateTime _lastJusticeLoss; + [SerializableFieldSaveFlag(3)] private bool ShouldSerializeLastJusticeLoss() => !JusticeVirtue.CanAtrophy(this); - [AnchoredDateTime] + [DeltaDateTime] [SerializableField(4)] - [SaveFlag(nameof(ShouldSerializeLastCompassionLoss))] [SerializedCommandProperty(AccessLevel.GameMaster, AccessLevel.Administrator)] private DateTime _lastCompassionLoss; + [SerializableFieldSaveFlag(4)] private bool ShouldSerializeLastCompassionLoss() => !CompassionVirtue.CanAtrophy(this); - [AnchoredDateTime] + [DeltaDateTime] [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; - [AnchoredDateTime] + [DeltaDateTime] [SerializableField(7)] - [SaveFlag(nameof(ShouldSerializeValorLoss))] [SerializedCommandProperty(AccessLevel.GameMaster, AccessLevel.Administrator)] private DateTime _lastValorLoss; + [SerializableFieldSaveFlag(7)] private bool ShouldSerializeValorLoss() => !ValorVirtue.CanAtrophy(this); - [AnchoredDateTime] + [DeltaDateTime] [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/Gumps/AdminGump.cs b/Projects/UOContent/Gumps/AdminGump.cs index a4fbe0f18..130d031fc 100644 --- a/Projects/UOContent/Gumps/AdminGump.cs +++ b/Projects/UOContent/Gumps/AdminGump.cs @@ -227,8 +227,7 @@ namespace Server.Gumps } case AdminGumpPage.Information_Perf: { - var loopStatus = Core.IdleSleepUnsupported ? "Spinning - host cannot honor short waits" : - Core.EventLoopIdleWaitMs == 0 ? "Spinning (configured)" : + var loopStatus = Core.EventLoopIdleWaitMs == 0 ? "Spinning (configured)" : Core.IdleSleepSuspended ? "Sleep suspended - host returning waits late" : "Healthy"; AddLabel(20, 130, LabelHue, "Event Loop:"); diff --git a/Projects/UOContent/Items/Addons/BaseAddon.cs b/Projects/UOContent/Items/Addons/BaseAddon.cs index f2a662f6a..6b2abbc66 100644 --- a/Projects/UOContent/Items/Addons/BaseAddon.cs +++ b/Projects/UOContent/Items/Addons/BaseAddon.cs @@ -63,14 +63,22 @@ namespace Server.Items } } - [SerializableField(1, fieldChanged: nameof(OnResourceChanged))] - [SerializedCommandProperty(AccessLevel.GameMaster)] - [InvalidateProperties] - private CraftResource _resource; - - private void OnResourceChanged(CraftResource oldValue, CraftResource newValue) + [SerializableProperty(1)] + [CommandProperty(AccessLevel.GameMaster)] + public CraftResource Resource { - Hue = CraftResources.GetHue(_resource); + get => _resource; + set + { + if (_resource != value) + { + _resource = value; + Hue = CraftResources.GetHue(_resource); + + InvalidateProperties(); + this.MarkDirty(); + } + } } Item IAddon.Deed => Deed; diff --git a/Projects/UOContent/Items/Addons/BaseAddonContainer.cs b/Projects/UOContent/Items/Addons/BaseAddonContainer.cs index 8554ad4e5..ad655c24e 100644 --- a/Projects/UOContent/Items/Addons/BaseAddonContainer.cs +++ b/Projects/UOContent/Items/Addons/BaseAddonContainer.cs @@ -41,14 +41,22 @@ namespace Server.Items } } - [SerializableField(1, fieldChanged: nameof(OnResourceChanged))] - [SerializedCommandProperty(AccessLevel.GameMaster)] - [InvalidateProperties] - private CraftResource _resource; - - private void OnResourceChanged(CraftResource oldValue, CraftResource newValue) + [SerializableProperty(1)] + [CommandProperty(AccessLevel.GameMaster)] + public CraftResource Resource { - Hue = CraftResources.GetHue(_resource); + get => _resource; + set + { + if (_resource != value) + { + _resource = value; + Hue = CraftResources.GetHue(_resource); + + InvalidateProperties(); + this.MarkDirty(); + } + } } public virtual bool RetainDeedHue => false; diff --git a/Projects/UOContent/Items/Addons/BaseAddonContainerDeed.cs b/Projects/UOContent/Items/Addons/BaseAddonContainerDeed.cs index 7d5014dee..c7e08e955 100644 --- a/Projects/UOContent/Items/Addons/BaseAddonContainerDeed.cs +++ b/Projects/UOContent/Items/Addons/BaseAddonContainerDeed.cs @@ -23,14 +23,22 @@ public abstract partial class BaseAddonContainerDeed : Item, ICraftable public abstract BaseAddonContainer Addon { get; } - [SerializableField(0, fieldChanged: nameof(OnResourceChanged))] - [SerializedCommandProperty(AccessLevel.GameMaster)] - [InvalidateProperties] - private CraftResource _resource; - - private void OnResourceChanged(CraftResource oldValue, CraftResource newValue) + [SerializableProperty(0)] + [CommandProperty(AccessLevel.GameMaster)] + public CraftResource Resource { - Hue = CraftResources.GetHue(_resource); + get => _resource; + set + { + if (_resource != value) + { + _resource = value; + Hue = CraftResources.GetHue(_resource); + + InvalidateProperties(); + this.MarkDirty(); + } + } } public virtual int OnCraft( diff --git a/Projects/UOContent/Items/Addons/FlourMillEastAddon.cs b/Projects/UOContent/Items/Addons/FlourMillEastAddon.cs index decf3ca65..f84c959cf 100644 --- a/Projects/UOContent/Items/Addons/FlourMillEastAddon.cs +++ b/Projects/UOContent/Items/Addons/FlourMillEastAddon.cs @@ -48,19 +48,17 @@ namespace Server.Items [CommandProperty(AccessLevel.GameMaster)] public int MaxFlour => 2; - [SerializableField(0, fieldChanged: nameof(OnCurFlourChanged), allowFieldChange: nameof(AllowCurFlourChange))] - [SerializedCommandProperty(AccessLevel.GameMaster)] - private int _curFlour; - - private bool AllowCurFlourChange(ref int value) + [SerializableProperty(0)] + [CommandProperty(AccessLevel.GameMaster)] + public int CurFlour { - value = Math.Clamp(value, 0, MaxFlour); - return true; - } - - private void OnCurFlourChanged(int oldValue, int newValue) - { - UpdateStage(); + get => _curFlour; + set + { + _curFlour = Math.Clamp(value, 0, MaxFlour); + UpdateStage(); + this.MarkDirty(); + } } public void StartWorking(Mobile from) diff --git a/Projects/UOContent/Items/Addons/FlourMillSouthAddon.cs b/Projects/UOContent/Items/Addons/FlourMillSouthAddon.cs index fa48aa83c..cbd0e2cc3 100644 --- a/Projects/UOContent/Items/Addons/FlourMillSouthAddon.cs +++ b/Projects/UOContent/Items/Addons/FlourMillSouthAddon.cs @@ -35,19 +35,16 @@ namespace Server.Items [CommandProperty(AccessLevel.GameMaster)] public int MaxFlour => 2; - [SerializableField(0, fieldChanged: nameof(OnCurFlourChanged), allowFieldChange: nameof(AllowCurFlourChange))] - [SerializedCommandProperty(AccessLevel.GameMaster)] - private int _curFlour; - - private bool AllowCurFlourChange(ref int value) + [SerializableProperty(0)] + [CommandProperty(AccessLevel.GameMaster)] + public int CurFlour { - value = Math.Max(0, Math.Min(value, MaxFlour)); - return true; - } - - private void OnCurFlourChanged(int oldValue, int newValue) - { - UpdateStage(); + get => _curFlour; + set + { + _curFlour = Math.Max(0, Math.Min(value, MaxFlour)); + UpdateStage(); + } } public void StartWorking(Mobile from) diff --git a/Projects/UOContent/Items/Addons/SHTeleporter.cs b/Projects/UOContent/Items/Addons/SHTeleporter.cs index e6ce612b4..f34f9cb85 100644 --- a/Projects/UOContent/Items/Addons/SHTeleporter.cs +++ b/Projects/UOContent/Items/Addons/SHTeleporter.cs @@ -25,27 +25,35 @@ namespace Server.Items _teleOffset = offset; } - [SerializableField(0, fieldChanged: nameof(OnActiveChanged))] - [SerializedCommandProperty(AccessLevel.GameMaster)] - private bool _active; - - private void OnActiveChanged(bool oldValue, bool newValue) + [SerializableProperty(0)] + [CommandProperty(AccessLevel.GameMaster)] + public bool Active { - if (Addon is SHTeleporter sourceAddon) + get => _active; + set { - sourceAddon.ChangeActive(newValue); + _active = value; + + if (Addon is SHTeleporter sourceAddon) + { + sourceAddon.ChangeActive(value); + } } } - [SerializableField(1, fieldChanged: nameof(OnTeleDestChanged))] - [SerializedCommandProperty(AccessLevel.GameMaster)] - private SHTeleComponent _teleDest; - - private void OnTeleDestChanged(SHTeleComponent oldValue, SHTeleComponent newValue) + [SerializableProperty(1)] + [CommandProperty(AccessLevel.GameMaster)] + public SHTeleComponent TeleDest { - if (Addon is SHTeleporter sourceAddon) + get => _teleDest; + set { - sourceAddon.ChangeDest(newValue); + _teleDest = value; + + if (Addon is SHTeleporter sourceAddon) + { + sourceAddon.ChangeDest(value); + } } } diff --git a/Projects/UOContent/Items/Aquarium/Aquarium.cs b/Projects/UOContent/Items/Aquarium/Aquarium.cs index 054fea588..749c677c2 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 75fe6fc6f..cdd59d9d9 100644 --- a/Projects/UOContent/Items/Aquarium/AquariumState.cs +++ b/Projects/UOContent/Items/Aquarium/AquariumState.cs @@ -30,14 +30,19 @@ namespace Server.Items public AquariumState(Aquarium parent) => _aquarium = parent; - [SerializableField(0, allowFieldChange: nameof(AllowStateChange))] - [SerializedCommandProperty(AccessLevel.GameMaster)] - private int _state; - - private bool AllowStateChange(ref int value) + [SerializableProperty(0)] + [CommandProperty(AccessLevel.GameMaster)] + public int State { - value = Math.Clamp(value, 0, 4); - return true; + get => _state; + set + { + if (_state != value) + { + _state = Math.Clamp(value, 0, 4); + MarkDirty(); + } + } } [SerializableField(1)] diff --git a/Projects/UOContent/Items/Armor/BaseArmor.Migrations.cs b/Projects/UOContent/Items/Armor/BaseArmor.Migrations.cs index f15a7fd6a..3135850a9 100644 --- a/Projects/UOContent/Items/Armor/BaseArmor.Migrations.cs +++ b/Projects/UOContent/Items/Armor/BaseArmor.Migrations.cs @@ -1,40 +1,9 @@ -using System; using AMA = Server.Items.ArmorMeditationAllowance; namespace Server.Items; public partial class BaseArmor { - // PlayerConstructed moved onto Item - private void MigrateFrom(V9Content content) - { - _attributes = content.Attributes ?? AttributesDefaultValue(); - _armorAttributes = content.ArmorAttributes ?? ArmorAttributesDefaultValue(); - _physicalBonus = content.PhysicalBonus ?? 0; - _fireBonus = content.FireBonus ?? 0; - _coldBonus = content.ColdBonus ?? 0; - _poisonBonus = content.PoisonBonus ?? 0; - _energyBonus = content.EnergyBonus ?? 0; - _identified = content.Identified; - _maxHitPoints = content.MaxHitPoints ?? 0; - _hitPoints = content.HitPoints ?? 0; - _crafter = content.Crafter; - _quality = content.Quality ?? ArmorQuality.Regular; - _durability = content.Durability ?? ArmorDurabilityLevel.Regular; - _protectionLevel = content.ProtectionLevel ?? ArmorProtectionLevel.Regular; - _resource = content.Resource ?? DefaultResource; - _armorBase = content.BaseArmorRating ?? -1; - _strBonus = content.StrBonus ?? -1; - _dexBonus = content.DexBonus ?? -1; - _intBonus = content.IntBonus ?? -1; - _strReq = content.StrRequirement ?? -1; - _dexReq = content.DexRequirement ?? -1; - _intReq = content.IntRequirement ?? -1; - _meditate = content.MeditationAllowance ?? (AMA)(-1); - _skillBonuses = content.SkillBonuses ?? SkillBonusesDefaultValue(); - PlayerConstructed = content.PlayerConstructed; - } - private void MigrateFrom(V8Content content) { _attributes = content.Attributes ?? AttributesDefaultValue(); @@ -60,7 +29,7 @@ public partial class BaseArmor _intReq = content.IntRequirement ?? -1; _meditate = content.MeditationAllowance ?? (AMA)(-1); _skillBonuses = content.SkillBonuses ?? SkillBonusesDefaultValue(); - PlayerConstructed = content.PlayerConstructed; + _playerConstructed = content.PlayerConstructed; } // Version 7 (pre-codegen) @@ -193,37 +162,4 @@ public partial class BaseArmor PlayerConstructed = GetSaveFlag(flags, OldSaveFlag.PlayerConstructed); } - - private static bool GetSaveFlag(OldSaveFlag flags, OldSaveFlag toGet) => (flags & toGet) != 0; - - [Flags] - private enum OldSaveFlag - { - None = 0x00000000, - Attributes = 0x00000001, - ArmorAttributes = 0x00000002, - PhysicalBonus = 0x00000004, - FireBonus = 0x00000008, - ColdBonus = 0x00000010, - PoisonBonus = 0x00000020, - EnergyBonus = 0x00000040, - Identified = 0x00000080, - MaxHitPoints = 0x00000100, - HitPoints = 0x00000200, - Crafter = 0x00000400, - Quality = 0x00000800, - Durability = 0x00001000, - Protection = 0x00002000, - Resource = 0x00004000, - BaseArmor = 0x00008000, - StrBonus = 0x00010000, - DexBonus = 0x00020000, - IntBonus = 0x00040000, - StrReq = 0x00080000, - DexReq = 0x00100000, - IntReq = 0x00200000, - MedAllowance = 0x00400000, - SkillBonuses = 0x00800000, - PlayerConstructed = 0x01000000 - } } diff --git a/Projects/UOContent/Items/Armor/BaseArmor.cs b/Projects/UOContent/Items/Armor/BaseArmor.cs index f4062326d..7f01648a0 100644 --- a/Projects/UOContent/Items/Armor/BaseArmor.cs +++ b/Projects/UOContent/Items/Armor/BaseArmor.cs @@ -12,98 +12,101 @@ using AMT = Server.Items.ArmorMaterialType; namespace Server.Items { - [SerializationGenerator(10, false)] + [SerializationGenerator(9, false)] public abstract partial class BaseArmor : Item, IScissorable, IFactionItem, ICraftable, IWearableDurability, IAosItem, IIdentifiable { [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 @@ -132,14 +135,22 @@ 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); + [SerializableField(24)] + [SerializedCommandProperty(AccessLevel.GameMaster)] + public bool _playerConstructed; + + [SerializableFieldSaveFlag(24)] + private bool ShouldSerializePlayerConstructed() => _playerConstructed; + private FactionItem m_FactionState; public BaseArmor(int itemID) : base(itemID) @@ -186,7 +197,6 @@ namespace Server.Items public virtual int OldIntReq => 0; [SerializableProperty(11)] - [SaveFlag(nameof(ShouldSerializeArmorQuality), nameof(QualityDefaultValue))] [CommandProperty(AccessLevel.GameMaster)] public ArmorQuality Quality { @@ -199,12 +209,13 @@ 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 { @@ -217,24 +228,33 @@ namespace Server.Items } } + [SerializableFieldSaveFlag(12)] private bool ShouldSerializeDurability() => _durability != ArmorDurabilityLevel.Regular; - [SerializableField(13, fieldChanged: nameof(OnProtectionLevelChanged))] - [SaveFlag(nameof(ShouldSerializeProtectionLevel))] - [SerializedCommandProperty(AccessLevel.GameMaster)] - [InvalidateProperties] - private ArmorProtectionLevel _protectionLevel; - - private void OnProtectionLevelChanged(ArmorProtectionLevel oldValue, ArmorProtectionLevel newValue) + [SerializableProperty(13)] + [CommandProperty(AccessLevel.GameMaster)] + public ArmorProtectionLevel ProtectionLevel { - Invalidate(); - (Parent as Mobile)?.UpdateResistances(); + get => _protectionLevel; + set + { + if (_protectionLevel != value) + { + _protectionLevel = value; + + Invalidate(); + InvalidateProperties(); + + (Parent as Mobile)?.UpdateResistances(); + this.MarkDirty(); + } + } } + [SerializableFieldSaveFlag(13)] private bool ShouldSerializeProtectionLevel() => _protectionLevel != ArmorProtectionLevel.Regular; [SerializableProperty(14)] - [SaveFlag(nameof(ShouldSerializeResource), nameof(ResourceDefaultValue))] [CommandProperty(AccessLevel.GameMaster)] public CraftResource Resource { @@ -260,11 +280,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 { @@ -277,8 +297,10 @@ namespace Server.Items } } + [SerializableFieldSaveFlag(15)] private bool ShouldSerializeArmorBase() => _armorBase != -1; + [SerializableFieldDefault(15)] private int ArmorBaseDefaultValue() => -1; public double BaseArmorRatingScaled => BaseArmorRating * ArmorScalar; @@ -328,7 +350,6 @@ namespace Server.Items [EncodedInt] [SerializableProperty(16, useField: nameof(_strBonus))] - [SaveFlag(nameof(ShouldSerializeStrBonus), nameof(StrBonusDefaultValue))] [CommandProperty(AccessLevel.GameMaster)] public int StrBonus { @@ -341,13 +362,14 @@ 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 { @@ -360,13 +382,14 @@ 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 { @@ -379,13 +402,14 @@ 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 { @@ -398,13 +422,14 @@ 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 { @@ -417,13 +442,14 @@ 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 { @@ -436,12 +462,13 @@ 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 { @@ -453,6 +480,7 @@ namespace Server.Items } } + [SerializableFieldSaveFlag(22)] private bool ShouldSerializeMeditationAllowance() => _meditate >= AMA.All; public virtual double ArmorScalar @@ -542,6 +570,7 @@ namespace Server.Items var resourceType = typeRes ?? craftItem.Resources[0].ItemType; Resource = CraftResources.GetFromType(resourceType); + PlayerConstructed = true; Identified = true; var context = craftSystem.GetContext(from); @@ -668,7 +697,6 @@ namespace Server.Items [EncodedInt] [SerializableProperty(9)] - [SaveFlag(nameof(ShouldSerializeHitPoints))] [CommandProperty(AccessLevel.GameMaster)] public int HitPoints { @@ -696,6 +724,7 @@ namespace Server.Items } } + [SerializableFieldSaveFlag(9)] private bool ShouldSerializeHitPoints() => _hitPoints != 0; public virtual int InitMinHits => 0; @@ -997,6 +1026,8 @@ namespace Server.Items (Parent as Mobile)?.Delta(MobileDelta.Armor); // Tell them armor rating has changed } + private static bool GetSaveFlag(OldSaveFlag flags, OldSaveFlag toGet) => (flags & toGet) != 0; + [AfterDeserialization] private void AfterDeserialization() { @@ -1483,5 +1514,35 @@ namespace Server.Items }; } + [Flags] + private enum OldSaveFlag + { + None = 0x00000000, + Attributes = 0x00000001, + ArmorAttributes = 0x00000002, + PhysicalBonus = 0x00000004, + FireBonus = 0x00000008, + ColdBonus = 0x00000010, + PoisonBonus = 0x00000020, + EnergyBonus = 0x00000040, + Identified = 0x00000080, + MaxHitPoints = 0x00000100, + HitPoints = 0x00000200, + Crafter = 0x00000400, + Quality = 0x00000800, + Durability = 0x00001000, + Protection = 0x00002000, + Resource = 0x00004000, + BaseArmor = 0x00008000, + StrBonus = 0x00010000, + DexBonus = 0x00020000, + IntBonus = 0x00040000, + StrReq = 0x00080000, + DexReq = 0x00100000, + IntReq = 0x00200000, + MedAllowance = 0x00400000, + SkillBonuses = 0x00800000, + PlayerConstructed = 0x01000000 + } } } diff --git a/Projects/UOContent/Items/Armor/Glasses/ElvenGlasses.cs b/Projects/UOContent/Items/Armor/Glasses/ElvenGlasses.cs index e44967f0b..4b0fb791f 100644 --- a/Projects/UOContent/Items/Armor/Glasses/ElvenGlasses.cs +++ b/Projects/UOContent/Items/Armor/Glasses/ElvenGlasses.cs @@ -31,12 +31,13 @@ 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 aa023cdc3..f9abd485b 100644 --- a/Projects/UOContent/Items/Armor/Leather/LeafGloves.cs +++ b/Projects/UOContent/Items/Armor/Leather/LeafGloves.cs @@ -33,26 +33,32 @@ namespace Server.Items public override ArmorMeditationAllowance DefMedAllowance => ArmorMeditationAllowance.All; - [SerializableField(0, fieldChanged: nameof(OnCurArcaneChargesChanged))] [EncodedInt] - [SerializedCommandProperty(AccessLevel.GameMaster)] - [InvalidateProperties] - private int _curArcaneCharges; - - private void OnCurArcaneChargesChanged(int oldValue, int newValue) + [SerializableProperty(0)] + [CommandProperty(AccessLevel.GameMaster)] + public int CurArcaneCharges { - Update(); + get => _curArcaneCharges; + set + { + _curArcaneCharges = value; + InvalidateProperties(); + Update(); + } } - [SerializableField(1, fieldChanged: nameof(OnMaxArcaneChargesChanged))] [EncodedInt] - [SerializedCommandProperty(AccessLevel.GameMaster)] - [InvalidateProperties] - private int _maxArcaneCharges; - - private void OnMaxArcaneChargesChanged(int oldValue, int newValue) + [SerializableProperty(1)] + [CommandProperty(AccessLevel.GameMaster)] + public int MaxArcaneCharges { - Update(); + get => _maxArcaneCharges; + set + { + _maxArcaneCharges = value; + InvalidateProperties(); + Update(); + } } [CommandProperty(AccessLevel.GameMaster)] diff --git a/Projects/UOContent/Items/Armor/Leather/LeatherGloves.cs b/Projects/UOContent/Items/Armor/Leather/LeatherGloves.cs index 297bd8ed1..a1ded151b 100644 --- a/Projects/UOContent/Items/Armor/Leather/LeatherGloves.cs +++ b/Projects/UOContent/Items/Armor/Leather/LeatherGloves.cs @@ -32,26 +32,32 @@ namespace Server.Items public override ArmorMeditationAllowance DefMedAllowance => ArmorMeditationAllowance.All; - [SerializableField(0, fieldChanged: nameof(OnCurArcaneChargesChanged))] [EncodedInt] - [SerializedCommandProperty(AccessLevel.GameMaster)] - [InvalidateProperties] - private int _curArcaneCharges; - - private void OnCurArcaneChargesChanged(int oldValue, int newValue) + [SerializableProperty(0)] + [CommandProperty(AccessLevel.GameMaster)] + public int CurArcaneCharges { - Update(); + get => _curArcaneCharges; + set + { + _curArcaneCharges = value; + InvalidateProperties(); + Update(); + } } - [SerializableField(1, fieldChanged: nameof(OnMaxArcaneChargesChanged))] [EncodedInt] - [SerializedCommandProperty(AccessLevel.GameMaster)] - [InvalidateProperties] - private int _maxArcaneCharges; - - private void OnMaxArcaneChargesChanged(int oldValue, int newValue) + [SerializableProperty(1)] + [CommandProperty(AccessLevel.GameMaster)] + public int MaxArcaneCharges { - Update(); + get => _maxArcaneCharges; + set + { + _maxArcaneCharges = value; + InvalidateProperties(); + Update(); + } } [CommandProperty(AccessLevel.GameMaster)] diff --git a/Projects/UOContent/Items/Books/BaseBook.cs b/Projects/UOContent/Items/Books/BaseBook.cs index 3d9b9f7b8..47f368754 100644 --- a/Projects/UOContent/Items/Books/BaseBook.cs +++ b/Projects/UOContent/Items/Books/BaseBook.cs @@ -19,38 +19,41 @@ 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.Migrations.cs b/Projects/UOContent/Items/Clothing/BaseClothing.Migrations.cs index 7c431f076..e267cd1aa 100644 --- a/Projects/UOContent/Items/Clothing/BaseClothing.Migrations.cs +++ b/Projects/UOContent/Items/Clothing/BaseClothing.Migrations.cs @@ -1,25 +1,7 @@ -using System; - namespace Server.Items; public partial class BaseClothing { - // PlayerConstructed moved onto Item - private void MigrateFrom(V7Content content) - { - _resource = content.Resource ?? DefaultResource; - _attributes = content.Attributes ?? AttributesDefaultValue(); - _clothingAttributes = content.ClothingAttributes ?? ClothingAttributesDefaultValue(); - _skillBonuses = content.SkillBonuses ?? SkillBonusesDefaultValue(); - _resistances = content.Resistances ?? ResistancesDefaultValue(); - _maxHitPoints = content.MaxHitPoints ?? 0; - _hitPoints = content.HitPoints ?? 0; - PlayerConstructed = content.PlayerConstructed; - _crafter = content.Crafter; - _quality = content.Quality ?? ClothingQuality.Regular; - _strReq = content.StrRequirement ?? -1; - } - private void MigrateFrom(V6Content content) { _resource = content.RawResource ?? DefaultResource; @@ -28,7 +10,7 @@ public partial class BaseClothing _skillBonuses = content.SkillBonuses ?? SkillBonusesDefaultValue(); _resistances = content.Resistances ?? ResistancesDefaultValue(); _maxHitPoints = content.MaxHitPoints ?? 0; - PlayerConstructed = content.PlayerConstructed; + _playerConstructed = content.PlayerConstructed; Timer.DelayCall((item, crafter) => item._crafter = crafter?.RawName, this, content.Crafter); _quality = content.Quality ?? ClothingQuality.Regular; _strReq = content.StrRequirement ?? -1; @@ -103,23 +85,4 @@ public partial class BaseClothing PlayerConstructed = GetSaveFlag(flags, OldSaveFlag.PlayerConstructed); } - - private static bool GetSaveFlag(OldSaveFlag flags, OldSaveFlag toGet) => (flags & toGet) != 0; - - [Flags] - private enum OldSaveFlag - { - None = 0x00000000, - Resource = 0x00000001, - Attributes = 0x00000002, - ClothingAttributes = 0x00000004, - SkillBonuses = 0x00000008, - Resistances = 0x00000010, - MaxHitPoints = 0x00000020, - HitPoints = 0x00000040, - PlayerConstructed = 0x00000080, - Crafter = 0x00000100, - Quality = 0x00000200, - StrReq = 0x00000400 - } } diff --git a/Projects/UOContent/Items/Clothing/BaseClothing.cs b/Projects/UOContent/Items/Clothing/BaseClothing.cs index 983568d2d..725eed100 100644 --- a/Projects/UOContent/Items/Clothing/BaseClothing.cs +++ b/Projects/UOContent/Items/Clothing/BaseClothing.cs @@ -22,78 +22,90 @@ namespace Server.Items int MaxArcaneCharges { get; set; } } - [SerializationGenerator(8, false)] + [SerializationGenerator(7, false)] 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; + private bool _playerConstructed; - private bool ShouldSerializeCrafter() => !string.IsNullOrEmpty(_crafter); + [SerializableFieldSaveFlag(7)] + private bool ShouldSerializePlayerConstructed() => _playerConstructed; [InvalidateProperties] [SerializableField(8)] - [SaveFlag(nameof(ShouldSerializeQuality))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private string _crafter; + + [SerializableFieldSaveFlag(8)] + private bool ShouldSerializeCrafter() => !string.IsNullOrEmpty(_crafter); + + [InvalidateProperties] + [SerializableField(9)] [SerializedCommandProperty(AccessLevel.GameMaster)] private ClothingQuality _quality = ClothingQuality.Regular; + [SerializableFieldSaveFlag(9)] private bool ShouldSerializeQuality() => _quality != ClothingQuality.Regular; - // Field 9 + // Field 10 private int _strReq = -1; private FactionItem _factionState; @@ -113,19 +125,21 @@ namespace Server.Items Resistances = new AosElementAttributes(this); } - [SerializableField(0, fieldChanged: nameof(OnResourceChanged))] - [SaveFlag(nameof(ShouldSerializeResource))] - [SerializedCommandProperty(AccessLevel.GameMaster)] - [InvalidateProperties] - private CraftResource _resource; - - private void OnResourceChanged(CraftResource oldValue, CraftResource newValue) + [SerializableProperty(0)] + [CommandProperty(AccessLevel.GameMaster)] + public CraftResource Resource { - Hue = CraftResources.GetHue(_resource); + get => _resource; + set + { + _resource = value; + Hue = CraftResources.GetHue(_resource); + InvalidateProperties(); + this.MarkDirty(); + } } - [SerializableProperty(9, useField: nameof(_strReq))] - [SaveFlag(nameof(ShouldSerializeStrReq))] + [SerializableProperty(10, useField: nameof(_strReq))] [CommandProperty(AccessLevel.GameMaster)] public int StrRequirement { @@ -138,6 +152,7 @@ namespace Server.Items } } + [SerializableFieldSaveFlag(10)] private bool ShouldSerializeStrReq() => _strReq != -1; public virtual CraftResource DefaultResource => CraftResource.None; @@ -192,6 +207,8 @@ namespace Server.Items Hue = resHue; } + PlayerConstructed = true; + var context = craftSystem.GetContext(from); if (context?.DoNotColor == true) @@ -291,7 +308,6 @@ namespace Server.Items [EncodedInt] [SerializableProperty(6)] - [SaveFlag(nameof(ShouldSerializeHitPoints))] [CommandProperty(AccessLevel.GameMaster)] public int HitPoints { @@ -317,6 +333,7 @@ namespace Server.Items } } + [SerializableFieldSaveFlag(6)] private bool ShouldSerializeHitPoints() => _hitPoints != 0; public virtual int InitMinHits => 0; @@ -872,6 +889,8 @@ namespace Server.Items InvalidateProperties(); } + private static bool GetSaveFlag(OldSaveFlag flags, OldSaveFlag toGet) => (flags & toGet) != 0; + [AfterDeserialization] private void AfterDeserialization() { @@ -892,5 +911,21 @@ namespace Server.Items } } + [Flags] + private enum OldSaveFlag + { + None = 0x00000000, + Resource = 0x00000001, + Attributes = 0x00000002, + ClothingAttributes = 0x00000004, + SkillBonuses = 0x00000008, + Resistances = 0x00000010, + MaxHitPoints = 0x00000020, + HitPoints = 0x00000040, + PlayerConstructed = 0x00000080, + Crafter = 0x00000100, + Quality = 0x00000200, + StrReq = 0x00000400 + } } } diff --git a/Projects/UOContent/Items/Clothing/Cloaks.cs b/Projects/UOContent/Items/Clothing/Cloaks.cs index a92e8fc8c..67f572d9d 100644 --- a/Projects/UOContent/Items/Clothing/Cloaks.cs +++ b/Projects/UOContent/Items/Clothing/Cloaks.cs @@ -22,26 +22,34 @@ namespace Server.Items public override double DefaultWeight => 5.0; - [SerializableField(0, fieldChanged: nameof(OnCurArcaneChargesChanged))] [EncodedInt] - [SerializedCommandProperty(AccessLevel.GameMaster)] - [InvalidateProperties] - private int _curArcaneCharges; - - private void OnCurArcaneChargesChanged(int oldValue, int newValue) + [SerializableProperty(0)] + [CommandProperty(AccessLevel.GameMaster)] + public int CurArcaneCharges { - Update(); + get => _curArcaneCharges; + set + { + _curArcaneCharges = value; + this.MarkDirty(); + InvalidateProperties(); + Update(); + } } - [SerializableField(1, fieldChanged: nameof(OnMaxArcaneChargesChanged))] [EncodedInt] - [SerializedCommandProperty(AccessLevel.GameMaster)] - [InvalidateProperties] - private int _maxArcaneCharges; - - private void OnMaxArcaneChargesChanged(int oldValue, int newValue) + [SerializableProperty(1)] + [CommandProperty(AccessLevel.GameMaster)] + public int MaxArcaneCharges { - Update(); + get => _maxArcaneCharges; + set + { + _maxArcaneCharges = value; + this.MarkDirty(); + InvalidateProperties(); + Update(); + } } [CommandProperty(AccessLevel.GameMaster)] diff --git a/Projects/UOContent/Items/Clothing/OuterTorso.cs b/Projects/UOContent/Items/Clothing/OuterTorso.cs index f3b0b077f..4fda45bb5 100644 --- a/Projects/UOContent/Items/Clothing/OuterTorso.cs +++ b/Projects/UOContent/Items/Clothing/OuterTorso.cs @@ -37,22 +37,21 @@ namespace Server.Items public override double DefaultWeight => 3.0; } - [SerializationGenerator(4, false)] + [SerializationGenerator(3, 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; - private void DeserializeDecayTimer(TimeSpan delay) => BeginDecay(delay); - - private void MigrateFrom(V3Content content) + [DeserializeTimerField(0)] + private void DeserializeDecayTimer(TimeSpan delay) { - if (content.DecayTimerDelay != TimeSpan.MinValue) + if (delay != TimeSpan.MinValue) { - DeserializeDecayTimer(content.DecayTimerDelay); + BeginDecay(delay); } } @@ -325,26 +324,34 @@ namespace Server.Items public override double DefaultWeight => 3.0; - [SerializableField(0, fieldChanged: nameof(OnCurArcaneChargesChanged))] [EncodedInt] - [SerializedCommandProperty(AccessLevel.GameMaster)] - [InvalidateProperties] - private int _curArcaneCharges; - - private void OnCurArcaneChargesChanged(int oldValue, int newValue) + [SerializableProperty(0)] + [CommandProperty(AccessLevel.GameMaster)] + public int CurArcaneCharges { - Update(); + get => _curArcaneCharges; + set + { + _curArcaneCharges = value; + InvalidateProperties(); + Update(); + this.MarkDirty(); + } } - [SerializableField(1, fieldChanged: nameof(OnMaxArcaneChargesChanged))] [EncodedInt] - [SerializedCommandProperty(AccessLevel.GameMaster)] - [InvalidateProperties] - private int _maxArcaneCharges; - - private void OnMaxArcaneChargesChanged(int oldValue, int newValue) + [SerializableProperty(1)] + [CommandProperty(AccessLevel.GameMaster)] + public int MaxArcaneCharges { - Update(); + get => _maxArcaneCharges; + set + { + _maxArcaneCharges = value; + InvalidateProperties(); + Update(); + this.MarkDirty(); + } } [CommandProperty(AccessLevel.GameMaster)] diff --git a/Projects/UOContent/Items/Clothing/Shoes.cs b/Projects/UOContent/Items/Clothing/Shoes.cs index f791b3a62..4d80b4699 100644 --- a/Projects/UOContent/Items/Clothing/Shoes.cs +++ b/Projects/UOContent/Items/Clothing/Shoes.cs @@ -54,10 +54,11 @@ namespace Server.Items { [EncodedInt] [InvalidateProperties] - [SerializableField(0, fieldChanged: nameof(OnCurArcaneChargesChanged))] + [SerializableField(0)] [SerializedCommandProperty(AccessLevel.GameMaster)] private int _curArcaneCharges; + [SerializableFieldChanged(0)] [MethodImpl(MethodImplOptions.AggressiveInlining)] private void OnCurArcaneChargesChanged(int oldValue, int newValue) => Update(); @@ -70,15 +71,19 @@ namespace Server.Items public override CraftResource DefaultResource => CraftResource.RegularLeather; - [SerializableField(1, fieldChanged: nameof(OnMaxArcaneChargesChanged))] [EncodedInt] - [SerializedCommandProperty(AccessLevel.GameMaster)] - [InvalidateProperties] - private int _maxArcaneCharges; - - private void OnMaxArcaneChargesChanged(int oldValue, int newValue) + [SerializableProperty(1)] + [CommandProperty(AccessLevel.GameMaster)] + public int MaxArcaneCharges { - Update(); + get => _maxArcaneCharges; + set + { + _maxArcaneCharges = value; + InvalidateProperties(); + Update(); + this.MarkDirty(); + } } [CommandProperty(AccessLevel.GameMaster)] diff --git a/Projects/UOContent/Items/Construction/Doors/BaseDoor.cs b/Projects/UOContent/Items/Construction/Doors/BaseDoor.cs index ef779760b..dc3449c24 100644 --- a/Projects/UOContent/Items/Construction/Doors/BaseDoor.cs +++ b/Projects/UOContent/Items/Construction/Doors/BaseDoor.cs @@ -74,31 +74,43 @@ public abstract partial class BaseDoor : Item, ILockable, ITelekinesisable Movable = false; } - [SerializableField(1, fieldChanged: nameof(OnOpenChanged))] - [SerializedCommandProperty(AccessLevel.GameMaster)] - private bool _open; - - private void OnOpenChanged(bool oldValue, bool newValue) + [SerializableProperty(1)] + [CommandProperty(AccessLevel.GameMaster)] + public bool Open { - ItemID = _open ? _openedId : _closedId; - if (_open) + get => _open; + set { - 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; + 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(); + } } } diff --git a/Projects/UOContent/Items/Containers/Fillable Containers/FillableContainer.cs b/Projects/UOContent/Items/Containers/Fillable Containers/FillableContainer.cs index da4d8b77f..a9da67dbb 100644 --- a/Projects/UOContent/Items/Containers/Fillable Containers/FillableContainer.cs +++ b/Projects/UOContent/Items/Containers/Fillable Containers/FillableContainer.cs @@ -3,22 +3,19 @@ using ModernUO.Serialization; namespace Server.Items; -[SerializationGenerator(3, false)] +[SerializationGenerator(2, false)] public abstract partial class FillableContainer : LockableContainer { + [TimerDrift] [SerializableField(1)] - [DeserializeTimer(nameof(DeserializeRespawnTimer))] private Timer _respawnTimer; - private void DeserializeRespawnTimer(TimeSpan delay) => _respawnTimer = Timer.DelayCall(delay, Respawn); - - private void MigrateFrom(V2Content content) + [DeserializeTimerField(1)] + private void DeserializeRespawnTimer(TimeSpan delay) { - _contentType = content.ContentType; - - if (content.RespawnTimerDelay != TimeSpan.MinValue) + if (delay > TimeSpan.MinValue) { - DeserializeRespawnTimer(content.RespawnTimerDelay); + _respawnTimer = Timer.DelayCall(delay, Respawn); } } diff --git a/Projects/UOContent/Items/Containers/MarkContainer.cs b/Projects/UOContent/Items/Containers/MarkContainer.cs index 04e7c89ea..fa3f7e440 100644 --- a/Projects/UOContent/Items/Containers/MarkContainer.cs +++ b/Projects/UOContent/Items/Containers/MarkContainer.cs @@ -3,13 +3,14 @@ using ModernUO.Serialization; namespace Server.Items; -[SerializationGenerator(1, false)] +[SerializationGenerator(0, 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) @@ -18,19 +19,6 @@ 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; @@ -62,19 +50,23 @@ public partial class MarkContainer : LockableContainer } } - [SerializableField(0, fieldChanged: nameof(OnAutoLockChanged))] - [SerializedCommandProperty(AccessLevel.GameMaster)] - private bool _autoLock; - - private void OnAutoLockChanged(bool oldValue, bool newValue) + [SerializableProperty(0)] + [CommandProperty(AccessLevel.GameMaster)] + public bool AutoLock { - if (!_autoLock) + get => _autoLock; + set { - StopTimer(); - } - else if (!Locked) - { - _relockTimer ??= new InternalTimer(this); + _autoLock = value; + + 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 a1aafa0b3..b59911a8a 100644 --- a/Projects/UOContent/Items/Containers/TreasureMapChest.cs +++ b/Projects/UOContent/Items/Containers/TreasureMapChest.cs @@ -9,11 +9,10 @@ using Server.Network; namespace Server.Items; -[SerializationGenerator(4, false)] +[SerializationGenerator(2, false)] public partial class TreasureMapChest : LockableContainer { [Tidy] - [CanBeNull] [SerializableField(0, setter: "private")] private List _guardians; @@ -29,11 +28,12 @@ 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) @@ -42,29 +42,11 @@ 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")] [SerializedCommandProperty(AccessLevel.GameMaster)] private HashSet _lifted; - // False only while the constructor fills the chest; deserialized chests are always filled. - private bool _filled; - [Constructible] public TreasureMapChest(int level) : this(null, level) { @@ -76,10 +58,10 @@ public partial class TreasureMapChest : LockableContainer _level = level; _temporary = temporary; + _guardians = []; _expireTimer = Timer.DelayCall(TimeSpan.FromHours(3.0), Delete); Fill(this, level); - _filled = true; } public override int LabelNumber => 3000541; @@ -200,6 +182,7 @@ public partial class TreasureMapChest : LockableContainer 2 => 76, 3 => 84, 4 => 92, + 5 => 100, _ => 100 }; @@ -317,17 +300,14 @@ public partial class TreasureMapChest : LockableContainer if (_level == 0 && from.AccessLevel < AccessLevel.GameMaster) { - if (_guardians != null) + for (var i = 0; i < _guardians.Count; i++) { - for (var i = 0; i < _guardians.Count; i++) + var m = _guardians[i]; + if (m.Alive) { - var m = _guardians[i]; - if (m.Alive) - { - // You must first kill the guardians before you may open this chest. - from.SendLocalizedMessage(1046448); - return true; - } + // You must first kill the guardians before you may open this chest. + from.SendLocalizedMessage(1046448); + return true; } } @@ -381,21 +361,12 @@ public partial class TreasureMapChest : LockableContainer public override bool CheckLift(Mobile from, Item item, ref LRReason reject) => CheckLoot(from, true) && base.CheckLift(from, item, ref reject); - public override void OnItemAdded(Item item) - { - base.OnItemAdded(item); - - if (_filled) - { - _lifted ??= []; - _lifted.Add(item); - } - } - public override void OnItemLifted(Mobile from, Item item) { + var notYetLifted = _lifted?.Contains(item) != true; from.RevealingAction(); - if (_lifted?.Contains(item) != true) + + if (notYetLifted) { _lifted ??= []; _lifted.Add(item); @@ -422,6 +393,8 @@ public partial class TreasureMapChest : LockableContainer private void Deserialize(IGenericReader reader, int version) { + _guardians = []; + _owner = reader.ReadEntity(); _level = reader.ReadInt(); var expireTimerNext = reader.ReadDeltaTime(); @@ -429,34 +402,12 @@ public partial class TreasureMapChest : LockableContainer _lifted = reader.ReadEntitySet(); } - private void MigrateFrom(V2Content content) - { - _guardians = content.Guardians; - if (_guardians.Count == 0) - { - _guardians = null; - } - _temporary = content.Temporary; - _owner = content.Owner; - _level = content.Level; - _lifted = content.Lifted; - if (_lifted.Count == 0) - { - _lifted = null; - } - - var expireTimerDelay = content.ExpireTimerDelay; - DeserializeExpireTimer(expireTimerDelay); - } - - [AfterDeserialization] + [AfterDeserialization(false)] private void AfterDeserialization() { - _filled = true; - if (_expireTimer == null) { - Timer.DelayCall(Delete); + Delete(); } } diff --git a/Projects/UOContent/Items/Deeds/DragonBardingDeed.cs b/Projects/UOContent/Items/Deeds/DragonBardingDeed.cs index 5ed370153..6599e6c56 100644 --- a/Projects/UOContent/Items/Deeds/DragonBardingDeed.cs +++ b/Projects/UOContent/Items/Deeds/DragonBardingDeed.cs @@ -28,14 +28,17 @@ public partial class DragonBardingDeed : Item, ICraftable public override int LabelNumber => _exceptional ? 1053181 : 1053012; // dragon barding deed - [SerializableField(2, fieldChanged: nameof(OnResourceChanged))] - [SerializedCommandProperty(AccessLevel.GameMaster)] - [InvalidateProperties] - private CraftResource _resource; - - private void OnResourceChanged(CraftResource oldValue, CraftResource newValue) + [SerializableProperty(2)] + [CommandProperty(AccessLevel.GameMaster)] + public CraftResource Resource { - Hue = CraftResources.GetHue(newValue); + get => _resource; + set + { + _resource = value; + Hue = CraftResources.GetHue(value); + InvalidateProperties(); + } } public int OnCraft( diff --git a/Projects/UOContent/Items/Food/Beverage.cs b/Projects/UOContent/Items/Food/Beverage.cs index 85eecf17f..3a9f50297 100644 --- a/Projects/UOContent/Items/Food/Beverage.cs +++ b/Projects/UOContent/Items/Food/Beverage.cs @@ -324,21 +324,27 @@ public abstract partial class BaseBeverage : Item, IHasQuantity [CommandProperty(AccessLevel.GameMaster)] public bool IsFull => _quantity >= MaxQuantity; - [SerializableField(2, fieldChanged: nameof(OnContentChanged))] - [SerializedCommandProperty(AccessLevel.GameMaster)] - [InvalidateProperties] - private BeverageType _content; - - private void OnContentChanged(BeverageType oldValue, BeverageType newValue) + [SerializableProperty(2)] + [CommandProperty(AccessLevel.GameMaster)] + public BeverageType Content { - var itemID = ComputeItemID(); - if (itemID > 0) + get => _content; + set { - ItemID = itemID; - } - else - { - Delete(); + _content = value; + + InvalidateProperties(); + + var itemID = ComputeItemID(); + + if (itemID > 0) + { + ItemID = itemID; + } + else + { + Delete(); + } } } diff --git a/Projects/UOContent/Items/Food/Cooking.cs b/Projects/UOContent/Items/Food/Cooking.cs index cd010432d..6cff16d72 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; - [SerializableField(0, fieldChanged: nameof(OnQuantityChanged), allowFieldChange: nameof(AllowQuantityChange))] - [SerializedCommandProperty(AccessLevel.GameMaster)] - private int _quantity; - - private bool AllowQuantityChange(ref int value) + [SerializableProperty(0)] + [CommandProperty(AccessLevel.GameMaster)] + public int Quantity { - value = Math.Min(20, Math.Max(0, value)); - return true; - } + get => _quantity; + set + { + _quantity = Math.Min(20, Math.Max(0, value)); - private void OnQuantityChanged(int oldValue, int newValue) - { - if (_quantity == 0) - { - Delete(); - } - else if (_quantity < 20 && ItemID is 0x1039 or 0x1045) - { - ++ItemID; + if (_quantity == 0) + { + Delete(); + } + else if (_quantity < 20 && ItemID is 0x1039 or 0x1045) + { + ++ItemID; + } + + this.MarkDirty(); } } diff --git a/Projects/UOContent/Items/Games/Mahjong/MahjongGame.cs b/Projects/UOContent/Items/Games/Mahjong/MahjongGame.cs index 89e4d6f8f..de7e663b7 100644 --- a/Projects/UOContent/Items/Games/Mahjong/MahjongGame.cs +++ b/Projects/UOContent/Items/Games/Mahjong/MahjongGame.cs @@ -55,33 +55,55 @@ public partial class MahjongGame : Item, ISecurable public override double DefaultWeight => 5.0; - [SerializableField(6, fieldChanged: nameof(OnShowScoresChanged))] - [SerializedCommandProperty(AccessLevel.GameMaster)] - private bool _showScores; - - private void OnShowScoresChanged(bool oldValue, bool newValue) + [CommandProperty(AccessLevel.GameMaster)] + [SerializableProperty(6)] + public bool ShowScores { - if (newValue) + get => _showScores; + set { - _players.SendPlayersPacket(true, true); + 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.SendGeneralPacket(true, true); - _players.SendLocalizedMessage(newValue ? 1062777 : 1062778); // The dealer has enabled/disabled score display. } - [SerializableField(7, fieldChanged: nameof(OnSpectatorVisionChanged))] - [SerializedCommandProperty(AccessLevel.GameMaster)] - [InvalidateProperties] - private bool _spectatorVision; - - private void OnSpectatorVisionChanged(bool oldValue, bool newValue) + [CommandProperty(AccessLevel.GameMaster)] + [SerializableProperty(7)] + public bool SpectatorVision { - if (_players.IsInGamePlayer(_players.DealerPosition)) + get => _spectatorVision; + set { - _players.Dealer.NetState.SendMahjongGeneralInfo(this); + 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.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 e693c0aaa..4eb01cbe0 100644 --- a/Projects/UOContent/Items/Jewels/BaseJewel.cs +++ b/Projects/UOContent/Items/Jewels/BaseJewel.cs @@ -93,13 +93,16 @@ public abstract partial class BaseJewel : Item, ICraftable, IAosItem } } - [SerializableField(2, fieldChanged: nameof(OnResourceChanged))] - [SerializedCommandProperty(AccessLevel.GameMaster)] - private CraftResource _resource; - - private void OnResourceChanged(CraftResource oldValue, CraftResource newValue) + [SerializableProperty(2)] + [CommandProperty(AccessLevel.GameMaster)] + public CraftResource Resource { - Hue = CraftResources.GetHue(_resource); + get => _resource; + set + { + _resource = value; + 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 4bd63ba63..d3bff4af5 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(2, false)] +[SerializationGenerator(1, false)] public abstract partial class BaseLight : Item { public static readonly bool Burnout = false; @@ -16,10 +16,11 @@ 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) @@ -28,19 +29,6 @@ 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 07df233e9..a52b1a12d 100644 --- a/Projects/UOContent/Items/Misc/CommunicationCrystals.cs +++ b/Projects/UOContent/Items/Misc/CommunicationCrystals.cs @@ -275,16 +275,8 @@ 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; @@ -305,6 +297,20 @@ 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 deleted file mode 100644 index bc9ce047e..000000000 --- a/Projects/UOContent/Items/Misc/Corpses/Corpse.Migrations.cs +++ /dev/null @@ -1,179 +0,0 @@ -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 04e9806c9..1ee04170c 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(19, false)] +[SerializationGenerator(17, 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; - [AnchoredDateTime] + [DeltaDateTime] [SerializableField(2)] [SerializedCommandProperty(AccessLevel.GameMaster)] private DateTime _timeOfDeath; @@ -114,10 +114,11 @@ 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")] @@ -317,6 +318,127 @@ 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 17267a6cc..ead812e2f 100644 --- a/Projects/UOContent/Items/Misc/Corpses/DecayedCorpse.cs +++ b/Projects/UOContent/Items/Misc/Corpses/DecayedCorpse.cs @@ -3,25 +3,18 @@ using ModernUO.Serialization; namespace Server.Items; -[SerializationGenerator(3, false)] +[SerializationGenerator(2, 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 7e5a74d8b..c5dd9c0de 100644 --- a/Projects/UOContent/Items/Misc/MorphItem.cs +++ b/Projects/UOContent/Items/Misc/MorphItem.cs @@ -30,24 +30,20 @@ public partial class MorphItem : Item _outsideRange = outRange; } - [SerializableField(0, allowFieldChange: nameof(AllowOutsideRangeChange))] - [SerializedCommandProperty(AccessLevel.GameMaster)] - private int _outsideRange; - - private bool AllowOutsideRangeChange(ref int value) + [SerializableProperty(0)] + [CommandProperty(AccessLevel.GameMaster)] + public int OutsideRange { - value = Math.Clamp(value, 0, 18); - return true; + get => _outsideRange; + set => _outsideRange = Math.Clamp(value, 0, 18); } - [SerializableField(3, allowFieldChange: nameof(AllowInsideRangeChange))] - [SerializedCommandProperty(AccessLevel.GameMaster)] - private int _insideRange; - - private bool AllowInsideRangeChange(ref int value) + [SerializableProperty(3)] + [CommandProperty(AccessLevel.GameMaster)] + public int InsideRange { - value = Math.Clamp(value, 0, 18); - return true; + get => _insideRange; + set => _insideRange = Math.Clamp(value, 0, 18); } [CommandProperty(AccessLevel.GameMaster)] diff --git a/Projects/UOContent/Items/Misc/WarningItem.cs b/Projects/UOContent/Items/Misc/WarningItem.cs index 3779e4900..5d0b491a4 100644 --- a/Projects/UOContent/Items/Misc/WarningItem.cs +++ b/Projects/UOContent/Items/Misc/WarningItem.cs @@ -14,16 +14,8 @@ 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; @@ -47,6 +39,18 @@ 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 70824c9f6..427871f16 100644 --- a/Projects/UOContent/Items/Quivers/BaseQuiver.cs +++ b/Projects/UOContent/Items/Quivers/BaseQuiver.cs @@ -11,62 +11,64 @@ 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 8170b286d..ce772da40 100644 --- a/Projects/UOContent/Items/Resources/Blacksmithing/Ingots.cs +++ b/Projects/UOContent/Items/Resources/Blacksmithing/Ingots.cs @@ -16,14 +16,22 @@ public abstract partial class BaseIngot : Item, ICommodity public override double DefaultWeight => 0.1; - [SerializableField(0, fieldChanged: nameof(OnResourceChanged))] - [SerializedCommandProperty(AccessLevel.GameMaster)] - [InvalidateProperties] - private CraftResource _resource; - - private void OnResourceChanged(CraftResource oldValue, CraftResource newValue) + [SerializableProperty(0)] + [CommandProperty(AccessLevel.GameMaster)] + public CraftResource Resource { - Hue = CraftResources.GetHue(newValue); + get => _resource; + set + { + if (_resource != value) + { + _resource = value; + Hue = CraftResources.GetHue(value); + + InvalidateProperties(); + this.MarkDirty(); + } + } } public override int LabelNumber diff --git a/Projects/UOContent/Items/Resources/Blacksmithing/Ore.cs b/Projects/UOContent/Items/Resources/Blacksmithing/Ore.cs index 6ae0c71c3..3116d7136 100644 --- a/Projects/UOContent/Items/Resources/Blacksmithing/Ore.cs +++ b/Projects/UOContent/Items/Resources/Blacksmithing/Ore.cs @@ -17,14 +17,22 @@ public abstract partial class BaseOre : Item _resource = resource; } - [SerializableField(0, fieldChanged: nameof(OnResourceChanged))] - [SerializedCommandProperty(AccessLevel.GameMaster)] - [InvalidateProperties] - private CraftResource _resource; - - private void OnResourceChanged(CraftResource oldValue, CraftResource newValue) + [SerializableProperty(0)] + [CommandProperty(AccessLevel.GameMaster)] + public CraftResource Resource { - Hue = CraftResources.GetHue(newValue); + get => _resource; + set + { + if (_resource != value) + { + _resource = value; + Hue = CraftResources.GetHue(value); + + InvalidateProperties(); + this.MarkDirty(); + } + } } public override int LabelNumber diff --git a/Projects/UOContent/Items/Resources/Blacksmithing/Scales.cs b/Projects/UOContent/Items/Resources/Blacksmithing/Scales.cs index 277ed569d..fa63d902c 100644 --- a/Projects/UOContent/Items/Resources/Blacksmithing/Scales.cs +++ b/Projects/UOContent/Items/Resources/Blacksmithing/Scales.cs @@ -14,14 +14,22 @@ public abstract partial class BaseScales : Item, ICommodity _resource = resource; } - [SerializableField(0, fieldChanged: nameof(OnResourceChanged))] - [SerializedCommandProperty(AccessLevel.GameMaster)] - [InvalidateProperties] - private CraftResource _resource; - - private void OnResourceChanged(CraftResource oldValue, CraftResource newValue) + [SerializableProperty(0)] + [CommandProperty(AccessLevel.GameMaster)] + public CraftResource Resource { - Hue = CraftResources.GetHue(newValue); + get => _resource; + set + { + if (_resource != value) + { + _resource = value; + Hue = CraftResources.GetHue(value); + + InvalidateProperties(); + this.MarkDirty(); + } + } } 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 089181ebe..ae4f215d7 100644 --- a/Projects/UOContent/Items/Resources/Masonry/Granite.cs +++ b/Projects/UOContent/Items/Resources/Masonry/Granite.cs @@ -15,14 +15,22 @@ public abstract partial class BaseGranite : Item public override double DefaultWeight => Core.ML ? 1.0 : 10.0; - [SerializableField(0, fieldChanged: nameof(OnResourceChanged))] - [SerializedCommandProperty(AccessLevel.GameMaster)] - [InvalidateProperties] - private CraftResource _resource; - - private void OnResourceChanged(CraftResource oldValue, CraftResource newValue) + [SerializableProperty(0)] + [CommandProperty(AccessLevel.GameMaster)] + public CraftResource Resource { - Hue = CraftResources.GetHue(newValue); + get => _resource; + set + { + if (_resource != value) + { + _resource = value; + Hue = CraftResources.GetHue(value); + + InvalidateProperties(); + this.MarkDirty(); + } + } } 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 d64eb2f0b..df6423fb2 100644 --- a/Projects/UOContent/Items/Resources/Tailor/Hides.cs +++ b/Projects/UOContent/Items/Resources/Tailor/Hides.cs @@ -15,14 +15,22 @@ public abstract partial class BaseHides : Item, ICommodity public override double DefaultWeight => 5.0; - [SerializableField(0, fieldChanged: nameof(OnResourceChanged))] - [SerializedCommandProperty(AccessLevel.GameMaster)] - [InvalidateProperties] - private CraftResource _resource; - - private void OnResourceChanged(CraftResource oldValue, CraftResource newValue) + [SerializableProperty(0)] + [CommandProperty(AccessLevel.GameMaster)] + public CraftResource Resource { - Hue = CraftResources.GetHue(newValue); + get => _resource; + set + { + if (_resource != value) + { + _resource = value; + Hue = CraftResources.GetHue(value); + + InvalidateProperties(); + this.MarkDirty(); + } + } } public override int LabelNumber diff --git a/Projects/UOContent/Items/Resources/Tailor/Leathers.cs b/Projects/UOContent/Items/Resources/Tailor/Leathers.cs index afb757e1f..173e2dae0 100644 --- a/Projects/UOContent/Items/Resources/Tailor/Leathers.cs +++ b/Projects/UOContent/Items/Resources/Tailor/Leathers.cs @@ -15,14 +15,22 @@ public abstract partial class BaseLeather : Item, ICommodity public override double DefaultWeight => 1.0; - [SerializableField(0, fieldChanged: nameof(OnResourceChanged))] - [SerializedCommandProperty(AccessLevel.GameMaster)] - [InvalidateProperties] - private CraftResource _resource; - - private void OnResourceChanged(CraftResource oldValue, CraftResource newValue) + [SerializableProperty(0)] + [CommandProperty(AccessLevel.GameMaster)] + public CraftResource Resource { - Hue = CraftResources.GetHue(newValue); + get => _resource; + set + { + if (_resource != value) + { + _resource = value; + Hue = CraftResources.GetHue(value); + + InvalidateProperties(); + this.MarkDirty(); + } + } } 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 53c8077f1..9ff0d8386 100644 --- a/Projects/UOContent/Items/Skill Items/Carpenter Items/Board.cs +++ b/Projects/UOContent/Items/Skill Items/Carpenter Items/Board.cs @@ -21,14 +21,22 @@ public partial class Board : Item, ICommodity Hue = CraftResources.GetHue(resource); } - [SerializableField(0, fieldChanged: nameof(OnResourceChanged))] - [SerializedCommandProperty(AccessLevel.GameMaster)] - [InvalidateProperties] - private CraftResource _resource; - - private void OnResourceChanged(CraftResource oldValue, CraftResource newValue) + [SerializableProperty(0)] + [CommandProperty(AccessLevel.GameMaster)] + public CraftResource Resource { - Hue = CraftResources.GetHue(newValue); + get => _resource; + set + { + if (_resource != value) + { + _resource = value; + Hue = CraftResources.GetHue(value); + + InvalidateProperties(); + this.MarkDirty(); + } + } } 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 bf626c2fd..5d6062099 100644 --- a/Projects/UOContent/Items/Skill Items/Fishing/Misc/MessageInABottle.cs +++ b/Projects/UOContent/Items/Skill Items/Fishing/Misc/MessageInABottle.cs @@ -25,14 +25,16 @@ public partial class MessageInABottle : Item public override int LabelNumber => 1041080; // a message in a bottle - [SerializableField(0, allowFieldChange: nameof(AllowLevelChange))] - [SerializedCommandProperty(AccessLevel.GameMaster)] - private int _level; - - private bool AllowLevelChange(ref int value) + [SerializableProperty(0)] + [CommandProperty(AccessLevel.GameMaster)] + public int Level { - value = Math.Max(1, Math.Min(value, 4)); - return true; + get => _level; + set + { + _level = Math.Max(1, Math.Min(value, 4)); + this.MarkDirty(); + } } 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 5374f8716..f8450e63f 100644 --- a/Projects/UOContent/Items/Skill Items/Fishing/Misc/SOS.cs +++ b/Projects/UOContent/Items/Skill Items/Fishing/Misc/SOS.cs @@ -103,20 +103,18 @@ public partial class SOS : Item [CommandProperty(AccessLevel.GameMaster)] public bool IsAncient => _level >= 4; - [SerializableField(0, fieldChanged: nameof(OnLevelChanged), allowFieldChange: nameof(AllowLevelChange))] - [SerializedCommandProperty(AccessLevel.GameMaster)] - [InvalidateProperties] - private int _level; - - private bool AllowLevelChange(ref int value) + [SerializableProperty(0)] + [CommandProperty(AccessLevel.GameMaster)] + public int Level { - value = Math.Max(1, Math.Min(value, 4)); - return true; - } - - private void OnLevelChanged(int oldValue, int newValue) - { - UpdateHue(); + get => _level; + set + { + _level = Math.Max(1, Math.Min(value, 4)); + UpdateHue(); + InvalidateProperties(); + this.MarkDirty(); + } } 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 2a06f535a..2873957d5 100644 --- a/Projects/UOContent/Items/Skill Items/Lumberjack/Log.cs +++ b/Projects/UOContent/Items/Skill Items/Lumberjack/Log.cs @@ -28,14 +28,22 @@ public partial class Log : Item, ICommodity, IAxe public override double DefaultWeight => 2.0; - [SerializableField(0, fieldChanged: nameof(OnResourceChanged))] - [SerializedCommandProperty(AccessLevel.GameMaster)] - [InvalidateProperties] - private CraftResource _resource; - - private void OnResourceChanged(CraftResource oldValue, CraftResource newValue) + [SerializableProperty(0)] + [CommandProperty(AccessLevel.GameMaster)] + public CraftResource Resource { - Hue = CraftResources.GetHue(newValue); + get => _resource; + set + { + if (_resource != value) + { + _resource = value; + Hue = CraftResources.GetHue(value); + + InvalidateProperties(); + this.MarkDirty(); + } + } } 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 14db7ac1e..68b6f324a 100644 --- a/Projects/UOContent/Items/Skill Items/Magical/Misc/RecallRune.cs +++ b/Projects/UOContent/Items/Skill Items/Magical/Misc/RecallRune.cs @@ -47,24 +47,36 @@ public partial class RecallRune : Item } } - [SerializableField(2, fieldChanged: nameof(OnMarkedChanged))] - [SerializedCommandProperty(AccessLevel.Counselor, AccessLevel.GameMaster)] - [InvalidateProperties] - private bool _marked; - - private void OnMarkedChanged(bool oldValue, bool newValue) + [SerializableProperty(2)] + [CommandProperty(AccessLevel.Counselor, AccessLevel.GameMaster)] + public bool Marked { - CalculateHue(); + get => _marked; + set + { + if (_marked != value) + { + _marked = value; + CalculateHue(); + InvalidateProperties(); + } + } } - [SerializableField(4, fieldChanged: nameof(OnTargetMapChanged))] - [SerializedCommandProperty(AccessLevel.Counselor, AccessLevel.GameMaster)] - [InvalidateProperties] - private Map _targetMap; - - private void OnTargetMapChanged(Map oldValue, Map newValue) + [SerializableProperty(4)] + [CommandProperty(AccessLevel.Counselor, AccessLevel.GameMaster)] + public Map TargetMap { - CalculateHue(); + get => _targetMap; + set + { + if (_targetMap != value) + { + _targetMap = value; + CalculateHue(); + InvalidateProperties(); + } + } } 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 f9ecdfa22..0a5a0f308 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 9447fddfa..f1ced7308 100644 --- a/Projects/UOContent/Items/Skill Items/Magical/Spellbook.cs +++ b/Projects/UOContent/Items/Skill Items/Magical/Spellbook.cs @@ -133,19 +133,28 @@ public partial class Spellbook : Item, ICraftable, ISlayer, IAosItem public virtual int BookOffset => 0; public virtual int BookCount => 64; - [SerializableField(7, fieldChanged: nameof(OnContentChanged))] - [SerializedCommandProperty(AccessLevel.GameMaster)] - [InvalidateProperties] - private ulong _content; - - private void OnContentChanged(ulong oldValue, ulong newValue) + [CommandProperty(AccessLevel.GameMaster)] + [SerializableProperty(7)] + public ulong Content { - // This assignment will mark it as dirty - SpellCount = 0; - while (newValue > 0) + get => _content; + set { - _spellCount += (int)(newValue & 0x1); - newValue >>= 1; + if (_content != value) + { + _content = value; + + // This assignment will mark it as dirty + SpellCount = 0; + + while (value > 0) + { + _spellCount += (int)(value & 0x1); + value >>= 1; + } + + InvalidateProperties(); + } } } diff --git a/Projects/UOContent/Items/Skill Items/Misc/RepairDeed.cs b/Projects/UOContent/Items/Skill Items/Misc/RepairDeed.cs index e4d486d10..dbb8df68c 100644 --- a/Projects/UOContent/Items/Skill Items/Misc/RepairDeed.cs +++ b/Projects/UOContent/Items/Skill Items/Misc/RepairDeed.cs @@ -62,15 +62,17 @@ public partial class RepairDeed : Item public override bool DisplayLootType => false; - [SerializableField(1, allowFieldChange: nameof(AllowSkillLevelChange))] - [SerializedCommandProperty(AccessLevel.GameMaster)] - [InvalidateProperties] - private double _skillLevel; - - private bool AllowSkillLevelChange(ref double value) + [CommandProperty(AccessLevel.GameMaster)] + [SerializableProperty(1)] + public double SkillLevel { - value = Math.Clamp(value, 0, 120.0); - return true; + get => _skillLevel; + set + { + _skillLevel = Math.Clamp(value, 0, 120.0); + InvalidateProperties(); + this.MarkDirty(); + } } 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 f7716a4a1..b01416211 100644 --- a/Projects/UOContent/Items/Skill Items/Musical Instruments/BaseInstrument.cs +++ b/Projects/UOContent/Items/Skill Items/Musical Instruments/BaseInstrument.cs @@ -74,13 +74,16 @@ public abstract partial class BaseInstrument : Item, ICraftable, ISlayer } } - [SerializableField(1, fieldChanged: nameof(OnLastReplenishedChanged))] - [SerializedCommandProperty(AccessLevel.GameMaster)] - private DateTime _lastReplenished; - - private void OnLastReplenishedChanged(DateTime oldValue, DateTime newValue) + [SerializableProperty(1)] + [CommandProperty(AccessLevel.GameMaster)] + public DateTime LastReplenished { - CheckReplenishUses(); + get => _lastReplenished; + set + { + _lastReplenished = value; + 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 e35cca471..6b557b7be 100644 --- a/Projects/UOContent/Items/Skill Items/Tailor Items/Dyetubs/DyeTub.cs +++ b/Projects/UOContent/Items/Skill Items/Tailor Items/Dyetubs/DyeTub.cs @@ -41,13 +41,20 @@ namespace Server.Items public virtual bool AllowDyables => true; - [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; + [SerializableProperty(2)] + [CommandProperty(AccessLevel.GameMaster)] + public int DyedHue + { + get => _dyedHue; + set + { + if (_redyable) + { + _dyedHue = value; + Hue = value; + } + } + } // 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 595b6b11d..6010ec376 100644 --- a/Projects/UOContent/Items/Skill Items/Tools/BaseRunicTool.cs +++ b/Projects/UOContent/Items/Skill Items/Tools/BaseRunicTool.cs @@ -60,14 +60,17 @@ public abstract partial class BaseRunicTool : BaseTool public BaseRunicTool(CraftResource resource, int uses, int itemID) : base(uses, itemID) => _resource = resource; - [SerializableField(0, fieldChanged: nameof(OnResourceChanged))] - [SerializedCommandProperty(AccessLevel.GameMaster)] - [InvalidateProperties] - private CraftResource _resource; - - private void OnResourceChanged(CraftResource oldValue, CraftResource newValue) + [SerializableProperty(0)] + [CommandProperty(AccessLevel.GameMaster)] + public CraftResource Resource { - Hue = CraftResources.GetHue(_resource); + get => _resource; + set + { + _resource = value; + Hue = CraftResources.GetHue(_resource); + InvalidateProperties(); + } } 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 404fc9012..3bdfd00fa 100644 --- a/Projects/UOContent/Items/Special/8th Anniversary Items/FountainOfLife.cs +++ b/Projects/UOContent/Items/Special/8th Anniversary Items/FountainOfLife.cs @@ -31,7 +31,6 @@ public partial class FountainOfLife : BaseAddonContainer public const int MaxCharges = 10; [SerializableField(1)] - [DeserializeTimer(nameof(DeserializeTimer), wallClock: true)] private Timer _timer; [Constructible] @@ -40,6 +39,7 @@ 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,15 +54,17 @@ public partial class FountainOfLife : BaseAddonContainer public override int DefaultDropSound => 66; public override int DefaultMaxItems => 125; - [SerializableField(0, allowFieldChange: nameof(AllowChargesChange))] - [SerializedCommandProperty(AccessLevel.GameMaster)] - [InvalidateProperties] - private int _charges; - - private bool AllowChargesChange(ref int value) + [SerializableProperty(0)] + [CommandProperty(AccessLevel.GameMaster)] + public int Charges { - value = Math.Min(value, MaxCharges); - return true; + get => _charges; + set + { + _charges = Math.Min(value, MaxCharges); + InvalidateProperties(); + this.MarkDirty(); + } } public override bool OnDragLift(Mobile from) => false; @@ -181,14 +183,16 @@ public partial class FountainOfLifeDeed : BaseAddonContainerDeed public override int LabelNumber => 1075197; // Fountain of Life public override BaseAddonContainer Addon => new FountainOfLife(_charges); - [SerializableField(0, allowFieldChange: nameof(AllowChargesChange))] - [SerializedCommandProperty(AccessLevel.GameMaster)] - [InvalidateProperties] - private int _charges; - - private bool AllowChargesChange(ref int value) + [SerializableProperty(0)] + [CommandProperty(AccessLevel.GameMaster)] + public int Charges { - value = Math.Min(value, FountainOfLife.MaxCharges); - return true; + get => _charges; + set + { + _charges = Math.Min(value, FountainOfLife.MaxCharges); + InvalidateProperties(); + this.MarkDirty(); + } } } diff --git a/Projects/UOContent/Items/Special/Heritage Items/FruitTrees.cs b/Projects/UOContent/Items/Special/Heritage Items/FruitTrees.cs index fabb756d8..6a7e5af6f 100644 --- a/Projects/UOContent/Items/Special/Heritage Items/FruitTrees.cs +++ b/Projects/UOContent/Items/Special/Heritage Items/FruitTrees.cs @@ -14,14 +14,12 @@ public abstract partial class BaseFruitTreeAddon : BaseAddon public abstract override BaseAddonDeed Deed { get; } public abstract Item Fruit { get; } - [SerializableField(0, allowFieldChange: nameof(AllowFruitsChange))] - [SerializedCommandProperty(AccessLevel.GameMaster)] - private int _fruits; - - private bool AllowFruitsChange(ref int value) + [SerializableProperty(0)] + [CommandProperty(AccessLevel.GameMaster)] + public int Fruits { - value = Math.Max(value, 0); - return true; + get => _fruits; + set => _fruits = Math.Max(value, 0); } 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 59e2b8277..c24985f9f 100644 --- a/Projects/UOContent/Items/Special/House Raffle/HouseRaffleStone.cs +++ b/Projects/UOContent/Items/Special/House Raffle/HouseRaffleStone.cs @@ -146,24 +146,34 @@ public partial class HouseRaffleStone : Item } } - [SerializableField(3, fieldChanged: nameof(OnPlotBoundsChanged))] - [SerializedCommandProperty(AccessLevel.GameMaster, AccessLevel.Seer)] - [InvalidateProperties] - private Rectangle2D _plotBounds; - - private void OnPlotBoundsChanged(Rectangle2D oldValue, Rectangle2D newValue) + [SerializableProperty(3)] + [CommandProperty(AccessLevel.GameMaster, AccessLevel.Seer)] + public Rectangle2D PlotBounds { - InvalidateRegion(); + get => _plotBounds; + set + { + _plotBounds = value; + + InvalidateRegion(); + InvalidateProperties(); + this.MarkDirty(); + } } - [SerializableField(4, fieldChanged: nameof(OnPlotFacetChanged))] - [SerializedCommandProperty(AccessLevel.GameMaster, AccessLevel.Seer)] - [InvalidateProperties] - private Map _plotFacet; - - private void OnPlotFacetChanged(Map oldValue, Map newValue) + [SerializableProperty(4)] + [CommandProperty(AccessLevel.GameMaster, AccessLevel.Seer)] + public Map PlotFacet { - InvalidateRegion(); + get => _plotFacet; + set + { + _plotFacet = value; + + InvalidateRegion(); + InvalidateProperties(); + this.MarkDirty(); + } } [CommandProperty(AccessLevel.GameMaster)] @@ -180,15 +190,17 @@ public partial class HouseRaffleStone : Item } } - [SerializableField(6, allowFieldChange: nameof(AllowTicketPriceChange))] - [SerializedCommandProperty(AccessLevel.GameMaster, AccessLevel.Seer)] - [InvalidateProperties] - private int _ticketPrice; - - private bool AllowTicketPriceChange(ref int value) + [SerializableProperty(6)] + [CommandProperty(AccessLevel.GameMaster, AccessLevel.Seer)] + public int TicketPrice { - value = Math.Max(0, value); - return true; + get => _ticketPrice; + set + { + _ticketPrice = Math.Max(0, value); + InvalidateProperties(); + this.MarkDirty(); + } } 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 fd26a0060..8d49dd0e4 100644 --- a/Projects/UOContent/Items/Special/MonsterStatuette.cs +++ b/Projects/UOContent/Items/Special/MonsterStatuette.cs @@ -162,15 +162,21 @@ public partial class MonsterStatuette : Item, IRewardItem, IGumpToggleItem _ => fallback }; - [SerializableField(0, fieldChanged: nameof(OnTypeChanged))] - [SerializedCommandProperty(AccessLevel.GameMaster)] - [InvalidateProperties] - private MonsterStatuetteType _type; - - private void OnTypeChanged(MonsterStatuetteType oldValue, MonsterStatuetteType newValue) + [SerializableProperty(0)] + [CommandProperty(AccessLevel.GameMaster)] + public MonsterStatuetteType Type { - ItemID = MonsterStatuetteInfo.GetInfo(_type).ItemID; - Hue = GetStatuetteHue(_type, Hue); + get => _type; + set + { + _type = value; + ItemID = MonsterStatuetteInfo.GetInfo(_type).ItemID; + + Hue = GetStatuetteHue(_type, Hue); + + InvalidateProperties(); + this.MarkDirty(); + } } 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 9441fd5c7..f2772b342 100644 --- a/Projects/UOContent/Items/Special/Solen Items/BagOfSending.cs +++ b/Projects/UOContent/Items/Special/Solen Items/BagOfSending.cs @@ -36,41 +36,50 @@ public partial class BagOfSending : Item, TranslocationItem public override int LabelNumber => 1054104; // a bag of sending - [SerializableField(0, fieldChanged: nameof(OnBagOfSendingHueChanged))] - [SerializedCommandProperty(AccessLevel.GameMaster)] - private BagOfSendingHue _bagOfSendingHue; - - private void OnBagOfSendingHueChanged(BagOfSendingHue oldValue, BagOfSendingHue newValue) + [SerializableProperty(0)] + [CommandProperty(AccessLevel.GameMaster)] + public BagOfSendingHue BagOfSendingHue { - Hue = newValue switch + get => _bagOfSendingHue; + set { - BagOfSendingHue.Yellow => 0x8A5, - BagOfSendingHue.Blue => 0x8AD, - BagOfSendingHue.Red => 0x89B, - _ => Hue - }; + _bagOfSendingHue = value; + + Hue = value switch + { + BagOfSendingHue.Yellow => 0x8A5, + BagOfSendingHue.Blue => 0x8AD, + BagOfSendingHue.Red => 0x89B, + _ => Hue + }; + this.MarkDirty(); + } } - [SerializableField(1, allowFieldChange: nameof(AllowChargesChange))] - [SerializedCommandProperty(AccessLevel.GameMaster)] - [InvalidateProperties] - private int _charges; - - private bool AllowChargesChange(ref int value) + [SerializableProperty(1)] + [CommandProperty(AccessLevel.GameMaster)] + public int Charges { - value = Math.Clamp(value, 0, MaxCharges); - return true; + get => _charges; + set + { + _charges = Math.Clamp(value, 0, MaxCharges); + InvalidateProperties(); + this.MarkDirty(); + } } - [SerializableField(2, allowFieldChange: nameof(AllowRechargesChange))] - [SerializedCommandProperty(AccessLevel.GameMaster)] - [InvalidateProperties] - private int _recharges; - - private bool AllowRechargesChange(ref int value) + [SerializableProperty(2)] + [CommandProperty(AccessLevel.GameMaster)] + public int Recharges { - value = Math.Clamp(value, 0, MaxRecharges); - return true; + get => _recharges; + set + { + _recharges = Math.Clamp(value, 0, MaxRecharges); + InvalidateProperties(); + this.MarkDirty(); + } } [CommandProperty(AccessLevel.GameMaster)] diff --git a/Projects/UOContent/Items/Special/Solen Items/BallOfSummoning.cs b/Projects/UOContent/Items/Special/Solen Items/BallOfSummoning.cs index 070a802b1..a76833e30 100644 --- a/Projects/UOContent/Items/Special/Solen Items/BallOfSummoning.cs +++ b/Projects/UOContent/Items/Special/Solen Items/BallOfSummoning.cs @@ -29,26 +29,30 @@ public partial class BallOfSummoning : Item, TranslocationItem public override double DefaultWeight => 10.0; - [SerializableField(0, allowFieldChange: nameof(AllowRechargesChange))] - [SerializedCommandProperty(AccessLevel.GameMaster)] - [InvalidateProperties] - private int _recharges; - - private bool AllowRechargesChange(ref int value) + [SerializableProperty(0)] + [CommandProperty(AccessLevel.GameMaster)] + public int Recharges { - value = Math.Clamp(value, 0, MaxRecharges); - return true; + get => _recharges; + set + { + _recharges = Math.Clamp(value, 0, MaxRecharges); + InvalidateProperties(); + this.MarkDirty(); + } } - [SerializableField(1, allowFieldChange: nameof(AllowChargesChange))] - [SerializedCommandProperty(AccessLevel.GameMaster)] - [InvalidateProperties] - private int _charges; - - private bool AllowChargesChange(ref int value) + [SerializableProperty(1)] + [CommandProperty(AccessLevel.GameMaster)] + public int Charges { - value = Math.Clamp(value, 0, MaxCharges); - return true; + get => _charges; + set + { + _charges = Math.Clamp(value, 0, MaxCharges); + InvalidateProperties(); + this.MarkDirty(); + } } [SerializableProperty(2)] diff --git a/Projects/UOContent/Items/Special/Solen Items/BraceletOfBinding.cs b/Projects/UOContent/Items/Special/Solen Items/BraceletOfBinding.cs index 1d508c4ac..2f82791c2 100644 --- a/Projects/UOContent/Items/Special/Solen Items/BraceletOfBinding.cs +++ b/Projects/UOContent/Items/Special/Solen Items/BraceletOfBinding.cs @@ -27,26 +27,30 @@ public partial class BraceletOfBinding : BaseBracelet, TranslocationItem public override double DefaultWeight => 1.0; - [SerializableField(0, allowFieldChange: nameof(AllowRechargesChange))] - [SerializedCommandProperty(AccessLevel.GameMaster)] - [InvalidateProperties] - private int _recharges; - - private bool AllowRechargesChange(ref int value) + [SerializableProperty(0)] + [CommandProperty(AccessLevel.GameMaster)] + public int Recharges { - value = Math.Clamp(value, 0, MaxRecharges); - return true; + get => _recharges; + set + { + _recharges = Math.Clamp(value, 0, MaxRecharges); + InvalidateProperties(); + this.MarkDirty(); + } } - [SerializableField(1, allowFieldChange: nameof(AllowChargesChange))] - [SerializedCommandProperty(AccessLevel.GameMaster)] - [InvalidateProperties] - private int _charges; - - private bool AllowChargesChange(ref int value) + [SerializableProperty(1)] + [CommandProperty(AccessLevel.GameMaster)] + public int Charges { - value = Math.Clamp(value, 0, MaxCharges); - return true; + get => _charges; + set + { + _charges = Math.Clamp(value, 0, MaxCharges); + InvalidateProperties(); + this.MarkDirty(); + } } [SerializableProperty(3)] diff --git a/Projects/UOContent/Items/Special/SoulStone.cs b/Projects/UOContent/Items/Special/SoulStone.cs index d26f6792c..bcad4a749 100644 --- a/Projects/UOContent/Items/Special/SoulStone.cs +++ b/Projects/UOContent/Items/Special/SoulStone.cs @@ -83,14 +83,20 @@ public partial class SoulStone : Item, ISecurable } } - [SerializableField(6, fieldChanged: nameof(OnSkillValueChanged))] - [SerializedCommandProperty(AccessLevel.GameMaster)] - [InvalidateProperties] - private double _skillValue; - - private void OnSkillValueChanged(double oldValue, double newValue) + [SerializableProperty(6)] + [CommandProperty(AccessLevel.GameMaster)] + public double SkillValue { - ItemID = IsEmpty ? _inactiveItemID : _activeItemID; + get => _skillValue; + set + { + _skillValue = value; + + ItemID = IsEmpty ? _inactiveItemID : _activeItemID; + + InvalidateProperties(); + this.MarkDirty(); + } } [CommandProperty(AccessLevel.GameMaster)] diff --git a/Projects/UOContent/Items/Suits/BaseSuit.cs b/Projects/UOContent/Items/Suits/BaseSuit.cs index dd1eb00f6..018c010a2 100644 --- a/Projects/UOContent/Items/Suits/BaseSuit.cs +++ b/Projects/UOContent/Items/Suits/BaseSuit.cs @@ -17,9 +17,20 @@ public abstract partial class BaseSuit : Item public override double DefaultWeight => 1.0; - [SerializableField(0, fieldChanged: nameof(OnAccessLevelChanged))] - [InvalidateProperties] - private AccessLevel _accessLevel; + [SerializableProperty(0)] + public AccessLevel AccessLevel + { + get => _accessLevel; + set + { + var oldAccessLevel = _accessLevel; + _accessLevel = value; + InvalidateProperties(); + this.MarkDirty(); + + OnAccessLevelChanged(oldAccessLevel, _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 b3f0c2ed4..632587523 100644 --- a/Projects/UOContent/Items/Talismans/BaseTalisman.cs +++ b/Projects/UOContent/Items/Talismans/BaseTalisman.cs @@ -128,131 +128,136 @@ 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; @@ -279,20 +284,26 @@ public partial class BaseTalisman : Item, IAosItem public override int LabelNumber => 1071023; // Talisman public virtual bool ForceShowName => false; // used to override default summoner/removal name - [SerializableField(10, fieldChanged: nameof(OnChargesChanged))] - [SaveFlag(nameof(ShouldSerializeCharges))] - [SerializedCommandProperty(AccessLevel.GameMaster)] - [InvalidateProperties] - private int _charges; - - private void OnChargesChanged(int oldValue, int newValue) + [SerializableProperty(10)] + [CommandProperty(AccessLevel.GameMaster)] + public int Charges { - if (_chargeTime > 0) + get => _charges; + set { - StartTimer(); + _charges = value; + + if (_chargeTime > 0) + { + StartTimer(); + } + + InvalidateProperties(); + this.MarkDirty(); } } + [SerializableFieldSaveFlag(10)] public bool ShouldSerializeCharges() => _charges != 0; public static void Configure() diff --git a/Projects/UOContent/Items/Weapons/BaseWeapon.Migrations.cs b/Projects/UOContent/Items/Weapons/BaseWeapon.Migrations.cs deleted file mode 100644 index 3385598d9..000000000 --- a/Projects/UOContent/Items/Weapons/BaseWeapon.Migrations.cs +++ /dev/null @@ -1,311 +0,0 @@ -using System; -using Server.Engines.Craft; - -namespace Server.Items; - -public partial class BaseWeapon -{ - // PlayerConstructed moved onto Item - private void MigrateFrom(V10Content content) - { - _damageLevel = content.DamageLevel ?? WeaponDamageLevel.Regular; - _accuracyLevel = content.AccuracyLevel ?? WeaponAccuracyLevel.Regular; - _durabilityLevel = content.DurabilityLevel ?? WeaponDurabilityLevel.Regular; - _quality = content.Quality ?? WeaponQuality.Regular; - _hitPoints = content.HitPoints ?? 0; - _maxHitPoints = content.MaxHitPoints ?? 0; - _slayer = content.Slayer ?? SlayerName.None; - _poison = content.Poison; - _poisonCharges = content.PoisonCharges ?? 0; - _crafter = content.Crafter; - _identified = content.Identified; - _strRequirement = content.StrRequirement ?? -1; - _dexRequirement = content.DexRequirement ?? -1; - _intRequirement = content.IntRequirement ?? -1; - _minDamage = content.MinDamage ?? -1; - _maxDamage = content.MaxDamage ?? -1; - _hitSound = content.HitSound ?? -1; - _missSound = content.MissSound ?? -1; - _speed = content.Speed ?? -1; - _maxRange = content.MaxRange ?? -1; - _skill = content.Skill ?? (SkillName)(-1); - _type = content.Type ?? (WeaponType)(-1); - _animation = content.Animation ?? (WeaponAnimation)(-1); - _resource = content.Resource ?? CraftResource.Iron; - _attributes = content.Attributes ?? AttributesDefaultValue(); - _weaponAttributes = content.WeaponAttributes ?? WeaponAttributesDefaultValue(); - PlayerConstructed = content.PlayerConstructed; - _skillBonuses = content.SkillBonuses ?? SkillBonusesDefaultValue(); - _slayer2 = content.Slayer2 ?? SlayerName.None; - _aosElementDamages = content.AosElementDamages ?? AosElementAttributesDefaultValue(); - _engravedText = content.EngravedText; - } - - // Version 9 (pre-codegen) - private void Deserialize(IGenericReader reader, int version) - { - var flags = (OldSaveFlag)reader.ReadInt(); - - if (GetSaveFlag(flags, OldSaveFlag.DamageLevel)) - { - _damageLevel = (WeaponDamageLevel)reader.ReadInt(); - } - - if (GetSaveFlag(flags, OldSaveFlag.AccuracyLevel)) - { - _accuracyLevel = (WeaponAccuracyLevel)reader.ReadInt(); - } - - if (GetSaveFlag(flags, OldSaveFlag.DurabilityLevel)) - { - _durabilityLevel = (WeaponDurabilityLevel)reader.ReadInt(); - } - - if (GetSaveFlag(flags, OldSaveFlag.Quality)) - { - _quality = (WeaponQuality)reader.ReadInt(); - } - else - { - _quality = WeaponQuality.Regular; - } - - if (GetSaveFlag(flags, OldSaveFlag.Hits)) - { - _hitPoints = reader.ReadInt(); - } - - if (GetSaveFlag(flags, OldSaveFlag.MaxHits)) - { - _maxHitPoints = reader.ReadInt(); - } - - if (GetSaveFlag(flags, OldSaveFlag.Slayer)) - { - _slayer = (SlayerName)reader.ReadInt(); - } - - if (GetSaveFlag(flags, OldSaveFlag.Poison)) - { - _poison = reader.ReadPoison(); - } - - if (GetSaveFlag(flags, OldSaveFlag.PoisonCharges)) - { - _poisonCharges = reader.ReadInt(); - } - - if (GetSaveFlag(flags, OldSaveFlag.Crafter)) - { - Timer.DelayCall(crafter => _crafter = crafter?.RawName, reader.ReadEntity()); - } - - if (GetSaveFlag(flags, OldSaveFlag.Identified)) - { - _identified = version >= 6 || reader.ReadBool(); - } - - if (GetSaveFlag(flags, OldSaveFlag.StrReq)) - { - _strRequirement = reader.ReadInt(); - } - else - { - _strRequirement = -1; - } - - if (GetSaveFlag(flags, OldSaveFlag.DexReq)) - { - _dexRequirement = reader.ReadInt(); - } - else - { - _dexRequirement = -1; - } - - if (GetSaveFlag(flags, OldSaveFlag.IntReq)) - { - _intRequirement = reader.ReadInt(); - } - else - { - _intRequirement = -1; - } - - if (GetSaveFlag(flags, OldSaveFlag.MinDamage)) - { - _minDamage = reader.ReadInt(); - } - else - { - _minDamage = -1; - } - - if (GetSaveFlag(flags, OldSaveFlag.MaxDamage)) - { - _maxDamage = reader.ReadInt(); - } - else - { - _maxDamage = -1; - } - - if (GetSaveFlag(flags, OldSaveFlag.HitSound)) - { - _hitSound = reader.ReadInt(); - } - else - { - _hitSound = -1; - } - - if (GetSaveFlag(flags, OldSaveFlag.MissSound)) - { - _missSound = reader.ReadInt(); - } - else - { - _missSound = -1; - } - - if (GetSaveFlag(flags, OldSaveFlag.Speed)) - { - if (version < 9) - { - _speed = reader.ReadInt(); - } - else - { - _speed = reader.ReadFloat(); - } - } - else - { - _speed = -1; - } - - if (GetSaveFlag(flags, OldSaveFlag.MaxRange)) - { - _maxRange = reader.ReadInt(); - } - else - { - _maxRange = -1; - } - - if (GetSaveFlag(flags, OldSaveFlag.Skill)) - { - _skill = (SkillName)reader.ReadInt(); - } - else - { - _skill = (SkillName)(-1); - } - - if (GetSaveFlag(flags, OldSaveFlag.Type)) - { - _type = (WeaponType)reader.ReadInt(); - } - else - { - _type = (WeaponType)(-1); - } - - if (GetSaveFlag(flags, OldSaveFlag.Animation)) - { - _animation = (WeaponAnimation)reader.ReadInt(); - } - else - { - _animation = (WeaponAnimation)(-1); - } - - if (GetSaveFlag(flags, OldSaveFlag.Resource)) - { - _resource = (CraftResource)reader.ReadInt(); - } - else - { - _resource = CraftResource.Iron; - } - - Attributes = new AosAttributes(this); - - if (GetSaveFlag(flags, OldSaveFlag.Attributes)) - { - Attributes.Deserialize(reader); - } - - WeaponAttributes = new AosWeaponAttributes(this); - - if (GetSaveFlag(flags, OldSaveFlag.WeaponAttributes)) - { - WeaponAttributes.Deserialize(reader); - } - - PlayerConstructed = GetSaveFlag(flags, OldSaveFlag.PlayerConstructed); - - SkillBonuses = new AosSkillBonuses(this); - - if (GetSaveFlag(flags, OldSaveFlag.SkillBonuses)) - { - SkillBonuses.Deserialize(reader); - } - - if (GetSaveFlag(flags, OldSaveFlag.Slayer2)) - { - _slayer2 = (SlayerName)reader.ReadInt(); - } - - AosElementDamages = new AosElementAttributes(this); - - if (GetSaveFlag(flags, OldSaveFlag.ElementalDamages)) - { - AosElementDamages.Deserialize(reader); - } - - if (GetSaveFlag(flags, OldSaveFlag.EngravedText)) - { - _engravedText = reader.ReadString(); - } - } - - private static bool GetSaveFlag(OldSaveFlag flags, OldSaveFlag toGet) => (flags & toGet) != 0; - - [Flags] - private enum OldSaveFlag - { - None = 0x00000000, - DamageLevel = 0x00000001, - AccuracyLevel = 0x00000002, - DurabilityLevel = 0x00000004, - Quality = 0x00000008, - Hits = 0x00000010, - MaxHits = 0x00000020, - Slayer = 0x00000040, - Poison = 0x00000080, - PoisonCharges = 0x00000100, - Crafter = 0x00000200, - Identified = 0x00000400, - StrReq = 0x00000800, - DexReq = 0x00001000, - IntReq = 0x00002000, - MinDamage = 0x00004000, - MaxDamage = 0x00008000, - HitSound = 0x00010000, - MissSound = 0x00020000, - Speed = 0x00040000, - MaxRange = 0x00080000, - Skill = 0x00100000, - Type = 0x00200000, - Animation = 0x00400000, - Resource = 0x00800000, - Attributes = 0x01000000, - WeaponAttributes = 0x02000000, - PlayerConstructed = 0x04000000, - SkillBonuses = 0x08000000, - Slayer2 = 0x10000000, - ElementalDamages = 0x20000000, - EngravedText = 0x40000000 - } -} diff --git a/Projects/UOContent/Items/Weapons/BaseWeapon.cs b/Projects/UOContent/Items/Weapons/BaseWeapon.cs index d48824670..b28f0d371 100644 --- a/Projects/UOContent/Items/Weapons/BaseWeapon.cs +++ b/Projects/UOContent/Items/Weapons/BaseWeapon.cs @@ -27,7 +27,7 @@ public interface ISlayer SlayerName Slayer2 { get; set; } } -[SerializationGenerator(11, false)] +[SerializationGenerator(10, false)] public abstract partial class BaseWeapon : Item, IWeapon, IFactionItem, ICraftable, ISlayer, IDurability, IAosItem, IIdentifiable { @@ -56,126 +56,138 @@ 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); + [SerializableField(26)] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private bool _playerConstructed; + + [SerializableFieldSaveFlag(26)] + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private bool ShouldSerializePlayerConstructed() => _playerConstructed; + [SerializedIgnoreDupe] - [SerializableField(26, setter: "private")] - [SaveFlag(nameof(ShouldSerializeSkillBonuses), nameof(SkillBonusesDefaultValue))] + [SerializableField(27, setter: "private")] [SerializedCommandProperty(AccessLevel.GameMaster, canModify: true)] private AosSkillBonuses _skillBonuses; + [SerializableFieldSaveFlag(27)] [MethodImpl(MethodImplOptions.AggressiveInlining)] private bool ShouldSerializeSkillBonuses() => !_skillBonuses.IsEmpty; + [SerializableFieldDefault(27)] private AosSkillBonuses SkillBonusesDefaultValue() => new(this); [InvalidateProperties] - [SerializableField(27)] - [SaveFlag(nameof(ShouldSerializeSlayer2))] + [SerializableField(28)] [SerializedCommandProperty(AccessLevel.GameMaster)] private SlayerName _slayer2; + [SerializableFieldSaveFlag(28)] [MethodImpl(MethodImplOptions.AggressiveInlining)] private bool ShouldSerializeSlayer2() => _slayer2 != SlayerName.None; [SerializedIgnoreDupe] - [SerializableField(28, setter: "private")] - [SaveFlag(nameof(ShouldSerializeElementAttributes), nameof(AosElementAttributesDefaultValue))] + [SerializableField(29, setter: "private")] [SerializedCommandProperty(AccessLevel.GameMaster, canModify: true)] private AosElementAttributes _aosElementDamages; + [SerializableFieldSaveFlag(29)] [MethodImpl(MethodImplOptions.AggressiveInlining)] private bool ShouldSerializeElementAttributes() => !_aosElementDamages.IsEmpty; + [SerializableFieldDefault(29)] private AosElementAttributes AosElementAttributesDefaultValue() => new(this); [InvalidateProperties] - [SerializableField(29)] - [SaveFlag(nameof(ShouldSerializeEngravedText))] + [SerializableField(30)] [SerializedCommandProperty(AccessLevel.GameMaster)] private string _engravedText; + [SerializableFieldSaveFlag(30)] [MethodImpl(MethodImplOptions.AggressiveInlining)] private bool ShouldSerializeEngravedText() => !string.IsNullOrEmpty(_engravedText); @@ -282,7 +294,6 @@ public abstract partial class BaseWeapon public bool Consecrated { get; set; } [SerializableProperty(1)] - [SaveFlag(nameof(ShouldSerializeWeaponAccuracy))] [CommandProperty(AccessLevel.GameMaster)] public WeaponAccuracyLevel AccuracyLevel { @@ -318,10 +329,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 { @@ -336,10 +347,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 { @@ -354,12 +365,13 @@ 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 { @@ -383,10 +395,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 { @@ -399,12 +411,13 @@ 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 { @@ -417,12 +430,13 @@ 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 { @@ -435,12 +449,13 @@ 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 { @@ -453,12 +468,13 @@ 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 { @@ -471,12 +487,13 @@ 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 { @@ -488,12 +505,13 @@ 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 { @@ -505,12 +523,13 @@ 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 { @@ -541,12 +560,13 @@ 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 { @@ -559,12 +579,13 @@ 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 { @@ -577,12 +598,13 @@ 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 { @@ -594,12 +616,13 @@ 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 { @@ -611,12 +634,13 @@ 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 { @@ -632,8 +656,10 @@ public abstract partial class BaseWeapon } } + [SerializableFieldSaveFlag(23)] private bool ShouldSerializeResource() => _resource != CraftResource.Iron; + [SerializableFieldDefault(23)] private CraftResource ResourceDefaultValue() => CraftResource.Iron; public virtual int OnCraft( @@ -648,6 +674,7 @@ public abstract partial class BaseWeapon Crafter = from.RawName; } + PlayerConstructed = true; Identified = true; var resourceType = typeRes ?? craftItem.Resources[0].ItemType; @@ -3553,6 +3580,236 @@ public abstract partial class BaseWeapon } } + private static bool GetSaveFlag(OldSaveFlag flags, OldSaveFlag toGet) => (flags & toGet) != 0; + + private void Deserialize(IGenericReader reader, int version) + { + var flags = (OldSaveFlag)reader.ReadInt(); + + if (GetSaveFlag(flags, OldSaveFlag.DamageLevel)) + { + _damageLevel = (WeaponDamageLevel)reader.ReadInt(); + } + + if (GetSaveFlag(flags, OldSaveFlag.AccuracyLevel)) + { + _accuracyLevel = (WeaponAccuracyLevel)reader.ReadInt(); + } + + if (GetSaveFlag(flags, OldSaveFlag.DurabilityLevel)) + { + _durabilityLevel = (WeaponDurabilityLevel)reader.ReadInt(); + } + + if (GetSaveFlag(flags, OldSaveFlag.Quality)) + { + _quality = (WeaponQuality)reader.ReadInt(); + } + else + { + _quality = WeaponQuality.Regular; + } + + if (GetSaveFlag(flags, OldSaveFlag.Hits)) + { + _hitPoints = reader.ReadInt(); + } + + if (GetSaveFlag(flags, OldSaveFlag.MaxHits)) + { + _maxHitPoints = reader.ReadInt(); + } + + if (GetSaveFlag(flags, OldSaveFlag.Slayer)) + { + _slayer = (SlayerName)reader.ReadInt(); + } + + if (GetSaveFlag(flags, OldSaveFlag.Poison)) + { + _poison = reader.ReadPoison(); + } + + if (GetSaveFlag(flags, OldSaveFlag.PoisonCharges)) + { + _poisonCharges = reader.ReadInt(); + } + + if (GetSaveFlag(flags, OldSaveFlag.Crafter)) + { + Timer.DelayCall(crafter => _crafter = crafter?.RawName, reader.ReadEntity()); + } + + if (GetSaveFlag(flags, OldSaveFlag.Identified)) + { + _identified = version >= 6 || reader.ReadBool(); + } + + if (GetSaveFlag(flags, OldSaveFlag.StrReq)) + { + _strRequirement = reader.ReadInt(); + } + else + { + _strRequirement = -1; + } + + if (GetSaveFlag(flags, OldSaveFlag.DexReq)) + { + _dexRequirement = reader.ReadInt(); + } + else + { + _dexRequirement = -1; + } + + if (GetSaveFlag(flags, OldSaveFlag.IntReq)) + { + _intRequirement = reader.ReadInt(); + } + else + { + _intRequirement = -1; + } + + if (GetSaveFlag(flags, OldSaveFlag.MinDamage)) + { + _minDamage = reader.ReadInt(); + } + else + { + _minDamage = -1; + } + + if (GetSaveFlag(flags, OldSaveFlag.MaxDamage)) + { + _maxDamage = reader.ReadInt(); + } + else + { + _maxDamage = -1; + } + + if (GetSaveFlag(flags, OldSaveFlag.HitSound)) + { + _hitSound = reader.ReadInt(); + } + else + { + _hitSound = -1; + } + + if (GetSaveFlag(flags, OldSaveFlag.MissSound)) + { + _missSound = reader.ReadInt(); + } + else + { + _missSound = -1; + } + + if (GetSaveFlag(flags, OldSaveFlag.Speed)) + { + if (version < 9) + { + _speed = reader.ReadInt(); + } + else + { + _speed = reader.ReadFloat(); + } + } + else + { + _speed = -1; + } + + if (GetSaveFlag(flags, OldSaveFlag.MaxRange)) + { + _maxRange = reader.ReadInt(); + } + else + { + _maxRange = -1; + } + + if (GetSaveFlag(flags, OldSaveFlag.Skill)) + { + _skill = (SkillName)reader.ReadInt(); + } + else + { + _skill = (SkillName)(-1); + } + + if (GetSaveFlag(flags, OldSaveFlag.Type)) + { + _type = (WeaponType)reader.ReadInt(); + } + else + { + _type = (WeaponType)(-1); + } + + if (GetSaveFlag(flags, OldSaveFlag.Animation)) + { + _animation = (WeaponAnimation)reader.ReadInt(); + } + else + { + _animation = (WeaponAnimation)(-1); + } + + if (GetSaveFlag(flags, OldSaveFlag.Resource)) + { + _resource = (CraftResource)reader.ReadInt(); + } + else + { + _resource = CraftResource.Iron; + } + + Attributes = new AosAttributes(this); + + if (GetSaveFlag(flags, OldSaveFlag.Attributes)) + { + Attributes.Deserialize(reader); + } + + WeaponAttributes = new AosWeaponAttributes(this); + + if (GetSaveFlag(flags, OldSaveFlag.WeaponAttributes)) + { + WeaponAttributes.Deserialize(reader); + } + + PlayerConstructed = GetSaveFlag(flags, OldSaveFlag.PlayerConstructed); + + SkillBonuses = new AosSkillBonuses(this); + + if (GetSaveFlag(flags, OldSaveFlag.SkillBonuses)) + { + SkillBonuses.Deserialize(reader); + } + + if (GetSaveFlag(flags, OldSaveFlag.Slayer2)) + { + _slayer2 = (SlayerName)reader.ReadInt(); + } + + AosElementDamages = new AosElementAttributes(this); + + if (GetSaveFlag(flags, OldSaveFlag.ElementalDamages)) + { + AosElementDamages.Deserialize(reader); + } + + if (GetSaveFlag(flags, OldSaveFlag.EngravedText)) + { + _engravedText = reader.ReadString(); + } + } + [AfterDeserialization] private void AfterDeserialization() { @@ -3618,6 +3875,42 @@ public abstract partial class BaseWeapon } } + [Flags] + private enum OldSaveFlag + { + None = 0x00000000, + DamageLevel = 0x00000001, + AccuracyLevel = 0x00000002, + DurabilityLevel = 0x00000004, + Quality = 0x00000008, + Hits = 0x00000010, + MaxHits = 0x00000020, + Slayer = 0x00000040, + Poison = 0x00000080, + PoisonCharges = 0x00000100, + Crafter = 0x00000200, + Identified = 0x00000400, + StrReq = 0x00000800, + DexReq = 0x00001000, + IntReq = 0x00002000, + MinDamage = 0x00004000, + MaxDamage = 0x00008000, + HitSound = 0x00010000, + MissSound = 0x00020000, + Speed = 0x00040000, + MaxRange = 0x00080000, + Skill = 0x00100000, + Type = 0x00200000, + Animation = 0x00400000, + Resource = 0x00800000, + Attributes = 0x01000000, + WeaponAttributes = 0x02000000, + PlayerConstructed = 0x04000000, + SkillBonuses = 0x08000000, + Slayer2 = 0x10000000, + ElementalDamages = 0x20000000, + EngravedText = 0x40000000 + } } public enum CheckSlayerResult diff --git a/Projects/UOContent/Migrations/Server.Engines.CannedEvil.ChampionSpawn.v11.json b/Projects/UOContent/Migrations/Server.Engines.CannedEvil.ChampionSpawn.v11.json deleted file mode 100644 index defcbe100..000000000 --- a/Projects/UOContent/Migrations/Server.Engines.CannedEvil.ChampionSpawn.v11.json +++ /dev/null @@ -1,189 +0,0 @@ -{ - "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 deleted file mode 100644 index 956de91db..000000000 --- a/Projects/UOContent/Migrations/Server.Engines.Virtues.VirtueContext.v1.json +++ /dev/null @@ -1,119 +0,0 @@ -{ - "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 deleted file mode 100644 index 04f4a0199..000000000 --- a/Projects/UOContent/Migrations/Server.Ethics.Player.v2.json +++ /dev/null @@ -1,50 +0,0 @@ -{ - "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.BaseArmor.v10.json b/Projects/UOContent/Migrations/Server.Items.BaseArmor.v10.json deleted file mode 100644 index d7844e116..000000000 --- a/Projects/UOContent/Migrations/Server.Items.BaseArmor.v10.json +++ /dev/null @@ -1,207 +0,0 @@ -{ - "version": 10, - "type": "Server.Items.BaseArmor", - "properties": [ - { - "name": "Attributes", - "type": "Server.AosAttributes", - "usesSaveFlag": true, - "rule": "RawSerializableMigrationRule", - "ruleArguments": [ - "DeserializationRequiresParent" - ] - }, - { - "name": "ArmorAttributes", - "type": "Server.AosArmorAttributes", - "usesSaveFlag": true, - "rule": "RawSerializableMigrationRule", - "ruleArguments": [ - "DeserializationRequiresParent" - ] - }, - { - "name": "PhysicalBonus", - "type": "int", - "usesSaveFlag": true, - "rule": "PrimitiveTypeMigrationRule", - "ruleArguments": [ - "EncodedInt" - ] - }, - { - "name": "FireBonus", - "type": "int", - "usesSaveFlag": true, - "rule": "PrimitiveTypeMigrationRule", - "ruleArguments": [ - "EncodedInt" - ] - }, - { - "name": "ColdBonus", - "type": "int", - "usesSaveFlag": true, - "rule": "PrimitiveTypeMigrationRule", - "ruleArguments": [ - "EncodedInt" - ] - }, - { - "name": "PoisonBonus", - "type": "int", - "usesSaveFlag": true, - "rule": "PrimitiveTypeMigrationRule", - "ruleArguments": [ - "EncodedInt" - ] - }, - { - "name": "EnergyBonus", - "type": "int", - "usesSaveFlag": true, - "rule": "PrimitiveTypeMigrationRule", - "ruleArguments": [ - "EncodedInt" - ] - }, - { - "name": "Identified", - "type": "bool", - "usesSaveFlag": true, - "rule": "PrimitiveTypeMigrationRule", - "ruleArguments": [ - "" - ] - }, - { - "name": "MaxHitPoints", - "type": "int", - "usesSaveFlag": true, - "rule": "PrimitiveTypeMigrationRule", - "ruleArguments": [ - "EncodedInt" - ] - }, - { - "name": "HitPoints", - "type": "int", - "usesSaveFlag": true, - "rule": "PrimitiveTypeMigrationRule", - "ruleArguments": [ - "EncodedInt" - ] - }, - { - "name": "Crafter", - "type": "string", - "usesSaveFlag": true, - "rule": "PrimitiveTypeMigrationRule", - "ruleArguments": [ - "" - ] - }, - { - "name": "Quality", - "type": "Server.Items.ArmorQuality", - "usesSaveFlag": true, - "rule": "EnumMigrationRule" - }, - { - "name": "Durability", - "type": "Server.Items.ArmorDurabilityLevel", - "usesSaveFlag": true, - "rule": "EnumMigrationRule" - }, - { - "name": "ProtectionLevel", - "type": "Server.Items.ArmorProtectionLevel", - "usesSaveFlag": true, - "rule": "EnumMigrationRule" - }, - { - "name": "Resource", - "type": "Server.Items.CraftResource", - "usesSaveFlag": true, - "rule": "EnumMigrationRule" - }, - { - "name": "BaseArmorRating", - "type": "int", - "usesSaveFlag": true, - "rule": "PrimitiveTypeMigrationRule", - "ruleArguments": [ - "EncodedInt" - ] - }, - { - "name": "StrBonus", - "type": "int", - "usesSaveFlag": true, - "rule": "PrimitiveTypeMigrationRule", - "ruleArguments": [ - "EncodedInt" - ] - }, - { - "name": "DexBonus", - "type": "int", - "usesSaveFlag": true, - "rule": "PrimitiveTypeMigrationRule", - "ruleArguments": [ - "EncodedInt" - ] - }, - { - "name": "IntBonus", - "type": "int", - "usesSaveFlag": true, - "rule": "PrimitiveTypeMigrationRule", - "ruleArguments": [ - "EncodedInt" - ] - }, - { - "name": "StrRequirement", - "type": "int", - "usesSaveFlag": true, - "rule": "PrimitiveTypeMigrationRule", - "ruleArguments": [ - "EncodedInt" - ] - }, - { - "name": "DexRequirement", - "type": "int", - "usesSaveFlag": true, - "rule": "PrimitiveTypeMigrationRule", - "ruleArguments": [ - "EncodedInt" - ] - }, - { - "name": "IntRequirement", - "type": "int", - "usesSaveFlag": true, - "rule": "PrimitiveTypeMigrationRule", - "ruleArguments": [ - "EncodedInt" - ] - }, - { - "name": "MeditationAllowance", - "type": "Server.Items.ArmorMeditationAllowance", - "usesSaveFlag": true, - "rule": "EnumMigrationRule" - }, - { - "name": "SkillBonuses", - "type": "Server.AosSkillBonuses", - "usesSaveFlag": true, - "rule": "RawSerializableMigrationRule", - "ruleArguments": [ - "DeserializationRequiresParent" - ] - } - ] -} \ No newline at end of file diff --git a/Projects/UOContent/Migrations/Server.Items.BaseClothing.v8.json b/Projects/UOContent/Migrations/Server.Items.BaseClothing.v8.json deleted file mode 100644 index 09d5e07be..000000000 --- a/Projects/UOContent/Migrations/Server.Items.BaseClothing.v8.json +++ /dev/null @@ -1,90 +0,0 @@ -{ - "version": 8, - "type": "Server.Items.BaseClothing", - "properties": [ - { - "name": "Resource", - "type": "Server.Items.CraftResource", - "usesSaveFlag": true, - "rule": "EnumMigrationRule" - }, - { - "name": "Attributes", - "type": "Server.AosAttributes", - "usesSaveFlag": true, - "rule": "RawSerializableMigrationRule", - "ruleArguments": [ - "DeserializationRequiresParent" - ] - }, - { - "name": "ClothingAttributes", - "type": "Server.AosArmorAttributes", - "usesSaveFlag": true, - "rule": "RawSerializableMigrationRule", - "ruleArguments": [ - "DeserializationRequiresParent" - ] - }, - { - "name": "SkillBonuses", - "type": "Server.AosSkillBonuses", - "usesSaveFlag": true, - "rule": "RawSerializableMigrationRule", - "ruleArguments": [ - "DeserializationRequiresParent" - ] - }, - { - "name": "Resistances", - "type": "Server.AosElementAttributes", - "usesSaveFlag": true, - "rule": "RawSerializableMigrationRule", - "ruleArguments": [ - "DeserializationRequiresParent" - ] - }, - { - "name": "MaxHitPoints", - "type": "int", - "usesSaveFlag": true, - "rule": "PrimitiveTypeMigrationRule", - "ruleArguments": [ - "EncodedInt" - ] - }, - { - "name": "HitPoints", - "type": "int", - "usesSaveFlag": true, - "rule": "PrimitiveTypeMigrationRule", - "ruleArguments": [ - "EncodedInt" - ] - }, - { - "name": "Crafter", - "type": "string", - "usesSaveFlag": true, - "rule": "PrimitiveTypeMigrationRule", - "ruleArguments": [ - "" - ] - }, - { - "name": "Quality", - "type": "Server.Items.ClothingQuality", - "usesSaveFlag": true, - "rule": "EnumMigrationRule" - }, - { - "name": "StrRequirement", - "type": "int", - "usesSaveFlag": true, - "rule": "PrimitiveTypeMigrationRule", - "ruleArguments": [ - "" - ] - } - ] -} \ 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 deleted file mode 100644 index c04a03f3b..000000000 --- a/Projects/UOContent/Migrations/Server.Items.BaseLight.v2.json +++ /dev/null @@ -1,43 +0,0 @@ -{ - "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.BaseWeapon.v11.json b/Projects/UOContent/Migrations/Server.Items.BaseWeapon.v11.json deleted file mode 100644 index 009666433..000000000 --- a/Projects/UOContent/Migrations/Server.Items.BaseWeapon.v11.json +++ /dev/null @@ -1,246 +0,0 @@ -{ - "version": 11, - "type": "Server.Items.BaseWeapon", - "properties": [ - { - "name": "DamageLevel", - "type": "Server.Items.WeaponDamageLevel", - "usesSaveFlag": true, - "rule": "EnumMigrationRule" - }, - { - "name": "AccuracyLevel", - "type": "Server.Items.WeaponAccuracyLevel", - "usesSaveFlag": true, - "rule": "EnumMigrationRule" - }, - { - "name": "DurabilityLevel", - "type": "Server.Items.WeaponDurabilityLevel", - "usesSaveFlag": true, - "rule": "EnumMigrationRule" - }, - { - "name": "Quality", - "type": "Server.Items.WeaponQuality", - "usesSaveFlag": true, - "rule": "EnumMigrationRule" - }, - { - "name": "HitPoints", - "type": "int", - "usesSaveFlag": true, - "rule": "PrimitiveTypeMigrationRule", - "ruleArguments": [ - "" - ] - }, - { - "name": "MaxHitPoints", - "type": "int", - "usesSaveFlag": true, - "rule": "PrimitiveTypeMigrationRule", - "ruleArguments": [ - "" - ] - }, - { - "name": "Slayer", - "type": "Server.Items.SlayerName", - "usesSaveFlag": true, - "rule": "EnumMigrationRule" - }, - { - "name": "Poison", - "type": "Server.Poison", - "usesSaveFlag": true, - "rule": "PrimitiveUOTypeMigrationRule", - "ruleArguments": [ - "Poison" - ] - }, - { - "name": "PoisonCharges", - "type": "int", - "usesSaveFlag": true, - "rule": "PrimitiveTypeMigrationRule", - "ruleArguments": [ - "" - ] - }, - { - "name": "Crafter", - "type": "string", - "usesSaveFlag": true, - "rule": "PrimitiveTypeMigrationRule", - "ruleArguments": [ - "" - ] - }, - { - "name": "Identified", - "type": "bool", - "usesSaveFlag": true, - "rule": "PrimitiveTypeMigrationRule", - "ruleArguments": [ - "" - ] - }, - { - "name": "StrRequirement", - "type": "int", - "usesSaveFlag": true, - "rule": "PrimitiveTypeMigrationRule", - "ruleArguments": [ - "" - ] - }, - { - "name": "DexRequirement", - "type": "int", - "usesSaveFlag": true, - "rule": "PrimitiveTypeMigrationRule", - "ruleArguments": [ - "" - ] - }, - { - "name": "IntRequirement", - "type": "int", - "usesSaveFlag": true, - "rule": "PrimitiveTypeMigrationRule", - "ruleArguments": [ - "" - ] - }, - { - "name": "MinDamage", - "type": "int", - "usesSaveFlag": true, - "rule": "PrimitiveTypeMigrationRule", - "ruleArguments": [ - "" - ] - }, - { - "name": "MaxDamage", - "type": "int", - "usesSaveFlag": true, - "rule": "PrimitiveTypeMigrationRule", - "ruleArguments": [ - "" - ] - }, - { - "name": "HitSound", - "type": "int", - "usesSaveFlag": true, - "rule": "PrimitiveTypeMigrationRule", - "ruleArguments": [ - "" - ] - }, - { - "name": "MissSound", - "type": "int", - "usesSaveFlag": true, - "rule": "PrimitiveTypeMigrationRule", - "ruleArguments": [ - "" - ] - }, - { - "name": "Speed", - "type": "float", - "usesSaveFlag": true, - "rule": "PrimitiveTypeMigrationRule", - "ruleArguments": [ - "" - ] - }, - { - "name": "MaxRange", - "type": "int", - "usesSaveFlag": true, - "rule": "PrimitiveTypeMigrationRule", - "ruleArguments": [ - "" - ] - }, - { - "name": "Skill", - "type": "Server.SkillName", - "usesSaveFlag": true, - "rule": "EnumMigrationRule" - }, - { - "name": "Type", - "type": "Server.Items.WeaponType", - "usesSaveFlag": true, - "rule": "EnumMigrationRule" - }, - { - "name": "Animation", - "type": "Server.Items.WeaponAnimation", - "usesSaveFlag": true, - "rule": "EnumMigrationRule" - }, - { - "name": "Resource", - "type": "Server.Items.CraftResource", - "usesSaveFlag": true, - "rule": "EnumMigrationRule" - }, - { - "name": "Attributes", - "type": "Server.AosAttributes", - "usesSaveFlag": true, - "rule": "RawSerializableMigrationRule", - "ruleArguments": [ - "DeserializationRequiresParent" - ] - }, - { - "name": "WeaponAttributes", - "type": "Server.AosWeaponAttributes", - "usesSaveFlag": true, - "rule": "RawSerializableMigrationRule", - "ruleArguments": [ - "DeserializationRequiresParent" - ] - }, - { - "name": "SkillBonuses", - "type": "Server.AosSkillBonuses", - "usesSaveFlag": true, - "rule": "RawSerializableMigrationRule", - "ruleArguments": [ - "DeserializationRequiresParent" - ] - }, - { - "name": "Slayer2", - "type": "Server.Items.SlayerName", - "usesSaveFlag": true, - "rule": "EnumMigrationRule" - }, - { - "name": "AosElementDamages", - "type": "Server.AosElementAttributes", - "usesSaveFlag": true, - "rule": "RawSerializableMigrationRule", - "ruleArguments": [ - "DeserializationRequiresParent" - ] - }, - { - "name": "EngravedText", - "type": "string", - "usesSaveFlag": true, - "rule": "PrimitiveTypeMigrationRule", - "ruleArguments": [ - "" - ] - } - ] -} \ 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 deleted file mode 100644 index 452e3ba7c..000000000 --- a/Projects/UOContent/Migrations/Server.Items.Corpse.v18.json +++ /dev/null @@ -1,137 +0,0 @@ -{ - "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 deleted file mode 100644 index 2cccb267a..000000000 --- a/Projects/UOContent/Migrations/Server.Items.Corpse.v19.json +++ /dev/null @@ -1,137 +0,0 @@ -{ - "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 deleted file mode 100644 index ef81d79cd..000000000 --- a/Projects/UOContent/Migrations/Server.Items.DeathRobe.v4.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "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 deleted file mode 100644 index adcf02ff6..000000000 --- a/Projects/UOContent/Migrations/Server.Items.DecayedCorpse.v3.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "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 deleted file mode 100644 index 2384676a3..000000000 --- a/Projects/UOContent/Migrations/Server.Items.FillableContainer.v3.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "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 deleted file mode 100644 index 267ca4709..000000000 --- a/Projects/UOContent/Migrations/Server.Items.MarkContainer.v1.json +++ /dev/null @@ -1,46 +0,0 @@ -{ - "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 deleted file mode 100644 index 8424276f6..000000000 --- a/Projects/UOContent/Migrations/Server.Items.PuzzleChestSolutionAndTime.v1.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "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 deleted file mode 100644 index 0a61f7bfe..000000000 --- a/Projects/UOContent/Migrations/Server.Items.StarRoomGate.v2.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "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 deleted file mode 100644 index 3745c4428..000000000 --- a/Projects/UOContent/Migrations/Server.Items.TransientItem.v2.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "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.v3.json b/Projects/UOContent/Migrations/Server.Items.TreasureMapChest.v3.json deleted file mode 100644 index 805bbb6d1..000000000 --- a/Projects/UOContent/Migrations/Server.Items.TreasureMapChest.v3.json +++ /dev/null @@ -1,57 +0,0 @@ -{ - "version": 3, - "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": [ - "@TimerDrift" - ] - }, - { - "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.Items.TreasureMapChest.v4.json b/Projects/UOContent/Migrations/Server.Items.TreasureMapChest.v4.json deleted file mode 100644 index b4d1f85d4..000000000 --- a/Projects/UOContent/Migrations/Server.Items.TreasureMapChest.v4.json +++ /dev/null @@ -1,57 +0,0 @@ -{ - "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 deleted file mode 100644 index 5149f4347..000000000 --- a/Projects/UOContent/Migrations/Server.Mobiles.BaseEscortable.v3.json +++ /dev/null @@ -1,43 +0,0 @@ -{ - "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 deleted file mode 100644 index 6b9c9eb1c..000000000 --- a/Projects/UOContent/Migrations/Server.Mobiles.PlayerVendor.v4.json +++ /dev/null @@ -1,62 +0,0 @@ -{ - "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 deleted file mode 100644 index b42fa33eb..000000000 --- a/Projects/UOContent/Migrations/Server.Mobiles.RentedVendor.v1.json +++ /dev/null @@ -1,62 +0,0 @@ -{ - "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 deleted file mode 100644 index bd224f77f..000000000 --- a/Projects/UOContent/Migrations/Server.Mobiles.Sheep.v1.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "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 deleted file mode 100644 index 140d3dd64..000000000 --- a/Projects/UOContent/Migrations/Server.Multis.BaseBoat.v5.json +++ /dev/null @@ -1,73 +0,0 @@ -{ - "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 deleted file mode 100644 index 471c645f1..000000000 --- a/Projects/UOContent/Migrations/Server.Multis.BaseCamp.v2.json +++ /dev/null @@ -1,34 +0,0 @@ -{ - "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 deleted file mode 100644 index 1f16a4d7f..000000000 --- a/Projects/UOContent/Migrations/Server.Spells.Fifth.PoisonField.v1.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "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 deleted file mode 100644 index 3e91c7a4d..000000000 --- a/Projects/UOContent/Migrations/Server.Spells.Fourth.FireFieldItem.v1.json +++ /dev/null @@ -1,27 +0,0 @@ -{ - "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 deleted file mode 100644 index dd202da03..000000000 --- a/Projects/UOContent/Migrations/Server.Spells.Seventh.EnergyField.v2.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "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 deleted file mode 100644 index 3e834a6b1..000000000 --- a/Projects/UOContent/Migrations/Server.Spells.Sixth.ParalyzeField.v1.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "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 deleted file mode 100644 index eafcc5945..000000000 --- a/Projects/UOContent/Migrations/Server.Spells.Third.WallOfStone.v1.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "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 8516e6678..762a0be9c 100644 --- a/Projects/UOContent/Misc/ShardPoller.cs +++ b/Projects/UOContent/Misc/ShardPoller.cs @@ -36,14 +36,16 @@ public partial class ShardPoller : Item Movable = false; } - [SerializableField(0, allowFieldChange: nameof(AllowTitleChange))] - [SerializedCommandProperty(AccessLevel.GameMaster, AccessLevel.Administrator)] - private string _title; - - private bool AllowTitleChange(ref string value) + [SerializableProperty(0)] + [CommandProperty(AccessLevel.GameMaster, AccessLevel.Administrator)] + public string Title { - value = ShardPollPrompt.UrlToHref(value); - return true; + get => _title; + set + { + _title = ShardPollPrompt.UrlToHref(value); + this.MarkDirty(); + } } [CommandProperty(AccessLevel.GameMaster, AccessLevel.Administrator)] @@ -52,20 +54,31 @@ public partial class ShardPoller : Item ? TimeSpan.Zero : Utility.Max(StartTime + Duration - Core.Now, TimeSpan.Zero); - [SerializableField(3, fieldChanged: nameof(OnActiveChanged))] - [SerializedCommandProperty(AccessLevel.GameMaster, AccessLevel.Administrator)] - private bool _active; - - private void OnActiveChanged(bool oldValue, bool newValue) + [SerializableProperty(3)] + [CommandProperty(AccessLevel.GameMaster, AccessLevel.Administrator)] + public bool Active { - if (_active) + get => _active; + set { - StartTime = Core.Now; - _activePollers.Add(this); - } - else - { - _activePollers.Remove(this); + if (_active == value) + { + return; + } + + _active = value; + + if (_active) + { + StartTime = Core.Now; + _activePollers.Add(this); + } + else + { + _activePollers.Remove(this); + } + + this.MarkDirty(); } } @@ -237,12 +250,15 @@ public partial class ShardPollOption } } - [SerializableField(0, fieldChanged: nameof(OnTitleChanged))] - private string _title; - - private void OnTitleChanged(string oldValue, string newValue) + [SerializableProperty(0)] + public string Title { - _lineBreaks = -1; + get => _title; + set + { + _title = value; + _lineBreaks = -1; + } } public int Votes => Voters.Length; diff --git a/Projects/UOContent/Mobiles/AI/AnimalAI.cs b/Projects/UOContent/Mobiles/AI/AnimalAI.cs index e5a1670e3..a9d410483 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, Mobile.RangeFight, Mobile.RangeFight)) + if (!WalkMobileRange(combatant, 1, false, 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 803587ceb..dd461a03f 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}, attacking"); + this.DebugSayFormatted($"I have detected {Mobile.FocusMob.Name} and I will attack"); Mobile.Combatant = Mobile.FocusMob; Action = ActionType.Combat; @@ -43,11 +43,11 @@ public class ArcherAI : BaseAI return true; } - if (!WalkMobileRange(combatant, 1, Mobile.RangeFight, Mobile.Weapon.MaxRange)) + if (!WalkMobileRange(combatant, 1, false, Mobile.RangeFight, Mobile.Weapon.MaxRange)) { this.DebugSayFormatted($"I am still not in range of {combatant.Name}"); - if (!Mobile.InRange(combatant, Mobile.ChaseLeashRange)) + if ((int)Mobile.GetDistanceToSqrt(combatant) > Mobile.RangePerception + 1) { 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 4463bf4de..08a745296 100644 --- a/Projects/UOContent/Mobiles/AI/BaseAI/AIGroupMovement.cs +++ b/Projects/UOContent/Mobiles/AI/BaseAI/AIGroupMovement.cs @@ -36,34 +36,12 @@ public abstract partial class BaseAI } } - /// - /// 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) => + private bool UseGroupMovement(Mobile target) => Mobile.Combatant == target && !Mobile.Controlled - && Mobile.InRange(target, range + 2) - && CountCrowdingAllies(target, range) > 0; + && CountNearbyAllies(target) > 0; - 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) + public static bool MoveToWithGroup(BaseAI ai, Mobile target, bool run, int range) { if (Core.TickCount - _lastGroupUpdateTime > 1000) { @@ -78,8 +56,7 @@ public abstract partial class BaseAI { if (optimalPosition == Point3D.Zero) { - - return ai.MoveToWithCollisionAvoidance(target, range); + return ai.MoveToWithCollisionAvoidance(target, run, range); } _reservedPositions[mobile] = optimalPosition; @@ -91,15 +68,7 @@ public abstract partial class BaseAI direction = GetAdjustedDirection(direction); } - 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); + return ai.DoMove(direction, true); } finally { @@ -107,6 +76,21 @@ 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 8b2a16cf2..1063dd70a 100644 --- a/Projects/UOContent/Mobiles/AI/BaseAI/AIMovement.cs +++ b/Projects/UOContent/Mobiles/AI/BaseAI/AIMovement.cs @@ -13,12 +13,10 @@ * 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; @@ -39,17 +37,7 @@ public abstract partial class BaseAI private bool _approachGaveUp; private Point3D _approachGaveUpGoalLoc; - // --- 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) + public static double BadlyHurtMoveDelay(BaseCreature bc) { var statMin = Core.HS ? bc.Stam : bc.Hits; var statMax = Core.HS ? bc.StamMax : bc.HitsMax; @@ -57,59 +45,20 @@ public abstract partial class BaseAI if (!bc.IsDeadPet && (bc.ReduceSpeedWithDamage || bc.IsSubdued) && statMax > 0 && statMin < statMax * 0.3) { - var stat = (double)statMin / statMax; + var hits = (double)statMin / statMax; - if (stat < 0.1) { return delay + 0.15; } - if (stat < 0.2) { return delay + 0.1; } - - return delay + 0.05; + 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; } } - return delay; + return bc.CurrentSpeed; } public bool CanMoveNow(out double delay) { delay = 0.0; - 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)); + return Core.TickCount >= NextMove; } public virtual bool CheckMove() => !(Mobile.Deleted || Mobile.DisallowAllMoves); @@ -127,8 +76,6 @@ 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; @@ -139,20 +86,22 @@ public abstract partial class BaseAI if (TryMove(d)) { - // Obeying pets are paced by their order handlers. - if (!IsObeyingMoveOrder()) + if (Core.AOS && IsFollowingMaster()) { - if (Mobile.Warmode || Mobile.Combatant != null) - { - Mobile.SetCurrentSpeedToActive(); - } - else - { - Mobile.SetCurrentSpeedToPassive(); - } + 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; } - - ConsumeMoveBudget(); return MoveResult.Success; } @@ -202,7 +151,6 @@ public abstract partial class BaseAI if (Mobile.Move(Mobile.Direction)) { - ConsumeMoveBudget(); return MoveResult.SuccessAutoTurn; } } @@ -355,18 +303,16 @@ 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, int range) + protected bool ApproachTarget(Mobile target, bool run, int range) { if (Mobile.Deleted || Mobile.DisallowAllMoves || target?.Deleted != false) { - ClearMoveIntent(); return false; } if (Mobile.InRange(target, range)) { ResetApproach(); - ClearMoveIntent(); return true; } @@ -375,15 +321,12 @@ 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 @@ -394,87 +337,35 @@ public abstract partial class BaseAI if (Path == null && Mobile.InLOS(target)) { var distBefore = Mobile.GetDistanceToSqrt(target); - var res = DoMoveImpl(Mobile.GetDirectionTo(target), true); + var res = DoMoveImpl(Mobile.GetDirectionTo(target, run), true); if (res == MoveResult.BadState) { - return true; // not allowed to move this tick (frozen/casting/throttled); not a failure + return false; // not allowed to move this tick; not a stall } if (res == MoveResult.Success && Mobile.GetDistanceToSqrt(target) < distBefore) { - ResetApproach(); - return true; // healthy en-route progress + return Mobile.InRange(target, range); } - // 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 }; } - // 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)) + if (Path.Follow(run, range)) { ResetApproach(); return true; } - 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; + TrackApproachProgress(target); + return false; } /// @@ -485,11 +376,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, bool couldMove) + private void TrackApproachProgress(Mobile target) { - if (!couldMove) + if (!CanMoveNow(out _)) { - return; // a tick that was never allowed to move (stun, stall) is not a stall + return; // a not-yet-due move (stun) is not a stall } var dist = Mobile.GetDistanceToSqrt(target); @@ -520,7 +411,6 @@ public abstract partial class BaseAI _approachGaveUp = true; _approachGaveUpGoalLoc = goalLoc; Path = null; - ClearMoveIntent(); } } @@ -536,73 +426,30 @@ public abstract partial class BaseAI _approachGaveUp = false; } - 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) + public virtual bool MoveTo(Mobile m, bool run, 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, range)) + if (UseGroupMovement(m)) { - return MoveToWithGroup(this, m, range); + return MoveToWithGroup(this, m, shouldRun, range); } - return ApproachTarget(m, range); + return ApproachTarget(m, shouldRun, range); } [MethodImpl(MethodImplOptions.AggressiveInlining)] @@ -612,22 +459,15 @@ public abstract partial class BaseAI Mobile.ControlTarget == Mobile.ControlMaster && Mobile.Combatant == null; - // 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) + private bool MoveToWithCollisionAvoidance(Mobile target, bool run, int range) { + var distance = (int)Mobile.GetDistanceToSqrt(target); + + var shouldRun = run && distance > 5; + var direction = Mobile.GetDirectionTo(target); - // 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) + if (DoMove(direction, true)) { return true; } @@ -636,14 +476,14 @@ public abstract partial class BaseAI { var clockwise = (Direction)(((int)direction + i) % 8); - if (DoMoveImpl(clockwise, true) == MoveResult.Success) + if (DoMove(clockwise, true)) { return true; } var counterclockwise = (Direction)(((int)direction - i + 8) % 8); - if (DoMoveImpl(counterclockwise, true) == MoveResult.Success) + if (DoMove(counterclockwise, true)) { return true; } @@ -651,10 +491,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, range); + return ApproachTarget(target, shouldRun, range); } - public virtual bool WalkMobileRange(Mobile m, int iSteps, int iWantDistMin, int iWantDistMax) + public virtual bool WalkMobileRange(Mobile m, int iSteps, bool run, int iWantDistMin, int iWantDistMax) { if (Mobile.Deleted || Mobile.DisallowAllMoves || m == null) { @@ -665,12 +505,14 @@ 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, iCurrDist, iWantDistMax)) + if (!MoveTowardsOrAwayFrom(m, shouldRun, iCurrDist, iWantDistMax)) { return false; } @@ -681,16 +523,18 @@ public abstract partial class BaseAI return dist >= iWantDistMin && dist <= iWantDistMax; } - private bool MoveTowardsOrAwayFrom(Mobile m, int iCurrDist, int iWantDistMax) + private bool MoveTowardsOrAwayFrom(Mobile m, bool run, int iCurrDist, int iWantDistMax) { + var shouldRun = run && iCurrDist > 5; + if (iCurrDist > iWantDistMax) { // Too far: approach via the centralized progress-based primitive. - return ApproachTarget(m, iWantDistMax); + return ApproachTarget(m, shouldRun, iWantDistMax); } // Too close: back away. Retreat keeps the simple greedy behavior (out of scope). - if (DoMove(m.GetDirectionTo(Mobile), true)) + if (DoMove(m.GetDirectionTo(Mobile, shouldRun), true)) { Path = null; return true; diff --git a/Projects/UOContent/Mobiles/AI/BaseAI/AITimer.cs b/Projects/UOContent/Mobiles/AI/BaseAI/AITimer.cs index e11268e87..3c08d944b 100644 --- a/Projects/UOContent/Mobiles/AI/BaseAI/AITimer.cs +++ b/Projects/UOContent/Mobiles/AI/BaseAI/AITimer.cs @@ -17,116 +17,26 @@ 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; - // 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)) + public AITimer(BaseAI owner) : base(TimeSpan.FromMilliseconds(Utility.Random(3000)), + TimeSpan.FromSeconds(owner.Mobile.CurrentSpeed)) { _owner = owner; _owner._nextDetectHidden = Core.TickCount; - _nextThink = Core.TickCount; } public void Activate() { - _nextThink = Core.TickCount; - - if (Running) - { - return; - } - - // Short random spread: the creature responds within a think while a sector's - // worth of timers avoids a same-tick burst; the idle think jitter keeps the - // cohort apart from there. - Delay = TimeSpan.FromMilliseconds(Utility.Random(256)); + Interval = TimeSpan.FromSeconds(_owner.Mobile.CurrentSpeed); 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()) { @@ -134,64 +44,23 @@ public sealed class AITimer : Timer return; } - if (Core.TickCount - _nextThink >= 0) + _owner.Mobile.OnThink(); + + if (ShouldStop()) { - _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(); + Stop(); + return; } - ScheduleNext(); - } + Interval = TimeSpan.FromSeconds(_owner.Mobile.CurrentSpeed); + HandleBardEffects(); - private void ScheduleNext() - { - var now = Core.TickCount; - var delay = _nextThink - now; - - if (_owner.TryGetMoveWake(out var nextMove)) + if (_owner.Mobile.Controlled ? !_owner.Obey() : !_owner.Think()) { - 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; - } + return; } - // The wheel rounds up to its 8ms resolution; a non-positive delay becomes one turn. - Interval = TimeSpan.FromMilliseconds(delay); - _nextWake = now + (long)Interval.TotalMilliseconds; + HandleDetectHidden(); } private bool ShouldStop() diff --git a/Projects/UOContent/Mobiles/AI/BaseAI/BaseAI.cs b/Projects/UOContent/Mobiles/AI/BaseAI/BaseAI.cs index 4b8753d10..57b6f6a25 100644 --- a/Projects/UOContent/Mobiles/AI/BaseAI/BaseAI.cs +++ b/Projects/UOContent/Mobiles/AI/BaseAI/BaseAI.cs @@ -26,26 +26,11 @@ 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; } @@ -59,12 +44,11 @@ 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.Activate(); + AITimer.Start(); } if (Action != ActionType.Wander) @@ -249,15 +233,6 @@ 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: @@ -297,9 +272,6 @@ public abstract partial class BaseAI public virtual void OnActionChanged() { - // A change of course invalidates between-think movement continuation. - ClearMoveIntent(); - switch (Action) { case ActionType.Wander: @@ -351,15 +323,6 @@ 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() @@ -442,7 +405,7 @@ public abstract partial class BaseAI var master = Mobile.SummonMaster; if (master != null && master.Map == Mobile.Map && master.InRange(Mobile, Mobile.RangePerception)) { - MoveTo(master, 1); + MoveTo(master, false, 1); } } @@ -490,118 +453,18 @@ public abstract partial class BaseAI public virtual bool DoActionGuard() { - if (_investigating) + if (Mobile.Combatant == null) { - if (InvestigateLastKnown()) - { - return true; - } - - _investigating = false; - _guardStopTick = Core.TickCount + GuardGraceDuration; + DebugSay("No threats found. Going home..."); + Action = ActionType.Wander; } - 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..."); + DebugSay("I stopped being on guard."); 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; @@ -628,7 +491,6 @@ public abstract partial class BaseAI if (target == null) { - _herdGoal = null; return false; } @@ -636,15 +498,7 @@ public abstract partial class BaseAI if (distance >= 1 && distance <= 15) { - // 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); + DoMove(Mobile.GetDirectionTo(target)); return true; } @@ -654,7 +508,6 @@ public abstract partial class BaseAI } Mobile.TargetLocation = null; - _herdGoal = null; return false; } @@ -798,7 +651,7 @@ public abstract partial class BaseAI { if (AcquireFocusMob(Mobile.RangePerception * 2, FightMode.Closest, true, false, true)) { - if (WalkMobileRange(Mobile.FocusMob, 1, Mobile.RangePerception, Mobile.RangePerception * 2)) + if (WalkMobileRange(Mobile.FocusMob, 1, false, Mobile.RangePerception, Mobile.RangePerception * 2)) { DebugSay("I backed off to safety. Wandering..."); @@ -850,24 +703,22 @@ public abstract partial class BaseAI return false; } - var reacquireDelay = (long)Mobile.ReacquireDelay.TotalMilliseconds; - var gateRemaining = Mobile.NextReacquireTime - Core.TickCount; - - if (gateRemaining > 0 && gateRemaining <= reacquireDelay) + if (Core.TickCount - Mobile.NextReacquireTime < 0) { Mobile.FocusMob = null; return false; } - DebugSay("Acquiring new target...", 0); + Mobile.NextReacquireTime = Core.TickCount + (int)Mobile.ReacquireDelay.TotalMilliseconds; - var acquired = AcquireNewFocusMob(Mobile.Map, iRange, acqType, bPlayerOnly, bFacFriend, bFacFoe); + DebugSay("Acquiring new target..."); - // Reaction time is the approach path (BaseCreature.ScheduleAcquireOnApproach), - // not this poll — every scan honors the full delay. - Mobile.NextReacquireTime = Core.TickCount + reacquireDelay; + if (Mobile.Map == null) + { + return Mobile.FocusMob != null; + } - return acquired; + return AcquireNewFocusMob(Mobile.Map, iRange, acqType, bPlayerOnly, bFacFriend, bFacFoe); } private bool HandleBardProvoked() @@ -943,10 +794,8 @@ public abstract partial class BaseAI private bool AcquireNewFocusMob(Map map, int iRange, FightMode acqType, bool bPlayerOnly, bool bFacFriend, bool bFacFoe) { - Mobile newFocusMob = null; - Mobile enemySummonMob = null; - var val = double.MinValue; - var enemySummonVal = double.MinValue; + Mobile newFocusMob = null, enemySummonMob = null; + double val = double.MinValue, enemySummonVal = double.MinValue; foreach (var m in map.GetMobilesInRange(Mobile.Location, iRange)) { @@ -1150,6 +999,6 @@ public abstract partial class BaseAI public virtual void OnCurrentSpeedChanged() { - AITimer.OnSpeedChanged(); + AITimer.Interval = TimeSpan.FromSeconds(Mobile.CurrentSpeed); } } diff --git a/Projects/UOContent/Mobiles/AI/BaseAI/PetOrderHandlers.cs b/Projects/UOContent/Mobiles/AI/BaseAI/PetOrderHandlers.cs index 862c78649..ecb456d2e 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; } - AITimer.Prod(); + Activate(); switch (Mobile.ControlOrder) { @@ -36,10 +36,6 @@ public abstract partial class BaseAI break; } case OrderType.Come: - { - Mobile.SetCurrentSpeedToActive(); - break; - } case OrderType.Drop: case OrderType.Friend: case OrderType.Unfriend: @@ -139,7 +135,6 @@ public abstract partial class BaseAI Mobile.FocusMob = null; Mobile.Warmode = false; Mobile.Combatant = null; - Mobile.SetCurrentSpeedToPassive(); } private void HandleTransferOrder() @@ -153,7 +148,6 @@ public abstract partial class BaseAI Mobile.FocusMob = null; Mobile.Warmode = false; Mobile.Combatant = null; - Mobile.SetCurrentSpeedToPassive(); Mobile.PlaySound(Mobile.GetIdleSound()); _commandIssuer = null; } @@ -168,16 +162,9 @@ public abstract partial class BaseAI _commandIssuer?.RevealingAction(); Mobile.FocusMob = null; Mobile.Warmode = true; - 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. - } - + Mobile.PlaySound(Mobile.GetAttackSound()); + Mobile.ControlMaster?.SendLocalizedMessage(1049671, Mobile.Name); + // ~1_NAME~ is now guarding you. _commandIssuer = null; } @@ -204,7 +191,6 @@ public abstract partial class BaseAI } Mobile.Warmode = true; - Mobile.SetCurrentSpeedToActive(); Mobile.PlaySound(Mobile.GetAttackSound()); _commandIssuer = null; } @@ -220,7 +206,6 @@ public abstract partial class BaseAI Mobile.FocusMob = null; Mobile.Warmode = false; Mobile.Combatant = null; - Mobile.SetCurrentSpeedToActive(); Mobile.PlaySound(Mobile.GetIdleSound()); _commandIssuer = null; } @@ -236,7 +221,6 @@ 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 e6c48850d..1b7139a96 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, 1, 2); + WalkMobileRange(Mobile.ControlMaster, 1, false, 1, 2); if (Mobile.GetDistanceToSqrt(Mobile.ControlMaster) <= 2) { @@ -128,15 +128,9 @@ public abstract partial class BaseAI this.DebugSayFormatted($"I am ordered to follow {Mobile.ControlTarget?.Name}."); - // 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, 1, 2); + WalkMobileRange(Mobile.ControlTarget, 1, currentDistance > 2, 1, 2); } } @@ -297,13 +291,14 @@ public abstract partial class BaseAI return true; } - var combatant = FindGuardTarget(); + FindCombatant(); - if (combatant != null) + if (IsValidCombatant(Mobile.Combatant)) { + 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; @@ -314,30 +309,16 @@ public abstract partial class BaseAI { this.DebugSayFormatted($"Guarding my master, {controlMaster.Name}."); - // Stand down; a stale Warmode would skew the return pace. - Mobile.FocusMob = null; - Mobile.Warmode = false; - Mobile.Combatant = null; + var guardLocation = controlMaster.Location; - var distance = (int)Mobile.GetDistanceToSqrt(controlMaster); + var distance = (int)Mobile.GetDistanceToSqrt(guardLocation); if (distance > 3) { - // 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); + DoMove(Mobile.GetDirectionTo(guardLocation)); } else { - Mobile.SetCurrentSpeedToActive(); // alert at the master's side WalkRandom(3, 1, 1); } } @@ -378,83 +359,67 @@ public abstract partial class BaseAI Mobile.ControlTarget = Mobile.ControlMaster; ResumePersistentOrder(); - // 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)) + if (Mobile.FightMode is FightMode.Closest or FightMode.Aggressor) { - 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(); + 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() + private void FindCombatant() { 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 (aggr == best || aggr == Mobile || aggr == controlMaster || - aggr.IsDeadBondedPet || !aggr.Alive || - aggr.Combatant != Mobile && (controlMaster == null || aggr.Combatant != controlMaster)) + if (!Mobile.CanSee(aggr) || aggr.IsDeadBondedPet || !aggr.Alive) { continue; } - var dist = aggr.GetDistanceToSqrt(anchor); + var isAttackingPet = aggr.Combatant == Mobile; + var isAttackingMaster = controlMaster != null && aggr.Combatant == controlMaster; - if (dist < bestDist && Mobile.CanSee(aggr) && Mobile.InLOS(aggr)) + if (isAttackingPet || isAttackingMaster) { - best = aggr; - bestDist = dist; + 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; + } } } - var aggressors = controlMaster?.Aggressors; - - if (aggressors != null) + if (controlMaster?.Aggressors != null) { - for (var i = 0; i < aggressors.Count; i++) + for (var i = 0; i < controlMaster.Aggressors.Count; i++) { - var aggressor = aggressors[i].Attacker; + var aggressor = controlMaster.Aggressors[i].Attacker; - if (aggressor == best || aggressor?.Deleted != false || !aggressor.Alive || - aggressor.IsDeadBondedPet || !Mobile.InRange(aggressor, Mobile.RangePerception)) + if (aggressor?.Deleted != false || !aggressor.Alive || aggressor.IsDeadBondedPet) { continue; } - var dist = aggressor.GetDistanceToSqrt(anchor); - - if (dist < bestDist && Mobile.CanSee(aggressor) && Mobile.InLOS(aggressor)) + if (Mobile.InRange(aggressor, Mobile.RangePerception) && Mobile.CanSee(aggressor) && Mobile.InLOS(aggressor)) { - best = aggressor; - bestDist = dist; + Mobile.ControlTarget = aggressor; + Mobile.ControlOrder = OrderType.Attack; + Mobile.Combatant = aggressor; + + this.DebugSayFormatted($"{aggressor.Name} recently attacked my master! Retaliating..."); + + Think(); + return; } } } - - return best; } public virtual bool DoOrderRelease() diff --git a/Projects/UOContent/Mobiles/AI/BerserkAI.cs b/Projects/UOContent/Mobiles/AI/BerserkAI.cs index 8a2015f1a..4663ae8d0 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}, attacking"); + this.DebugSayFormatted($"I have detected {Mobile.FocusMob.Name} and I will attack"); Mobile.Combatant = Mobile.FocusMob; Action = ActionType.Combat; @@ -38,7 +38,7 @@ public class BerserkAI : BaseAI return true; } - if (!WalkMobileRange(combatant, 1, Mobile.RangeFight, Mobile.RangeFight)) + if (!WalkMobileRange(combatant, 1, false, Mobile.RangeFight, Mobile.RangeFight)) { this.DebugSayFormatted($"I am still not in range of {combatant.Name}"); diff --git a/Projects/UOContent/Mobiles/AI/HealerAI.cs b/Projects/UOContent/Mobiles/AI/HealerAI.cs index cc54c73c3..2a1127b43 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, 4, 7); + WalkMobileRange(Mobile.FocusMob, 1, false, 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 1ed8cbdda..e9450db98 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, Mobile.RangeFight)) + if (!MoveTo(m, false, 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, 1)) + else if (!Mobile.InRange(m, Math.Max(Mobile.RangeFight, 2)) && !MoveTo(m, false, 1)) { OnFailedMove(); } } else if (!Mobile.InRange(m, Mobile.RangeFight)) { - if (!MoveTo(m, 1)) + if (!MoveTo(m, false, 1)) { OnFailedMove(); } @@ -679,7 +679,7 @@ public class MageAI : BaseAI Mobile.Combatant = Mobile.FocusMob; Mobile.FocusMob = null; } - else if (!Mobile.InRange(c, Mobile.ChaseLeashRange)) + else if (!Mobile.InRange(c, Mobile.RangePerception * 3)) { Mobile.Combatant = null; } @@ -695,23 +695,6 @@ 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!"); @@ -1035,16 +1018,7 @@ public class MageAI : BaseAI if (toTarget != null) { - // Without line of sight the stand-off is pointless — close in so the held - // target can be invoked. - if (!Mobile.InLOS(toTarget)) - { - MoveTo(toTarget, 1); - } - else - { - RunTo(toTarget); - } + RunTo(toTarget); } } diff --git a/Projects/UOContent/Mobiles/AI/MeleeAI.cs b/Projects/UOContent/Mobiles/AI/MeleeAI.cs index a6d0a9bf9..aa262caee 100644 --- a/Projects/UOContent/Mobiles/AI/MeleeAI.cs +++ b/Projects/UOContent/Mobiles/AI/MeleeAI.cs @@ -1,5 +1,3 @@ -using System.Runtime.CompilerServices; - namespace Server.Mobiles; public class MeleeAI : BaseAI @@ -16,7 +14,6 @@ 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; } @@ -68,9 +65,13 @@ public class MeleeAI : BaseAI return true; } - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private bool IsValidCombatant(Mobile combatant) => - combatant?.Deleted == false && combatant.Map == Mobile.Map && combatant.Alive && !combatant.IsDeadBondedPet; + private bool IsValidCombatant(Mobile combatant) + { + return combatant?.Deleted == false + && combatant.Map == Mobile.Map + && combatant.Alive + && !combatant.IsDeadBondedPet; + } private bool HandleOutOfRangeCombatant(Mobile combatant) { @@ -81,7 +82,7 @@ public class MeleeAI : BaseAI return true; } - if (!Mobile.InRange(combatant, Mobile.ChaseLeashRange)) + if (!Mobile.InRange(combatant, Mobile.RangePerception * 3)) { Mobile.Combatant = null; } @@ -98,7 +99,7 @@ public class MeleeAI : BaseAI private bool AttemptMoveToCombatant(Mobile combatant) { - if (MoveTo(combatant, Mobile.RangeFight)) + if (MoveTo(combatant, false, Mobile.RangeFight)) { return true; } @@ -126,8 +127,7 @@ 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 b1ea3a638..5e0e01520 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, Mobile.RangeFight, Mobile.RangeFight)) + if (!WalkMobileRange(combatant, 1, false, 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, Mobile.RangePerception, Mobile.RangePerception * 2)) + if (WalkMobileRange(Mobile.FocusMob, 1, false, 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 a9209dfbb..0fa37bbc9 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, Mobile.RangeFight, Mobile.RangeFight)) + if (!WalkMobileRange(combatant, 1, false, 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 741093110..b43c34272 100644 --- a/Projects/UOContent/Mobiles/Animals/Misc/Sheep.cs +++ b/Projects/UOContent/Mobiles/Animals/Misc/Sheep.cs @@ -5,14 +5,9 @@ using System.Runtime.CompilerServices; namespace Server.Mobiles { - [SerializationGenerator(1, false)] + [SerializationGenerator(0, false)] public partial class Sheep : BaseCreature, ICarvable { - private void MigrateFrom(V0Content content) - { - _nextWoolTime = content.NextWoolTime; - } - [Constructible] public Sheep() : base(AIType.AI_Animal, FightMode.Aggressor) { @@ -48,14 +43,18 @@ namespace Server.Mobiles public override string CorpseName => "a sheep corpse"; - [SerializableField(0, fieldChanged: nameof(OnNextWoolTimeChanged))] - [AnchoredDateTime] - [SerializedCommandProperty(AccessLevel.GameMaster)] - private DateTime _nextWoolTime; - - private void OnNextWoolTimeChanged(DateTime oldValue, DateTime newValue) + [DeltaDateTime] + [SerializableProperty(0)] + [CommandProperty(AccessLevel.GameMaster)] + public DateTime NextWoolTime { - SheepBody(); + get => _nextWoolTime; + set + { + _nextWoolTime = value; + SheepBody(); + this.MarkDirty(); + } } [MethodImpl(MethodImplOptions.AggressiveInlining)] diff --git a/Projects/UOContent/Mobiles/Animals/Mounts/Ethereals.cs b/Projects/UOContent/Mobiles/Animals/Mounts/Ethereals.cs index f6dc13b40..f13cebcbe 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,27 +40,43 @@ namespace Server.Mobiles public override double DefaultWeight => 1.0; - [SerializableField(2, fieldChanged: nameof(OnMountedIDChanged))] - [SerializedCommandProperty(AccessLevel.GameMaster)] - private int _mountedID; - - private void OnMountedIDChanged(int oldValue, int newValue) + [SerializableProperty(2)] + [CommandProperty(AccessLevel.GameMaster)] + public int MountedID { - if (_rider != null) + get => _mountedID; + set { - ItemID = newValue; + if (_mountedID != value) + { + _mountedID = value; + + if (_rider != null) + { + ItemID = value; + } + this.MarkDirty(); + } } } - [SerializableField(3, fieldChanged: nameof(OnRegularIDChanged))] - [SerializedCommandProperty(AccessLevel.GameMaster)] - private int _regularID; - - private void OnRegularIDChanged(int oldValue, int newValue) + [SerializableProperty(3)] + [CommandProperty(AccessLevel.GameMaster)] + public int RegularID { - if (_rider == null) + get => _regularID; + set { - ItemID = newValue; + if (_regularID != value) + { + _regularID = value; + + if (_rider == null) + { + ItemID = value; + } + this.MarkDirty(); + } } } @@ -71,7 +87,6 @@ namespace Server.Mobiles public virtual int EtherealHue => 0x4001; [SerializableProperty(4)] - [SaveFlag(nameof(ShouldSerializeRider))] [CommandProperty(AccessLevel.GameMaster)] public Mobile Rider { @@ -109,19 +124,22 @@ namespace Server.Mobiles } } + [SerializableFieldSaveFlag(4)] private bool ShouldSerializeRider() => _rider != null; - [SerializableField(5, allowFieldChange: nameof(AllowStepsChange))] - [SerializedCommandProperty(AccessLevel.GameMaster)] - [SaveFlag(nameof(ShouldSerializeSteps))] - private int _steps; - - private bool AllowStepsChange(ref int value) + [CommandProperty(AccessLevel.GameMaster)] + [SerializableProperty(5)] + public int Steps { - value = Math.Clamp(value, 0, StepsMax); - return true; + get => _steps; + set + { + _steps = Math.Clamp(value, 0, StepsMax); + this.MarkDirty(); + } } + [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 19a061af2..69bd9ce56 100644 --- a/Projects/UOContent/Mobiles/Animals/Mounts/SwampDragon.cs +++ b/Projects/UOContent/Mobiles/Animals/Mounts/SwampDragon.cs @@ -62,37 +62,48 @@ namespace Server.Mobiles [SerializableField(3)] private int _bardingHP; - [SerializableField(2, fieldChanged: nameof(OnHasBardingChanged))] - [SerializedCommandProperty(AccessLevel.GameMaster)] - [InvalidateProperties] - private bool _hasBarding; - - private void OnHasBardingChanged(bool oldValue, bool newValue) + [CommandProperty(AccessLevel.GameMaster)] + [SerializableProperty(2)] + public bool HasBarding { - if (_hasBarding) + get => _hasBarding; + set { - Hue = CraftResources.GetHue(_bardingResource); - Body = 0x31F; - ItemID = 0x3EBE; - } - else - { - Hue = 0x851; - Body = 0x31A; - ItemID = 0x3EBD; + _hasBarding = value; + + if (_hasBarding) + { + Hue = CraftResources.GetHue(_bardingResource); + Body = 0x31F; + ItemID = 0x3EBE; + } + else + { + Hue = 0x851; + Body = 0x31A; + ItemID = 0x3EBD; + } + InvalidateProperties(); + this.MarkDirty(); } } - [SerializableField(4, fieldChanged: nameof(OnBardingResourceChanged))] - [SerializedCommandProperty(AccessLevel.GameMaster)] - [InvalidateProperties] - private CraftResource _bardingResource; - - private void OnBardingResourceChanged(CraftResource oldValue, CraftResource newValue) + [CommandProperty(AccessLevel.GameMaster)] + [SerializableProperty(4)] + public CraftResource BardingResource { - if (_hasBarding) + get => _bardingResource; + set { - Hue = CraftResources.GetHue(newValue); + _bardingResource = value; + + if (_hasBarding) + { + Hue = CraftResources.GetHue(value); + } + + InvalidateProperties(); + this.MarkDirty(); } } diff --git a/Projects/UOContent/Mobiles/BaseCreature.cs b/Projects/UOContent/Mobiles/BaseCreature.cs index 404aaede8..af85a195a 100644 --- a/Projects/UOContent/Mobiles/BaseCreature.cs +++ b/Projects/UOContent/Mobiles/BaseCreature.cs @@ -265,12 +265,8 @@ namespace Server.Mobiles private double _passiveSpeed; private double _currentSpeed; - // 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. + // Herding - Overrides the AI to force the mob to move to a specific location + // Thinking: 0.3s, Movement: 0.6s. private IPoint2D _targetLocation; private int m_DamageMax = -1; @@ -346,7 +342,6 @@ namespace Server.Mobiles FightMode = mode; GetSpeeds(out var activeSpeed, out var passiveSpeed); - GetMoveSpeeds(out _activeMoveSpeed, out _passiveMoveSpeed); ActiveSpeed = activeSpeed; PassiveSpeed = passiveSpeed; @@ -665,20 +660,12 @@ 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 { @@ -692,7 +679,6 @@ namespace Server.Mobiles } } - /// Seconds per AI decision while idle; see for movement pace. [CommandProperty(AccessLevel.GameMaster)] public virtual double PassiveSpeed { @@ -707,37 +693,21 @@ 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; + set + { + _targetLocation = value; + AIObject?.OnCurrentSpeedChanged(); + } } [CommandProperty(AccessLevel.GameMaster)] public double CurrentSpeed { - get => _currentSpeed; + get => _targetLocation != null ? 0.3 : _currentSpeed; set { if (Math.Abs(_currentSpeed - value) > 0.0001) @@ -748,27 +718,8 @@ 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 CurrentMoveSpeed - { - get - { - if (_targetLocation != null) - { - return HerdingMoveSpeed; - } - - return _currentSpeed == _activeSpeed ? ActiveMoveSpeed - : _currentSpeed == _passiveSpeed ? PassiveMoveSpeed - : _currentSpeed; - } - } + public double MoveSpeedMod { get; set; } [CommandProperty(AccessLevel.GameMaster)] public Point3D Home @@ -846,8 +797,6 @@ 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 { @@ -936,49 +885,20 @@ 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); - - // 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 virtual bool AcquireOnApproach => m_Paragon; + public virtual int AcquireOnApproachRange => 10; public static bool Summoning { get; set; } @@ -1923,7 +1843,7 @@ namespace Server.Mobiles { base.Serialize(writer); - writer.Write(22); // version + writer.Write(20); // version writer.Write((int)m_CurrentAI); writer.Write((int)m_DefaultAI); @@ -1960,7 +1880,7 @@ namespace Server.Mobiles if (_summoned) { - writer.WriteAnchoredTime(SummonEnd); + writer.WriteDeltaTime(SummonEnd); } writer.Write(ControlSlots); @@ -2043,18 +1963,12 @@ 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(); @@ -2121,7 +2035,7 @@ namespace Server.Mobiles if (_summoned) { - SummonEnd = version >= 21 ? reader.ReadAnchoredTime() : reader.ReadDeltaTime(); + SummonEnd = reader.ReadDeltaTime(); new UnsummonTimer(this, SummonEnd - Core.Now).Start(); } @@ -2252,16 +2166,6 @@ 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. @@ -2886,9 +2790,15 @@ namespace Server.Mobiles public override void OnMovement(Mobile m, Point3D oldLocation) { - if (ShouldAcquireOnApproach(m)) + if (AcquireOnApproach && !Controlled && !Summoned && !BardPacified && FightMode != FightMode.Aggressor) { - ScheduleAcquireOnApproach(); + if (InRange(m.Location, AcquireOnApproachRange) && !InRange(oldLocation, AcquireOnApproachRange) && + CanBeHarmful(m) && IsEnemy(m)) + { + Combatant = FocusMob = m; + AIObject?.MoveTo(m, true, 1); + DoHarmful(m); + } } else if (ReacquireOnMovement) { @@ -3123,12 +3033,14 @@ namespace Server.Mobiles return base.OnBeforeDeath(); } - public int ComputeBonusDamage(in ValueLinkList list, Mobile m) + public int ComputeBonusDamage(List list, Mobile m) { var bonus = 0; - foreach (var de in list.ByDescending()) + for (var i = list.Count - 1; i >= 0; --i) { + var de = list[i]; + if (de.Damager == m || de.Damager is not BaseCreature bc) { continue; @@ -3165,15 +3077,26 @@ namespace Server.Mobiles Combatant is PlayerMobile || Combatant is BaseCreature { Controlled: true } bc && bc.GetMaster() is PlayerMobile; - // 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) + public static List GetLootingRights(List damageEntries, int hitsMax) { var rights = new List(); DamageStore firstDamager = null; - foreach (var de in damageEntries.ByDescending()) + for (var i = damageEntries.Count - 1; i >= 0; --i) { + if (i >= damageEntries.Count) + { + continue; + } + + var de = damageEntries[i]; + + if (de.HasExpired) + { + damageEntries.RemoveAt(i); + continue; + } + var damage = de.DamageGiven; var respList = de.Responsible; @@ -4656,78 +4579,13 @@ 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; @@ -5041,25 +4899,6 @@ 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 46d939f3b..d3b119523 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, 1, 1) == true) + if (AIObject?.WalkMobileRange(master, 5, false, 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 3f96e5d36..e3fbadd97 100644 --- a/Projects/UOContent/Mobiles/Hireables/BaseHire.cs +++ b/Projects/UOContent/Mobiles/Hireables/BaseHire.cs @@ -23,14 +23,19 @@ public partial class BaseHire : BaseCreature public int GoldOnDeath { get; set; } - [SerializableField(1, fieldChanged: nameof(OnIsHiredChanged))] - [SerializedCommandProperty(AccessLevel.GameMaster)] - [InvalidateProperties] - private bool _isHired; - - private void OnIsHiredChanged(bool oldValue, bool newValue) + [SerializableProperty(1)] + [CommandProperty(AccessLevel.GameMaster)] + public bool IsHired { - Delta(MobileDelta.Noto); + get => _isHired; + set + { + _isHired = value; + + Delta(MobileDelta.Noto); + InvalidateProperties(); + this.MarkDirty(); + } } 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 ce4f3526d..91367a5f0 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, 5); + AIObject?.MoveTo(SummonMaster, false, 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 8f5e908bb..a61ce70c6 100644 --- a/Projects/UOContent/Mobiles/NPCSpeeds.cs +++ b/Projects/UOContent/Mobiles/NPCSpeeds.cs @@ -38,22 +38,6 @@ 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; @@ -94,13 +78,6 @@ 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 8d87943bc..ee3fa1873 100644 --- a/Projects/UOContent/Mobiles/Special/Harrower.cs +++ b/Projects/UOContent/Mobiles/Special/Harrower.cs @@ -1,7 +1,6 @@ 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 cbd0a30c7..825609e22 100644 --- a/Projects/UOContent/Mobiles/Special/Paragon.cs +++ b/Projects/UOContent/Mobiles/Special/Paragon.cs @@ -79,7 +79,6 @@ public static class Paragon bc.PassiveSpeed /= SpeedBuff; bc.ActiveSpeed /= SpeedBuff; - bc.ScaleMoveSpeed(1.0 / SpeedBuff); bc.CurrentSpeed = bc.PassiveSpeed; bc.DamageMin += DamageBuff; @@ -144,8 +143,6 @@ 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 cf439a76c..462307be3 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(3, false)] +[SerializationGenerator(2, false)] public partial class BaseEscortable : BaseCreature { private static readonly ILogger logger = LogFactory.GetLogger(typeof(BaseEscortable)); @@ -158,22 +158,16 @@ public partial class BaseEscortable : BaseCreature [SerializableField(0, setter: "private")] private string _destinationString; + [TimerDrift] [SerializableField(1)] - [DeserializeTimer(nameof(DeserializeDeleteTimer))] private Timer _deleteTimer; - private void DeserializeDeleteTimer(TimeSpan delay) => Timer.DelayCall(delay, Delete); - - private void MigrateFrom(V2Content content) + [DeserializeTimerField(1)] + private void DeserializeDeleteTimer(TimeSpan delay) { - _destinationString = content.DestinationString; - _mlQuestType = content.MlQuestType; - _mlQuestDestinationMessage = content.MlQuestDestinationMessage; - _mlQuestPaymentMessage = content.MlQuestPaymentMessage; - - if (content.DeleteTimerDelay != TimeSpan.MinValue) + if (delay >= TimeSpan.Zero) { - DeserializeDeleteTimer(content.DeleteTimerDelay); + Timer.DelayCall(delay, Delete); } } diff --git a/Projects/UOContent/Mobiles/Vendors/Barkeeper/PlayerBarkeeper.cs b/Projects/UOContent/Mobiles/Vendors/Barkeeper/PlayerBarkeeper.cs index bbbc3ff6f..d80c60dcc 100644 --- a/Projects/UOContent/Mobiles/Vendors/Barkeeper/PlayerBarkeeper.cs +++ b/Projects/UOContent/Mobiles/Vendors/Barkeeper/PlayerBarkeeper.cs @@ -148,13 +148,18 @@ public partial class PlayerBarkeeper : BaseVendor LoadSBInfo(); } - [SerializableField(0, fieldChanged: nameof(OnHouseChanged))] - private BaseHouse _house; - - private void OnHouseChanged(BaseHouse oldValue, BaseHouse newValue) + [SerializableProperty(0)] + public BaseHouse House { - oldValue?.PlayerBarkeepers.Remove(this); - newValue?.PlayerBarkeepers.Add(this); + get => _house; + set + { + _house?.PlayerBarkeepers.Remove(this); + value?.PlayerBarkeepers.Add(this); + + _house = value; + this.MarkDirty(); + } } public override bool IsActiveBuyer => false; diff --git a/Projects/UOContent/Mobiles/Vendors/PlayerVendor.cs b/Projects/UOContent/Mobiles/Vendors/PlayerVendor.cs index 9b8f5a2f3..2ff6eb812 100644 --- a/Projects/UOContent/Mobiles/Vendors/PlayerVendor.cs +++ b/Projects/UOContent/Mobiles/Vendors/PlayerVendor.cs @@ -22,20 +22,9 @@ 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(4, false)] +[SerializationGenerator(3, 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] @@ -43,7 +32,7 @@ public partial class PlayerVendor : Mobile [SerializedCommandProperty(AccessLevel.GameMaster)] private string _shopName; - [AnchoredDateTime] + [DeltaDateTime] [SerializableField(1, setter: "private")] [SerializedCommandProperty(AccessLevel.GameMaster)] private DateTime _nextPayTime; @@ -105,13 +94,18 @@ public partial class PlayerVendor : Mobile public PlayerVendorPlaceholder Placeholder { get; set; } - [SerializableField(2, fieldChanged: nameof(OnHouseChanged))] - private BaseHouse _house; - - private void OnHouseChanged(BaseHouse oldValue, BaseHouse newValue) + [SerializableProperty(2)] + public BaseHouse House { - oldValue?.PlayerVendors.Remove(this); - newValue?.PlayerVendors.Add(this); + get => _house; + set + { + _house?.PlayerVendors.Remove(this); + value?.PlayerVendors.Add(this); + + _house = value; + this.MarkDirty(); + } } public int ChargePerDay diff --git a/Projects/UOContent/Mobiles/Vendors/RentedVendor.cs b/Projects/UOContent/Mobiles/Vendors/RentedVendor.cs index d7d0a3ab6..99d818826 100644 --- a/Projects/UOContent/Mobiles/Vendors/RentedVendor.cs +++ b/Projects/UOContent/Mobiles/Vendors/RentedVendor.cs @@ -46,20 +46,9 @@ public class VendorRentalDuration } } -[SerializationGenerator(1)] +[SerializationGenerator(0)] 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( @@ -104,7 +93,7 @@ public partial class RentedVendor : PlayerVendor [SerializedCommandProperty(AccessLevel.GameMaster)] private int _rentalGold; - [AnchoredDateTime] + [DeltaDateTime] [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 8f0a1fba8..d4d5ce4bd 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 = version >= 1 ? reader.ReadAnchoredTime() : reader.ReadDeltaTime(); + ExpireTime = reader.ReadDeltaTime(); if (Items.Count == 0 && Gold == 0) { @@ -88,7 +88,7 @@ namespace Server.Mobiles public void Serialize(IGenericWriter writer) { - writer.WriteEncodedInt(1); // version + writer.WriteEncodedInt(0); // version writer.Write(Owner); writer.Write(VendorName); @@ -98,7 +98,7 @@ namespace Server.Mobiles writer.Write(Items); writer.Write(Gold); - writer.WriteAnchoredTime(ExpireTime); + writer.WriteDeltaTime(ExpireTime); } private class ExpireTimer : Timer diff --git a/Projects/UOContent/Mobiles/Vendors/VendorItem.cs b/Projects/UOContent/Mobiles/Vendors/VendorItem.cs index 99d1446e6..14af3656a 100644 --- a/Projects/UOContent/Mobiles/Vendors/VendorItem.cs +++ b/Projects/UOContent/Mobiles/Vendors/VendorItem.cs @@ -32,20 +32,18 @@ public partial class VendorItem public string FormattedPrice => Core.ML ? Price.ToString("N0", CultureInfo.GetCultureInfo("en-US")) : Price.ToString(); - [SerializableField(2, fieldChanged: nameof(OnDescriptionChanged), allowFieldChange: nameof(AllowDescriptionChange))] - private string _description; - - private bool AllowDescriptionChange(ref string value) + [SerializableProperty(2)] + public string Description { - value = value ?? ""; - return true; - } - - private void OnDescriptionChanged(string oldValue, string newValue) - { - if (Valid) + get => _description; + set { - Item.InvalidateProperties(); + _description = value ?? ""; + + if (Valid) + { + Item.InvalidateProperties(); + } } } diff --git a/Projects/UOContent/Multis/Boats/BaseBoat.cs b/Projects/UOContent/Multis/Boats/BaseBoat.cs index ed618b91a..2082056da 100644 --- a/Projects/UOContent/Multis/Boats/BaseBoat.cs +++ b/Projects/UOContent/Multis/Boats/BaseBoat.cs @@ -19,24 +19,9 @@ namespace Server.Multis Single } - [SerializationGenerator(5, false)] + [SerializationGenerator(4, 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, @@ -151,23 +136,31 @@ namespace Server.Multis } } - [SerializableField(3, fieldChanged: nameof(OnTimeOfDecayChanged))] - [AnchoredDateTime] - [SerializedCommandProperty(AccessLevel.GameMaster)] - private DateTime _timeOfDecay; - - private void OnTimeOfDecayChanged(DateTime oldValue, DateTime newValue) + [DeltaDateTime] + [SerializableProperty(3)] + [CommandProperty(AccessLevel.GameMaster)] + public DateTime TimeOfDecay { - TillerMan?.InvalidateProperties(); + get => _timeOfDecay; + set + { + _timeOfDecay = value; + TillerMan?.InvalidateProperties(); + this.MarkDirty(); + } } - [SerializableField(10, fieldChanged: nameof(OnShipNameChanged))] - [SerializedCommandProperty(AccessLevel.GameMaster)] - private string _shipName; - - private void OnShipNameChanged(string oldValue, string newValue) + [SerializableProperty(10)] + [CommandProperty(AccessLevel.GameMaster)] + public string ShipName { - TillerMan?.InvalidateProperties(); + get => _shipName; + set + { + _shipName = value; + TillerMan?.InvalidateProperties(); + this.MarkDirty(); + } } [CommandProperty(AccessLevel.GameMaster)] diff --git a/Projects/UOContent/Multis/Camps/BaseCamp.cs b/Projects/UOContent/Multis/Camps/BaseCamp.cs index e60e3e46e..e3d10dd0b 100644 --- a/Projects/UOContent/Multis/Camps/BaseCamp.cs +++ b/Projects/UOContent/Multis/Camps/BaseCamp.cs @@ -6,16 +6,9 @@ using Server.Mobiles; namespace Server.Multis; -[SerializationGenerator(2, false)] +[SerializationGenerator(1, 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; @@ -24,7 +17,7 @@ public abstract partial class BaseCamp : BaseMulti [SerializableField(1, setter: "private")] private List _mobiles; - [AnchoredDateTime] + [DeltaDateTime] [SerializableField(2, setter: "private")] private DateTime _decayTime; diff --git a/Projects/UOContent/Network/AutoDenylist/AutoDenylist.cs b/Projects/UOContent/Network/AutoDenylist/AutoDenylist.cs index 7b3c44ae4..b9b4dd7c1 100644 --- a/Projects/UOContent/Network/AutoDenylist/AutoDenylist.cs +++ b/Projects/UOContent/Network/AutoDenylist/AutoDenylist.cs @@ -32,26 +32,12 @@ namespace Server.Network; /// no bouncer, which is the default. Not persisted, by design: a holding pen that survives restarts is a ban /// without a ban's review. Only verdicts are held. /// -/// -/// A hold is never refreshed, so every expiry is insertion + duration and the ring is sorted by -/// construction. Retiring lapsed entries is therefore the number expiring rather than the number held, which -/// is what lets the cap be sized for the flood instead of for a scan. -/// public static class AutoDenylist { private static readonly ILogger logger = LogFactory.GetLogger(typeof(AutoDenylist)); - // Membership only (normalized v6 bits). Loop-only. The expiry lives beside the key in the ring, so - // there is exactly one copy of it and the two cannot disagree. - private static readonly HashSet _held = []; - - // The same keys in expiry order. Parallel arrays rather than an array of structs: UInt128 forces - // 16-byte alignment, so a packed (key, expiry) struct costs 32 bytes where these cost 24 -- and the - // drain reads only the long[], 8 sequential bytes per entry. - private static UInt128[] _ringKeys = []; - private static long[] _ringExpiry = []; - private static int _ringHead; - private static int _ringCount; + // Address (normalized v6 bits) -> Core.TickCount at which the hold lapses. Loop-only. + private static readonly Dictionary _held = []; private static bool _enabled; private static long _durationMs; @@ -60,9 +46,6 @@ public static class AutoDenylist public static int Count => _held.Count; - // Test seam: the ring and the set hold the same entries, and nothing else may assume it. - internal static int RingCount => _ringCount; - public static void Configure() { AutoDenylistConfiguration.Load(); @@ -94,47 +77,35 @@ public static class AutoDenylist return false; } - Drain(nowTicks); - var key = address.ToUInt128(); - // Deliberately not refreshed: the first detection sets the expiry and later ones leave it alone. - // That keeps insertion order equal to expiry order, which is why the drain can stop at the first - // live record. A flooder whose hold lapses trips the rate limiter on its next attempt -- which - // runs ahead of the connection filters -- and is held again. - if (!_held.Add(key)) + // An address already held is just extended, so no cap check is needed. + if (!_held.ContainsKey(key) && _held.Count >= _maxEntries) { - return true; - } + Sweep(nowTicks); - // Drain already reclaimed everything reclaimable, so being over now means genuinely full. - if (_held.Count > _maxEntries) - { - _held.Remove(key); - - if (!_warnedFull) + if (_held.Count >= _maxEntries) { - _warnedFull = true; - logger.Warning( - "Auto-denylist is full at {Max} addresses; further detections are disconnected but not held", - _maxEntries - ); - } + if (!_warnedFull) + { + _warnedFull = true; + logger.Warning( + "Auto-denylist is full at {Max} addresses; further detections are disconnected but not held", + _maxEntries + ); + } - return false; + return false; + } } - Push(key, nowTicks + _durationMs); + _held[key] = nowTicks + _durationMs; return true; } public static bool IsDenied(IPAddress address) => IsDenied(address, Core.TickCount); - /// - /// The accept-path decision, split out so the policy can be tested without a clock. Drains first: the - /// expiry lives in the ring, not beside the membership, so a lapsed hold has to be retired here rather - /// than expired on read. One array read when nothing has lapsed. - /// + /// The pure decision, split out so the accept-path policy can be tested without a clock. internal static bool IsDenied(IPAddress address, long nowTicks) { if (!_enabled || address == null) @@ -142,136 +113,46 @@ public static class AutoDenylist return false; } - Drain(nowTicks); - return _held.Contains(address.ToUInt128()); + // Decided on read, so a lapsed hold cannot deny even before the sweep. Subtraction: TickCount wraps. + return _held.TryGetValue(address.ToUInt128(), out var expires) && expires - nowTicks > 0; } /// Releases an address early, e.g. when an operator retracts a ban. public static void Release(IPAddress address) { - if (!_enabled || address == null) + if (_enabled && address != null) + { + _held.Remove(address.ToUInt128()); + } + } + + internal static void Sweep(long nowTicks) + { + if (_held.Count == 0) { return; } - var key = address.ToUInt128(); - if (_held.Remove(key)) - { - // The ring record has to go too. Nothing records that this key was released, so if it were - // detected again before the old record lapsed, that record would retire the new hold early. - // O(n), but this is an operator retraction, not the accept path. - PurgeRing(key); - } - } + var lapsed = 0; - /// - /// Retires everything that has lapsed. Expiries only ever increase along the ring, so the first live - /// record ends the scan and the cost is the number actually expiring, not the number held. - /// - internal static void Drain(long nowTicks) - { - var before = _ringCount; - - // Subtraction, never a direct compare: tick counts wrap. See dev-docs/tick-counts.md. - while (_ringCount > 0 && _ringExpiry[_ringHead] - nowTicks <= 0) + foreach (var (address, expires) in _held) { - _held.Remove(_ringKeys[_ringHead]); - _ringHead = _ringHead + 1 == _ringKeys.Length ? 0 : _ringHead + 1; - _ringCount--; + if (expires - nowTicks <= 0) + { + _held.Remove(address); + lapsed++; + } } - if (_ringCount != before) + if (lapsed > 0) { _warnedFull = false; } } - private static void Push(UInt128 key, long expiry) - { - if (_ringCount == _ringKeys.Length) - { - Grow(); - } - - var tail = _ringHead + _ringCount; - if (tail >= _ringKeys.Length) - { - tail -= _ringKeys.Length; - } - - _ringKeys[tail] = key; - _ringExpiry[tail] = expiry; - _ringCount++; - } - - private static void Grow() - { - // Capped at the entry cap: Push only runs below it, so the ring never needs more, and doubling - // past it would reserve roughly twice the slots it can ever use. - var size = Math.Min(Math.Max(64, _ringKeys.Length * 2), _maxEntries); - var keys = new UInt128[size]; - var expiry = new long[size]; - - for (var i = 0; i < _ringCount; i++) - { - var from = _ringHead + i; - if (from >= _ringKeys.Length) - { - from -= _ringKeys.Length; - } - - keys[i] = _ringKeys[from]; - expiry[i] = _ringExpiry[from]; - } - - _ringKeys = keys; - _ringExpiry = expiry; - _ringHead = 0; - } - - private static void PurgeRing(UInt128 key) - { - var capacity = _ringKeys.Length; - - for (var i = 0; i < _ringCount; i++) - { - var at = _ringHead + i; - if (at >= capacity) - { - at -= capacity; - } - - if (_ringKeys[at] != key) - { - continue; - } - - // Close the gap so the ring stays contiguous and expiry-ordered. - for (var j = i; j < _ringCount - 1; j++) - { - var to = _ringHead + j; - if (to >= capacity) - { - to -= capacity; - } - - var from = to + 1 == capacity ? 0 : to + 1; - _ringKeys[to] = _ringKeys[from]; - _ringExpiry[to] = _ringExpiry[from]; - } - - _ringCount--; - return; - } - } - internal static void LoadForTesting(bool enabled, long durationMs, int maxEntries) { _held.Clear(); - _ringKeys = []; - _ringExpiry = []; - _ringHead = 0; - _ringCount = 0; _enabled = enabled; _durationMs = durationMs; _maxEntries = maxEntries; @@ -282,8 +163,6 @@ public static class AutoDenylist /// Accept-path gate for . public sealed class AutoDenylistFilter : IConnectionFilter { - private Timer _sweepTimer; - public string Name => "auto-denylist"; public void Register() @@ -292,20 +171,12 @@ public sealed class AutoDenylistFilter : IConnectionFilter public void Start(CancellationToken token) { - // Only reclaims memory: Hold and IsDenied both drain, so this matters on a shard that has gone - // quiet after a flood and would otherwise hold the ring until someone next connects. - _sweepTimer = Timer.DelayCall( - TimeSpan.FromMinutes(1), - TimeSpan.FromMinutes(1), - () => AutoDenylist.Drain(Core.TickCount) - ); + // Only an optimisation: IsDenied expires on read. + Timer.DelayCall(TimeSpan.FromMinutes(1), TimeSpan.FromMinutes(1), () => AutoDenylist.Sweep(Core.TickCount)); } public void Stop() { - // Recurring, so an uncancelled sweep survives Stop and the next Start adds a second one. - _sweepTimer?.Stop(); - _sweepTimer = null; } public bool ShouldDeny(IPAddress address) => AutoDenylist.IsDenied(address); diff --git a/Projects/UOContent/Network/AutoDenylist/AutoDenylistConfiguration.cs b/Projects/UOContent/Network/AutoDenylist/AutoDenylistConfiguration.cs index fd13ef003..4d45da350 100644 --- a/Projects/UOContent/Network/AutoDenylist/AutoDenylistConfiguration.cs +++ b/Projects/UOContent/Network/AutoDenylist/AutoDenylistConfiguration.cs @@ -21,8 +21,9 @@ using Server.Json; namespace Server.Network; /// -/// Loads the from Configuration/auto-denylist.json. Loaded once; -/// a missing file writes a template so operators have something to edit. +/// Loads the from Configuration/auto-denylist.json (matching the +/// per-feature JSON config pattern used by BlocklistConfiguration). Loaded once; a missing file writes +/// a template so operators have something to edit. /// public static class AutoDenylistConfiguration { @@ -75,14 +76,6 @@ public record AutoDenylistSettings /// stops it becoming the exhaustion it prevents. At the cap new addresses are not tracked, but are still /// disconnected by whichever gate detected them. /// - /// - /// A HashSet capacity, not a round number. Grown from empty it steps 36,353 → 75,431 → 156,437 - /// → 324,449, so this fills one exactly instead of stranding slots: 65,536 sat just past a resize and - /// left 9,895 of them unusable. Sized to cover the 50k–250k distinct-source floods seen in practice, - /// for ~19 MB — 36 bytes a set slot plus 24 for the ring record. Raising it is bounded by memory - /// rather than by a scan, since holds are retired from the ring in expiry order; a flood past it wants - /// upstream scrubbing rather than a larger cap. - /// [JsonPropertyName("maxEntries")] - public int MaxEntries { get; set; } = 324_449; + public int MaxEntries { get; set; } = 65536; } diff --git a/Projects/UOContent/Network/BanExemptions.cs b/Projects/UOContent/Network/BanExemptions.cs index c5f6b726a..5566bd5e7 100644 --- a/Projects/UOContent/Network/BanExemptions.cs +++ b/Projects/UOContent/Network/BanExemptions.cs @@ -20,7 +20,7 @@ using Server.Network.Bans; namespace Server.Network; /// -/// Combines and into the one answer +/// Combines and into the one answer /// asks for, so neither source has to know about the other. /// public static class BanExemptions @@ -51,7 +51,7 @@ public static class BanExemptions } // Deliberate and unconditional, so it wins and must not spend the earned list's strikes. - if (ManualAllowlist.Contains(address)) + if (FileAllowlist.Contains(address)) { return true; } diff --git a/Projects/UOContent/Network/Blocklist/BlocklistConfiguration.cs b/Projects/UOContent/Network/Blocklist/BlocklistConfiguration.cs index 19541636f..8a2df5ca6 100644 --- a/Projects/UOContent/Network/Blocklist/BlocklistConfiguration.cs +++ b/Projects/UOContent/Network/Blocklist/BlocklistConfiguration.cs @@ -21,8 +21,9 @@ using Server.Json; namespace Server.Network.Bans; /// -/// Loads the from Configuration/blocklist.json. Loaded once; a -/// missing file writes a template so operators have something to edit. +/// Loads the from Configuration/blocklist.json (matching the +/// per-feature JSON config pattern used by AssistantConfiguration). Loaded once; a missing file +/// writes a template so operators have something to edit. /// public static class BlocklistConfiguration { @@ -52,19 +53,12 @@ public static class BlocklistConfiguration } /// -/// Bound configuration for . The filter is inert unless -/// is set and points at a list that exists, so a shard that never runs the generator -/// pays nothing for the defaults. +/// Bound configuration for . The filter is inert unless +/// points at a list that actually exists, so the shipped defaults are safe on a shard that never runs +/// the generator. /// public record BlocklistSettings { - /// - /// Whether the accept-path gate runs at all. Off by default: the reload poll runs for the whole - /// uptime, which no shard should pay before an operator has chosen to run a blocklist. - /// - [JsonPropertyName("enabled")] - public bool Enabled { get; set; } - /// /// Path to the blocklist. A relative path resolves against ; an /// absolute path is used as-is (handy when several shards share one generated list). Set to @@ -73,6 +67,19 @@ public record BlocklistSettings [JsonPropertyName("file")] public string File { get; set; } = "Configuration/ip-blocklist.txt"; + /// + /// Addresses that must never be blocked and never escalated, in the blocklist's own format. The same + /// files tools/Export-IpBlocklist.ps1 subtracts at generation time; the shard reads them so an + /// entry also suppresses ban contributions, which the generator alone cannot do. See + /// . + /// + /// + /// The filename may contain wildcards, which is how the default picks up a carve-out an admin adds + /// without anyone editing this file. + /// + [JsonPropertyName("allowlistFiles")] + public string[] AllowlistFiles { get; set; } = ["Configuration/ip-allowlist*.txt"]; + /// How often the file is checked for changes. Reloads only happen when it actually changed. [JsonPropertyName("reloadInterval")] public TimeSpan ReloadInterval { get; set; } = TimeSpan.FromSeconds(60); diff --git a/Projects/UOContent/Network/Blocklist/BlocklistFilter.cs b/Projects/UOContent/Network/Blocklist/BlocklistFilter.cs index 030997803..36354160b 100644 --- a/Projects/UOContent/Network/Blocklist/BlocklistFilter.cs +++ b/Projects/UOContent/Network/Blocklist/BlocklistFilter.cs @@ -25,8 +25,8 @@ namespace Server.Network.Bans; /// /// Accept-path gate for a large, file-sourced IP blocklist, hydrated from the file a generator /// (tools/Export-IpBlocklist.ps1) writes on a schedule. Holds an immutable snapshot swapped -/// atomically by an off-loop reload poll, so accept-path reads are lock-free. Opt-in via -/// blocklist.json's enabled; inert when off, or when no file is configured or present. +/// atomically by an off-loop reload poll, so accept-path reads are lock-free. Inert when no file is +/// configured or present. /// /// /// This is the demand-paging half of the design: an OS firewall cannot hold millions of entries on @@ -38,13 +38,12 @@ public sealed class BlocklistFilter : IConnectionFilter { private static readonly ILogger logger = LogFactory.GetLogger(typeof(BlocklistFilter)); - // Written by the reload poll (off-loop), read by the accept path (game loop). One volatile reference - // swap is the whole synchronization story: readers see the old or the new snapshot, whole. + // Written by the reload poll (off-loop), read by the accept path (game loop): a single volatile + // reference swap is the whole synchronization story — readers see the old or the new snapshot, whole. private volatile BlocklistSnapshot _snapshot = BlocklistSnapshot.Empty; private readonly PromotedGuard _guard = new(); - private bool _enabled; private string _path; private TimeSpan _interval; private bool _reportHits; @@ -53,7 +52,6 @@ public sealed class BlocklistFilter : IConnectionFilter private string _lastGenerated; private DateTime _lastWriteUtc; private CancellationTokenSource _cts; - private Timer _sweepTimer; public string Name => "blocklist"; @@ -70,7 +68,6 @@ public sealed class BlocklistFilter : IConnectionFilter var s = BlocklistConfiguration.Settings; _path = ResolvePath(s.File); - _enabled = s.Enabled && _path != null; _interval = s.ReloadInterval <= TimeSpan.Zero ? TimeSpan.FromSeconds(60) : s.ReloadInterval; _reportHits = s.ReportHits; _banDuration = s.BanDuration; @@ -94,26 +91,16 @@ public sealed class BlocklistFilter : IConnectionFilter public void Start(CancellationToken token) { - if (!_enabled) + if (_path == null) { - LogWhyDisabled(); + logger.Information("Blocklist disabled (\"file\" empty in blocklist.json)"); return; } - // The operator's override on this gate, opted into separately. Without it only the generator's - // subtraction covers carve-outs, and that does not cover ban contributions. - if (!ManualAllowlist.Enabled) - { - logger.Warning( - "Blocklist is on but the manual allowlist is not; set \"enabled\" in ip-allowlist.json so a " + - "carve-out also suppresses ban contributions" - ); - } - _cts = CancellationTokenSource.CreateLinkedTokenSource(token); - // A missing file is not an error: the gate stays inert until the poll picks up whatever the - // generator first writes. No restart needed. + // A missing file is the shipped default, not an error: the gate stays inert until the poll picks + // up whatever the generator first writes. No restart needed. if (File.Exists(_path)) { Reload(); // synchronous prime; empty on failure (fail-open) @@ -123,47 +110,17 @@ public sealed class BlocklistFilter : IConnectionFilter logger.Information("Blocklist inert: no list at \"{Path}\"; polling every {Interval}", _path, _interval); } - // Sweep the promote-guard so a distinct-IP flood cannot grow it unbounded. Only marked when hits - // are reported, so there is nothing to sweep otherwise. - if (_reportHits) - { - _sweepTimer = Timer.DelayCall(TimeSpan.FromMinutes(1), TimeSpan.FromMinutes(1), SweepGuard); - } + // Sweep the promote-guard so a distinct-IP flood cannot grow it unbounded. + Timer.DelayCall(TimeSpan.FromMinutes(1), TimeSpan.FromMinutes(1), SweepGuard); _ = Task.Run(() => PollLoop(_cts.Token), _cts.Token); } - private void LogWhyDisabled() - { - if (_path == null) - { - logger.Information("Blocklist disabled (\"file\" empty in blocklist.json)"); - } - else if (File.Exists(_path)) - { - // An upgraded shard has a list on disk but no "enabled" key, so say so rather than silently - // dropping a gate it was relying on. - logger.Warning( - "Blocklist is off (\"enabled\" false in blocklist.json) but a list is present at \"{Path}\"; " + - "no addresses will be denied", - _path - ); - } - else - { - logger.Information("Blocklist disabled (\"enabled\" false in blocklist.json)"); - } - } - public void Stop() { _cts?.Cancel(); _cts?.Dispose(); _cts = null; - - // Recurring, so an uncancelled sweep survives Stop and the next Start adds a second one. - _sweepTimer?.Stop(); - _sweepTimer = null; } public bool ShouldDeny(IPAddress address) @@ -196,9 +153,9 @@ public sealed class BlocklistFilter : IConnectionFilter } // Both are asked only once the list has matched, so they cost the common accept nothing. The file - // list is usually redundant because the generator subtracts it — except right after an operator - // adds an entry without regenerating, which is when someone is waiting to get back in. - if (ManualAllowlist.Contains(address)) + // list is usually redundant because the generator subtracts it — except right after an operator adds + // an entry without regenerating, which is exactly when someone is waiting to get back in. + if (FileAllowlist.Contains(address)) { return false; } @@ -291,8 +248,9 @@ public sealed class BlocklistFilter : IConnectionFilter private void Reload() { - // Capture the mtime/header BEFORE Load() so they describe the version being parsed. Capturing - // after could skip a version the producer swapped in mid-parse; stale markers only cost a reload. + // Capture the mtime/header BEFORE Load() so the markers describe the version being parsed, not + // one the producer swapped in mid-parse. Stale markers only cost an extra reload next poll; + // capturing after could skip a version entirely. var writeUtc = default(DateTime); try { diff --git a/Projects/UOContent/Network/Blocklist/BlocklistSnapshot.cs b/Projects/UOContent/Network/Blocklist/BlocklistSnapshot.cs index d62df0752..29b2e5176 100644 --- a/Projects/UOContent/Network/Blocklist/BlocklistSnapshot.cs +++ b/Projects/UOContent/Network/Blocklist/BlocklistSnapshot.cs @@ -46,7 +46,8 @@ public sealed class BlocklistSnapshot /// Parses a blocklist directly from its UTF-8/ASCII file bytes — one line at a time, splitting on /// '\n' with no per-line string allocation. IPv4 singles and CIDRs are parsed straight from the /// byte span; IPv6 (the rare path) decodes the single address token and defers to the framework parser. - /// Malformed lines increment and never throw. + /// Malformed lines increment and never throw. Build-time intermediates use + /// the multithreaded pool because this runs off the game loop on the reload/bootstrap thread. /// public static BlocklistSnapshot Build(ReadOnlySpan data, out int parsed, out int skipped) { @@ -182,7 +183,7 @@ public sealed class BlocklistSnapshot } /// - /// Plain set membership, for callers whose set is an ALLOWlist (see ) and for + /// Plain set membership, for callers whose set is an ALLOWlist (see ) and for /// whom would read backwards. The interval machinery is direction-agnostic. /// public bool Contains(IPAddress ip) => IsBanned(ip); diff --git a/Projects/UOContent/Network/ManualAllowlist/ManualAllowlist.cs b/Projects/UOContent/Network/Blocklist/FileAllowlist.cs similarity index 83% rename from Projects/UOContent/Network/ManualAllowlist/ManualAllowlist.cs rename to Projects/UOContent/Network/Blocklist/FileAllowlist.cs index 20e351457..0d5582713 100644 --- a/Projects/UOContent/Network/ManualAllowlist/ManualAllowlist.cs +++ b/Projects/UOContent/Network/Blocklist/FileAllowlist.cs @@ -2,7 +2,7 @@ * ModernUO * * Copyright 2019-2026 - ModernUO Development Team * * Email: hi@modernuo.com * - * File: ManualAllowlist.cs * + * File: FileAllowlist.cs * * * * This program is free software: you can redistribute it and/or modify * * it under the terms of the GNU General Public License as published by * @@ -32,15 +32,15 @@ namespace Server.Network.Bans; /// Behavioural detections never consult the blocklist, so without reading the files here a carve-out is /// quietly routed around: one scanner behind a shared CGNAT address is enough to get the whole address /// contributed and firewalled. Reading them also means an entry applies on the next reload rather than the -/// next regeneration. Opt-in via ip-allowlist.json's enabled, since the poll runs for the -/// whole uptime; no shield against a manual ban either — see . +/// next regeneration. Unconditional, unlike , but still no shield against a +/// manual ban — see . /// -public static class ManualAllowlist +public static class FileAllowlist { - private static readonly ILogger logger = LogFactory.GetLogger(typeof(ManualAllowlist)); + private static readonly ILogger logger = LogFactory.GetLogger(typeof(FileAllowlist)); - // Written by the reload poll (off-loop), read by the accept path (game loop). One volatile reference - // swap is the whole synchronization story: readers see the old or the new snapshot, whole. + // Written by the reload poll (off-loop), read by the accept path (game loop): a single volatile + // reference swap is the whole synchronization story — readers see the old or the new snapshot, whole. private static volatile BlocklistSnapshot _snapshot = BlocklistSnapshot.Empty; private static string[] _patterns = []; @@ -53,51 +53,24 @@ public static class ManualAllowlist /// True when an operator listed this address. Safe before . public static bool Contains(IPAddress address) => address != null && _snapshot.Contains(address); - /// True when the shard is reading allowlist files. Safe before . - public static bool Enabled { get; private set; } - public static void Initialize() { - ManualAllowlistConfiguration.Load(); - var settings = ManualAllowlistConfiguration.Settings; + // BlocklistFilter.Register ran during the Configure sweep, so the settings are populated. + var settings = BlocklistConfiguration.Settings; if (settings == null) { return; } - _patterns = ResolvePaths(settings.Files); + _patterns = ResolvePaths(settings.AllowlistFiles); _interval = settings.ReloadInterval <= TimeSpan.Zero ? TimeSpan.FromSeconds(60) : settings.ReloadInterval; - if (!settings.Enabled) - { - // The generator still subtracts these files, so a carve-out an operator already wrote looks - // like it works right up until a behavioural detection contributes the address anyway. - var present = ExpandPaths().Length; - _patterns = []; - - if (present > 0) - { - logger.Warning( - "Manual allowlist is off (\"enabled\" false in ip-allowlist.json) but {Count} allowlist file(s) " + - "are present; those carve-outs will not suppress ban contributions", - present - ); - } - else - { - logger.Information("Manual allowlist disabled (\"enabled\" false in ip-allowlist.json)"); - } - - return; - } - if (_patterns.Length == 0) { - logger.Information("Manual allowlist disabled (\"files\" empty in ip-allowlist.json)"); + logger.Information("File allowlist disabled (\"allowlistFiles\" empty in blocklist.json)"); return; } - Enabled = true; Reload(); _cts = CancellationTokenSource.CreateLinkedTokenSource(Core.ClosingTokenSource.Token); @@ -224,7 +197,7 @@ public static class ManualAllowlist } catch (Exception e) { - logger.Warning(e, "Manual allowlist reload check failed; keeping last snapshot ({Count})", Count); + logger.Warning(e, "File allowlist reload check failed; keeping last snapshot ({Count})", Count); } } } @@ -273,7 +246,7 @@ public static class ManualAllowlist _lastStamp = stamp; logger.Information( - "Manual allowlist loaded {Count} range(s) from {Files} file(s)", + "File allowlist loaded {Count} range(s) from {Files} file(s)", next.Count, files ); diff --git a/Projects/UOContent/Network/CrowdSec/CrowdSecConfiguration.cs b/Projects/UOContent/Network/CrowdSec/CrowdSecConfiguration.cs index 5855633f9..b1376b6a0 100644 --- a/Projects/UOContent/Network/CrowdSec/CrowdSecConfiguration.cs +++ b/Projects/UOContent/Network/CrowdSec/CrowdSecConfiguration.cs @@ -21,8 +21,9 @@ using Server.Json; namespace Server.Network.Bans.CrowdSec; /// -/// Loads the from Configuration/crowdsec.json. Loaded once; a -/// missing file writes a disabled-by-default template so operators have something to edit. +/// Loads the from Configuration/crowdsec.json (matching the +/// per-feature JSON config pattern used by AssistantConfiguration). Loaded once; a missing file +/// writes a disabled-by-default template so operators have something to edit. /// public static class CrowdSecConfiguration { diff --git a/Projects/UOContent/Network/CrowdSec/CrowdSecReporter.cs b/Projects/UOContent/Network/CrowdSec/CrowdSecReporter.cs index c900eb90f..385534d2a 100644 --- a/Projects/UOContent/Network/CrowdSec/CrowdSecReporter.cs +++ b/Projects/UOContent/Network/CrowdSec/CrowdSecReporter.cs @@ -304,10 +304,12 @@ public sealed class CrowdSecReporter : IBanReporter } /// - /// Up to 3 attempts (1 initial + 2 retries) backing off 1s then 2s, for transient LAPI failures - /// (network blips, 5xx). Backoff uses so it never blocks the thread, and a - /// cancellation during it propagates so the drain loop exits cleanly. Returns false rather than - /// throwing once attempts are exhausted, so the caller counts the drop and keeps draining. + /// Sends with up to 3 attempts total (1 initial + 2 retries), backing off 1s then 2s between + /// attempts, for transient LAPI failures (network blips, 5xx). Backoff uses + /// so it never blocks the thread; a cancellation during backoff propagates as + /// so the drain loop exits cleanly. Returns false (never + /// throws for a send failure) once attempts are exhausted, so the caller can count the drop and keep + /// draining instead of losing the rest of the batch/queue. /// private static async ValueTask SendWithBoundedRetryAsync(Func send, CancellationToken token) { diff --git a/Projects/UOContent/Network/Firewall/Firewall.cs b/Projects/UOContent/Network/Firewall/Firewall.cs index 36c11bbf9..075b47ec3 100644 --- a/Projects/UOContent/Network/Firewall/Firewall.cs +++ b/Projects/UOContent/Network/Firewall/Firewall.cs @@ -28,10 +28,10 @@ namespace Server.Network; public static class Firewall { // Single-threaded: the accept path, admin gump/command, TTL expiry timer, and boot load all run on - // the main game loop, so no locks, caches, or version counters are needed. _entries is the - // authoritative store; _index is a derived, rebuild-on-demand SortedRangeIndex used only for the - // accept-path IsBlocked lookup, over the same primitive the blocklist uses (see BlocklistSnapshot). - // See dev-docs/ip-bans-and-allowlists.md. + // the main game loop. No locks, caches, or version counters are needed. See the ban-channel design doc. + // _entries is the authoritative store (gump/persistence/TTL/command all work against it); _index is a + // derived, rebuild-on-demand SortedRangeIndex used only for the accept-path IsBlocked lookup, shared + // with the same sorted-range binary-search primitive the blocklist uses (see BlocklistSnapshot). private static readonly List _entries = []; // Entries with a TTL: entry -> absolute expiry tick (Core.TickCount). Permanent entries are absent. @@ -152,7 +152,7 @@ public static class Firewall } /// - /// Removes every entry whose TTL has elapsed. Called from the main-thread maintenance timer. + /// Removes every entry whose TTL has elapsed. Called from the main-thread maintenance timer (Task 2). /// internal static void ExpireEntries(long nowTicks) { diff --git a/Projects/UOContent/Network/LoginAllowlist/LoginAllowlist.cs b/Projects/UOContent/Network/LoginAllowlist/LoginAllowlist.cs index 23075f7ff..c44ceb33d 100644 --- a/Projects/UOContent/Network/LoginAllowlist/LoginAllowlist.cs +++ b/Projects/UOContent/Network/LoginAllowlist/LoginAllowlist.cs @@ -19,7 +19,6 @@ using System.Globalization; using System.IO; using System.Net; using System.Text; -using System.Threading; using System.Threading.Tasks; using Server.Logging; using Server.Network.Bans; @@ -31,11 +30,16 @@ namespace Server.Network; /// blocked and a flaky connection cannot get one globally banned. /// /// -/// Consulted only after the blocklist has already matched, so a normal accept pays nothing for it. An entry -/// is evidence rather than a licence: enough strikes inside the window revokes it. It cannot bootstrap, so -/// it does not replace . Both dictionaries are game-loop state; only the file -/// write runs off-loop, over a snapshot taken on the loop. -/// See dev-docs/ip-bans-and-allowlists.md. +/// +/// Consulted only after the blocklist has already matched, and again before a ban is contributed, so a +/// normal accept pays nothing for it. An entry is evidence rather than a licence: enough strikes inside the +/// window revokes it. It cannot bootstrap, so it hedges stable addresses and does not replace +/// . See dev-docs/ip-bans-and-allowlists.md. +/// +/// +/// Both dictionaries are game-loop state. Only the file write runs off-loop, over a snapshot taken on the +/// loop. +/// /// public static class LoginAllowlist { @@ -48,11 +52,6 @@ public static class LoginAllowlist // _allowed and cannot be grown by an attacker. private static readonly Dictionary _strikes = []; - // Reused: past ~5,300 entries a fresh UInt128[] is an LOH allocation, once per flush. Grown - // geometrically, never shrunk. - private static UInt128[] _addressBuffer = []; - private static long[] _stampBuffer = []; - private static bool _enabled; private static string _path; private static long _ttlSeconds; @@ -60,10 +59,6 @@ public static class LoginAllowlist private static long _strikeWindowSeconds; private static bool _dirty; - // Loop-only. The writer owns the buffers until it posts completion back, so a flush landing mid-write - // waits rather than overwriting them. - private static bool _writing; - public static int Count => _allowed.Count; private struct Strike @@ -102,14 +97,10 @@ public static class LoginAllowlist var interval = LoginAllowlistConfiguration.Settings.FlushInterval; if (interval <= TimeSpan.Zero) { - interval = TimeSpan.FromHours(1); + interval = TimeSpan.FromMinutes(1); } Timer.DelayCall(interval, interval, Flush); - - // HandleClosed skips InvokeShutdown when the server crashed, so the crash path needs its own. - EventSink.Shutdown += OnShutdown; - EventSink.ServerCrashed += OnCrashed; } /// @@ -206,8 +197,6 @@ public static class LoginAllowlist { _allowed.Clear(); _strikes.Clear(); - _writing = false; - _dirty = false; _enabled = enabled; _ttlSeconds = ttlSeconds; _escalateAfterStrikes = escalateAfterStrikes; @@ -219,87 +208,28 @@ public static class LoginAllowlist private static void Flush() { + if (!_enabled || !_dirty) + { + return; + } + // A save owns the disk and nothing here is urgent. _dirty stays set, so skipping loses nothing. // See the threading policy in CLAUDE.md (rules #3 and #10). - if (!_enabled || !_dirty || _writing || World.Saving || World.WorldState == WorldState.PendingSave) + if (World.Saving || World.WorldState == WorldState.PendingSave) { return; } - var count = Snapshot(out var dropped); - var addresses = _addressBuffer; - var stamps = _stampBuffer; - var path = _path; - - _dirty = false; - _writing = true; - - _ = Task.Run( - () => - { - var written = Write(path, addresses, stamps, count, dropped); - - // _writing and _dirty are loop state, so the writer hands the release back. Rule #10. - Core.LoopContext.Post( - () => - { - _writing = false; - if (!written) - { - _dirty = true; // nothing reached disk; the next flush retries - } - } - ); - } - ); - } - - /// - /// A crash is the case the flush interval cannot cover, so write on the way down. Runs on whichever - /// thread faulted, and the dictionaries are loop state, so it only writes when that is the loop. - /// - private static void OnCrashed(ServerCrashedEventArgs e) - { - if (Thread.CurrentThread == Core.Thread) - { - OnShutdown(); - } - } - - /// Synchronous: nothing schedules after this, so a handed-off write would reach no disk. - private static void OnShutdown() - { - // A write already in flight holds the buffers and has all but the last moments of the list. - if (!_enabled || !_dirty || _writing) - { - return; - } - - var count = Snapshot(out var dropped); - _dirty = false; - - Write(_path, _addressBuffer, _stampBuffer, count, dropped); - } - - /// - /// Prunes expired entries and copies what survives into the shared buffers. Returns the live count; the - /// buffers run longer and everything past it is stale. - /// - private static int Snapshot(out int dropped) - { var nowUnix = ToUnixSeconds(Core.Now); var cutoff = nowUnix - _ttlSeconds; - if (_addressBuffer.Length < _allowed.Count) - { - // Geometric so a shard adding addresses one at a time does not reallocate every flush. - var size = Math.Max(_allowed.Count, Math.Max(64, _addressBuffer.Length * 2)); - _addressBuffer = new UInt128[size]; - _stampBuffer = new long[size]; - } - + // Prune and snapshot in one loop-side pass; the writer only sees private copies. Not pooled: + // STArrayPool is single-threaded and these escape to another thread. + var addresses = new UInt128[_allowed.Count]; + var stamps = new long[_allowed.Count]; var count = 0; - dropped = 0; + + var dropped = 0; foreach (var (address, stamp) in _allowed) { @@ -311,13 +241,19 @@ public static class LoginAllowlist continue; } - _addressBuffer[count] = address; - _stampBuffer[count] = stamp; + addresses[count] = address; + stamps[count] = stamp; count++; } PruneStaleStrikes(nowUnix); - return count; + + _dirty = false; + + var path = _path; + var total = count; + + _ = Task.Run(() => Write(path, addresses, stamps, total, dropped)); } /// Drops tallies whose window has closed. @@ -337,8 +273,7 @@ public static class LoginAllowlist } } - /// Writes the list out. Returns false when nothing reached disk, so the caller can retry. - private static bool Write(string path, UInt128[] addresses, long[] stamps, int count, int dropped) + private static void Write(string path, UInt128[] addresses, long[] stamps, int count, int dropped) { try { @@ -374,14 +309,11 @@ public static class LoginAllowlist { logger.Information("Login allowlist wrote {Count} entr(ies), dropped {Dropped} past TTL", count, dropped); } - - return true; } catch (Exception e) { // Recoverable: entries are still in memory and the next flush retries. logger.Warning(e, "Could not write the login allowlist to \"{Path}\"", path); - return false; } } diff --git a/Projects/UOContent/Network/LoginAllowlist/LoginAllowlistConfiguration.cs b/Projects/UOContent/Network/LoginAllowlist/LoginAllowlistConfiguration.cs index 5c673eb10..08f04ecf2 100644 --- a/Projects/UOContent/Network/LoginAllowlist/LoginAllowlistConfiguration.cs +++ b/Projects/UOContent/Network/LoginAllowlist/LoginAllowlistConfiguration.cs @@ -21,8 +21,9 @@ using Server.Json; namespace Server.Network; /// -/// Loads the from Configuration/login-allowlist.json. Loaded -/// once; a missing file writes a template so operators have something to edit. +/// Loads the from Configuration/login-allowlist.json (matching +/// the per-feature JSON config pattern used by BlocklistConfiguration). Loaded once; a missing file +/// writes a template so operators have something to edit. /// public static class LoginAllowlistConfiguration { @@ -81,12 +82,11 @@ public record LoginAllowlistSettings public TimeSpan Ttl { get; set; } = TimeSpan.FromDays(90); /// - /// How often a changed list is written out. A clean shutdown always writes, so this only bounds what a - /// crash loses — and an entry is re-earned by the next login. Hourly against a 90-day TTL, because each - /// flush walks the whole list on the game loop. + /// How often a changed list is written out. A crash loses at most this much, and an entry is re-earned by + /// the next login. /// [JsonPropertyName("flushInterval")] - public TimeSpan FlushInterval { get; set; } = TimeSpan.FromHours(1); + public TimeSpan FlushInterval { get; set; } = TimeSpan.FromMinutes(1); /// /// How many suppressed contributions inside revoke an address's entry. Past diff --git a/Projects/UOContent/Network/ManualAllowlist/ManualAllowlistConfiguration.cs b/Projects/UOContent/Network/ManualAllowlist/ManualAllowlistConfiguration.cs deleted file mode 100644 index dd21fe97c..000000000 --- a/Projects/UOContent/Network/ManualAllowlist/ManualAllowlistConfiguration.cs +++ /dev/null @@ -1,83 +0,0 @@ -/************************************************************************* - * ModernUO * - * Copyright 2019-2026 - ModernUO Development Team * - * Email: hi@modernuo.com * - * File: ManualAllowlistConfiguration.cs * - * * - * This program is free software: you can redistribute it and/or modify * - * it under the terms of the GNU General Public License as published by * - * the Free Software Foundation, either version 3 of the License, or * - * (at your option) any later version. * - * * - * You should have received a copy of the GNU General Public License * - * along with this program. If not, see . * - *************************************************************************/ - -using System; -using System.IO; -using System.Text.Json.Serialization; -using Server.Json; - -namespace Server.Network.Bans; - -/// -/// Loads the from Configuration/ip-allowlist.json. Loaded once; -/// a missing file writes a template so operators have something to edit. -/// -public static class ManualAllowlistConfiguration -{ - private const string _path = "Configuration/ip-allowlist.json"; - - public static ManualAllowlistSettings Settings { get; private set; } - - public static void Load() - { - var path = Path.Join(Core.BaseDirectory, _path); - - if (File.Exists(path)) - { - Settings = JsonConfig.Deserialize(path); - } - else - { - Settings = new ManualAllowlistSettings(); - Save(); - } - } - - private static void Save() - { - JsonConfig.Serialize(Path.Join(Core.BaseDirectory, _path), Settings); - } -} - -/// -/// Bound configuration for . Its own file rather than a corner of -/// blocklist.json: the blocklist is only one of two consumers, and the other -/// () works on a shard that runs no blocklist at all. -/// -public record ManualAllowlistSettings -{ - /// - /// Whether the shard reads at all. Off by default: reading them costs a poll for - /// the whole uptime, which no shard should pay before an operator has written a carve-out. - /// - [JsonPropertyName("enabled")] - public bool Enabled { get; set; } - - /// - /// Addresses that must never be blocked and never escalated, in the blocklist's own format. The same - /// files tools/Export-IpBlocklist.ps1 subtracts at generation time; the shard reads them so an - /// entry also suppresses ban contributions, which the generator alone cannot do. - /// - /// - /// The filename may contain wildcards, which is how the default picks up a carve-out an admin adds - /// without anyone editing this file. - /// - [JsonPropertyName("files")] - public string[] Files { get; set; } = ["Configuration/ip-allowlist*.txt"]; - - /// How often the files are checked for changes. Reloads only happen when one actually changed. - [JsonPropertyName("reloadInterval")] - public TimeSpan ReloadInterval { get; set; } = TimeSpan.FromSeconds(60); -} diff --git a/Projects/UOContent/Network/Packets/IncomingPlayerPackets.cs b/Projects/UOContent/Network/Packets/IncomingPlayerPackets.cs index 384e468b9..a6297837d 100644 --- a/Projects/UOContent/Network/Packets/IncomingPlayerPackets.cs +++ b/Projects/UOContent/Network/Packets/IncomingPlayerPackets.cs @@ -86,17 +86,11 @@ 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); - if (state.HuePickers == null) + foreach (var huePicker in state.HuePickers) { - return; - } - - for (var i = 0; i < state.HuePickers.Count; i++) - { - var huePicker = state.HuePickers[i]; if (huePicker.Serial == serial) { state.RemoveHuePicker(huePicker); @@ -293,18 +287,13 @@ public static class IncomingPlayerPackets public static void MenuResponse(NetState state, SpanReader reader) { var serial = reader.ReadUInt32(); - reader.ReadInt16(); // menu id + int menuID = reader.ReadInt16(); int index = reader.ReadInt16(); - reader.ReadInt16(); // item id - reader.ReadInt16(); // hue + int itemID = reader.ReadInt16(); + int hue = reader.ReadInt16(); 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 776ec269d..b18c7fa18 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.Z; + var ez = rect.End.X; if (l1 < l2) { diff --git a/Projects/UOContent/Spells/Fifth/PoisonField.cs b/Projects/UOContent/Spells/Fifth/PoisonField.cs index 92a797f19..3c5d2f609 100644 --- a/Projects/UOContent/Spells/Fifth/PoisonField.cs +++ b/Projects/UOContent/Spells/Fifth/PoisonField.cs @@ -64,19 +64,13 @@ public class PoisonFieldSpell : MagerySpell, ITargetingSpell } [DispellableField] -[SerializationGenerator(1, false)] +[SerializationGenerator(0, false)] public partial class PoisonField : Item { - private void MigrateFrom(V0Content content) - { - _caster = content.Caster; - _end = content.End; - } - [SerializableField(0)] private Mobile _caster; - [AnchoredDateTime] + [DeltaDateTime] [SerializableField(1)] private DateTime _end; diff --git a/Projects/UOContent/Spells/Fourth/FireField.cs b/Projects/UOContent/Spells/Fourth/FireField.cs index 8f20f995b..0b0a22129 100644 --- a/Projects/UOContent/Spells/Fourth/FireField.cs +++ b/Projects/UOContent/Spells/Fourth/FireField.cs @@ -67,23 +67,16 @@ public class FireFieldSpell : MagerySpell, ITargetingSpell } [DispellableField] -[SerializationGenerator(1, false)] +[SerializationGenerator(0, 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; - [AnchoredDateTime] + [DeltaDateTime] [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 63788aa69..6d85b9d73 100644 --- a/Projects/UOContent/Spells/Ninjitsu/MirrorImage.cs +++ b/Projects/UOContent/Spells/Ninjitsu/MirrorImage.cs @@ -238,7 +238,10 @@ namespace Server.Mobiles if (master?.Map == Mobile.Map && master?.InRange(Mobile, Mobile.RangePerception) == true) { - WalkMobileRange(master, 2, 0, 1); + var iCurrDist = (int)Mobile.GetDistanceToSqrt(master); + var bRun = iCurrDist > 5; + + WalkMobileRange(master, 2, bRun, 0, 1); } else { diff --git a/Projects/UOContent/Spells/Seventh/EnergyField.cs b/Projects/UOContent/Spells/Seventh/EnergyField.cs index dbfece36d..5e3ea1149 100644 --- a/Projects/UOContent/Spells/Seventh/EnergyField.cs +++ b/Projects/UOContent/Spells/Seventh/EnergyField.cs @@ -77,19 +77,13 @@ public class EnergyFieldSpell : MagerySpell, ITargetingSpell } [DispellableField] -[SerializationGenerator(2, false)] +[SerializationGenerator(1, false)] public partial class EnergyField : Item { - private void MigrateFrom(V1Content content) - { - _caster = content.Caster; - _end = content.End; - } - [SerializableField(0)] private Mobile _caster; - [AnchoredDateTime] + [DeltaDateTime] [SerializableField(1)] private DateTime _end; diff --git a/Projects/UOContent/Spells/Sixth/ParalyzeField.cs b/Projects/UOContent/Spells/Sixth/ParalyzeField.cs index 301cee507..830f16dfe 100644 --- a/Projects/UOContent/Spells/Sixth/ParalyzeField.cs +++ b/Projects/UOContent/Spells/Sixth/ParalyzeField.cs @@ -77,19 +77,13 @@ public class ParalyzeFieldSpell : MagerySpell, ITargetingSpell } [DispellableField] -[SerializationGenerator(1, false)] +[SerializationGenerator(0, false)] public partial class ParalyzeField : Item { - private void MigrateFrom(V0Content content) - { - _caster = content.Caster; - _end = content.End; - } - [SerializableField(0)] private Mobile _caster; - [AnchoredDateTime] + [DeltaDateTime] [SerializableField(1)] private DateTime _end; diff --git a/Projects/UOContent/Spells/Spellweaving/Items/TransientItem.cs b/Projects/UOContent/Spells/Spellweaving/Items/TransientItem.cs index 49894cc95..54ac52030 100644 --- a/Projects/UOContent/Spells/Spellweaving/Items/TransientItem.cs +++ b/Projects/UOContent/Spells/Spellweaving/Items/TransientItem.cs @@ -3,17 +3,12 @@ using ModernUO.Serialization; namespace Server.Items; -[SerializationGenerator(2, false)] +[SerializationGenerator(1, false)] public partial class TransientItem : Item { - private void MigrateFrom(V1Content content) - { - _expiration = content.Expiration; - } - private TimerExecutionToken _timerToken; - [AnchoredDateTime] + [DeltaDateTime] [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 1b1dd04d3..7cae3b848 100644 --- a/Projects/UOContent/Spells/Third/WallOfStone.cs +++ b/Projects/UOContent/Spells/Third/WallOfStone.cs @@ -63,19 +63,13 @@ public class WallOfStoneSpell : MagerySpell, ITargetingSpell } [DispellableField] -[SerializationGenerator(1, false)] +[SerializationGenerator(0, false)] public partial class WallOfStone : Item { - private void MigrateFrom(V0Content content) - { - _caster = content.Caster; - _end = content.End; - } - [SerializableField(0)] private Mobile _caster; - [AnchoredDateTime] + [DeltaDateTime] [SerializableField(1)] private DateTime _end; diff --git a/Projects/UOContent/UOContent.csproj b/Projects/UOContent/UOContent.csproj index 60f28ac6f..d9961f38d 100644 --- a/Projects/UOContent/UOContent.csproj +++ b/Projects/UOContent/UOContent.csproj @@ -41,17 +41,18 @@ false - + - + - - + + + diff --git a/README.md b/README.md index 01a368804..b8d49f544 100644 --- a/README.md +++ b/README.md @@ -46,7 +46,13 @@ 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 | -See [dev-docs/server-requirements.md](dev-docs/server-requirements.md) for more information. +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. #### 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 7310c104e..8edf70a72 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,9 +29,6 @@ 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 6e0902b64..12c9515ef 100644 --- a/dev-docs/claude-skills/modernuo-content-patterns.md +++ b/dev-docs/claude-skills/modernuo-content-patterns.md @@ -23,22 +23,6 @@ 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 91e3d5e95..c96244656 100644 --- a/dev-docs/claude-skills/modernuo-serialization.md +++ b/dev-docs/claude-skills/modernuo-serialization.md @@ -40,31 +40,21 @@ public partial class MyItem : Item { } public partial class MigratedItem : Item { } ``` -### [SerializableField(index, getter, setter, isVirtual, fieldChanged, allowFieldChange)] +### [SerializableField(index, setter, saveIf)] Applied to `_camelCase` private fields. Generates `PascalCase` property. - `index`: Serialization order (0+) -- `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. +- `setter`: Access level -- `"private"`, `"internal"`, or omit for public +- `saveIf`: Condition method name for conditional serialization ```csharp -[SerializableField(0, allowFieldChange: nameof(AllowChargesChange))] +[SerializableField(0)] [SerializedCommandProperty(AccessLevel.GameMaster)] -[InvalidateProperties] private int _charges; - -private bool AllowChargesChange(ref int value) -{ - value = Math.Clamp(value, 0, MaxCharges); - return true; -} +// Generates: public int Charges { get; set; } ``` ### [SerializableProperty(index, useField)] -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. +Applied to properties with custom get/set logic. - `index`: Serialization order - `useField`: Backing field name if auto-detection fails @@ -73,12 +63,12 @@ Applied to properties with **custom getters** (fallback defaults, lazy/self-heal [CommandProperty(AccessLevel.GameMaster)] public int MaxItems { - get => _maxItems == -1 ? DefaultMaxItems : _maxItems; // custom getter: the reason this is a property + get => _maxItems == -1 ? DefaultMaxItems : _maxItems; set { _maxItems = value; InvalidateProperties(); - this.MarkDirty(); // REQUIRED in custom setters + this.MarkDirty(); } } ``` @@ -99,11 +89,8 @@ 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). Legacy: rewrites bytes every save; prefer `[AnchoredDateTime]` for new fields. Converting between the two changes the wire format (version bump). +Stores DateTime as offset from current time (handles server restarts). ### [InternString] Interns strings to reduce memory for repeated values. @@ -141,32 +128,28 @@ private void AfterDeserialization() } ``` -### [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. +### [DeserializeTimerField(fieldIndex)] +Custom timer deserialization. Timer is saved as remaining TimeSpan. ```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 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. +### [SerializableFieldSaveFlag(fieldIndex)] / [SerializableFieldDefault(fieldIndex)] +Conditional serialization -- skip fields with default values. ```csharp -[SerializableField(0)] -[SaveFlag(nameof(ShouldSerializeMaxItems), nameof(MaxItemsDefaultValue))] -private int _maxItems; - +[SerializableFieldSaveFlag(0)] private bool ShouldSerializeMaxItems() => _maxItems != -1; +[SerializableFieldDefault(0)] private int MaxItemsDefaultValue() => -1; ``` @@ -233,35 +216,28 @@ public partial class ChargedItem : Item } ``` -### Item with Setter Hooks (coerce + side effects) +### Item with Custom Properties ```csharp [SerializationGenerator(2)] public partial class BagOfSending : Item { - [SerializableField(0, fieldChanged: nameof(OnBagOfSendingHueChanged))] - [SerializedCommandProperty(AccessLevel.GameMaster)] - private BagOfSendingHue _bagOfSendingHue; - - private void OnBagOfSendingHueChanged(BagOfSendingHue oldValue, BagOfSendingHue newValue) + [SerializableProperty(0)] + [CommandProperty(AccessLevel.GameMaster)] + public BagOfSendingHue BagOfSendingHue { - Hue = newValue switch + get => _bagOfSendingHue; + set { - 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; + _bagOfSendingHue = value; + Hue = value switch + { + BagOfSendingHue.Yellow => 0x8A5, + BagOfSendingHue.Blue => 0x8AD, + BagOfSendingHue.Red => 0x89B, + _ => Hue + }; + this.MarkDirty(); + } } } ``` @@ -352,13 +328,11 @@ 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` -- 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` +- Custom properties: `Projects/UOContent/Items/Special/Solen Items/BagOfSending.cs` - Complex with AfterDeserialization: `Projects/UOContent/Accounting/Account.cs` -- Timer deserialization (wall-clock): `Projects/UOContent/Items/Aquarium/Aquarium.cs` -- Timer deserialization (drifting/anchored + timer MigrateFrom): `Projects/UOContent/Items/Lights/BaseLight.cs` +- Timer deserialization: `Projects/UOContent/Items/Aquarium/Aquarium.cs` - Tidy + DeltaDateTime: `Projects/UOContent/Engines/CannedEvil/ChampionSpawn.cs` -- Conditional serialization ([SaveFlag]): `Projects/Server/Items/Container.cs` +- Conditional serialization: `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 f366b3569..2c5c8168e 100644 --- a/dev-docs/claude-skills/modernuo-timers.md +++ b/dev-docs/claude-skills/modernuo-timers.md @@ -147,27 +147,18 @@ public partial class DecayingItem : Item } ``` -### [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. - +### [DeserializeTimerField] Pattern (for Timer fields) ```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 6205ec2d4..30bb942f3 100644 --- a/dev-docs/content-patterns.md +++ b/dev-docs/content-patterns.md @@ -254,120 +254,6 @@ 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/debugging-event-loop.md b/dev-docs/debugging-event-loop.md index c38a0e6f7..5c5ea3b63 100644 --- a/dev-docs/debugging-event-loop.md +++ b/dev-docs/debugging-event-loop.md @@ -8,8 +8,7 @@ funnel in order; most incidents resolve before the last step. Do not start with Every second of the main thread's wall time goes to exactly one of four places: 1. **Work** — the loop's phases: mobile deltas, item deltas, timer callbacks (`Timer.Slice`), - network processing (`NetState.Slice`), posted tasks (`LoopContext`), world snapshots - (`WorldSnapshot` — the on-loop portion of a save). + network processing (`NetState.Slice`), posted tasks (`LoopContext`). 2. **Sleep** — idle blocking in `NetState.WaitForCompletion`, bounded by the next timer tick and `server.eventLoopIdleWaitMs`. 3. **GC pauses** — land inside whichever phase (or sleep) was running. @@ -27,15 +26,8 @@ No build changes needed. Three signals exist, all actionable: | Signal | Meaning | Action | |---|---|---| | Startup error: *host cannot honour short waits* | No high-resolution timer and `timeBeginPeriod` failed. Very old or unusual Windows. | Nothing is wrong with the server; it spins and uses a full core. Upgrade the OS or accept the core. | -| Warning: *host returned a Nms idle wait late … for the Nth time running* | The OS did not reschedule the process promptly after a 1–2ms wait, through several escalating backoffs. Shared/burstable vCPU signature. | Move to dedicated CPU, or set `server.eventLoopIdleWaitMs=0` to spin permanently. This is a **host** problem — no amount of server-side change fixes it. | -| Error: *keeps returning idle waits late and sleeping has backed off N times* | The escalation hit its 120s ceiling. The host is not going to recover. | As above, but stop waiting for it to settle. Logged once per degradation, re-armed after a clean minute. | -| Admin gump → Performance → *Event Loop* | `Healthy` / `Sleep suspended (host)` / `Spinning (configured)` / `Spinning - host cannot honor short waits` | Same as above; the last verdict is the startup error's state, not a config choice. | - -The first two backoffs of any episode log at **Debug**, not Warning: a single suspension is -recoverable and not something an operator can act on. Raise the log level if you are chasing a -marginal host and want to see them. Late wakes that coincide with a gen1-or-higher GC are not -counted at all — the GC deliberately collects during idle sleeps, so its pauses land there by -design and are not the host's fault. +| Warning: *host returned a Nms idle wait late* + sleeping suspended | The OS did not reschedule the process promptly after a 1–2ms wait. Shared/burstable vCPU signature. | Move to dedicated CPU, or set `server.eventLoopIdleWaitMs=0` to spin permanently. This is a **host** problem — no amount of server-side change fixes it. | +| Admin gump → Performance → *Event Loop* | `Healthy` / `Sleep suspended (host)` / `Spinning (configured)` | Same as above. | If none of these fired and the shard still feels laggy, the cause is work, GC, or something a boot-time signal cannot see. Continue. diff --git a/dev-docs/ip-bans-and-allowlists.md b/dev-docs/ip-bans-and-allowlists.md index 40320efe5..5588bb19e 100644 --- a/dev-docs/ip-bans-and-allowlists.md +++ b/dev-docs/ip-bans-and-allowlists.md @@ -24,7 +24,7 @@ Filters are consulted in registration order, first denial wins: | Filter | Source | Scope | |---|---|---| | `firewall` | `Configuration/firewall.json`, mutable in-game | Admin-curated, permanent | -| `blocklist` | `Configuration/ip-blocklist.txt` (millions of entries, opt-in) | Reputation feeds | +| `blocklist` | `Configuration/ip-blocklist.txt` (millions of entries) | Reputation feeds | | `auto-denylist` | In-memory, 15 min | What this shard just caught misbehaving | ### Contributing a ban @@ -38,7 +38,7 @@ Two, with different authority: | List | Source | Revocable? | Covers | |---|---|---|---| -| `ManualAllowlist` | every `ip-allowlist*.txt` (opt-in) | No — an operator said so | Blocking **and** escalation | +| `FileAllowlist` | every `ip-allowlist*.txt` | No — an operator said so | Blocking **and** escalation | | `LoginAllowlist` | Earned by authenticating, 90-day TTL | Yes — 10 strikes/hour | Blocking **and** escalation | Both are consulted **only after the blocklist has already matched**, so a normal accept — the one an @@ -66,22 +66,16 @@ If none of those match, they may be inside a **CIDR** in the blocklist, or held ### 2. Add them to the allowlist -Set `"enabled": true` in `Configuration/ip-allowlist.json` first — it is off by default, so a shard that -has never written a carve-out does not poll for one. The shard logs a warning at startup if allowlist files -are present while the flag is off. - -Then one entry per line in `Distribution/Configuration/ip-allowlist.txt` — a bare address or a CIDR. This -file is yours; the generator creates it once and never rewrites it. +One entry per line in `Distribution/Configuration/ip-allowlist.txt` — a bare address or a CIDR. This file +is yours; the generator creates it once and never rewrites it. ``` 203.0.113.42 # shard owner, listed via a shared upstream address 198.51.100.0/24 # a whole range if the ISP rotates within it ``` -With the flag on, the shard reloads within `reloadInterval` (60s default). **No restart, and no need to -re-run the generator.** From that point the address is neither blocked nor contributed. With the flag off -the generator still subtracts the file at generation time, so the address stops being *blocked* — but a -behavioural detection can still contribute it, which is the case the flag exists to cover. +The shard reloads within `reloadInterval` (60s default). **No restart, and no need to re-run the +generator.** From that point the address is neither blocked nor contributed. ### 3. Clear any ban that already exists @@ -129,10 +123,8 @@ where your players actually are, and a carve-out names a real network, so you bu ``` That writes `ip-allowlist-starlink.txt` beside the blocklist, and every `ip-allowlist*.txt` there is -subtracted by the generator with no config edit. For the shard to read them too — which is what also stops -a carve-out address being *contributed* by a behavioural detection — set `enabled` in `ip-allowlist.json`; -it is off by default so no shard polls for files it never wrote. Starlink costs about 0.1% of the list. -Blank a file (keep the file) to reputation-block that network again; delete it to drop the carve-out. +subtracted — both by the generator and by the shard, with no config edit. Starlink costs about 0.1% of the +list. Blank a file (keep the file) to reputation-block that network again; delete it to drop the carve-out. Carve-out files carry an `asn=` marker in their header, which is how `-RefreshCarveouts` finds them. A hand-written allowlist has no marker and is never rewritten. @@ -163,12 +155,6 @@ Escalation is **immediate**, on the first detection: a 15-minute local hold plus (4h) contribution. There is no N-connection threshold; the strike counter governs only revoking a `LoginAllowlist` entry. -The local hold runs 15 minutes from the **first** detection and is never extended by later ones, so an -address that keeps trying is released on schedule rather than held indefinitely. It does not get a free -run: the rate limiter sits *ahead* of the connection filters, so a flooder is re-reported and re-held on -its next attempt. Not refreshing is what keeps the holds in expiry order, which is what makes retiring -lapsed ones cost the number expiring rather than the number held. - ### What is deliberately NOT detected **Do not add rules based on arrival framing.** TCP has no message boundaries, so the network, the OS or a @@ -191,27 +177,21 @@ firewalled off. Shortening the 5s handshake window has been tried and broke real - **An allowlist cannot bootstrap.** A `LoginAllowlist` entry is only earned by getting in, so it can never repair an existing false positive, and it is weakest on rotating CGNAT — a player whose lease moved is a - stranger again. `ManualAllowlist` is the fix for that, which is why it is manual — and opt-in, via - `ip-allowlist.json`. + stranger again. `FileAllowlist` is the fix for that, which is why it is manual. - **A never-logged-in player on a shared address can still be caught**, for up to `badConnectDuration`, if a co-tenant misbehaves. Accepted: it is 4h and self-healing. The cheapest lever is `badConnectDuration`. - **`MaxConnections` (4096) is a hard ceiling.** The accept gate runs *after* the kernel completed the TCP handshake, so a blocklist match saves the socket setup and the `NetState` slot but never the connection itself. Only an upstream L4 proxy or edge scrubbing moves that cost off the shard. -- **The `auto-denylist` stops tracking at `maxEntries`.** Past it a detection still disconnects the - connection, but the address is not held, so it pays full detection cost on every reconnect instead of a - cheap accept-gate deny. The default is sized for the 50k–250k distinct-source floods seen in practice; a - flood past it wants upstream scrubbing rather than a larger cap, which only buys a longer on-loop scan. ## Configuration | File | Controls | |---|---| | `bans.json` | `reportRateLimitTrips`, `autoBanDuration`, `reportBadConnects`, `badConnectDuration` | -| `blocklist.json` | `enabled` (default `false`), `file`, `reloadInterval`, `reportHits`, `banDuration`, `promoteSuppression` | -| `ip-allowlist.json` | `enabled` (default `false`), `files` (wildcards allowed), `reloadInterval` | +| `blocklist.json` | `file`, `allowlistFiles` (wildcards allowed), `reloadInterval`, `reportHits`, `banDuration`, `promoteSuppression` | | `login-allowlist.json` | `enabled`, `file`, `ttl`, `flushInterval`, `escalateAfterStrikes`, `strikeWindow` | -| `auto-denylist.json` | `enabled`, `duration`, `maxEntries` (default `324,449` — sized for the floods seen in practice; see the remark on the setting before raising it) | +| `auto-denylist.json` | `enabled`, `duration`, `maxEntries` | | `crowdsec.json` | `lapiUrl`, `machineId`, `password`, `origin`, `manualBanDuration`, `flushInterval`, `maxQueue` | | `firewall.json` | Admin-curated entries | @@ -228,7 +208,7 @@ A shard fronted by an upstream proxy can disable all of it and register nothing. | `Projects/Server/Network/Bans/BanReasons.cs` | Reason slugs + the behavioural opt-in set | | `Projects/UOContent/Network/BanExemptions.cs` | Combines both allowlists into one answer | | `Projects/UOContent/Network/Blocklist/BlocklistFilter.cs` | File-sourced blocklist filter | -| `Projects/UOContent/Network/Blocklist/ManualAllowlist.cs` | Operator carve-outs, read from the allowlist files | +| `Projects/UOContent/Network/Blocklist/FileAllowlist.cs` | Operator carve-outs, read from the allowlist files | | `Projects/UOContent/Network/LoginAllowlist/LoginAllowlist.cs` | Allowlist earned by authenticating | | `Projects/UOContent/Network/AutoDenylist/AutoDenylist.cs` | Short-lived local hold | | `Projects/UOContent/Network/CrowdSec/CrowdSecReporter.cs` | LAPI contribution sink | diff --git a/dev-docs/measuring-event-loop.md b/dev-docs/measuring-event-loop.md new file mode 100644 index 000000000..18d92544e --- /dev/null +++ b/dev-docs/measuring-event-loop.md @@ -0,0 +1,263 @@ +# Measuring the Event Loop + +> **This is the perennial `measure/event-loop` branch** — main plus the measurement harness, kept +> for future loop work and periodically rebased onto main. The idle-sleep design and the +> `EVENT_LOOP_PROFILING` diagnostics tier shipped in main (see +> `dev-docs/debugging-event-loop.md` for day-to-day diagnosis); this branch adds the A/B +> measurement scripts below (`tools/Measure-EventLoop.ps1`, `tools/measure-event-loop.sh`, +> `tools/HostLatencyProbe.cs`). +> +> When a future experiment needs to iterate on IORingGroup itself without burning published +> package versions, re-vendor it here: copy the ring branch's `IORingGroup/*.cs` byte-identical +> into `Projects/IORingGroup/`, give it a minimal csproj that inherits `Directory.Build.props` +> (needs `LangVersion` `Preview` and `NoWarn` `CS8981;CS0649` under `TreatWarningsAsErrors`), +> swap Server.csproj's `PackageReference` for a `ProjectReference`, and add the project to +> `ModernUO.slnx`. Revert = delete the directory and restore the `PackageReference`. + +The game loop sleeps when it has nothing to do, and runs flat out when it does. This page covers the +one setting that controls it, the metric that tells you whether it is healthy, and how to measure +both on your own hardware. + +## The lever + +`server.eventLoopIdleWaitMs` is the longest the loop will block when every queue is empty. It wakes +on the next timer tick or the instant work arrives, whichever comes first, so this only caps how +long a *quiet* loop stays blocked. + +Measured on an idle shard with a real world loaded (190,728 items, 33,158 mobiles): + +| Setting | CPU | Skipped slots / 1875 | Peak lag (avg over windows) | +|---|---|---|---| +| `0` | **98.5%** of one core | **0** | **0 ms** | +| `1` | 0.81% | 0–1 | 7.8 ms | +| `2` (default) | ~0.8% | 0–1 | 11.2 ms | +| `4` | 0.57% | 0–1 | 7.0 ms | +| `8` | 0.16% | ~1 | 9.8 ms | + +**The trade-off is not a smooth slope.** `0` is qualitatively different — never sleeping means +never waking late, so zero skipped slots and zero lag. Everything from `1` to `8` sits in the same +band: peaks of 7–11 ms with no trend, and roughly one lost slot in several thousand. The differences +across that range are measurement noise, not a dial. Pick `0` or pick a sleep value; the specific +sleep value barely matters for latency and matters a lot for CPU. + +**`0` is the way out of this entire apparatus** — no sleeping, no wake signals, no backoff — for the +price of a core. A large shard on dedicated CPU that would rather spend the core than ever risk a +late wake should set it and stop reading here. + +**`2` is the default** because it is the conservative end of that band while still costing almost +nothing. On an idle shard it loses about one slot in 7,500 — and so does pure spinning, because that +floor is the operating system, not the scheduler. + +## The metric that matters: skipped slots + +The timer wheel advances one slot per 8 ms of elapsed time. A **skipped slot** is one that came due +while the loop was elsewhere, counted as turns beyond the first in a single pass. + +That distinction is the point. A wake can never land exactly on an 8 ms boundary, so the wheel is +always a fraction late and "lag" is always non-zero. Losing a *slot* is different: the wheel took a +step it should have taken earlier. Read as a rate it is directly meaningful — 125 slots per second +at an 8 ms tick, so a handful per minute is jitter and hundreds per minute is a server that cannot +keep up. + +**Peak tick lag is reported but is a weak signal.** It is a single worst case over the whole window, +so one hiccup pins it and it reads identically whether the server stumbled once or is permanently +behind. Use it to size an outlier, not to judge health. + +### Wheel lag is not network latency + +These are separate paths, and conflating them is the easiest mistake to make here. + +A player's action arrives as a packet. The receive completion is in the loop's wait set, so it wakes +the loop **immediately** — not after `eventLoopIdleWaitMs` elapses — and the packet is handled inline +by `NetState.Slice` → `HandleReceive` → `HandlePacket`. Sleeping longer does not add round-trip +latency, because a sleeping loop is woken by the thing it was waiting for. + +Wheel lag only delays **timer-driven** logic: combat swings, spell timers, AI ticks, spawners. Those +are built around 100 ms to multi-second intervals, so 8–23 ms of wheel lag is well inside their +noise floor and is not something a player can perceive. + +The one path that genuinely waits out the sleep is a **new connection**, because AcceptEx +completions are polled rather than signalled. That adds up to `eventLoopIdleWaitMs` to connection +setup — single-digit milliseconds on a handshake that takes far longer. + +Round-trip latency under real player load has not been measured end to end here; the above is read +off the code paths, not off a wire capture. + +**Cycles per second is not a health signal at all.** It counts loop iterations, so once the loop +sleeps it is paced by `eventLoopIdleWaitMs` rather than by anything about your shard: roughly 400 at +the default, whether the world is empty or busy but keeping up. It is retained because existing +tooling reads it. Do not build alerts on it. + +### The first time any code path runs, it will probably miss + +A freshly started shard misses the budget on paths it has never executed: the first login, the +first character creation, the first time a particular gump is opened. That is tier-0 JIT and +first-touch static initialisation, not the cost of the operation. + +Profiling caught this directly. A 113 ms stall in a house-placement gump was two thirds +`InitClassSlow` and `GetGCStaticBaseSlow` — running static constructors — and only a third the gump +logic itself. Character creation on an M1 shows the same shape: a few missed deadlines the first +time, from a workload that is nowhere near 24 ms of actual work. + +To tell them apart, run the same operation two or three times in one session. Cold-path cost +disappears on the second run; real cost does not. You do not need to reach tiered-compilation +maturity to see the difference, just the cold-to-warm transition. + +### The 16 ms bar is stricter than the game needs + +This measures **timer accuracy, not network latency**. A missed budget means whatever timers were +sitting in those wheel slots fired late — it does not mean a packet was delayed, since packets are +handled inline and wake a sleeping loop immediately. + +UO systems run on 100 ms to multi-second cadences: combat swings, AI ticks, spawners, decay. A 24 ms +wheel stall is inside their noise floor, and invisible next to a typical 50 ms ping. The bar is set +where it is because it makes a sensitive detector that catches problems long before players would, +not because 16 ms is a threshold players can perceive. + +So an occasional missed budget is not a defect. What matters is the **rate**, and whether the +misses coincide with sleeps that returned late. Sustained misses are worth chasing; a handful on a +cold path is the metric working. + +### Self-inflicted stalls are expected, and are not the scheduler's fault + +A world save can block the loop for a second or more on a large shard. A staff command like +advanced search is knowingly expensive. Both blow the 16 ms budget, sometimes badly — the wheel +then catches up by turning many times in one pass, because ModernUO does **not** drift timers and +should not start. + +Those misses are real. What they are not is a reason to stop sleeping — and the backoff cannot be +tripped by them **by construction**: a sleep is bounded by the time to the next wheel turn, so a +correctly honoured sleep can never miss a deadline. The only thing the backoff watches is the +sleep itself returning late (`elapsed - requested >= tick rate`), which is measured around each +`WaitForCompletion` call and can only be caused by the host descheduling the process. + +## Automatic backoff + +If the host returns more than `server.lateWakeThreshold` waits late (default 1) per second, across +**two consecutive** samples, idle sleeping suspends with escalating duration (5s doubling to 2min) +and the loop spins instead. It logs: + +``` +This host returned a 2ms idle wait at least 8ms late N time(s) in the last second; idle sleeping suspended for 5000ms +``` + +Under sustained load the backoff is inert, because the queues are never empty and the loop was not +sleeping anyway. At the escalation ceiling it logs an error telling the operator to set +`server.eventLoopIdleWaitMs=0`. + +## Turn on reporting + +Build with the profiling flag and use the in-game command: + +``` +dotnet build -p:EventLoopProfiling=true +[LoopStats +``` + +`[LoopStats` prints the last minute's wall-time decomposition — sleep / GC / stolen percentages +and per-phase work with worst-second peaks — and writes the full ~15-minute per-second history to +a CSV for comparison. Normal builds compile all of this out (`[Conditional]` hooks), so there is +nothing to disable in production. See `dev-docs/debugging-event-loop.md` for how to read it. + +**Do not** build a probe that calls `Process.Threads` or `Process.GetProcesses` on a timer — that +enumerates every process and thread on the machine, and has been measured costing several percent +of the main thread and causing the very stalls it was added to diagnose. + +## Will a busy shard regress? + +**Constant networking generates no wakes at all.** Packet handling runs inline on the loop thread +(`NetState.Slice` → `HandleReceive` → `HandlePacket`), as do timers (`Timer.Slice` → `Turn` → +`OnTick`). Neither goes through `LoopContext.Post`, so traffic volume is not connected to wake +volume. Wakes come only from cross-thread work — async continuations and a handful of explicit +posts such as world-save boundaries. Posts originating *on* the loop thread are elided outright, +which is exact rather than heuristic: the loop is executing that call, so it cannot be blocked. + +**Under sustained load the loop stops sleeping**, because the queues are never all empty at once. +At that point it runs exactly as it did before, and the only added per-iteration work is the idle +check plus one timestamp read per (rare) sleep. + +To confirm on your own shard, at peak population, with the profiling build: + +1. Check sleeps/second in `[LoopStats` — near 0 means idle sleeping is dormant and cannot be + costing you anything. +2. Check `lateWakes` and worst wheel lag — the regression signals, not CPU. +3. Repeated backoff warnings in the log mean the host keeps returning waits late, which is worth + reporting. +4. If you want certainty, set `eventLoopIdleWaitMs=0` and compare wheel lag. Equal means the + scheduler costs you nothing at that load. + +## Measuring on your hardware + +For a clean comparison, hold everything else constant: `"autosave.enabled": "False"`, +`"pathfinding.prebakeMaps": "False"`, same world, same population, back to back. + +### Before you start, on Linux + +Two native libraries are needed, and their absence looks alarming — the build succeeds and the +tests then fail with `DllNotFoundException`, which reads like a broken branch rather than a missing +package. The resolver wants the unversioned `.so`, so the `-dev` packages are the ones that matter: + +```bash +sudo apt-get install -y libdeflate-dev libargon2-dev # Debian/Ubuntu +``` + +Also **clone with full history**. A `--depth 1` clone fails the build in Nerdbank.GitVersioning, +which needs the commit history to compute a version, and the error points at MSBuild internals +rather than at the clone. + +Verified in a clean container: with those two packages and a full clone, this branch builds and +passes 826 Server.Tests and 642 UOContent.Tests on Linux, matching Windows. + +### Windows + +```powershell +pwsh tools/Measure-EventLoop.ps1 -WarmupSeconds 45 -SampleSeconds 60 +``` + +Boots the shard twice, samples `TotalProcessorTime` over the window, and prints both results. + +### macOS and Linux + +```bash +./tools/measure-event-loop.sh 45 60 +``` + +Same procedure as the Windows script: boots the shard twice, samples process CPU over the window, +and prints the `loop:` lines from both runs so the CPU figures can be read against what they cost +in timer accuracy. Needs `python3`, which ships with the macOS developer tools. + +To sample a shard that is already running instead: + +```bash +pid=$(pgrep -f ModernUO) +ps -o time= -p "$pid"; sleep 60; ps -o time= -p "$pid" +``` + +or watch it with `top -pid "$pid"` (macOS) / `top -p "$pid"` (Linux). + +**Run these rather than assuming the Windows result carries over.** The wake path is different code +on every backend — `EVFILT_USER` on kqueue, an eventfd on io_uring and epoll, an event object on +Windows — and so is the cost structure the loop sits on. Windows pays a syscall per iteration in the +accept scan that the others do not, and the timer-resolution problem that shapes the Windows +implementation has no equivalent elsewhere. + +Before measuring on a platform for the first time, run the ring's own tests, which exercise the wake +contract natively: + +```bash +dotnet test IORingGroup.Tests/IORingGroup.Tests.csproj +``` + +`WakeBeforeWaitIsNotLost` and `WakeFromAnotherThreadUnblocksWait` are the ones that matter. If the +platform's wake primitive is broken, those fail and the loop would otherwise appear merely +"slow to notice work" for no visible reason. + +## If skipped slots are high regardless of setting + +The loop is not your problem. In rough order: + +1. **The host is not scheduling you.** Common on burstable/shared vCPU. Check steal time (`%st`). +2. **A custom system is blocking the loop** — file or network I/O, large scans, or `Process` + enumeration inside a timer tick or command handler. +3. **Saves.** Re-enable `autosave.enabled` and see whether spikes line up with save intervals. +4. **You are undersized.** See [server-requirements.md](server-requirements.md). diff --git a/dev-docs/networking-packets.md b/dev-docs/networking-packets.md index 639963cfd..af47a1e06 100644 --- a/dev-docs/networking-packets.md +++ b/dev-docs/networking-packets.md @@ -511,9 +511,9 @@ Rules: Core owns the question; **every implementation lives in UOContent**. The three that ship are `firewall` (admin-curated, mutable at runtime, persisted to `Configuration/firewall.json`), `blocklist` (file-sourced, -millions of entries, demand-pages hits to CrowdSec, **opt-in**) and `auto-denylist` (in-memory, -short-lived, fed by the shard's own behavioural detections). A shard that fronts its server with an -upstream proxy or edge scrubbing can drop all of them and register nothing. +millions of entries, demand-pages hits to CrowdSec) and `auto-denylist` (in-memory, short-lived, fed by the +shard's own behavioural detections). A shard that fronts its server with an upstream proxy or edge scrubbing +can drop all of them and register nothing. The allowlists, ban contribution, behavioural detection and the operator process for exempting a false-positive address are covered separately in diff --git a/dev-docs/runuo-migration-docs/02-serialization.md b/dev-docs/runuo-migration-docs/02-serialization.md index 666ebfdb4..1470792af 100644 --- a/dev-docs/runuo-migration-docs/02-serialization.md +++ b/dev-docs/runuo-migration-docs/02-serialization.md @@ -457,24 +457,16 @@ 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 `[SaveFlag]` on the serializable field to skip default values (the second method is -optional -- omit it and the field keeps its default at load): +Use `[SerializableFieldSaveFlag]` and `[SerializableFieldDefault]` to skip default values: ```csharp -[SerializableField(0)] -[SaveFlag(nameof(ShouldSerializeMaxItems), nameof(MaxItemsDefaultValue))] -private int _maxItems; - +[SerializableFieldSaveFlag(0)] private bool ShouldSerializeMaxItems() => _maxItems != -1; +[SerializableFieldDefault(0)] private int MaxItemsDefaultValue() => -1; ``` @@ -487,15 +479,12 @@ private List _followers; ``` ### 6. DateTime Fields -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: +Use `[DeltaDateTime]` to survive server restarts: ```csharp -[AnchoredDateTime] +[DeltaDateTime] [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 13596a079..29f3d1282 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 a serialized timer field with `[DeserializeTimer]`) 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 `[DeserializeTimerField]`) 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 def2ecf48..c5029131c 100644 --- a/dev-docs/runuo-migration-docs/09-items-mobiles-creatures.md +++ b/dev-docs/runuo-migration-docs/09-items-mobiles-creatures.md @@ -474,88 +474,6 @@ 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 b6c12bfe8..215dd3b06 100644 --- a/dev-docs/runuo-migration-docs/11-api-reference.md +++ b/dev-docs/runuo-migration-docs/11-api-reference.md @@ -130,13 +130,6 @@ 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 059fbff69..151d9d717 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, getter, setter, isVirtual, fieldChanged, allowFieldChange)] +### [SerializableField(index, setter, saveIf)] **Target**: Private field (`_camelCase`) **Generates**: Public `PascalCase` property with get/set @@ -125,11 +125,8 @@ See `dev-docs/runuo-migration-docs/02-serialization.md` for complete migration g | Parameter | Type | Default | Description | |---|---|---|---| | `index` | `int` | Required | Serialization order (0-based) | -| `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 | +| `setter` | `string` | `null` (public) | `"private"` or `"internal"` to restrict setter | +| `saveIf` | `string` | `null` | Method name returning bool for conditional save | ```csharp [SerializableField(0)] // Public property @@ -147,57 +144,14 @@ The generated property for `_charges` would be: public int Charges { get => _charges; - set - { - if (value != _charges) - { - _charges = value; - this.MarkDirty(); - } - } + set { _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 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. +**Use when**: You need non-trivial getter/setter logic | Parameter | Type | Default | Description | |---|---|---|---| @@ -209,7 +163,7 @@ instead — the generated setter handles equality, `MarkDirty`, and ordering for [CommandProperty(AccessLevel.GameMaster)] public int MaxItems { - get => _maxItems == -1 ? DefaultMaxItems : _maxItems; // custom getter: the reason this is a property + get => _maxItems == -1 ? DefaultMaxItems : _maxItems; set { _maxItems = value; @@ -219,10 +173,6 @@ 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 @@ -258,32 +208,12 @@ 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. -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. +This ensures timers and expiration dates survive server restarts correctly. ```csharp [DeltaDateTime] @@ -359,76 +289,40 @@ private void AfterDeserialization() } ``` -### [DeserializeTimer(nameof(Method), wallClock)] +### [DeserializeTimerField(fieldIndex)] -**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. +**Target**: Method taking `TimeSpan` parameter +**Effect**: Custom deserialization for Timer fields. The timer is saved as remaining delay. ```csharp [SerializableField(0, setter: "private")] -[DeserializeTimer(nameof(DeserializeDecayTimer))] private Timer _decayTimer; -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) +[DeserializeTimerField(0)] +private void DeserializeDecayTimer(TimeSpan delay) { - if (content.DecayTimerDelay != TimeSpan.MinValue) - { - DeserializeDecayTimer(content.DecayTimerDelay); - } + _decayTimer = Timer.DelayCall(delay, Delete); + _decayTimer.Start(); } ``` -### [SaveFlag(nameof(ShouldSerializeMethod), nameof(DefaultValueMethod))] +### [SerializableFieldSaveFlag(fieldIndex)] / [SerializableFieldDefault(fieldIndex)] -**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/server-requirements.md b/dev-docs/server-requirements.md index 0b2c50569..daf71e03e 100644 --- a/dev-docs/server-requirements.md +++ b/dev-docs/server-requirements.md @@ -105,8 +105,7 @@ See the README for the full supported list. Two things are worth calling out: | Setting | Default | Why change it | |---|---|---| | `server.eventLoopIdleWaitMs` | `2` | `0` never sleeps: ~98% of one core, but zero skipped timer slots and zero lag. The choice for a large shard on dedicated CPU that would rather spend a core than risk a late wake. Above `2` the wheel starts losing slots. | -| `server.lateWakeThreshold` | `1` | Floor for the backoff: idle waits the host may return a full tick late, per second, before the rate test below applies at all. Raise on a jittery host; set very high to disable the backoff. | -| `server.lateWakePercent` | `10` | Share of a second's idle waits that must come back late before idle sleeping backs off. An idle loop sleeps hundreds of times a second, so a bare count cannot tell a few tail outliers from a host that never schedules the process — a genuinely bad host misses *most* of its waits. `0` leaves `lateWakeThreshold` in sole charge. | +| `server.lateWakeThreshold` | `1` | Idle waits the host may return a full tick late, per second, before idle sleeping backs off. Raise on a jittery host; set very high to disable the backoff. | | `world.useMultithreadedSaves` | `true` | Set `false` on 2-core hosts so saves do not contend with the game loop. | | `pathfinding.prebakeMaps` | varies | Leave off on memory-constrained hosts; it peaks above 1 GB while baking. | | `network.sendBufferSize` | 256 KB | Lower it if you are memory-bound with many connections. | diff --git a/dev-docs/timers.md b/dev-docs/timers.md index 2c051cca6..c10f31bd6 100644 --- a/dev-docs/timers.md +++ b/dev-docs/timers.md @@ -188,19 +188,15 @@ 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) diff --git a/tools/HostLatencyProbe.cs b/tools/HostLatencyProbe.cs new file mode 100644 index 000000000..5470b7f16 --- /dev/null +++ b/tools/HostLatencyProbe.cs @@ -0,0 +1,87 @@ +#:property TreatWarningsAsErrors=false +// Host operation-cost probe. +// +// The ModernUO event loop reads the clock every iteration and, on Windows, polls pending accept +// slots with a syscall. On bare metal those cost tens of nanoseconds and vanish. On a virtualised +// host without invariant-TSC passthrough they can trap to the hypervisor and cost microseconds, +// which is the difference between a loop running 1,200,000 cycles/sec and one running 20,000. +// +// This measures the primitives directly so a slow shard can be attributed to the host rather than +// guessed at. It touches nothing in ModernUO and needs no shard running. +// +// Run: dotnet run tools/HostLatencyProbe.cs +// +// Reference (Windows desktop, dedicated cores) is printed alongside each result. + +using System.Diagnostics; +using System.Runtime.InteropServices; + +const int Warmup = 100_000; +const int Iterations = 2_000_000; + +Console.WriteLine($"OS : {RuntimeInformation.OSDescription}"); +Console.WriteLine($"Arch : {RuntimeInformation.ProcessArchitecture}"); +Console.WriteLine($"Processors : {Environment.ProcessorCount}"); +Console.WriteLine($"QPC freq : {Stopwatch.Frequency:N0} Hz"); +Console.WriteLine($"HighRes : {Stopwatch.IsHighResolution}"); +Console.WriteLine(); +Console.WriteLine($"{"operation",-34}{"ns/op",12} {"desktop ref",-14} verdict"); +Console.WriteLine(new string('-', 86)); + +Measure("Stopwatch.GetTimestamp()", 20, () => Stopwatch.GetTimestamp()); +Measure("DateTime.UtcNow", 25, () => DateTime.UtcNow.Ticks); + +if (OperatingSystem.IsWindows()) +{ + // Mirrors CheckAcceptExCompletions, which polls each pending accept slot this way. An + // already-signalled event is the cheapest possible case, so this is a floor, not a typical cost. + var evt = CreateEventW(0, 1, 1, 0); + if (evt != 0) + { + Measure("WaitForSingleObject(signalled, 0)", 250, () => (long)WaitForSingleObject(evt, 0)); + CloseHandle(evt); + } +} + +Console.WriteLine(); +Console.WriteLine("A host whose clock reads cost microseconds rather than nanoseconds is trapping to"); +Console.WriteLine("the hypervisor. That penalises every loop iteration and cannot be tuned away in"); +Console.WriteLine("the server -- it is a host or VM-configuration problem (TSC passthrough)."); + +static void Measure(string name, double desktopNs, Func op) +{ + long sink = 0; + for (var i = 0; i < Warmup; i++) + { + sink += op(); + } + + var sw = Stopwatch.StartNew(); + for (var i = 0; i < Iterations; i++) + { + sink += op(); + } + + sw.Stop(); + GC.KeepAlive(sink); + + var ns = sw.Elapsed.TotalNanoseconds / Iterations; + var ratio = ns / desktopNs; + var verdict = ratio switch + { + < 3 => "normal", + < 10 => "SLOW (~" + ratio.ToString("F0") + "x)", + _ => "TRAPPING (~" + ratio.ToString("F0") + "x)" + }; + + Console.WriteLine($"{name,-34}{ns,12:F1} {desktopNs + " ns",-14} {verdict}"); +} + +[DllImport("kernel32.dll")] +static extern nint CreateEventW(nint attrs, int manualReset, int initialState, nint name); + +[DllImport("kernel32.dll")] +static extern uint WaitForSingleObject(nint handle, uint ms); + +[DllImport("kernel32.dll")] +static extern int CloseHandle(nint handle); diff --git a/tools/Measure-EventLoop.ps1 b/tools/Measure-EventLoop.ps1 new file mode 100644 index 000000000..e7d34adba --- /dev/null +++ b/tools/Measure-EventLoop.ps1 @@ -0,0 +1,96 @@ +# A/B measurement for the event loop scheduler. +# +# Boots the shard twice against identical binaries -- once with idle sleeping disabled +# (server.eventLoopIdleWaitMs = 0) and once with idle sleeping (= 2) -- and samples process CPU +# time over a fixed window. Everything else is held constant, so the delta is the scheduler. +# +# Usage: pwsh tools/Measure-EventLoop.ps1 [-WarmupSeconds 45] [-SampleSeconds 60] + +param( + [int]$WarmupSeconds = 45, + [int]$SampleSeconds = 60 +) + +$ErrorActionPreference = 'Stop' + +$root = Split-Path -Parent $PSScriptRoot +$dist = Join-Path $root 'Distribution' +$exe = Join-Path $dist 'ModernUO.exe' +$configPath = Join-Path $dist 'Configuration\modernuo.json' + +if (-not (Test-Path $exe)) { + throw "ModernUO.exe not found at $exe. Build with: dotnet build -c Release" +} + +function Set-IdleWait([int]$value) { + $json = Get-Content $configPath -Raw | ConvertFrom-Json + $json.settings.'server.eventLoopIdleWaitMs' = "$value" + $json | ConvertTo-Json -Depth 20 | Set-Content $configPath -Encoding UTF8 +} + +function Measure-Loop([int]$idleWait, [string]$label) { + Set-IdleWait $idleWait + + Write-Host "" + Write-Host "=== $label (server.eventLoopIdleWaitMs = $idleWait) ===" -ForegroundColor Cyan + + # Redirect stdin from an empty file so the server runs headless and never blocks on console + # input. PowerShell cannot redirect from NUL, so an actual empty file stands in for it. + $stdin = Join-Path $dist 'empty.in' + if (-not (Test-Path $stdin)) { + Set-Content -Path $stdin -Value '' -NoNewline + } + + $proc = Start-Process -FilePath $exe -WorkingDirectory $dist -PassThru ` + -RedirectStandardInput $stdin ` + -RedirectStandardOutput (Join-Path $dist "Logs\measure-$idleWait.out") ` + -RedirectStandardError (Join-Path $dist "Logs\measure-$idleWait.err") + + try { + Write-Host " pid $($proc.Id); warming up for ${WarmupSeconds}s..." + Start-Sleep -Seconds $WarmupSeconds + + if ($proc.HasExited) { + throw "Server exited during warmup (code $($proc.ExitCode)). See Logs\measure-$idleWait.err" + } + + $proc.Refresh() + $cpuBefore = $proc.TotalProcessorTime + $wallBefore = Get-Date + + Write-Host " sampling for ${SampleSeconds}s..." + Start-Sleep -Seconds $SampleSeconds + + $proc.Refresh() + $cpuAfter = $proc.TotalProcessorTime + $wallAfter = Get-Date + + $cpuMs = ($cpuAfter - $cpuBefore).TotalMilliseconds + $wallMs = ($wallAfter - $wallBefore).TotalMilliseconds + $pct = $cpuMs / $wallMs * 100 + + Write-Host (" CPU: {0:F2}% of one core ({1:F0}ms CPU over {2:F0}ms wall)" -f $pct, $cpuMs, $wallMs) -ForegroundColor Yellow + + [pscustomobject]@{ Label = $label; IdleWait = $idleWait; CpuPercent = $pct } + } + finally { + if (-not $proc.HasExited) { + $proc.Kill() + $proc.WaitForExit(10000) | Out-Null + } + } +} + +$legacy = Measure-Loop 0 'Never sleep (max responsiveness)' +$sleeping = Measure-Loop 2 'Idle sleeping' + +Write-Host "" +Write-Host "=== Result ===" -ForegroundColor Green +Write-Host (" never sleep : {0,6:F2}% of one core" -f $legacy.CpuPercent) +Write-Host (" idle sleep : {0,6:F2}% of one core" -f $sleeping.CpuPercent) +if ($sleeping.CpuPercent -gt 0) { + Write-Host (" reduction : {0,6:F1}x" -f ($legacy.CpuPercent / $sleeping.CpuPercent)) +} + +# Leave the config on the new behaviour. +Set-IdleWait 2 diff --git a/tools/measure-event-loop.sh b/tools/measure-event-loop.sh new file mode 100755 index 000000000..9d3db24a2 --- /dev/null +++ b/tools/measure-event-loop.sh @@ -0,0 +1,131 @@ +#!/usr/bin/env bash +# A/B measurement for the event loop scheduler, for macOS and Linux. +# +# Boots the shard twice against identical binaries -- once with idle sleeping disabled +# (server.eventLoopIdleWaitMs = 0) and once with it enabled (= 2) -- and samples process CPU time +# over a fixed window. Everything else is held constant, so the delta is the scheduler. +# +# The Windows equivalent is tools/Measure-EventLoop.ps1. +# +# Usage: ./tools/measure-event-loop.sh [warmup_seconds] [sample_seconds] + +set -euo pipefail + +WARMUP="${1:-45}" +SAMPLE="${2:-60}" + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +DIST="$ROOT/Distribution" +CONFIG="$DIST/Configuration/modernuo.json" + +# The published binary has no extension on these platforms. +EXE="$DIST/ModernUO" + +if [ ! -x "$EXE" ]; then + echo "ModernUO not found at $EXE. Build with: dotnet build -c Release" >&2 + exit 1 +fi + +if [ ! -f "$CONFIG" ]; then + echo "No configuration at $CONFIG. Start the shard once to generate it." >&2 + exit 1 +fi + +mkdir -p "$DIST/Logs" + +# python3 rather than sed: the value must be replaced inside JSON, and the file is the live +# server configuration. macOS ships python3 with the developer tools. +set_idle_wait() { + python3 - "$CONFIG" "$1" <<'PY' +import json, sys +path, value = sys.argv[1], sys.argv[2] +with open(path) as f: + cfg = json.load(f) +cfg.setdefault("settings", {})["server.eventLoopIdleWaitMs"] = value +with open(path, "w") as f: + json.dump(cfg, f, indent=2) +PY +} + +# Process CPU time in seconds. ps reports [[dd-]hh:]mm:ss, which needs unpacking. +cpu_seconds() { + local t + t="$(ps -o time= -p "$1" | tr -d ' ')" + python3 - "$t" <<'PY' +import sys +raw = sys.argv[1] +days, _, rest = raw.rpartition('-') +parts = [float(p) for p in rest.split(':')] +total = 0.0 +for p in parts: + total = total * 60 + p +if days: + total += float(days) * 86400 +print(total) +PY +} + +measure() { + local idle="$1" label="$2" + + set_idle_wait "$idle" + + echo + echo "=== $label (server.eventLoopIdleWaitMs = $idle) ===" + + # Redirect stdin from /dev/null so the server runs headless and never blocks on console input. + "$EXE" < /dev/null > "$DIST/Logs/measure-$idle.out" 2> "$DIST/Logs/measure-$idle.err" & + local pid=$! + + # shellcheck disable=SC2064 + trap "kill $pid 2>/dev/null || true" EXIT + + echo " pid $pid; warming up for ${WARMUP}s..." + sleep "$WARMUP" + + if ! kill -0 "$pid" 2>/dev/null; then + echo " server exited during warmup. See Logs/measure-$idle.err" >&2 + tail -20 "$DIST/Logs/measure-$idle.err" >&2 || true + exit 1 + fi + + local before after wall_before wall_after + before="$(cpu_seconds "$pid")" + wall_before="$(date +%s)" + + echo " sampling for ${SAMPLE}s..." + sleep "$SAMPLE" + + after="$(cpu_seconds "$pid")" + wall_after="$(date +%s)" + + kill "$pid" 2>/dev/null || true + wait "$pid" 2>/dev/null || true + trap - EXIT + + python3 - "$before" "$after" "$wall_before" "$wall_after" <<'PY' +import sys +cpu = float(sys.argv[2]) - float(sys.argv[1]) +wall = float(sys.argv[4]) - float(sys.argv[3]) +pct = cpu / wall * 100 if wall > 0 else 0 +print(f" CPU: {pct:.2f}% of one core ({cpu:.1f}s CPU over {wall:.0f}s wall)") +PY +} + +measure 0 "Never sleep (max responsiveness)" +measure 2 "Idle sleeping" + +echo +echo "=== Result ===" +echo " Compare the two CPU figures above." +echo " Then read the loop: lines for what it cost in timer accuracy:" +echo +grep -h "loop: " "$DIST/Logs/measure-0.out" | tail -3 || true +echo " ---" +grep -h "loop: " "$DIST/Logs/measure-2.out" | tail -3 || true +echo +echo " missed16ms and sched are the health numbers. cpu alone does not tell you" +echo " whether the trade was worth it." + +# Leave the configuration on the default. +set_idle_wait 2