Compare commits
29 commits
measure/ev
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 25a2aa03c5 | |||
|
|
e52d54b7da | ||
|
|
708a354337 | ||
|
|
d3bf283e2d | ||
|
|
547c2ea0fa | ||
|
|
c9875e7f64 | ||
|
|
e07416902a | ||
|
|
4420872b22 | ||
|
|
38c74a968b | ||
|
|
e7f85d404d | ||
|
|
8e39da2810 | ||
|
|
2935eafe24 | ||
|
|
b992c7b955 | ||
|
|
b042edcf0b | ||
|
|
73f9688083 | ||
|
|
126a10ce53 | ||
|
|
541dbc5ac5 | ||
|
|
971d7b6a77 | ||
|
|
fd27b7a3c9 | ||
|
|
be3a08513f | ||
|
|
2dbaa87377 | ||
|
|
240118340e | ||
|
|
9b35b39d0d | ||
|
|
55ac2c3d98 | ||
|
|
bd79cb7759 | ||
|
|
5ce0f1e92b | ||
|
|
1bc83339bb | ||
|
|
c1442aff3e | ||
|
|
0628902644 |
249 changed files with 8956 additions and 3569 deletions
|
|
@ -3,7 +3,7 @@
|
|||
"isRoot": true,
|
||||
"tools": {
|
||||
"modernuoschemagenerator": {
|
||||
"version": "2.14.3",
|
||||
"version": "4.0.0",
|
||||
"commands": [
|
||||
"ModernUOSchemaGenerator"
|
||||
]
|
||||
|
|
|
|||
4
.gitignore
vendored
4
.gitignore
vendored
|
|
@ -1,4 +1,5 @@
|
|||
# Distribution Files
|
||||
/Distribution/Data/Files
|
||||
/Distribution/Logger
|
||||
/Distribution/Logger.*
|
||||
/Distribution/ModernUO
|
||||
|
|
@ -64,6 +65,3 @@
|
|||
# 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
|
||||
|
|
|
|||
|
|
@ -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<T>.Shared`** not `ArrayPool<T>.Shared` — single-threaded optimized, no locks
|
||||
8. **`PooledRefList<T>`** not `new List<T>()` on hot paths — zero GC pressure, stack-allocated ref struct
|
||||
9. **Serialization** — class must be `partial`, constructor needs `[Constructible]`, `TimerExecutionToken` must NOT have `[SerializableField]`. New classes: use `[SerializationGenerator(version)]` (omit `encoded`). When bumping versions, add `MigrateFrom(VXContent)` (X = previous version). Never modify `Deserialize(reader, version)` for version bumps — that method is only for pre-codegen legacy saves. When migrating from pre-codegen Serialize/Deserialize: pass `false` if old code used `reader.ReadInt()`, bump version +1, and keep old logic as `private void Deserialize(IGenericReader reader, int version)` → `dev-docs/runuo-migration-docs/02-serialization.md`
|
||||
9. **Serialization** — class must be `partial`, constructor needs `[Constructible]`, `TimerExecutionToken` must NOT have `[SerializableField]`. New classes: use `[SerializationGenerator(version)]` (omit `encoded`). Setters that coerce/veto/run side effects: use `[SerializableField]` args `allowFieldChange: nameof(BoolRefMethod)` / `fieldChanged: nameof(OldNewMethod)` — reserve `[SerializableProperty]` for custom getters. Serializable `Timer` members declare `[DeserializeTimer(nameof(Method))]` on the field (anchored by default — downtime preserves remaining delay; `wallClock: true` = absolute; method runs only when a timer was running at save). Conditional writes: `[SaveFlag(nameof(Should), nameof(Default))]` on the field. When bumping versions, add `MigrateFrom(VXContent)` (X = previous version). Never modify `Deserialize(reader, version)` for version bumps — that method is only for pre-codegen legacy saves. When migrating from pre-codegen Serialize/Deserialize: pass `false` if old code used `reader.ReadInt()`, bump version +1, and keep old logic as `private void Deserialize(IGenericReader reader, int version)` → `dev-docs/serialization.md`, `dev-docs/runuo-migration-docs/02-serialization.md`
|
||||
10. **No `Task.Run`/`new Thread()` for game logic** (tandem with rule #3) — game logic is the single-threaded event loop. Backgrounding is allowed only for work that does not itself touch game state (external service calls, large-file parse). **Prove the need before adding a thread**: measure **on-loop** time, not wall-clock (frozen world is the cost, player latency is not), and gate on `Environment.ProcessorCount` — off-loading creates no CPU and buys nothing on 1–2 cores. New workers go in the vetted table in `dev-docs/threading-model.md` with their measurement. When such work must *feed* game logic: run the heavy/I/O part off-loop and `ConfigureAwait(false)` its awaits so a continuation never resumes on the loop and silently foregrounds heavy work; then hand the result back **explicitly** — publish an immutable snapshot swapped via a `volatile` reference (the loop reads it lock-free), or marshal the apply step with `Core.LoopContext.Post(() => …)`, re-validating in the continuation whatever may have changed while it ran. Never touch game state off-thread; never let the scheduler decide where the heavy work runs → `dev-docs/threading-model.md`
|
||||
11. **Never assume era** — if code uses `Core.AOS`/`Core.SE`/etc., ask which expansion to target
|
||||
12. **Naming** — `_camelCase` private fields, `PascalCase` properties/methods/classes; don't flag legacy `m_` but use `_` for new code
|
||||
|
|
@ -48,7 +48,6 @@ 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` |
|
||||
|
|
|
|||
|
|
@ -71,7 +71,7 @@
|
|||
<DefineConstants>$(DefineConstants);EVENT_LOOP_PROFILING</DefineConstants>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Serilog" Version="4.3.1" />
|
||||
<PackageReference Include="Serilog" Version="4.4.0" />
|
||||
<PackageReference Include="Serilog.Sinks.Async" Version="2.1.0" />
|
||||
<PackageReference Include="Serilog.Sinks.Console" Version="6.1.1" />
|
||||
<PackageReference Include="Nerdbank.GitVersioning" Condition="!Exists('packages.config')">
|
||||
|
|
|
|||
|
|
@ -3,12 +3,16 @@
|
|||
"level": "VerySlow",
|
||||
"active": 0.4,
|
||||
"passive": 0.8,
|
||||
"activeMove": 0.9,
|
||||
"passiveMove": 1.5,
|
||||
"types": []
|
||||
},
|
||||
{
|
||||
"level": "Slow",
|
||||
"active": 0.3,
|
||||
"passive": 0.6,
|
||||
"activeMove": 0.6,
|
||||
"passiveMove": 1.2,
|
||||
"types": [
|
||||
"AntLion", "ArcticOgreLord", "BogThing",
|
||||
"Bogle", "BoneKnight", "EarthElemental",
|
||||
|
|
@ -28,6 +32,8 @@
|
|||
"level": "Medium",
|
||||
"active": 0.25,
|
||||
"passive": 0.5,
|
||||
"activeMove": 0.45,
|
||||
"passiveMove": 1.05,
|
||||
"types": [
|
||||
"AcidElemental", "AgapiteElemental", "Alligator",
|
||||
"AncientLich", "Betrayer", "Bird",
|
||||
|
|
@ -108,6 +114,8 @@
|
|||
"level": "Fast",
|
||||
"active": 0.2,
|
||||
"passive": 0.4,
|
||||
"activeMove": 0.3,
|
||||
"passiveMove": 0.9,
|
||||
"types": [
|
||||
"LordOaks", "Silvani", "AirElemental",
|
||||
"AncientWyrm", "Balron", "BladeSpirits",
|
||||
|
|
@ -139,6 +147,8 @@
|
|||
"level": "VeryFast",
|
||||
"active": 0.125,
|
||||
"passive": 0.30,
|
||||
"activeMove": 0.125,
|
||||
"passiveMove": 0.6,
|
||||
"types": [
|
||||
"Barracoon", "Mephitis", "Neira",
|
||||
"Rikktor", "Semidar", "EnergyVortex",
|
||||
|
|
|
|||
|
|
@ -17,6 +17,5 @@
|
|||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Spectre.Console" Version="0.57.2" />
|
||||
<PackageReference Update="Serilog" Version="4.4.0" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
|
|
|||
|
|
@ -5,17 +5,16 @@
|
|||
<RootNamespace>Server.Tests</RootNamespace>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.8.1" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.9.0" />
|
||||
<PackageReference Include="xunit" Version="2.9.3" />
|
||||
<PackageReference Include="xunit.SkippableFact" Version="1.5.61" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.5">
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="4.0.0">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<ProjectReference Include="..\Application\Application.csproj" />
|
||||
<DataFiles Include="$(SolutionDir)\Distribution\Data\**" />
|
||||
<ProjectReference Include="..\UOContent\UOContent.csproj" />
|
||||
<PackageReference Update="Serilog" Version="4.4.0" />
|
||||
</ItemGroup>
|
||||
<!-- Copy native ioring.dll for tests -->
|
||||
<ItemGroup>
|
||||
|
|
|
|||
|
|
@ -208,6 +208,272 @@ public class DecayRegistrationTests
|
|||
item.Delete();
|
||||
}
|
||||
|
||||
// Unfreezing an item with a stale LastMoved must grant a fresh decay window,
|
||||
// not delete it on the next tick.
|
||||
[Fact]
|
||||
public void StaleImmovableItemMadeMovable_GetsAFreshDecayWindow()
|
||||
{
|
||||
var start = Core._now;
|
||||
|
||||
try
|
||||
{
|
||||
var item = new Item(0x1234);
|
||||
item.MoveToWorld(new Point3D(107, 100, 0), Map.Felucca);
|
||||
|
||||
item.Movable = false;
|
||||
Assert.False(DecayScheduler.IsRegistered(item), "A frozen item must not be tracked for decay.");
|
||||
|
||||
Core._now = start + TimeSpan.FromDays(30);
|
||||
var flipped = Core._now;
|
||||
|
||||
item.Movable = true;
|
||||
|
||||
Assert.True(DecayScheduler.IsRegistered(item), "An unfrozen item must be tracked for decay.");
|
||||
|
||||
AdvanceDecay(flipped, item.DecayTime - TimeSpan.FromMinutes(2), item);
|
||||
Assert.False(item.Deleted, "An unfrozen item must get a full decay window, not vanish immediately.");
|
||||
|
||||
AdvanceDecay(Core._now, TimeSpan.FromMinutes(4), item);
|
||||
Assert.True(item.Deleted, "An unfrozen item must still decay once the fresh window elapses.");
|
||||
}
|
||||
finally
|
||||
{
|
||||
Core._now = start;
|
||||
}
|
||||
}
|
||||
|
||||
// Same transition through the Visible setter: unhiding a long-hidden item.
|
||||
[Fact]
|
||||
public void StaleHiddenItemMadeVisible_GetsAFreshDecayWindow()
|
||||
{
|
||||
var start = Core._now;
|
||||
|
||||
try
|
||||
{
|
||||
var item = new Item(0x1234);
|
||||
item.MoveToWorld(new Point3D(109, 100, 0), Map.Felucca);
|
||||
|
||||
item.Visible = false;
|
||||
Assert.False(DecayScheduler.IsRegistered(item), "A hidden item must not be tracked for decay.");
|
||||
|
||||
Core._now = start + TimeSpan.FromDays(30);
|
||||
var flipped = Core._now;
|
||||
|
||||
item.Visible = true;
|
||||
|
||||
Assert.True(DecayScheduler.IsRegistered(item), "An unhidden item must be tracked for decay.");
|
||||
|
||||
AdvanceDecay(flipped, item.DecayTime - TimeSpan.FromMinutes(2), item);
|
||||
Assert.False(item.Deleted, "An unhidden item must get a full decay window, not vanish immediately.");
|
||||
|
||||
AdvanceDecay(Core._now, TimeSpan.FromMinutes(4), item);
|
||||
Assert.True(item.Deleted, "An unhidden item must still decay once the fresh window elapses.");
|
||||
}
|
||||
finally
|
||||
{
|
||||
Core._now = start;
|
||||
}
|
||||
}
|
||||
|
||||
// A refusal restarts the countdown without rewriting LastMoved.
|
||||
[Fact]
|
||||
public void RefusedDecay_DoesNotRewriteLastMoved()
|
||||
{
|
||||
var start = Core._now;
|
||||
|
||||
try
|
||||
{
|
||||
var item = new RefusesDecayItem();
|
||||
item.MoveToWorld(new Point3D(110, 100, 0), Map.Felucca);
|
||||
var lastMoved = item.LastMoved;
|
||||
|
||||
AdvanceDecay(start, item.DecayTime + TimeSpan.FromMinutes(2), item);
|
||||
|
||||
Assert.False(item.Deleted, "A refused decay must not delete the item.");
|
||||
Assert.True(DecayScheduler.IsRegistered(item), "A refused decay must leave the item tracked.");
|
||||
Assert.Equal(lastMoved, item.LastMoved);
|
||||
|
||||
item.Delete();
|
||||
}
|
||||
finally
|
||||
{
|
||||
Core._now = start;
|
||||
}
|
||||
}
|
||||
|
||||
// The fresh window must survive a save/load cycle, or a restart mid-window deletes the item.
|
||||
[Fact]
|
||||
public void FreshDecayWindow_SurvivesSerialization()
|
||||
{
|
||||
var start = Core._now;
|
||||
|
||||
try
|
||||
{
|
||||
var item = new Item(0x1234);
|
||||
item.MoveToWorld(new Point3D(111, 100, 0), Map.Felucca);
|
||||
|
||||
item.Movable = false;
|
||||
Core._now = start + TimeSpan.FromDays(30);
|
||||
item.Movable = true;
|
||||
|
||||
var expected = item.ScheduledDecayTime;
|
||||
|
||||
var writer = new BufferWriter(new byte[512], true);
|
||||
item.Serialize(writer);
|
||||
|
||||
var copy = new Item(item.Serial);
|
||||
copy.Deserialize(new BufferReader(writer.Buffer));
|
||||
|
||||
// The stamp is stored as a delta, so it ages only by the real time between
|
||||
// write and read - milliseconds here, the downtime in production.
|
||||
Assert.True(
|
||||
(copy.ScheduledDecayTime - expected).Duration() <= TimeSpan.FromSeconds(5),
|
||||
"The restarted decay window must survive a save/load cycle."
|
||||
);
|
||||
|
||||
item.Delete();
|
||||
copy.Delete();
|
||||
}
|
||||
finally
|
||||
{
|
||||
Core._now = start;
|
||||
}
|
||||
}
|
||||
|
||||
// A real move supersedes the reset stamp; it must be dropped so the CompactInfo can collapse.
|
||||
[Fact]
|
||||
public void MovingAnItem_ClearsASupersededDecayResetStamp()
|
||||
{
|
||||
var start = Core._now;
|
||||
|
||||
try
|
||||
{
|
||||
var item = new Item(0x1234);
|
||||
item.MoveToWorld(new Point3D(112, 100, 0), Map.Felucca);
|
||||
|
||||
item.Movable = false;
|
||||
Core._now = start + TimeSpan.FromDays(30);
|
||||
item.Movable = true;
|
||||
|
||||
Assert.NotEqual(default, item.DecayResetTime);
|
||||
|
||||
Core._now += TimeSpan.FromMinutes(1);
|
||||
item.MoveToWorld(new Point3D(113, 100, 0), Map.Felucca);
|
||||
|
||||
Assert.Equal(default, item.DecayResetTime);
|
||||
Assert.Equal(item.LastMoved + item.DecayTime, item.ScheduledDecayTime);
|
||||
Assert.True(DecayScheduler.IsRegistered(item));
|
||||
|
||||
item.Delete();
|
||||
}
|
||||
finally
|
||||
{
|
||||
Core._now = start;
|
||||
}
|
||||
}
|
||||
|
||||
// Losing decay eligibility makes the stamp meaningless; it must be dropped so the
|
||||
// CompactInfo is not held for as long as the item stays ineligible.
|
||||
[Fact]
|
||||
public void ItemBecomingIneligible_DropsTheDecayResetStamp()
|
||||
{
|
||||
var start = Core._now;
|
||||
|
||||
try
|
||||
{
|
||||
var item = new Item(0x1234);
|
||||
item.MoveToWorld(new Point3D(115, 100, 0), Map.Felucca);
|
||||
|
||||
item.Movable = false;
|
||||
Core._now = start + TimeSpan.FromDays(30);
|
||||
item.Movable = true;
|
||||
|
||||
Assert.NotEqual(default, item.DecayResetTime);
|
||||
|
||||
item.Movable = false;
|
||||
|
||||
Assert.Equal(default, item.DecayResetTime);
|
||||
|
||||
item.Delete();
|
||||
}
|
||||
finally
|
||||
{
|
||||
Core._now = start;
|
||||
}
|
||||
}
|
||||
|
||||
// Moving a stamped item into a container programmatically (no drop, no SetLastMoved)
|
||||
// must also drop the stamp.
|
||||
[Fact]
|
||||
public void StampedItemAddedToContainer_DropsTheDecayResetStamp()
|
||||
{
|
||||
var start = Core._now;
|
||||
|
||||
try
|
||||
{
|
||||
var pack = new Container(0xE75);
|
||||
pack.MoveToWorld(new Point3D(116, 100, 0), Map.Felucca);
|
||||
|
||||
var item = new Item(0x1234);
|
||||
item.MoveToWorld(new Point3D(117, 100, 0), Map.Felucca);
|
||||
|
||||
item.Movable = false;
|
||||
Core._now = start + TimeSpan.FromDays(30);
|
||||
item.Movable = true;
|
||||
|
||||
Assert.NotEqual(default, item.DecayResetTime);
|
||||
|
||||
pack.AddItem(item);
|
||||
|
||||
Assert.Equal(default, item.DecayResetTime);
|
||||
|
||||
pack.Delete();
|
||||
}
|
||||
finally
|
||||
{
|
||||
Core._now = start;
|
||||
}
|
||||
}
|
||||
|
||||
// A raw Map assignment (e.g. a GM changing Map through props) is a move: it must
|
||||
// enroll an untracked item for decay.
|
||||
[Fact]
|
||||
public void ItemMovedToRealMapViaMapSetter_IsRegisteredForDecay()
|
||||
{
|
||||
var item = new Item(0x1234);
|
||||
Assert.False(DecayScheduler.IsRegistered(item));
|
||||
|
||||
item.Map = Map.Felucca;
|
||||
|
||||
Assert.True(item.CanDecay());
|
||||
Assert.True(DecayScheduler.IsRegistered(item), "Item placed on a map via the Map setter must be tracked.");
|
||||
|
||||
item.Delete();
|
||||
}
|
||||
|
||||
// LiftItemDupe places the remainder of a partially lifted ground stack via raw
|
||||
// Location/Map assignments, with no MoveToWorld fallback: it must still be tracked.
|
||||
[Fact]
|
||||
public void PartialLiftOfGroundStack_LeavesRemainderRegisteredForDecay()
|
||||
{
|
||||
var stack = new Item(0x1234) { Stackable = true, Amount = 10 };
|
||||
stack.MoveToWorld(new Point3D(114, 100, 0), Map.Felucca);
|
||||
|
||||
var remainder = Mobile.LiftItemDupe(stack, 3);
|
||||
|
||||
Assert.NotNull(remainder);
|
||||
Assert.Equal(7, remainder.Amount);
|
||||
Assert.Null(remainder.Parent);
|
||||
Assert.Equal(Map.Felucca, remainder.Map);
|
||||
Assert.True(
|
||||
DecayScheduler.IsRegistered(remainder),
|
||||
"The remainder of a partially lifted ground stack must be tracked for decay."
|
||||
);
|
||||
|
||||
stack.Delete();
|
||||
remainder.Delete();
|
||||
}
|
||||
|
||||
// Dropping into a container must untrack; taking it back out to the ground must re-track.
|
||||
[Fact]
|
||||
public void ItemMovedIntoContainerThenBackToGround_IsRegisteredForDecay()
|
||||
|
|
|
|||
|
|
@ -0,0 +1,95 @@
|
|||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
311
Projects/Server.Tests/Tests/Mobiles/DamageEntryTests.cs
Normal file
311
Projects/Server.Tests/Tests/Mobiles/DamageEntryTests.cs
Normal file
|
|
@ -0,0 +1,311 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Server.Collections;
|
||||
using Xunit;
|
||||
|
||||
namespace Server.Tests;
|
||||
|
||||
[Collection("Sequential Server Tests")]
|
||||
public class DamageEntryTests
|
||||
{
|
||||
private class TestMobile : Mobile
|
||||
{
|
||||
}
|
||||
|
||||
private class PetMobile : Mobile
|
||||
{
|
||||
public Mobile Master { get; set; }
|
||||
|
||||
public override Mobile GetDamageMaster(Mobile damagee) => Master;
|
||||
}
|
||||
|
||||
private static List<Mobile> Damagers(Mobile victim)
|
||||
{
|
||||
var result = new List<Mobile>();
|
||||
foreach (var de in victim.DamageEntries)
|
||||
{
|
||||
result.Add(de.Damager);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FreshMobile_HasNoEntries()
|
||||
{
|
||||
var m = new TestMobile();
|
||||
|
||||
try
|
||||
{
|
||||
Assert.Equal(0, m.DamageEntries.Count);
|
||||
Assert.Null(m.FindMostRecentDamageEntry(true));
|
||||
Assert.Null(m.FindLeastRecentDamageEntry(true));
|
||||
Assert.Null(m.FindMostTotalDamageEntry(true));
|
||||
Assert.Null(m.FindLeastTotalDamageEntry(true));
|
||||
Assert.Null(m.FindDamageEntryFor(m));
|
||||
}
|
||||
finally
|
||||
{
|
||||
m.Delete();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RegisterDamage_OrdersLeastRecentToMostRecent()
|
||||
{
|
||||
var victim = new TestMobile();
|
||||
var a = new TestMobile();
|
||||
var b = new TestMobile();
|
||||
|
||||
try
|
||||
{
|
||||
victim.RegisterDamage(10, a);
|
||||
victim.RegisterDamage(20, b);
|
||||
victim.RegisterDamage(5, a); // a becomes most recent again
|
||||
|
||||
Assert.Equal(2, victim.DamageEntries.Count);
|
||||
Assert.Equal(new[] { b, a }, Damagers(victim));
|
||||
Assert.Equal(15, victim.FindDamageEntryFor(a).DamageGiven);
|
||||
Assert.Same(a, victim.FindMostRecentDamager(true));
|
||||
Assert.Same(b, victim.FindLeastRecentDamager(true));
|
||||
}
|
||||
finally
|
||||
{
|
||||
victim.Delete();
|
||||
a.Delete();
|
||||
b.Delete();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FindRecent_HonorsAllowSelf()
|
||||
{
|
||||
var victim = new TestMobile();
|
||||
var a = new TestMobile();
|
||||
|
||||
try
|
||||
{
|
||||
victim.RegisterDamage(10, a);
|
||||
victim.RegisterDamage(10, victim); // self is most recent
|
||||
|
||||
Assert.Same(victim, victim.FindMostRecentDamager(true));
|
||||
Assert.Same(a, victim.FindMostRecentDamager(false));
|
||||
Assert.Same(a, victim.FindLeastRecentDamager(false));
|
||||
}
|
||||
finally
|
||||
{
|
||||
victim.Delete();
|
||||
a.Delete();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FindLeastRecent_HonorsAllowSelf()
|
||||
{
|
||||
var victim = new TestMobile();
|
||||
var a = new TestMobile();
|
||||
|
||||
try
|
||||
{
|
||||
victim.RegisterDamage(10, victim); // self is least recent, so the head is the one to skip
|
||||
victim.RegisterDamage(10, a);
|
||||
|
||||
Assert.Same(victim, victim.FindLeastRecentDamager(true));
|
||||
Assert.Same(a, victim.FindLeastRecentDamager(false));
|
||||
}
|
||||
finally
|
||||
{
|
||||
victim.Delete();
|
||||
a.Delete();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FindTotal_PicksByDamage_MostRecentWinsTies()
|
||||
{
|
||||
var victim = new TestMobile();
|
||||
var a = new TestMobile();
|
||||
var b = new TestMobile();
|
||||
var c = new TestMobile();
|
||||
|
||||
try
|
||||
{
|
||||
victim.RegisterDamage(30, a);
|
||||
victim.RegisterDamage(30, b); // ties a; b is more recent
|
||||
victim.RegisterDamage(1, c);
|
||||
|
||||
Assert.Same(b, victim.FindMostTotalDamager(true));
|
||||
Assert.Same(c, victim.FindLeastTotalDamager(true));
|
||||
}
|
||||
finally
|
||||
{
|
||||
victim.Delete();
|
||||
a.Delete();
|
||||
b.Delete();
|
||||
c.Delete();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FindLeastTotal_MostRecentWinsTies()
|
||||
{
|
||||
var victim = new TestMobile();
|
||||
var a = new TestMobile();
|
||||
var b = new TestMobile();
|
||||
var c = new TestMobile();
|
||||
|
||||
try
|
||||
{
|
||||
victim.RegisterDamage(30, a);
|
||||
victim.RegisterDamage(5, b);
|
||||
victim.RegisterDamage(5, c); // ties b for the minimum; c is more recent
|
||||
|
||||
Assert.Same(a, victim.FindMostTotalDamager(true));
|
||||
Assert.Same(c, victim.FindLeastTotalDamager(true));
|
||||
}
|
||||
finally
|
||||
{
|
||||
victim.Delete();
|
||||
a.Delete();
|
||||
b.Delete();
|
||||
c.Delete();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Prune_RemovesExpiredPrefix_KeepsOrder()
|
||||
{
|
||||
var start = Core._now;
|
||||
var victim = new TestMobile();
|
||||
var a = new TestMobile();
|
||||
var b = new TestMobile();
|
||||
|
||||
try
|
||||
{
|
||||
victim.RegisterDamage(10, a);
|
||||
|
||||
Core._now = start + DamageEntry.ExpireDelay + TimeSpan.FromSeconds(1);
|
||||
victim.RegisterDamage(10, b); // a is now expired, b is live
|
||||
|
||||
Assert.Equal(new[] { b }, Damagers(victim));
|
||||
Assert.Null(victim.FindDamageEntryFor(a));
|
||||
}
|
||||
finally
|
||||
{
|
||||
Core._now = start;
|
||||
victim.Delete();
|
||||
a.Delete();
|
||||
b.Delete();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Prune_AllExpired_EmptiesList()
|
||||
{
|
||||
var start = Core._now;
|
||||
var victim = new TestMobile();
|
||||
var a = new TestMobile();
|
||||
var b = new TestMobile();
|
||||
|
||||
try
|
||||
{
|
||||
victim.RegisterDamage(10, a);
|
||||
victim.RegisterDamage(10, b);
|
||||
|
||||
Core._now = start + DamageEntry.ExpireDelay + TimeSpan.FromSeconds(1);
|
||||
|
||||
Assert.Equal(0, victim.DamageEntries.Count);
|
||||
Assert.Null(victim.FindMostRecentDamageEntry(true));
|
||||
}
|
||||
finally
|
||||
{
|
||||
Core._now = start;
|
||||
victim.Delete();
|
||||
a.Delete();
|
||||
b.Delete();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ClearDamageEntries_UnlinksEveryNode()
|
||||
{
|
||||
var victim = new TestMobile();
|
||||
var a = new TestMobile();
|
||||
var b = new TestMobile();
|
||||
|
||||
try
|
||||
{
|
||||
var ea = victim.RegisterDamage(10, a);
|
||||
var eb = victim.RegisterDamage(10, b);
|
||||
|
||||
victim.ClearDamageEntries();
|
||||
|
||||
Assert.Equal(0, victim.DamageEntries.Count);
|
||||
Assert.False(ea.OnLinkList);
|
||||
Assert.False(eb.OnLinkList);
|
||||
Assert.Null(ea.Next);
|
||||
Assert.Null(ea.Previous);
|
||||
Assert.Null(eb.Next);
|
||||
Assert.Null(eb.Previous);
|
||||
}
|
||||
finally
|
||||
{
|
||||
victim.Delete();
|
||||
a.Delete();
|
||||
b.Delete();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FullHitPoints_ClearsEntries()
|
||||
{
|
||||
var victim = new TestMobile();
|
||||
var a = new TestMobile();
|
||||
|
||||
try
|
||||
{
|
||||
victim.RawStr = 50; // HitsMax follows Str for a base Mobile
|
||||
victim.Hits = 10;
|
||||
victim.RegisterDamage(10, a);
|
||||
Assert.Equal(1, victim.DamageEntries.Count);
|
||||
|
||||
// Also stops the HitsTimer the Hits = 10 write started, so the test leaves no timer behind.
|
||||
victim.Hits = victim.HitsMax;
|
||||
|
||||
Assert.Equal(0, victim.DamageEntries.Count);
|
||||
}
|
||||
finally
|
||||
{
|
||||
victim.Delete();
|
||||
a.Delete();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RegisterDamage_AccumulatesResponsibleMaster()
|
||||
{
|
||||
var victim = new TestMobile();
|
||||
var master = new TestMobile();
|
||||
var pet = new PetMobile { Master = master };
|
||||
|
||||
try
|
||||
{
|
||||
victim.RegisterDamage(10, pet);
|
||||
var entry = victim.RegisterDamage(5, pet);
|
||||
|
||||
Assert.Same(pet, entry.Damager);
|
||||
Assert.Equal(15, entry.DamageGiven);
|
||||
Assert.NotNull(entry.Responsible);
|
||||
Assert.Single(entry.Responsible);
|
||||
Assert.Same(master, entry.Responsible[0].Damager);
|
||||
Assert.Equal(15, entry.Responsible[0].DamageGiven);
|
||||
Assert.False(entry.Responsible[0].OnLinkList); // sub-entries never join the main list
|
||||
}
|
||||
finally
|
||||
{
|
||||
victim.Delete();
|
||||
master.Delete();
|
||||
pet.Delete();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,76 @@
|
|||
using System;
|
||||
using Xunit;
|
||||
|
||||
namespace Server.Tests;
|
||||
|
||||
[Collection("Sequential Server Tests")]
|
||||
public class AnchoredItemSerializationTests
|
||||
{
|
||||
private static byte[] SerializeItem(Item item)
|
||||
{
|
||||
var writer = new BufferWriter(new byte[256], true);
|
||||
item.Serialize(writer);
|
||||
return writer.Buffer[..(int)writer.Position];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
[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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
[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();
|
||||
}
|
||||
}
|
||||
189
Projects/Server.Tests/Tests/Serialization/AnchoredTimeTests.cs
Normal file
189
Projects/Server.Tests/Tests/Serialization/AnchoredTimeTests.cs
Normal file
|
|
@ -0,0 +1,189 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using Xunit;
|
||||
|
||||
namespace Server.Tests;
|
||||
|
||||
public class AnchoredTimeTests
|
||||
{
|
||||
private static (BufferWriter Writer, Func<TimeSpan, IGenericReader> 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<AnchoredEntity>
|
||||
{
|
||||
public AnchoredPersistence(int priority) : base("AnchoredTrip", priority, 1, 0x7FFFFFFF)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
[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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -26,6 +26,7 @@ public enum LoopPhase
|
|||
TimerSlice,
|
||||
NetworkSlice,
|
||||
LoopTasks,
|
||||
WorldSnapshot,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -42,7 +43,7 @@ public enum LoopPhase
|
|||
/// </remarks>
|
||||
public static class EventLoopProfiler
|
||||
{
|
||||
public const int PhaseCount = 5;
|
||||
public const int PhaseCount = 6;
|
||||
private const int RingSize = 900;
|
||||
private const long SampleIntervalMs = 1000;
|
||||
|
||||
|
|
|
|||
|
|
@ -44,10 +44,10 @@ public partial class Container : Item
|
|||
internal int _version;
|
||||
|
||||
[SerializableField(3)]
|
||||
[SaveFlag(nameof(ShouldSerializeLiftOverride))]
|
||||
[SerializedCommandProperty(AccessLevel.GameMaster)]
|
||||
private bool _liftOverride;
|
||||
|
||||
[SerializableFieldSaveFlag(3)]
|
||||
private bool ShouldSerializeLiftOverride() => _liftOverride;
|
||||
|
||||
public Container(int itemID) : base(itemID)
|
||||
|
|
@ -84,6 +84,7 @@ public partial class Container : Item
|
|||
|
||||
[EncodedInt]
|
||||
[SerializableProperty(0)]
|
||||
[SaveFlag(nameof(ShouldSerializeMaxItems), nameof(MaxItemsDefaultValue))]
|
||||
[CommandProperty(AccessLevel.GameMaster)]
|
||||
public int MaxItems
|
||||
{
|
||||
|
|
@ -96,14 +97,13 @@ public partial class Container : Item
|
|||
}
|
||||
}
|
||||
|
||||
[SerializableFieldSaveFlag(0)]
|
||||
private bool ShouldSerializeMaxItems() => _maxItems != -1;
|
||||
|
||||
[SerializableFieldDefault(0)]
|
||||
private int MaxItemsDefaultValue() => -1;
|
||||
|
||||
[EncodedInt]
|
||||
[SerializableProperty(1)]
|
||||
[SaveFlag(nameof(ShouldSerializeGumpId), nameof(GumpIDDefaultValue))]
|
||||
[CommandProperty(AccessLevel.GameMaster)]
|
||||
public int GumpID
|
||||
{
|
||||
|
|
@ -115,14 +115,13 @@ public partial class Container : Item
|
|||
}
|
||||
}
|
||||
|
||||
[SerializableFieldSaveFlag(1)]
|
||||
private bool ShouldSerializeGumpId() => _gumpID != -1;
|
||||
|
||||
[SerializableFieldDefault(1)]
|
||||
private int GumpIDDefaultValue() => -1;
|
||||
|
||||
[EncodedInt]
|
||||
[SerializableProperty(2)]
|
||||
[SaveFlag(nameof(ShouldSerializeDropSound), nameof(DropSoundDefaultValue))]
|
||||
[CommandProperty(AccessLevel.GameMaster)]
|
||||
public int DropSound
|
||||
{
|
||||
|
|
@ -134,10 +133,8 @@ public partial class Container : Item
|
|||
}
|
||||
}
|
||||
|
||||
[SerializableFieldSaveFlag(2)]
|
||||
private bool ShouldSerializeDropSound() => _dropSound != -1;
|
||||
|
||||
[SerializableFieldDefault(2)]
|
||||
private int DropSoundDefaultValue() => -1;
|
||||
|
||||
[CommandProperty(AccessLevel.GameMaster)]
|
||||
|
|
|
|||
|
|
@ -311,7 +311,7 @@ public class DecayScheduler : Timer
|
|||
|
||||
if (timeUntilDecay > _bucketInterval)
|
||||
{
|
||||
// Item was moved (SetLastMoved called) - re-bucket or move to overflow
|
||||
// Deadline was pushed out (SetLastMoved/RestartDecay) - re-bucket or move to overflow
|
||||
if (timeUntilDecay > _totalBucketSpan)
|
||||
{
|
||||
// Extended beyond total span - move to overflow
|
||||
|
|
@ -429,7 +429,7 @@ public class DecayScheduler : Timer
|
|||
{
|
||||
// Refused by the region. Restart the clock rather than dropping the item, which has
|
||||
// already left the queue; re-registering as-is would spin on a due time in the past.
|
||||
item.SetLastMoved();
|
||||
item.RestartDecay();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -335,7 +335,25 @@ public partial class Item : IHued, IComparable<Item>, ISpawnable, IObjectPropert
|
|||
[CommandProperty(AccessLevel.GameMaster)]
|
||||
public virtual bool Decays => Movable && Visible && Spawner == null;
|
||||
|
||||
public DateTime LastMoved { get; set; }
|
||||
private DateTime _lastMoved;
|
||||
|
||||
public DateTime LastMoved
|
||||
{
|
||||
get => _lastMoved;
|
||||
set
|
||||
{
|
||||
_lastMoved = value;
|
||||
|
||||
// A move at or past the reset stamp supersedes it; drop it so the CompactInfo can collapse.
|
||||
var info = LookupCompactInfo();
|
||||
|
||||
if (info != null && info.m_DecayReset != default && info.m_DecayReset <= value)
|
||||
{
|
||||
info.m_DecayReset = default;
|
||||
VerifyCompactInfo();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[CommandProperty(AccessLevel.GameMaster)]
|
||||
public bool Stackable
|
||||
|
|
@ -373,7 +391,7 @@ public partial class Item : IHued, IComparable<Item>, ISpawnable, IObjectPropert
|
|||
}
|
||||
|
||||
Delta(ItemDelta.Update);
|
||||
UpdateDecayRegistration();
|
||||
RestartDecay();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -389,7 +407,7 @@ public partial class Item : IHued, IComparable<Item>, ISpawnable, IObjectPropert
|
|||
SetFlag(ImplFlag.Movable, value);
|
||||
|
||||
Delta(ItemDelta.Update);
|
||||
UpdateDecayRegistration();
|
||||
RestartDecay();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -749,6 +767,12 @@ public partial class Item : IHued, IComparable<Item>, ISpawnable, IObjectPropert
|
|||
|
||||
public static bool ScissorCopyLootType { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// True when the item was produced by the crafting system rather than bought or looted.
|
||||
/// </summary>
|
||||
[CommandProperty(AccessLevel.GameMaster)]
|
||||
public bool PlayerConstructed { get; set; }
|
||||
|
||||
[CommandProperty(AccessLevel.GameMaster)]
|
||||
public bool QuestItem
|
||||
{
|
||||
|
|
@ -839,7 +863,7 @@ public partial class Item : IHued, IComparable<Item>, ISpawnable, IObjectPropert
|
|||
|
||||
public virtual void Serialize(IGenericWriter writer)
|
||||
{
|
||||
writer.Write(9); // version
|
||||
writer.Write(11); // version
|
||||
|
||||
var flags = SaveFlag.None;
|
||||
|
||||
|
|
@ -949,6 +973,11 @@ public partial class Item : IHued, IComparable<Item>, ISpawnable, IObjectPropert
|
|||
{
|
||||
flags |= SaveFlag.SavedFlags;
|
||||
}
|
||||
|
||||
if (info.m_DecayReset > LastMoved)
|
||||
{
|
||||
flags |= SaveFlag.DecayReset;
|
||||
}
|
||||
}
|
||||
|
||||
if (info == null || info.m_Weight < 0)
|
||||
|
|
@ -979,16 +1008,21 @@ public partial class Item : IHued, IComparable<Item>, ISpawnable, IObjectPropert
|
|||
flags |= SaveFlag.ImplFlags;
|
||||
}
|
||||
|
||||
if (PlayerConstructed)
|
||||
{
|
||||
flags |= SaveFlag.PlayerConstructed;
|
||||
}
|
||||
|
||||
writer.Write((int)flags);
|
||||
|
||||
/* begin last moved time optimization */
|
||||
var ticks = LastMoved.Ticks;
|
||||
var now = Core.Now.Ticks;
|
||||
// Anchored: shifted by downtime at load, so time-since-moved is preserved and the
|
||||
// bytes are stable across saves while the item does not move.
|
||||
writer.WriteAnchoredTime(LastMoved);
|
||||
|
||||
var minutes = new TimeSpan(now - ticks).TotalMinutes;
|
||||
|
||||
writer.WriteEncodedInt((int)Math.Clamp(minutes, int.MinValue, int.MaxValue));
|
||||
/* end */
|
||||
if (GetSaveFlag(flags, SaveFlag.DecayReset))
|
||||
{
|
||||
writer.WriteAnchoredTime(info.m_DecayReset);
|
||||
}
|
||||
|
||||
if (GetSaveFlag(flags, SaveFlag.Direction))
|
||||
{
|
||||
|
|
@ -1307,6 +1341,12 @@ public partial class Item : IHued, IComparable<Item>, 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();
|
||||
|
|
@ -1537,7 +1577,7 @@ public partial class Item : IHued, IComparable<Item>, ISpawnable, IObjectPropert
|
|||
|
||||
if (oldValue != value)
|
||||
{
|
||||
UpdateDecayRegistration();
|
||||
RestartDecay();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1731,6 +1771,7 @@ public partial class Item : IHued, IComparable<Item>, 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;
|
||||
|
|
@ -2315,7 +2356,64 @@ public partial class Item : IHued, IComparable<Item>, ISpawnable, IObjectPropert
|
|||
public virtual bool OnDecay() =>
|
||||
CanDecay() && Region.Find(Location, Map).OnDecay(this);
|
||||
|
||||
public DateTime ScheduledDecayTime => LastMoved + DecayTime;
|
||||
public DateTime ScheduledDecayTime
|
||||
{
|
||||
get
|
||||
{
|
||||
var reset = DecayResetTime;
|
||||
var lastMoved = LastMoved;
|
||||
|
||||
return (reset > lastMoved ? reset : lastMoved) + DecayTime;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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 <see cref="LastMoved" />.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Restarts the decay countdown without touching <see cref="LastMoved" />: call when decay
|
||||
/// eligibility changes state (Movable/Visible/Spawner) or a region refuses a decay, where a
|
||||
/// stale <see cref="LastMoved" /> would otherwise decay the item on the next tick.
|
||||
/// Stamps <see cref="DecayResetTime" /> only when that extends the current deadline, then
|
||||
/// updates the scheduler registration.
|
||||
/// </summary>
|
||||
public void RestartDecay()
|
||||
{
|
||||
if (CanDecay())
|
||||
{
|
||||
var now = Core.Now;
|
||||
|
||||
if (ScheduledDecayTime < now + DecayTime)
|
||||
{
|
||||
DecayResetTime = now;
|
||||
}
|
||||
}
|
||||
|
||||
UpdateDecayRegistration();
|
||||
}
|
||||
|
||||
public void UpdateDecayRegistration()
|
||||
{
|
||||
|
|
@ -2325,6 +2423,12 @@ public partial class Item : IHued, IComparable<Item>, 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()
|
||||
|
|
@ -2357,6 +2461,11 @@ public partial class Item : IHued, IComparable<Item>, ISpawnable, IObjectPropert
|
|||
}
|
||||
|
||||
Amount += dropped.Amount;
|
||||
if (PlayerConstructed != dropped.PlayerConstructed)
|
||||
{
|
||||
PlayerConstructed = false;
|
||||
}
|
||||
|
||||
dropped.Delete();
|
||||
|
||||
if (playSound && from != null)
|
||||
|
|
@ -2657,6 +2766,8 @@ public partial class Item : IHued, IComparable<Item>, ISpawnable, IObjectPropert
|
|||
|
||||
switch (version)
|
||||
{
|
||||
case 11:
|
||||
case 10:
|
||||
case 9:
|
||||
case 8:
|
||||
case 7:
|
||||
|
|
@ -2664,7 +2775,11 @@ public partial class Item : IHued, IComparable<Item>, ISpawnable, IObjectPropert
|
|||
{
|
||||
var flags = (SaveFlag)reader.ReadInt();
|
||||
|
||||
if (version < 7)
|
||||
if (version >= 11)
|
||||
{
|
||||
LastMoved = reader.ReadAnchoredTime();
|
||||
}
|
||||
else if (version < 7)
|
||||
{
|
||||
LastMoved = reader.ReadDeltaTime();
|
||||
}
|
||||
|
|
@ -2682,6 +2797,18 @@ public partial class Item : IHued, IComparable<Item>, 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();
|
||||
|
|
@ -2854,6 +2981,8 @@ public partial class Item : IHued, IComparable<Item>, ISpawnable, IObjectPropert
|
|||
AcquireCompactInfo().m_SavedFlags = reader.ReadEncodedInt();
|
||||
}
|
||||
|
||||
PlayerConstructed = GetSaveFlag(flags, SaveFlag.PlayerConstructed);
|
||||
|
||||
if (m_Map != null && m_Parent == null)
|
||||
{
|
||||
m_Map.OnEnter(this);
|
||||
|
|
@ -3437,7 +3566,7 @@ public partial class Item : IHued, IComparable<Item>, ISpawnable, IObjectPropert
|
|||
for (var i = 0; i < props.Length; i++)
|
||||
{
|
||||
var p = props[i];
|
||||
if (p.GetCustomAttribute(typeof(IgnoreDupeAttribute), true) != null || !p.CanRead || !p.CanWrite)
|
||||
if (p.GetCustomAttribute<IgnoreDupeAttribute>(true) != null || !p.CanRead || !p.CanWrite)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
|
@ -4343,6 +4472,8 @@ public partial class Item : IHued, IComparable<Item>, ISpawnable, IObjectPropert
|
|||
|
||||
public ISpawner m_Spawner;
|
||||
|
||||
public DateTime m_DecayReset;
|
||||
|
||||
public int m_TempFlags;
|
||||
|
||||
public double m_Weight = -1;
|
||||
|
|
@ -4380,6 +4511,8 @@ public partial class Item : IHued, IComparable<Item>, ISpawnable, IObjectPropert
|
|||
HeldBy = 0x00800000,
|
||||
IntWeight = 0x01000000,
|
||||
SavedFlags = 0x02000000,
|
||||
NullWeight = 0x04000000
|
||||
NullWeight = 0x04000000,
|
||||
PlayerConstructed = 0x08000000,
|
||||
DecayReset = 0x10000000
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -39,24 +39,28 @@ public static class Core
|
|||
{
|
||||
private static readonly ILogger logger = LogFactory.GetLogger(typeof(Core));
|
||||
|
||||
// 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.
|
||||
// Written off-loop (Kill, RequestSnapshot); volatile because the loop blocks between reads.
|
||||
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 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.
|
||||
// A backstop, not a latency control: the wheel's tick rate already bounds the sleep.
|
||||
// Measured across 1/2/4/8ms; 2 is optimal.
|
||||
private static int _eventLoopIdleWaitMs = 2;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public static int EventLoopIdleWaitMs => _eventLoopIdleWaitMs;
|
||||
|
||||
/// <summary>
|
||||
/// True when idle sleeping was disabled at startup because the host cannot honor short
|
||||
/// waits, overriding whatever <c>server.eventLoopIdleWaitMs</c> was configured to.
|
||||
/// </summary>
|
||||
public static bool IdleSleepUnsupported { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether idle sleeping is currently suspended because the host returned waits late.
|
||||
/// </summary>
|
||||
|
|
@ -68,9 +72,8 @@ public static class Core
|
|||
|
||||
private const long HealthSampleIntervalMs = 1000;
|
||||
|
||||
// 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.
|
||||
// Doubling: a fixed suspension oscillates forever on a persistently bad host, while doubling
|
||||
// converges on "stop sleeping" yet still recovers from a transient.
|
||||
private const long BackoffBaseMs = 5000;
|
||||
private const long BackoffMaxMs = 120_000;
|
||||
private const int BackoffMaxShift = 5;
|
||||
|
|
@ -78,16 +81,20 @@ public static class Core
|
|||
// Clean streak that clears the escalation.
|
||||
private const long BackoffResetAfterCleanMs = 60_000;
|
||||
|
||||
// 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.
|
||||
// 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.
|
||||
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;
|
||||
|
|
@ -109,7 +116,25 @@ 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)
|
||||
{
|
||||
|
|
@ -117,8 +142,17 @@ public static class Core
|
|||
return;
|
||||
}
|
||||
|
||||
// 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.
|
||||
// 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.
|
||||
if (++_consecutiveBadSamples < 2)
|
||||
{
|
||||
return;
|
||||
|
|
@ -129,20 +163,6 @@ 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;
|
||||
|
|
@ -151,7 +171,7 @@ public static class Core
|
|||
|
||||
if (_currentBackoffMs >= BackoffMaxMs)
|
||||
{
|
||||
// Escalation has run out of room; say so once in terms the operator can act on.
|
||||
// Escalation has run out of room; say so once.
|
||||
if (!_loggedBackoffCeiling)
|
||||
{
|
||||
_loggedBackoffCeiling = true;
|
||||
|
|
@ -166,12 +186,31 @@ 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} time(s) in the last " +
|
||||
"second; idle sleeping suspended for {Duration}ms",
|
||||
"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",
|
||||
_eventLoopIdleWaitMs,
|
||||
Timer.TickRate,
|
||||
late,
|
||||
sleeps,
|
||||
_consecutiveBackoffs,
|
||||
_currentBackoffMs
|
||||
);
|
||||
}
|
||||
|
|
@ -360,8 +399,8 @@ public static class Core
|
|||
_restartOnKill = restart;
|
||||
_performProcessKill = true;
|
||||
|
||||
// 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.
|
||||
// Callers are usually off-loop (console input, signal handlers); wake so the request
|
||||
// is noticed now rather than whenever the loop next surfaces.
|
||||
NetState.Wake();
|
||||
}
|
||||
|
||||
|
|
@ -553,11 +592,43 @@ public static class Core
|
|||
ServerConfiguration.Load();
|
||||
|
||||
// 0 disables idle sleeping entirely (full-core spin, zero scheduling overhead).
|
||||
_eventLoopIdleWaitMs = ServerConfiguration.GetSetting("server.eventLoopIdleWaitMs", 2);
|
||||
var idleWaitMs = ServerConfiguration.GetSetting("server.eventLoopIdleWaitMs", 2);
|
||||
if (idleWaitMs < 0)
|
||||
{
|
||||
logger.Warning(
|
||||
"server.eventLoopIdleWaitMs {Value} is negative; using 0 (idle sleeping disabled)",
|
||||
idleWaitMs
|
||||
);
|
||||
}
|
||||
|
||||
// 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);
|
||||
_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);
|
||||
|
||||
var assemblyPath = Path.Join(BaseDirectory, AssembliesConfiguration);
|
||||
|
||||
|
|
@ -575,10 +646,8 @@ public static class Core
|
|||
|
||||
AssemblyHandler.LoadAssemblies(assemblyFiles);
|
||||
|
||||
// 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?").
|
||||
// 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.
|
||||
AssemblyHandler.Invoke("ConfigurePrompts");
|
||||
|
||||
logger.Information("Running on {Framework}", RuntimeInformation.FrameworkDescription);
|
||||
|
|
@ -588,9 +657,8 @@ public static class Core
|
|||
_now = DateTime.UtcNow;
|
||||
_firstTick = _tickCount = GetTimestamp();
|
||||
|
||||
// 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.
|
||||
// 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.
|
||||
_nextHealthSample = _tickCount + HealthSampleIntervalMs;
|
||||
_idleSleepSuspendedUntil = _tickCount;
|
||||
|
||||
|
|
@ -611,9 +679,8 @@ public static class Core
|
|||
PingServer.Start();
|
||||
EventSink.InvokeServerStarted();
|
||||
|
||||
// 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.
|
||||
// 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.
|
||||
if (_eventLoopIdleWaitMs > 0 && NetState.Ring?.SupportsHighResolutionWait == false)
|
||||
{
|
||||
logger.Error(
|
||||
|
|
@ -621,6 +688,7 @@ public static class Core
|
|||
"resolution failed). Idle sleeping is disabled. The loop will spin instead, using a full core."
|
||||
);
|
||||
|
||||
IdleSleepUnsupported = true;
|
||||
_eventLoopIdleWaitMs = 0;
|
||||
}
|
||||
|
||||
|
|
@ -629,8 +697,7 @@ public static class Core
|
|||
|
||||
/// <summary>
|
||||
/// True when every queue the loop drains is empty, so sleeping cannot strand pending work.
|
||||
/// 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.
|
||||
/// The drains are bounded, so leftovers are normal and must keep the loop awake.
|
||||
/// </summary>
|
||||
private static bool IsIdle() =>
|
||||
!Mobile.HasQueuedDeltas && !Item.HasQueuedDeltas && LoopContext.IsEmpty && NetState.IsIdle;
|
||||
|
|
@ -672,8 +739,10 @@ 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;
|
||||
}
|
||||
|
||||
|
|
@ -687,21 +756,28 @@ public static class Core
|
|||
|
||||
if (_eventLoopIdleWaitMs > 0 && _tickCount - _idleSleepSuspendedUntil >= 0 && IsIdle())
|
||||
{
|
||||
// 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.
|
||||
// Re-read the clock: a stale timestamp overstates the time to the next tick
|
||||
// and sleeps 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++;
|
||||
|
||||
// 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)
|
||||
// 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)
|
||||
{
|
||||
_lateWakes++;
|
||||
}
|
||||
|
|
@ -723,8 +799,7 @@ public static class Core
|
|||
_snapshotPath = snapshotPath;
|
||||
_performSnapshot = true;
|
||||
|
||||
// Save requests arrive off-loop. Wake so the snapshot starts now rather than after the
|
||||
// loop happens to surface for another reason.
|
||||
// Save requests arrive off-loop; wake so the snapshot starts now.
|
||||
NetState.Wake();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -42,7 +42,7 @@ public delegate void PromptCallback(Mobile from, string text);
|
|||
|
||||
public delegate void PromptStateCallback<in T>(Mobile from, string text, T state);
|
||||
|
||||
public class DamageEntry
|
||||
public class DamageEntry : IValueLinkListNode<DamageEntry>
|
||||
{
|
||||
public DamageEntry(Mobile damager) => Damager = damager;
|
||||
|
||||
|
|
@ -57,6 +57,11 @@ public class DamageEntry
|
|||
public List<DamageEntry> Responsible { get; set; }
|
||||
|
||||
public static TimeSpan ExpireDelay { get; set; } = TimeSpan.FromMinutes(2.0);
|
||||
|
||||
// Intrusive links for Mobile._damageEntries. Sub-entries in Responsible never join a list.
|
||||
public DamageEntry Next { get; set; }
|
||||
public DamageEntry Previous { get; set; }
|
||||
public bool OnLinkList { get; set; }
|
||||
}
|
||||
|
||||
[Flags]
|
||||
|
|
@ -377,7 +382,6 @@ public partial class Mobile : IHued, IComparable<Mobile>, ISpawnable, IObjectPro
|
|||
Aggressors = new List<AggressorInfo>();
|
||||
Aggressed = new List<AggressorInfo>();
|
||||
NextSkillTime = Core.TickCount;
|
||||
DamageEntries = new List<DamageEntry>();
|
||||
}
|
||||
|
||||
// Sectors
|
||||
|
|
@ -958,7 +962,23 @@ public partial class Mobile : IHued, IComparable<Mobile>, ISpawnable, IObjectPro
|
|||
|
||||
public static VisibleDamageType VisibleDamageType { get; set; }
|
||||
|
||||
public List<DamageEntry> DamageEntries { get; private set; }
|
||||
private ValueLinkList<DamageEntry> _damageEntries;
|
||||
|
||||
/// <summary>
|
||||
/// Damage entries ordered least recent (head) to most recent (tail). Expired entries are
|
||||
/// pruned on access. Enumerate with <c>foreach</c> (ascending) or <c>.ByDescending()</c>.
|
||||
/// Mutate only through <see cref="RegisterDamage"/> and <see cref="ClearDamageEntries"/>.
|
||||
/// Calling a ValueLinkList mutator on this reference compiles, but operates on a defensive copy
|
||||
/// while still unlinking the real nodes — it silently corrupts the list.
|
||||
/// </summary>
|
||||
public ref readonly ValueLinkList<DamageEntry> DamageEntries
|
||||
{
|
||||
get
|
||||
{
|
||||
PruneExpiredDamageEntries();
|
||||
return ref _damageEntries;
|
||||
}
|
||||
}
|
||||
|
||||
[CommandProperty(AccessLevel.GameMaster)]
|
||||
public Mobile LastKiller { get; set; }
|
||||
|
|
@ -1627,7 +1647,7 @@ public partial class Mobile : IHued, IComparable<Mobile>, ISpawnable, IObjectPro
|
|||
|
||||
public virtual bool KeepsItemsOnDeath => m_AccessLevel > AccessLevel.Player;
|
||||
|
||||
public bool HasTrade => m_NetState?.Trades.Count > 0;
|
||||
public bool HasTrade => m_NetState?.Trades?.Count > 0;
|
||||
|
||||
public bool NoMoveHS { get; set; }
|
||||
|
||||
|
|
@ -2020,10 +2040,7 @@ public partial class Mobile : IHued, IComparable<Mobile>, ISpawnable, IObjectPro
|
|||
Aggressors[i].CanReportMurder = false;
|
||||
}
|
||||
|
||||
if (DamageEntries.Count > 0)
|
||||
{
|
||||
DamageEntries.Clear(); // reset damage entries on full HP
|
||||
}
|
||||
ClearDamageEntries(); // reset damage entries on full HP
|
||||
}
|
||||
else if (CanRegenHits)
|
||||
{
|
||||
|
|
@ -2324,11 +2341,11 @@ public partial class Mobile : IHued, IComparable<Mobile>, ISpawnable, IObjectPro
|
|||
|
||||
public virtual void Serialize(IGenericWriter writer)
|
||||
{
|
||||
writer.Write(37); // version
|
||||
writer.Write(38); // version
|
||||
|
||||
writer.WriteDeltaTime(LastStrGain);
|
||||
writer.WriteDeltaTime(LastIntGain);
|
||||
writer.WriteDeltaTime(LastDexGain);
|
||||
writer.WriteAnchoredTime(LastStrGain);
|
||||
writer.WriteAnchoredTime(LastIntGain);
|
||||
writer.WriteAnchoredTime(LastDexGain);
|
||||
|
||||
byte hairflag = 0x00;
|
||||
|
||||
|
|
@ -5248,8 +5265,15 @@ public partial class Mobile : IHued, IComparable<Mobile>, ISpawnable, IObjectPro
|
|||
item.Name = oldItem.Name;
|
||||
item.Weight = oldItem.Weight;
|
||||
|
||||
item.PlayerConstructed = oldItem.PlayerConstructed;
|
||||
item.Amount = oldAmount - amount;
|
||||
item.Map = oldItem.Map;
|
||||
|
||||
// A parented remainder gets its map from AddItem (parent first, then map), keeping the
|
||||
// split off the decay scheduler; a ground remainder is placed and enrolled here.
|
||||
if (oldItem.Parent == null)
|
||||
{
|
||||
item.Map = oldItem.Map;
|
||||
}
|
||||
|
||||
oldItem.OnAfterDuped(item);
|
||||
|
||||
|
|
@ -5738,24 +5762,54 @@ public partial class Mobile : IHued, IComparable<Mobile>, ISpawnable, IObjectPro
|
|||
}
|
||||
}
|
||||
|
||||
// Entries are kept in LastDamage order, so expired entries are always a head prefix.
|
||||
private void PruneExpiredDamageEntries()
|
||||
{
|
||||
#if DEBUG
|
||||
for (var node = _damageEntries._first; node != null; node = node.Next)
|
||||
{
|
||||
Debug.Assert(
|
||||
node.Next == null || node.Next.LastDamage >= node.LastDamage,
|
||||
"Damage entries must be ordered by LastDamage ascending."
|
||||
);
|
||||
}
|
||||
#endif
|
||||
|
||||
var first = _damageEntries._first;
|
||||
|
||||
if (first?.HasExpired != true)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var firstLive = first.Next;
|
||||
|
||||
while (firstLive?.HasExpired == true)
|
||||
{
|
||||
firstLive = firstLive.Next;
|
||||
}
|
||||
|
||||
if (firstLive == null)
|
||||
{
|
||||
_damageEntries.RemoveAll();
|
||||
}
|
||||
else
|
||||
{
|
||||
_damageEntries.RemoveAllBefore(firstLive);
|
||||
}
|
||||
}
|
||||
|
||||
public void ClearDamageEntries() => _damageEntries.RemoveAll();
|
||||
|
||||
public Mobile FindMostRecentDamager(bool allowSelf) => FindMostRecentDamageEntry(allowSelf)?.Damager;
|
||||
|
||||
public DamageEntry FindMostRecentDamageEntry(bool allowSelf)
|
||||
{
|
||||
for (var i = DamageEntries.Count - 1; i >= 0; --i)
|
||||
PruneExpiredDamageEntries();
|
||||
|
||||
for (var de = _damageEntries._last; de != null; de = de.Previous)
|
||||
{
|
||||
if (i >= DamageEntries.Count)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var de = DamageEntries[i];
|
||||
|
||||
if (de.HasExpired)
|
||||
{
|
||||
DamageEntries.RemoveAt(i);
|
||||
}
|
||||
else if (allowSelf || de.Damager != this)
|
||||
if (allowSelf || de.Damager != this)
|
||||
{
|
||||
return de;
|
||||
}
|
||||
|
|
@ -5768,21 +5822,11 @@ public partial class Mobile : IHued, IComparable<Mobile>, ISpawnable, IObjectPro
|
|||
|
||||
public DamageEntry FindLeastRecentDamageEntry(bool allowSelf)
|
||||
{
|
||||
for (var i = 0; i < DamageEntries.Count; ++i)
|
||||
PruneExpiredDamageEntries();
|
||||
|
||||
for (var de = _damageEntries._first; de != null; de = de.Next)
|
||||
{
|
||||
if (i < 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var de = DamageEntries[i];
|
||||
|
||||
if (de.HasExpired)
|
||||
{
|
||||
DamageEntries.RemoveAt(i);
|
||||
--i;
|
||||
}
|
||||
else if (allowSelf || de.Damager != this)
|
||||
if (allowSelf || de.Damager != this)
|
||||
{
|
||||
return de;
|
||||
}
|
||||
|
|
@ -5793,24 +5837,17 @@ public partial class Mobile : IHued, IComparable<Mobile>, ISpawnable, IObjectPro
|
|||
|
||||
public Mobile FindMostTotalDamager(bool allowSelf) => FindMostTotalDamageEntry(allowSelf)?.Damager;
|
||||
|
||||
// Walks most recent first with a strict comparison so the most recent entry wins ties,
|
||||
// matching the previous reverse-indexed loop.
|
||||
public DamageEntry FindMostTotalDamageEntry(bool allowSelf)
|
||||
{
|
||||
PruneExpiredDamageEntries();
|
||||
|
||||
DamageEntry mostTotal = null;
|
||||
|
||||
for (var i = DamageEntries.Count - 1; i >= 0; --i)
|
||||
for (var de = _damageEntries._last; de != null; de = de.Previous)
|
||||
{
|
||||
if (i >= DamageEntries.Count)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var de = DamageEntries[i];
|
||||
|
||||
if (de.HasExpired)
|
||||
{
|
||||
DamageEntries.RemoveAt(i);
|
||||
}
|
||||
else if ((allowSelf || de.Damager != this) && (mostTotal == null || de.DamageGiven > mostTotal.DamageGiven))
|
||||
if ((allowSelf || de.Damager != this) && (mostTotal == null || de.DamageGiven > mostTotal.DamageGiven))
|
||||
{
|
||||
mostTotal = de;
|
||||
}
|
||||
|
|
@ -5823,46 +5860,28 @@ public partial class Mobile : IHued, IComparable<Mobile>, ISpawnable, IObjectPro
|
|||
|
||||
public DamageEntry FindLeastTotalDamageEntry(bool allowSelf)
|
||||
{
|
||||
DamageEntry mostTotal = null;
|
||||
PruneExpiredDamageEntries();
|
||||
|
||||
for (var i = DamageEntries.Count - 1; i >= 0; --i)
|
||||
DamageEntry leastTotal = null;
|
||||
|
||||
for (var de = _damageEntries._last; de != null; de = de.Previous)
|
||||
{
|
||||
if (i >= DamageEntries.Count)
|
||||
if ((allowSelf || de.Damager != this) && (leastTotal == null || de.DamageGiven < leastTotal.DamageGiven))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var de = DamageEntries[i];
|
||||
|
||||
if (de.HasExpired)
|
||||
{
|
||||
DamageEntries.RemoveAt(i);
|
||||
}
|
||||
else if ((allowSelf || de.Damager != this) && (mostTotal == null || de.DamageGiven < mostTotal.DamageGiven))
|
||||
{
|
||||
mostTotal = de;
|
||||
leastTotal = de;
|
||||
}
|
||||
}
|
||||
|
||||
return mostTotal;
|
||||
return leastTotal;
|
||||
}
|
||||
|
||||
public DamageEntry FindDamageEntryFor(Mobile m)
|
||||
{
|
||||
for (var i = DamageEntries.Count - 1; i >= 0; --i)
|
||||
PruneExpiredDamageEntries();
|
||||
|
||||
for (var de = _damageEntries._last; de != null; de = de.Previous)
|
||||
{
|
||||
if (i >= DamageEntries.Count)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var de = DamageEntries[i];
|
||||
|
||||
if (de.HasExpired)
|
||||
{
|
||||
DamageEntries.RemoveAt(i);
|
||||
}
|
||||
else if (de.Damager == m)
|
||||
if (de.Damager == m)
|
||||
{
|
||||
return de;
|
||||
}
|
||||
|
|
@ -5880,8 +5899,13 @@ public partial class Mobile : IHued, IComparable<Mobile>, ISpawnable, IObjectPro
|
|||
de.DamageGiven += amount;
|
||||
de.LastDamage = Core.Now;
|
||||
|
||||
DamageEntries.Remove(de);
|
||||
DamageEntries.Add(de);
|
||||
// Move to the tail so the list stays in LastDamage order.
|
||||
if (de.OnLinkList)
|
||||
{
|
||||
_damageEntries.Remove(de);
|
||||
}
|
||||
|
||||
_damageEntries.AddLast(de);
|
||||
|
||||
var master = from.GetDamageMaster(this);
|
||||
|
||||
|
|
@ -6143,6 +6167,7 @@ public partial class Mobile : IHued, IComparable<Mobile>, 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
|
||||
|
|
@ -6151,9 +6176,18 @@ public partial class Mobile : IHued, IComparable<Mobile>, ISpawnable, IObjectPro
|
|||
case 32: // Removed StuckMenu
|
||||
case 31:
|
||||
{
|
||||
LastStrGain = reader.ReadDeltaTime();
|
||||
LastIntGain = reader.ReadDeltaTime();
|
||||
LastDexGain = reader.ReadDeltaTime();
|
||||
if (version >= 38)
|
||||
{
|
||||
LastStrGain = reader.ReadAnchoredTime();
|
||||
LastIntGain = reader.ReadAnchoredTime();
|
||||
LastDexGain = reader.ReadAnchoredTime();
|
||||
}
|
||||
else
|
||||
{
|
||||
LastStrGain = reader.ReadDeltaTime();
|
||||
LastIntGain = reader.ReadDeltaTime();
|
||||
LastDexGain = reader.ReadDeltaTime();
|
||||
}
|
||||
|
||||
goto case 30;
|
||||
}
|
||||
|
|
@ -6461,9 +6495,6 @@ public partial class Mobile : IHued, IComparable<Mobile>, ISpawnable, IObjectPro
|
|||
m_DexLock = (StatLockType)reader.ReadByte();
|
||||
m_IntLock = (StatLockType)reader.ReadByte();
|
||||
|
||||
_statMods = new List<StatMod>();
|
||||
_skillMods = new List<SkillMod>();
|
||||
|
||||
if (version < 32)
|
||||
{
|
||||
if (reader.ReadBool())
|
||||
|
|
@ -7796,13 +7827,10 @@ public partial class Mobile : IHued, IComparable<Mobile>, ISpawnable, IObjectPro
|
|||
m_FollowersMax = 5;
|
||||
Skills = new Skills(this);
|
||||
Items = new List<Item>();
|
||||
_statMods = new List<StatMod>();
|
||||
_skillMods = new List<SkillMod>();
|
||||
Map = Map.Internal;
|
||||
AutoPageNotify = true;
|
||||
Aggressors = new List<AggressorInfo>();
|
||||
Aggressed = new List<AggressorInfo>();
|
||||
DamageEntries = new List<DamageEntry>();
|
||||
|
||||
NextSkillTime = Core.TickCount;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,17 +21,15 @@ namespace Server;
|
|||
[SerializationGenerator(0)]
|
||||
public partial class ResistanceMod : MobileMod
|
||||
{
|
||||
[SerializableField(0)]
|
||||
[SerializableField(0, fieldChanged: nameof(OnTypeChanged))]
|
||||
private ResistanceType _type;
|
||||
|
||||
[SerializableFieldChanged(0)]
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
private void OnTypeChanged(ResistanceType oldValue, ResistanceType newValue) => Owner?.UpdateResistances();
|
||||
|
||||
[SerializableField(1)]
|
||||
[SerializableField(1, fieldChanged: nameof(OnOffsetChanged))]
|
||||
private int _offset;
|
||||
|
||||
[SerializableFieldChanged(1)]
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
private void OnOffsetChanged(int oldValue, int newValue) => Owner?.UpdateResistances();
|
||||
|
||||
|
|
|
|||
|
|
@ -21,33 +21,29 @@ namespace Server;
|
|||
[SerializationGenerator(0)]
|
||||
public abstract partial class SkillMod : MobileMod
|
||||
{
|
||||
[SerializableField(0)]
|
||||
[SerializableField(0, fieldChanged: nameof(OnObeyCapChanged))]
|
||||
private bool _obeyCap;
|
||||
|
||||
[SerializableFieldChanged(0)]
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
private void OnObeCapChanged(bool oldValue, bool newValue) => Owner?.Skills[_skill]?.Update();
|
||||
private void OnObeyCapChanged(bool oldValue, bool newValue) => Owner?.Skills[_skill]?.Update();
|
||||
|
||||
[SerializableField(1)]
|
||||
[SerializableField(1, fieldChanged: nameof(OnSkillChanged))]
|
||||
private SkillName _skill;
|
||||
|
||||
[SerializableFieldChanged(1)]
|
||||
private void OnSkillChanged(SkillName oldValue, SkillName newValue)
|
||||
{
|
||||
Owner?.Skills[newValue]?.Update();
|
||||
Owner?.Skills[oldValue]?.Update();
|
||||
}
|
||||
|
||||
[SerializableField(2)]
|
||||
[SerializableField(2, fieldChanged: nameof(OnRelativeChanged))]
|
||||
private bool _relative;
|
||||
|
||||
[SerializableFieldChanged(2)]
|
||||
private void OnRelativeChanged(bool oldValue, bool newValue) => Owner?.Skills[_skill]?.Update();
|
||||
|
||||
[SerializableField(3)]
|
||||
[SerializableField(3, fieldChanged: nameof(OnValueChanged))]
|
||||
private double _value;
|
||||
|
||||
[SerializableFieldChanged(3)]
|
||||
private void OnValueChanged(double oldValue, double newValue) => Owner?.Skills[_skill]?.Update();
|
||||
|
||||
public SkillMod(Mobile owner) : base(owner)
|
||||
|
|
|
|||
|
|
@ -50,9 +50,6 @@ public static class MovementThrottle
|
|||
private const int ClientMaxUnackedMovements = 5;
|
||||
private const int MaxQueueWithUnmodifiedClient = ClientMaxUnackedMovements - 1; // 4
|
||||
|
||||
// Debug logging - enable for testing speed hack detection
|
||||
private static bool _debugLogging = false;
|
||||
|
||||
// Track NetStates with queued movements for efficient processing
|
||||
private static readonly HashSet<NetState> _netStatesWithQueuedMovements = new(256);
|
||||
|
||||
|
|
@ -83,15 +80,9 @@ public static class MovementThrottle
|
|||
|
||||
public static void Configure()
|
||||
{
|
||||
_maxCredit = ServerConfiguration.GetOrUpdateSetting(
|
||||
"movementThrottle.maxCredit",
|
||||
_maxCredit
|
||||
);
|
||||
|
||||
_hardQueueLimit = ServerConfiguration.GetOrUpdateSetting(
|
||||
"movementThrottle.hardQueueLimit",
|
||||
_hardQueueLimit
|
||||
);
|
||||
_maxCredit = ServerConfiguration.GetOrUpdateSetting("movementThrottle.maxCredit", _maxCredit);
|
||||
_maxRttBonus = ServerConfiguration.GetOrUpdateSetting("movementThrottle.maxRttBonus", _maxRttBonus);
|
||||
_hardQueueLimit = ServerConfiguration.GetOrUpdateSetting("movementThrottle.hardQueueLimit", _hardQueueLimit);
|
||||
|
||||
_movementHistorySize = ServerConfiguration.GetOrUpdateSetting(
|
||||
"movementThrottle.movementHistorySize",
|
||||
|
|
@ -103,6 +94,13 @@ public static class MovementThrottle
|
|||
_minSamplesForRate
|
||||
);
|
||||
|
||||
_maxChainGap = ServerConfiguration.GetOrUpdateSetting("movementThrottle.maxChainGap", _maxChainGap);
|
||||
|
||||
_speedHackNotificationCooldown = ServerConfiguration.GetOrUpdateSetting(
|
||||
"movementThrottle.speedHackNotificationCooldown",
|
||||
_speedHackNotificationCooldown
|
||||
);
|
||||
|
||||
_suspiciousRateThreshold = (float)ServerConfiguration.GetOrUpdateSetting(
|
||||
"movementThrottle.suspiciousRateThreshold",
|
||||
_suspiciousRateThreshold
|
||||
|
|
@ -112,11 +110,6 @@ public static class MovementThrottle
|
|||
"movementThrottle.definiteRateThreshold",
|
||||
_definiteRateThreshold
|
||||
);
|
||||
|
||||
_debugLogging = ServerConfiguration.GetOrUpdateSetting(
|
||||
"movementThrottle.debugLogging",
|
||||
_debugLogging
|
||||
);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -191,15 +184,16 @@ public static class MovementThrottle
|
|||
// Credit can go negative up to -dynamicCredit (debt limit)
|
||||
if (ns._movementCredit - earlyAmount >= -dynamicCredit)
|
||||
{
|
||||
var prevCredit = ns._movementCredit;
|
||||
// Use credit to cover early arrival
|
||||
ns._movementCredit -= earlyAmount;
|
||||
|
||||
if (_debugLogging && ns._movementLogging)
|
||||
if (ns._movementLogging)
|
||||
{
|
||||
var prevCredit = ns._movementCredit + earlyAmount;
|
||||
|
||||
logger.Debug(
|
||||
"[Credit] {Name}: delta={Delta}ms early={Early}ms credit={PrevCredit}->{Credit}/{MaxCredit} action=execute",
|
||||
mobile.RawName, delta, earlyAmount, prevCredit, ns._movementCredit, dynamicCredit
|
||||
mobile, delta, earlyAmount, prevCredit, ns._movementCredit, dynamicCredit
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -208,11 +202,11 @@ public static class MovementThrottle
|
|||
return;
|
||||
}
|
||||
|
||||
if (_debugLogging && ns._movementLogging)
|
||||
if (ns._movementLogging)
|
||||
{
|
||||
logger.Debug(
|
||||
"[Credit] {Name}: delta={Delta}ms early={Early}ms credit={Credit}/{MaxCredit} EXHAUSTED -> queue",
|
||||
mobile.RawName, delta, earlyAmount, ns._movementCredit, dynamicCredit
|
||||
mobile, delta, earlyAmount, ns._movementCredit, dynamicCredit
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -227,11 +221,11 @@ public static class MovementThrottle
|
|||
var prevCredit = ns._movementCredit;
|
||||
ns._movementCredit = Math.Min(ns._movementCredit + delta, dynamicCredit);
|
||||
|
||||
if (_debugLogging && ns._movementLogging && ns._movementCredit != prevCredit)
|
||||
if (ns._movementLogging && ns._movementCredit != prevCredit)
|
||||
{
|
||||
logger.Debug(
|
||||
"[Credit] {Name}: delta=+{Delta}ms credit={PrevCredit}->{Credit}/{MaxCredit} action=execute",
|
||||
mobile.RawName, delta, prevCredit, ns._movementCredit, dynamicCredit
|
||||
mobile, delta, prevCredit, ns._movementCredit, dynamicCredit
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -247,12 +241,9 @@ public static class MovementThrottle
|
|||
{
|
||||
if (!mobile.Move(dir))
|
||||
{
|
||||
if (_debugLogging && ns._movementLogging)
|
||||
if (ns._movementLogging)
|
||||
{
|
||||
logger.Debug(
|
||||
"[Execute] {Name}: Move FAILED dir={Dir} seq={Seq} -> reject+reset",
|
||||
mobile.RawName, dir, seq
|
||||
);
|
||||
logger.Debug("[Execute] {Name}: Move FAILED dir={Dir} seq={Seq} -> reject+reset", mobile, dir, seq);
|
||||
}
|
||||
|
||||
// Movement failed (blocked, paralyzed, frozen, etc.)
|
||||
|
|
@ -260,11 +251,11 @@ public static class MovementThrottle
|
|||
return;
|
||||
}
|
||||
|
||||
if (_debugLogging && ns._movementLogging)
|
||||
if (ns._movementLogging)
|
||||
{
|
||||
logger.Debug(
|
||||
"[Execute] {Name}: Move OK dir={Dir} seq={Seq} nextMove={NextMove}ms",
|
||||
mobile.RawName, dir, seq, ns._nextMovementTime - Core.TickCount
|
||||
mobile, dir, seq, ns._nextMovementTime - Core.TickCount
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -304,11 +295,11 @@ public static class MovementThrottle
|
|||
ns._hasQueuedMovements = true;
|
||||
_netStatesWithQueuedMovements.Add(ns);
|
||||
|
||||
if (_debugLogging && ns._movementLogging)
|
||||
if (ns._movementLogging)
|
||||
{
|
||||
logger.Debug(
|
||||
"[Queue] {Name}: enqueued dir={Dir} seq={Seq} (depth={Depth})",
|
||||
ns.Mobile?.RawName, dir, seq, ns._movementQueue.Count
|
||||
ns.Mobile, dir, seq, ns._movementQueue.Count
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -320,7 +311,6 @@ public static class MovementThrottle
|
|||
{
|
||||
ns.SendMovementRej(seq, mobile);
|
||||
ns.ResetMovementState();
|
||||
_netStatesWithQueuedMovements.Remove(ns);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -333,20 +323,18 @@ public static class MovementThrottle
|
|||
return;
|
||||
}
|
||||
|
||||
// Process each NetState with queued movements
|
||||
// Use a snapshot to avoid modification during iteration
|
||||
var toProcess = new List<NetState>(_netStatesWithQueuedMovements);
|
||||
|
||||
for (var i = 0; i < toProcess.Count; i++)
|
||||
foreach (var ns in _netStatesWithQueuedMovements)
|
||||
{
|
||||
var ns = toProcess[i];
|
||||
if (!ns.Running)
|
||||
if (ns.Running)
|
||||
{
|
||||
_netStatesWithQueuedMovements.Remove(ns);
|
||||
continue;
|
||||
ProcessMovementQueue(ns);
|
||||
if (ns._hasQueuedMovements)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
ProcessMovementQueue(ns);
|
||||
_netStatesWithQueuedMovements.Remove(ns);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -356,6 +344,7 @@ public static class MovementThrottle
|
|||
public static void ProcessMovementQueue(NetState ns)
|
||||
{
|
||||
var mobile = ns.Mobile;
|
||||
|
||||
if (mobile?.Deleted != false)
|
||||
{
|
||||
ClearQueue(ns);
|
||||
|
|
@ -374,7 +363,7 @@ public static class MovementThrottle
|
|||
while (ns._movementQueue?.Count > 0)
|
||||
{
|
||||
// Check if it's time to execute
|
||||
if (now < ns._nextMovementTime)
|
||||
if (now - ns._nextMovementTime < 0)
|
||||
{
|
||||
// Not yet - leave remaining items in queue for next Slice
|
||||
break;
|
||||
|
|
@ -394,11 +383,11 @@ public static class MovementThrottle
|
|||
// Execute the move
|
||||
if (!mobile.Move(movement.Direction))
|
||||
{
|
||||
if (_debugLogging && ns._movementLogging)
|
||||
if (ns._movementLogging)
|
||||
{
|
||||
logger.Debug(
|
||||
"[Queue] {Name}: dequeued FAILED dir={Dir} (remaining={Remaining})",
|
||||
mobile.RawName, movement.Direction, remaining
|
||||
mobile, movement.Direction, remaining
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -407,12 +396,12 @@ public static class MovementThrottle
|
|||
return;
|
||||
}
|
||||
|
||||
if (_debugLogging && ns._movementLogging)
|
||||
if (ns._movementLogging)
|
||||
{
|
||||
var waited = now - ns._nextMovementTime;
|
||||
logger.Debug(
|
||||
"[Queue] {Name}: dequeued OK dir={Dir} (remaining={Remaining}, waited={Waited}ms)",
|
||||
mobile.RawName, movement.Direction, remaining, waited >= 0 ? waited : 0
|
||||
mobile, movement.Direction, remaining, waited >= 0 ? waited : 0
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -430,10 +419,6 @@ public static class MovementThrottle
|
|||
|
||||
// Update tracking
|
||||
ns._hasQueuedMovements = ns._movementQueue?.Count > 0;
|
||||
if (!ns._hasQueuedMovements)
|
||||
{
|
||||
_netStatesWithQueuedMovements.Remove(ns);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -469,7 +454,6 @@ public static class MovementThrottle
|
|||
{
|
||||
ns._movementQueue?.Clear();
|
||||
ns._hasQueuedMovements = false;
|
||||
_netStatesWithQueuedMovements.Remove(ns);
|
||||
}
|
||||
|
||||
// Maximum expected packets per second (mounted running = 100ms = 10/sec, plus tolerance)
|
||||
|
|
@ -484,7 +468,7 @@ public static class MovementThrottle
|
|||
logger.Information(
|
||||
"Movement queue overflow: {Character} ({Account}) | " +
|
||||
"Queue reached hard limit: {Limit} | IP: {IP}",
|
||||
mobile?.RawName ?? "Unknown",
|
||||
mobile,
|
||||
ns.Account?.Username ?? "Unknown",
|
||||
_hardQueueLimit,
|
||||
ns.Address
|
||||
|
|
@ -516,7 +500,7 @@ public static class MovementThrottle
|
|||
private static void RecordMovement(NetState ns, long now, int cost, Direction dir, Mobile mobile)
|
||||
{
|
||||
// Calculate interval since last movement
|
||||
var interval = ns._lastMovementRecordTime > 0
|
||||
var interval = ns._hasMovementRecord
|
||||
? (int)(now - ns._lastMovementRecordTime)
|
||||
: -1; // -1 indicates first movement (no previous time)
|
||||
|
||||
|
|
@ -525,6 +509,7 @@ public static class MovementThrottle
|
|||
if (interval <= 0 || interval > _maxChainGap)
|
||||
{
|
||||
ns._lastMovementRecordTime = now;
|
||||
ns._hasMovementRecord = true;
|
||||
|
||||
// Use RTT to distinguish "stopped moving" vs "lagged"
|
||||
// - Stable low-latency connection with gap >> RTT → player stopped, reset history
|
||||
|
|
@ -544,19 +529,19 @@ public static class MovementThrottle
|
|||
// A large gap followed by a burst of packets = likely lag recovery, not speed hack
|
||||
ns._lastGapDuration = interval;
|
||||
|
||||
if (_debugLogging && mobile?.RawName != null)
|
||||
if (ns._movementLogging)
|
||||
{
|
||||
var action = shouldReset ? "history reset" : "history preserved (possible lag)";
|
||||
logger.Debug(
|
||||
"[Movement] {Name}: SKIP recording (gap {Gap}ms > {MaxGap}ms, " +
|
||||
"RTT={RTT}ms stable={Stable} → {Action})",
|
||||
mobile.RawName, interval, _maxChainGap, avgRtt, ns.HasStableConnection, action
|
||||
mobile, interval, _maxChainGap, avgRtt, ns.HasStableConnection, action
|
||||
);
|
||||
}
|
||||
}
|
||||
else if (_debugLogging && mobile?.RawName != null)
|
||||
else if (ns._movementLogging)
|
||||
{
|
||||
logger.Debug("[Movement] {Name}: SKIP recording (first in chain)", mobile.RawName);
|
||||
logger.Debug("[Movement] {Name}: SKIP recording (first in chain)", mobile);
|
||||
}
|
||||
|
||||
return;
|
||||
|
|
@ -572,12 +557,9 @@ public static class MovementThrottle
|
|||
// the next real move's interval artificially short, inflating rate.
|
||||
if (cost == 0)
|
||||
{
|
||||
if (_debugLogging && mobile?.RawName != null)
|
||||
if (ns._movementLogging)
|
||||
{
|
||||
logger.Debug(
|
||||
"[Movement] {Name}: SKIP direction-only change (preserves interval measurement)",
|
||||
mobile.RawName
|
||||
);
|
||||
logger.Debug("[Movement] {Name}: SKIP direction-only change (preserves interval measurement)", mobile);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
|
@ -613,15 +595,16 @@ public static class MovementThrottle
|
|||
}
|
||||
|
||||
ns._lastMovementRecordTime = now;
|
||||
ns._hasMovementRecord = true;
|
||||
|
||||
// Debug logging
|
||||
if (_debugLogging && mobile?.RawName != null)
|
||||
if (ns._movementLogging)
|
||||
{
|
||||
var historyCount = ns._movementHistoryFull ? _movementHistorySize : ns._movementHistoryIndex;
|
||||
logger.Debug(
|
||||
"[Movement] {Name}: interval={Interval}ms target={Target}ms queue={Queue} " +
|
||||
"flags={Flags} history={History}/{MaxHistory} RTT={RTT}ms",
|
||||
mobile.RawName, interval, cost, record.QueueDepth,
|
||||
mobile, interval, cost, record.QueueDepth,
|
||||
flags, historyCount, _movementHistorySize, ns.AverageRtt
|
||||
);
|
||||
}
|
||||
|
|
@ -814,7 +797,7 @@ public static class MovementThrottle
|
|||
var averageRtt = ns.AverageRtt;
|
||||
|
||||
// Detailed rate breakdown for debugging
|
||||
if (_debugLogging)
|
||||
if (ns._movementLogging)
|
||||
{
|
||||
logger.Debug("[MovementAnalysis] Rate={Rate:F3}, Samples={Samples}, RTT={RTT}ms",
|
||||
rate, sampleCount, averageRtt);
|
||||
|
|
@ -977,19 +960,19 @@ public static class MovementThrottle
|
|||
var verdict = AnalyzeMovement(ns, out var rate, out var sampleCount, out var confidence);
|
||||
|
||||
// Debug logging
|
||||
if (_debugLogging && ns.Mobile?.RawName != null)
|
||||
if (ns._movementLogging)
|
||||
{
|
||||
var (burstSize, _) = DetectRecentBurst(ns);
|
||||
var probeStatus = ns._rttProbeTime > 0 ? "pending" : "idle";
|
||||
var probeStatus = ns._rttProbePending ? "pending" : "idle";
|
||||
var queueDepth = ns._movementQueue?.Count ?? 0;
|
||||
logger.Debug(
|
||||
"[RateCheck] {Name}: rate={Rate:F3} samples={Samples} verdict={Verdict} " +
|
||||
"confidence={Confidence:P0} queue={Queue} burst={Burst} sustained={Sustained}s",
|
||||
ns.Mobile.RawName, rate, sampleCount, verdict, confidence, queueDepth, burstSize, ns._consecutiveHighRateSeconds
|
||||
ns.Mobile, rate, sampleCount, verdict, confidence, queueDepth, burstSize, ns._consecutiveHighRateSeconds
|
||||
);
|
||||
logger.Debug(
|
||||
" RTT: avg={Avg}ms last={Last}ms var={Var} samples={RttSamples} stable={Stable} probe={Probe}",
|
||||
ns.AverageRtt, ns._lastRtt, ns._rttVariance, ns._rttSampleCount, ns.HasStableConnection, probeStatus
|
||||
ns.AverageRtt, ns.LastRtt, ns._rttVariance, ns._rttSampleCount, ns.HasStableConnection, probeStatus
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -1025,11 +1008,11 @@ public static class MovementThrottle
|
|||
|
||||
if (shouldNotify)
|
||||
{
|
||||
if (_debugLogging)
|
||||
if (ns._movementLogging)
|
||||
{
|
||||
logger.Debug(
|
||||
"[ALERT] {Urgency} - {Name}: rate={Rate:F3} verdict={Verdict} confidence={Confidence:P0}",
|
||||
urgency, ns.Mobile?.RawName, rate, verdict, confidence
|
||||
urgency, ns.Mobile, rate, verdict, confidence
|
||||
);
|
||||
}
|
||||
NotifyStaff(ns, rate, sampleCount, confidence, verdict, urgency);
|
||||
|
|
@ -1054,11 +1037,12 @@ public static class MovementThrottle
|
|||
var now = Core.TickCount;
|
||||
|
||||
// Rate-limit notifications per player
|
||||
if (now - ns._lastSpeedHackNotification < _speedHackNotificationCooldown)
|
||||
if (ns._speedHackNotified && now - ns._lastSpeedHackNotification < _speedHackNotificationCooldown)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
ns._speedHackNotified = true;
|
||||
ns._lastSpeedHackNotification = now;
|
||||
|
||||
var mobile = ns.Mobile;
|
||||
|
|
@ -1070,7 +1054,7 @@ public static class MovementThrottle
|
|||
"PacketRate: {PacketRate}/s (peak: {PeakRate}/s) | RTT: {Rtt}ms (stable: {Stable}) | " +
|
||||
"Sustained: {Sustained}s | Queue: {Queue} | Location: {Location} Map: {Map} | IP: {IP}",
|
||||
urgency,
|
||||
mobile?.RawName ?? "Unknown",
|
||||
mobile,
|
||||
ns.Account?.Username ?? "Unknown",
|
||||
rate,
|
||||
sampleCount,
|
||||
|
|
@ -1138,7 +1122,7 @@ public static class MovementThrottle
|
|||
Verdict = verdict,
|
||||
Confidence = confidence,
|
||||
AverageRtt = ns.AverageRtt,
|
||||
LastRtt = ns._lastRtt,
|
||||
LastRtt = ns.LastRtt,
|
||||
RttVariance = ns._rttVariance,
|
||||
StableConnection = ns.HasStableConnection,
|
||||
RttSampleCount = ns._rttSampleCount,
|
||||
|
|
|
|||
|
|
@ -15,7 +15,6 @@
|
|||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Runtime.InteropServices;
|
||||
using Server.Logging;
|
||||
|
||||
|
|
@ -70,23 +69,24 @@ public partial class NetState
|
|||
internal Queue<QueuedMovement> _movementQueue; // Lazy initialized
|
||||
internal long _movementCredit; // Credit buffer for timing jitter
|
||||
internal long _nextMovementTime = Core.TickCount; // When next movement is allowed
|
||||
internal int _sustainedQueueDepth; // Tracks sustained high queue depth
|
||||
internal long _lastQueueDepthCheck; // Throttle depth check frequency
|
||||
internal long _lastQueueDepthCheck = Core.TickCount; // Throttle depth check frequency
|
||||
internal bool _hasQueuedMovements; // Fast check for Slice()
|
||||
|
||||
// Movement history for rate-based speed hack detection (lazy initialized)
|
||||
internal MovementRecord[] _movementHistory; // Circular buffer
|
||||
internal int _movementHistoryIndex; // Next write position (also serves as count until full)
|
||||
internal bool _movementHistoryFull; // True once buffer has wrapped
|
||||
internal long _lastMovementRecordTime; // For calculating intervals
|
||||
internal long _lastMovementRecordTime; // For calculating intervals (valid only when _hasMovementRecord)
|
||||
internal bool _hasMovementRecord; // False until the first movement in a chain is seen
|
||||
|
||||
// Detection state
|
||||
internal int _consecutiveHighRateSeconds; // Sustained detection counter
|
||||
internal long _lastSpeedHackNotification; // Rate-limit notifications
|
||||
internal long _lastSpeedHackNotification; // Rate-limit notifications (valid only when _speedHackNotified)
|
||||
internal bool _speedHackNotified; // False until the first notification is sent
|
||||
internal int _lastGapDuration; // Duration of last gap > maxChainGap (for burst forgiveness)
|
||||
|
||||
// Movement packet rate tracking (for speed hack detection)
|
||||
internal long _movementWindowStart; // Start of current 1-second window
|
||||
internal long _movementWindowStart = Core.TickCount; // Start of current 1-second window
|
||||
internal int _movementsInWindow; // Count in current window
|
||||
internal int _peakMovementRate; // Highest rate seen (packets/sec)
|
||||
|
||||
|
|
@ -100,10 +100,9 @@ public partial class NetState
|
|||
_nextMovementTime = Core.TickCount;
|
||||
_movementCredit = 0;
|
||||
_hasQueuedMovements = false;
|
||||
_sustainedQueueDepth = 0;
|
||||
|
||||
// Reset movement history - next movement starts a new chain
|
||||
_lastMovementRecordTime = 0;
|
||||
_hasMovementRecord = false;
|
||||
_movementHistoryIndex = 0;
|
||||
_movementHistoryFull = false;
|
||||
|
||||
|
|
@ -113,7 +112,7 @@ public partial class NetState
|
|||
_rttProbeInterval = RttProbeIntervalNormal;
|
||||
|
||||
// Reset packet rate window
|
||||
_movementWindowStart = 0;
|
||||
_movementWindowStart = Core.TickCount;
|
||||
_movementsInWindow = 0;
|
||||
}
|
||||
|
||||
|
|
@ -165,17 +164,19 @@ public partial class NetState
|
|||
private const long MaxStableLatency = 200; // Max RTT (ms) for "stable" connection
|
||||
|
||||
// RTT state
|
||||
internal long _rttProbeTime; // When we sent the probe (0 = not waiting)
|
||||
internal long _lastRtt; // Most recent RTT measurement
|
||||
internal bool _rttProbePending; // True while waiting for a probe response
|
||||
internal long _rttProbeTime; // When we sent the probe (valid only when _rttProbePending)
|
||||
internal long[] _rttHistory; // Rolling history (lazy init)
|
||||
internal int _rttHistoryIndex; // Current position in history
|
||||
internal int _rttSampleCount; // Number of samples collected (saturates at RttHistorySize)
|
||||
internal long _rttVariance; // Calculated variance for stability
|
||||
internal long _nextRttProbe; // When to send next probe
|
||||
internal long _nextRttProbe = Core.TickCount; // When to send next probe
|
||||
internal int _rttProbeInterval = RttProbeIntervalNormal; // Current probe interval
|
||||
|
||||
// High-resolution timestamp for RTT measurement (Stopwatch ticks, not game loop ticks)
|
||||
private long _rttProbeTimestampHiRes;
|
||||
/// <summary>
|
||||
/// Gets the most recent RTT measurement, or 0 if none has been recorded.
|
||||
/// </summary>
|
||||
public long LastRtt => _rttSampleCount > 0 ? _rttHistory[(_rttHistoryIndex - 1) & (RttHistorySize - 1)] : 0;
|
||||
|
||||
/// <summary>
|
||||
/// Sets the RTT probe interval based on suspicion level.
|
||||
|
|
@ -206,23 +207,22 @@ public partial class NetState
|
|||
var now = Core.TickCount;
|
||||
|
||||
// Don't send if we're still waiting for a response
|
||||
if (_rttProbeTime > 0)
|
||||
if (_rttProbePending)
|
||||
{
|
||||
// Timeout after 10 seconds - connection is probably dead or very laggy
|
||||
if (now - _rttProbeTime > 10000)
|
||||
{
|
||||
_rttProbeTime = 0;
|
||||
_rttProbeTimestampHiRes = 0;
|
||||
_rttProbePending = false;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// First probe: send immediately when player starts moving
|
||||
// Subsequent probes: send when interval has passed
|
||||
if (_nextRttProbe == 0 || now >= _nextRttProbe)
|
||||
if (now - _nextRttProbe >= 0)
|
||||
{
|
||||
_rttProbePending = true;
|
||||
_rttProbeTime = now;
|
||||
_rttProbeTimestampHiRes = Stopwatch.GetTimestamp();
|
||||
_nextRttProbe = now + _rttProbeInterval + Utility.Random(RttProbeJitter);
|
||||
|
||||
if (_movementLogging)
|
||||
|
|
@ -242,10 +242,9 @@ public partial class NetState
|
|||
/// </summary>
|
||||
public void RecordRttMeasurement()
|
||||
{
|
||||
var nowHiRes = Stopwatch.GetTimestamp();
|
||||
var now = Core.TickCount;
|
||||
|
||||
if (_rttProbeTime <= 0)
|
||||
if (!_rttProbePending)
|
||||
{
|
||||
// Not expecting a response (client-initiated version send) - ignore silently
|
||||
return;
|
||||
|
|
@ -253,19 +252,15 @@ public partial class NetState
|
|||
|
||||
var rtt = now - _rttProbeTime;
|
||||
|
||||
// High-resolution RTT in microseconds
|
||||
var rttHiResUs = (nowHiRes - _rttProbeTimestampHiRes) * 1_000_000 / Stopwatch.Frequency;
|
||||
|
||||
if (_movementLogging)
|
||||
{
|
||||
movementLogger.Debug(
|
||||
"[RTT-Response] {Account}: {Rtt}ms (HiRes: {RttHiRes:F2}ms)",
|
||||
Account?.Username ?? _toString, rtt, rttHiResUs / 1000.0
|
||||
"[RTT-Response] {Account}: {Rtt}ms",
|
||||
Account?.Username ?? _toString, rtt
|
||||
);
|
||||
}
|
||||
|
||||
_rttProbeTime = 0;
|
||||
_rttProbeTimestampHiRes = 0;
|
||||
_rttProbePending = false;
|
||||
|
||||
// Sanity check - RTT should be positive and reasonable
|
||||
if (rtt is <= 0 or > 10000)
|
||||
|
|
@ -285,7 +280,6 @@ public partial class NetState
|
|||
|
||||
// Update history
|
||||
_rttHistory[_rttHistoryIndex++ & (RttHistorySize - 1)] = rtt;
|
||||
_lastRtt = rtt;
|
||||
|
||||
// Track sample count (saturates at buffer size)
|
||||
if (_rttSampleCount < RttHistorySize)
|
||||
|
|
|
|||
|
|
@ -87,7 +87,7 @@ public partial class NetState
|
|||
/// </summary>
|
||||
internal static bool IsIdle =>
|
||||
_throttled.Count == 0 && _throttledPending.Count == 0 &&
|
||||
_flushPending.Count == 0 && _disposed.Count == 0;
|
||||
_flushPending.Count == 0 && _pendingDisconnects.Count == 0 && _disposed.Count == 0;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the listening addresses that the server is bound to.
|
||||
|
|
|
|||
|
|
@ -44,7 +44,7 @@ public partial class NetState : IComparable<NetState>, IValueLinkListNode<NetSta
|
|||
|
||||
private static readonly Queue<NetState> _connectingQueue = new(2048);
|
||||
private static readonly HashSet<NetState> _instances = new(2048);
|
||||
public static IReadOnlySet<NetState> Instances => _instances;
|
||||
public static HashSet<NetState> Instances => _instances;
|
||||
|
||||
private readonly string _toString;
|
||||
private ClientVersion _version;
|
||||
|
|
@ -109,9 +109,6 @@ public partial class NetState : IComparable<NetState>, IValueLinkListNode<NetSta
|
|||
Address = address;
|
||||
|
||||
Seeded = false;
|
||||
HuePickers = [];
|
||||
Menus = [];
|
||||
Trades = [];
|
||||
NextActivityCheck = Core.TickCount + 30000;
|
||||
ConnectedOn = Core.Now;
|
||||
_toString = address?.ToString() ?? "(error)";
|
||||
|
|
@ -166,7 +163,7 @@ public partial class NetState : IComparable<NetState>, IValueLinkListNode<NetSta
|
|||
|
||||
public bool BlockAllPackets { get; set; }
|
||||
|
||||
public List<SecureTrade> Trades { get; }
|
||||
public List<SecureTrade> Trades { get; private set; }
|
||||
|
||||
public bool Seeded { get; set; }
|
||||
|
||||
|
|
@ -260,8 +257,18 @@ public partial class NetState : IComparable<NetState>, IValueLinkListNode<NetSta
|
|||
|
||||
public void ValidateAllTrades()
|
||||
{
|
||||
if (Trades == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
for (var i = Trades.Count - 1; i >= 0; --i)
|
||||
{
|
||||
if (Trades == null)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
if (i >= Trades.Count)
|
||||
{
|
||||
continue;
|
||||
|
|
@ -280,8 +287,18 @@ public partial class NetState : IComparable<NetState>, IValueLinkListNode<NetSta
|
|||
|
||||
public void CancelAllTrades()
|
||||
{
|
||||
if (Trades == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
for (var i = Trades.Count - 1; i >= 0; --i)
|
||||
{
|
||||
if (Trades != null)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
if (i < Trades.Count)
|
||||
{
|
||||
Trades[i].Cancel();
|
||||
|
|
@ -291,11 +308,21 @@ public partial class NetState : IComparable<NetState>, IValueLinkListNode<NetSta
|
|||
|
||||
public void RemoveTrade(SecureTrade trade)
|
||||
{
|
||||
Trades.Remove(trade);
|
||||
Trades?.Remove(trade);
|
||||
|
||||
if (Trades?.Count == 0)
|
||||
{
|
||||
Trades = null;
|
||||
}
|
||||
}
|
||||
|
||||
public SecureTrade FindTrade(Mobile m)
|
||||
{
|
||||
if (Trades == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
for (var i = 0; i < Trades.Count; ++i)
|
||||
{
|
||||
var trade = Trades[i];
|
||||
|
|
@ -311,6 +338,11 @@ public partial class NetState : IComparable<NetState>, IValueLinkListNode<NetSta
|
|||
|
||||
public SecureTradeContainer FindTradeContainer(Mobile m)
|
||||
{
|
||||
if (Trades == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
for (var i = 0; i < Trades.Count; ++i)
|
||||
{
|
||||
var trade = Trades[i];
|
||||
|
|
@ -336,7 +368,11 @@ public partial class NetState : IComparable<NetState>, IValueLinkListNode<NetSta
|
|||
{
|
||||
var newTrade = new SecureTrade(Mobile, state.Mobile);
|
||||
|
||||
Trades ??= [];
|
||||
|
||||
Trades.Add(newTrade);
|
||||
|
||||
state.Trades ??= [];
|
||||
state.Trades.Add(newTrade);
|
||||
|
||||
return newTrade.From.Container;
|
||||
|
|
@ -1176,8 +1212,16 @@ public partial class NetState : IComparable<NetState>, IValueLinkListNode<NetSta
|
|||
|
||||
var a = Account;
|
||||
|
||||
Menus.Clear();
|
||||
HuePickers.Clear();
|
||||
Menus?.Clear();
|
||||
Menus = null;
|
||||
|
||||
HuePickers?.Clear();
|
||||
HuePickers = null;
|
||||
|
||||
// Just in case, but should already be nulled when Mobile.NetState is set to null and CancelAllTrades is called.
|
||||
Trades?.Clear();
|
||||
Trades = null;
|
||||
|
||||
Account = null;
|
||||
ServerInfo = null;
|
||||
CityInfo = null;
|
||||
|
|
|
|||
|
|
@ -74,6 +74,12 @@ public sealed unsafe class BinaryFileReader : IDisposable, IGenericReader
|
|||
/// </summary>
|
||||
public long Position => _reader.Position;
|
||||
|
||||
public TimeSpan AnchoredTimeShift
|
||||
{
|
||||
get => _reader.AnchoredTimeShift;
|
||||
set => _reader.AnchoredTimeShift = value;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_accessor?.SafeMemoryMappedViewHandle.ReleasePointer();
|
||||
|
|
|
|||
|
|
@ -37,6 +37,8 @@ public class BufferReader : IGenericReader
|
|||
public long Position => _position;
|
||||
public long BufferSize => _buffer.Length;
|
||||
|
||||
public TimeSpan AnchoredTimeShift { get; set; }
|
||||
|
||||
public BufferReader(byte[] buffer, Dictionary<ulong, string> typesDb = null, Encoding encoding = null)
|
||||
{
|
||||
_buffer = buffer;
|
||||
|
|
|
|||
|
|
@ -384,6 +384,7 @@ public class BufferWriter : IGenericWriter
|
|||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
[Obsolete("Delta time rewrites its bytes on every save. Write anchored time instead (WriteAnchoredTime, or [AnchoredDateTime] on generated fields); bump the containing type's version, as the wire format changes. Existing delta payloads remain readable through ReadDeltaTime in old-version fallbacks.")]
|
||||
public void WriteDeltaTime(DateTime value)
|
||||
{
|
||||
if (value == DateTime.MinValue)
|
||||
|
|
@ -407,6 +408,21 @@ public class BufferWriter : IGenericWriter
|
|||
Write(value.Ticks - DateTime.UtcNow.Ticks);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes the absolute value; <see cref="IGenericReader.ReadAnchoredTime" /> 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.
|
||||
/// </summary>
|
||||
public void WriteAnchoredTime(DateTime value)
|
||||
{
|
||||
if (value.Kind == DateTimeKind.Local)
|
||||
{
|
||||
value = value.ToUniversalTime();
|
||||
}
|
||||
|
||||
Write(value.Ticks);
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
public void Write(IPAddress value)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -114,9 +114,10 @@ public class GenericEntityPersistence<T> : GenericPersistence, IGenericEntityPer
|
|||
using var binFs = new FileStream(
|
||||
Path.Combine(dir, $"{Name}.bin"), FileMode.Create, FileAccess.Write, FileShare.None, 1024 * 1024
|
||||
);
|
||||
// v4 records are fixed-width 26 bytes; the header carries the type table
|
||||
// (name lengths vary — 64 bytes per entry is a staging hint, not a contract).
|
||||
var expectedIdxSize = 12 + 26L * EntitiesBySerial.Count + 64L * _typeTable.Count;
|
||||
// v4 records are fixed-width 26 bytes; the v5 header carries the save-start anchor
|
||||
// and the type table (name lengths vary — 64 bytes per entry is a staging hint, not
|
||||
// a contract).
|
||||
var expectedIdxSize = 20 + 26L * EntitiesBySerial.Count + 64L * _typeTable.Count;
|
||||
using var idx = new FileBufferWriter(Path.Combine(dir, $"{Name}.idx"), expectedIdxSize);
|
||||
|
||||
var binPosition = 0L;
|
||||
|
|
@ -142,7 +143,10 @@ public class GenericEntityPersistence<T> : GenericPersistence, IGenericEntityPer
|
|||
binPosition += _selfLength;
|
||||
}
|
||||
|
||||
idx.Write(4); // Version
|
||||
idx.Write(5); // Version
|
||||
|
||||
// One anchor for the whole save: the world is frozen from the moment it is stamped.
|
||||
idx.Write(World.SaveStartTime.Ticks);
|
||||
|
||||
// The type table is fully known at freeze (AddEntity diverts to the pending
|
||||
// queues while saving) and is written before the records so the loader can
|
||||
|
|
@ -494,6 +498,18 @@ public class GenericEntityPersistence<T> : GenericPersistence, IGenericEntityPer
|
|||
|
||||
var version = dataReader.ReadInt();
|
||||
|
||||
if (version >= 5)
|
||||
{
|
||||
// Re-base anchored timestamps by the elapsed time since the save started.
|
||||
var anchor = new DateTime(dataReader.ReadLong(), DateTimeKind.Utc);
|
||||
var shift = Core.Now - anchor;
|
||||
_anchoredTimeShift = anchor.Ticks > 0 && shift > TimeSpan.Zero ? shift : TimeSpan.Zero;
|
||||
|
||||
// The whole save shares one anchor. Publish it so payloads without their own
|
||||
// (GenericPersistence bins) can shift too; indexes load before any of them.
|
||||
World.LoadTimeShift = _anchoredTimeShift;
|
||||
}
|
||||
|
||||
if (version >= 4)
|
||||
{
|
||||
DeserializeIndexesV4(dataReader, entities);
|
||||
|
|
@ -660,6 +676,9 @@ public class GenericEntityPersistence<T> : GenericPersistence, IGenericEntityPer
|
|||
|
||||
private static List<T> _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<ulong, string> typesDb)
|
||||
{
|
||||
using var mmf = MemoryMappedFile.CreateFromFile(filePath, FileMode.Open);
|
||||
|
|
@ -667,7 +686,10 @@ public class GenericEntityPersistence<T> : GenericPersistence, IGenericEntityPer
|
|||
|
||||
byte* ptr = null;
|
||||
accessor.SafeMemoryMappedViewHandle.AcquirePointer(ref ptr);
|
||||
var dataReader = new UnmanagedDataReader(ptr, accessor.Length, typesDb);
|
||||
var dataReader = new UnmanagedDataReader(ptr, accessor.Length, typesDb)
|
||||
{
|
||||
AnchoredTimeShift = _anchoredTimeShift
|
||||
};
|
||||
|
||||
Deserialize(dataReader);
|
||||
|
||||
|
|
|
|||
|
|
@ -98,7 +98,13 @@ public abstract class GenericPersistence : Persistence, IGenericSerializable
|
|||
|
||||
byte* ptr = null;
|
||||
accessor.SafeMemoryMappedViewHandle.AcquirePointer(ref ptr);
|
||||
var dataReader = new UnmanagedDataReader(ptr, accessor.Length, typesDb);
|
||||
var dataReader = new UnmanagedDataReader(ptr, accessor.Length, typesDb)
|
||||
{
|
||||
// These payloads carry no anchor of their own; they inherit the save-wide
|
||||
// shift stamped while the entity indexes were read (indexes always load
|
||||
// before persistence payloads — see Persistence.Load).
|
||||
AnchoredTimeShift = World.LoadTimeShift
|
||||
};
|
||||
Deserialize(dataReader);
|
||||
|
||||
error = dataReader.Position != fileLength
|
||||
|
|
|
|||
|
|
@ -43,6 +43,12 @@ public interface IGenericReader
|
|||
DateTime ReadDateTime() => new(ReadLong(), DateTimeKind.Utc);
|
||||
TimeSpan ReadTimeSpan() => new(ReadLong());
|
||||
|
||||
/// <summary>
|
||||
/// 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 <see cref="ReadAnchoredTime" />. <see cref="IGenericWriter.WriteDeltaTime" /> is
|
||||
/// obsolete: no current-version format may write delta time.
|
||||
/// </summary>
|
||||
DateTime ReadDeltaTime()
|
||||
{
|
||||
return ReadLong() switch
|
||||
|
|
@ -52,6 +58,37 @@ public interface IGenericReader
|
|||
var delta => new DateTime(delta + DateTime.UtcNow.Ticks, DateTimeKind.Utc)
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Elapsed time between the loaded save starting and this load, applied by
|
||||
/// <see cref="ReadAnchoredTime" />. Zero when the source carries no anchor.
|
||||
/// </summary>
|
||||
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()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -40,7 +40,11 @@ public interface IGenericWriter
|
|||
void Write(decimal value);
|
||||
void WriteEncodedInt(int value);
|
||||
void Write(DateTime value);
|
||||
|
||||
[Obsolete("Delta time rewrites its bytes on every save. Write anchored time instead (WriteAnchoredTime, or [AnchoredDateTime] on generated fields); bump the containing type's version, as the wire format changes. Existing delta payloads remain readable through ReadDeltaTime in old-version fallbacks.")]
|
||||
void WriteDeltaTime(DateTime value);
|
||||
|
||||
void WriteAnchoredTime(DateTime value);
|
||||
void Write(IPAddress value);
|
||||
void Write(TimeSpan value);
|
||||
void Write(Point3D value);
|
||||
|
|
|
|||
|
|
@ -43,6 +43,8 @@ public unsafe class UnmanagedDataReader : IGenericReader
|
|||
/// </summary>
|
||||
public long Position { get; private set; }
|
||||
|
||||
public TimeSpan AnchoredTimeShift { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Read bits of data raw from a serialized file using Little-endian.
|
||||
/// </summary>
|
||||
|
|
|
|||
|
|
@ -37,11 +37,10 @@
|
|||
<PackageReference Include="IORingGroup" Version="1.0.10" />
|
||||
<PackageReference Include="CommunityToolkit.HighPerformance" Version="8.4.2" />
|
||||
<PackageReference Include="LibDeflate.Bindings" Version="1.0.4" />
|
||||
<PackageReference Include="System.IO.Hashing" Version="10.0.10" />
|
||||
<PackageReference Include="System.IO.Hashing" Version="10.0.11" />
|
||||
|
||||
<PackageReference Include="ModernUO.Serialization.Annotations" Version="2.14.2" />
|
||||
<PackageReference Include="ModernUO.Serialization.Generator" Version="2.14.3" />
|
||||
<PackageReference Update="Serilog" Version="4.4.0" />
|
||||
<PackageReference Include="ModernUO.Serialization.Annotations" Version="4.0.0" />
|
||||
<PackageReference Include="ModernUO.Serialization.Generator" Version="4.0.0" PrivateAssets="all" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<AdditionalFiles Include="Migrations/*.v*.json" />
|
||||
|
|
|
|||
|
|
@ -93,6 +93,21 @@ public static class World
|
|||
public static string SavePath { get; private set; }
|
||||
public static WorldState WorldState { get; private set; }
|
||||
public static bool Saving => WorldState == WorldState.Saving;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public static DateTime SaveStartTime { get; internal set; }
|
||||
|
||||
/// <summary>
|
||||
/// 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 <see cref="SaveStartTime" />) and applied
|
||||
/// to every reader of that save's files — including <see cref="GenericPersistence" />
|
||||
/// payloads, which carry no anchor of their own. Zero for saves that predate the anchor.
|
||||
/// </summary>
|
||||
public static TimeSpan LoadTimeShift { get; internal set; }
|
||||
public static bool Running => WorldState is not WorldState.Loading and not WorldState.Initial;
|
||||
public static bool Loading => WorldState == WorldState.Loading;
|
||||
|
||||
|
|
@ -287,6 +302,10 @@ public static class World
|
|||
|
||||
WorldState = WorldState.Saving;
|
||||
|
||||
// The world is frozen from here: one anchor for the whole save. Written into save
|
||||
// indexes so anchored timestamps can be re-based by the downtime at load.
|
||||
SaveStartTime = Core.Now;
|
||||
|
||||
Broadcast(0x35, true, "The world is saving, please wait.");
|
||||
|
||||
logger.Information("Saving world");
|
||||
|
|
|
|||
|
|
@ -103,6 +103,7 @@ internal static class TestServerInitializer
|
|||
// Registers the Accounts entity persistence; without it no test can construct an Account.
|
||||
Server.Accounting.Accounts.Configure();
|
||||
RaceDefinitions.Configure();
|
||||
Server.Movement.Movement.Configure();
|
||||
MovementImpl.Configure();
|
||||
PathFollower.Configure();
|
||||
World.Load();
|
||||
|
|
|
|||
|
|
@ -0,0 +1,111 @@
|
|||
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;
|
||||
}
|
||||
}
|
||||
211
Projects/UOContent.Tests/Tests/Mobiles/AI/AcquisitionTests.cs
Normal file
211
Projects/UOContent.Tests/Tests/Mobiles/AI/AcquisitionTests.cs
Normal file
|
|
@ -0,0 +1,211 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Server;
|
||||
using Server.Mobiles;
|
||||
using Xunit;
|
||||
|
||||
namespace UOContent.Tests.Mobiles.AI;
|
||||
|
||||
// Pins the reacquire gate and the AcquireOnApproachDelay gradient: every scan re-arms the
|
||||
// full ReacquireDelay; enemy movement clamps the deadline to the approach delay (Zero =
|
||||
// prodded scan); an illegal deadline self-heals.
|
||||
[Collection("Sequential Pathfinding Tests")]
|
||||
public class AcquisitionTests : IDisposable
|
||||
{
|
||||
private readonly List<Mobile> _created = new();
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
foreach (var m in _created)
|
||||
{
|
||||
m?.Delete();
|
||||
}
|
||||
|
||||
_created.Clear();
|
||||
}
|
||||
|
||||
private sealed class WildStub : BaseCreature
|
||||
{
|
||||
public WildStub() : base(AIType.AI_Melee, FightMode.Closest, 16, 1) => Body = 0xC9;
|
||||
|
||||
public override void GetSpeeds(out double activeSpeed, out double passiveSpeed)
|
||||
{
|
||||
activeSpeed = 0.3;
|
||||
passiveSpeed = 0.6;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class TargetStub : Mobile
|
||||
{
|
||||
public TargetStub() => Body = 0x190;
|
||||
}
|
||||
|
||||
private WildStub Spawn(Map map, Point3D loc)
|
||||
{
|
||||
var bc = new WildStub();
|
||||
bc.MoveToWorld(loc, map);
|
||||
bc.AIObject.AITimer?.Stop();
|
||||
_created.Add(bc);
|
||||
return bc;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EmptyScan_HonorsReacquireDelay()
|
||||
{
|
||||
var map = Map.Maps[1];
|
||||
Assert.NotNull(map);
|
||||
map.GetAverageZ(1500, 1600, out _, out var z, out _);
|
||||
|
||||
var bc = Spawn(map, new Point3D(1500, 1600, (sbyte)z));
|
||||
bc.NextReacquireTime = Core.TickCount;
|
||||
|
||||
Assert.False(bc.AIObject.AcquireFocusMob(bc.RangePerception, FightMode.Closest, false, false, true));
|
||||
Assert.InRange(bc.NextReacquireTime - Core.TickCount, 5000, 10000);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WedgedGate_SelfHeals()
|
||||
{
|
||||
var map = Map.Maps[1];
|
||||
Assert.NotNull(map);
|
||||
map.GetAverageZ(1500, 1600, out _, out var z, out _);
|
||||
|
||||
var bc = Spawn(map, new Point3D(1500, 1600, (sbyte)z));
|
||||
|
||||
var target = new TargetStub();
|
||||
target.DefaultMobileInit();
|
||||
target.MoveToWorld(new Point3D(1497, 1600, (sbyte)z), map);
|
||||
_created.Add(target);
|
||||
|
||||
// Illegal deadline (beyond ReacquireDelay): must read as open, not block forever.
|
||||
bc.NextReacquireTime = Core.TickCount + 60000;
|
||||
|
||||
Assert.True(bc.AIObject.AcquireFocusMob(bc.RangePerception, FightMode.Closest, false, false, true));
|
||||
Assert.Equal(target, bc.FocusMob);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(false, 5, true)] // an enemy moving inside approach range (10) clamps the deadline
|
||||
[InlineData(true, 5, false)] // a same-team wild creature is not an enemy — ignored
|
||||
[InlineData(false, 12, false)] // inside RangePerception but outside approach range — poll only
|
||||
[InlineData(false, 20, false)] // outside approach range (10) is ignored
|
||||
public void MovementClampsScanDeadlineOnlyForEnemiesInRange(bool wildMover, int distance, bool notices)
|
||||
{
|
||||
var map = Map.Maps[1];
|
||||
Assert.NotNull(map);
|
||||
map.GetAverageZ(1500, 1600, out _, out var z, out _);
|
||||
|
||||
var bc = Spawn(map, new Point3D(1500, 1600, (sbyte)z));
|
||||
bc.NextReacquireTime = Core.TickCount + 8000;
|
||||
|
||||
Mobile mover;
|
||||
if (wildMover)
|
||||
{
|
||||
mover = Spawn(map, new Point3D(1500 - distance, 1600, (sbyte)z));
|
||||
}
|
||||
else
|
||||
{
|
||||
mover = new TargetStub { Player = true };
|
||||
mover.DefaultMobileInit();
|
||||
mover.MoveToWorld(new Point3D(1500 - distance, 1600, (sbyte)z), map);
|
||||
_created.Add(mover);
|
||||
}
|
||||
|
||||
bc.OnMovement(mover, new Point3D(1400, 1600, (sbyte)z));
|
||||
|
||||
var remaining = bc.NextReacquireTime - Core.TickCount;
|
||||
|
||||
if (notices)
|
||||
{
|
||||
// Clamped to the approach delay (2s), never opened outright.
|
||||
Assert.InRange(remaining, 1, (long)bc.AcquireOnApproachDelay.TotalMilliseconds);
|
||||
}
|
||||
else
|
||||
{
|
||||
Assert.True(remaining > 5000);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class InstantStub : BaseCreature
|
||||
{
|
||||
public InstantStub() : base(AIType.AI_Melee, FightMode.Closest, 16, 1) => Body = 0xC9;
|
||||
|
||||
public override TimeSpan AcquireOnApproachDelay => TimeSpan.Zero;
|
||||
|
||||
public override void GetSpeeds(out double activeSpeed, out double passiveSpeed)
|
||||
{
|
||||
activeSpeed = 0.3;
|
||||
passiveSpeed = 0.6;
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ZeroApproachDelay_OpensGateImmediately()
|
||||
{
|
||||
var map = Map.Maps[1];
|
||||
Assert.NotNull(map);
|
||||
map.GetAverageZ(1500, 1600, out _, out var z, out _);
|
||||
|
||||
var bc = new InstantStub();
|
||||
bc.MoveToWorld(new Point3D(1500, 1600, (sbyte)z), map);
|
||||
bc.AIObject.AITimer?.Stop();
|
||||
_created.Add(bc);
|
||||
bc.NextReacquireTime = Core.TickCount + 8000;
|
||||
|
||||
var mover = new TargetStub { Player = true };
|
||||
mover.DefaultMobileInit();
|
||||
mover.MoveToWorld(new Point3D(1495, 1600, (sbyte)z), map);
|
||||
_created.Add(mover);
|
||||
|
||||
bc.OnMovement(mover, new Point3D(1400, 1600, (sbyte)z));
|
||||
|
||||
// Zero = the gate opens and the AI is prodded to think now; no direct engage.
|
||||
Assert.True(Core.TickCount - bc.NextReacquireTime >= 0);
|
||||
Assert.Null(bc.Combatant);
|
||||
Assert.True(bc.AIObject.AITimer.Running);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RepeatedMovement_DoesNotShortenBelowApproachDelay()
|
||||
{
|
||||
var map = Map.Maps[1];
|
||||
Assert.NotNull(map);
|
||||
map.GetAverageZ(1500, 1600, out _, out var z, out _);
|
||||
|
||||
var bc = Spawn(map, new Point3D(1500, 1600, (sbyte)z));
|
||||
bc.NextReacquireTime = Core.TickCount + 8000;
|
||||
|
||||
var mover = new TargetStub { Player = true };
|
||||
mover.DefaultMobileInit();
|
||||
mover.MoveToWorld(new Point3D(1495, 1600, (sbyte)z), map);
|
||||
_created.Add(mover);
|
||||
|
||||
bc.OnMovement(mover, new Point3D(1400, 1600, (sbyte)z));
|
||||
var afterFirst = bc.NextReacquireTime;
|
||||
|
||||
bc.OnMovement(mover, new Point3D(1496, 1600, (sbyte)z));
|
||||
|
||||
Assert.Equal(afterFirst, bc.NextReacquireTime);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SuccessfulAcquire_HoldsFullDelay()
|
||||
{
|
||||
var map = Map.Maps[1];
|
||||
Assert.NotNull(map);
|
||||
map.GetAverageZ(1500, 1600, out _, out var z, out _);
|
||||
|
||||
var bc = Spawn(map, new Point3D(1500, 1600, (sbyte)z));
|
||||
|
||||
var target = new TargetStub();
|
||||
target.DefaultMobileInit();
|
||||
target.MoveToWorld(new Point3D(1497, 1600, (sbyte)z), map);
|
||||
_created.Add(target);
|
||||
|
||||
bc.NextReacquireTime = Core.TickCount;
|
||||
|
||||
Assert.True(bc.AIObject.AcquireFocusMob(bc.RangePerception, FightMode.Closest, false, false, true));
|
||||
Assert.Equal(target, bc.FocusMob);
|
||||
Assert.True(bc.NextReacquireTime - Core.TickCount > 5000);
|
||||
}
|
||||
}
|
||||
|
|
@ -40,7 +40,7 @@ public class ApproachTargetTests
|
|||
for (var i = 0; i < maxTicks; i++)
|
||||
{
|
||||
ai.NextMove = 0;
|
||||
ai.WalkMobileRange(target, 1, false, 1, 2);
|
||||
ai.WalkMobileRange(target, 1, 1, 2);
|
||||
if (bc.InRange(target, arriveDist))
|
||||
{
|
||||
return true;
|
||||
|
|
@ -123,7 +123,7 @@ public class ApproachTargetTests
|
|||
for (var i = 0; i < 200; i++)
|
||||
{
|
||||
ai.NextMove = 0;
|
||||
ai.MoveTo(target, false, 1);
|
||||
ai.MoveTo(target, 1);
|
||||
if (bc.InRange(target, 1))
|
||||
{
|
||||
arrived = true;
|
||||
|
|
@ -154,7 +154,7 @@ public class ApproachTargetTests
|
|||
for (var i = 0; i < 60; i++)
|
||||
{
|
||||
ai.NextMove = 0;
|
||||
ai.MoveTo(target, true, 1);
|
||||
ai.MoveTo(target, 1);
|
||||
|
||||
// Target walks west every other tick for its first several steps, then stops,
|
||||
// so a same-speed chaser eventually closes the gap.
|
||||
|
|
@ -214,7 +214,7 @@ public class ApproachTargetTests
|
|||
for (var i = 0; i < 120; i++)
|
||||
{
|
||||
ai.NextMove = 0;
|
||||
ai.MoveTo(target, false, 1);
|
||||
ai.MoveTo(target, 1);
|
||||
}
|
||||
|
||||
// After giving up, the creature must idle (not oscillate) while the goal is still.
|
||||
|
|
@ -223,7 +223,7 @@ public class ApproachTargetTests
|
|||
for (var i = 0; i < 20; i++)
|
||||
{
|
||||
ai.NextMove = 0;
|
||||
ai.MoveTo(target, false, 1);
|
||||
ai.MoveTo(target, 1);
|
||||
if (bc.Location != idleStart)
|
||||
{
|
||||
stayedIdle = false;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,96 @@
|
|||
using System.Collections.Generic;
|
||||
using Server;
|
||||
using Server.Mobiles;
|
||||
using Xunit;
|
||||
|
||||
namespace UOContent.Tests.Mobiles.AI;
|
||||
|
||||
// Guard-following may pathfind, so this shares the pathfinding collection.
|
||||
[Collection("Sequential Pathfinding Tests")]
|
||||
public class GuardFollowTests
|
||||
{
|
||||
[Fact]
|
||||
public void GuardFollow_StepsTowardMaster_AndRegistersMoveIntent()
|
||||
{
|
||||
var map = Map.Maps[1];
|
||||
Assert.NotNull(map);
|
||||
map.GetAverageZ(1500, 1600, out _, out var z, out _);
|
||||
|
||||
var master = new PlayerMobile(World.NewMobile);
|
||||
master.DefaultMobileInit();
|
||||
master.MoveToWorld(new Point3D(1494, 1600, (sbyte)z), map);
|
||||
|
||||
var pet = new PetTestStub();
|
||||
pet.MoveToWorld(new Point3D(1500, 1600, (sbyte)z), map); // 6 tiles east, open terrain
|
||||
pet.SetControlMaster(master);
|
||||
|
||||
var ai = pet.AIObject;
|
||||
ai.AITimer?.Stop(); // drive manually
|
||||
pet.ControlOrder = OrderType.Guard;
|
||||
ai.AITimer?.Stop(); // the order change may restart the timer
|
||||
|
||||
var start = pet.Location;
|
||||
ai.NextMove = 0;
|
||||
ai.Obey();
|
||||
|
||||
var moved = pet.Location != start;
|
||||
var hasIntent = ai.TryGetMoveWake(out _);
|
||||
var currentSpeed = pet.CurrentSpeed;
|
||||
var currentMoveSpeed = pet.CurrentMoveSpeed;
|
||||
|
||||
pet.Delete();
|
||||
master.Delete();
|
||||
|
||||
Assert.True(moved, "a guarding pet beyond guard range must step toward its master");
|
||||
// Without a move intent, guard-following only steps on the think grid.
|
||||
Assert.True(hasIntent, "guard-following must register a move intent");
|
||||
|
||||
// AOS return sprint on both clocks; the per-step speed flip must not undo it.
|
||||
Assert.Equal(0.1, currentSpeed);
|
||||
Assert.Equal(0.1, currentMoveSpeed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GuardReturn_PreAOS_RunsActive()
|
||||
{
|
||||
var previous = Core.Expansion;
|
||||
|
||||
try
|
||||
{
|
||||
Core.Expansion = Expansion.UOR;
|
||||
|
||||
var map = Map.Maps[1];
|
||||
Assert.NotNull(map);
|
||||
map.GetAverageZ(1500, 1600, out _, out var z, out _);
|
||||
|
||||
var master = new PlayerMobile(World.NewMobile);
|
||||
master.DefaultMobileInit();
|
||||
master.MoveToWorld(new Point3D(1494, 1600, (sbyte)z), map);
|
||||
|
||||
var pet = new PetTestStub();
|
||||
pet.MoveToWorld(new Point3D(1500, 1600, (sbyte)z), map);
|
||||
pet.SetControlMaster(master);
|
||||
|
||||
var ai = pet.AIObject;
|
||||
ai.AITimer?.Stop();
|
||||
pet.ControlOrder = OrderType.Guard;
|
||||
ai.AITimer?.Stop();
|
||||
pet.SetCurrentSpeedToPassive(); // a stale passive state must not persist
|
||||
|
||||
ai.NextMove = 0;
|
||||
ai.Obey();
|
||||
|
||||
var currentSpeed = pet.CurrentSpeed;
|
||||
|
||||
pet.Delete();
|
||||
master.Delete();
|
||||
|
||||
// No sprint pre-AOS: the return runs active.
|
||||
Assert.Equal(0.2, currentSpeed);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Core.Expansion = previous;
|
||||
}
|
||||
}
|
||||
}
|
||||
137
Projects/UOContent.Tests/Tests/Mobiles/AI/GuardOrderTests.cs
Normal file
137
Projects/UOContent.Tests/Tests/Mobiles/AI/GuardOrderTests.cs
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Server;
|
||||
using Server.Mobiles;
|
||||
using Xunit;
|
||||
|
||||
namespace UOContent.Tests.Mobiles.AI;
|
||||
|
||||
// A guarding pet fights without leaving the Guard order, retargets toward the master's
|
||||
// closest aggressor, and stands down when nothing threatens. Scene: the open
|
||||
// (1495..1500, 1600) Trammel segment; targets are adjacent so no pathfinding runs.
|
||||
[Collection("Sequential UOContent Tests")]
|
||||
public class GuardOrderTests : IDisposable
|
||||
{
|
||||
private readonly List<Mobile> _created = new();
|
||||
|
||||
private sealed class AggressorStub : Mobile
|
||||
{
|
||||
public AggressorStub() => Body = 0xC9;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
foreach (var m in _created)
|
||||
{
|
||||
m?.Delete();
|
||||
}
|
||||
|
||||
_created.Clear();
|
||||
}
|
||||
|
||||
private (PlayerMobile master, PetTestStub pet) SpawnGuardingPet(out Map map, out int z)
|
||||
{
|
||||
map = Map.Maps[1];
|
||||
Assert.NotNull(map);
|
||||
map.GetAverageZ(1500, 1600, out _, out z, out _);
|
||||
|
||||
var master = new PlayerMobile(World.NewMobile);
|
||||
master.DefaultMobileInit();
|
||||
master.MoveToWorld(new Point3D(1500, 1600, (sbyte)z), map);
|
||||
_created.Add(master);
|
||||
|
||||
var pet = new PetTestStub();
|
||||
pet.MoveToWorld(new Point3D(1499, 1600, (sbyte)z), map);
|
||||
pet.SetControlMaster(master);
|
||||
_created.Add(pet);
|
||||
|
||||
pet.AIObject.AITimer?.Stop(); // drive manually
|
||||
pet.ControlOrder = OrderType.Guard;
|
||||
pet.AIObject.AITimer?.Stop(); // the order change restarts the timer
|
||||
|
||||
return (master, pet);
|
||||
}
|
||||
|
||||
private AggressorStub SpawnAggressor(PetTestStub pet, Point3D loc, Mobile attacking)
|
||||
{
|
||||
var aggr = new AggressorStub();
|
||||
aggr.MoveToWorld(loc, pet.Map);
|
||||
_created.Add(aggr);
|
||||
|
||||
// Setup guard: the scene must stay LOS-clear and the combatant must not be vetoed.
|
||||
Assert.True(pet.InLOS(aggr), $"no LOS from pet to aggressor at {loc}");
|
||||
|
||||
if (attacking != null)
|
||||
{
|
||||
aggr.Combatant = attacking;
|
||||
Assert.Same(attacking, aggr.Combatant);
|
||||
}
|
||||
|
||||
return aggr;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GuardEngage_KeepsGuardOrder()
|
||||
{
|
||||
var (master, pet) = SpawnGuardingPet(out _, out var z);
|
||||
var aggr = SpawnAggressor(pet, new Point3D(1498, 1600, (sbyte)z), master);
|
||||
|
||||
pet.AIObject.Obey();
|
||||
|
||||
Assert.Same(aggr, pet.Combatant);
|
||||
Assert.Equal(OrderType.Guard, pet.ControlOrder);
|
||||
Assert.Equal(OrderType.Guard, pet.AIObject.PersistentOrder);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Guard_RetargetsToAggressorClosestToMaster()
|
||||
{
|
||||
var (master, pet) = SpawnGuardingPet(out _, out var z);
|
||||
var far = SpawnAggressor(pet, new Point3D(1495, 1600, (sbyte)z), master);
|
||||
var near = SpawnAggressor(pet, new Point3D(1498, 1600, (sbyte)z), master);
|
||||
|
||||
pet.Combatant = far; // already fighting the far aggressor
|
||||
|
||||
pet.AIObject.Obey();
|
||||
|
||||
Assert.Same(near, pet.Combatant); // defends the master, not the current fight
|
||||
Assert.Equal(OrderType.Guard, pet.ControlOrder);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExplicitAttack_ResumesGuard_WithoutChainingIntoAttack()
|
||||
{
|
||||
var (master, pet) = SpawnGuardingPet(out _, out var z);
|
||||
|
||||
// Explicit kill order on a target that then becomes invalid.
|
||||
var victim = SpawnAggressor(pet, new Point3D(1498, 1600, (sbyte)z), null);
|
||||
pet.ControlTarget = victim;
|
||||
pet.ControlOrder = OrderType.Attack;
|
||||
victim.Hidden = true;
|
||||
|
||||
// A second aggressor is still after the master; FightMode.Closest would chain it.
|
||||
var aggr2 = SpawnAggressor(pet, new Point3D(1497, 1600, (sbyte)z), master);
|
||||
|
||||
pet.AIObject.Obey(); // attack completes -> resume the persistent Guard
|
||||
|
||||
Assert.Equal(OrderType.Guard, pet.ControlOrder);
|
||||
|
||||
pet.AIObject.Obey(); // the guard scan engages the remaining aggressor in-order
|
||||
|
||||
Assert.Same(aggr2, pet.Combatant);
|
||||
Assert.Equal(OrderType.Guard, pet.ControlOrder);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PeacefulGuard_StandsDown()
|
||||
{
|
||||
var (_, pet) = SpawnGuardingPet(out _, out _);
|
||||
Assert.True(pet.Warmode); // the guard order opens in war stance
|
||||
|
||||
pet.AIObject.Obey(); // nothing to guard against
|
||||
|
||||
Assert.False(pet.Warmode);
|
||||
Assert.Null(pet.Combatant);
|
||||
Assert.Null(pet.FocusMob);
|
||||
}
|
||||
}
|
||||
219
Projects/UOContent.Tests/Tests/Mobiles/AI/MoveSpeedTests.cs
Normal file
219
Projects/UOContent.Tests/Tests/Mobiles/AI/MoveSpeedTests.cs
Normal file
|
|
@ -0,0 +1,219 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Server;
|
||||
using Server.Mobiles;
|
||||
using Xunit;
|
||||
|
||||
namespace UOContent.Tests.Mobiles.AI;
|
||||
|
||||
// Pins the CurrentMoveSpeed classification (verbatim active/passive maps to the matching
|
||||
// move value; bespoke stays fused), SetSpeed's one-clock guarantee, and the v22 tail.
|
||||
[Collection("Sequential UOContent Tests")]
|
||||
public class MoveSpeedTests : IDisposable
|
||||
{
|
||||
// Delete spawned stubs so they don't linger in the shared static World.
|
||||
private readonly List<Mobile> _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);
|
||||
}
|
||||
}
|
||||
222
Projects/UOContent.Tests/Tests/Mobiles/AI/PetPacingTests.cs
Normal file
222
Projects/UOContent.Tests/Tests/Mobiles/AI/PetPacingTests.cs
Normal file
|
|
@ -0,0 +1,222 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Server;
|
||||
using Server.Mobiles;
|
||||
using Xunit;
|
||||
|
||||
namespace UOContent.Tests.Mobiles.AI;
|
||||
|
||||
// Pet order handlers own the speed clocks; combat chases and herding keep their own pacing.
|
||||
[Collection("Sequential UOContent Tests")]
|
||||
public class PetPacingTests : IDisposable
|
||||
{
|
||||
private readonly List<Mobile> _created = new();
|
||||
|
||||
private (PlayerMobile master, PetTestStub pet) Spawn(Point3D masterLoc, Point3D petLoc)
|
||||
{
|
||||
var pair = PetTestSetup.SpawnControlledPet(masterLoc, petLoc);
|
||||
_created.Add(pair.master);
|
||||
_created.Add(pair.pet);
|
||||
return pair;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
foreach (var m in _created)
|
||||
{
|
||||
m?.Delete();
|
||||
}
|
||||
|
||||
_created.Clear();
|
||||
}
|
||||
|
||||
// Movement orders run active, resting orders run passive; the move clock follows.
|
||||
[Fact]
|
||||
public void OrderIssue_SetsThinkClock()
|
||||
{
|
||||
var (master, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1001, 1000, 0));
|
||||
pet.SetMoveSpeed(0.3, 0.9);
|
||||
pet.SetCurrentSpeedToPassive();
|
||||
|
||||
pet.ControlOrder = OrderType.Come;
|
||||
Assert.Equal(0.2, pet.CurrentSpeed);
|
||||
Assert.Equal(0.3, pet.CurrentMoveSpeed); // verbatim active -> activeMove
|
||||
|
||||
pet.ControlOrder = OrderType.Stay;
|
||||
Assert.Equal(0.4, pet.CurrentSpeed);
|
||||
Assert.Equal(0.9, pet.CurrentMoveSpeed);
|
||||
|
||||
pet.ControlTarget = master;
|
||||
pet.ControlOrder = OrderType.Follow;
|
||||
Assert.Equal(0.2, pet.CurrentSpeed);
|
||||
|
||||
pet.ControlOrder = OrderType.Guard;
|
||||
Assert.Equal(0.2, pet.CurrentSpeed);
|
||||
}
|
||||
|
||||
// AOS: following the master sprints at a bespoke 0.1 on both clocks.
|
||||
[Fact]
|
||||
public void FollowMaster_ObeySprints()
|
||||
{
|
||||
var (master, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1001, 1000, 0));
|
||||
pet.SetMoveSpeed(0.3, 0.9);
|
||||
pet.AIObject.AITimer?.Stop();
|
||||
|
||||
pet.ControlTarget = master;
|
||||
pet.ControlOrder = OrderType.Follow; // fixture era is EJ
|
||||
pet.AIObject.Obey();
|
||||
|
||||
Assert.Equal(0.1, pet.CurrentSpeed);
|
||||
Assert.Equal(0.1, pet.CurrentMoveSpeed);
|
||||
}
|
||||
|
||||
// At the master's side a guarding pet stays active: no stale-warmode passive, no sprint.
|
||||
[Fact]
|
||||
public void GuardAtMastersSide_IsActive()
|
||||
{
|
||||
var (_, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1001, 1000, 0));
|
||||
pet.SetMoveSpeed(0.3, 0.9);
|
||||
pet.AIObject.AITimer?.Stop();
|
||||
pet.SetCurrentSpeedToPassive();
|
||||
|
||||
pet.ControlOrder = OrderType.Guard;
|
||||
pet.AIObject.Obey(); // nothing to guard against, master adjacent
|
||||
|
||||
Assert.Equal(0.2, pet.CurrentSpeed);
|
||||
Assert.Equal(0.3, pet.CurrentMoveSpeed);
|
||||
}
|
||||
|
||||
// A pet chasing a combatant keeps the move table.
|
||||
[Fact]
|
||||
public void CombatChasingPet_KeepsMoveTable()
|
||||
{
|
||||
var (_, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1001, 1000, 0));
|
||||
var target = new PetTestStub();
|
||||
target.MoveToWorld(new Point3D(1003, 1000, 0), Map.Felucca);
|
||||
_created.Add(target);
|
||||
|
||||
pet.SetMoveSpeed(0.3, 0.9);
|
||||
pet.ControlOrder = OrderType.Guard;
|
||||
pet.Combatant = target;
|
||||
pet.SetCurrentSpeedToActive();
|
||||
|
||||
Assert.Equal(0.3, pet.CurrentMoveSpeed);
|
||||
}
|
||||
|
||||
// Herding overrides order pacing.
|
||||
[Fact]
|
||||
public void HerdedObeyingPet_KeepsHerdingPace()
|
||||
{
|
||||
var (_, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1001, 1000, 0));
|
||||
pet.SetMoveSpeed(0.45, 0.9);
|
||||
pet.SetCurrentSpeedToPassive();
|
||||
|
||||
pet.TargetLocation = new Point2D(1010, 1010);
|
||||
|
||||
Assert.Equal(0.3, pet.CurrentMoveSpeed); // fixed herding pace
|
||||
}
|
||||
|
||||
private sealed class ThinkProbe : PetTestStub
|
||||
{
|
||||
public int Thinks;
|
||||
|
||||
public override void OnThink()
|
||||
{
|
||||
Thinks++;
|
||||
base.OnThink();
|
||||
}
|
||||
}
|
||||
|
||||
private (PlayerMobile master, ThinkProbe pet) SpawnProbe()
|
||||
{
|
||||
var master = new PlayerMobile(World.NewMobile);
|
||||
master.DefaultMobileInit();
|
||||
master.MoveToWorld(new Point3D(1000, 1000, 0), Map.Felucca);
|
||||
_created.Add(master);
|
||||
|
||||
var pet = new ThinkProbe();
|
||||
pet.MoveToWorld(new Point3D(1001, 1000, 0), Map.Felucca);
|
||||
pet.SetControlMaster(master);
|
||||
_created.Add(pet);
|
||||
|
||||
return (master, pet);
|
||||
}
|
||||
|
||||
// Advances time in 8ms lockstep so the wheel and Core.TickCount stay in sync.
|
||||
private static void RunFor(long ms)
|
||||
{
|
||||
var deadline = Core._tickCount + ms;
|
||||
|
||||
while (Core._tickCount < deadline)
|
||||
{
|
||||
Core._tickCount += 8;
|
||||
Timer.Slice(Core._tickCount);
|
||||
}
|
||||
}
|
||||
|
||||
private static bool RunUntil(Func<bool> condition, long maxMs)
|
||||
{
|
||||
var deadline = Core._tickCount + maxMs;
|
||||
|
||||
while (Core._tickCount < deadline)
|
||||
{
|
||||
if (condition())
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
Core._tickCount += 8;
|
||||
Timer.Slice(Core._tickCount);
|
||||
}
|
||||
|
||||
return condition();
|
||||
}
|
||||
|
||||
// Runs past the spawn stagger; returns right after a think with the next 0.4s away.
|
||||
private ThinkProbe SettledProbe(out PlayerMobile master)
|
||||
{
|
||||
Core._tickCount = 0;
|
||||
Timer.Init(0);
|
||||
|
||||
var (m, pet) = SpawnProbe();
|
||||
master = m;
|
||||
pet.ForceIdle = true; // no wandering; pure cadence
|
||||
pet.ControlOrder = OrderType.Stay;
|
||||
|
||||
var settled = RunUntil(() => pet.Thinks >= 2, 8000);
|
||||
Assert.True(settled, "the AI must reach a steady think cadence");
|
||||
|
||||
return pet;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OrderChange_WakesStaleThinkTimer()
|
||||
{
|
||||
var pet = SettledProbe(out var master);
|
||||
var thinksBefore = pet.Thinks;
|
||||
|
||||
RunFor(200); // mid-wait, next think ~200ms out
|
||||
Assert.Equal(thinksBefore, pet.Thinks);
|
||||
|
||||
pet.ControlTarget = master;
|
||||
pet.ControlOrder = OrderType.Follow;
|
||||
|
||||
RunFor(80);
|
||||
Assert.True(pet.Thinks > thinksBefore, "a fresh order must wake the AI promptly");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SpeedUp_ReschedulesPendingWake()
|
||||
{
|
||||
var pet = SettledProbe(out _);
|
||||
var thinksBefore = pet.Thinks;
|
||||
|
||||
RunFor(200); // mid-wait, next think ~200ms out
|
||||
Assert.Equal(thinksBefore, pet.Thinks);
|
||||
|
||||
pet.CurrentSpeed = 0.1;
|
||||
|
||||
RunFor(120);
|
||||
Assert.True(pet.Thinks > thinksBefore, "a speed-up must reschedule the pending wake");
|
||||
}
|
||||
}
|
||||
162
Projects/UOContent.Tests/Tests/Mobiles/AI/RunFlagTests.cs
Normal file
162
Projects/UOContent.Tests/Tests/Mobiles/AI/RunFlagTests.cs
Normal file
|
|
@ -0,0 +1,162 @@
|
|||
using System.Collections.Generic;
|
||||
using Server;
|
||||
using Server.Mobiles;
|
||||
using Xunit;
|
||||
|
||||
namespace UOContent.Tests.Mobiles.AI;
|
||||
|
||||
// The Running bit is derived from the step pace: a step shorter than the client's walk
|
||||
// interpolation (400ms on foot, 200ms mounted/flying) is flagged as a run.
|
||||
[Collection("Sequential Pathfinding Tests")]
|
||||
public class RunFlagTests : System.IDisposable
|
||||
{
|
||||
private readonly List<Mobile> _created = new();
|
||||
|
||||
private PetTestStub Spawn(double activeMove)
|
||||
{
|
||||
var pet = new PetTestStub();
|
||||
pet.MoveToWorld(new Point3D(1000, 1000, 0), Map.Felucca);
|
||||
pet.AIObject.AITimer?.Stop();
|
||||
pet.SetMoveSpeed(activeMove, activeMove * 3);
|
||||
pet.SetCurrentSpeedToActive();
|
||||
pet.LastMoveTime = Core.TickCount; // mid-cadence unless a test says otherwise
|
||||
_created.Add(pet);
|
||||
return pet;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
foreach (var m in _created)
|
||||
{
|
||||
m?.Delete();
|
||||
}
|
||||
|
||||
_created.Clear();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0.3, true)]
|
||||
[InlineData(0.125, true)]
|
||||
[InlineData(0.4, false)]
|
||||
[InlineData(0.45, false)]
|
||||
[InlineData(1.05, false)]
|
||||
public void FootCreature_RunsOnlyWhenFasterThanWalk(double activeMove, bool expected)
|
||||
{
|
||||
var pet = Spawn(activeMove);
|
||||
|
||||
Assert.Equal(activeMove, pet.CurrentMoveSpeed);
|
||||
Assert.Equal(expected, pet.AIObject.ShouldRun());
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0.3, false)]
|
||||
[InlineData(0.15, true)]
|
||||
public void FlyingCreature_UsesMountThresholds(double activeMove, bool expected)
|
||||
{
|
||||
var pet = Spawn(activeMove);
|
||||
pet.Flying = true;
|
||||
|
||||
Assert.Equal(expected, pet.AIObject.ShouldRun());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BadlyHurt_SlowsBelowWalk_DropsToWalk()
|
||||
{
|
||||
var pet = Spawn(0.35);
|
||||
Assert.True(pet.AIObject.ShouldRun());
|
||||
|
||||
// The hurt inflation is on the observed step pace, so the flag follows it.
|
||||
pet.SetHits(100);
|
||||
pet.Hits = 5;
|
||||
pet.SetStam(100);
|
||||
pet.Stam = 5;
|
||||
|
||||
Assert.False(pet.AIObject.ShouldRun());
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0.3, true)]
|
||||
[InlineData(0.45, false)]
|
||||
public void DoMove_StampsRunningBit(double activeMove, bool expected)
|
||||
{
|
||||
var map = Map.Maps[1];
|
||||
Assert.NotNull(map);
|
||||
map.GetAverageZ(1500, 1600, out _, out var z, out _);
|
||||
|
||||
var pet = Spawn(activeMove);
|
||||
pet.MoveToWorld(new Point3D(1500, 1600, (sbyte)z), map);
|
||||
|
||||
var ai = pet.AIObject;
|
||||
ai.NextMove = 0;
|
||||
var start = pet.Location;
|
||||
|
||||
Assert.True(ai.DoMove(Direction.West));
|
||||
Assert.NotEqual(start, pet.Location);
|
||||
Assert.Equal(expected, (pet.Direction & Direction.Running) != 0);
|
||||
}
|
||||
|
||||
// An isolated step (after standing at least a walk interval) renders alone and darts
|
||||
// if run-flagged, so it walks; continuing cadences and true sprinters keep the flag.
|
||||
[Fact]
|
||||
public void IsolatedStep_DropsToWalk()
|
||||
{
|
||||
var pet = Spawn(0.3);
|
||||
pet.LastMoveTime = Core.TickCount - 1000;
|
||||
|
||||
Assert.False(pet.AIObject.ShouldRun());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsolatedStep_SprinterStillRuns()
|
||||
{
|
||||
var pet = Spawn(0.125);
|
||||
pet.LastMoveTime = Core.TickCount - 1000;
|
||||
|
||||
Assert.True(pet.AIObject.ShouldRun());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StallDoesNotBankCatchUpSteps()
|
||||
{
|
||||
var map = Map.Maps[1];
|
||||
Assert.NotNull(map);
|
||||
map.GetAverageZ(1500, 1600, out _, out var z, out _);
|
||||
|
||||
var pet = Spawn(0.3);
|
||||
pet.MoveToWorld(new Point3D(1500, 1600, (sbyte)z), map);
|
||||
|
||||
var ai = pet.AIObject;
|
||||
ai.NextMove = Core.TickCount - 1000;
|
||||
|
||||
Assert.True(ai.DoMove(Direction.West));
|
||||
|
||||
// A stall must restart the cadence at full pace: banked catch-up steps
|
||||
// release as a burst the client renders as a sprint/teleport.
|
||||
Assert.False(ai.CanMoveNow(out _));
|
||||
Assert.True(ai.NextMove - Core.TickCount > 250);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LateStepDoesNotEarnAQuickerFollowUp()
|
||||
{
|
||||
var map = Map.Maps[1];
|
||||
Assert.NotNull(map);
|
||||
map.GetAverageZ(1500, 1600, out _, out var z, out _);
|
||||
|
||||
var pet = Spawn(0.3);
|
||||
pet.MoveToWorld(new Point3D(1500, 1600, (sbyte)z), map);
|
||||
|
||||
pet.Warmode = true; // keep the active move clock through the step
|
||||
|
||||
var ai = pet.AIObject;
|
||||
// The step lands 200ms past the budget — under one period, the reactive
|
||||
// mirroring case (think grid vs budget deadline misalignment).
|
||||
ai.NextMove = Core.TickCount - 200;
|
||||
|
||||
Assert.True(ai.DoMove(Direction.West));
|
||||
|
||||
// The debt must not be repaid: a sub-period catch-up step follows ~100ms
|
||||
// behind and renders as a dart pair beside the player.
|
||||
Assert.True(ai.NextMove - Core.TickCount > 250);
|
||||
}
|
||||
}
|
||||
146
Projects/UOContent.Tests/Tests/Mobiles/LootingRightsTests.cs
Normal file
146
Projects/UOContent.Tests/Tests/Mobiles/LootingRightsTests.cs
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
using System.Collections.Generic;
|
||||
using Server.Mobiles;
|
||||
using Xunit;
|
||||
|
||||
namespace Server.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Pins the looting-rights rules that the inline damage entry list has to keep producing: the
|
||||
/// returned stores are sorted by damage descending, the first (least recent) damager takes the
|
||||
/// 1.25x bonus, the hitsMax band decides who clears the threshold, and a pet's damage is credited
|
||||
/// to its damage master rather than to the pet.
|
||||
/// </summary>
|
||||
[Collection("Sequential UOContent Tests")]
|
||||
public class LootingRightsTests
|
||||
{
|
||||
private class TestMobile : Mobile
|
||||
{
|
||||
}
|
||||
|
||||
private class PetMobile : Mobile
|
||||
{
|
||||
public Mobile Master { get; set; }
|
||||
|
||||
public override Mobile GetDamageMaster(Mobile damagee) => Master;
|
||||
}
|
||||
|
||||
// GetLootingRights only ever credits mobiles flagged as players.
|
||||
private static TestMobile NewPlayer() => new() { Player = true };
|
||||
|
||||
private static DamageStore FindStore(List<DamageStore> rights, Mobile m)
|
||||
{
|
||||
for (var i = 0; i < rights.Count; i++)
|
||||
{
|
||||
if (rights[i].m_Mobile == m)
|
||||
{
|
||||
return rights[i];
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TwoPlayerDamagers_SortDescending_AndTheFirstDamagerTakesTheBonus()
|
||||
{
|
||||
var victim = new TestMobile();
|
||||
var first = NewPlayer();
|
||||
var second = NewPlayer();
|
||||
|
||||
try
|
||||
{
|
||||
victim.RegisterDamage(100, first);
|
||||
victim.RegisterDamage(40, second); // second is the most recent, first is the "first damager"
|
||||
|
||||
// hitsMax < 200 puts the bar at topDamage / 2.
|
||||
var rights = BaseCreature.GetLootingRights(victim.DamageEntries, 100);
|
||||
|
||||
Assert.Equal(2, rights.Count);
|
||||
|
||||
// Sorted by damage descending.
|
||||
Assert.True(rights[0].m_Damage >= rights[1].m_Damage);
|
||||
Assert.Same(first, rights[0].m_Mobile);
|
||||
Assert.Same(second, rights[1].m_Mobile);
|
||||
|
||||
// The first damager - the least recent entry - gets the 1.25x bonus; nobody else does.
|
||||
Assert.Equal(125, rights[0].m_Damage);
|
||||
Assert.Equal(40, rights[1].m_Damage);
|
||||
|
||||
// topDamage 125 / 2 = 62, so 40 is below the bar.
|
||||
Assert.True(rights[0].m_HasRight);
|
||||
Assert.False(rights[1].m_HasRight);
|
||||
}
|
||||
finally
|
||||
{
|
||||
victim.Delete();
|
||||
first.Delete();
|
||||
second.Delete();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HitsMaxBand_MovesTheRightsThreshold()
|
||||
{
|
||||
var victim = new TestMobile();
|
||||
var first = NewPlayer();
|
||||
var second = NewPlayer();
|
||||
|
||||
try
|
||||
{
|
||||
victim.RegisterDamage(100, first);
|
||||
victim.RegisterDamage(40, second);
|
||||
|
||||
// hitsMax >= 200 drops the bar to topDamage / 4 = 31, which 40 clears.
|
||||
var rights = BaseCreature.GetLootingRights(victim.DamageEntries, 200);
|
||||
|
||||
Assert.Equal(2, rights.Count);
|
||||
Assert.True(rights[0].m_HasRight);
|
||||
Assert.True(rights[1].m_HasRight);
|
||||
Assert.Same(second, rights[1].m_Mobile);
|
||||
}
|
||||
finally
|
||||
{
|
||||
victim.Delete();
|
||||
first.Delete();
|
||||
second.Delete();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PetDamage_CreditsTheMaster_NotThePet()
|
||||
{
|
||||
var victim = new TestMobile();
|
||||
var master = NewPlayer();
|
||||
var pet = new PetMobile { Master = master };
|
||||
var wild = new TestMobile(); // no damage master, and not a player
|
||||
|
||||
try
|
||||
{
|
||||
victim.RegisterDamage(50, pet);
|
||||
victim.RegisterDamage(20, wild);
|
||||
|
||||
var rights = BaseCreature.GetLootingRights(victim.DamageEntries, 100);
|
||||
|
||||
// The master is credited through the entry's Responsible sub-entry, and is the only one.
|
||||
Assert.Single(rights);
|
||||
|
||||
var masterStore = FindStore(rights, master);
|
||||
Assert.NotNull(masterStore);
|
||||
Assert.Equal(62, masterStore.m_Damage); // 50, then the first-damager 1.25x bonus
|
||||
Assert.True(masterStore.m_HasRight);
|
||||
|
||||
// The pet's own damage was fully handed to the master, so it earns no store.
|
||||
Assert.Null(FindStore(rights, pet));
|
||||
|
||||
// A non-player damager earns nothing even when its damage was never reassigned.
|
||||
Assert.Null(FindStore(rights, wild));
|
||||
}
|
||||
finally
|
||||
{
|
||||
victim.Delete();
|
||||
master.Delete();
|
||||
pet.Delete();
|
||||
wild.Delete();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -20,8 +20,8 @@ using Xunit;
|
|||
|
||||
namespace Server.Tests.Network.AutoDenylists;
|
||||
|
||||
// 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.
|
||||
// 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).
|
||||
[Collection("Sequential UOContent Tests")]
|
||||
public class AutoDenylistTests
|
||||
{
|
||||
|
|
@ -62,8 +62,11 @@ 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_extends_the_hold()
|
||||
public void Repeat_detection_does_not_extend_the_hold()
|
||||
{
|
||||
Reset();
|
||||
var ip = IPAddress.Parse("198.51.100.13");
|
||||
|
|
@ -71,8 +74,72 @@ public class AutoDenylistTests
|
|||
AutoDenylist.Hold(ip, BanReasons.SilentConnect, Now);
|
||||
AutoDenylist.Hold(ip, BanReasons.SilentConnect, Now + DurationMs - 1);
|
||||
|
||||
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
|
||||
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);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
|
|||
|
|
@ -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 WithFileAllowlist(string contents) =>
|
||||
FileAllowlist.LoadForTesting(BlocklistSnapshot.Build(Encoding.ASCII.GetBytes(contents), out _, out _));
|
||||
private static void WithManualAllowlist(string contents) =>
|
||||
ManualAllowlist.LoadForTesting(BlocklistSnapshot.Build(Encoding.ASCII.GetBytes(contents), out _, out _));
|
||||
|
||||
private static void WithEmptyFileAllowlist() => FileAllowlist.LoadForTesting(BlocklistSnapshot.Empty);
|
||||
private static void WithEmptyManualAllowlist() => ManualAllowlist.LoadForTesting(BlocklistSnapshot.Empty);
|
||||
|
||||
[Fact]
|
||||
public void File_allowlist_exempts_behavioral_contributions()
|
||||
public void Manual_allowlist_exempts_behavioral_contributions()
|
||||
{
|
||||
WithFileAllowlist("192.0.2.10");
|
||||
WithManualAllowlist("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 File_allowlist_covers_cidr_entries()
|
||||
public void Manual_allowlist_covers_cidr_entries()
|
||||
{
|
||||
// Carve-outs are CIDRs, so a shared-CGNAT player is only covered if ranges work here.
|
||||
WithFileAllowlist("192.0.2.0/24");
|
||||
WithManualAllowlist("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_file_allowlisted()
|
||||
public void Manual_bans_are_never_exempt_even_when_allowlisted()
|
||||
{
|
||||
WithFileAllowlist("192.0.2.10");
|
||||
WithManualAllowlist("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()
|
||||
{
|
||||
WithFileAllowlist("192.0.2.10");
|
||||
WithManualAllowlist("192.0.2.10");
|
||||
|
||||
Assert.False(BanExemptions.IsExempt(_listed, BanReasons.Blocklist, NeverCalled));
|
||||
Assert.False(BanExemptions.IsExempt(_listed, "some-future-reason", NeverCalled));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void File_allowlist_does_not_spend_the_earned_lists_strikes()
|
||||
public void Manual_allowlist_does_not_spend_the_earned_lists_strikes()
|
||||
{
|
||||
WithFileAllowlist("192.0.2.10");
|
||||
WithManualAllowlist("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()
|
||||
{
|
||||
WithEmptyFileAllowlist();
|
||||
WithEmptyManualAllowlist();
|
||||
|
||||
var consulted = 0;
|
||||
|
||||
|
|
@ -107,7 +107,7 @@ public class BanExemptionsTests
|
|||
[Fact]
|
||||
public void Null_address_is_never_exempt()
|
||||
{
|
||||
WithEmptyFileAllowlist();
|
||||
WithEmptyManualAllowlist();
|
||||
|
||||
Assert.False(BanExemptions.IsExempt(null, BanReasons.RateLimit, NeverCalled));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ public class BlocklistConfigurationTests
|
|||
{
|
||||
var original = new BlocklistSettings
|
||||
{
|
||||
Enabled = true,
|
||||
File = "D:/shared/ip-blocklist.txt",
|
||||
ReloadInterval = TimeSpan.FromMinutes(5),
|
||||
ReportHits = false,
|
||||
|
|
@ -39,6 +40,7 @@ public class BlocklistConfigurationTests
|
|||
|
||||
var json = JsonConfig.Serialize(original);
|
||||
|
||||
Assert.Contains("\"enabled\"", json);
|
||||
Assert.Contains("\"file\"", json);
|
||||
Assert.Contains("\"reloadInterval\"", json);
|
||||
Assert.Contains("\"reportHits\"", json);
|
||||
|
|
@ -48,6 +50,7 @@ public class BlocklistConfigurationTests
|
|||
var restored = JsonSerializer.Deserialize<BlocklistSettings>(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);
|
||||
|
|
@ -55,6 +58,13 @@ 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]
|
||||
|
|
|
|||
|
|
@ -0,0 +1,66 @@
|
|||
/*************************************************************************
|
||||
* 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 <http://www.gnu.org/licenses/>. *
|
||||
*************************************************************************/
|
||||
|
||||
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<ManualAllowlistSettings>(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);
|
||||
}
|
||||
}
|
||||
|
|
@ -4,9 +4,9 @@
|
|||
<Configurations>Debug;Release;Analyze</Configurations>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.8.1" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.9.0" />
|
||||
<PackageReference Include="xunit" Version="2.9.3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.5">
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="4.0.0">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
|
|
@ -15,7 +15,6 @@
|
|||
<ProjectReference Include="..\UOContent\UOContent.csproj" />
|
||||
<ProjectReference Include="..\Server.Tests\Server.Tests.csproj" />
|
||||
<DataFiles Include="$(SolutionDir)\Distribution\Data\**" />
|
||||
<PackageReference Update="Serilog" Version="4.4.0" />
|
||||
</ItemGroup>
|
||||
<Target Name="CopyData" AfterTargets="AfterBuild">
|
||||
<Copy SourceFiles="@(DataFiles)" DestinationFolder="$(OutDir)\Data\%(RecursiveDir)" />
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -33,17 +33,13 @@ public partial class ChampionSkullBrazier : AddonComponent
|
|||
[SerializedCommandProperty(AccessLevel.GameMaster)]
|
||||
private ChampionSkullPlatform _platform;
|
||||
|
||||
[SerializableProperty(2)]
|
||||
[CommandProperty(AccessLevel.GameMaster)]
|
||||
public Item Skull
|
||||
[SerializableField(2, fieldChanged: nameof(OnSkullChanged))]
|
||||
[SerializedCommandProperty(AccessLevel.GameMaster)]
|
||||
private Item _skull;
|
||||
|
||||
private void OnSkullChanged(Item oldValue, Item newValue)
|
||||
{
|
||||
get => _skull;
|
||||
set
|
||||
{
|
||||
_skull = value;
|
||||
this.MarkDirty();
|
||||
_platform?.Validate();
|
||||
}
|
||||
_platform?.Validate();
|
||||
}
|
||||
|
||||
public override int LabelNumber => 1049489 + (int)_type;
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ using System.Net;
|
|||
using System.Collections.Generic;
|
||||
using System.Runtime.InteropServices;
|
||||
using ModernUO.Serialization;
|
||||
using Server.Collections;
|
||||
using Server.Engines.Virtues;
|
||||
using Server.Gumps;
|
||||
using Server.Items;
|
||||
|
|
@ -27,16 +28,44 @@ using Server.Logging;
|
|||
|
||||
namespace Server.Engines.CannedEvil;
|
||||
|
||||
[SerializationGenerator(10, false)]
|
||||
[SerializationGenerator(11, false)]
|
||||
public partial class ChampionSpawn : Item
|
||||
{
|
||||
private void MigrateFrom(V10Content content)
|
||||
{
|
||||
_level = content.Level;
|
||||
_activatedByProximity = content.ActivatedByProximity;
|
||||
_nextProximityTime = content.NextProximityTime;
|
||||
_maxLevel = content.MaxLevel;
|
||||
_activatedByValor = content.ActivatedByValor;
|
||||
_damageEntries = content.DamageEntries;
|
||||
_confinedRoaming = content.ConfinedRoaming;
|
||||
_idol = content.Idol;
|
||||
_hasBeenAdvanced = content.HasBeenAdvanced;
|
||||
_spawnArea = content.SpawnArea;
|
||||
_randomizeType = content.RandomizeType;
|
||||
_kills = content.Kills;
|
||||
_active = content.Active;
|
||||
_type = content.Type;
|
||||
_creatures = content.Creatures;
|
||||
_redSkulls = content.RedSkulls;
|
||||
_whiteSkulls = content.WhiteSkulls;
|
||||
_platform = content.Platform;
|
||||
_altar = content.Altar;
|
||||
_expireDelay = content.ExpireDelay;
|
||||
_expireTime = content.ExpireTime;
|
||||
_champion = content.Champion;
|
||||
_restartDelay = content.RestartDelay;
|
||||
_restartTime = content.RestartTime;
|
||||
}
|
||||
|
||||
private static readonly ILogger logger = LogFactory.GetLogger(typeof(ChampionSpawn));
|
||||
|
||||
[SerializableField(1)]
|
||||
[SerializedCommandProperty(AccessLevel.GameMaster)]
|
||||
private bool _activatedByProximity;
|
||||
|
||||
[DeltaDateTime]
|
||||
[AnchoredDateTime]
|
||||
[SerializableField(2)]
|
||||
[SerializedCommandProperty(AccessLevel.GameMaster)]
|
||||
private DateTime _nextProximityTime;
|
||||
|
|
@ -96,7 +125,7 @@ public partial class ChampionSpawn : Item
|
|||
[SerializedCommandProperty(AccessLevel.GameMaster)]
|
||||
private TimeSpan _expireDelay;
|
||||
|
||||
[DeltaDateTime]
|
||||
[AnchoredDateTime]
|
||||
[SerializableField(20)]
|
||||
[SerializedCommandProperty(AccessLevel.GameMaster)]
|
||||
private DateTime _expireTime;
|
||||
|
|
@ -109,7 +138,7 @@ public partial class ChampionSpawn : Item
|
|||
[SerializedCommandProperty(AccessLevel.GameMaster)]
|
||||
private TimeSpan _restartDelay;
|
||||
|
||||
[DeltaDateTime]
|
||||
[AnchoredDateTime]
|
||||
[SerializableField(23, setter: "private")]
|
||||
[SerializedCommandProperty(AccessLevel.GameMaster)]
|
||||
private DateTime _restartTime;
|
||||
|
|
@ -203,47 +232,38 @@ public partial class ChampionSpawn : Item
|
|||
}
|
||||
}
|
||||
|
||||
[SerializableProperty(3)]
|
||||
[CommandProperty(AccessLevel.GameMaster, AccessLevel.Administrator)]
|
||||
public int MaxLevel
|
||||
[SerializableField(3, allowFieldChange: nameof(AllowMaxLevelChange))]
|
||||
[SerializedCommandProperty(AccessLevel.GameMaster, AccessLevel.Administrator)]
|
||||
private int _maxLevel;
|
||||
|
||||
private bool AllowMaxLevelChange(ref int value)
|
||||
{
|
||||
get => _maxLevel;
|
||||
set => _maxLevel = Math.Clamp(value, 0, 18);
|
||||
value = Math.Clamp(value, 0, 18);
|
||||
return true;
|
||||
}
|
||||
|
||||
[SerializableProperty(9)]
|
||||
[CommandProperty(AccessLevel.GameMaster)]
|
||||
public Rectangle2D SpawnArea
|
||||
[SerializableField(9, fieldChanged: nameof(OnSpawnAreaChanged))]
|
||||
[SerializedCommandProperty(AccessLevel.GameMaster)]
|
||||
[InvalidateProperties]
|
||||
private Rectangle2D _spawnArea;
|
||||
|
||||
private void OnSpawnAreaChanged(Rectangle2D oldValue, Rectangle2D newValue)
|
||||
{
|
||||
get => _spawnArea;
|
||||
set
|
||||
{
|
||||
_spawnArea = value;
|
||||
this.MarkDirty();
|
||||
InvalidateProperties();
|
||||
UpdateRegion();
|
||||
}
|
||||
UpdateRegion();
|
||||
}
|
||||
|
||||
[SerializableProperty(11)]
|
||||
[CommandProperty(AccessLevel.GameMaster)]
|
||||
public int Kills
|
||||
[SerializableField(11, fieldChanged: nameof(OnKillsChanged))]
|
||||
[SerializedCommandProperty(AccessLevel.GameMaster)]
|
||||
[InvalidateProperties]
|
||||
private int _kills;
|
||||
|
||||
private void OnKillsChanged(int oldValue, int newValue)
|
||||
{
|
||||
get => _kills;
|
||||
set
|
||||
var n = _kills / (double)MaxKills;
|
||||
var p = (int)(n * 100);
|
||||
if (p < 90)
|
||||
{
|
||||
_kills = value;
|
||||
this.MarkDirty();
|
||||
|
||||
var n = _kills / (double)MaxKills;
|
||||
var p = (int)(n * 100);
|
||||
|
||||
if (p < 90)
|
||||
{
|
||||
SetWhiteSkullCount(p / 20);
|
||||
}
|
||||
|
||||
InvalidateProperties();
|
||||
SetWhiteSkullCount(p / 20);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1162,11 +1182,6 @@ public partial class ChampionSpawn : Item
|
|||
|
||||
foreach (var de in m.DamageEntries)
|
||||
{
|
||||
if (de.HasExpired)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var damager = de.Damager;
|
||||
var master = damager.GetDamageMaster(m);
|
||||
|
||||
|
|
|
|||
|
|
@ -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)]
|
||||
|
|
|
|||
|
|
@ -3,15 +3,21 @@ using ModernUO.Serialization;
|
|||
|
||||
namespace Server.Items;
|
||||
|
||||
[SerializationGenerator(1, false)]
|
||||
[SerializationGenerator(2, false)]
|
||||
public partial class StarRoomGate : Moongate
|
||||
{
|
||||
private void MigrateFrom(V1Content content)
|
||||
{
|
||||
_decays = content.Decays;
|
||||
_decayTime = content.DecayTime;
|
||||
}
|
||||
|
||||
private static TimeSpan GateDuration = TimeSpan.FromMinutes(2.0);
|
||||
|
||||
[SerializableField(0)]
|
||||
private bool _decays;
|
||||
|
||||
[DeltaDateTime]
|
||||
[AnchoredDateTime]
|
||||
[SerializableField(1)]
|
||||
private DateTime _decayTime;
|
||||
|
||||
|
|
|
|||
|
|
@ -830,10 +830,9 @@ public partial class BRBomb : Item
|
|||
[SerializationGenerator(0, false)]
|
||||
public partial class BRGoal : BaseAddon
|
||||
{
|
||||
[SerializableField(0)]
|
||||
[SerializableField(0, fieldChanged: nameof(OnNorthChanged))]
|
||||
private bool _north;
|
||||
|
||||
[SerializableFieldChanged(0)]
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
private void OnNorthChanged(bool oldValue, bool newValue) => Remake();
|
||||
|
||||
|
|
|
|||
|
|
@ -252,10 +252,9 @@ public partial class HillOfTheKing : Item
|
|||
public partial class KHBoard : Item
|
||||
{
|
||||
[SerializedCommandProperty(AccessLevel.GameMaster)]
|
||||
[SerializableField(0)]
|
||||
[SerializableField(0, fieldChanged: nameof(OnControllerChanged))]
|
||||
private KHController _controller;
|
||||
|
||||
[SerializableFieldChanged(0)]
|
||||
private void OnControllerChanged(KHController oldValue, KHController newValue)
|
||||
{
|
||||
oldValue?.RemoveBoard(this);
|
||||
|
|
|
|||
|
|
@ -64,17 +64,13 @@ public partial class Trophy : Item
|
|||
UpdateStyle();
|
||||
}
|
||||
|
||||
[SerializableProperty(1)]
|
||||
[CommandProperty(AccessLevel.GameMaster)]
|
||||
public TrophyRank Rank
|
||||
[SerializableField(1, fieldChanged: nameof(OnRankChanged))]
|
||||
[SerializedCommandProperty(AccessLevel.GameMaster)]
|
||||
private TrophyRank _rank;
|
||||
|
||||
private void OnRankChanged(TrophyRank oldValue, TrophyRank newValue)
|
||||
{
|
||||
get => _rank;
|
||||
set
|
||||
{
|
||||
_rank = value;
|
||||
UpdateStyle();
|
||||
this.MarkDirty();
|
||||
}
|
||||
UpdateStyle();
|
||||
}
|
||||
|
||||
private void Deserialize(IGenericReader reader, int version)
|
||||
|
|
|
|||
|
|
@ -1451,6 +1451,9 @@ 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);
|
||||
|
|
@ -1742,6 +1745,9 @@ 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);
|
||||
|
|
|
|||
|
|
@ -5,9 +5,20 @@ using Server.Mobiles;
|
|||
namespace Server.Ethics;
|
||||
|
||||
[PropertyObject]
|
||||
[SerializationGenerator(1)]
|
||||
[SerializationGenerator(2)]
|
||||
public partial class Player : EthicsEntity
|
||||
{
|
||||
private void MigrateFrom(V1Content content)
|
||||
{
|
||||
_mobile = content.Mobile;
|
||||
_power = content.Power;
|
||||
_history = content.History;
|
||||
_steed = content.Steed;
|
||||
_familiar = content.Familiar;
|
||||
_shield = content.Shield;
|
||||
_ethic = content.Ethic;
|
||||
}
|
||||
|
||||
[SerializableField(0, setter: "private")]
|
||||
private Mobile _mobile;
|
||||
|
||||
|
|
@ -27,7 +38,7 @@ public partial class Player : EthicsEntity
|
|||
[SerializedCommandProperty(AccessLevel.GameMaster, AccessLevel.Administrator)]
|
||||
private Mobile _familiar;
|
||||
|
||||
[DeltaDateTime]
|
||||
[AnchoredDateTime]
|
||||
[SerializableField(5, setter: "private")]
|
||||
private DateTime _shield;
|
||||
|
||||
|
|
|
|||
|
|
@ -359,14 +359,14 @@ namespace Server.Factions
|
|||
{
|
||||
if (m_Mobile.InRange( m, 1 ))
|
||||
RunFrom( m );
|
||||
else if (!m_Mobile.InRange( m, m_Mobile.RangeFight > 2 ? m_Mobile.RangeFight : 2 ) && !MoveTo( m, true, 1 ))
|
||||
else if (!m_Mobile.InRange( m, m_Mobile.RangeFight > 2 ? m_Mobile.RangeFight : 2 ) && !MoveTo(m, 1))
|
||||
OnFailedMove();
|
||||
}
|
||||
else
|
||||
{*/
|
||||
if (!Mobile.InRange(m, Mobile.RangeFight))
|
||||
{
|
||||
if (!MoveTo(m, true, 1))
|
||||
if (!MoveTo(m, 1))
|
||||
{
|
||||
OnFailedMove();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -219,7 +219,7 @@ namespace Server.Engines.Harvest
|
|||
}
|
||||
else
|
||||
{
|
||||
item.Delete();
|
||||
bonusItem?.Delete();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -162,10 +162,15 @@ namespace Server.Items
|
|||
}
|
||||
}
|
||||
|
||||
[SerializationGenerator(0)]
|
||||
[SerializationGenerator(1)]
|
||||
public partial class PuzzleChestSolutionAndTime : PuzzleChestSolution
|
||||
{
|
||||
[DeltaDateTime]
|
||||
private void MigrateFrom(V0Content content)
|
||||
{
|
||||
_when = content.When;
|
||||
}
|
||||
|
||||
[AnchoredDateTime]
|
||||
[SerializableField(0)]
|
||||
private DateTime _when;
|
||||
|
||||
|
|
@ -237,16 +242,12 @@ namespace Server.Items
|
|||
}
|
||||
}
|
||||
|
||||
[SerializableProperty(0)]
|
||||
public PuzzleChestSolution Solution
|
||||
[SerializableField(0, fieldChanged: nameof(OnSolutionChanged))]
|
||||
private PuzzleChestSolution _solution;
|
||||
|
||||
private void OnSolutionChanged(PuzzleChestSolution oldValue, PuzzleChestSolution newValue)
|
||||
{
|
||||
get => _solution;
|
||||
set
|
||||
{
|
||||
_solution = value;
|
||||
InitHints();
|
||||
this.MarkDirty();
|
||||
}
|
||||
InitHints();
|
||||
}
|
||||
|
||||
public PuzzleChestCylinder FirstHint
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ namespace Server.Engines.MLQuests
|
|||
{
|
||||
base.Serialize(writer);
|
||||
|
||||
writer.Write(2); // version
|
||||
writer.Write(3); // version
|
||||
writer.Write(MLQuestSystem.Contexts.Count);
|
||||
|
||||
foreach (var context in MLQuestSystem.Contexts.Values)
|
||||
|
|
|
|||
|
|
@ -119,7 +119,7 @@ namespace Server.Engines.MLQuests.Objectives
|
|||
if (IsTimed)
|
||||
{
|
||||
writer.Write(true);
|
||||
writer.WriteDeltaTime(EndTime);
|
||||
writer.WriteAnchoredTime(EndTime);
|
||||
}
|
||||
else
|
||||
{
|
||||
|
|
@ -135,7 +135,7 @@ namespace Server.Engines.MLQuests.Objectives
|
|||
{
|
||||
if (reader.ReadBool())
|
||||
{
|
||||
var endTime = reader.ReadDeltaTime();
|
||||
var endTime = version >= 3 ? reader.ReadAnchoredTime() : reader.ReadDeltaTime();
|
||||
|
||||
if (objInstance != null)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -83,7 +83,7 @@ public class PathFollower
|
|||
public static bool Check(Point3D loc, Point3D goal, int range) =>
|
||||
Utility.InRange(loc, goal, range) && (range > 1 || (loc.Z - goal.Z).Abs() < 16);
|
||||
|
||||
public bool Follow(bool run, int range)
|
||||
public bool Follow(int range)
|
||||
{
|
||||
var goal = GetGoalLocation();
|
||||
Direction d;
|
||||
|
|
@ -97,13 +97,13 @@ public class PathFollower
|
|||
|
||||
if (!(Enabled && m_Path.Success))
|
||||
{
|
||||
d = m_From.GetDirectionTo(goal, run);
|
||||
d = m_From.GetDirectionTo(goal);
|
||||
m_From.SetDirection(d);
|
||||
|
||||
return Move(d) is MoveResult.Success or MoveResult.SuccessAutoTurn && Check(m_From.Location, goal, range);
|
||||
}
|
||||
|
||||
d = m_From.GetDirectionTo(m_Next, run);
|
||||
d = m_From.GetDirectionTo(m_Next);
|
||||
m_From.SetDirection(d);
|
||||
var res = Move(d);
|
||||
|
||||
|
|
|
|||
|
|
@ -37,17 +37,17 @@ public partial class PlantItem : Item, ISecurable
|
|||
|
||||
[SerializedIgnoreDupe]
|
||||
[SerializableField(0)]
|
||||
[SaveFlag(nameof(ShouldSerializeSecureLevel))]
|
||||
[SerializedCommandProperty(AccessLevel.GameMaster)]
|
||||
private SecureLevel _level;
|
||||
|
||||
[SerializableFieldSaveFlag(0)]
|
||||
private bool ShouldSerializeSecureLevel() => (int)_level != 0;
|
||||
|
||||
[SerializedIgnoreDupe]
|
||||
[SerializableField(5, setter: "private")]
|
||||
[SaveFlag(nameof(ShouldSerializePlantSystem))]
|
||||
private PlantSystem _plantSystem;
|
||||
|
||||
[SerializableFieldSaveFlag(5)]
|
||||
private bool ShouldSerializePlantSystem() => _plantStatus < PlantStatus.DecorativePlant;
|
||||
|
||||
// For clients older than 7.0.12.0
|
||||
|
|
@ -82,6 +82,7 @@ public partial class PlantItem : Item, ISecurable
|
|||
|
||||
[CommandProperty(AccessLevel.GameMaster)]
|
||||
[SerializableProperty(1)]
|
||||
[SaveFlag(nameof(ShouldSerializePlantStatus))]
|
||||
public PlantStatus PlantStatus
|
||||
{
|
||||
get => _plantStatus;
|
||||
|
|
@ -120,53 +121,38 @@ public partial class PlantItem : Item, ISecurable
|
|||
}
|
||||
}
|
||||
|
||||
[SerializableFieldSaveFlag(1)]
|
||||
private bool ShouldSerializePlantStatus() => _plantStatus != PlantStatus.BowlOfDirt;
|
||||
|
||||
[SerializableProperty(2)]
|
||||
[CommandProperty(AccessLevel.GameMaster)]
|
||||
public PlantType PlantType
|
||||
[SerializableField(2, fieldChanged: nameof(OnPlantTypeChanged))]
|
||||
[SaveFlag(nameof(ShouldSerializePlantType))]
|
||||
[SerializedCommandProperty(AccessLevel.GameMaster)]
|
||||
private PlantType _plantType;
|
||||
|
||||
private void OnPlantTypeChanged(PlantType oldValue, PlantType newValue)
|
||||
{
|
||||
get => _plantType;
|
||||
set
|
||||
{
|
||||
_plantType = value;
|
||||
Update();
|
||||
}
|
||||
Update();
|
||||
}
|
||||
|
||||
[SerializableFieldSaveFlag(2)]
|
||||
private bool ShouldSerializePlantType() => (int)_plantType != 0;
|
||||
|
||||
[SerializableProperty(3)]
|
||||
[CommandProperty(AccessLevel.GameMaster)]
|
||||
public PlantHue PlantHue
|
||||
[SerializableField(3, fieldChanged: nameof(OnPlantHueChanged))]
|
||||
[SaveFlag(nameof(ShouldSerializePlantHue))]
|
||||
[SerializedCommandProperty(AccessLevel.GameMaster)]
|
||||
private PlantHue _plantHue;
|
||||
|
||||
private void OnPlantHueChanged(PlantHue oldValue, PlantHue newValue)
|
||||
{
|
||||
get => _plantHue;
|
||||
set
|
||||
{
|
||||
_plantHue = value;
|
||||
Update();
|
||||
}
|
||||
Update();
|
||||
}
|
||||
|
||||
[SerializableFieldSaveFlag(3)]
|
||||
private bool ShouldSerializePlantHue() => _plantHue != PlantHue.None;
|
||||
|
||||
[SerializableProperty(4)]
|
||||
[CommandProperty(AccessLevel.GameMaster)]
|
||||
public bool ShowType
|
||||
{
|
||||
get => _showType;
|
||||
set
|
||||
{
|
||||
_showType = value;
|
||||
InvalidateProperties();
|
||||
this.MarkDirty();
|
||||
}
|
||||
}
|
||||
[SerializableField(4)]
|
||||
[SaveFlag(nameof(ShouldSerializeShowType))]
|
||||
[SerializedCommandProperty(AccessLevel.GameMaster)]
|
||||
[InvalidateProperties]
|
||||
private bool _showType;
|
||||
|
||||
[SerializableFieldSaveFlag(4)]
|
||||
private bool ShouldSerializeShowType() => _showType;
|
||||
|
||||
[CommandProperty(AccessLevel.GameMaster)]
|
||||
|
|
|
|||
|
|
@ -33,24 +33,24 @@ namespace Server.Engines.Plants
|
|||
private PlantItem _plant;
|
||||
|
||||
[SerializableField(0)]
|
||||
[SaveFlag(nameof(ShouldSerializeFertileDirt))]
|
||||
private bool _fertileDirt;
|
||||
|
||||
[SerializableFieldSaveFlag(0)]
|
||||
private bool ShouldSerializeFertileDirt() => _fertileDirt;
|
||||
|
||||
[SerializableField(1)]
|
||||
private DateTime _nextGrowth;
|
||||
|
||||
[SerializableField(2, setter: "private")]
|
||||
[SaveFlag(nameof(ShouldSerializeGrowthIndicator))]
|
||||
private PlantGrowthIndicator _growthIndicator;
|
||||
|
||||
[SerializableFieldSaveFlag(2)]
|
||||
private bool ShouldSerializeGrowthIndicator() => _growthIndicator != PlantGrowthIndicator.None;
|
||||
|
||||
[SerializableField(13)]
|
||||
[SaveFlag(nameof(ShouldSerializePollinated))]
|
||||
private bool _pollinated;
|
||||
|
||||
[SerializableFieldSaveFlag(13)]
|
||||
private bool ShouldSerializePollinated() => _pollinated;
|
||||
|
||||
public PlantSystem(PlantItem plant)
|
||||
|
|
@ -97,45 +97,42 @@ namespace Server.Engines.Plants
|
|||
|
||||
public bool IsFullWater => _water >= 4;
|
||||
|
||||
[SerializableProperty(3)]
|
||||
public int Water
|
||||
[SerializableField(3, fieldChanged: nameof(OnWaterChanged), allowFieldChange: nameof(AllowWaterChange))]
|
||||
[SaveFlag(nameof(ShouldSerializeWater))]
|
||||
private int _water;
|
||||
|
||||
private bool AllowWaterChange(ref int value)
|
||||
{
|
||||
get => _water;
|
||||
set
|
||||
{
|
||||
_water = Math.Clamp(value, 0, 4);
|
||||
Plant.InvalidateProperties();
|
||||
MarkDirty();
|
||||
}
|
||||
value = Math.Clamp(value, 0, 4);
|
||||
return true;
|
||||
}
|
||||
|
||||
private void OnWaterChanged(int oldValue, int newValue)
|
||||
{
|
||||
Plant.InvalidateProperties();
|
||||
}
|
||||
|
||||
[SerializableFieldSaveFlag(3)]
|
||||
private bool ShouldSerializeWater() => _water != 0;
|
||||
|
||||
[SerializableProperty(4)]
|
||||
public int Hits
|
||||
[SerializableField(4, fieldChanged: nameof(OnHitsChanged), allowFieldChange: nameof(AllowHitsChange))]
|
||||
[SaveFlag(nameof(ShouldSerializeHits))]
|
||||
private int _hits;
|
||||
|
||||
private bool AllowHitsChange(ref int value)
|
||||
{
|
||||
get => _hits;
|
||||
set
|
||||
{
|
||||
if (_hits == value)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_hits = Math.Clamp(value, 0, MaxHits);
|
||||
|
||||
if (_hits == 0)
|
||||
{
|
||||
Plant.Die();
|
||||
}
|
||||
|
||||
Plant.InvalidateProperties();
|
||||
MarkDirty();
|
||||
}
|
||||
value = Math.Clamp(value, 0, MaxHits);
|
||||
return true;
|
||||
}
|
||||
|
||||
private void OnHitsChanged(int oldValue, int newValue)
|
||||
{
|
||||
if (_hits == 0)
|
||||
{
|
||||
Plant.Die();
|
||||
}
|
||||
Plant.InvalidateProperties();
|
||||
}
|
||||
|
||||
[SerializableFieldSaveFlag(4)]
|
||||
private bool ShouldSerializeHits() => _hits != 0;
|
||||
|
||||
public int MaxHits => 10 + (int)Plant.PlantStatus * 2;
|
||||
|
|
@ -149,124 +146,108 @@ namespace Server.Engines.Plants
|
|||
_ => PlantHealth.Vibrant
|
||||
};
|
||||
|
||||
[SerializableProperty(5)]
|
||||
public int Infestation
|
||||
[SerializableField(5, allowFieldChange: nameof(AllowInfestationChange))]
|
||||
[SaveFlag(nameof(ShouldSerializeInfestation))]
|
||||
private int _infestation;
|
||||
|
||||
private bool AllowInfestationChange(ref int value)
|
||||
{
|
||||
get => _infestation;
|
||||
set
|
||||
{
|
||||
_infestation = Math.Clamp(value, 0, 2);
|
||||
MarkDirty();
|
||||
}
|
||||
value = Math.Clamp(value, 0, 2);
|
||||
return true;
|
||||
}
|
||||
|
||||
[SerializableFieldSaveFlag(5)]
|
||||
private bool ShouldSerializeInfestation() => _infestation != 0;
|
||||
|
||||
[SerializableProperty(6)]
|
||||
public int Fungus
|
||||
[SerializableField(6, allowFieldChange: nameof(AllowFungusChange))]
|
||||
[SaveFlag(nameof(ShouldSerializeFungus))]
|
||||
private int _fungus;
|
||||
|
||||
private bool AllowFungusChange(ref int value)
|
||||
{
|
||||
get => _fungus;
|
||||
set
|
||||
{
|
||||
_fungus = Math.Clamp(value, 0, 2);
|
||||
MarkDirty();
|
||||
}
|
||||
value = Math.Clamp(value, 0, 2);
|
||||
return true;
|
||||
}
|
||||
|
||||
[SerializableFieldSaveFlag(6)]
|
||||
private bool ShouldSerializeFungus() => _fungus != 0;
|
||||
|
||||
[SerializableProperty(7)]
|
||||
public int Poison
|
||||
[SerializableField(7, allowFieldChange: nameof(AllowPoisonChange))]
|
||||
[SaveFlag(nameof(ShouldSerializePoison))]
|
||||
private int _poison;
|
||||
|
||||
private bool AllowPoisonChange(ref int value)
|
||||
{
|
||||
get => _poison;
|
||||
set
|
||||
{
|
||||
_poison = Math.Clamp(value, 0, 2);
|
||||
MarkDirty();
|
||||
}
|
||||
value = Math.Clamp(value, 0, 2);
|
||||
return true;
|
||||
}
|
||||
|
||||
[SerializableFieldSaveFlag(7)]
|
||||
private bool ShouldSerializePoison() => _poison != 0;
|
||||
|
||||
[SerializableProperty(8)]
|
||||
public int Disease
|
||||
[SerializableField(8, allowFieldChange: nameof(AllowDiseaseChange))]
|
||||
[SaveFlag(nameof(ShouldSerializeDisease))]
|
||||
private int _disease;
|
||||
|
||||
private bool AllowDiseaseChange(ref int value)
|
||||
{
|
||||
get => _disease;
|
||||
set
|
||||
{
|
||||
_disease = Math.Clamp(value, 0, 2);
|
||||
MarkDirty();
|
||||
}
|
||||
value = Math.Clamp(value, 0, 2);
|
||||
return true;
|
||||
}
|
||||
|
||||
[SerializableFieldSaveFlag(8)]
|
||||
private bool ShouldSerializeDisease() => _disease != 0;
|
||||
|
||||
public bool IsFullPoisonPotion => _poisonPotion >= 2;
|
||||
|
||||
[SerializableProperty(9)]
|
||||
public int PoisonPotion
|
||||
[SerializableField(9, allowFieldChange: nameof(AllowPoisonPotionChange))]
|
||||
[SaveFlag(nameof(ShouldSerializePoisonPotion))]
|
||||
private int _poisonPotion;
|
||||
|
||||
private bool AllowPoisonPotionChange(ref int value)
|
||||
{
|
||||
get => _poisonPotion;
|
||||
set
|
||||
{
|
||||
_poisonPotion = Math.Clamp(value, 0, 2);
|
||||
MarkDirty();
|
||||
}
|
||||
value = Math.Clamp(value, 0, 2);
|
||||
return true;
|
||||
}
|
||||
|
||||
[SerializableFieldSaveFlag(9)]
|
||||
private bool ShouldSerializePoisonPotion() => _poisonPotion != 0;
|
||||
|
||||
public bool IsFullCurePotion => _curePotion >= 2;
|
||||
|
||||
[SerializableProperty(10)]
|
||||
public int CurePotion
|
||||
[SerializableField(10, allowFieldChange: nameof(AllowCurePotionChange))]
|
||||
[SaveFlag(nameof(ShouldSerializeCurePotion))]
|
||||
private int _curePotion;
|
||||
|
||||
private bool AllowCurePotionChange(ref int value)
|
||||
{
|
||||
get => _curePotion;
|
||||
set
|
||||
{
|
||||
_curePotion = Math.Clamp(value, 0, 2);
|
||||
MarkDirty();
|
||||
}
|
||||
value = Math.Clamp(value, 0, 2);
|
||||
return true;
|
||||
}
|
||||
|
||||
[SerializableFieldSaveFlag(10)]
|
||||
private bool ShouldSerializeCurePotion() => _curePotion != 0;
|
||||
|
||||
public bool IsFullHealPotion => _healPotion >= 2;
|
||||
|
||||
[SerializableProperty(11)]
|
||||
public int HealPotion
|
||||
[SerializableField(11, allowFieldChange: nameof(AllowHealPotionChange))]
|
||||
[SaveFlag(nameof(ShouldSerializeHealPotion))]
|
||||
private int _healPotion;
|
||||
|
||||
private bool AllowHealPotionChange(ref int value)
|
||||
{
|
||||
get => _healPotion;
|
||||
set
|
||||
{
|
||||
_healPotion = Math.Clamp(value, 0, 2);
|
||||
MarkDirty();
|
||||
}
|
||||
value = Math.Clamp(value, 0, 2);
|
||||
return true;
|
||||
}
|
||||
|
||||
[SerializableFieldSaveFlag(11)]
|
||||
private bool ShouldSerializeHealPotion() => _healPotion != 0;
|
||||
|
||||
public bool IsFullStrengthPotion => _strengthPotion >= 2;
|
||||
|
||||
[SerializableProperty(12)]
|
||||
public int StrengthPotion
|
||||
[SerializableField(12, allowFieldChange: nameof(AllowStrengthPotionChange))]
|
||||
[SaveFlag(nameof(ShouldSerializeStrengthPotion))]
|
||||
private int _strengthPotion;
|
||||
|
||||
private bool AllowStrengthPotionChange(ref int value)
|
||||
{
|
||||
get => _strengthPotion;
|
||||
set
|
||||
{
|
||||
_strengthPotion = Math.Clamp(value, 0, 2);
|
||||
MarkDirty();
|
||||
}
|
||||
value = Math.Clamp(value, 0, 2);
|
||||
return true;
|
||||
}
|
||||
|
||||
[SerializableFieldSaveFlag(12)]
|
||||
private bool ShouldSerializeStrengthPotion() => _strengthPotion != 0;
|
||||
|
||||
public bool HasMaladies => Infestation > 0 || Fungus > 0 || Poison > 0 || Disease > 0 || Water != 2;
|
||||
|
|
@ -274,6 +255,7 @@ namespace Server.Engines.Plants
|
|||
public bool PollenProducing => Plant.IsCrossable && Plant.PlantStatus >= PlantStatus.FullGrownPlant;
|
||||
|
||||
[SerializableProperty(14)]
|
||||
[SaveFlag(nameof(ShouldSerializeSeedType))]
|
||||
public PlantType SeedType
|
||||
{
|
||||
get => Pollinated ? _seedType : Plant.PlantType;
|
||||
|
|
@ -284,10 +266,10 @@ namespace Server.Engines.Plants
|
|||
}
|
||||
}
|
||||
|
||||
[SerializableFieldSaveFlag(14)]
|
||||
private bool ShouldSerializeSeedType() => _pollinated;
|
||||
|
||||
[SerializableProperty(15)]
|
||||
[SaveFlag(nameof(ShouldSerializeSeedHue))]
|
||||
public PlantHue SeedHue
|
||||
{
|
||||
get => Pollinated ? _seedHue : Plant.PlantHue;
|
||||
|
|
@ -298,53 +280,58 @@ namespace Server.Engines.Plants
|
|||
}
|
||||
}
|
||||
|
||||
[SerializableFieldSaveFlag(15)]
|
||||
private bool ShouldSerializeSeedHue() => _pollinated;
|
||||
|
||||
[SerializableProperty(16)]
|
||||
public int AvailableSeeds
|
||||
[SerializableField(16, allowFieldChange: nameof(AllowAvailableSeedsChange))]
|
||||
[SaveFlag(nameof(ShouldSerializeAvailableSeeds))]
|
||||
private int _availableSeeds;
|
||||
|
||||
private bool AllowAvailableSeedsChange(ref int value)
|
||||
{
|
||||
get => _availableSeeds;
|
||||
set => _availableSeeds = Math.Max(value, 0);
|
||||
value = Math.Max(value, 0);
|
||||
return true;
|
||||
}
|
||||
|
||||
[SerializableFieldSaveFlag(16)]
|
||||
private bool ShouldSerializeAvailableSeeds() => _availableSeeds != 0;
|
||||
|
||||
[SerializableProperty(17)]
|
||||
public int LeftSeeds
|
||||
[SerializableField(17, allowFieldChange: nameof(AllowLeftSeedsChange))]
|
||||
[SaveFlag(nameof(ShouldSerializeLeftSeeds), nameof(LeftSeedsDefaultValue))]
|
||||
private int _leftSeeds;
|
||||
|
||||
private bool AllowLeftSeedsChange(ref int value)
|
||||
{
|
||||
get => _leftSeeds;
|
||||
set => _leftSeeds = Math.Max(value, 0);
|
||||
value = Math.Max(value, 0);
|
||||
return true;
|
||||
}
|
||||
|
||||
[SerializableFieldSaveFlag(17)]
|
||||
private bool ShouldSerializeLeftSeeds() => _leftSeeds != 8;
|
||||
|
||||
[SerializableFieldDefault(17)]
|
||||
private int LeftSeedsDefaultValue() => 8;
|
||||
|
||||
[SerializableProperty(18)]
|
||||
public int AvailableResources
|
||||
[SerializableField(18, allowFieldChange: nameof(AllowAvailableResourcesChange))]
|
||||
[SaveFlag(nameof(ShouldSerializeAvailableResources))]
|
||||
private int _availableResources;
|
||||
|
||||
private bool AllowAvailableResourcesChange(ref int value)
|
||||
{
|
||||
get => _availableResources;
|
||||
set => _availableResources = Math.Max(value, 0);
|
||||
value = Math.Max(value, 0);
|
||||
return true;
|
||||
}
|
||||
|
||||
[SerializableFieldSaveFlag(18)]
|
||||
private bool ShouldSerializeAvailableResources() => _availableResources != 0;
|
||||
|
||||
[SerializableProperty(19)]
|
||||
public int LeftResources
|
||||
[SerializableField(19, allowFieldChange: nameof(AllowLeftResourcesChange))]
|
||||
[SaveFlag(nameof(ShouldSerializeLeftResources), nameof(LeftResourcesDefaultValue))]
|
||||
private int _leftResources;
|
||||
|
||||
private bool AllowLeftResourcesChange(ref int value)
|
||||
{
|
||||
get => _leftResources;
|
||||
set => _leftResources = Math.Max(value, 0);
|
||||
value = Math.Max(value, 0);
|
||||
return true;
|
||||
}
|
||||
|
||||
[SerializableFieldSaveFlag(19)]
|
||||
private bool ShouldSerializeLeftResources() => _leftResources != 8;
|
||||
|
||||
[SerializableFieldDefault(19)]
|
||||
private int LeftResourcesDefaultValue() => 8;
|
||||
|
||||
public void Reset(bool potions)
|
||||
|
|
|
|||
|
|
@ -35,18 +35,14 @@ public partial class Seed : Item
|
|||
|
||||
public override double DefaultWeight => 1.0;
|
||||
|
||||
[CommandProperty(AccessLevel.GameMaster)]
|
||||
[SerializableProperty(1)]
|
||||
public PlantHue PlantHue
|
||||
[SerializableField(1, fieldChanged: nameof(OnPlantHueChanged))]
|
||||
[SerializedCommandProperty(AccessLevel.GameMaster)]
|
||||
[InvalidateProperties]
|
||||
private PlantHue _plantHue;
|
||||
|
||||
private void OnPlantHueChanged(PlantHue oldValue, PlantHue newValue)
|
||||
{
|
||||
get => _plantHue;
|
||||
set
|
||||
{
|
||||
_plantHue = value;
|
||||
Hue = PlantHueInfo.GetInfo(value).Hue;
|
||||
InvalidateProperties();
|
||||
this.MarkDirty();
|
||||
}
|
||||
Hue = PlantHueInfo.GetInfo(newValue).Hue;
|
||||
}
|
||||
|
||||
public override int LabelNumber => 1060810; // seed
|
||||
|
|
|
|||
|
|
@ -16,12 +16,14 @@ public partial class MurderContext
|
|||
[SerializedCommandProperty(AccessLevel.GameMaster)]
|
||||
private TimeSpan _longTermElapse;
|
||||
|
||||
[SerializableProperty(2)]
|
||||
[CommandProperty(AccessLevel.GameMaster)]
|
||||
public int ShortTermMurders
|
||||
[SerializableField(2, allowFieldChange: nameof(AllowShortTermMurdersChange))]
|
||||
[SerializedCommandProperty(AccessLevel.GameMaster)]
|
||||
private int _shortTermMurders;
|
||||
|
||||
private bool AllowShortTermMurdersChange(ref int value)
|
||||
{
|
||||
get => _shortTermMurders;
|
||||
set => _shortTermMurders = Math.Max(value, 0);
|
||||
value = Math.Max(value, 0);
|
||||
return true;
|
||||
}
|
||||
|
||||
[SerializableField(3)]
|
||||
|
|
|
|||
|
|
@ -12,16 +12,12 @@ public partial class SummoningAltar : AbbatoirAddon
|
|||
{
|
||||
}
|
||||
|
||||
[SerializableProperty(0)]
|
||||
public BoneDemon Daemon
|
||||
[SerializableField(0, fieldChanged: nameof(OnDaemonChanged))]
|
||||
private BoneDemon _daemon;
|
||||
|
||||
private void OnDaemonChanged(BoneDemon oldValue, BoneDemon newValue)
|
||||
{
|
||||
get => _daemon;
|
||||
set
|
||||
{
|
||||
_daemon = value;
|
||||
CheckDaemon();
|
||||
this.MarkDirty();
|
||||
}
|
||||
CheckDaemon();
|
||||
}
|
||||
|
||||
public void CheckDaemon()
|
||||
|
|
|
|||
|
|
@ -54,10 +54,10 @@ public abstract partial class BaseSpawner : Item, ISpawner
|
|||
[SerializedCommandProperty(AccessLevel.Developer)]
|
||||
private Guid _guid;
|
||||
|
||||
[SerializableFieldSaveFlag(1)]
|
||||
private bool ShouldSerializeReturnOnDeactivate() => _returnOnDeactivate;
|
||||
|
||||
[SerializableField(1)]
|
||||
[SaveFlag(nameof(ShouldSerializeReturnOnDeactivate))]
|
||||
[SerializedCommandProperty(AccessLevel.Developer)]
|
||||
private bool _returnOnDeactivate;
|
||||
|
||||
|
|
@ -67,48 +67,46 @@ public abstract partial class BaseSpawner : Item, ISpawner
|
|||
|
||||
private int _walkingRange = -1;
|
||||
|
||||
[SerializableFieldSaveFlag(4)]
|
||||
private bool ShouldSerializeWayPoint() => _wayPoint != null;
|
||||
|
||||
[SerializableField(4)]
|
||||
[SaveFlag(nameof(ShouldSerializeWayPoint))]
|
||||
[SerializedCommandProperty(AccessLevel.Developer)]
|
||||
private WayPoint _wayPoint;
|
||||
|
||||
[SerializableFieldSaveFlag(5)]
|
||||
private bool ShouldSerializeGroup() => _group;
|
||||
|
||||
[InvalidateProperties]
|
||||
[SerializableField(5)]
|
||||
[SaveFlag(nameof(ShouldSerializeGroup))]
|
||||
[SerializedCommandProperty(AccessLevel.Developer)]
|
||||
private bool _group;
|
||||
|
||||
[SerializableFieldSaveFlag(6)]
|
||||
private bool ShouldSerializeMinDelay() => _minDelay != DefaultMinDelay;
|
||||
|
||||
[SerializableFieldDefault(6)]
|
||||
private TimeSpan MinDelayDefault() => DefaultMinDelay;
|
||||
|
||||
[InvalidateProperties]
|
||||
[SerializableField(6)]
|
||||
[SaveFlag(nameof(ShouldSerializeMinDelay), nameof(MinDelayDefault))]
|
||||
[SerializedCommandProperty(AccessLevel.Developer)]
|
||||
private TimeSpan _minDelay;
|
||||
|
||||
[SerializableFieldSaveFlag(7)]
|
||||
private bool ShouldSerializeMaxDelay() => _maxDelay != DefaultMaxDelay;
|
||||
|
||||
[SerializableFieldDefault(7)]
|
||||
private TimeSpan MaxDelayDefault() => DefaultMaxDelay;
|
||||
|
||||
[InvalidateProperties]
|
||||
[SerializableField(7)]
|
||||
[SaveFlag(nameof(ShouldSerializeMaxDelay), nameof(MaxDelayDefault))]
|
||||
[SerializedCommandProperty(AccessLevel.Developer)]
|
||||
private TimeSpan _maxDelay;
|
||||
|
||||
[SerializableFieldSaveFlag(9)]
|
||||
private bool ShouldSerializeTeam() => _team != 0;
|
||||
|
||||
[InvalidateProperties]
|
||||
[SerializableField(9)]
|
||||
[SaveFlag(nameof(ShouldSerializeTeam))]
|
||||
[SerializedCommandProperty(AccessLevel.Developer)]
|
||||
private int _team;
|
||||
|
||||
|
|
@ -125,29 +123,29 @@ public abstract partial class BaseSpawner : Item, ISpawner
|
|||
/// If true, the home location of the spawn is the location where it spawned
|
||||
/// If false, the home location of the spawn is the location of the spawner
|
||||
/// </summary>
|
||||
[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;
|
||||
|
||||
/// <summary>
|
||||
/// Controls how spawn position optimization is handled.
|
||||
/// </summary>
|
||||
[SerializableFieldSaveFlag(13)]
|
||||
private bool ShouldSerializeSpawnPositionMode() =>
|
||||
_spawnPositionMode is not SpawnPositionMode.Automatic and not SpawnPositionMode.Abandoned;
|
||||
|
||||
[SerializableField(13)]
|
||||
[SaveFlag(nameof(ShouldSerializeSpawnPositionMode))]
|
||||
[SerializedCommandProperty(AccessLevel.Developer)]
|
||||
private SpawnPositionMode _spawnPositionMode;
|
||||
|
||||
|
|
@ -156,13 +154,12 @@ public abstract partial class BaseSpawner : Item, ISpawner
|
|||
/// <summary>
|
||||
/// Maximum number of random position attempts before engaging optimization.
|
||||
/// </summary>
|
||||
[SerializableFieldSaveFlag(14)]
|
||||
private bool ShouldSerializeMaxSpawnAttempts() => _maxSpawnAttempts != DefaultMaxSpawnAttempts;
|
||||
|
||||
[SerializableFieldDefault(14)]
|
||||
private int MaxSpawnAttemptsDefault() => DefaultMaxSpawnAttempts;
|
||||
|
||||
[SerializableField(14)]
|
||||
[SaveFlag(nameof(ShouldSerializeMaxSpawnAttempts), nameof(MaxSpawnAttemptsDefault))]
|
||||
[SerializedCommandProperty(AccessLevel.Developer)]
|
||||
private int _maxSpawnAttempts;
|
||||
|
||||
|
|
@ -314,26 +311,20 @@ public abstract partial class BaseSpawner : Item, ISpawner
|
|||
}
|
||||
}
|
||||
|
||||
[SerializableProperty(8)]
|
||||
[CommandProperty(AccessLevel.Developer)]
|
||||
public int Count
|
||||
[SerializableField(8, fieldChanged: nameof(OnCountChanged))]
|
||||
[SerializedCommandProperty(AccessLevel.Developer)]
|
||||
[InvalidateProperties]
|
||||
private int _count;
|
||||
|
||||
private void OnCountChanged(int oldValue, int newValue)
|
||||
{
|
||||
get => _count;
|
||||
set
|
||||
if (IsFull)
|
||||
{
|
||||
_count = value;
|
||||
|
||||
if (IsFull)
|
||||
{
|
||||
_timer?.Stop();
|
||||
}
|
||||
else if (_timer?.Running != true)
|
||||
{
|
||||
DoTimer();
|
||||
}
|
||||
|
||||
InvalidateProperties();
|
||||
this.MarkDirty();
|
||||
_timer?.Stop();
|
||||
}
|
||||
else if (_timer?.Running != true)
|
||||
{
|
||||
DoTimer();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
/// </summary>
|
||||
[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
|
||||
{
|
||||
|
|
|
|||
|
|
@ -248,7 +248,7 @@ public class StealableArtifacts : GenericPersistence
|
|||
|
||||
public override void Serialize(IGenericWriter writer)
|
||||
{
|
||||
writer.WriteEncodedInt(1); // version
|
||||
writer.WriteEncodedInt(2); // version
|
||||
|
||||
writer.Write(_enabled);
|
||||
|
||||
|
|
@ -261,7 +261,7 @@ public class StealableArtifacts : GenericPersistence
|
|||
var si = _artifacts[i];
|
||||
|
||||
writer.Write(si.Item);
|
||||
writer.WriteDeltaTime(si.NextRespawn);
|
||||
writer.WriteAnchoredTime(si.NextRespawn);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -282,7 +282,7 @@ public class StealableArtifacts : GenericPersistence
|
|||
for (var i = 0; i < length; i++)
|
||||
{
|
||||
var item = reader.ReadEntity<Item>();
|
||||
var nextRespawn = reader.ReadDeltaTime();
|
||||
var nextRespawn = version >= 2 ? reader.ReadAnchoredTime() : reader.ReadDeltaTime();
|
||||
|
||||
if (i < _artifacts.Length)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -332,27 +332,22 @@ public partial class PigmentsOfTokuno : BasePigmentsOfTokuno
|
|||
[Constructible]
|
||||
public PigmentsOfTokuno(PigmentType type, int uses) : base(uses) => Type = type;
|
||||
|
||||
[SerializableProperty(0)]
|
||||
[CommandProperty(AccessLevel.GameMaster)]
|
||||
public PigmentType Type
|
||||
[SerializableField(0, fieldChanged: nameof(OnTypeChanged))]
|
||||
[SerializedCommandProperty(AccessLevel.GameMaster)]
|
||||
private PigmentType _type;
|
||||
|
||||
private void OnTypeChanged(PigmentType oldValue, PigmentType newValue)
|
||||
{
|
||||
get => _type;
|
||||
set
|
||||
var v = (int)_type;
|
||||
if (v >= 0 && v < _table.Length)
|
||||
{
|
||||
_type = value;
|
||||
|
||||
var v = (int)_type;
|
||||
|
||||
if (v >= 0 && v < _table.Length)
|
||||
{
|
||||
Hue = _table[v][0];
|
||||
Label = _table[v][1];
|
||||
}
|
||||
else
|
||||
{
|
||||
Hue = 0;
|
||||
Label = -1;
|
||||
}
|
||||
Hue = _table[v][0];
|
||||
Label = _table[v][1];
|
||||
}
|
||||
else
|
||||
{
|
||||
Hue = 0;
|
||||
Label = -1;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -617,27 +617,22 @@ public partial class LesserPigmentsOfTokuno : BasePigmentsOfTokuno
|
|||
[Constructible]
|
||||
public LesserPigmentsOfTokuno(LesserPigmentType type) : base(1) => Type = type;
|
||||
|
||||
[SerializableProperty(0)]
|
||||
[CommandProperty(AccessLevel.GameMaster)]
|
||||
public LesserPigmentType Type
|
||||
[SerializableField(0, fieldChanged: nameof(OnTypeChanged))]
|
||||
[SerializedCommandProperty(AccessLevel.GameMaster)]
|
||||
private LesserPigmentType _type;
|
||||
|
||||
private void OnTypeChanged(LesserPigmentType oldValue, LesserPigmentType newValue)
|
||||
{
|
||||
get => _type;
|
||||
set
|
||||
var v = (int)_type;
|
||||
if (v >= 0 && v < _table.Length)
|
||||
{
|
||||
_type = value;
|
||||
|
||||
var v = (int)_type;
|
||||
|
||||
if (v >= 0 && v < _table.Length)
|
||||
{
|
||||
Hue = _table[v][0];
|
||||
Label = _table[v][1];
|
||||
}
|
||||
else
|
||||
{
|
||||
Hue = 0;
|
||||
Label = -1;
|
||||
}
|
||||
Hue = _table[v][0];
|
||||
Label = _table[v][1];
|
||||
}
|
||||
else
|
||||
{
|
||||
Hue = 0;
|
||||
Label = -1;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -79,45 +79,33 @@ public partial class CharacterStatue : Mobile, IRewardItem
|
|||
InvalidateHues();
|
||||
}
|
||||
|
||||
[SerializableProperty(0)]
|
||||
[CommandProperty(AccessLevel.GameMaster)]
|
||||
public StatueType StatueType
|
||||
[SerializableField(0, fieldChanged: nameof(OnStatueTypeChanged))]
|
||||
[SerializedCommandProperty(AccessLevel.GameMaster)]
|
||||
private StatueType _statueType;
|
||||
|
||||
private void OnStatueTypeChanged(StatueType oldValue, StatueType newValue)
|
||||
{
|
||||
get => _statueType;
|
||||
set
|
||||
{
|
||||
_statueType = value;
|
||||
InvalidateHues();
|
||||
InvalidatePose();
|
||||
this.MarkDirty();
|
||||
}
|
||||
InvalidateHues();
|
||||
InvalidatePose();
|
||||
}
|
||||
|
||||
[SerializableProperty(1)]
|
||||
[CommandProperty(AccessLevel.GameMaster)]
|
||||
public StatuePose Pose
|
||||
[SerializableField(1, fieldChanged: nameof(OnPoseChanged))]
|
||||
[SerializedCommandProperty(AccessLevel.GameMaster)]
|
||||
private StatuePose _pose;
|
||||
|
||||
private void OnPoseChanged(StatuePose oldValue, StatuePose newValue)
|
||||
{
|
||||
get => _pose;
|
||||
set
|
||||
{
|
||||
_pose = value;
|
||||
InvalidatePose();
|
||||
this.MarkDirty();
|
||||
}
|
||||
InvalidatePose();
|
||||
}
|
||||
|
||||
[SerializableProperty(2)]
|
||||
[CommandProperty(AccessLevel.GameMaster)]
|
||||
public StatueMaterial Material
|
||||
[SerializableField(2, fieldChanged: nameof(OnMaterialChanged))]
|
||||
[SerializedCommandProperty(AccessLevel.GameMaster)]
|
||||
private StatueMaterial _material;
|
||||
|
||||
private void OnMaterialChanged(StatueMaterial oldValue, StatueMaterial newValue)
|
||||
{
|
||||
get => _material;
|
||||
set
|
||||
{
|
||||
_material = value;
|
||||
InvalidateHues();
|
||||
InvalidatePose();
|
||||
this.MarkDirty();
|
||||
}
|
||||
InvalidateHues();
|
||||
InvalidatePose();
|
||||
}
|
||||
|
||||
public override void OnDoubleClick(Mobile from)
|
||||
|
|
|
|||
|
|
@ -25,17 +25,13 @@ public partial class CharacterStatueMaker : Item, IRewardItem
|
|||
|
||||
public override int LabelNumber => 1076173; // Character Statue Maker
|
||||
|
||||
[SerializableProperty(1)]
|
||||
[CommandProperty(AccessLevel.GameMaster)]
|
||||
public StatueType StatueType
|
||||
[SerializableField(1, fieldChanged: nameof(OnStatueTypeChanged))]
|
||||
[SerializedCommandProperty(AccessLevel.GameMaster)]
|
||||
private StatueType _statueType;
|
||||
|
||||
private void OnStatueTypeChanged(StatueType oldValue, StatueType newValue)
|
||||
{
|
||||
get => _statueType;
|
||||
set
|
||||
{
|
||||
_statueType = value;
|
||||
InvalidateHue();
|
||||
this.MarkDirty();
|
||||
}
|
||||
InvalidateHue();
|
||||
}
|
||||
|
||||
public override void OnDoubleClick(Mobile from)
|
||||
|
|
|
|||
|
|
@ -5,102 +5,121 @@ using Server.Mobiles;
|
|||
namespace Server.Engines.Virtues;
|
||||
|
||||
[PropertyObject]
|
||||
[SerializationGenerator(0)]
|
||||
[SerializationGenerator(1)]
|
||||
public partial class VirtueContext
|
||||
{
|
||||
[DeltaDateTime]
|
||||
private void MigrateFrom(V0Content content)
|
||||
{
|
||||
// Save-flagged values arrive as nullables; unset flags fall back to the same
|
||||
// defaults the old deserialize left in place.
|
||||
_lastSacrificeGain = content.LastSacrificeGain ?? default;
|
||||
_lastSacrificeLoss = content.LastSacrificeLoss ?? default;
|
||||
_availableResurrects = content.AvailableResurrects ?? 0;
|
||||
_lastJusticeLoss = content.LastJusticeLoss ?? default;
|
||||
_lastCompassionLoss = content.LastCompassionLoss ?? default;
|
||||
_nextCompassionDay = content.NextCompassionDay ?? default;
|
||||
_compassionGains = content.CompassionGains ?? 0;
|
||||
_lastValorLoss = content.LastValorLoss ?? default;
|
||||
_lastHonorUse = content.LastHonorUse ?? default;
|
||||
_honorActive = content.HonorActive;
|
||||
_justiceProtection = content.JusticeProtection;
|
||||
_justiceStatus = content.JusticeStatus ?? JusticeProtectorStatus.None;
|
||||
_values = content.Values;
|
||||
}
|
||||
|
||||
[AnchoredDateTime]
|
||||
[SerializableField(0)]
|
||||
[SaveFlag(nameof(ShouldSerializeLastSacrificeGain))]
|
||||
[SerializedCommandProperty(AccessLevel.GameMaster, AccessLevel.Administrator)]
|
||||
private DateTime _lastSacrificeGain;
|
||||
|
||||
[SerializableFieldSaveFlag(0)]
|
||||
private bool ShouldSerializeLastSacrificeGain() => !SacrificeVirtue.CanGain(this);
|
||||
|
||||
[DeltaDateTime]
|
||||
[AnchoredDateTime]
|
||||
[SerializableField(1)]
|
||||
[SaveFlag(nameof(ShouldSerializeLastSacrificeLoss))]
|
||||
[SerializedCommandProperty(AccessLevel.GameMaster, AccessLevel.Administrator)]
|
||||
private DateTime _lastSacrificeLoss;
|
||||
|
||||
[SerializableFieldSaveFlag(1)]
|
||||
private bool ShouldSerializeLastSacrificeLoss() => !SacrificeVirtue.CanAtrophy(this);
|
||||
|
||||
[SerializableField(2)]
|
||||
[SaveFlag(nameof(ShouldSerializeAvailableResurrects))]
|
||||
[SerializedCommandProperty(AccessLevel.GameMaster)]
|
||||
private int _availableResurrects;
|
||||
|
||||
[SerializableFieldSaveFlag(2)]
|
||||
private bool ShouldSerializeAvailableResurrects() => _availableResurrects > 0;
|
||||
|
||||
[DeltaDateTime]
|
||||
[AnchoredDateTime]
|
||||
[SerializableField(3)]
|
||||
[SaveFlag(nameof(ShouldSerializeLastJusticeLoss))]
|
||||
[SerializedCommandProperty(AccessLevel.GameMaster, AccessLevel.Administrator)]
|
||||
private DateTime _lastJusticeLoss;
|
||||
|
||||
[SerializableFieldSaveFlag(3)]
|
||||
private bool ShouldSerializeLastJusticeLoss() => !JusticeVirtue.CanAtrophy(this);
|
||||
|
||||
[DeltaDateTime]
|
||||
[AnchoredDateTime]
|
||||
[SerializableField(4)]
|
||||
[SaveFlag(nameof(ShouldSerializeLastCompassionLoss))]
|
||||
[SerializedCommandProperty(AccessLevel.GameMaster, AccessLevel.Administrator)]
|
||||
private DateTime _lastCompassionLoss;
|
||||
|
||||
[SerializableFieldSaveFlag(4)]
|
||||
private bool ShouldSerializeLastCompassionLoss() => !CompassionVirtue.CanAtrophy(this);
|
||||
|
||||
[DeltaDateTime]
|
||||
[AnchoredDateTime]
|
||||
[SerializableField(5)]
|
||||
[SaveFlag(nameof(ShouldSerializeNextCompassionDay))]
|
||||
[SerializedCommandProperty(AccessLevel.GameMaster)]
|
||||
private DateTime _nextCompassionDay;
|
||||
|
||||
[SerializableFieldSaveFlag(5)]
|
||||
private bool ShouldSerializeNextCompassionDay() => _nextCompassionDay > Core.Now;
|
||||
|
||||
[SerializableField(6)]
|
||||
[SaveFlag(nameof(ShouldSerializeCompassionGains))]
|
||||
[SerializedCommandProperty(AccessLevel.GameMaster)]
|
||||
private int _compassionGains;
|
||||
|
||||
[SerializableFieldSaveFlag(6)]
|
||||
private bool ShouldSerializeCompassionGains() => _compassionGains > 0;
|
||||
|
||||
[DeltaDateTime]
|
||||
[AnchoredDateTime]
|
||||
[SerializableField(7)]
|
||||
[SaveFlag(nameof(ShouldSerializeValorLoss))]
|
||||
[SerializedCommandProperty(AccessLevel.GameMaster, AccessLevel.Administrator)]
|
||||
private DateTime _lastValorLoss;
|
||||
|
||||
[SerializableFieldSaveFlag(7)]
|
||||
private bool ShouldSerializeValorLoss() => !ValorVirtue.CanAtrophy(this);
|
||||
|
||||
[DeltaDateTime]
|
||||
[AnchoredDateTime]
|
||||
[SerializableField(8)]
|
||||
[SaveFlag(nameof(ShouldSerializeLastHonorUse))]
|
||||
[SerializedCommandProperty(AccessLevel.GameMaster)]
|
||||
private DateTime _lastHonorUse;
|
||||
|
||||
[SerializableFieldSaveFlag(8)]
|
||||
private bool ShouldSerializeLastHonorUse() => !HonorVirtue.CanUse(this);
|
||||
|
||||
[SerializableField(9)]
|
||||
[SaveFlag(nameof(ShouldSerializeHonorActive))]
|
||||
[SerializedCommandProperty(AccessLevel.GameMaster, AccessLevel.Administrator)]
|
||||
private bool _honorActive;
|
||||
|
||||
[SerializableFieldSaveFlag(9)]
|
||||
private bool ShouldSerializeHonorActive() => _honorActive;
|
||||
|
||||
[SerializableField(10)]
|
||||
[SaveFlag(nameof(ShouldSerializeJusticeProtection))]
|
||||
private PlayerMobile _justiceProtection;
|
||||
|
||||
[SerializableFieldSaveFlag(10)]
|
||||
private bool ShouldSerializeJusticeProtection() => _justiceProtection != null && _justiceStatus != JusticeProtectorStatus.None;
|
||||
|
||||
[SerializableField(11)]
|
||||
[SaveFlag(nameof(ShouldSerializeJusticeStatus))]
|
||||
private JusticeProtectorStatus _justiceStatus;
|
||||
|
||||
[SerializableFieldSaveFlag(11)]
|
||||
private bool ShouldSerializeJusticeStatus() => _justiceProtection != null && _justiceStatus != JusticeProtectorStatus.None;
|
||||
|
||||
[SerializableField(12, setter: "private")]
|
||||
[SaveFlag(nameof(ShouldSerializeValues))]
|
||||
private int[] _values;
|
||||
|
||||
[SerializableFieldSaveFlag(12)]
|
||||
private bool ShouldSerializeValues()
|
||||
{
|
||||
if (_values == null)
|
||||
|
|
|
|||
|
|
@ -227,7 +227,8 @@ namespace Server.Gumps
|
|||
}
|
||||
case AdminGumpPage.Information_Perf:
|
||||
{
|
||||
var loopStatus = Core.EventLoopIdleWaitMs == 0 ? "Spinning (configured)" :
|
||||
var loopStatus = Core.IdleSleepUnsupported ? "Spinning - host cannot honor short waits" :
|
||||
Core.EventLoopIdleWaitMs == 0 ? "Spinning (configured)" :
|
||||
Core.IdleSleepSuspended ? "Sleep suspended - host returning waits late" : "Healthy";
|
||||
|
||||
AddLabel(20, 130, LabelHue, "Event Loop:");
|
||||
|
|
|
|||
|
|
@ -63,22 +63,14 @@ namespace Server.Items
|
|||
}
|
||||
}
|
||||
|
||||
[SerializableProperty(1)]
|
||||
[CommandProperty(AccessLevel.GameMaster)]
|
||||
public CraftResource Resource
|
||||
{
|
||||
get => _resource;
|
||||
set
|
||||
{
|
||||
if (_resource != value)
|
||||
{
|
||||
_resource = value;
|
||||
Hue = CraftResources.GetHue(_resource);
|
||||
[SerializableField(1, fieldChanged: nameof(OnResourceChanged))]
|
||||
[SerializedCommandProperty(AccessLevel.GameMaster)]
|
||||
[InvalidateProperties]
|
||||
private CraftResource _resource;
|
||||
|
||||
InvalidateProperties();
|
||||
this.MarkDirty();
|
||||
}
|
||||
}
|
||||
private void OnResourceChanged(CraftResource oldValue, CraftResource newValue)
|
||||
{
|
||||
Hue = CraftResources.GetHue(_resource);
|
||||
}
|
||||
|
||||
Item IAddon.Deed => Deed;
|
||||
|
|
|
|||
|
|
@ -41,22 +41,14 @@ namespace Server.Items
|
|||
}
|
||||
}
|
||||
|
||||
[SerializableProperty(1)]
|
||||
[CommandProperty(AccessLevel.GameMaster)]
|
||||
public CraftResource Resource
|
||||
{
|
||||
get => _resource;
|
||||
set
|
||||
{
|
||||
if (_resource != value)
|
||||
{
|
||||
_resource = value;
|
||||
Hue = CraftResources.GetHue(_resource);
|
||||
[SerializableField(1, fieldChanged: nameof(OnResourceChanged))]
|
||||
[SerializedCommandProperty(AccessLevel.GameMaster)]
|
||||
[InvalidateProperties]
|
||||
private CraftResource _resource;
|
||||
|
||||
InvalidateProperties();
|
||||
this.MarkDirty();
|
||||
}
|
||||
}
|
||||
private void OnResourceChanged(CraftResource oldValue, CraftResource newValue)
|
||||
{
|
||||
Hue = CraftResources.GetHue(_resource);
|
||||
}
|
||||
|
||||
public virtual bool RetainDeedHue => false;
|
||||
|
|
|
|||
|
|
@ -23,22 +23,14 @@ public abstract partial class BaseAddonContainerDeed : Item, ICraftable
|
|||
|
||||
public abstract BaseAddonContainer Addon { get; }
|
||||
|
||||
[SerializableProperty(0)]
|
||||
[CommandProperty(AccessLevel.GameMaster)]
|
||||
public CraftResource Resource
|
||||
{
|
||||
get => _resource;
|
||||
set
|
||||
{
|
||||
if (_resource != value)
|
||||
{
|
||||
_resource = value;
|
||||
Hue = CraftResources.GetHue(_resource);
|
||||
[SerializableField(0, fieldChanged: nameof(OnResourceChanged))]
|
||||
[SerializedCommandProperty(AccessLevel.GameMaster)]
|
||||
[InvalidateProperties]
|
||||
private CraftResource _resource;
|
||||
|
||||
InvalidateProperties();
|
||||
this.MarkDirty();
|
||||
}
|
||||
}
|
||||
private void OnResourceChanged(CraftResource oldValue, CraftResource newValue)
|
||||
{
|
||||
Hue = CraftResources.GetHue(_resource);
|
||||
}
|
||||
|
||||
public virtual int OnCraft(
|
||||
|
|
|
|||
|
|
@ -48,17 +48,19 @@ namespace Server.Items
|
|||
[CommandProperty(AccessLevel.GameMaster)]
|
||||
public int MaxFlour => 2;
|
||||
|
||||
[SerializableProperty(0)]
|
||||
[CommandProperty(AccessLevel.GameMaster)]
|
||||
public int CurFlour
|
||||
[SerializableField(0, fieldChanged: nameof(OnCurFlourChanged), allowFieldChange: nameof(AllowCurFlourChange))]
|
||||
[SerializedCommandProperty(AccessLevel.GameMaster)]
|
||||
private int _curFlour;
|
||||
|
||||
private bool AllowCurFlourChange(ref int value)
|
||||
{
|
||||
get => _curFlour;
|
||||
set
|
||||
{
|
||||
_curFlour = Math.Clamp(value, 0, MaxFlour);
|
||||
UpdateStage();
|
||||
this.MarkDirty();
|
||||
}
|
||||
value = Math.Clamp(value, 0, MaxFlour);
|
||||
return true;
|
||||
}
|
||||
|
||||
private void OnCurFlourChanged(int oldValue, int newValue)
|
||||
{
|
||||
UpdateStage();
|
||||
}
|
||||
|
||||
public void StartWorking(Mobile from)
|
||||
|
|
|
|||
|
|
@ -35,16 +35,19 @@ namespace Server.Items
|
|||
[CommandProperty(AccessLevel.GameMaster)]
|
||||
public int MaxFlour => 2;
|
||||
|
||||
[SerializableProperty(0)]
|
||||
[CommandProperty(AccessLevel.GameMaster)]
|
||||
public int CurFlour
|
||||
[SerializableField(0, fieldChanged: nameof(OnCurFlourChanged), allowFieldChange: nameof(AllowCurFlourChange))]
|
||||
[SerializedCommandProperty(AccessLevel.GameMaster)]
|
||||
private int _curFlour;
|
||||
|
||||
private bool AllowCurFlourChange(ref int value)
|
||||
{
|
||||
get => _curFlour;
|
||||
set
|
||||
{
|
||||
_curFlour = Math.Max(0, Math.Min(value, MaxFlour));
|
||||
UpdateStage();
|
||||
}
|
||||
value = Math.Max(0, Math.Min(value, MaxFlour));
|
||||
return true;
|
||||
}
|
||||
|
||||
private void OnCurFlourChanged(int oldValue, int newValue)
|
||||
{
|
||||
UpdateStage();
|
||||
}
|
||||
|
||||
public void StartWorking(Mobile from)
|
||||
|
|
|
|||
|
|
@ -25,35 +25,27 @@ namespace Server.Items
|
|||
_teleOffset = offset;
|
||||
}
|
||||
|
||||
[SerializableProperty(0)]
|
||||
[CommandProperty(AccessLevel.GameMaster)]
|
||||
public bool Active
|
||||
{
|
||||
get => _active;
|
||||
set
|
||||
{
|
||||
_active = value;
|
||||
[SerializableField(0, fieldChanged: nameof(OnActiveChanged))]
|
||||
[SerializedCommandProperty(AccessLevel.GameMaster)]
|
||||
private bool _active;
|
||||
|
||||
if (Addon is SHTeleporter sourceAddon)
|
||||
{
|
||||
sourceAddon.ChangeActive(value);
|
||||
}
|
||||
private void OnActiveChanged(bool oldValue, bool newValue)
|
||||
{
|
||||
if (Addon is SHTeleporter sourceAddon)
|
||||
{
|
||||
sourceAddon.ChangeActive(newValue);
|
||||
}
|
||||
}
|
||||
|
||||
[SerializableProperty(1)]
|
||||
[CommandProperty(AccessLevel.GameMaster)]
|
||||
public SHTeleComponent TeleDest
|
||||
{
|
||||
get => _teleDest;
|
||||
set
|
||||
{
|
||||
_teleDest = value;
|
||||
[SerializableField(1, fieldChanged: nameof(OnTeleDestChanged))]
|
||||
[SerializedCommandProperty(AccessLevel.GameMaster)]
|
||||
private SHTeleComponent _teleDest;
|
||||
|
||||
if (Addon is SHTeleporter sourceAddon)
|
||||
{
|
||||
sourceAddon.ChangeDest(value);
|
||||
}
|
||||
private void OnTeleDestChanged(SHTeleComponent oldValue, SHTeleComponent newValue)
|
||||
{
|
||||
if (Addon is SHTeleporter sourceAddon)
|
||||
{
|
||||
sourceAddon.ChangeDest(newValue);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -30,19 +30,14 @@ namespace Server.Items
|
|||
|
||||
public AquariumState(Aquarium parent) => _aquarium = parent;
|
||||
|
||||
[SerializableProperty(0)]
|
||||
[CommandProperty(AccessLevel.GameMaster)]
|
||||
public int State
|
||||
[SerializableField(0, allowFieldChange: nameof(AllowStateChange))]
|
||||
[SerializedCommandProperty(AccessLevel.GameMaster)]
|
||||
private int _state;
|
||||
|
||||
private bool AllowStateChange(ref int value)
|
||||
{
|
||||
get => _state;
|
||||
set
|
||||
{
|
||||
if (_state != value)
|
||||
{
|
||||
_state = Math.Clamp(value, 0, 4);
|
||||
MarkDirty();
|
||||
}
|
||||
}
|
||||
value = Math.Clamp(value, 0, 4);
|
||||
return true;
|
||||
}
|
||||
|
||||
[SerializableField(1)]
|
||||
|
|
|
|||
|
|
@ -1,9 +1,40 @@
|
|||
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();
|
||||
|
|
@ -29,7 +60,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)
|
||||
|
|
@ -162,4 +193,37 @@ 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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,101 +12,98 @@ using AMT = Server.Items.ArmorMaterialType;
|
|||
|
||||
namespace Server.Items
|
||||
{
|
||||
[SerializationGenerator(9, false)]
|
||||
[SerializationGenerator(10, 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
|
||||
|
|
@ -135,22 +132,14 @@ 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)
|
||||
|
|
@ -197,6 +186,7 @@ namespace Server.Items
|
|||
public virtual int OldIntReq => 0;
|
||||
|
||||
[SerializableProperty(11)]
|
||||
[SaveFlag(nameof(ShouldSerializeArmorQuality), nameof(QualityDefaultValue))]
|
||||
[CommandProperty(AccessLevel.GameMaster)]
|
||||
public ArmorQuality Quality
|
||||
{
|
||||
|
|
@ -209,13 +199,12 @@ namespace Server.Items
|
|||
}
|
||||
}
|
||||
|
||||
[SerializableFieldSaveFlag(11)]
|
||||
private bool ShouldSerializeArmorQuality() => _quality != ArmorQuality.Regular;
|
||||
|
||||
[SerializableFieldDefault(11)]
|
||||
private ArmorQuality QualityDefaultValue() => ArmorQuality.Regular;
|
||||
|
||||
[SerializableProperty(12)]
|
||||
[SaveFlag(nameof(ShouldSerializeDurability))]
|
||||
[CommandProperty(AccessLevel.GameMaster)]
|
||||
public ArmorDurabilityLevel Durability
|
||||
{
|
||||
|
|
@ -228,33 +217,24 @@ namespace Server.Items
|
|||
}
|
||||
}
|
||||
|
||||
[SerializableFieldSaveFlag(12)]
|
||||
private bool ShouldSerializeDurability() => _durability != ArmorDurabilityLevel.Regular;
|
||||
|
||||
[SerializableProperty(13)]
|
||||
[CommandProperty(AccessLevel.GameMaster)]
|
||||
public ArmorProtectionLevel ProtectionLevel
|
||||
[SerializableField(13, fieldChanged: nameof(OnProtectionLevelChanged))]
|
||||
[SaveFlag(nameof(ShouldSerializeProtectionLevel))]
|
||||
[SerializedCommandProperty(AccessLevel.GameMaster)]
|
||||
[InvalidateProperties]
|
||||
private ArmorProtectionLevel _protectionLevel;
|
||||
|
||||
private void OnProtectionLevelChanged(ArmorProtectionLevel oldValue, ArmorProtectionLevel newValue)
|
||||
{
|
||||
get => _protectionLevel;
|
||||
set
|
||||
{
|
||||
if (_protectionLevel != value)
|
||||
{
|
||||
_protectionLevel = value;
|
||||
|
||||
Invalidate();
|
||||
InvalidateProperties();
|
||||
|
||||
(Parent as Mobile)?.UpdateResistances();
|
||||
this.MarkDirty();
|
||||
}
|
||||
}
|
||||
Invalidate();
|
||||
(Parent as Mobile)?.UpdateResistances();
|
||||
}
|
||||
|
||||
[SerializableFieldSaveFlag(13)]
|
||||
private bool ShouldSerializeProtectionLevel() => _protectionLevel != ArmorProtectionLevel.Regular;
|
||||
|
||||
[SerializableProperty(14)]
|
||||
[SaveFlag(nameof(ShouldSerializeResource), nameof(ResourceDefaultValue))]
|
||||
[CommandProperty(AccessLevel.GameMaster)]
|
||||
public CraftResource Resource
|
||||
{
|
||||
|
|
@ -280,11 +260,11 @@ namespace Server.Items
|
|||
}
|
||||
}
|
||||
|
||||
[SerializableFieldDefault(14)]
|
||||
private CraftResource ResourceDefaultValue() => DefaultResource;
|
||||
|
||||
[EncodedInt]
|
||||
[SerializableProperty(15, useField: nameof(_armorBase))]
|
||||
[SaveFlag(nameof(ShouldSerializeArmorBase), nameof(ArmorBaseDefaultValue))]
|
||||
[CommandProperty(AccessLevel.GameMaster)]
|
||||
public int BaseArmorRating
|
||||
{
|
||||
|
|
@ -297,10 +277,8 @@ namespace Server.Items
|
|||
}
|
||||
}
|
||||
|
||||
[SerializableFieldSaveFlag(15)]
|
||||
private bool ShouldSerializeArmorBase() => _armorBase != -1;
|
||||
|
||||
[SerializableFieldDefault(15)]
|
||||
private int ArmorBaseDefaultValue() => -1;
|
||||
|
||||
public double BaseArmorRatingScaled => BaseArmorRating * ArmorScalar;
|
||||
|
|
@ -350,6 +328,7 @@ namespace Server.Items
|
|||
|
||||
[EncodedInt]
|
||||
[SerializableProperty(16, useField: nameof(_strBonus))]
|
||||
[SaveFlag(nameof(ShouldSerializeStrBonus), nameof(StrBonusDefaultValue))]
|
||||
[CommandProperty(AccessLevel.GameMaster)]
|
||||
public int StrBonus
|
||||
{
|
||||
|
|
@ -362,14 +341,13 @@ namespace Server.Items
|
|||
}
|
||||
}
|
||||
|
||||
[SerializableFieldSaveFlag(16)]
|
||||
private bool ShouldSerializeStrBonus() => _strBonus != -1;
|
||||
|
||||
[SerializableFieldDefault(16)]
|
||||
private int StrBonusDefaultValue() => -1;
|
||||
|
||||
[EncodedInt]
|
||||
[SerializableProperty(17, useField: nameof(_dexBonus))]
|
||||
[SaveFlag(nameof(ShouldSerializeDexBonus), nameof(DexBonusDefaultValue))]
|
||||
[CommandProperty(AccessLevel.GameMaster)]
|
||||
public int DexBonus
|
||||
{
|
||||
|
|
@ -382,14 +360,13 @@ namespace Server.Items
|
|||
}
|
||||
}
|
||||
|
||||
[SerializableFieldSaveFlag(17)]
|
||||
private bool ShouldSerializeDexBonus() => _dexBonus != -1;
|
||||
|
||||
[SerializableFieldDefault(17)]
|
||||
private int DexBonusDefaultValue() => -1;
|
||||
|
||||
[EncodedInt]
|
||||
[SerializableProperty(18, useField: nameof(_intBonus))]
|
||||
[SaveFlag(nameof(ShouldSerializeIntBonus), nameof(IntBonusDefaultValue))]
|
||||
[CommandProperty(AccessLevel.GameMaster)]
|
||||
public int IntBonus
|
||||
{
|
||||
|
|
@ -402,14 +379,13 @@ namespace Server.Items
|
|||
}
|
||||
}
|
||||
|
||||
[SerializableFieldSaveFlag(18)]
|
||||
private bool ShouldSerializeIntBonus() => _intBonus != -1;
|
||||
|
||||
[SerializableFieldDefault(18)]
|
||||
private int IntBonusDefaultValue() => -1;
|
||||
|
||||
[EncodedInt]
|
||||
[SerializableProperty(19, useField: nameof(_strReq))]
|
||||
[SaveFlag(nameof(ShouldSerializeStrReq), nameof(StrReqDefaultValue))]
|
||||
[CommandProperty(AccessLevel.GameMaster)]
|
||||
public int StrRequirement
|
||||
{
|
||||
|
|
@ -422,14 +398,13 @@ namespace Server.Items
|
|||
}
|
||||
}
|
||||
|
||||
[SerializableFieldSaveFlag(19)]
|
||||
private bool ShouldSerializeStrReq() => _strReq != -1;
|
||||
|
||||
[SerializableFieldDefault(19)]
|
||||
private int StrReqDefaultValue() => -1;
|
||||
|
||||
[EncodedInt]
|
||||
[SerializableProperty(20, useField: nameof(_dexReq))]
|
||||
[SaveFlag(nameof(ShouldSerializeDexReq), nameof(DexReqDefaultValue))]
|
||||
[CommandProperty(AccessLevel.GameMaster)]
|
||||
public int DexRequirement
|
||||
{
|
||||
|
|
@ -442,14 +417,13 @@ namespace Server.Items
|
|||
}
|
||||
}
|
||||
|
||||
[SerializableFieldSaveFlag(20)]
|
||||
private bool ShouldSerializeDexReq() => _dexReq != -1;
|
||||
|
||||
[SerializableFieldDefault(20)]
|
||||
private int DexReqDefaultValue() => -1;
|
||||
|
||||
[EncodedInt]
|
||||
[SerializableProperty(21, useField: nameof(_intReq))]
|
||||
[SaveFlag(nameof(ShouldSerializeIntReq), nameof(IntReqDefaultValue))]
|
||||
[CommandProperty(AccessLevel.GameMaster)]
|
||||
public int IntRequirement
|
||||
{
|
||||
|
|
@ -462,13 +436,12 @@ namespace Server.Items
|
|||
}
|
||||
}
|
||||
|
||||
[SerializableFieldSaveFlag(21)]
|
||||
private bool ShouldSerializeIntReq() => _intReq != -1;
|
||||
|
||||
[SerializableFieldDefault(21)]
|
||||
private int IntReqDefaultValue() => -1;
|
||||
|
||||
[SerializableProperty(22, useField: nameof(_meditate))]
|
||||
[SaveFlag(nameof(ShouldSerializeMeditationAllowance))]
|
||||
[CommandProperty(AccessLevel.GameMaster)]
|
||||
public AMA MeditationAllowance
|
||||
{
|
||||
|
|
@ -480,7 +453,6 @@ namespace Server.Items
|
|||
}
|
||||
}
|
||||
|
||||
[SerializableFieldSaveFlag(22)]
|
||||
private bool ShouldSerializeMeditationAllowance() => _meditate >= AMA.All;
|
||||
|
||||
public virtual double ArmorScalar
|
||||
|
|
@ -570,7 +542,6 @@ namespace Server.Items
|
|||
var resourceType = typeRes ?? craftItem.Resources[0].ItemType;
|
||||
|
||||
Resource = CraftResources.GetFromType(resourceType);
|
||||
PlayerConstructed = true;
|
||||
Identified = true;
|
||||
|
||||
var context = craftSystem.GetContext(from);
|
||||
|
|
@ -697,6 +668,7 @@ namespace Server.Items
|
|||
|
||||
[EncodedInt]
|
||||
[SerializableProperty(9)]
|
||||
[SaveFlag(nameof(ShouldSerializeHitPoints))]
|
||||
[CommandProperty(AccessLevel.GameMaster)]
|
||||
public int HitPoints
|
||||
{
|
||||
|
|
@ -724,7 +696,6 @@ namespace Server.Items
|
|||
}
|
||||
}
|
||||
|
||||
[SerializableFieldSaveFlag(9)]
|
||||
private bool ShouldSerializeHitPoints() => _hitPoints != 0;
|
||||
|
||||
public virtual int InitMinHits => 0;
|
||||
|
|
@ -1026,8 +997,6 @@ 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()
|
||||
{
|
||||
|
|
@ -1514,35 +1483,5 @@ 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -31,13 +31,12 @@ namespace Server.Items
|
|||
public override ArmorMeditationAllowance DefMedAllowance => ArmorMeditationAllowance.All;
|
||||
|
||||
[SerializableField(0, setter: "private")]
|
||||
[SaveFlag(nameof(ShouldSerializeWeaponAttributes), nameof(WeaponAttributesDefaultValue))]
|
||||
[SerializedCommandProperty(AccessLevel.GameMaster, canModify: true)]
|
||||
public AosWeaponAttributes _weaponAttributes;
|
||||
|
||||
[SerializableFieldSaveFlag(0)]
|
||||
private bool ShouldSerializeWeaponAttributes() => !_weaponAttributes.IsEmpty;
|
||||
|
||||
[SerializableFieldDefault(0)]
|
||||
private AosWeaponAttributes WeaponAttributesDefaultValue() => new(this);
|
||||
|
||||
public override void AppendChildNameProperties(IPropertyList list)
|
||||
|
|
|
|||
|
|
@ -33,32 +33,26 @@ namespace Server.Items
|
|||
|
||||
public override ArmorMeditationAllowance DefMedAllowance => ArmorMeditationAllowance.All;
|
||||
|
||||
[SerializableField(0, fieldChanged: nameof(OnCurArcaneChargesChanged))]
|
||||
[EncodedInt]
|
||||
[SerializableProperty(0)]
|
||||
[CommandProperty(AccessLevel.GameMaster)]
|
||||
public int CurArcaneCharges
|
||||
[SerializedCommandProperty(AccessLevel.GameMaster)]
|
||||
[InvalidateProperties]
|
||||
private int _curArcaneCharges;
|
||||
|
||||
private void OnCurArcaneChargesChanged(int oldValue, int newValue)
|
||||
{
|
||||
get => _curArcaneCharges;
|
||||
set
|
||||
{
|
||||
_curArcaneCharges = value;
|
||||
InvalidateProperties();
|
||||
Update();
|
||||
}
|
||||
Update();
|
||||
}
|
||||
|
||||
[SerializableField(1, fieldChanged: nameof(OnMaxArcaneChargesChanged))]
|
||||
[EncodedInt]
|
||||
[SerializableProperty(1)]
|
||||
[CommandProperty(AccessLevel.GameMaster)]
|
||||
public int MaxArcaneCharges
|
||||
[SerializedCommandProperty(AccessLevel.GameMaster)]
|
||||
[InvalidateProperties]
|
||||
private int _maxArcaneCharges;
|
||||
|
||||
private void OnMaxArcaneChargesChanged(int oldValue, int newValue)
|
||||
{
|
||||
get => _maxArcaneCharges;
|
||||
set
|
||||
{
|
||||
_maxArcaneCharges = value;
|
||||
InvalidateProperties();
|
||||
Update();
|
||||
}
|
||||
Update();
|
||||
}
|
||||
|
||||
[CommandProperty(AccessLevel.GameMaster)]
|
||||
|
|
|
|||
|
|
@ -32,32 +32,26 @@ namespace Server.Items
|
|||
|
||||
public override ArmorMeditationAllowance DefMedAllowance => ArmorMeditationAllowance.All;
|
||||
|
||||
[SerializableField(0, fieldChanged: nameof(OnCurArcaneChargesChanged))]
|
||||
[EncodedInt]
|
||||
[SerializableProperty(0)]
|
||||
[CommandProperty(AccessLevel.GameMaster)]
|
||||
public int CurArcaneCharges
|
||||
[SerializedCommandProperty(AccessLevel.GameMaster)]
|
||||
[InvalidateProperties]
|
||||
private int _curArcaneCharges;
|
||||
|
||||
private void OnCurArcaneChargesChanged(int oldValue, int newValue)
|
||||
{
|
||||
get => _curArcaneCharges;
|
||||
set
|
||||
{
|
||||
_curArcaneCharges = value;
|
||||
InvalidateProperties();
|
||||
Update();
|
||||
}
|
||||
Update();
|
||||
}
|
||||
|
||||
[SerializableField(1, fieldChanged: nameof(OnMaxArcaneChargesChanged))]
|
||||
[EncodedInt]
|
||||
[SerializableProperty(1)]
|
||||
[CommandProperty(AccessLevel.GameMaster)]
|
||||
public int MaxArcaneCharges
|
||||
[SerializedCommandProperty(AccessLevel.GameMaster)]
|
||||
[InvalidateProperties]
|
||||
private int _maxArcaneCharges;
|
||||
|
||||
private void OnMaxArcaneChargesChanged(int oldValue, int newValue)
|
||||
{
|
||||
get => _maxArcaneCharges;
|
||||
set
|
||||
{
|
||||
_maxArcaneCharges = value;
|
||||
InvalidateProperties();
|
||||
Update();
|
||||
}
|
||||
Update();
|
||||
}
|
||||
|
||||
[CommandProperty(AccessLevel.GameMaster)]
|
||||
|
|
|
|||
|
|
@ -19,41 +19,38 @@ namespace Server.Items
|
|||
[InternString]
|
||||
[InvalidateProperties]
|
||||
[SerializableField(1)]
|
||||
[SaveFlag(nameof(ShouldSerializeTitle), nameof(TitleDefaultValue))]
|
||||
[SerializedCommandProperty(AccessLevel.GameMaster)]
|
||||
private string _title;
|
||||
|
||||
[SerializableFieldSaveFlag(1)]
|
||||
private bool ShouldSerializeTitle() => _title != DefaultContent?.Title;
|
||||
|
||||
[SerializableFieldDefault(1)]
|
||||
private string TitleDefaultValue() => DefaultContent?.Title;
|
||||
|
||||
[InvalidateProperties]
|
||||
[SerializableField(2)]
|
||||
[SaveFlag(nameof(ShouldSerializeAuthor), nameof(AuthorDefaultValue))]
|
||||
[SerializedCommandProperty(AccessLevel.GameMaster)]
|
||||
private string _author;
|
||||
|
||||
[SerializableFieldSaveFlag(2)]
|
||||
private bool ShouldSerializeAuthor() => _author != DefaultContent?.Author;
|
||||
|
||||
[SerializableFieldDefault(2)]
|
||||
private string AuthorDefaultValue() => DefaultContent?.Author;
|
||||
|
||||
[SerializableField(3)]
|
||||
[SaveFlag(nameof(ShouldSerializeWritable))]
|
||||
[SerializedCommandProperty(AccessLevel.GameMaster)]
|
||||
private bool _writable;
|
||||
|
||||
[SerializableFieldSaveFlag(3)]
|
||||
private bool ShouldSerializeWritable() => _writable;
|
||||
|
||||
[SerializedIgnoreDupe]
|
||||
[SerializableField(4, setter: "protected")]
|
||||
[SaveFlag(nameof(ShouldSerializePages), nameof(PagesDefaultValue))]
|
||||
private BookPageInfo[] _pages;
|
||||
|
||||
[SerializableFieldSaveFlag(4)]
|
||||
private bool ShouldSerializePages() => DefaultContent?.IsMatch(_pages) != true;
|
||||
|
||||
[SerializableFieldDefault(4)]
|
||||
private BookPageInfo[] PagesDefaultValue() => DefaultContent?.Copy() ?? Array.Empty<BookPageInfo>();
|
||||
|
||||
[Constructible]
|
||||
|
|
|
|||
|
|
@ -1,7 +1,25 @@
|
|||
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;
|
||||
|
|
@ -10,7 +28,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;
|
||||
|
|
@ -85,4 +103,23 @@ 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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,90 +22,78 @@ namespace Server.Items
|
|||
int MaxArcaneCharges { get; set; }
|
||||
}
|
||||
|
||||
[SerializationGenerator(7, false)]
|
||||
[SerializationGenerator(8, 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;
|
||||
|
||||
[SerializableField(7)]
|
||||
[SerializedCommandProperty(AccessLevel.GameMaster)]
|
||||
private bool _playerConstructed;
|
||||
|
||||
[SerializableFieldSaveFlag(7)]
|
||||
private bool ShouldSerializePlayerConstructed() => _playerConstructed;
|
||||
|
||||
[InvalidateProperties]
|
||||
[SerializableField(8)]
|
||||
[SerializableField(7)]
|
||||
[SaveFlag(nameof(ShouldSerializeCrafter))]
|
||||
[SerializedCommandProperty(AccessLevel.GameMaster)]
|
||||
private string _crafter;
|
||||
|
||||
[SerializableFieldSaveFlag(8)]
|
||||
private bool ShouldSerializeCrafter() => !string.IsNullOrEmpty(_crafter);
|
||||
|
||||
[InvalidateProperties]
|
||||
[SerializableField(9)]
|
||||
[SerializableField(8)]
|
||||
[SaveFlag(nameof(ShouldSerializeQuality))]
|
||||
[SerializedCommandProperty(AccessLevel.GameMaster)]
|
||||
private ClothingQuality _quality = ClothingQuality.Regular;
|
||||
|
||||
[SerializableFieldSaveFlag(9)]
|
||||
private bool ShouldSerializeQuality() => _quality != ClothingQuality.Regular;
|
||||
|
||||
// Field 10
|
||||
// Field 9
|
||||
private int _strReq = -1;
|
||||
|
||||
private FactionItem _factionState;
|
||||
|
|
@ -125,21 +113,19 @@ namespace Server.Items
|
|||
Resistances = new AosElementAttributes(this);
|
||||
}
|
||||
|
||||
[SerializableProperty(0)]
|
||||
[CommandProperty(AccessLevel.GameMaster)]
|
||||
public CraftResource Resource
|
||||
[SerializableField(0, fieldChanged: nameof(OnResourceChanged))]
|
||||
[SaveFlag(nameof(ShouldSerializeResource))]
|
||||
[SerializedCommandProperty(AccessLevel.GameMaster)]
|
||||
[InvalidateProperties]
|
||||
private CraftResource _resource;
|
||||
|
||||
private void OnResourceChanged(CraftResource oldValue, CraftResource newValue)
|
||||
{
|
||||
get => _resource;
|
||||
set
|
||||
{
|
||||
_resource = value;
|
||||
Hue = CraftResources.GetHue(_resource);
|
||||
InvalidateProperties();
|
||||
this.MarkDirty();
|
||||
}
|
||||
Hue = CraftResources.GetHue(_resource);
|
||||
}
|
||||
|
||||
[SerializableProperty(10, useField: nameof(_strReq))]
|
||||
[SerializableProperty(9, useField: nameof(_strReq))]
|
||||
[SaveFlag(nameof(ShouldSerializeStrReq))]
|
||||
[CommandProperty(AccessLevel.GameMaster)]
|
||||
public int StrRequirement
|
||||
{
|
||||
|
|
@ -152,7 +138,6 @@ namespace Server.Items
|
|||
}
|
||||
}
|
||||
|
||||
[SerializableFieldSaveFlag(10)]
|
||||
private bool ShouldSerializeStrReq() => _strReq != -1;
|
||||
|
||||
public virtual CraftResource DefaultResource => CraftResource.None;
|
||||
|
|
@ -207,8 +192,6 @@ namespace Server.Items
|
|||
Hue = resHue;
|
||||
}
|
||||
|
||||
PlayerConstructed = true;
|
||||
|
||||
var context = craftSystem.GetContext(from);
|
||||
|
||||
if (context?.DoNotColor == true)
|
||||
|
|
@ -308,6 +291,7 @@ namespace Server.Items
|
|||
|
||||
[EncodedInt]
|
||||
[SerializableProperty(6)]
|
||||
[SaveFlag(nameof(ShouldSerializeHitPoints))]
|
||||
[CommandProperty(AccessLevel.GameMaster)]
|
||||
public int HitPoints
|
||||
{
|
||||
|
|
@ -333,7 +317,6 @@ namespace Server.Items
|
|||
}
|
||||
}
|
||||
|
||||
[SerializableFieldSaveFlag(6)]
|
||||
private bool ShouldSerializeHitPoints() => _hitPoints != 0;
|
||||
|
||||
public virtual int InitMinHits => 0;
|
||||
|
|
@ -889,8 +872,6 @@ namespace Server.Items
|
|||
InvalidateProperties();
|
||||
}
|
||||
|
||||
private static bool GetSaveFlag(OldSaveFlag flags, OldSaveFlag toGet) => (flags & toGet) != 0;
|
||||
|
||||
[AfterDeserialization]
|
||||
private void AfterDeserialization()
|
||||
{
|
||||
|
|
@ -911,21 +892,5 @@ 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,34 +22,26 @@ namespace Server.Items
|
|||
|
||||
public override double DefaultWeight => 5.0;
|
||||
|
||||
[SerializableField(0, fieldChanged: nameof(OnCurArcaneChargesChanged))]
|
||||
[EncodedInt]
|
||||
[SerializableProperty(0)]
|
||||
[CommandProperty(AccessLevel.GameMaster)]
|
||||
public int CurArcaneCharges
|
||||
[SerializedCommandProperty(AccessLevel.GameMaster)]
|
||||
[InvalidateProperties]
|
||||
private int _curArcaneCharges;
|
||||
|
||||
private void OnCurArcaneChargesChanged(int oldValue, int newValue)
|
||||
{
|
||||
get => _curArcaneCharges;
|
||||
set
|
||||
{
|
||||
_curArcaneCharges = value;
|
||||
this.MarkDirty();
|
||||
InvalidateProperties();
|
||||
Update();
|
||||
}
|
||||
Update();
|
||||
}
|
||||
|
||||
[SerializableField(1, fieldChanged: nameof(OnMaxArcaneChargesChanged))]
|
||||
[EncodedInt]
|
||||
[SerializableProperty(1)]
|
||||
[CommandProperty(AccessLevel.GameMaster)]
|
||||
public int MaxArcaneCharges
|
||||
[SerializedCommandProperty(AccessLevel.GameMaster)]
|
||||
[InvalidateProperties]
|
||||
private int _maxArcaneCharges;
|
||||
|
||||
private void OnMaxArcaneChargesChanged(int oldValue, int newValue)
|
||||
{
|
||||
get => _maxArcaneCharges;
|
||||
set
|
||||
{
|
||||
_maxArcaneCharges = value;
|
||||
this.MarkDirty();
|
||||
InvalidateProperties();
|
||||
Update();
|
||||
}
|
||||
Update();
|
||||
}
|
||||
|
||||
[CommandProperty(AccessLevel.GameMaster)]
|
||||
|
|
|
|||
|
|
@ -37,21 +37,22 @@ namespace Server.Items
|
|||
public override double DefaultWeight => 3.0;
|
||||
}
|
||||
|
||||
[SerializationGenerator(3, false)]
|
||||
[SerializationGenerator(4, false)]
|
||||
public partial class DeathRobe : Robe
|
||||
{
|
||||
private static readonly TimeSpan m_DefaultDecayTime = TimeSpan.FromMinutes(1.0);
|
||||
|
||||
[TimerDrift]
|
||||
[SerializableField(0)]
|
||||
[DeserializeTimer(nameof(DeserializeDecayTimer))]
|
||||
private Timer _decayTimer;
|
||||
|
||||
[DeserializeTimerField(0)]
|
||||
private void DeserializeDecayTimer(TimeSpan delay)
|
||||
private void DeserializeDecayTimer(TimeSpan delay) => BeginDecay(delay);
|
||||
|
||||
private void MigrateFrom(V3Content content)
|
||||
{
|
||||
if (delay != TimeSpan.MinValue)
|
||||
if (content.DecayTimerDelay != TimeSpan.MinValue)
|
||||
{
|
||||
BeginDecay(delay);
|
||||
DeserializeDecayTimer(content.DecayTimerDelay);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -324,34 +325,26 @@ namespace Server.Items
|
|||
|
||||
public override double DefaultWeight => 3.0;
|
||||
|
||||
[SerializableField(0, fieldChanged: nameof(OnCurArcaneChargesChanged))]
|
||||
[EncodedInt]
|
||||
[SerializableProperty(0)]
|
||||
[CommandProperty(AccessLevel.GameMaster)]
|
||||
public int CurArcaneCharges
|
||||
[SerializedCommandProperty(AccessLevel.GameMaster)]
|
||||
[InvalidateProperties]
|
||||
private int _curArcaneCharges;
|
||||
|
||||
private void OnCurArcaneChargesChanged(int oldValue, int newValue)
|
||||
{
|
||||
get => _curArcaneCharges;
|
||||
set
|
||||
{
|
||||
_curArcaneCharges = value;
|
||||
InvalidateProperties();
|
||||
Update();
|
||||
this.MarkDirty();
|
||||
}
|
||||
Update();
|
||||
}
|
||||
|
||||
[SerializableField(1, fieldChanged: nameof(OnMaxArcaneChargesChanged))]
|
||||
[EncodedInt]
|
||||
[SerializableProperty(1)]
|
||||
[CommandProperty(AccessLevel.GameMaster)]
|
||||
public int MaxArcaneCharges
|
||||
[SerializedCommandProperty(AccessLevel.GameMaster)]
|
||||
[InvalidateProperties]
|
||||
private int _maxArcaneCharges;
|
||||
|
||||
private void OnMaxArcaneChargesChanged(int oldValue, int newValue)
|
||||
{
|
||||
get => _maxArcaneCharges;
|
||||
set
|
||||
{
|
||||
_maxArcaneCharges = value;
|
||||
InvalidateProperties();
|
||||
Update();
|
||||
this.MarkDirty();
|
||||
}
|
||||
Update();
|
||||
}
|
||||
|
||||
[CommandProperty(AccessLevel.GameMaster)]
|
||||
|
|
|
|||
|
|
@ -54,11 +54,10 @@ namespace Server.Items
|
|||
{
|
||||
[EncodedInt]
|
||||
[InvalidateProperties]
|
||||
[SerializableField(0)]
|
||||
[SerializableField(0, fieldChanged: nameof(OnCurArcaneChargesChanged))]
|
||||
[SerializedCommandProperty(AccessLevel.GameMaster)]
|
||||
private int _curArcaneCharges;
|
||||
|
||||
[SerializableFieldChanged(0)]
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
private void OnCurArcaneChargesChanged(int oldValue, int newValue) => Update();
|
||||
|
||||
|
|
@ -71,19 +70,15 @@ namespace Server.Items
|
|||
|
||||
public override CraftResource DefaultResource => CraftResource.RegularLeather;
|
||||
|
||||
[SerializableField(1, fieldChanged: nameof(OnMaxArcaneChargesChanged))]
|
||||
[EncodedInt]
|
||||
[SerializableProperty(1)]
|
||||
[CommandProperty(AccessLevel.GameMaster)]
|
||||
public int MaxArcaneCharges
|
||||
[SerializedCommandProperty(AccessLevel.GameMaster)]
|
||||
[InvalidateProperties]
|
||||
private int _maxArcaneCharges;
|
||||
|
||||
private void OnMaxArcaneChargesChanged(int oldValue, int newValue)
|
||||
{
|
||||
get => _maxArcaneCharges;
|
||||
set
|
||||
{
|
||||
_maxArcaneCharges = value;
|
||||
InvalidateProperties();
|
||||
Update();
|
||||
this.MarkDirty();
|
||||
}
|
||||
Update();
|
||||
}
|
||||
|
||||
[CommandProperty(AccessLevel.GameMaster)]
|
||||
|
|
|
|||
|
|
@ -74,43 +74,31 @@ public abstract partial class BaseDoor : Item, ILockable, ITelekinesisable
|
|||
Movable = false;
|
||||
}
|
||||
|
||||
[SerializableProperty(1)]
|
||||
[CommandProperty(AccessLevel.GameMaster)]
|
||||
public bool Open
|
||||
[SerializableField(1, fieldChanged: nameof(OnOpenChanged))]
|
||||
[SerializedCommandProperty(AccessLevel.GameMaster)]
|
||||
private bool _open;
|
||||
|
||||
private void OnOpenChanged(bool oldValue, bool newValue)
|
||||
{
|
||||
get => _open;
|
||||
set
|
||||
ItemID = _open ? _openedId : _closedId;
|
||||
if (_open)
|
||||
{
|
||||
if (_open != value)
|
||||
{
|
||||
_open = value;
|
||||
|
||||
ItemID = _open ? _openedId : _closedId;
|
||||
|
||||
if (_open)
|
||||
{
|
||||
Location = new Point3D(X + _offset.X, Y + _offset.Y, Z + _offset.Z);
|
||||
}
|
||||
else
|
||||
{
|
||||
Location = new Point3D(X - _offset.X, Y - _offset.Y, Z - _offset.Z);
|
||||
}
|
||||
|
||||
Effects.PlaySound(this, _open ? OpenedSound : ClosedSound);
|
||||
|
||||
if (_open)
|
||||
{
|
||||
_timer ??= new InternalTimer(this);
|
||||
_timer.Start();
|
||||
}
|
||||
else
|
||||
{
|
||||
_timer.Stop();
|
||||
_timer = null;
|
||||
}
|
||||
|
||||
this.MarkDirty();
|
||||
}
|
||||
Location = new Point3D(X + _offset.X, Y + _offset.Y, Z + _offset.Z);
|
||||
}
|
||||
else
|
||||
{
|
||||
Location = new Point3D(X - _offset.X, Y - _offset.Y, Z - _offset.Z);
|
||||
}
|
||||
Effects.PlaySound(this, _open ? OpenedSound : ClosedSound);
|
||||
if (_open)
|
||||
{
|
||||
_timer ??= new InternalTimer(this);
|
||||
_timer.Start();
|
||||
}
|
||||
else
|
||||
{
|
||||
_timer.Stop();
|
||||
_timer = null;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -3,19 +3,22 @@ using ModernUO.Serialization;
|
|||
|
||||
namespace Server.Items;
|
||||
|
||||
[SerializationGenerator(2, false)]
|
||||
[SerializationGenerator(3, false)]
|
||||
public abstract partial class FillableContainer : LockableContainer
|
||||
{
|
||||
[TimerDrift]
|
||||
[SerializableField(1)]
|
||||
[DeserializeTimer(nameof(DeserializeRespawnTimer))]
|
||||
private Timer _respawnTimer;
|
||||
|
||||
[DeserializeTimerField(1)]
|
||||
private void DeserializeRespawnTimer(TimeSpan delay)
|
||||
private void DeserializeRespawnTimer(TimeSpan delay) => _respawnTimer = Timer.DelayCall(delay, Respawn);
|
||||
|
||||
private void MigrateFrom(V2Content content)
|
||||
{
|
||||
if (delay > TimeSpan.MinValue)
|
||||
_contentType = content.ContentType;
|
||||
|
||||
if (content.RespawnTimerDelay != TimeSpan.MinValue)
|
||||
{
|
||||
_respawnTimer = Timer.DelayCall(delay, Respawn);
|
||||
DeserializeRespawnTimer(content.RespawnTimerDelay);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue