diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json index f9f5fbe15..da66d8d0c 100644 --- a/.config/dotnet-tools.json +++ b/.config/dotnet-tools.json @@ -3,7 +3,7 @@ "isRoot": true, "tools": { "modernuoschemagenerator": { - "version": "4.0.0", + "version": "2.14.3", "commands": [ "ModernUOSchemaGenerator" ] diff --git a/.github/workflows/build-test.yml b/.github/workflows/build-test.yml index eb74c39a4..e7bf70d70 100644 --- a/.github/workflows/build-test.yml +++ b/.github/workflows/build-test.yml @@ -46,7 +46,7 @@ jobs: - name: Install Prerequisites run: | brew update - brew install icu4c libdeflate argon2 + brew install icu4c libdeflate zstd argon2 - name: Set Library Path run: echo "DYLD_LIBRARY_PATH=/opt/homebrew/lib:$DYLD_LIBRARY_PATH" >> $GITHUB_ENV - name: Build @@ -124,36 +124,12 @@ jobs: dnf config-manager --set-enabled crb dnf install -y epel-release if: ${{ matrix.epel }} - # Runtime packages only, deliberately. Installing the -dev packages here would add the - # unversioned .so symlink and mask the very thing the binding packages now probe for, so a - # regression in versioned-SONAME resolution would sail through CI. - name: Install Prerequisites using dnf - run: dnf makecache --refresh && dnf install -y findutils libicu libdeflate libargon2 tzdata + run: dnf makecache --refresh && dnf install -y findutils libicu libdeflate-devel zstd libargon2-devel liburing-devel if: ${{ matrix.packageManager == 'dnf' }} - # ICU's runtime package carries the ABI version in its name (libicu70 on jammy, libicu76 on - # trixie) and has no stable alias, so match it by pattern. libicu-dev was the old way to stay - # version-independent, but it drags in the unversioned symlink and defeats the check below. - name: Install Prerequisites using apt - run: apt-get update -y && apt-get install -y curl '^libicu[0-9]+$' libdeflate0 libargon2-1 tzdata + run: apt-get update -y && apt-get install -y curl libicu-dev libdeflate-dev zstd libargon2-dev tzdata liburing-dev if: ${{ matrix.packageManager == 'apt' }} - # Versioned-SONAME resolution is only under test while the unversioned symlink is absent. If a - # base image or a package ever starts shipping it, every probe would succeed on the first try - # and a regression in the fallback would sail through CI, so fail loudly instead of silently - # testing nothing. - - name: Assert the unversioned .so symlinks are absent - run: | - found="" - for lib in libicuuc libicui18n libdeflate libargon2; do - hit=$(ls /usr/lib/*/"$lib".so /usr/lib64/"$lib".so 2>/dev/null || true) - if [ -n "$hit" ]; then - found="$found $hit" - fi - done - if [ -n "$found" ]; then - echo "::error::Unversioned symlinks present, so CI is no longer exercising versioned SONAME resolution:$found" - exit 1 - fi - echo "No unversioned symlinks present; versioned SONAME resolution is under test." - uses: actions/checkout@v7 with: fetch-depth: 0 # avoid shallow clone so nbgv can do its work. diff --git a/.gitignore b/.gitignore index d29ee313a..2cc93a052 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,4 @@ # Distribution Files -/Distribution/Data/Files /Distribution/Logger /Distribution/Logger.* /Distribution/ModernUO @@ -15,7 +14,6 @@ /Distribution/Configuration/blocklist.json /Distribution/Configuration/crowdsec.json /Distribution/Configuration/expansion.json -/Distribution/Configuration/firewall.json /Distribution/Configuration/ip-allowlist*.txt /Distribution/Configuration/ip-allowlist*.txt.tmp /Distribution/Configuration/ip-blocklist.txt @@ -27,7 +25,6 @@ /Distribution/Configuration/email-settings.json /Distribution/Configuration/throttles.json /Distribution/Configuration/tot.json -/Distribution/Data/Pathfinding /Distribution/Logs /Distribution/Archives /Distribution/Backups diff --git a/CLAUDE.md b/CLAUDE.md index c77d39e19..7f24661a4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -18,8 +18,8 @@ Apply these when writing or reviewing `.cs` files under `Projects/`. 6. **Cancel timers in `OnDelete()`/`OnAfterDelete()`** — call `_token.Cancel()` or `_timer?.Stop()` 7. **`STArrayPool.Shared`** not `ArrayPool.Shared` — single-threaded optimized, no locks 8. **`PooledRefList`** not `new List()` on hot paths — zero GC pressure, stack-allocated ref struct -9. **Serialization** — class must be `partial`, constructor needs `[Constructible]`, `TimerExecutionToken` must NOT have `[SerializableField]`. New classes: use `[SerializationGenerator(version)]` (omit `encoded`). Setters that coerce/veto/run side effects: use `[SerializableField]` args `allowFieldChange: nameof(BoolRefMethod)` / `fieldChanged: nameof(OldNewMethod)` — reserve `[SerializableProperty]` for custom getters. Serializable `Timer` members declare `[DeserializeTimer(nameof(Method))]` on the field (anchored by default — downtime preserves remaining delay; `wallClock: true` = absolute; method runs only when a timer was running at save). Conditional writes: `[SaveFlag(nameof(Should), nameof(Default))]` on the field. When bumping versions, add `MigrateFrom(VXContent)` (X = previous version). Never modify `Deserialize(reader, version)` for version bumps — that method is only for pre-codegen legacy saves. When migrating from pre-codegen Serialize/Deserialize: pass `false` if old code used `reader.ReadInt()`, bump version +1, and keep old logic as `private void Deserialize(IGenericReader reader, int version)` → `dev-docs/serialization.md`, `dev-docs/runuo-migration-docs/02-serialization.md` -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` +9. **Serialization** — class must be `partial`, constructor needs `[Constructible]`, `TimerExecutionToken` must NOT have `[SerializableField]`. New classes: use `[SerializationGenerator(version)]` (omit `encoded`). When bumping versions, add `MigrateFrom(VXContent)` (X = previous version). Never modify `Deserialize(reader, version)` for version bumps — that method is only for pre-codegen legacy saves. When migrating from pre-codegen Serialize/Deserialize: pass `false` if old code used `reader.ReadInt()`, bump version +1, and keep old logic as `private void Deserialize(IGenericReader reader, int version)` → `dev-docs/runuo-migration-docs/02-serialization.md` +10. **No `Task.Run`/`new Thread()` for game logic** (tandem with rule #3) — game logic is the single-threaded event loop. Backgrounding is allowed only for work that does not itself touch game state (external service calls, large-file parse). 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(() => …)`. 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 13. **No empty gumps** — every gump must produce visual elements. An empty gump leaks on client+server (no way to close it). Use static `DisplayTo()` to validate before constructing → `dev-docs/gump-system.md` @@ -29,7 +29,6 @@ Apply these when writing or reviewing `.cs` files under `Projects/`. 17. **No `System.Text.StringBuilder`** — use `ValueStringBuilder` with `stackalloc` (bounded output) or `ValueStringBuilder.Create()` (unbounded). Supports `$"..."` interpolation directly. Always use `using var` for disposal. Use `Reset()` instead of reassigning → `dev-docs/string-handling.md` 18. **Interpolation anti-patterns on handler-aware APIs** — `Send*`/`Say`/`Emote`/`PublicOverhead*`/`IPropertyList.Add`/gump `AddLabel`/`AddHtml`/`Html.Center`/`SpanWriter.Write*` all have `ref RawInterpolatedStringHandler` overloads that allocate zero strings, but only when the call-site argument is a `$"..."` literal directly. Avoid: ternaries with interpolated branches (`Send(c ? $"a" : $"b")`), switch expressions with interpolated arms, pre-built `var s = $"..."` locals (single-use), `.ToString()` / `.String()` / `string.Format` inside holes, string concat (`{a + b}`), LINQ string ops in holes. Use `:L` format spec for lowercase (`{rank:L}` not `rank.ToString().ToLowerInvariant()`) → `dev-docs/string-handling.md` § Interpolation Anti-Patterns 19. **No `InvalidateProperties()` from inside `GetProperties`** — every property a `GetProperties` override reads must be a pure read. `InvalidateProperties()` rebuilds the list in place (`Reset()` + rebuild), and `Reset()` returns the pooled interpolation buffer — which the compiler rents for the whole `$"..."` expression, so every hole is evaluated while it is live — and rewinds the packet cursor. A getter that invalidates therefore throws `ArgumentNullException` (parameter `"array"`) out of `GetProperties` from an unrelated-looking line, or silently corrupts the tooltip. The engine refuses and logs an error; `DEBUG` throws. Lazy recomputation in a getter is fine — the *notification* is not. Invalidate in the setter that changes the value, or defer with `Timer.DelayCall(InvalidateProperties)` → `dev-docs/property-lists.md` § Never Invalidate From Inside `GetProperties` -20. **Tick-count math must be wraparound-safe** — compare `Core.TickCount`/`GetTimestamp()` values only by subtraction (`a - b < 0`, never `a < b`), no zero/sign sentinels on tick fields, seed deadline fields from a real tick (never rely on the 0 default). Cloud hypervisors (GCP) pass through the host's never-resetting counter: ticks start enormous and can wrap negative. Linux affected in production; Windows not so far → `dev-docs/tick-counts.md` ## Dev-Docs Reference @@ -46,11 +45,7 @@ Apply these when writing or reviewing `.cs` files under `Projects/`. | Commands & targeting | `dev-docs/commands-targeting.md` | | Event system | `dev-docs/events.md` | | 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` | -| 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` | | Configuration system | `dev-docs/configuration.md` | | Networking & packets | `dev-docs/networking-packets.md` | | IP bans, blocklists & allowlists (incl. unblocking a player) | `dev-docs/ip-bans-and-allowlists.md` | @@ -99,18 +94,7 @@ Then copy only the relevant skill files based on the task: | Migrate persistence (WorldSave) | `migrate-from-runuo/migrate-persistence` | | Migrate multi-file system | `migrate-from-runuo/migrate-systems` | -To enable a skill — Claude Code loads `.claude/skills//SKILL.md`; a bare `.md` dropped -directly into `.claude/skills/` is **not** picked up, and newly installed skills appear in the -*next* session: - -```sh -# Standard skills (modernuo-*) -mkdir -p .claude/skills/ && cp dev-docs/claude-skills/.md .claude/skills//SKILL.md - -# Migration skills — sources live in the migrate-from-runuo/ subfolder, but install under the -# bare skill name (the table's "migrate-from-runuo/" is the source path, not the name): -mkdir -p .claude/skills/ && cp dev-docs/claude-skills/migrate-from-runuo/.md .claude/skills//SKILL.md -``` +To enable a skill: `cp dev-docs/claude-skills/.md .claude/skills/` Migration skills reference the deep docs in `dev-docs/runuo-migration-docs/` and point to existing ModernUO skills for best practices. diff --git a/Directory.Build.props b/Directory.Build.props index c832f1006..59311d439 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -63,15 +63,8 @@ ..\..\Rules.ruleset latest - - - $(DefineConstants);EVENT_LOOP_PROFILING - - + diff --git a/Distribution/Data/npc-speeds.json b/Distribution/Data/npc-speeds.json index 707f61e4a..6e07859d5 100644 --- a/Distribution/Data/npc-speeds.json +++ b/Distribution/Data/npc-speeds.json @@ -3,16 +3,12 @@ "level": "VerySlow", "active": 0.4, "passive": 0.8, - "activeMove": 0.9, - "passiveMove": 1.5, "types": [] }, { "level": "Slow", "active": 0.3, "passive": 0.6, - "activeMove": 0.6, - "passiveMove": 1.2, "types": [ "AntLion", "ArcticOgreLord", "BogThing", "Bogle", "BoneKnight", "EarthElemental", @@ -32,8 +28,6 @@ "level": "Medium", "active": 0.25, "passive": 0.5, - "activeMove": 0.45, - "passiveMove": 1.05, "types": [ "AcidElemental", "AgapiteElemental", "Alligator", "AncientLich", "Betrayer", "Bird", @@ -114,8 +108,6 @@ "level": "Fast", "active": 0.2, "passive": 0.4, - "activeMove": 0.3, - "passiveMove": 0.9, "types": [ "LordOaks", "Silvani", "AirElemental", "AncientWyrm", "Balron", "BladeSpirits", @@ -147,8 +139,6 @@ "level": "VeryFast", "active": 0.125, "passive": 0.30, - "activeMove": 0.125, - "passiveMove": 0.6, "types": [ "Barracoon", "Mephitis", "Neira", "Rikktor", "Semidar", "EnergyVortex", diff --git a/Projects/BuildTool/BuildOptions.cs b/Projects/BuildTool/BuildOptions.cs index 9e1d56388..9aea5010d 100644 --- a/Projects/BuildTool/BuildOptions.cs +++ b/Projects/BuildTool/BuildOptions.cs @@ -14,11 +14,4 @@ public sealed class BuildOptions public string? Arch { get; set; } public bool SkipPrereqs { get; set; } public bool Interactive { get; set; } - - /// - /// Report the native library prerequisites and exit. The interactive flow is the only other - /// path that runs these checks, so without this there is no way to verify a deployment target - /// from a script or a container. - /// - public bool CheckPrereqsOnly { get; set; } } diff --git a/Projects/BuildTool/BuildTool.csproj b/Projects/BuildTool/BuildTool.csproj index 983000240..b3f37cf7a 100644 --- a/Projects/BuildTool/BuildTool.csproj +++ b/Projects/BuildTool/BuildTool.csproj @@ -17,5 +17,6 @@ + diff --git a/Projects/BuildTool/Prerequisites/NativeLibraryChecker.cs b/Projects/BuildTool/Prerequisites/NativeLibraryChecker.cs index 95c521c24..5334e4a6a 100644 --- a/Projects/BuildTool/Prerequisites/NativeLibraryChecker.cs +++ b/Projects/BuildTool/Prerequisites/NativeLibraryChecker.cs @@ -1,4 +1,3 @@ -using System.Runtime.InteropServices; using BuildTool.Platform; using BuildTool.Publishing; @@ -32,10 +31,8 @@ public static class NativeLibraryChecker "Linux", [ ".NET 10 Runtime — https://dotnet.microsoft.com/download/dotnet/10.0", - "Debian/Ubuntu: sudo apt-get install -y libdeflate0 libargon2-1 libicuNN tzdata", - " (libicuNN varies by release, e.g. libicu76 — run build-tool --check-prereqs there for the exact name)", - " (add tzdata-legacy if the shard is configured with an alias such as US/Eastern)", - "Fedora/RHEL: sudo dnf install -y libdeflate libargon2 libicu tzdata", + "Debian/Ubuntu: sudo apt-get install -y libicu-dev libdeflate-dev zstd libargon2-dev liburing-dev", + "Fedora/RHEL: sudo dnf install -y libicu libdeflate-devel zstd libargon2-devel liburing-devel", "CentOS: Also requires epel-release and CRB enabled" ] ), @@ -179,59 +176,82 @@ public static class NativeLibraryChecker return results; } - /// - /// Native libraries the server needs from the system on Linux, and the SONAME range to accept - /// for each. Rationale and per-distro package names: dev-docs/platform-prerequisites.md. - /// - private static readonly (string Name, int MinSoVersion, int MaxSoVersion)[] _linuxLibraries = - [ - ("libicuuc", 60, 120), - ("libicui18n", 60, 120), - ("libdeflate", 0, 9), - ("libargon2", 0, 9) - ]; - private static List CheckLinux(PlatformInfo platform) + { + return platform.PackageManager switch + { + PackageManager.Apt => CheckLinuxApt(), + PackageManager.Dnf => CheckLinuxDnf(platform), + _ => CheckLinuxGeneric(platform) + }; + } + + private static List CheckLinuxApt() { var results = new List(); + var packages = new[] { "libicu-dev", "libdeflate-dev", "zstd", "libargon2-dev", "liburing-dev" }; var missing = new List(); - foreach (var (name, minSoVersion, maxSoVersion) in _linuxLibraries) + foreach (var package in packages) { - var found = CanLoad(name, minSoVersion, maxSoVersion); + var result = ProcessRunner.RunCaptured("dpkg", $"-l {package}"); + var installed = result.Success && result.StandardOutput.Contains("ii"); - if (!found) + if (!installed) { - missing.Add(name); + missing.Add(package); } results.Add(new PrerequisiteResult { - Name = name, - Passed = found, - Details = found ? "Found" : "Not found" + Name = package, + Passed = installed, + Details = installed ? "Installed" : "Not installed" }); } - var hasTimeZoneData = HasTimeZoneData(); - if (!hasTimeZoneData) + if (missing.Count > 0) { - missing.Add("tzdata"); + results.Add(new PrerequisiteResult + { + Name = "Install all missing", + Passed = false, + IsWarning = true, + Details = "Run the following command to install all missing dependencies:", + InstallCommand = $"sudo apt-get install -y {string.Join(' ', missing)}" + }); } - results.Add(new PrerequisiteResult - { - Name = "tzdata", - Passed = hasTimeZoneData, - Details = hasTimeZoneData ? "Found" : "Not found — every zone except UTC will throw" - }); + return results; + } - if (missing.Count == 0) + private static List CheckLinuxDnf(PlatformInfo platform) + { + var results = new List(); + var packages = new[] { "libicu", "libdeflate-devel", "zstd", "libargon2-devel", "liburing-devel" }; + var missing = new List(); + + foreach (var package in packages) { - return results; + var result = ProcessRunner.RunCaptured("rpm", $"-q {package}"); + var installed = result.Success; + + if (!installed) + { + missing.Add(package); + } + + results.Add(new PrerequisiteResult + { + Name = package, + Passed = installed, + Details = installed ? "Installed" : "Not installed" + }); } - if (platform.DistroId?.Equals("centos", StringComparison.OrdinalIgnoreCase) == true) + // Check if this is CentOS (needs EPEL) + var isCentOs = platform.DistroId?.Equals("centos", StringComparison.OrdinalIgnoreCase) == true; + if (isCentOs && missing.Count > 0) { results.Add(new PrerequisiteResult { @@ -243,131 +263,49 @@ public static class NativeLibraryChecker }); } - results.Add(new PrerequisiteResult + if (missing.Count > 0) { - Name = "Install all missing", - Passed = false, - IsWarning = true, - Details = "Install the missing dependencies. The -dev/-devel packages are not required:", - InstallCommand = BuildInstallCommand(platform, missing) - }); + results.Add(new PrerequisiteResult + { + Name = "Install all missing", + Passed = false, + IsWarning = true, + Details = "Run the following command to install all missing dependencies:", + InstallCommand = $"sudo dnf install -y {string.Join(' ', missing)}" + }); + } return results; } - /// - /// tzdata is data, not a library, so no loader probe finds it. Asking the runtime rather than - /// stat'ing a path keeps TZDIR honoured, and the count is still accurate under - /// InvariantGlobalization, which this tool runs with — only display names degrade there. - /// - private static bool HasTimeZoneData() + private static List CheckLinuxGeneric(PlatformInfo platform) { - try - { - return TimeZoneInfo.GetSystemTimeZones().Count > 1; - } - catch - { - return false; - } - } + var results = new List(); - /// - /// Asks the loader directly rather than querying a package database or scanning ldconfig's - /// cache, both of which answer a different question and can disagree with what dlopen will do. - /// Mirrors the binding packages' own probing: the unversioned name first, then libfoo.so.N - /// descending. Bare names go through the full loader search path, so LD_LIBRARY_PATH and - /// /etc/ld.so.conf.d still apply. - /// - private static bool CanLoad(string library, int minSoVersion, int maxSoVersion) - { - if (TryLoadAndFree($"{library}.so")) - { - return true; - } + // Use ldconfig to check for shared libraries + var ldResult = ProcessRunner.RunCaptured("ldconfig", "-p"); + var ldOutput = ldResult.Success ? ldResult.StandardOutput : ""; - for (var soVersion = maxSoVersion; soVersion >= minSoVersion; soVersion--) + var libraries = new Dictionary { - if (TryLoadAndFree($"{library}.so.{soVersion}")) + ["libicu"] = "libicuuc", + ["libdeflate"] = "libdeflate", + ["zstd"] = "libzstd", + ["libargon2"] = "libargon2", + ["liburing"] = "liburing" + }; + + foreach (var (name, soName) in libraries) + { + var found = ldOutput.Contains(soName, StringComparison.OrdinalIgnoreCase); + results.Add(new PrerequisiteResult { - return true; - } + Name = name, + Passed = found, + Details = found ? "Found" : "Not found — install using your package manager" + }); } - return false; - } - - private static bool TryLoadAndFree(string candidate) - { - if (!NativeLibrary.TryLoad(candidate, out var handle)) - { - return false; - } - - NativeLibrary.Free(handle); - return true; - } - - private static string BuildInstallCommand(PlatformInfo platform, List missing) - { - switch (platform.PackageManager) - { - case PackageManager.Apt: - { - // Distinct because the two ICU libraries resolve to the same package, and - // ResolveAptIcuPackage shells out, so it is memoized rather than called per name. - var packages = missing.Select( - library => library switch - { - "libdeflate" => "libdeflate0", - "libargon2" => "libargon2-1", - "tzdata" => "tzdata", - _ => _aptIcuPackage ??= ResolveAptIcuPackage() - } - ).Distinct(); - - return $"sudo apt-get install -y {string.Join(' ', packages)}"; - } - case PackageManager.Dnf: - { - var packages = missing.Select( - library => library switch - { - "libdeflate" => "libdeflate", - "libargon2" => "libargon2", - "tzdata" => "tzdata", - _ => "libicu" - } - ).Distinct(); - - return $"sudo dnf install -y {string.Join(' ', packages)}"; - } - default: - return $"Install your distribution's runtime packages for: {string.Join(", ", missing)}"; - } - } - - private static string _aptIcuPackage; - - /// - /// ICU's apt package carries the ABI version in its name and there is no stable alias, so ask - /// apt which one this release actually ships instead of printing a name that rots. - /// - private static string ResolveAptIcuPackage() - { - var result = ProcessRunner.RunCaptured("apt-cache", "search --names-only ^libicu[0-9]+$"); - if (!result.Success) - { - return "libicu"; - } - - var best = result.StandardOutput - .Split('\n', StringSplitOptions.RemoveEmptyEntries) - .Select(line => line.Split(' ', 2)[0].Trim()) - .Where(name => name.StartsWith("libicu", StringComparison.Ordinal)) - .OrderBy(name => int.TryParse(name.AsSpan(6), out var version) ? version : 0) - .LastOrDefault(); - - return best ?? "libicu"; + return results; } } diff --git a/Projects/BuildTool/Program.cs b/Projects/BuildTool/Program.cs index 915839bbf..3b36066da 100644 --- a/Projects/BuildTool/Program.cs +++ b/Projects/BuildTool/Program.cs @@ -4,7 +4,6 @@ using BuildTool.Interactive; using BuildTool.Platform; using BuildTool.Prerequisites; using BuildTool.Publishing; -using Spectre.Console; Console.OutputEncoding = Encoding.UTF8; @@ -37,22 +36,6 @@ options.Os ??= detectedPlatform.OsRid; options.Arch ??= detectedPlatform.ArchRid; var rid = $"{options.Os}-{options.Arch}"; -if (options.CheckPrereqsOnly) -{ - // Same renderer the guided menu uses, so the two cannot drift. Spectre drops ANSI styling by - // itself when stdout is not a terminal, which is the case this flag exists for, but it also - // falls back to an 80 column width and folds anything longer. The install hints we print are - // shell commands — the CentOS one is 95 characters — and a fold puts a newline in the middle of - // a command that someone is meant to copy. Widen the profile so they stay on one line. - if (Console.IsOutputRedirected) - { - AnsiConsole.Profile.Width = 200; - } - - // Exit code is the machine-readable half: 0 when everything resolves, 1 when anything is missing. - return PrerequisiteChecker.CheckNativeLibraries(detectedPlatform, interactive: false) ? 0 : 1; -} - // Run prerequisite checks unless skipped if (!options.SkipPrereqs) { @@ -125,12 +108,6 @@ static BuildOptions ParseArguments(string[] args) hasNamedArgs = true; break; } - case "--check-prereqs": - { - options.CheckPrereqsOnly = true; - hasNamedArgs = true; - break; - } case "--interactive": { options.Interactive = true; diff --git a/Projects/Server.Tests/Server.Tests.csproj b/Projects/Server.Tests/Server.Tests.csproj index 4bae245cb..1be789258 100644 --- a/Projects/Server.Tests/Server.Tests.csproj +++ b/Projects/Server.Tests/Server.Tests.csproj @@ -5,16 +5,17 @@ Server.Tests - + - + all runtime; build; native; contentfiles; analyzers; buildtransitive + diff --git a/Projects/Server.Tests/Tests/Items/DecayRegistrationTests.cs b/Projects/Server.Tests/Tests/Items/DecayRegistrationTests.cs index 59d736c6f..0e48977ef 100644 --- a/Projects/Server.Tests/Tests/Items/DecayRegistrationTests.cs +++ b/Projects/Server.Tests/Tests/Items/DecayRegistrationTests.cs @@ -208,272 +208,6 @@ public class DecayRegistrationTests item.Delete(); } - // Unfreezing an item with a stale LastMoved must grant a fresh decay window, - // not delete it on the next tick. - [Fact] - public void StaleImmovableItemMadeMovable_GetsAFreshDecayWindow() - { - var start = Core._now; - - try - { - var item = new Item(0x1234); - item.MoveToWorld(new Point3D(107, 100, 0), Map.Felucca); - - item.Movable = false; - Assert.False(DecayScheduler.IsRegistered(item), "A frozen item must not be tracked for decay."); - - Core._now = start + TimeSpan.FromDays(30); - var flipped = Core._now; - - item.Movable = true; - - Assert.True(DecayScheduler.IsRegistered(item), "An unfrozen item must be tracked for decay."); - - AdvanceDecay(flipped, item.DecayTime - TimeSpan.FromMinutes(2), item); - Assert.False(item.Deleted, "An unfrozen item must get a full decay window, not vanish immediately."); - - AdvanceDecay(Core._now, TimeSpan.FromMinutes(4), item); - Assert.True(item.Deleted, "An unfrozen item must still decay once the fresh window elapses."); - } - finally - { - Core._now = start; - } - } - - // Same transition through the Visible setter: unhiding a long-hidden item. - [Fact] - public void StaleHiddenItemMadeVisible_GetsAFreshDecayWindow() - { - var start = Core._now; - - try - { - var item = new Item(0x1234); - item.MoveToWorld(new Point3D(109, 100, 0), Map.Felucca); - - item.Visible = false; - Assert.False(DecayScheduler.IsRegistered(item), "A hidden item must not be tracked for decay."); - - Core._now = start + TimeSpan.FromDays(30); - var flipped = Core._now; - - item.Visible = true; - - Assert.True(DecayScheduler.IsRegistered(item), "An unhidden item must be tracked for decay."); - - AdvanceDecay(flipped, item.DecayTime - TimeSpan.FromMinutes(2), item); - Assert.False(item.Deleted, "An unhidden item must get a full decay window, not vanish immediately."); - - AdvanceDecay(Core._now, TimeSpan.FromMinutes(4), item); - Assert.True(item.Deleted, "An unhidden item must still decay once the fresh window elapses."); - } - finally - { - Core._now = start; - } - } - - // A refusal restarts the countdown without rewriting LastMoved. - [Fact] - public void RefusedDecay_DoesNotRewriteLastMoved() - { - var start = Core._now; - - try - { - var item = new RefusesDecayItem(); - item.MoveToWorld(new Point3D(110, 100, 0), Map.Felucca); - var lastMoved = item.LastMoved; - - AdvanceDecay(start, item.DecayTime + TimeSpan.FromMinutes(2), item); - - Assert.False(item.Deleted, "A refused decay must not delete the item."); - Assert.True(DecayScheduler.IsRegistered(item), "A refused decay must leave the item tracked."); - Assert.Equal(lastMoved, item.LastMoved); - - item.Delete(); - } - finally - { - Core._now = start; - } - } - - // The fresh window must survive a save/load cycle, or a restart mid-window deletes the item. - [Fact] - public void FreshDecayWindow_SurvivesSerialization() - { - var start = Core._now; - - try - { - var item = new Item(0x1234); - item.MoveToWorld(new Point3D(111, 100, 0), Map.Felucca); - - item.Movable = false; - Core._now = start + TimeSpan.FromDays(30); - item.Movable = true; - - var expected = item.ScheduledDecayTime; - - var writer = new BufferWriter(new byte[512], true); - item.Serialize(writer); - - var copy = new Item(item.Serial); - copy.Deserialize(new BufferReader(writer.Buffer)); - - // The stamp is stored as a delta, so it ages only by the real time between - // write and read - milliseconds here, the downtime in production. - Assert.True( - (copy.ScheduledDecayTime - expected).Duration() <= TimeSpan.FromSeconds(5), - "The restarted decay window must survive a save/load cycle." - ); - - item.Delete(); - copy.Delete(); - } - finally - { - Core._now = start; - } - } - - // A real move supersedes the reset stamp; it must be dropped so the CompactInfo can collapse. - [Fact] - public void MovingAnItem_ClearsASupersededDecayResetStamp() - { - var start = Core._now; - - try - { - var item = new Item(0x1234); - item.MoveToWorld(new Point3D(112, 100, 0), Map.Felucca); - - item.Movable = false; - Core._now = start + TimeSpan.FromDays(30); - item.Movable = true; - - Assert.NotEqual(default, item.DecayResetTime); - - Core._now += TimeSpan.FromMinutes(1); - item.MoveToWorld(new Point3D(113, 100, 0), Map.Felucca); - - Assert.Equal(default, item.DecayResetTime); - Assert.Equal(item.LastMoved + item.DecayTime, item.ScheduledDecayTime); - Assert.True(DecayScheduler.IsRegistered(item)); - - item.Delete(); - } - finally - { - Core._now = start; - } - } - - // Losing decay eligibility makes the stamp meaningless; it must be dropped so the - // CompactInfo is not held for as long as the item stays ineligible. - [Fact] - public void ItemBecomingIneligible_DropsTheDecayResetStamp() - { - var start = Core._now; - - try - { - var item = new Item(0x1234); - item.MoveToWorld(new Point3D(115, 100, 0), Map.Felucca); - - item.Movable = false; - Core._now = start + TimeSpan.FromDays(30); - item.Movable = true; - - Assert.NotEqual(default, item.DecayResetTime); - - item.Movable = false; - - Assert.Equal(default, item.DecayResetTime); - - item.Delete(); - } - finally - { - Core._now = start; - } - } - - // Moving a stamped item into a container programmatically (no drop, no SetLastMoved) - // must also drop the stamp. - [Fact] - public void StampedItemAddedToContainer_DropsTheDecayResetStamp() - { - var start = Core._now; - - try - { - var pack = new Container(0xE75); - pack.MoveToWorld(new Point3D(116, 100, 0), Map.Felucca); - - var item = new Item(0x1234); - item.MoveToWorld(new Point3D(117, 100, 0), Map.Felucca); - - item.Movable = false; - Core._now = start + TimeSpan.FromDays(30); - item.Movable = true; - - Assert.NotEqual(default, item.DecayResetTime); - - pack.AddItem(item); - - Assert.Equal(default, item.DecayResetTime); - - pack.Delete(); - } - finally - { - Core._now = start; - } - } - - // A raw Map assignment (e.g. a GM changing Map through props) is a move: it must - // enroll an untracked item for decay. - [Fact] - public void ItemMovedToRealMapViaMapSetter_IsRegisteredForDecay() - { - var item = new Item(0x1234); - Assert.False(DecayScheduler.IsRegistered(item)); - - item.Map = Map.Felucca; - - Assert.True(item.CanDecay()); - Assert.True(DecayScheduler.IsRegistered(item), "Item placed on a map via the Map setter must be tracked."); - - item.Delete(); - } - - // LiftItemDupe places the remainder of a partially lifted ground stack via raw - // Location/Map assignments, with no MoveToWorld fallback: it must still be tracked. - [Fact] - public void PartialLiftOfGroundStack_LeavesRemainderRegisteredForDecay() - { - var stack = new Item(0x1234) { Stackable = true, Amount = 10 }; - stack.MoveToWorld(new Point3D(114, 100, 0), Map.Felucca); - - var remainder = Mobile.LiftItemDupe(stack, 3); - - Assert.NotNull(remainder); - Assert.Equal(7, remainder.Amount); - Assert.Null(remainder.Parent); - Assert.Equal(Map.Felucca, remainder.Map); - Assert.True( - DecayScheduler.IsRegistered(remainder), - "The remainder of a partially lifted ground stack must be tracked for decay." - ); - - stack.Delete(); - remainder.Delete(); - } - // Dropping into a container must untrack; taking it back out to the ground must re-track. [Fact] public void ItemMovedIntoContainerThenBackToGround_IsRegisteredForDecay() diff --git a/Projects/Server.Tests/Tests/Items/PlayerConstructedStackingTests.cs b/Projects/Server.Tests/Tests/Items/PlayerConstructedStackingTests.cs deleted file mode 100644 index 955a87293..000000000 --- a/Projects/Server.Tests/Tests/Items/PlayerConstructedStackingTests.cs +++ /dev/null @@ -1,95 +0,0 @@ -using Xunit; - -namespace Server.Tests; - -[Collection("Sequential Server Tests")] -public class PlayerConstructedStackingTests -{ - // PlayerConstructed is per-instance provenance, and stack operations were written when no - // item carried any. Merging keeps the receiver's copy of a field and splitting rebuilds one - // half from a fixed list of fields, so a flag that is not accounted for in both places is - // one that ordinary stacking can launder or erase. - - // Stands in for a real stackable type. LiftItemDupe builds the remainder through the - // parameterless constructor and copies only a fixed list of fields onto it -- Stackable is - // not on that list -- so the remainder is only stackable if the type restores it the way - // every genuine stackable does. - private class StackableItem : Item - { - public StackableItem() => Stackable = true; - - public StackableItem(Serial serial) : base(serial) => Stackable = true; - } - - private static StackableItem MakeStack(Serial serial, int amount, bool playerConstructed) => - new(serial) { Amount = amount, PlayerConstructed = playerConstructed }; - - [Theory] - [InlineData(false)] - [InlineData(true)] - public void CanStackWith_IsTrueWhenProvenanceMatches(bool playerConstructed) - { - var first = MakeStack((Serial)0x1, 5, playerConstructed); - var second = MakeStack((Serial)0x2, 7, playerConstructed); - - try - { - Assert.True(first.CanStackWith(second)); - } - finally - { - first.Delete(); - second.Delete(); - } - } - - [Theory] - [InlineData(false)] - [InlineData(true)] - public void LiftItemDupe_CopiesPlayerConstructedToRemainder(bool playerConstructed) - { - var stack = MakeStack((Serial)0x1, 10, playerConstructed); - Item remainder = null; - - try - { - remainder = Mobile.LiftItemDupe(stack, 4); - - Assert.NotNull(remainder); - Assert.NotSame(stack, remainder); - Assert.Equal(4, stack.Amount); - Assert.Equal(6, remainder.Amount); - Assert.Equal(playerConstructed, remainder.PlayerConstructed); - } - finally - { - stack.Delete(); - remainder?.Delete(); - } - } - - [Fact] - public void SplitHalvesRemainStackableWithEachOther() - { - // The two halves of a split must still be one pile's worth: if the split dropped the - // flag, the remainder would no longer stack back onto what it came from. - var stack = MakeStack((Serial)0x1, 10, true); - Item remainder = null; - - try - { - remainder = Mobile.LiftItemDupe(stack, 4); - Assert.NotNull(remainder); - - Assert.True(stack.CanStackWith(remainder)); - Assert.True(stack.StackWith(null, remainder, false)); - Assert.Equal(10, stack.Amount); - Assert.True(stack.PlayerConstructed); - } - finally - { - stack.Delete(); - remainder?.Delete(); - } - } -} diff --git a/Projects/Server.Tests/Tests/Mobiles/DamageEntryTests.cs b/Projects/Server.Tests/Tests/Mobiles/DamageEntryTests.cs deleted file mode 100644 index 9c3cdfd14..000000000 --- a/Projects/Server.Tests/Tests/Mobiles/DamageEntryTests.cs +++ /dev/null @@ -1,311 +0,0 @@ -using System; -using System.Collections.Generic; -using Server.Collections; -using Xunit; - -namespace Server.Tests; - -[Collection("Sequential Server Tests")] -public class DamageEntryTests -{ - private class TestMobile : Mobile - { - } - - private class PetMobile : Mobile - { - public Mobile Master { get; set; } - - public override Mobile GetDamageMaster(Mobile damagee) => Master; - } - - private static List Damagers(Mobile victim) - { - var result = new List(); - foreach (var de in victim.DamageEntries) - { - result.Add(de.Damager); - } - - return result; - } - - [Fact] - public void FreshMobile_HasNoEntries() - { - var m = new TestMobile(); - - try - { - Assert.Equal(0, m.DamageEntries.Count); - Assert.Null(m.FindMostRecentDamageEntry(true)); - Assert.Null(m.FindLeastRecentDamageEntry(true)); - Assert.Null(m.FindMostTotalDamageEntry(true)); - Assert.Null(m.FindLeastTotalDamageEntry(true)); - Assert.Null(m.FindDamageEntryFor(m)); - } - finally - { - m.Delete(); - } - } - - [Fact] - public void RegisterDamage_OrdersLeastRecentToMostRecent() - { - var victim = new TestMobile(); - var a = new TestMobile(); - var b = new TestMobile(); - - try - { - victim.RegisterDamage(10, a); - victim.RegisterDamage(20, b); - victim.RegisterDamage(5, a); // a becomes most recent again - - Assert.Equal(2, victim.DamageEntries.Count); - Assert.Equal(new[] { b, a }, Damagers(victim)); - Assert.Equal(15, victim.FindDamageEntryFor(a).DamageGiven); - Assert.Same(a, victim.FindMostRecentDamager(true)); - Assert.Same(b, victim.FindLeastRecentDamager(true)); - } - finally - { - victim.Delete(); - a.Delete(); - b.Delete(); - } - } - - [Fact] - public void FindRecent_HonorsAllowSelf() - { - var victim = new TestMobile(); - var a = new TestMobile(); - - try - { - victim.RegisterDamage(10, a); - victim.RegisterDamage(10, victim); // self is most recent - - Assert.Same(victim, victim.FindMostRecentDamager(true)); - Assert.Same(a, victim.FindMostRecentDamager(false)); - Assert.Same(a, victim.FindLeastRecentDamager(false)); - } - finally - { - victim.Delete(); - a.Delete(); - } - } - - [Fact] - public void FindLeastRecent_HonorsAllowSelf() - { - var victim = new TestMobile(); - var a = new TestMobile(); - - try - { - victim.RegisterDamage(10, victim); // self is least recent, so the head is the one to skip - victim.RegisterDamage(10, a); - - Assert.Same(victim, victim.FindLeastRecentDamager(true)); - Assert.Same(a, victim.FindLeastRecentDamager(false)); - } - finally - { - victim.Delete(); - a.Delete(); - } - } - - [Fact] - public void FindTotal_PicksByDamage_MostRecentWinsTies() - { - var victim = new TestMobile(); - var a = new TestMobile(); - var b = new TestMobile(); - var c = new TestMobile(); - - try - { - victim.RegisterDamage(30, a); - victim.RegisterDamage(30, b); // ties a; b is more recent - victim.RegisterDamage(1, c); - - Assert.Same(b, victim.FindMostTotalDamager(true)); - Assert.Same(c, victim.FindLeastTotalDamager(true)); - } - finally - { - victim.Delete(); - a.Delete(); - b.Delete(); - c.Delete(); - } - } - - [Fact] - public void FindLeastTotal_MostRecentWinsTies() - { - var victim = new TestMobile(); - var a = new TestMobile(); - var b = new TestMobile(); - var c = new TestMobile(); - - try - { - victim.RegisterDamage(30, a); - victim.RegisterDamage(5, b); - victim.RegisterDamage(5, c); // ties b for the minimum; c is more recent - - Assert.Same(a, victim.FindMostTotalDamager(true)); - Assert.Same(c, victim.FindLeastTotalDamager(true)); - } - finally - { - victim.Delete(); - a.Delete(); - b.Delete(); - c.Delete(); - } - } - - [Fact] - public void Prune_RemovesExpiredPrefix_KeepsOrder() - { - var start = Core._now; - var victim = new TestMobile(); - var a = new TestMobile(); - var b = new TestMobile(); - - try - { - victim.RegisterDamage(10, a); - - Core._now = start + DamageEntry.ExpireDelay + TimeSpan.FromSeconds(1); - victim.RegisterDamage(10, b); // a is now expired, b is live - - Assert.Equal(new[] { b }, Damagers(victim)); - Assert.Null(victim.FindDamageEntryFor(a)); - } - finally - { - Core._now = start; - victim.Delete(); - a.Delete(); - b.Delete(); - } - } - - [Fact] - public void Prune_AllExpired_EmptiesList() - { - var start = Core._now; - var victim = new TestMobile(); - var a = new TestMobile(); - var b = new TestMobile(); - - try - { - victim.RegisterDamage(10, a); - victim.RegisterDamage(10, b); - - Core._now = start + DamageEntry.ExpireDelay + TimeSpan.FromSeconds(1); - - Assert.Equal(0, victim.DamageEntries.Count); - Assert.Null(victim.FindMostRecentDamageEntry(true)); - } - finally - { - Core._now = start; - victim.Delete(); - a.Delete(); - b.Delete(); - } - } - - [Fact] - public void ClearDamageEntries_UnlinksEveryNode() - { - var victim = new TestMobile(); - var a = new TestMobile(); - var b = new TestMobile(); - - try - { - var ea = victim.RegisterDamage(10, a); - var eb = victim.RegisterDamage(10, b); - - victim.ClearDamageEntries(); - - Assert.Equal(0, victim.DamageEntries.Count); - Assert.False(ea.OnLinkList); - Assert.False(eb.OnLinkList); - Assert.Null(ea.Next); - Assert.Null(ea.Previous); - Assert.Null(eb.Next); - Assert.Null(eb.Previous); - } - finally - { - victim.Delete(); - a.Delete(); - b.Delete(); - } - } - - [Fact] - public void FullHitPoints_ClearsEntries() - { - var victim = new TestMobile(); - var a = new TestMobile(); - - try - { - victim.RawStr = 50; // HitsMax follows Str for a base Mobile - victim.Hits = 10; - victim.RegisterDamage(10, a); - Assert.Equal(1, victim.DamageEntries.Count); - - // Also stops the HitsTimer the Hits = 10 write started, so the test leaves no timer behind. - victim.Hits = victim.HitsMax; - - Assert.Equal(0, victim.DamageEntries.Count); - } - finally - { - victim.Delete(); - a.Delete(); - } - } - - [Fact] - public void RegisterDamage_AccumulatesResponsibleMaster() - { - var victim = new TestMobile(); - var master = new TestMobile(); - var pet = new PetMobile { Master = master }; - - try - { - victim.RegisterDamage(10, pet); - var entry = victim.RegisterDamage(5, pet); - - Assert.Same(pet, entry.Damager); - Assert.Equal(15, entry.DamageGiven); - Assert.NotNull(entry.Responsible); - Assert.Single(entry.Responsible); - Assert.Same(master, entry.Responsible[0].Damager); - Assert.Equal(15, entry.Responsible[0].DamageGiven); - Assert.False(entry.Responsible[0].OnLinkList); // sub-entries never join the main list - } - finally - { - victim.Delete(); - master.Delete(); - pet.Delete(); - } - } -} diff --git a/Projects/Server.Tests/Tests/Network/EventLoopIdleTests.cs b/Projects/Server.Tests/Tests/Network/EventLoopIdleTests.cs deleted file mode 100644 index dfb8c7be7..000000000 --- a/Projects/Server.Tests/Tests/Network/EventLoopIdleTests.cs +++ /dev/null @@ -1,69 +0,0 @@ -using Xunit; - -namespace Server.Tests; - -/// -/// The event loop only sleeps when every queue it drains is empty. These drains are deliberately -/// bounded -- ExecuteTasks stops at its per-frame cap -- so leftover work is normal and must keep -/// the loop awake. Getting this wrong strands queued work for the length of a sleep. -/// -[Collection("Sequential Server Tests")] -public class EventLoopIdleTests -{ - [Fact] - public void FreshContextIsEmpty() - { - var context = new EventLoopContext(); - - Assert.True(context.IsEmpty); - } - - [Fact] - public void PostedWorkMakesContextNonEmpty() - { - var context = new EventLoopContext(); - - context.Post(() => { }); - - Assert.False(context.IsEmpty); - } - - [Fact] - public void PriorityWorkMakesContextNonEmpty() - { - var context = new EventLoopContext(); - - context.Post(() => { }, EventLoopContext.Priority.High); - - Assert.False(context.IsEmpty); - } - - [Fact] - public void ContextIsEmptyAgainOnceDrained() - { - var context = new EventLoopContext(); - context.Post(() => { }); - - context.ExecuteTasks(); - - Assert.True(context.IsEmpty); - } - - [Fact] - public void WorkBeyondThePerFrameCapKeepsContextNonEmpty() - { - // The cap is what makes IsEmpty necessary: a single ExecuteTasks pass cannot be assumed - // to have drained everything, so the loop must not treat "I just ran tasks" as "idle". - const int perFrameCap = 128; - var context = new EventLoopContext(perFrameCap); - - for (var i = 0; i < perFrameCap + 10; i++) - { - context.Post(() => { }); - } - - context.ExecuteTasks(); - - Assert.False(context.IsEmpty); - } -} diff --git a/Projects/Server.Tests/Tests/Serialization/AnchoredItemSerializationTests.cs b/Projects/Server.Tests/Tests/Serialization/AnchoredItemSerializationTests.cs deleted file mode 100644 index 7889c57c8..000000000 --- a/Projects/Server.Tests/Tests/Serialization/AnchoredItemSerializationTests.cs +++ /dev/null @@ -1,76 +0,0 @@ -using System; -using Xunit; - -namespace Server.Tests; - -[Collection("Sequential Server Tests")] -public class AnchoredItemSerializationTests -{ - private static byte[] SerializeItem(Item item) - { - var writer = new BufferWriter(new byte[256], true); - item.Serialize(writer); - return writer.Buffer[..(int)writer.Position]; - } - - /// - /// Item v11 stores LastMoved and DecayResetTime as anchored time: the serialized bytes - /// are a function of item state only, not of when the save runs. Pre-v11 stored - /// minutes-since-moved and delta time, which rewrote the bytes on every save. - /// - [Fact] - public void ItemBytes_AreStable_AcrossSavesAtDifferentTimes() - { - var start = Core._now; - - try - { - var item = new Item(0x1F13); - item.MoveToWorld(new Point3D(120, 100, 0), Map.Felucca); - item.RestartDecay(); - - var first = SerializeItem(item); - - // A save hours later, with no state change, must produce identical bytes. - Core._now = start + TimeSpan.FromHours(5); - var second = SerializeItem(item); - - Assert.Equal(first, second); - - item.Delete(); - } - finally - { - Core._now = start; - } - } - - /// - /// Pre-v11 LastMoved was stored at whole-minute precision relative to the save time and - /// could never round-trip exactly. Anchored storage is absolute and exact. - /// - [Fact] - public void LastMovedAndDecayReset_RoundTripExactly() - { - var item = new Item(0x1F13); - item.MoveToWorld(new Point3D(121, 100, 0), Map.Felucca); - - // Sub-minute precision that the old minutes encoding would have destroyed. - var moved = Core.Now - TimeSpan.FromSeconds(90.5) - TimeSpan.FromMilliseconds(123); - item.LastMoved = moved; - - item.RestartDecay(); - var decayReset = item.DecayResetTime; - Assert.NotEqual(default(DateTime), decayReset); - - var bytes = SerializeItem(item); - - var restored = new Item((Serial)0x7ffff123u); - restored.Deserialize(new BufferReader(bytes)); - - Assert.Equal(moved, restored.LastMoved); - Assert.Equal(decayReset, restored.DecayResetTime); - - item.Delete(); - } -} diff --git a/Projects/Server.Tests/Tests/Serialization/AnchoredTimeTests.cs b/Projects/Server.Tests/Tests/Serialization/AnchoredTimeTests.cs deleted file mode 100644 index a99b9850c..000000000 --- a/Projects/Server.Tests/Tests/Serialization/AnchoredTimeTests.cs +++ /dev/null @@ -1,189 +0,0 @@ -using System; -using System.Collections.Generic; -using System.IO; -using Xunit; - -namespace Server.Tests; - -public class AnchoredTimeTests -{ - private static (BufferWriter Writer, Func Read) CreateRoundTrip() - { - var writer = new BufferWriter(new byte[64], true); - return (writer, shift => new BufferReader(writer.Buffer) { AnchoredTimeShift = shift }); - } - - [Fact] - public void AnchoredTime_RoundTripsExactly_WithZeroShift() - { - var (writer, read) = CreateRoundTrip(); - var value = new DateTime(2026, 8, 22, 12, 30, 0, DateTimeKind.Utc); - - writer.WriteAnchoredTime(value); - - Assert.Equal(value, read(TimeSpan.Zero).ReadAnchoredTime()); - } - - [Fact] - public void AnchoredTime_AppliesShiftOnRead() - { - var (writer, read) = CreateRoundTrip(); - var value = new DateTime(2026, 8, 22, 12, 30, 0, DateTimeKind.Utc); - var shift = TimeSpan.FromHours(3); - - writer.WriteAnchoredTime(value); - - Assert.Equal(value + shift, read(shift).ReadAnchoredTime()); - } - - [Fact] - public void AnchoredTime_SentinelsPassThroughUnshifted() - { - var (writer, read) = CreateRoundTrip(); - - writer.WriteAnchoredTime(DateTime.MinValue); - writer.WriteAnchoredTime(DateTime.MaxValue); - - var reader = read(TimeSpan.FromDays(2)); - Assert.Equal(DateTime.MinValue, reader.ReadAnchoredTime()); - Assert.Equal(DateTime.MaxValue, reader.ReadAnchoredTime()); - } - - [Fact] - public void AnchoredTime_SaturatesInsteadOfOverflowing() - { - var (writer, read) = CreateRoundTrip(); - - writer.WriteAnchoredTime(DateTime.MaxValue - TimeSpan.FromMinutes(1)); - - Assert.Equal(DateTime.MaxValue, read(TimeSpan.FromDays(1)).ReadAnchoredTime()); - } - - [Fact] - public void AnchoredTime_NormalizesLocalKindOnWrite() - { - var (writer, read) = CreateRoundTrip(); - var local = new DateTime(2026, 8, 22, 12, 30, 0, DateTimeKind.Local); - - writer.WriteAnchoredTime(local); - - Assert.Equal(local.ToUniversalTime(), read(TimeSpan.Zero).ReadAnchoredTime()); - } -} - -internal class AnchoredEntity : ISerializable -{ - public AnchoredEntity(Serial serial) => Serial = serial; - - public Serial Serial { get; } - public DateTime Created { get; set; } = DateTime.UtcNow; - public bool Deleted => false; - - public DateTime LastRested { get; set; } - - public void Delete() - { - } - - public void Serialize(IGenericWriter writer) => writer.WriteAnchoredTime(LastRested); - - public void Deserialize(IGenericReader reader) => LastRested = reader.ReadAnchoredTime(); -} - -[Collection("Sequential Server Tests")] -public class AnchoredTimePersistenceTests -{ - private class AnchoredPersistence : GenericEntityPersistence - { - public AnchoredPersistence(int priority) : base("AnchoredTrip", priority, 1, 0x7FFFFFFF) - { - } - } - - /// - /// The idx v5 header carries the save-start anchor; loading re-bases anchored timestamps - /// by the elapsed time since the save started, so downtime does not age them. - /// - [Fact] - public void SaveStartAnchor_RebasesAnchoredTimestampsAtLoad() - { - var previousAssemblies = AssemblyHandler.Assemblies; - AssemblyHandler.Assemblies = [.. previousAssemblies ?? [], typeof(AnchoredEntity).Assembly]; - - var source = new SerializationChunkSource(); - var workers = new SerializationThreadWorker[2]; - for (var i = 0; i < workers.Length; i++) - { - workers[i] = new SerializationThreadWorker(i, source); - workers[i].AllocateHeap(); - } - - var previousWorkers = World._threadWorkers; - World._threadWorkers = workers; - - var previousSaveStart = World.SaveStartTime; - - var persistence = new AnchoredPersistence(2100); - AnchoredPersistence loaded = null; - - var dir = Path.Combine(Path.GetTempPath(), $"muo-anchored-{Guid.NewGuid():N}"); - Directory.CreateDirectory(dir); - - try - { - var lastRested = Core.Now - TimeSpan.FromMinutes(10); - var serial = (Serial)1u; - persistence.EntitiesBySerial[serial] = new AnchoredEntity(serial) { LastRested = lastRested }; - persistence.RegisterType(typeof(AnchoredEntity)); - - // Pretend the save started two hours ago, as if the server had been down since. - var downtime = TimeSpan.FromHours(2); - World.SaveStartTime = Core.Now - downtime; - - foreach (var worker in workers) - { - worker.Wake(); - } - - source.SetOwner(persistence); - Assert.True(persistence.TrySnapshotEntries(out var slotCount)); - source.PushSlotRanges(persistence, slotCount); - - source.Flush(); - foreach (var worker in workers) - { - worker.Sleep(); - } - - persistence.WriteSnapshot(dir); - persistence.PostWorldSave(); - - loaded = new AnchoredPersistence(2101); - loaded.DeserializeIndexes(dir, null); - loaded.Deserialize(dir, null); - - var entity = loaded.EntitiesBySerial[serial]; - var expected = lastRested + downtime; - - Assert.True( - (entity.LastRested - expected).Duration() <= TimeSpan.FromSeconds(30), - $"Anchored timestamp must re-base by the downtime; expected ~{expected}, got {entity.LastRested}." - ); - } - finally - { - World.SaveStartTime = previousSaveStart; - persistence.Unregister(); - loaded?.Unregister(); - - foreach (var worker in workers) - { - worker.Exit(); - } - - World._threadWorkers = previousWorkers; - AssemblyHandler.Assemblies = previousAssemblies; - Directory.Delete(dir, true); - } - } -} diff --git a/Projects/Server/Diagnostics/EventLoopProfiler.cs b/Projects/Server/Diagnostics/EventLoopProfiler.cs deleted file mode 100644 index 615a84afd..000000000 --- a/Projects/Server/Diagnostics/EventLoopProfiler.cs +++ /dev/null @@ -1,234 +0,0 @@ -/************************************************************************* - * ModernUO * - * Copyright 2019-2026 - ModernUO Development Team * - * Email: hi@modernuo.com * - * File: EventLoopProfiler.cs * - * * - * This program is free software: you can redistribute it and/or modify * - * it under the terms of the GNU General Public License as published by * - * the Free Software Foundation, either version 3 of the License, or * - * (at your option) any later version. * - * * - * You should have received a copy of the GNU General Public License * - * along with this program. If not, see . * - *************************************************************************/ - -using System; -using System.Diagnostics; -using System.Runtime.CompilerServices; - -namespace Server; - -public enum LoopPhase -{ - MobileDeltas, - ItemDeltas, - TimerSlice, - NetworkSlice, - LoopTasks, - WorldSnapshot, -} - -/// -/// Event-loop time accounting, compiled out of normal builds. Build with -/// -p:EventLoopProfiling=true to enable; every hook is -/// [Conditional("EVENT_LOOP_PROFILING")], so without the flag the call sites do not exist -/// in the IL and this class is dormant. See dev-docs/debugging-event-loop.md for how to read it. -/// -/// -/// Each one-second sample decomposes wall time into work (per ), sleep, -/// GC pause, and a stolen residual (wall - work - sleep): time the host ran something else. -/// Samples land in a ring buffer (~15 minutes) so a lag episode can be compared against the good -/// minutes on the same box, build, and world — the baseline RunUO's profiler never had. -/// -public static class EventLoopProfiler -{ - public const int PhaseCount = 6; - private const int RingSize = 900; - private const long SampleIntervalMs = 1000; - - public struct Sample - { - public long WallStart; // Core.TickCount at sample start - public long WallMs; // sample length - public long Iterations; - public long Sleeps; - public double SleepMs; // total time blocked in WaitForCompletion - public double SleepOvershootMaxMs; // worst (elapsed - requested) this sample - public long LateWakes; // overshoot >= Timer.TickRate - public long WheelLagMaxMs; // worst wheel lateness observed at Slice entry - public long WakesIssued; - public long WakesElided; - public double GcPauseMs; // GC.GetTotalPauseDuration delta - public int Gen0; - public int Gen1; - public int Gen2; - public PhaseTimes Phases; - - // Work the phases did not account for and the loop did not spend sleeping: host - // scheduling steals, and anything between the bracketed phases. GC pauses inside a - // phase or sleep inflate those measurements instead, so GcPauseMs overlaps rather - // than subtracts. - public double StolenMs - { - get - { - var known = SleepMs + Phases.Total; - return WallMs > known ? WallMs - known : 0; - } - } - } - - [InlineArray(PhaseCount)] - public struct PhaseTimes - { - private double _element0; - - public double Total - { - get - { - double total = 0; - for (var i = 0; i < PhaseCount; i++) - { - total += this[i]; - } - - return total; - } - } - } - - private static readonly double _msPerTick = 1000.0 / Stopwatch.Frequency; - - private static Sample[] _ring; - private static int _ringCount; - private static int _ringHead; - - private static Sample _current; - private static long _phaseStartTimestamp; - private static long _sampleStartedAt; - private static TimeSpan _lastGcPause; - private static int _lastGen0; - private static int _lastGen1; - private static int _lastGen2; - - /// Number of samples recorded so far (capped at the ring size). - public static int SampleCount => _ringCount; - - /// The sample currently being accumulated (not yet in the ring). - public static Sample Current => _current; - - /// - /// Copies the newest completed samples, oldest first. - /// - public static Sample[] History(int count = RingSize) - { - count = Math.Min(count, _ringCount); - var result = new Sample[count]; - for (var i = 0; i < count; i++) - { - result[i] = _ring[(_ringHead - count + i + RingSize) % RingSize]; - } - - return result; - } - - [Conditional("EVENT_LOOP_PROFILING")] - public static void IterationStart(long tickCount) - { - if (_ring == null) - { - _ring = new Sample[RingSize]; - _sampleStartedAt = tickCount; - _current.WallStart = tickCount; - _lastGcPause = GC.GetTotalPauseDuration(); - _lastGen0 = GC.CollectionCount(0); - _lastGen1 = GC.CollectionCount(1); - _lastGen2 = GC.CollectionCount(2); - } - - _current.Iterations++; - - if (tickCount - _sampleStartedAt < SampleIntervalMs) - { - return; - } - - _current.WallMs = tickCount - _sampleStartedAt; - - var pause = GC.GetTotalPauseDuration(); - _current.GcPauseMs = (pause - _lastGcPause).TotalMilliseconds; - _lastGcPause = pause; - - var gen0 = GC.CollectionCount(0); - var gen1 = GC.CollectionCount(1); - var gen2 = GC.CollectionCount(2); - _current.Gen0 = gen0 - _lastGen0; - _current.Gen1 = gen1 - _lastGen1; - _current.Gen2 = gen2 - _lastGen2; - _lastGen0 = gen0; - _lastGen1 = gen1; - _lastGen2 = gen2; - - _ring[_ringHead] = _current; - _ringHead = (_ringHead + 1) % RingSize; - if (_ringCount < RingSize) - { - _ringCount++; - } - - _sampleStartedAt = tickCount; - _current = default; - _current.WallStart = tickCount; - } - - [Conditional("EVENT_LOOP_PROFILING")] - public static void PhaseStart(LoopPhase phase) => _phaseStartTimestamp = Stopwatch.GetTimestamp(); - - [Conditional("EVENT_LOOP_PROFILING")] - public static void PhaseEnd(LoopPhase phase) => - _current.Phases[(int)phase] += (Stopwatch.GetTimestamp() - _phaseStartTimestamp) * _msPerTick; - - [Conditional("EVENT_LOOP_PROFILING")] - public static void SleepEnd(int requestedMs, long elapsedMs) - { - _current.Sleeps++; - _current.SleepMs += elapsedMs; - - var overshoot = elapsedMs - requestedMs; - if (overshoot > _current.SleepOvershootMaxMs) - { - _current.SleepOvershootMaxMs = overshoot; - } - - if (overshoot >= Timer.TickRate) - { - _current.LateWakes++; - } - } - - [Conditional("EVENT_LOOP_PROFILING")] - public static void WheelSlice(long deltaSinceTurn) - { - var lag = deltaSinceTurn - Timer.TickRate; - if (lag > _current.WheelLagMaxMs) - { - _current.WheelLagMaxMs = lag; - } - } - - // Cross-thread; approximate counts are fine for diagnosis, so no interlocked. - [Conditional("EVENT_LOOP_PROFILING")] - public static void WakeSignal(bool elided) - { - if (elided) - { - _current.WakesElided++; - } - else - { - _current.WakesIssued++; - } - } -} diff --git a/Projects/Server/EventLoopTasks.cs b/Projects/Server/EventLoopTasks.cs index c3ff2c10c..88ae35b49 100644 --- a/Projects/Server/EventLoopTasks.cs +++ b/Projects/Server/EventLoopTasks.cs @@ -42,47 +42,10 @@ public sealed class EventLoopContext : SynchronizationContext public override SynchronizationContext CreateCopy() => new EventLoopContext(); - /// - /// True when no callbacks are waiting to run. - /// - /// - /// drains at most _maxPerFrame callbacks, so work can - /// legitimately be left over. The event loop checks this before sleeping so a backlog keeps - /// it running instead. - /// - public bool IsEmpty => _queue.IsEmpty && _priorityQueue.IsEmpty; - - public void Post(Action d, Priority priority = Priority.Normal) - { + public void Post(Action d, Priority priority = Priority.Normal) => (priority == Priority.High ? _priorityQueue : _queue).Enqueue(d); - WakeEventLoop(); - } - public override void Post(SendOrPostCallback d, object state) - { - _queue.Enqueue(() => d(state)); - WakeEventLoop(); - } - - /// - /// Nudges the game loop in case it is asleep: the loop blocks on network I/O, which a queue - /// push alone does not signal. - /// - private void WakeEventLoop() - { - // A post from the loop thread cannot need a wake -- the loop is executing this very call - // -- and the signal is a syscall on every backend. - if (Thread.CurrentThread == _mainThread) - { - EventLoopProfiler.WakeSignal(elided: true); - return; - } - - EventLoopProfiler.WakeSignal(elided: false); - - // Safe before networking is configured and after teardown; NetState.Wake does nothing. - Network.NetState.Wake(); - } + public override void Post(SendOrPostCallback d, object state) => _queue.Enqueue(() => d(state)); public override void Send(SendOrPostCallback d, object state) { @@ -100,8 +63,6 @@ public sealed class EventLoopContext : SynchronizationContext evt.Set(); }); - WakeEventLoop(); - evt.WaitOne(); } diff --git a/Projects/Server/Events/AccountLoginEvent.cs b/Projects/Server/Events/AccountLoginEvent.cs index cf1c0bd1a..5c81af000 100644 --- a/Projects/Server/Events/AccountLoginEvent.cs +++ b/Projects/Server/Events/AccountLoginEvent.cs @@ -37,13 +37,6 @@ public class AccountLoginEventArgs public bool Accepted { get; set; } public ALRReason RejectReason { get; set; } - - /// - /// No verdict yet: a subscriber moved the password check off the game loop and replies itself - /// once it lands. The packet handler must send neither accept nor reject while this is set, or - /// the client gets two answers to one login. - /// - public bool Deferred { get; set; } } public static partial class EventSink diff --git a/Projects/Server/Items/Container.cs b/Projects/Server/Items/Container.cs index 06c676b25..f139da24b 100644 --- a/Projects/Server/Items/Container.cs +++ b/Projects/Server/Items/Container.cs @@ -44,10 +44,10 @@ public partial class Container : Item internal int _version; [SerializableField(3)] - [SaveFlag(nameof(ShouldSerializeLiftOverride))] [SerializedCommandProperty(AccessLevel.GameMaster)] private bool _liftOverride; + [SerializableFieldSaveFlag(3)] private bool ShouldSerializeLiftOverride() => _liftOverride; public Container(int itemID) : base(itemID) @@ -84,7 +84,6 @@ public partial class Container : Item [EncodedInt] [SerializableProperty(0)] - [SaveFlag(nameof(ShouldSerializeMaxItems), nameof(MaxItemsDefaultValue))] [CommandProperty(AccessLevel.GameMaster)] public int MaxItems { @@ -97,13 +96,14 @@ public partial class Container : Item } } + [SerializableFieldSaveFlag(0)] private bool ShouldSerializeMaxItems() => _maxItems != -1; + [SerializableFieldDefault(0)] private int MaxItemsDefaultValue() => -1; [EncodedInt] [SerializableProperty(1)] - [SaveFlag(nameof(ShouldSerializeGumpId), nameof(GumpIDDefaultValue))] [CommandProperty(AccessLevel.GameMaster)] public int GumpID { @@ -115,13 +115,14 @@ public partial class Container : Item } } + [SerializableFieldSaveFlag(1)] private bool ShouldSerializeGumpId() => _gumpID != -1; + [SerializableFieldDefault(1)] private int GumpIDDefaultValue() => -1; [EncodedInt] [SerializableProperty(2)] - [SaveFlag(nameof(ShouldSerializeDropSound), nameof(DropSoundDefaultValue))] [CommandProperty(AccessLevel.GameMaster)] public int DropSound { @@ -133,8 +134,10 @@ public partial class Container : Item } } + [SerializableFieldSaveFlag(2)] private bool ShouldSerializeDropSound() => _dropSound != -1; + [SerializableFieldDefault(2)] private int DropSoundDefaultValue() => -1; [CommandProperty(AccessLevel.GameMaster)] diff --git a/Projects/Server/Items/DecayScheduler.cs b/Projects/Server/Items/DecayScheduler.cs index 70dc1ef27..694d9b6b2 100644 --- a/Projects/Server/Items/DecayScheduler.cs +++ b/Projects/Server/Items/DecayScheduler.cs @@ -311,7 +311,7 @@ public class DecayScheduler : Timer if (timeUntilDecay > _bucketInterval) { - // Deadline was pushed out (SetLastMoved/RestartDecay) - re-bucket or move to overflow + // Item was moved (SetLastMoved called) - re-bucket or move to overflow if (timeUntilDecay > _totalBucketSpan) { // Extended beyond total span - move to overflow @@ -429,7 +429,7 @@ public class DecayScheduler : Timer { // Refused by the region. Restart the clock rather than dropping the item, which has // already left the queue; re-registering as-is would spin on a due time in the past. - item.RestartDecay(); + item.SetLastMoved(); } } } diff --git a/Projects/Server/Items/Item.cs b/Projects/Server/Items/Item.cs index 13f249f2f..186437400 100644 --- a/Projects/Server/Items/Item.cs +++ b/Projects/Server/Items/Item.cs @@ -335,25 +335,7 @@ public partial class Item : IHued, IComparable, ISpawnable, IObjectPropert [CommandProperty(AccessLevel.GameMaster)] public virtual bool Decays => Movable && Visible && Spawner == null; - private DateTime _lastMoved; - - public DateTime LastMoved - { - get => _lastMoved; - set - { - _lastMoved = value; - - // A move at or past the reset stamp supersedes it; drop it so the CompactInfo can collapse. - var info = LookupCompactInfo(); - - if (info != null && info.m_DecayReset != default && info.m_DecayReset <= value) - { - info.m_DecayReset = default; - VerifyCompactInfo(); - } - } - } + public DateTime LastMoved { get; set; } [CommandProperty(AccessLevel.GameMaster)] public bool Stackable @@ -391,7 +373,7 @@ public partial class Item : IHued, IComparable, ISpawnable, IObjectPropert } Delta(ItemDelta.Update); - RestartDecay(); + UpdateDecayRegistration(); } } } @@ -407,7 +389,7 @@ public partial class Item : IHued, IComparable, ISpawnable, IObjectPropert SetFlag(ImplFlag.Movable, value); Delta(ItemDelta.Update); - RestartDecay(); + UpdateDecayRegistration(); } } } @@ -767,12 +749,6 @@ public partial class Item : IHued, IComparable, ISpawnable, IObjectPropert public static bool ScissorCopyLootType { get; set; } - /// - /// True when the item was produced by the crafting system rather than bought or looted. - /// - [CommandProperty(AccessLevel.GameMaster)] - public bool PlayerConstructed { get; set; } - [CommandProperty(AccessLevel.GameMaster)] public bool QuestItem { @@ -863,7 +839,7 @@ public partial class Item : IHued, IComparable, ISpawnable, IObjectPropert public virtual void Serialize(IGenericWriter writer) { - writer.Write(11); // version + writer.Write(9); // version var flags = SaveFlag.None; @@ -973,11 +949,6 @@ public partial class Item : IHued, IComparable, ISpawnable, IObjectPropert { flags |= SaveFlag.SavedFlags; } - - if (info.m_DecayReset > LastMoved) - { - flags |= SaveFlag.DecayReset; - } } if (info == null || info.m_Weight < 0) @@ -1008,21 +979,16 @@ public partial class Item : IHued, IComparable, ISpawnable, IObjectPropert flags |= SaveFlag.ImplFlags; } - if (PlayerConstructed) - { - flags |= SaveFlag.PlayerConstructed; - } - writer.Write((int)flags); - // Anchored: shifted by downtime at load, so time-since-moved is preserved and the - // bytes are stable across saves while the item does not move. - writer.WriteAnchoredTime(LastMoved); + /* begin last moved time optimization */ + var ticks = LastMoved.Ticks; + var now = Core.Now.Ticks; - if (GetSaveFlag(flags, SaveFlag.DecayReset)) - { - writer.WriteAnchoredTime(info.m_DecayReset); - } + var minutes = new TimeSpan(now - ticks).TotalMinutes; + + writer.WriteEncodedInt((int)Math.Clamp(minutes, int.MinValue, int.MaxValue)); + /* end */ if (GetSaveFlag(flags, SaveFlag.Direction)) { @@ -1341,12 +1307,6 @@ public partial class Item : IHued, IComparable, ISpawnable, IObjectPropert OnMapChange(); - if (m_Parent == null) - { - // A map change is a move; nothing else updates decay registration for a raw Map change. - SetLastMoved(); - } - if (old == null || old == Map.Internal) { InvalidateProperties(); @@ -1577,7 +1537,7 @@ public partial class Item : IHued, IComparable, ISpawnable, IObjectPropert if (oldValue != value) { - RestartDecay(); + UpdateDecayRegistration(); } } } @@ -1771,7 +1731,6 @@ public partial class Item : IHued, IComparable, ISpawnable, IObjectPropert || info.m_HeldBy != null || info.m_BlessedFor != null || info.m_Spawner != null - || info.m_DecayReset != default || info.m_TempFlags != 0 || info.m_SavedFlags != 0 || info.m_Weight >= 0; @@ -2356,64 +2315,7 @@ public partial class Item : IHued, IComparable, ISpawnable, IObjectPropert public virtual bool OnDecay() => CanDecay() && Region.Find(Location, Map).OnDecay(this); - public DateTime ScheduledDecayTime - { - get - { - var reset = DecayResetTime; - var lastMoved = LastMoved; - - return (reset > lastMoved ? reset : lastMoved) + DecayTime; - } - } - - /// - /// When decay eligibility was last restored without the item moving, e.g. a GM unfreezing it. - /// The decay countdown runs from the later of this and . - /// - public DateTime DecayResetTime - { - get => LookupCompactInfo()?.m_DecayReset ?? default; - private set - { - if (value == default) - { - var info = LookupCompactInfo(); - - if (info != null && info.m_DecayReset != default) - { - info.m_DecayReset = default; - VerifyCompactInfo(); - } - } - else - { - AcquireCompactInfo().m_DecayReset = value; - } - } - } - - /// - /// Restarts the decay countdown without touching : call when decay - /// eligibility changes state (Movable/Visible/Spawner) or a region refuses a decay, where a - /// stale would otherwise decay the item on the next tick. - /// Stamps only when that extends the current deadline, then - /// updates the scheduler registration. - /// - public void RestartDecay() - { - if (CanDecay()) - { - var now = Core.Now; - - if (ScheduledDecayTime < now + DecayTime) - { - DecayResetTime = now; - } - } - - UpdateDecayRegistration(); - } + public DateTime ScheduledDecayTime => LastMoved + DecayTime; public void UpdateDecayRegistration() { @@ -2423,12 +2325,6 @@ public partial class Item : IHued, IComparable, ISpawnable, IObjectPropert { DecayScheduler.Register(this); } - else - { - // No countdown to anchor while ineligible; drop the stamp so the CompactInfo - // can collapse. Re-eligibility always re-anchors. - DecayResetTime = default; - } } public void SetLastMoved() @@ -2461,11 +2357,6 @@ public partial class Item : IHued, IComparable, ISpawnable, IObjectPropert } Amount += dropped.Amount; - if (PlayerConstructed != dropped.PlayerConstructed) - { - PlayerConstructed = false; - } - dropped.Delete(); if (playSound && from != null) @@ -2766,8 +2657,6 @@ public partial class Item : IHued, IComparable, ISpawnable, IObjectPropert switch (version) { - case 11: - case 10: case 9: case 8: case 7: @@ -2775,11 +2664,7 @@ public partial class Item : IHued, IComparable, ISpawnable, IObjectPropert { var flags = (SaveFlag)reader.ReadInt(); - if (version >= 11) - { - LastMoved = reader.ReadAnchoredTime(); - } - else if (version < 7) + if (version < 7) { LastMoved = reader.ReadDeltaTime(); } @@ -2797,18 +2682,6 @@ public partial class Item : IHued, IComparable, ISpawnable, IObjectPropert } } - if (version >= 10 && GetSaveFlag(flags, SaveFlag.DecayReset)) - { - var reset = version >= 11 ? reader.ReadAnchoredTime() : reader.ReadDeltaTime(); - - // Pre-v11 LastMoved was stored at whole-minute precision; keep the - // stamp only while it still extends the deadline. - if (reset > LastMoved) - { - DecayResetTime = reset; - } - } - if (GetSaveFlag(flags, SaveFlag.Direction)) { m_Direction = (Direction)reader.ReadByte(); @@ -2981,8 +2854,6 @@ public partial class Item : IHued, IComparable, ISpawnable, IObjectPropert AcquireCompactInfo().m_SavedFlags = reader.ReadEncodedInt(); } - PlayerConstructed = GetSaveFlag(flags, SaveFlag.PlayerConstructed); - if (m_Map != null && m_Parent == null) { m_Map.OnEnter(this); @@ -3454,12 +3325,6 @@ public partial class Item : IHued, IComparable, ISpawnable, IObjectPropert m_DeltaFlags &= ~flags; } - /// - /// True when deltas remain queued after a pass, which is - /// bounded by the count it saw on entry. The event loop consults this before sleeping. - /// - public static bool HasQueuedDeltas => m_DeltaQueue.Count > 0; - public static void ProcessDeltaQueue() { var limit = m_DeltaQueue.Count; @@ -3566,7 +3431,7 @@ public partial class Item : IHued, IComparable, ISpawnable, IObjectPropert for (var i = 0; i < props.Length; i++) { var p = props[i]; - if (p.GetCustomAttribute(true) != null || !p.CanRead || !p.CanWrite) + if (p.GetCustomAttribute(typeof(IgnoreDupeAttribute), true) != null || !p.CanRead || !p.CanWrite) { continue; } @@ -4472,8 +4337,6 @@ public partial class Item : IHued, IComparable, ISpawnable, IObjectPropert public ISpawner m_Spawner; - public DateTime m_DecayReset; - public int m_TempFlags; public double m_Weight = -1; @@ -4511,8 +4374,6 @@ public partial class Item : IHued, IComparable, ISpawnable, IObjectPropert HeldBy = 0x00800000, IntWeight = 0x01000000, SavedFlags = 0x02000000, - NullWeight = 0x04000000, - PlayerConstructed = 0x08000000, - DecayReset = 0x10000000 + NullWeight = 0x04000000 } } diff --git a/Projects/Server/Main.cs b/Projects/Server/Main.cs index 608256651..2be9d7d04 100644 --- a/Projects/Server/Main.cs +++ b/Projects/Server/Main.cs @@ -39,181 +39,10 @@ public static class Core { private static readonly ILogger logger = LogFactory.GetLogger(typeof(Core)); - // Written off-loop (Kill, RequestSnapshot); volatile because the loop blocks between reads. - private static volatile bool _performProcessKill; + private static bool _performProcessKill; private static bool _restartOnKill; - private static volatile bool _performSnapshot; + private static bool _performSnapshot; private static string _snapshotPath; - - // A backstop, not a latency control: the wheel's tick rate already bounds the sleep. - // Measured across 1/2/4/8ms; 2 is optimal. - private static int _eventLoopIdleWaitMs = 2; - - /// - /// Longest the loop will block while idle, in milliseconds. 0 spins instead; the backoff - /// does the same temporarily when the host keeps returning waits late. - /// - public static int EventLoopIdleWaitMs => _eventLoopIdleWaitMs; - - /// - /// True when idle sleeping was disabled at startup because the host cannot honor short - /// waits, overriding whatever server.eventLoopIdleWaitMs was configured to. - /// - public static bool IdleSleepUnsupported { get; private set; } - - /// - /// Whether idle sleeping is currently suspended because the host returned waits late. - /// - /// - /// Compared by subtraction, never directly: tick counts can start enormous and wrap. - /// See dev-docs/tick-counts.md. - /// - public static bool IdleSleepSuspended => _tickCount - _idleSleepSuspendedUntil < 0; - - private const long HealthSampleIntervalMs = 1000; - - // Doubling: a fixed suspension oscillates forever on a persistently bad host, while doubling - // converges on "stop sleeping" yet still recovers from a transient. - private const long BackoffBaseMs = 5000; - private const long BackoffMaxMs = 120_000; - private const int BackoffMaxShift = 5; - - // Clean streak that clears the escalation. - private const long BackoffResetAfterCleanMs = 60_000; - - // Below this a backoff is still recoverable and not actionable, so it only logs at Debug. - private const int WarnAfterConsecutiveBackoffs = 3; - - // A sleep is bounded by the next wheel turn, so only a wait returning late can cost a deadline. - // Measured per sleep, which is why server work (saves, heavy commands) cannot trip the backoff. - 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; - private static long _currentBackoffMs = BackoffBaseMs; - private static long _lastBackoffAt; - private static bool _loggedBackoffCeiling; - - /// - /// Once a second, suspends idle sleeping (with escalating duration) if the host keeps - /// returning idle waits a full tick or more late. - /// - private static void CheckSchedulerHealth() - { - if (_tickCount - _nextHealthSample < 0) - { - return; - } - - _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) - { - _consecutiveBadSamples = 0; - return; - } - - // Lateness is a rate: an idle loop sleeps hundreds of times a second, so a few outliers are - // normal, while a host that cannot schedule the process returns most of its waits late. The - // threshold above is the floor for windows with too few sleeps for a proportion to mean anything. - if (late * 100 < sleeps * _lateWakePercent) - { - _consecutiveBadSamples = 0; - return; - } - - // Require persistence: any host can drop one sample to unrelated load, but an oversubscribed - // one stays bad. - if (++_consecutiveBadSamples < 2) - { - return; - } - - if (_eventLoopIdleWaitMs <= 0) - { - return; - } - - _currentBackoffMs = Math.Min(BackoffBaseMs << Math.Min(_consecutiveBackoffs, BackoffMaxShift), BackoffMaxMs); - _consecutiveBackoffs++; - _lastBackoffAt = _tickCount; - _idleSleepSuspendedUntil = _tickCount + _currentBackoffMs; - _idleSleepBackoffs++; - - if (_currentBackoffMs >= BackoffMaxMs) - { - // Escalation has run out of room; say so once. - if (!_loggedBackoffCeiling) - { - _loggedBackoffCeiling = true; - logger.Error( - "This host keeps returning idle waits late and sleeping has backed off {Count} times. " + - "The process is not being scheduled promptly, which is typical of shared or burstable vCPUs. " + - "Set server.eventLoopIdleWaitMs to 0 to disable sleeping permanently and trade a full core for latency.", - _idleSleepBackoffs - ); - } - - return; - } - - // Each backoff doubles the suspension, so every line is a distinct escalation step and - // needs no further rate limiting. - if (_consecutiveBackoffs < WarnAfterConsecutiveBackoffs) - { - logger.Debug( - "This host returned a {Requested}ms idle wait at least {TickRate}ms late {Count} of {Sleeps} time(s) " + - "in the last second; idle sleeping suspended for {Duration}ms", - _eventLoopIdleWaitMs, - Timer.TickRate, - late, - sleeps, - _currentBackoffMs - ); - - return; - } - - logger.Warning( - "This host returned a {Requested}ms idle wait at least {TickRate}ms late {Count} of {Sleeps} time(s) in " + - "the last second, for the {Backoffs}th time running; idle sleeping suspended for {Duration}ms", - _eventLoopIdleWaitMs, - Timer.TickRate, - late, - sleeps, - _consecutiveBackoffs, - _currentBackoffMs - ); - } private static bool _crashed; private static string _baseDirectory; @@ -282,6 +111,14 @@ public static class Core public static long Uptime => TickCount - _firstTick; + private static double _currentCPS; + private static double _averageCPS; + private static bool _cpsInitialized; + + public static double CyclesPerSecond => _currentCPS; + + public static double AverageCPS => _averageCPS; + public static string BaseDirectory { get @@ -398,10 +235,6 @@ public static class Core { _restartOnKill = restart; _performProcessKill = true; - - // Callers are usually off-loop (console input, signal handlers); wake so the request - // is noticed now rather than whenever the loop next surfaces. - NetState.Wake(); } public static void CurrentDomain_UnhandledException(object sender, UnhandledExceptionEventArgs e) @@ -591,45 +424,6 @@ public static class Core ServerConfiguration.Load(); - // 0 disables idle sleeping entirely (full-core spin, zero scheduling overhead). - var idleWaitMs = ServerConfiguration.GetSetting("server.eventLoopIdleWaitMs", 2); - if (idleWaitMs < 0) - { - logger.Warning( - "server.eventLoopIdleWaitMs {Value} is negative; using 0 (idle sleeping disabled)", - idleWaitMs - ); - } - - _eventLoopIdleWaitMs = 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); // Load UOContent.dll @@ -646,8 +440,10 @@ public static class Core AssemblyHandler.LoadAssemblies(assemblyFiles); - // First-boot interactive setup. After assemblies load so content can register prompts, - // before any Serilog output so prompts are not interleaved with the async console sink. + // First-boot interactive setup. Runs after assemblies are loaded (so content can + // register prompts) but before any Serilog output, so console prompts are not + // interleaved with the async console sink. Handlers self-gate on first-boot state + // (e.g. "is my setting already present?"). AssemblyHandler.Invoke("ConfigurePrompts"); logger.Information("Running on {Framework}", RuntimeInformation.FrameworkDescription); @@ -657,11 +453,6 @@ public static class Core _now = DateTime.UtcNow; _firstTick = _tickCount = GetTimestamp(); - // Seed from a real tick: tick counts need not start near zero, so a zero-initialized - // deadline compares wrong. See dev-docs/tick-counts.md. - _nextHealthSample = _tickCount + HealthSampleIntervalMs; - _idleSleepSuspendedUntil = _tickCount; - Timer.Init(_tickCount); AssemblyHandler.Invoke("Configure"); @@ -678,71 +469,41 @@ public static class Core NetState.Start(); PingServer.Start(); EventSink.InvokeServerStarted(); - - // Without a high-resolution wait a 2ms request quantises to 15.625ms and the loop runs a - // tick behind. Only fires when the high-res timer and the timeBeginPeriod fallback both failed. - if (_eventLoopIdleWaitMs > 0 && NetState.Ring?.SupportsHighResolutionWait == false) - { - logger.Error( - "This host cannot honor short waits (no high-resolution timer, and raising the system timer " + - "resolution failed). Idle sleeping is disabled. The loop will spin instead, using a full core." - ); - - IdleSleepUnsupported = true; - _eventLoopIdleWaitMs = 0; - } - RunEventLoop(); } - /// - /// True when every queue the loop drains is empty, so sleeping cannot strand pending work. - /// The drains are bounded, so leftovers are normal and must keep the loop awake. - /// - private static bool IsIdle() => - !Mobile.HasQueuedDeltas && !Item.HasQueuedDeltas && LoopContext.IsEmpty && NetState.IsIdle; - public static void RunEventLoop() { try { + var lastRaw = Stopwatch.GetTimestamp(); + const int interval = 100; + double frequency = Stopwatch.Frequency * interval; + const double alpha = 2.0 / 129; // EMA smoothing (≈128-sample window) + + var sample = 0; + while (!Closing) { _tickCount = GetTimestamp(); _now = DateTime.UtcNow; - EventLoopProfiler.IterationStart(_tickCount); - - EventLoopProfiler.PhaseStart(LoopPhase.MobileDeltas); Mobile.ProcessDeltaQueue(); - EventLoopProfiler.PhaseEnd(LoopPhase.MobileDeltas); - - EventLoopProfiler.PhaseStart(LoopPhase.ItemDeltas); Item.ProcessDeltaQueue(); - EventLoopProfiler.PhaseEnd(LoopPhase.ItemDeltas); - - EventLoopProfiler.PhaseStart(LoopPhase.TimerSlice); Timer.Slice(_tickCount); - EventLoopProfiler.PhaseEnd(LoopPhase.TimerSlice); // Handle networking - EventLoopProfiler.PhaseStart(LoopPhase.NetworkSlice); NetState.Slice(); - EventLoopProfiler.PhaseEnd(LoopPhase.NetworkSlice); // Execute captured post-await methods (like Timer.Pause) - EventLoopProfiler.PhaseStart(LoopPhase.LoopTasks); LoopContext.ExecuteTasks(); - EventLoopProfiler.PhaseEnd(LoopPhase.LoopTasks); Timer.CheckTimerPool(); // Check for pool depletion so we can async refill it. 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; } @@ -752,35 +513,29 @@ public static class Core break; } - CheckSchedulerHealth(); - - if (_eventLoopIdleWaitMs > 0 && _tickCount - _idleSleepSuspendedUntil >= 0 && IsIdle()) + if (sample++ == interval) { - // 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) + sample = 0; + var nowRaw = Stopwatch.GetTimestamp(); + + _currentCPS = frequency / (nowRaw - lastRaw); + + if (!_cpsInitialized) { - var requested = (int)Math.Min(due, _eventLoopIdleWaitMs); + _averageCPS = _currentCPS; + _cpsInitialized = true; + } + else + { + _averageCPS += alpha * (_currentCPS - _averageCPS); + } - // 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); + lastRaw = nowRaw; - NetState.WaitForCompletion(requested); - - var elapsed = GetTimestamp() - start; - EventLoopProfiler.SleepEnd(requested, elapsed); - _sleepAttempts++; - - // The second collection read sits behind the overshoot test, so the common - // path reads the counter once, not twice. - if (elapsed - requested >= Timer.TickRate && GC.CollectionCount(1) == collections) - { - _lateWakes++; - } + var sleepMs = (int)Timer.MillisecondsUntilNextTick(_tickCount); + if (sleepMs >= 2) + { + NetState.WaitForCompletion(sleepMs - 1); } } } @@ -798,9 +553,6 @@ public static class Core { _snapshotPath = snapshotPath; _performSnapshot = true; - - // Save requests arrive off-loop; wake so the snapshot starts now. - NetState.Wake(); } public static void VerifySerialization() diff --git a/Projects/Server/Mobiles/Mobile.cs b/Projects/Server/Mobiles/Mobile.cs index d1db8113b..f22c9a939 100644 --- a/Projects/Server/Mobiles/Mobile.cs +++ b/Projects/Server/Mobiles/Mobile.cs @@ -42,7 +42,7 @@ public delegate void PromptCallback(Mobile from, string text); public delegate void PromptStateCallback(Mobile from, string text, T state); -public class DamageEntry : IValueLinkListNode +public class DamageEntry { public DamageEntry(Mobile damager) => Damager = damager; @@ -57,11 +57,6 @@ public class DamageEntry : IValueLinkListNode public List Responsible { get; set; } public static TimeSpan ExpireDelay { get; set; } = TimeSpan.FromMinutes(2.0); - - // Intrusive links for Mobile._damageEntries. Sub-entries in Responsible never join a list. - public DamageEntry Next { get; set; } - public DamageEntry Previous { get; set; } - public bool OnLinkList { get; set; } } [Flags] @@ -382,6 +377,7 @@ public partial class Mobile : IHued, IComparable, ISpawnable, IObjectPro Aggressors = new List(); Aggressed = new List(); NextSkillTime = Core.TickCount; + DamageEntries = new List(); } // Sectors @@ -962,23 +958,7 @@ public partial class Mobile : IHued, IComparable, ISpawnable, IObjectPro public static VisibleDamageType VisibleDamageType { get; set; } - private ValueLinkList _damageEntries; - - /// - /// Damage entries ordered least recent (head) to most recent (tail). Expired entries are - /// pruned on access. Enumerate with foreach (ascending) or .ByDescending(). - /// Mutate only through and . - /// Calling a ValueLinkList mutator on this reference compiles, but operates on a defensive copy - /// while still unlinking the real nodes — it silently corrupts the list. - /// - public ref readonly ValueLinkList DamageEntries - { - get - { - PruneExpiredDamageEntries(); - return ref _damageEntries; - } - } + public List DamageEntries { get; private set; } [CommandProperty(AccessLevel.GameMaster)] public Mobile LastKiller { get; set; } @@ -1647,7 +1627,7 @@ public partial class Mobile : IHued, IComparable, ISpawnable, IObjectPro public virtual bool KeepsItemsOnDeath => m_AccessLevel > AccessLevel.Player; - public bool HasTrade => m_NetState?.Trades?.Count > 0; + public bool HasTrade => m_NetState?.Trades.Count > 0; public bool NoMoveHS { get; set; } @@ -2040,7 +2020,10 @@ public partial class Mobile : IHued, IComparable, ISpawnable, IObjectPro Aggressors[i].CanReportMurder = false; } - ClearDamageEntries(); // reset damage entries on full HP + if (DamageEntries.Count > 0) + { + DamageEntries.Clear(); // reset damage entries on full HP + } } else if (CanRegenHits) { @@ -2341,11 +2324,11 @@ public partial class Mobile : IHued, IComparable, ISpawnable, IObjectPro public virtual void Serialize(IGenericWriter writer) { - writer.Write(38); // version + writer.Write(37); // version - writer.WriteAnchoredTime(LastStrGain); - writer.WriteAnchoredTime(LastIntGain); - writer.WriteAnchoredTime(LastDexGain); + writer.WriteDeltaTime(LastStrGain); + writer.WriteDeltaTime(LastIntGain); + writer.WriteDeltaTime(LastDexGain); byte hairflag = 0x00; @@ -5265,15 +5248,8 @@ public partial class Mobile : IHued, IComparable, ISpawnable, IObjectPro item.Name = oldItem.Name; item.Weight = oldItem.Weight; - item.PlayerConstructed = oldItem.PlayerConstructed; item.Amount = oldAmount - amount; - - // A parented remainder gets its map from AddItem (parent first, then map), keeping the - // split off the decay scheduler; a ground remainder is placed and enrolled here. - if (oldItem.Parent == null) - { - item.Map = oldItem.Map; - } + item.Map = oldItem.Map; oldItem.OnAfterDuped(item); @@ -5762,54 +5738,24 @@ public partial class Mobile : IHued, IComparable, ISpawnable, IObjectPro } } - // Entries are kept in LastDamage order, so expired entries are always a head prefix. - private void PruneExpiredDamageEntries() - { -#if DEBUG - for (var node = _damageEntries._first; node != null; node = node.Next) - { - Debug.Assert( - node.Next == null || node.Next.LastDamage >= node.LastDamage, - "Damage entries must be ordered by LastDamage ascending." - ); - } -#endif - - var first = _damageEntries._first; - - if (first?.HasExpired != true) - { - return; - } - - var firstLive = first.Next; - - while (firstLive?.HasExpired == true) - { - firstLive = firstLive.Next; - } - - if (firstLive == null) - { - _damageEntries.RemoveAll(); - } - else - { - _damageEntries.RemoveAllBefore(firstLive); - } - } - - public void ClearDamageEntries() => _damageEntries.RemoveAll(); - public Mobile FindMostRecentDamager(bool allowSelf) => FindMostRecentDamageEntry(allowSelf)?.Damager; public DamageEntry FindMostRecentDamageEntry(bool allowSelf) { - PruneExpiredDamageEntries(); - - for (var de = _damageEntries._last; de != null; de = de.Previous) + for (var i = DamageEntries.Count - 1; i >= 0; --i) { - if (allowSelf || de.Damager != this) + if (i >= DamageEntries.Count) + { + continue; + } + + var de = DamageEntries[i]; + + if (de.HasExpired) + { + DamageEntries.RemoveAt(i); + } + else if (allowSelf || de.Damager != this) { return de; } @@ -5822,11 +5768,21 @@ public partial class Mobile : IHued, IComparable, ISpawnable, IObjectPro public DamageEntry FindLeastRecentDamageEntry(bool allowSelf) { - PruneExpiredDamageEntries(); - - for (var de = _damageEntries._first; de != null; de = de.Next) + for (var i = 0; i < DamageEntries.Count; ++i) { - if (allowSelf || de.Damager != this) + if (i < 0) + { + continue; + } + + var de = DamageEntries[i]; + + if (de.HasExpired) + { + DamageEntries.RemoveAt(i); + --i; + } + else if (allowSelf || de.Damager != this) { return de; } @@ -5837,17 +5793,24 @@ public partial class Mobile : IHued, IComparable, ISpawnable, IObjectPro public Mobile FindMostTotalDamager(bool allowSelf) => FindMostTotalDamageEntry(allowSelf)?.Damager; - // Walks most recent first with a strict comparison so the most recent entry wins ties, - // matching the previous reverse-indexed loop. public DamageEntry FindMostTotalDamageEntry(bool allowSelf) { - PruneExpiredDamageEntries(); - DamageEntry mostTotal = null; - for (var de = _damageEntries._last; de != null; de = de.Previous) + for (var i = DamageEntries.Count - 1; i >= 0; --i) { - if ((allowSelf || de.Damager != this) && (mostTotal == null || de.DamageGiven > mostTotal.DamageGiven)) + if (i >= DamageEntries.Count) + { + continue; + } + + var de = DamageEntries[i]; + + if (de.HasExpired) + { + DamageEntries.RemoveAt(i); + } + else if ((allowSelf || de.Damager != this) && (mostTotal == null || de.DamageGiven > mostTotal.DamageGiven)) { mostTotal = de; } @@ -5860,28 +5823,46 @@ public partial class Mobile : IHued, IComparable, ISpawnable, IObjectPro public DamageEntry FindLeastTotalDamageEntry(bool allowSelf) { - PruneExpiredDamageEntries(); + DamageEntry mostTotal = null; - DamageEntry leastTotal = null; - - for (var de = _damageEntries._last; de != null; de = de.Previous) + for (var i = DamageEntries.Count - 1; i >= 0; --i) { - if ((allowSelf || de.Damager != this) && (leastTotal == null || de.DamageGiven < leastTotal.DamageGiven)) + if (i >= DamageEntries.Count) { - leastTotal = de; + continue; + } + + var de = DamageEntries[i]; + + if (de.HasExpired) + { + DamageEntries.RemoveAt(i); + } + else if ((allowSelf || de.Damager != this) && (mostTotal == null || de.DamageGiven < mostTotal.DamageGiven)) + { + mostTotal = de; } } - return leastTotal; + return mostTotal; } public DamageEntry FindDamageEntryFor(Mobile m) { - PruneExpiredDamageEntries(); - - for (var de = _damageEntries._last; de != null; de = de.Previous) + for (var i = DamageEntries.Count - 1; i >= 0; --i) { - if (de.Damager == m) + if (i >= DamageEntries.Count) + { + continue; + } + + var de = DamageEntries[i]; + + if (de.HasExpired) + { + DamageEntries.RemoveAt(i); + } + else if (de.Damager == m) { return de; } @@ -5899,13 +5880,8 @@ public partial class Mobile : IHued, IComparable, ISpawnable, IObjectPro de.DamageGiven += amount; de.LastDamage = Core.Now; - // Move to the tail so the list stays in LastDamage order. - if (de.OnLinkList) - { - _damageEntries.Remove(de); - } - - _damageEntries.AddLast(de); + DamageEntries.Remove(de); + DamageEntries.Add(de); var master = from.GetDamageMaster(this); @@ -6167,7 +6143,6 @@ public partial class Mobile : IHued, IComparable, ISpawnable, IObjectPro switch (version) { - case 38: // Stat-gain stamps moved from delta time to anchored time case 37: // Decomposed hair into inline item id/hue (dropped the VirtualHairInfo object) case 36: // Moved virtues to VirtueSystem case 35: // Moved short term murders to PlayerMurderSystem @@ -6176,18 +6151,9 @@ public partial class Mobile : IHued, IComparable, ISpawnable, IObjectPro case 32: // Removed StuckMenu case 31: { - if (version >= 38) - { - LastStrGain = reader.ReadAnchoredTime(); - LastIntGain = reader.ReadAnchoredTime(); - LastDexGain = reader.ReadAnchoredTime(); - } - else - { - LastStrGain = reader.ReadDeltaTime(); - LastIntGain = reader.ReadDeltaTime(); - LastDexGain = reader.ReadDeltaTime(); - } + LastStrGain = reader.ReadDeltaTime(); + LastIntGain = reader.ReadDeltaTime(); + LastDexGain = reader.ReadDeltaTime(); goto case 30; } @@ -6495,6 +6461,9 @@ public partial class Mobile : IHued, IComparable, ISpawnable, IObjectPro m_DexLock = (StatLockType)reader.ReadByte(); m_IntLock = (StatLockType)reader.ReadByte(); + _statMods = new List(); + _skillMods = new List(); + if (version < 32) { if (reader.ReadBool()) @@ -7827,10 +7796,13 @@ public partial class Mobile : IHued, IComparable, ISpawnable, IObjectPro m_FollowersMax = 5; Skills = new Skills(this); Items = new List(); + _statMods = new List(); + _skillMods = new List(); Map = Map.Internal; AutoPageNotify = true; Aggressors = new List(); Aggressed = new List(); + DamageEntries = new List(); NextSkillTime = Core.TickCount; } @@ -7862,12 +7834,6 @@ public partial class Mobile : IHued, IComparable, ISpawnable, IObjectPro } } - /// - /// True when deltas remain queued after a pass, which is - /// bounded by the count it saw on entry. The event loop consults this before sleeping. - /// - public static bool HasQueuedDeltas => m_DeltaQueue.Count > 0; - public static void ProcessDeltaQueue() { var limit = m_DeltaQueue.Count; diff --git a/Projects/Server/Mobiles/Mods/ResistanceMod.cs b/Projects/Server/Mobiles/Mods/ResistanceMod.cs index a5423e039..bd569f20e 100644 --- a/Projects/Server/Mobiles/Mods/ResistanceMod.cs +++ b/Projects/Server/Mobiles/Mods/ResistanceMod.cs @@ -21,15 +21,17 @@ namespace Server; [SerializationGenerator(0)] public partial class ResistanceMod : MobileMod { - [SerializableField(0, fieldChanged: nameof(OnTypeChanged))] + [SerializableField(0)] private ResistanceType _type; + [SerializableFieldChanged(0)] [MethodImpl(MethodImplOptions.AggressiveInlining)] private void OnTypeChanged(ResistanceType oldValue, ResistanceType newValue) => Owner?.UpdateResistances(); - [SerializableField(1, fieldChanged: nameof(OnOffsetChanged))] + [SerializableField(1)] private int _offset; + [SerializableFieldChanged(1)] [MethodImpl(MethodImplOptions.AggressiveInlining)] private void OnOffsetChanged(int oldValue, int newValue) => Owner?.UpdateResistances(); diff --git a/Projects/Server/Mobiles/Mods/SkillMod.cs b/Projects/Server/Mobiles/Mods/SkillMod.cs index cfe96c0c4..a78ec5ce6 100644 --- a/Projects/Server/Mobiles/Mods/SkillMod.cs +++ b/Projects/Server/Mobiles/Mods/SkillMod.cs @@ -21,29 +21,33 @@ namespace Server; [SerializationGenerator(0)] public abstract partial class SkillMod : MobileMod { - [SerializableField(0, fieldChanged: nameof(OnObeyCapChanged))] + [SerializableField(0)] private bool _obeyCap; + [SerializableFieldChanged(0)] [MethodImpl(MethodImplOptions.AggressiveInlining)] - private void OnObeyCapChanged(bool oldValue, bool newValue) => Owner?.Skills[_skill]?.Update(); + private void OnObeCapChanged(bool oldValue, bool newValue) => Owner?.Skills[_skill]?.Update(); - [SerializableField(1, fieldChanged: nameof(OnSkillChanged))] + [SerializableField(1)] private SkillName _skill; + [SerializableFieldChanged(1)] private void OnSkillChanged(SkillName oldValue, SkillName newValue) { Owner?.Skills[newValue]?.Update(); Owner?.Skills[oldValue]?.Update(); } - [SerializableField(2, fieldChanged: nameof(OnRelativeChanged))] + [SerializableField(2)] private bool _relative; + [SerializableFieldChanged(2)] private void OnRelativeChanged(bool oldValue, bool newValue) => Owner?.Skills[_skill]?.Update(); - [SerializableField(3, fieldChanged: nameof(OnValueChanged))] + [SerializableField(3)] private double _value; + [SerializableFieldChanged(3)] private void OnValueChanged(double oldValue, double newValue) => Owner?.Skills[_skill]?.Update(); public SkillMod(Mobile owner) : base(owner) diff --git a/Projects/Server/Network/MovementThrottle.cs b/Projects/Server/Network/MovementThrottle.cs index 33ee318fc..c28ef2228 100644 --- a/Projects/Server/Network/MovementThrottle.cs +++ b/Projects/Server/Network/MovementThrottle.cs @@ -50,6 +50,9 @@ public static class MovementThrottle private const int ClientMaxUnackedMovements = 5; private const int MaxQueueWithUnmodifiedClient = ClientMaxUnackedMovements - 1; // 4 + // Debug logging - enable for testing speed hack detection + private static bool _debugLogging = false; + // Track NetStates with queued movements for efficient processing private static readonly HashSet _netStatesWithQueuedMovements = new(256); @@ -80,9 +83,15 @@ public static class MovementThrottle public static void Configure() { - _maxCredit = ServerConfiguration.GetOrUpdateSetting("movementThrottle.maxCredit", _maxCredit); - _maxRttBonus = ServerConfiguration.GetOrUpdateSetting("movementThrottle.maxRttBonus", _maxRttBonus); - _hardQueueLimit = ServerConfiguration.GetOrUpdateSetting("movementThrottle.hardQueueLimit", _hardQueueLimit); + _maxCredit = ServerConfiguration.GetOrUpdateSetting( + "movementThrottle.maxCredit", + _maxCredit + ); + + _hardQueueLimit = ServerConfiguration.GetOrUpdateSetting( + "movementThrottle.hardQueueLimit", + _hardQueueLimit + ); _movementHistorySize = ServerConfiguration.GetOrUpdateSetting( "movementThrottle.movementHistorySize", @@ -94,13 +103,6 @@ public static class MovementThrottle _minSamplesForRate ); - _maxChainGap = ServerConfiguration.GetOrUpdateSetting("movementThrottle.maxChainGap", _maxChainGap); - - _speedHackNotificationCooldown = ServerConfiguration.GetOrUpdateSetting( - "movementThrottle.speedHackNotificationCooldown", - _speedHackNotificationCooldown - ); - _suspiciousRateThreshold = (float)ServerConfiguration.GetOrUpdateSetting( "movementThrottle.suspiciousRateThreshold", _suspiciousRateThreshold @@ -110,6 +112,11 @@ public static class MovementThrottle "movementThrottle.definiteRateThreshold", _definiteRateThreshold ); + + _debugLogging = ServerConfiguration.GetOrUpdateSetting( + "movementThrottle.debugLogging", + _debugLogging + ); } /// @@ -184,16 +191,15 @@ public static class MovementThrottle // Credit can go negative up to -dynamicCredit (debt limit) if (ns._movementCredit - earlyAmount >= -dynamicCredit) { + var prevCredit = ns._movementCredit; // Use credit to cover early arrival ns._movementCredit -= earlyAmount; - if (ns._movementLogging) + if (_debugLogging && ns._movementLogging) { - var prevCredit = ns._movementCredit + earlyAmount; - logger.Debug( "[Credit] {Name}: delta={Delta}ms early={Early}ms credit={PrevCredit}->{Credit}/{MaxCredit} action=execute", - mobile, delta, earlyAmount, prevCredit, ns._movementCredit, dynamicCredit + mobile.RawName, delta, earlyAmount, prevCredit, ns._movementCredit, dynamicCredit ); } @@ -202,11 +208,11 @@ public static class MovementThrottle return; } - if (ns._movementLogging) + if (_debugLogging && ns._movementLogging) { logger.Debug( "[Credit] {Name}: delta={Delta}ms early={Early}ms credit={Credit}/{MaxCredit} EXHAUSTED -> queue", - mobile, delta, earlyAmount, ns._movementCredit, dynamicCredit + mobile.RawName, delta, earlyAmount, ns._movementCredit, dynamicCredit ); } @@ -221,11 +227,11 @@ public static class MovementThrottle var prevCredit = ns._movementCredit; ns._movementCredit = Math.Min(ns._movementCredit + delta, dynamicCredit); - if (ns._movementLogging && ns._movementCredit != prevCredit) + if (_debugLogging && ns._movementLogging && ns._movementCredit != prevCredit) { logger.Debug( "[Credit] {Name}: delta=+{Delta}ms credit={PrevCredit}->{Credit}/{MaxCredit} action=execute", - mobile, delta, prevCredit, ns._movementCredit, dynamicCredit + mobile.RawName, delta, prevCredit, ns._movementCredit, dynamicCredit ); } } @@ -241,9 +247,12 @@ public static class MovementThrottle { if (!mobile.Move(dir)) { - if (ns._movementLogging) + if (_debugLogging && ns._movementLogging) { - logger.Debug("[Execute] {Name}: Move FAILED dir={Dir} seq={Seq} -> reject+reset", mobile, dir, seq); + logger.Debug( + "[Execute] {Name}: Move FAILED dir={Dir} seq={Seq} -> reject+reset", + mobile.RawName, dir, seq + ); } // Movement failed (blocked, paralyzed, frozen, etc.) @@ -251,11 +260,11 @@ public static class MovementThrottle return; } - if (ns._movementLogging) + if (_debugLogging && ns._movementLogging) { logger.Debug( "[Execute] {Name}: Move OK dir={Dir} seq={Seq} nextMove={NextMove}ms", - mobile, dir, seq, ns._nextMovementTime - Core.TickCount + mobile.RawName, dir, seq, ns._nextMovementTime - Core.TickCount ); } @@ -295,11 +304,11 @@ public static class MovementThrottle ns._hasQueuedMovements = true; _netStatesWithQueuedMovements.Add(ns); - if (ns._movementLogging) + if (_debugLogging && ns._movementLogging) { logger.Debug( "[Queue] {Name}: enqueued dir={Dir} seq={Seq} (depth={Depth})", - ns.Mobile, dir, seq, ns._movementQueue.Count + ns.Mobile?.RawName, dir, seq, ns._movementQueue.Count ); } } @@ -311,6 +320,7 @@ public static class MovementThrottle { ns.SendMovementRej(seq, mobile); ns.ResetMovementState(); + _netStatesWithQueuedMovements.Remove(ns); } /// @@ -323,18 +333,20 @@ public static class MovementThrottle return; } - foreach (var ns in _netStatesWithQueuedMovements) + // Process each NetState with queued movements + // Use a snapshot to avoid modification during iteration + var toProcess = new List(_netStatesWithQueuedMovements); + + for (var i = 0; i < toProcess.Count; i++) { - if (ns.Running) + var ns = toProcess[i]; + if (!ns.Running) { - ProcessMovementQueue(ns); - if (ns._hasQueuedMovements) - { - continue; - } + _netStatesWithQueuedMovements.Remove(ns); + continue; } - _netStatesWithQueuedMovements.Remove(ns); + ProcessMovementQueue(ns); } } @@ -344,7 +356,6 @@ public static class MovementThrottle public static void ProcessMovementQueue(NetState ns) { var mobile = ns.Mobile; - if (mobile?.Deleted != false) { ClearQueue(ns); @@ -363,7 +374,7 @@ public static class MovementThrottle while (ns._movementQueue?.Count > 0) { // Check if it's time to execute - if (now - ns._nextMovementTime < 0) + if (now < ns._nextMovementTime) { // Not yet - leave remaining items in queue for next Slice break; @@ -383,11 +394,11 @@ public static class MovementThrottle // Execute the move if (!mobile.Move(movement.Direction)) { - if (ns._movementLogging) + if (_debugLogging && ns._movementLogging) { logger.Debug( "[Queue] {Name}: dequeued FAILED dir={Dir} (remaining={Remaining})", - mobile, movement.Direction, remaining + mobile.RawName, movement.Direction, remaining ); } @@ -396,12 +407,12 @@ public static class MovementThrottle return; } - if (ns._movementLogging) + if (_debugLogging && ns._movementLogging) { var waited = now - ns._nextMovementTime; logger.Debug( "[Queue] {Name}: dequeued OK dir={Dir} (remaining={Remaining}, waited={Waited}ms)", - mobile, movement.Direction, remaining, waited >= 0 ? waited : 0 + mobile.RawName, movement.Direction, remaining, waited >= 0 ? waited : 0 ); } @@ -419,6 +430,10 @@ public static class MovementThrottle // Update tracking ns._hasQueuedMovements = ns._movementQueue?.Count > 0; + if (!ns._hasQueuedMovements) + { + _netStatesWithQueuedMovements.Remove(ns); + } } /// @@ -454,6 +469,7 @@ public static class MovementThrottle { ns._movementQueue?.Clear(); ns._hasQueuedMovements = false; + _netStatesWithQueuedMovements.Remove(ns); } // Maximum expected packets per second (mounted running = 100ms = 10/sec, plus tolerance) @@ -468,7 +484,7 @@ public static class MovementThrottle logger.Information( "Movement queue overflow: {Character} ({Account}) | " + "Queue reached hard limit: {Limit} | IP: {IP}", - mobile, + mobile?.RawName ?? "Unknown", ns.Account?.Username ?? "Unknown", _hardQueueLimit, ns.Address @@ -500,7 +516,7 @@ public static class MovementThrottle private static void RecordMovement(NetState ns, long now, int cost, Direction dir, Mobile mobile) { // Calculate interval since last movement - var interval = ns._hasMovementRecord + var interval = ns._lastMovementRecordTime > 0 ? (int)(now - ns._lastMovementRecordTime) : -1; // -1 indicates first movement (no previous time) @@ -509,7 +525,6 @@ public static class MovementThrottle if (interval <= 0 || interval > _maxChainGap) { ns._lastMovementRecordTime = now; - ns._hasMovementRecord = true; // Use RTT to distinguish "stopped moving" vs "lagged" // - Stable low-latency connection with gap >> RTT → player stopped, reset history @@ -529,19 +544,19 @@ public static class MovementThrottle // A large gap followed by a burst of packets = likely lag recovery, not speed hack ns._lastGapDuration = interval; - if (ns._movementLogging) + if (_debugLogging && mobile?.RawName != null) { var action = shouldReset ? "history reset" : "history preserved (possible lag)"; logger.Debug( "[Movement] {Name}: SKIP recording (gap {Gap}ms > {MaxGap}ms, " + "RTT={RTT}ms stable={Stable} → {Action})", - mobile, interval, _maxChainGap, avgRtt, ns.HasStableConnection, action + mobile.RawName, interval, _maxChainGap, avgRtt, ns.HasStableConnection, action ); } } - else if (ns._movementLogging) + else if (_debugLogging && mobile?.RawName != null) { - logger.Debug("[Movement] {Name}: SKIP recording (first in chain)", mobile); + logger.Debug("[Movement] {Name}: SKIP recording (first in chain)", mobile.RawName); } return; @@ -557,9 +572,12 @@ public static class MovementThrottle // the next real move's interval artificially short, inflating rate. if (cost == 0) { - if (ns._movementLogging) + if (_debugLogging && mobile?.RawName != null) { - logger.Debug("[Movement] {Name}: SKIP direction-only change (preserves interval measurement)", mobile); + logger.Debug( + "[Movement] {Name}: SKIP direction-only change (preserves interval measurement)", + mobile.RawName + ); } return; } @@ -595,16 +613,15 @@ public static class MovementThrottle } ns._lastMovementRecordTime = now; - ns._hasMovementRecord = true; // Debug logging - if (ns._movementLogging) + if (_debugLogging && mobile?.RawName != null) { var historyCount = ns._movementHistoryFull ? _movementHistorySize : ns._movementHistoryIndex; logger.Debug( "[Movement] {Name}: interval={Interval}ms target={Target}ms queue={Queue} " + "flags={Flags} history={History}/{MaxHistory} RTT={RTT}ms", - mobile, interval, cost, record.QueueDepth, + mobile.RawName, interval, cost, record.QueueDepth, flags, historyCount, _movementHistorySize, ns.AverageRtt ); } @@ -797,7 +814,7 @@ public static class MovementThrottle var averageRtt = ns.AverageRtt; // Detailed rate breakdown for debugging - if (ns._movementLogging) + if (_debugLogging) { logger.Debug("[MovementAnalysis] Rate={Rate:F3}, Samples={Samples}, RTT={RTT}ms", rate, sampleCount, averageRtt); @@ -960,19 +977,19 @@ public static class MovementThrottle var verdict = AnalyzeMovement(ns, out var rate, out var sampleCount, out var confidence); // Debug logging - if (ns._movementLogging) + if (_debugLogging && ns.Mobile?.RawName != null) { var (burstSize, _) = DetectRecentBurst(ns); - var probeStatus = ns._rttProbePending ? "pending" : "idle"; + var probeStatus = ns._rttProbeTime > 0 ? "pending" : "idle"; var queueDepth = ns._movementQueue?.Count ?? 0; logger.Debug( "[RateCheck] {Name}: rate={Rate:F3} samples={Samples} verdict={Verdict} " + "confidence={Confidence:P0} queue={Queue} burst={Burst} sustained={Sustained}s", - ns.Mobile, rate, sampleCount, verdict, confidence, queueDepth, burstSize, ns._consecutiveHighRateSeconds + ns.Mobile.RawName, rate, sampleCount, verdict, confidence, queueDepth, burstSize, ns._consecutiveHighRateSeconds ); logger.Debug( " RTT: avg={Avg}ms last={Last}ms var={Var} samples={RttSamples} stable={Stable} probe={Probe}", - ns.AverageRtt, ns.LastRtt, ns._rttVariance, ns._rttSampleCount, ns.HasStableConnection, probeStatus + ns.AverageRtt, ns._lastRtt, ns._rttVariance, ns._rttSampleCount, ns.HasStableConnection, probeStatus ); } @@ -1008,11 +1025,11 @@ public static class MovementThrottle if (shouldNotify) { - if (ns._movementLogging) + if (_debugLogging) { logger.Debug( "[ALERT] {Urgency} - {Name}: rate={Rate:F3} verdict={Verdict} confidence={Confidence:P0}", - urgency, ns.Mobile, rate, verdict, confidence + urgency, ns.Mobile?.RawName, rate, verdict, confidence ); } NotifyStaff(ns, rate, sampleCount, confidence, verdict, urgency); @@ -1037,12 +1054,11 @@ public static class MovementThrottle var now = Core.TickCount; // Rate-limit notifications per player - if (ns._speedHackNotified && now - ns._lastSpeedHackNotification < _speedHackNotificationCooldown) + if (now - ns._lastSpeedHackNotification < _speedHackNotificationCooldown) { return; } - ns._speedHackNotified = true; ns._lastSpeedHackNotification = now; var mobile = ns.Mobile; @@ -1054,7 +1070,7 @@ public static class MovementThrottle "PacketRate: {PacketRate}/s (peak: {PeakRate}/s) | RTT: {Rtt}ms (stable: {Stable}) | " + "Sustained: {Sustained}s | Queue: {Queue} | Location: {Location} Map: {Map} | IP: {IP}", urgency, - mobile, + mobile?.RawName ?? "Unknown", ns.Account?.Username ?? "Unknown", rate, sampleCount, @@ -1122,7 +1138,7 @@ public static class MovementThrottle Verdict = verdict, Confidence = confidence, AverageRtt = ns.AverageRtt, - LastRtt = ns.LastRtt, + LastRtt = ns._lastRtt, RttVariance = ns._rttVariance, StableConnection = ns.HasStableConnection, RttSampleCount = ns._rttSampleCount, diff --git a/Projects/Server/Network/NetState/NetState.Movement.cs b/Projects/Server/Network/NetState/NetState.Movement.cs index c6f28fcd1..1cc79429e 100644 --- a/Projects/Server/Network/NetState/NetState.Movement.cs +++ b/Projects/Server/Network/NetState/NetState.Movement.cs @@ -15,6 +15,7 @@ using System; using System.Collections.Generic; +using System.Diagnostics; using System.Runtime.InteropServices; using Server.Logging; @@ -69,24 +70,23 @@ public partial class NetState internal Queue _movementQueue; // Lazy initialized internal long _movementCredit; // Credit buffer for timing jitter internal long _nextMovementTime = Core.TickCount; // When next movement is allowed - internal long _lastQueueDepthCheck = Core.TickCount; // Throttle depth check frequency + internal int _sustainedQueueDepth; // Tracks sustained high queue depth + internal long _lastQueueDepthCheck; // Throttle depth check frequency internal bool _hasQueuedMovements; // Fast check for Slice() // Movement history for rate-based speed hack detection (lazy initialized) internal MovementRecord[] _movementHistory; // Circular buffer internal int _movementHistoryIndex; // Next write position (also serves as count until full) internal bool _movementHistoryFull; // True once buffer has wrapped - internal long _lastMovementRecordTime; // For calculating intervals (valid only when _hasMovementRecord) - internal bool _hasMovementRecord; // False until the first movement in a chain is seen + internal long _lastMovementRecordTime; // For calculating intervals // Detection state internal int _consecutiveHighRateSeconds; // Sustained detection counter - internal long _lastSpeedHackNotification; // Rate-limit notifications (valid only when _speedHackNotified) - internal bool _speedHackNotified; // False until the first notification is sent + internal long _lastSpeedHackNotification; // Rate-limit notifications internal int _lastGapDuration; // Duration of last gap > maxChainGap (for burst forgiveness) // Movement packet rate tracking (for speed hack detection) - internal long _movementWindowStart = Core.TickCount; // Start of current 1-second window + internal long _movementWindowStart; // Start of current 1-second window internal int _movementsInWindow; // Count in current window internal int _peakMovementRate; // Highest rate seen (packets/sec) @@ -100,9 +100,10 @@ public partial class NetState _nextMovementTime = Core.TickCount; _movementCredit = 0; _hasQueuedMovements = false; + _sustainedQueueDepth = 0; // Reset movement history - next movement starts a new chain - _hasMovementRecord = false; + _lastMovementRecordTime = 0; _movementHistoryIndex = 0; _movementHistoryFull = false; @@ -112,7 +113,7 @@ public partial class NetState _rttProbeInterval = RttProbeIntervalNormal; // Reset packet rate window - _movementWindowStart = Core.TickCount; + _movementWindowStart = 0; _movementsInWindow = 0; } @@ -164,19 +165,17 @@ public partial class NetState private const long MaxStableLatency = 200; // Max RTT (ms) for "stable" connection // RTT state - internal bool _rttProbePending; // True while waiting for a probe response - internal long _rttProbeTime; // When we sent the probe (valid only when _rttProbePending) + internal long _rttProbeTime; // When we sent the probe (0 = not waiting) + internal long _lastRtt; // Most recent RTT measurement internal long[] _rttHistory; // Rolling history (lazy init) internal int _rttHistoryIndex; // Current position in history internal int _rttSampleCount; // Number of samples collected (saturates at RttHistorySize) internal long _rttVariance; // Calculated variance for stability - internal long _nextRttProbe = Core.TickCount; // When to send next probe + internal long _nextRttProbe; // When to send next probe internal int _rttProbeInterval = RttProbeIntervalNormal; // Current probe interval - /// - /// Gets the most recent RTT measurement, or 0 if none has been recorded. - /// - public long LastRtt => _rttSampleCount > 0 ? _rttHistory[(_rttHistoryIndex - 1) & (RttHistorySize - 1)] : 0; + // High-resolution timestamp for RTT measurement (Stopwatch ticks, not game loop ticks) + private long _rttProbeTimestampHiRes; /// /// Sets the RTT probe interval based on suspicion level. @@ -207,22 +206,23 @@ public partial class NetState var now = Core.TickCount; // Don't send if we're still waiting for a response - if (_rttProbePending) + if (_rttProbeTime > 0) { // Timeout after 10 seconds - connection is probably dead or very laggy if (now - _rttProbeTime > 10000) { - _rttProbePending = false; + _rttProbeTime = 0; + _rttProbeTimestampHiRes = 0; } return; } // First probe: send immediately when player starts moving // Subsequent probes: send when interval has passed - if (now - _nextRttProbe >= 0) + if (_nextRttProbe == 0 || now >= _nextRttProbe) { - _rttProbePending = true; _rttProbeTime = now; + _rttProbeTimestampHiRes = Stopwatch.GetTimestamp(); _nextRttProbe = now + _rttProbeInterval + Utility.Random(RttProbeJitter); if (_movementLogging) @@ -242,9 +242,10 @@ public partial class NetState /// public void RecordRttMeasurement() { + var nowHiRes = Stopwatch.GetTimestamp(); var now = Core.TickCount; - if (!_rttProbePending) + if (_rttProbeTime <= 0) { // Not expecting a response (client-initiated version send) - ignore silently return; @@ -252,15 +253,19 @@ public partial class NetState var rtt = now - _rttProbeTime; + // High-resolution RTT in microseconds + var rttHiResUs = (nowHiRes - _rttProbeTimestampHiRes) * 1_000_000 / Stopwatch.Frequency; + if (_movementLogging) { movementLogger.Debug( - "[RTT-Response] {Account}: {Rtt}ms", - Account?.Username ?? _toString, rtt + "[RTT-Response] {Account}: {Rtt}ms (HiRes: {RttHiRes:F2}ms)", + Account?.Username ?? _toString, rtt, rttHiResUs / 1000.0 ); } - _rttProbePending = false; + _rttProbeTime = 0; + _rttProbeTimestampHiRes = 0; // Sanity check - RTT should be positive and reasonable if (rtt is <= 0 or > 10000) @@ -280,6 +285,7 @@ public partial class NetState // Update history _rttHistory[_rttHistoryIndex++ & (RttHistorySize - 1)] = rtt; + _lastRtt = rtt; // Track sample count (saturates at buffer size) if (_rttSampleCount < RttHistorySize) diff --git a/Projects/Server/Network/NetState/NetState.Network.cs b/Projects/Server/Network/NetState/NetState.Network.cs index f69f92076..7a17d8cd0 100644 --- a/Projects/Server/Network/NetState/NetState.Network.cs +++ b/Projects/Server/Network/NetState/NetState.Network.cs @@ -71,24 +71,6 @@ public partial class NetState _socketManager?.WaitForCompletion(timeoutMs); } - /// - /// Wakes the game loop if it is blocked in . Safe from any - /// thread; a no-op before networking is configured or after teardown. The signal is sticky, - /// so a wake racing the loop's decision to sleep is not lost. - /// - public static void Wake() - { - _socketManager?.Ring?.Wake(); - } - - /// - /// True when no queued network work remains for the loop to drain. defers - /// work in several places, so an empty completion queue alone is not enough. - /// - internal static bool IsIdle => - _throttled.Count == 0 && _throttledPending.Count == 0 && - _flushPending.Count == 0 && _pendingDisconnects.Count == 0 && _disposed.Count == 0; - /// /// Gets the listening addresses that the server is bound to. /// diff --git a/Projects/Server/Network/NetState/NetState.cs b/Projects/Server/Network/NetState/NetState.cs index 04f1a5a76..7312603f7 100755 --- a/Projects/Server/Network/NetState/NetState.cs +++ b/Projects/Server/Network/NetState/NetState.cs @@ -44,7 +44,7 @@ public partial class NetState : IComparable, IValueLinkListNode _connectingQueue = new(2048); private static readonly HashSet _instances = new(2048); - public static HashSet Instances => _instances; + public static IReadOnlySet Instances => _instances; private readonly string _toString; private ClientVersion _version; @@ -109,6 +109,9 @@ public partial class NetState : IComparable, IValueLinkListNode, IValueLinkListNode Trades { get; private set; } + public List Trades { get; } public bool Seeded { get; set; } @@ -257,18 +260,8 @@ public partial class NetState : IComparable, IValueLinkListNode= 0; --i) { - if (Trades == null) - { - break; - } - if (i >= Trades.Count) { continue; @@ -287,18 +280,8 @@ public partial class NetState : IComparable, IValueLinkListNode= 0; --i) { - if (Trades != null) - { - break; - } - if (i < Trades.Count) { Trades[i].Cancel(); @@ -308,21 +291,11 @@ public partial class NetState : IComparable, IValueLinkListNode, IValueLinkListNode, IValueLinkListNode, IValueLinkListNode public long Position => _reader.Position; - public TimeSpan AnchoredTimeShift - { - get => _reader.AnchoredTimeShift; - set => _reader.AnchoredTimeShift = value; - } - public void Dispose() { _accessor?.SafeMemoryMappedViewHandle.ReleasePointer(); diff --git a/Projects/Server/Serialization/BufferReader.cs b/Projects/Server/Serialization/BufferReader.cs index bd37f4ff6..846e9e5dc 100644 --- a/Projects/Server/Serialization/BufferReader.cs +++ b/Projects/Server/Serialization/BufferReader.cs @@ -37,8 +37,6 @@ public class BufferReader : IGenericReader public long Position => _position; public long BufferSize => _buffer.Length; - public TimeSpan AnchoredTimeShift { get; set; } - public BufferReader(byte[] buffer, Dictionary typesDb = null, Encoding encoding = null) { _buffer = buffer; diff --git a/Projects/Server/Serialization/BufferWriter.cs b/Projects/Server/Serialization/BufferWriter.cs index ab53e2f2f..b811d68cb 100644 --- a/Projects/Server/Serialization/BufferWriter.cs +++ b/Projects/Server/Serialization/BufferWriter.cs @@ -384,7 +384,6 @@ public class BufferWriter : IGenericWriter } [MethodImpl(MethodImplOptions.AggressiveInlining)] - [Obsolete("Delta time rewrites its bytes on every save. Write anchored time instead (WriteAnchoredTime, or [AnchoredDateTime] on generated fields); bump the containing type's version, as the wire format changes. Existing delta payloads remain readable through ReadDeltaTime in old-version fallbacks.")] public void WriteDeltaTime(DateTime value) { if (value == DateTime.MinValue) @@ -408,21 +407,6 @@ public class BufferWriter : IGenericWriter Write(value.Ticks - DateTime.UtcNow.Ticks); } - /// - /// Writes the absolute value; re-bases it - /// by the elapsed time since the save started, so downtime does not age it and an - /// unchanged value serializes to identical bytes. - /// - public void WriteAnchoredTime(DateTime value) - { - if (value.Kind == DateTimeKind.Local) - { - value = value.ToUniversalTime(); - } - - Write(value.Ticks); - } - [MethodImpl(MethodImplOptions.AggressiveInlining)] public void Write(IPAddress value) { diff --git a/Projects/Server/Serialization/GenericEntityPersistence.cs b/Projects/Server/Serialization/GenericEntityPersistence.cs index c7323329f..a49f213eb 100644 --- a/Projects/Server/Serialization/GenericEntityPersistence.cs +++ b/Projects/Server/Serialization/GenericEntityPersistence.cs @@ -114,10 +114,9 @@ public class GenericEntityPersistence : GenericPersistence, IGenericEntityPer using var binFs = new FileStream( Path.Combine(dir, $"{Name}.bin"), FileMode.Create, FileAccess.Write, FileShare.None, 1024 * 1024 ); - // v4 records are fixed-width 26 bytes; the v5 header carries the save-start anchor - // and the type table (name lengths vary — 64 bytes per entry is a staging hint, not - // a contract). - var expectedIdxSize = 20 + 26L * EntitiesBySerial.Count + 64L * _typeTable.Count; + // v4 records are fixed-width 26 bytes; the header carries the type table + // (name lengths vary — 64 bytes per entry is a staging hint, not a contract). + var expectedIdxSize = 12 + 26L * EntitiesBySerial.Count + 64L * _typeTable.Count; using var idx = new FileBufferWriter(Path.Combine(dir, $"{Name}.idx"), expectedIdxSize); var binPosition = 0L; @@ -143,10 +142,7 @@ public class GenericEntityPersistence : GenericPersistence, IGenericEntityPer binPosition += _selfLength; } - idx.Write(5); // Version - - // One anchor for the whole save: the world is frozen from the moment it is stamped. - idx.Write(World.SaveStartTime.Ticks); + idx.Write(4); // Version // The type table is fully known at freeze (AddEntity diverts to the pending // queues while saving) and is written before the records so the loader can @@ -498,18 +494,6 @@ public class GenericEntityPersistence : GenericPersistence, IGenericEntityPer var version = dataReader.ReadInt(); - if (version >= 5) - { - // Re-base anchored timestamps by the elapsed time since the save started. - var anchor = new DateTime(dataReader.ReadLong(), DateTimeKind.Utc); - var shift = Core.Now - anchor; - _anchoredTimeShift = anchor.Ticks > 0 && shift > TimeSpan.Zero ? shift : TimeSpan.Zero; - - // The whole save shares one anchor. Publish it so payloads without their own - // (GenericPersistence bins) can shift too; indexes load before any of them. - World.LoadTimeShift = _anchoredTimeShift; - } - if (version >= 4) { DeserializeIndexesV4(dataReader, entities); @@ -676,9 +660,6 @@ public class GenericEntityPersistence : GenericPersistence, IGenericEntityPer private static List _toDelete; - // From the loaded idx (v5+); zero when the save predates the anchor. - private TimeSpan _anchoredTimeShift; - private unsafe void InternalDeserialize(string filePath, int index, Dictionary typesDb) { using var mmf = MemoryMappedFile.CreateFromFile(filePath, FileMode.Open); @@ -686,10 +667,7 @@ public class GenericEntityPersistence : GenericPersistence, IGenericEntityPer byte* ptr = null; accessor.SafeMemoryMappedViewHandle.AcquirePointer(ref ptr); - var dataReader = new UnmanagedDataReader(ptr, accessor.Length, typesDb) - { - AnchoredTimeShift = _anchoredTimeShift - }; + var dataReader = new UnmanagedDataReader(ptr, accessor.Length, typesDb); Deserialize(dataReader); diff --git a/Projects/Server/Serialization/GenericPersistence.cs b/Projects/Server/Serialization/GenericPersistence.cs index 2268a86c6..5b8e79c29 100644 --- a/Projects/Server/Serialization/GenericPersistence.cs +++ b/Projects/Server/Serialization/GenericPersistence.cs @@ -98,13 +98,7 @@ public abstract class GenericPersistence : Persistence, IGenericSerializable byte* ptr = null; accessor.SafeMemoryMappedViewHandle.AcquirePointer(ref ptr); - var dataReader = new UnmanagedDataReader(ptr, accessor.Length, typesDb) - { - // These payloads carry no anchor of their own; they inherit the save-wide - // shift stamped while the entity indexes were read (indexes always load - // before persistence payloads — see Persistence.Load). - AnchoredTimeShift = World.LoadTimeShift - }; + var dataReader = new UnmanagedDataReader(ptr, accessor.Length, typesDb); Deserialize(dataReader); error = dataReader.Position != fileLength diff --git a/Projects/Server/Serialization/IGenericReader.cs b/Projects/Server/Serialization/IGenericReader.cs index bfa302b39..0244a5166 100644 --- a/Projects/Server/Serialization/IGenericReader.cs +++ b/Projects/Server/Serialization/IGenericReader.cs @@ -43,12 +43,6 @@ public interface IGenericReader DateTime ReadDateTime() => new(ReadLong(), DateTimeKind.Utc); TimeSpan ReadTimeSpan() => new(ReadLong()); - /// - /// Decodes a legacy delta-time value. Only for reading old-version payloads (version - /// fallbacks and migration replays) — current formats store anchored time and read it - /// with . is - /// obsolete: no current-version format may write delta time. - /// DateTime ReadDeltaTime() { return ReadLong() switch @@ -58,37 +52,6 @@ public interface IGenericReader var delta => new DateTime(delta + DateTime.UtcNow.Ticks, DateTimeKind.Utc) }; } - - /// - /// Elapsed time between the loaded save starting and this load, applied by - /// . Zero when the source carries no anchor. - /// - TimeSpan AnchoredTimeShift => TimeSpan.Zero; - - DateTime ReadAnchoredTime() - { - var value = ReadDateTime(); - - if (value == DateTime.MinValue || value == DateTime.MaxValue) - { - return value; - } - - var shift = AnchoredTimeShift; - if (shift == TimeSpan.Zero) - { - return value; - } - - var ticks = value.Ticks + shift.Ticks; - - if (ticks >= DateTime.MaxValue.Ticks) - { - return DateTime.MaxValue; - } - - return ticks <= 0 ? DateTime.MinValue : new DateTime(ticks, DateTimeKind.Utc); - } decimal ReadDecimal() => new([ReadInt(), ReadInt(), ReadInt(), ReadInt()]); int ReadEncodedInt() { diff --git a/Projects/Server/Serialization/IGenericWriter.cs b/Projects/Server/Serialization/IGenericWriter.cs index 4162655de..9c9563139 100644 --- a/Projects/Server/Serialization/IGenericWriter.cs +++ b/Projects/Server/Serialization/IGenericWriter.cs @@ -40,11 +40,7 @@ public interface IGenericWriter void Write(decimal value); void WriteEncodedInt(int value); void Write(DateTime value); - - [Obsolete("Delta time rewrites its bytes on every save. Write anchored time instead (WriteAnchoredTime, or [AnchoredDateTime] on generated fields); bump the containing type's version, as the wire format changes. Existing delta payloads remain readable through ReadDeltaTime in old-version fallbacks.")] void WriteDeltaTime(DateTime value); - - void WriteAnchoredTime(DateTime value); void Write(IPAddress value); void Write(TimeSpan value); void Write(Point3D value); diff --git a/Projects/Server/Serialization/UnmanagedDataReader.cs b/Projects/Server/Serialization/UnmanagedDataReader.cs index aa3916ee9..7c8b5da1e 100644 --- a/Projects/Server/Serialization/UnmanagedDataReader.cs +++ b/Projects/Server/Serialization/UnmanagedDataReader.cs @@ -43,8 +43,6 @@ public unsafe class UnmanagedDataReader : IGenericReader /// public long Position { get; private set; } - public TimeSpan AnchoredTimeShift { get; set; } - /// /// Read bits of data raw from a serialized file using Little-endian. /// diff --git a/Projects/Server/Server.csproj b/Projects/Server/Server.csproj index fd69557b7..8953efcba 100644 --- a/Projects/Server/Server.csproj +++ b/Projects/Server/Server.csproj @@ -34,13 +34,14 @@ - + - - + + - - + + + diff --git a/Projects/Server/Timer/Timer.TimerWheel.cs b/Projects/Server/Timer/Timer.TimerWheel.cs index 2ab3ce547..8c9dc47fd 100644 --- a/Projects/Server/Timer/Timer.TimerWheel.cs +++ b/Projects/Server/Timer/Timer.TimerWheel.cs @@ -51,15 +51,8 @@ public partial class Timer } } - /// - /// Milliseconds of simulated time one wheel turn advances. - /// - public static int TickRate => _tickRate; - public static void Slice(long tickCount) { - EventLoopProfiler.WheelSlice(tickCount - _lastTickTurned); - var deltaSinceTurn = tickCount - _lastTickTurned; while (deltaSinceTurn >= _tickRate) { diff --git a/Projects/Server/Utilities/Utility.cs b/Projects/Server/Utilities/Utility.cs index 9d7cd9f3b..f3786d834 100644 --- a/Projects/Server/Utilities/Utility.cs +++ b/Projects/Server/Utilities/Utility.cs @@ -1050,16 +1050,28 @@ public static partial class Utility return; } + using var queue = PooledRefQueue.Create(); foreach (var (key, value) in dictionary) { - var deleted = serializableKey - ? ((ISerializable)key).Deleted - : value == null || ((ISerializable)value).Deleted; - - if (deleted) + if (serializableKey) { - dictionary.Remove(key); + if (key == null || ((ISerializable)key).Deleted) + { + queue.Enqueue(key); + } } + else + { + if (value == null || ((ISerializable)value).Deleted) + { + queue.Enqueue(key); + } + } + } + + while (queue.Count > 0) + { + dictionary.Remove(queue.Dequeue()); } dictionary.TrimExcess(); diff --git a/Projects/Server/World/World.cs b/Projects/Server/World/World.cs index c00fc85f6..2c9e6d216 100644 --- a/Projects/Server/World/World.cs +++ b/Projects/Server/World/World.cs @@ -93,21 +93,6 @@ public static class World public static string SavePath { get; private set; } public static WorldState WorldState { get; private set; } public static bool Saving => WorldState == WorldState.Saving; - - /// - /// UTC time the current or most recent world save started. Written into save indexes so - /// anchored timestamps can be re-based by the downtime at load. - /// - public static DateTime SaveStartTime { get; internal set; } - - /// - /// The anchored-time shift for the save currently being loaded: the downtime between the - /// save's start and this load. Stamped while entity indexes are read (they all carry the - /// same anchor, since the whole save shares one ) and applied - /// to every reader of that save's files — including - /// payloads, which carry no anchor of their own. Zero for saves that predate the anchor. - /// - public static TimeSpan LoadTimeShift { get; internal set; } public static bool Running => WorldState is not WorldState.Loading and not WorldState.Initial; public static bool Loading => WorldState == WorldState.Loading; @@ -302,10 +287,6 @@ public static class World WorldState = WorldState.Saving; - // The world is frozen from here: one anchor for the whole save. Written into save - // indexes so anchored timestamps can be re-based by the downtime at load. - SaveStartTime = Core.Now; - Broadcast(0x35, true, "The world is saving, please wait."); logger.Information("Saving world"); diff --git a/Projects/UOContent.Tests/Fixtures/TestServerInitializer.cs b/Projects/UOContent.Tests/Fixtures/TestServerInitializer.cs index 5e4230b7c..640c05837 100644 --- a/Projects/UOContent.Tests/Fixtures/TestServerInitializer.cs +++ b/Projects/UOContent.Tests/Fixtures/TestServerInitializer.cs @@ -100,10 +100,7 @@ internal static class TestServerInitializer } World.Configure(); - // Registers the Accounts entity persistence; without it no test can construct an Account. - Server.Accounting.Accounts.Configure(); RaceDefinitions.Configure(); - Server.Movement.Movement.Configure(); MovementImpl.Configure(); PathFollower.Configure(); World.Load(); diff --git a/Projects/UOContent.Tests/Tests/Accounting/AccountPasswordTests.cs b/Projects/UOContent.Tests/Tests/Accounting/AccountPasswordTests.cs deleted file mode 100644 index 29a1b818f..000000000 --- a/Projects/UOContent.Tests/Tests/Accounting/AccountPasswordTests.cs +++ /dev/null @@ -1,74 +0,0 @@ -using System; -using Server.Accounting; -using Server.Accounting.Security; -using Xunit; - -namespace Server.Tests.Accounting; - -[Collection("Sequential UOContent Tests")] -public class AccountPasswordTests : IDisposable -{ - private const string Password = "hunter2"; - - // CurrentAlgorithm is process-wide state shared with the rest of the collection. - private readonly PasswordProtectionAlgorithm _originalAlgorithm = AccountSecurity.CurrentAlgorithm; - - public void Dispose() => AccountSecurity.CurrentAlgorithm = _originalAlgorithm; - - [Theory] - [InlineData(PasswordProtectionAlgorithm.SHA1)] - [InlineData(PasswordProtectionAlgorithm.SHA2)] - [InlineData(PasswordProtectionAlgorithm.PBKDF2)] - [InlineData(PasswordProtectionAlgorithm.Argon2)] - public void NewAccount_CanLogIn(PasswordProtectionAlgorithm algorithm) - { - AccountSecurity.CurrentAlgorithm = algorithm; - var account = new Account($"new-{algorithm}-user", Password); - - Assert.Equal(algorithm, account.PasswordAlgorithm); - Assert.True(account.CheckPassword(Password)); - Assert.False(account.CheckPassword("wrong-password")); - } - - // SetPassword assigns PasswordAlgorithm before deriving the phrase from it. Reversed, the hash - // is salted by the outgoing algorithm's rule but stored under the incoming one, which verifies - // once and then never again. - [Theory] - [InlineData(PasswordProtectionAlgorithm.SHA1)] - [InlineData(PasswordProtectionAlgorithm.SHA2)] - [InlineData(PasswordProtectionAlgorithm.PBKDF2)] - public void UpgradingAlgorithm_DoesNotLockTheAccountOut(PasswordProtectionAlgorithm from) - { - AccountSecurity.CurrentAlgorithm = from; - var account = new Account($"upgrade-{from}-user", Password); - Assert.True(account.CheckPassword(Password)); - - AccountSecurity.CurrentAlgorithm = PasswordProtectionAlgorithm.Argon2; - - Assert.True(account.CheckPassword(Password)); - Assert.Equal(PasswordProtectionAlgorithm.Argon2, account.PasswordAlgorithm); - - // Must verify against what the rehash wrote. - Assert.True(account.CheckPassword(Password)); - Assert.False(account.CheckPassword("wrong-password")); - } - - [Fact] - public void StaleArgon2Parameters_AreRehashedOnLogin() - { - AccountSecurity.CurrentAlgorithm = PasswordProtectionAlgorithm.Argon2; - var account = new Account("stale-params-user", Password); - - // The shipping default before this change: Argon2i, m=8192, t=3, p=1. - account.Password = - "$argon2i$v=19$m=8192,t=3,p=1$LD1XJz7P3wQmIJ+Tu6ScgA$NO5hBABsHQ172C5nDO2X4gWnB4jDef3x6WhLdVE2LFw"; - - Assert.True(account.CheckPassword(Password)); - Assert.StartsWith("$argon2id$v=19$m=16384,t=1,p=1$", account.Password); - - // Already current: verifying again must not rewrite the hash. - var afterFirst = account.Password; - Assert.True(account.CheckPassword(Password)); - Assert.Equal(afterFirst, account.Password); - } -} diff --git a/Projects/UOContent.Tests/Tests/Accounting/PasswordWorkerTests.cs b/Projects/UOContent.Tests/Tests/Accounting/PasswordWorkerTests.cs deleted file mode 100644 index 5972e5eb9..000000000 --- a/Projects/UOContent.Tests/Tests/Accounting/PasswordWorkerTests.cs +++ /dev/null @@ -1,229 +0,0 @@ -using System; -using System.Threading; -using Server.Accounting; -using Server.Accounting.Security; -using Xunit; - -namespace Server.Tests.Accounting; - -[Collection("Sequential UOContent Tests")] -public class PasswordWorkerTests : IDisposable -{ - private const string Password = "hunter2"; - - private readonly PasswordProtectionAlgorithm _originalAlgorithm = AccountSecurity.CurrentAlgorithm; - - public PasswordWorkerTests() => AccountSecurity.CurrentAlgorithm = PasswordProtectionAlgorithm.Argon2; - - public void Dispose() => AccountSecurity.CurrentAlgorithm = _originalAlgorithm; - - private static Account CreateAccount(string username) => - Accounts.GetAccount(username) as Account ?? new Account(username, Password); - - /// - /// Enqueues work, then pumps the loop context until or the deadline. - /// - /// The context pins itself to the thread that constructed it and refuses ExecuteTasks - /// from any other. The fixture's belongs to whichever thread built the fixture, and xUnit gives - /// no guarantee that a test method runs on that thread even inside a sequential collection -- - /// so this owns one for the duration and puts the original back. Pumping the fixture's context - /// passed locally and failed on CI. - /// - private static void PumpUntil(Action enqueue, Func complete, int timeoutSeconds = 20) - { - var original = Core.LoopContext; - var owned = new EventLoopContext(); - Core.LoopContext = owned; - - try - { - enqueue(); - - var deadline = DateTime.UtcNow.AddSeconds(timeoutSeconds); - - while (!complete() && DateTime.UtcNow < deadline) - { - owned.ExecuteTasks(); - Thread.Sleep(5); - } - - // Anything that landed between the last pump and the final check. - owned.ExecuteTasks(); - } - finally - { - Core.LoopContext = original; - } - } - - private static PasswordJob JobFor(Account account, string submitted) => - new() - { - Account = account, - StoredHash = account.Password, - VerifyPhrase = account.GetVerifyPhrase(submitted), - HashPhrase = account.NeedsPasswordUpgrade() ? account.GetRehashPhrase(submitted) : null, - StoredAlgorithm = account.PasswordAlgorithm, - TargetAlgorithm = AccountSecurity.CurrentAlgorithm - }; - - /// - /// Drives the real queue rather than ComputeInline. A job with no NetState attached -- an - /// admin password change -- was being dropped by the liveness check, which read a null State as - /// a dead connection, so the change silently never happened and its callback never fired. - /// - [Fact] - public void RunsAJobThatHasNoConnectionAttached() - { - var account = CreateAccount("offloop-no-netstate-user"); - var applied = false; - - var job = new PasswordJob - { - Account = account, - HashPhrase = account.GetRehashPhrase("a-queued-password"), - TargetAlgorithm = AccountSecurity.CurrentAlgorithm, - OnComplete = (_, outcome) => applied = outcome.Hash != null - }; - - PumpUntil(() => Assert.True(PasswordWorker.TryEnqueue(job)), () => applied); - - Assert.True(applied); - Assert.True(account.CheckPassword("a-queued-password")); - } - - [Fact] - public void VerifiesTheCorrectPassword() - { - var account = CreateAccount("offloop-correct-user"); - - var outcome = PasswordWorker.ComputeInline(JobFor(account, Password)); - - Assert.True(outcome.Verified); - } - - [Fact] - public void RejectsTheWrongPassword() - { - var account = CreateAccount("offloop-wrong-user"); - - var outcome = PasswordWorker.ComputeInline(JobFor(account, "not-the-password")); - - Assert.False(outcome.Verified); - Assert.Null(outcome.Hash); - } - - [Fact] - public void ProducesNoUpgradeWhenParametersAreCurrent() - { - var account = CreateAccount("offloop-current-user"); - - var outcome = PasswordWorker.ComputeInline(JobFor(account, Password)); - - Assert.True(outcome.Verified); - Assert.Null(outcome.Hash); - } - - [Fact] - public void ProducesAnUpgradeWhenParametersAreStale() - { - var account = CreateAccount("offloop-stale-user"); - - // The shipping default before #2562: Argon2i, m=8192, t=3, p=1. - account.Password = - "$argon2i$v=19$m=8192,t=3,p=1$LD1XJz7P3wQmIJ+Tu6ScgA$NO5hBABsHQ172C5nDO2X4gWnB4jDef3x6WhLdVE2LFw"; - - var outcome = PasswordWorker.ComputeInline(JobFor(account, Password)); - - Assert.True(outcome.Verified); - Assert.StartsWith("$argon2id$v=19$m=16384,t=1,p=1$", outcome.Hash); - } - - [Fact] - public void ProducesNoUpgradeWhenThePasswordIsWrong() - { - var account = CreateAccount("offloop-wrong-stale-user"); - account.Password = - "$argon2i$v=19$m=8192,t=3,p=1$LD1XJz7P3wQmIJ+Tu6ScgA$NO5hBABsHQ172C5nDO2X4gWnB4jDef3x6WhLdVE2LFw"; - - var outcome = PasswordWorker.ComputeInline(JobFor(account, "not-the-password")); - - Assert.False(outcome.Verified); - Assert.Null(outcome.Hash); - } - - [Fact] - public void AppliesAWrite() - { - var account = CreateAccount("offloop-apply-user"); - var upgraded = Argon2PasswordProtection.Instance.EncryptPassword(Password); - - account.ApplyPasswordWrite(upgraded, PasswordProtectionAlgorithm.Argon2); - - Assert.Equal(upgraded, account.Password); - Assert.True(account.CheckPassword(Password)); - } - - /// - /// Writes apply in dispatch order, which is what makes a guard unnecessary: dispatch is on the - /// loop, one worker drains FIFO, and results return through the loop context in that same order. - /// A second worker thread would break this and would need ordering reintroduced. - /// - [Fact] - public void WritesApplyInDispatchOrder() - { - var account = CreateAccount("offloop-two-writes-user"); - var done = 0; - - PumpUntil( - () => - { - for (var i = 1; i <= 2; i++) - { - Assert.True( - PasswordWorker.TryEnqueue( - new PasswordJob - { - Account = account, - HashPhrase = account.GetRehashPhrase($"password-{i}"), - StoredAlgorithm = account.PasswordAlgorithm, - TargetAlgorithm = AccountSecurity.CurrentAlgorithm, - OnComplete = (_, _) => done++ - } - ) - ); - } - }, - () => done >= 2 - ); - - Assert.Equal(2, done); - Assert.True(account.CheckPassword("password-2")); - Assert.False(account.CheckPassword("password-1")); - } - - [Theory] - [InlineData(PasswordProtectionAlgorithm.SHA1)] - [InlineData(PasswordProtectionAlgorithm.SHA2)] - public void UsesTheUsernameSaltedPhraseForShaAccounts(PasswordProtectionAlgorithm algorithm) - { - AccountSecurity.CurrentAlgorithm = algorithm; - var account = CreateAccount($"offloop-phrase-{algorithm}-user"); - - // Verification must use the algorithm the hash was stored under... - Assert.Equal($"{account.Username}{Password}", account.GetVerifyPhrase(Password)); - - // ...and a rehash the one it is moving to. Swapping these is the #2562 lockout. - AccountSecurity.CurrentAlgorithm = PasswordProtectionAlgorithm.Argon2; - Assert.Equal(Password, account.GetRehashPhrase(Password)); - } - - [Fact] - public void UsesTheBarePasswordForArgon2Accounts() - { - var account = CreateAccount("offloop-phrase-argon2-user"); - - Assert.Equal(Password, account.GetVerifyPhrase(Password)); - Assert.Equal(Password, account.GetRehashPhrase(Password)); - } -} diff --git a/Projects/UOContent.Tests/Tests/Accounting/Security/PasswordProtectionTest.cs b/Projects/UOContent.Tests/Tests/Accounting/Security/PasswordProtectionTest.cs index a0ef97a56..effd2da88 100644 --- a/Projects/UOContent.Tests/Tests/Accounting/Security/PasswordProtectionTest.cs +++ b/Projects/UOContent.Tests/Tests/Accounting/Security/PasswordProtectionTest.cs @@ -74,89 +74,4 @@ public class PasswordProtectionTest Assert.False(passwordProtection.ValidatePassword(encryptedPassword, "Not the same password")); } - - /// - /// Literal digests of , so the stored format cannot drift. These are - /// compared as strings against what is already in every account database -- a casing or encoding - /// change would lock out every SHA and MD5 account on the shard at once. - /// - [Theory] - [InlineData("MD5", "52284053181040AC90DBDE74A0E7FF5E")] - [InlineData("SHA1", "9AC635509803AAE2D8312BA1879289259A50C5F0")] - [InlineData( - "SHA2", - "5A727BFF8F8E08A24BDF6B0CD5065F30A1F8E0060B857BB8AFD6955BE0ACBC489DA63F19B8F4CF08D73DE4069CF4B" + - "29D94B353F31513B2FB2D9382EFE15AE975" - )] - public void HashAlgorithm_StoredFormatIsStable(string algorithmType, string expected) - { - var protection = algorithmType switch - { - "SHA1" => HashAlgorithmPasswordProtection.SHA1Instance, - "SHA2" => HashAlgorithmPasswordProtection.SHA2Instance, - _ => HashAlgorithmPasswordProtection.MD5Instance, - }; - - Assert.Equal(expected, protection.EncryptPassword(plainPassword)); - Assert.True(protection.ValidatePassword(expected, plainPassword)); - } - - // The shipping default before this change, as a literal so it cannot drift with the configured - // defaults. Password: "hunter2". - private const string LegacyArgon2iHash = - "$argon2i$v=19$m=8192,t=3,p=1$LD1XJz7P3wQmIJ+Tu6ScgA$NO5hBABsHQ172C5nDO2X4gWnB4jDef3x6WhLdVE2LFw"; - - [Fact] - public void Argon2_ValidatesLegacyArgon2iHash() - { - Assert.True(Argon2PasswordProtection.Instance.ValidatePassword(LegacyArgon2iHash, "hunter2")); - Assert.False(Argon2PasswordProtection.Instance.ValidatePassword(LegacyArgon2iHash, "wrong")); - } - - [Theory] - // type, memory, time, parallelism -> expected NeedsRehash - [InlineData("argon2id", 16384, 1, 1, false)] // current defaults - [InlineData("argon2i", 8192, 3, 1, true)] // the old shipping default - [InlineData("argon2id", 8192, 1, 1, true)] // right type, stale memory - [InlineData("argon2id", 16384, 3, 1, true)] // right type, stale iterations - [InlineData("argon2id", 16384, 1, 2, true)] // right type, stale parallelism - [InlineData("argon2i", 16384, 1, 1, true)] // right cost, stale type - public void Argon2_NeedsRehash_ComparesTypeAndCost( - string type, int memory, int time, int parallelism, bool expected - ) - { - var hash = $"${type}$v=19$m={memory},t={time},p={parallelism}$" + - "LD1XJz7P3wQmIJ+Tu6ScgA$NO5hBABsHQ172C5nDO2X4gWnB4jDef3x6WhLdVE2LFw"; - - Assert.Equal(expected, Argon2PasswordProtection.Instance.NeedsRehash(hash)); - } - - // Digest and salt lengths are decoded base64 sizes rather than parameter-list entries, so they - // need their own literals. Current type and cost throughout; only a length differs. - [Theory] - // 16-byte digest: 22 base64 chars instead of the 43 a 32-byte digest encodes to. - [InlineData("$argon2id$v=19$m=16384,t=1,p=1$LD1XJz7P3wQmIJ+Tu6ScgA$NO5hBABsHQ172C5nDO2X4g")] - // 8-byte salt: 11 base64 chars instead of the 22 a 16-byte salt encodes to. - [InlineData("$argon2id$v=19$m=16384,t=1,p=1$LD1XJz7P3wQ$NO5hBABsHQ172C5nDO2X4gWnB4jDef3x6WhLdVE2LFw")] - public void Argon2_NeedsRehash_ComparesSaltAndDigestLengths(string hash) - { - Assert.True(Argon2PasswordProtection.Instance.NeedsRehash(hash)); - } - - [Theory] - [InlineData("")] - [InlineData("not-a-hash")] - public void Argon2_NeedsRehash_IsTrueForUnparseableHashes(string hash) - { - Assert.True(Argon2PasswordProtection.Instance.NeedsRehash(hash)); - } - - [Fact] - public void NonArgon2Protections_NeverNeedRehash() - { - Assert.False(PBKDF2PasswordProtection.Instance.NeedsRehash("anything")); - Assert.False(HashAlgorithmPasswordProtection.SHA2Instance.NeedsRehash("anything")); - Assert.False(HashAlgorithmPasswordProtection.SHA1Instance.NeedsRehash("anything")); - Assert.False(HashAlgorithmPasswordProtection.MD5Instance.NeedsRehash("anything")); - } } diff --git a/Projects/UOContent.Tests/Tests/Gumps/TestGumps/DynamicTestGump.cs b/Projects/UOContent.Tests/Tests/Gumps/TestGumps/DynamicTestGump.cs index fd3dafc56..998e4c537 100644 --- a/Projects/UOContent.Tests/Tests/Gumps/TestGumps/DynamicTestGump.cs +++ b/Projects/UOContent.Tests/Tests/Gumps/TestGumps/DynamicTestGump.cs @@ -6,8 +6,6 @@ public class DynamicTestGump : DynamicGump { private readonly string _petName; - public bool HasVisualElementsForTest => HasVisualElements; - public DynamicTestGump(string petName) : base(50, 50) { _petName = petName; diff --git a/Projects/UOContent.Tests/Tests/Gumps/TestGumps/EmptyTestGumps.cs b/Projects/UOContent.Tests/Tests/Gumps/TestGumps/EmptyTestGumps.cs deleted file mode 100644 index b99de1700..000000000 --- a/Projects/UOContent.Tests/Tests/Gumps/TestGumps/EmptyTestGumps.cs +++ /dev/null @@ -1,40 +0,0 @@ -using Server.Gumps; - -namespace Server.Tests.Gumps; - -public sealed class EmptyLegacyTestGump : Gump -{ - public bool HasVisualElementsForTest => HasVisualElements; - - public EmptyLegacyTestGump() : base(0, 0) - { - } -} - -public sealed class EmptyDynamicTestGump : DynamicGump -{ - public bool HasVisualElementsForTest => HasVisualElements; - - public EmptyDynamicTestGump() : base(0, 0) - { - } - - protected override void BuildLayout(ref DynamicGumpBuilder builder) - { - builder.AddPage(); - } -} - -public sealed class EmptyStaticTestGump : StaticGump -{ - public bool HasVisualElementsForTest => HasVisualElements; - - public EmptyStaticTestGump() : base(0, 0) - { - } - - protected override void BuildLayout(ref StaticGumpBuilder builder) - { - builder.SetNoClose(); - } -} diff --git a/Projects/UOContent.Tests/Tests/Gumps/TestGumps/LegacyTestGump.cs b/Projects/UOContent.Tests/Tests/Gumps/TestGumps/LegacyTestGump.cs index e45514c38..668d7e8ae 100644 --- a/Projects/UOContent.Tests/Tests/Gumps/TestGumps/LegacyTestGump.cs +++ b/Projects/UOContent.Tests/Tests/Gumps/TestGumps/LegacyTestGump.cs @@ -4,8 +4,6 @@ namespace Server.Tests.Gumps; public sealed class LegacyTestGump : Gump { - public bool HasVisualElementsForTest => HasVisualElements; - public LegacyTestGump(string petName) : base(50, 50) { Serial = (Serial)0x123; diff --git a/Projects/UOContent.Tests/Tests/Gumps/TestGumps/StaticTestGump.cs b/Projects/UOContent.Tests/Tests/Gumps/TestGumps/StaticTestGump.cs index 66d80c963..ea67720f5 100644 --- a/Projects/UOContent.Tests/Tests/Gumps/TestGumps/StaticTestGump.cs +++ b/Projects/UOContent.Tests/Tests/Gumps/TestGumps/StaticTestGump.cs @@ -4,8 +4,6 @@ namespace Server.Tests.Gumps; public class StaticTestGump : StaticGump { - public bool HasVisualElementsForTest => HasVisualElements; - public StaticTestGump() : base(50, 50) { Serial = (Serial)0x123; diff --git a/Projects/UOContent.Tests/Tests/Gumps/TestLayoutGumps.cs b/Projects/UOContent.Tests/Tests/Gumps/TestLayoutGumps.cs index 804408fc7..7cd8e67d1 100644 --- a/Projects/UOContent.Tests/Tests/Gumps/TestLayoutGumps.cs +++ b/Projects/UOContent.Tests/Tests/Gumps/TestLayoutGumps.cs @@ -73,32 +73,6 @@ public class TestLayoutGumps AssertThat.Equal(writer.Span, packet); } - [Fact] - public void TestEmptyGumpsHaveNoVisualElements() - { - Assert.False(Compile(new EmptyLegacyTestGump()).HasVisualElementsForTest); - Assert.False(Compile(new EmptyDynamicTestGump()).HasVisualElementsForTest); - Assert.False(Compile(new EmptyStaticTestGump()).HasVisualElementsForTest); - Assert.False(Compile(new EmptyStaticTestGump()).HasVisualElementsForTest); - } - - [Fact] - public void TestVisibleGumpsHaveVisualElements() - { - Assert.True(Compile(new LegacyTestGump("Test")).HasVisualElementsForTest); - Assert.True(Compile(new DynamicTestGump("Test")).HasVisualElementsForTest); - Assert.True(Compile(new StaticTestGump()).HasVisualElementsForTest); - Assert.True(Compile(new StaticTestGump()).HasVisualElementsForTest); - } - - private static T Compile(T gump) where T : BaseGump - { - var buffer = GC.AllocateUninitializedArray(512); - var writer = new SpanWriter(buffer); - gump.Compile(ref writer); - return gump; - } - private static void InternalTestStaticGump(ReadOnlySpan expectedLayout, StaticGump staticGump, string[] strings) where T : StaticGump { diff --git a/Projects/UOContent.Tests/Tests/Items/TreasureChests/TreasureMapChestLiftTests.cs b/Projects/UOContent.Tests/Tests/Items/TreasureChests/TreasureMapChestLiftTests.cs deleted file mode 100644 index f9b8763e7..000000000 --- a/Projects/UOContent.Tests/Tests/Items/TreasureChests/TreasureMapChestLiftTests.cs +++ /dev/null @@ -1,111 +0,0 @@ -using Server; -using Server.Items; -using Server.Mobiles; -using Server.Tests; -using Xunit; - -namespace UOContent.Tests; - -[Collection("Sequential UOContent Tests")] -public class TreasureMapChestLiftTests -{ - // Coordinates chosen to avoid overlap with Tracking (1000-4000, 1000-4000) and - // DetectHidden (1000-2400, 500) test areas. - - [Fact] - public void PartialLift_MarksSplitRemainderAsLifted() - { - using var rng = new PredictableRandom(10); // RandomDouble() = 0.5, no spawn roll fires - var map = Map.Felucca; - var location = new Point3D(5000, 600, 0); - var player = CreatePlayerMobile(map, location); - var chest = new TreasureMapChest(1); - - try - { - chest.MoveToWorld(location, map); - chest.Locked = false; - - var gold = FindGold(chest, null); - Assert.NotNull(gold); - - player.Lift(gold, 1, out var rejected, out _); - Assert.False(rejected); - - // The stack split re-adds the remainder as a brand-new item. It must count as - // already lifted, otherwise every 1-coin pull grants a fresh guardian spawn roll. - var remainder = FindGold(chest, gold); - Assert.NotNull(remainder); - Assert.Contains(remainder, chest.Lifted); - Assert.Contains(gold, chest.Lifted); - } - finally - { - player.Holding?.Delete(); - player.Delete(); - chest.Delete(); - } - } - - [Fact] - public void ItemAddedAfterFill_IsMarkedLifted() - { - using var rng = new PredictableRandom(10); - var chest = new TreasureMapChest(1); - var packed = new Gold(500); - - try - { - // Anything entering the chest after the initial fill (packed-back gold, split - // remainders, GM drops) was never part of the original loot and must not - // grant spawn rolls when lifted back out. - chest.DropItem(packed); - - Assert.Contains(packed, chest.Lifted); - } - finally - { - chest.Delete(); - } - } - - [Fact] - public void OriginalFillLoot_IsNotMarkedLifted() - { - using var rng = new PredictableRandom(10); - var chest = new TreasureMapChest(1); - - try - { - // The original loot must stay roll-eligible for its first lift. - Assert.True(chest.Lifted == null || chest.Lifted.Count == 0); - } - finally - { - chest.Delete(); - } - } - - private static Gold FindGold(TreasureMapChest chest, Gold except) - { - var items = chest.Items; - - for (var i = 0; i < items.Count; i++) - { - if (items[i] is Gold gold && gold != except) - { - return gold; - } - } - - return null; - } - - private static PlayerMobile CreatePlayerMobile(Map map, Point3D location) - { - var mobile = new PlayerMobile(World.NewMobile); - mobile.DefaultMobileInit(); - mobile.MoveToWorld(location, map); - return mobile; - } -} diff --git a/Projects/UOContent.Tests/Tests/Mobiles/AI/AcquisitionTests.cs b/Projects/UOContent.Tests/Tests/Mobiles/AI/AcquisitionTests.cs deleted file mode 100644 index 3ee516dcf..000000000 --- a/Projects/UOContent.Tests/Tests/Mobiles/AI/AcquisitionTests.cs +++ /dev/null @@ -1,211 +0,0 @@ -using System; -using System.Collections.Generic; -using Server; -using Server.Mobiles; -using Xunit; - -namespace UOContent.Tests.Mobiles.AI; - -// Pins the reacquire gate and the AcquireOnApproachDelay gradient: every scan re-arms the -// full ReacquireDelay; enemy movement clamps the deadline to the approach delay (Zero = -// prodded scan); an illegal deadline self-heals. -[Collection("Sequential Pathfinding Tests")] -public class AcquisitionTests : IDisposable -{ - private readonly List _created = new(); - - public void Dispose() - { - foreach (var m in _created) - { - m?.Delete(); - } - - _created.Clear(); - } - - private sealed class WildStub : BaseCreature - { - public WildStub() : base(AIType.AI_Melee, FightMode.Closest, 16, 1) => Body = 0xC9; - - public override void GetSpeeds(out double activeSpeed, out double passiveSpeed) - { - activeSpeed = 0.3; - passiveSpeed = 0.6; - } - } - - private sealed class TargetStub : Mobile - { - public TargetStub() => Body = 0x190; - } - - private WildStub Spawn(Map map, Point3D loc) - { - var bc = new WildStub(); - bc.MoveToWorld(loc, map); - bc.AIObject.AITimer?.Stop(); - _created.Add(bc); - return bc; - } - - [Fact] - public void EmptyScan_HonorsReacquireDelay() - { - var map = Map.Maps[1]; - Assert.NotNull(map); - map.GetAverageZ(1500, 1600, out _, out var z, out _); - - var bc = Spawn(map, new Point3D(1500, 1600, (sbyte)z)); - bc.NextReacquireTime = Core.TickCount; - - Assert.False(bc.AIObject.AcquireFocusMob(bc.RangePerception, FightMode.Closest, false, false, true)); - Assert.InRange(bc.NextReacquireTime - Core.TickCount, 5000, 10000); - } - - [Fact] - public void WedgedGate_SelfHeals() - { - var map = Map.Maps[1]; - Assert.NotNull(map); - map.GetAverageZ(1500, 1600, out _, out var z, out _); - - var bc = Spawn(map, new Point3D(1500, 1600, (sbyte)z)); - - var target = new TargetStub(); - target.DefaultMobileInit(); - target.MoveToWorld(new Point3D(1497, 1600, (sbyte)z), map); - _created.Add(target); - - // Illegal deadline (beyond ReacquireDelay): must read as open, not block forever. - bc.NextReacquireTime = Core.TickCount + 60000; - - Assert.True(bc.AIObject.AcquireFocusMob(bc.RangePerception, FightMode.Closest, false, false, true)); - Assert.Equal(target, bc.FocusMob); - } - - [Theory] - [InlineData(false, 5, true)] // an enemy moving inside approach range (10) clamps the deadline - [InlineData(true, 5, false)] // a same-team wild creature is not an enemy — ignored - [InlineData(false, 12, false)] // inside RangePerception but outside approach range — poll only - [InlineData(false, 20, false)] // outside approach range (10) is ignored - public void MovementClampsScanDeadlineOnlyForEnemiesInRange(bool wildMover, int distance, bool notices) - { - var map = Map.Maps[1]; - Assert.NotNull(map); - map.GetAverageZ(1500, 1600, out _, out var z, out _); - - var bc = Spawn(map, new Point3D(1500, 1600, (sbyte)z)); - bc.NextReacquireTime = Core.TickCount + 8000; - - Mobile mover; - if (wildMover) - { - mover = Spawn(map, new Point3D(1500 - distance, 1600, (sbyte)z)); - } - else - { - mover = new TargetStub { Player = true }; - mover.DefaultMobileInit(); - mover.MoveToWorld(new Point3D(1500 - distance, 1600, (sbyte)z), map); - _created.Add(mover); - } - - bc.OnMovement(mover, new Point3D(1400, 1600, (sbyte)z)); - - var remaining = bc.NextReacquireTime - Core.TickCount; - - if (notices) - { - // Clamped to the approach delay (2s), never opened outright. - Assert.InRange(remaining, 1, (long)bc.AcquireOnApproachDelay.TotalMilliseconds); - } - else - { - Assert.True(remaining > 5000); - } - } - - private sealed class InstantStub : BaseCreature - { - public InstantStub() : base(AIType.AI_Melee, FightMode.Closest, 16, 1) => Body = 0xC9; - - public override TimeSpan AcquireOnApproachDelay => TimeSpan.Zero; - - public override void GetSpeeds(out double activeSpeed, out double passiveSpeed) - { - activeSpeed = 0.3; - passiveSpeed = 0.6; - } - } - - [Fact] - public void ZeroApproachDelay_OpensGateImmediately() - { - var map = Map.Maps[1]; - Assert.NotNull(map); - map.GetAverageZ(1500, 1600, out _, out var z, out _); - - var bc = new InstantStub(); - bc.MoveToWorld(new Point3D(1500, 1600, (sbyte)z), map); - bc.AIObject.AITimer?.Stop(); - _created.Add(bc); - bc.NextReacquireTime = Core.TickCount + 8000; - - var mover = new TargetStub { Player = true }; - mover.DefaultMobileInit(); - mover.MoveToWorld(new Point3D(1495, 1600, (sbyte)z), map); - _created.Add(mover); - - bc.OnMovement(mover, new Point3D(1400, 1600, (sbyte)z)); - - // Zero = the gate opens and the AI is prodded to think now; no direct engage. - Assert.True(Core.TickCount - bc.NextReacquireTime >= 0); - Assert.Null(bc.Combatant); - Assert.True(bc.AIObject.AITimer.Running); - } - - [Fact] - public void RepeatedMovement_DoesNotShortenBelowApproachDelay() - { - var map = Map.Maps[1]; - Assert.NotNull(map); - map.GetAverageZ(1500, 1600, out _, out var z, out _); - - var bc = Spawn(map, new Point3D(1500, 1600, (sbyte)z)); - bc.NextReacquireTime = Core.TickCount + 8000; - - var mover = new TargetStub { Player = true }; - mover.DefaultMobileInit(); - mover.MoveToWorld(new Point3D(1495, 1600, (sbyte)z), map); - _created.Add(mover); - - bc.OnMovement(mover, new Point3D(1400, 1600, (sbyte)z)); - var afterFirst = bc.NextReacquireTime; - - bc.OnMovement(mover, new Point3D(1496, 1600, (sbyte)z)); - - Assert.Equal(afterFirst, bc.NextReacquireTime); - } - - [Fact] - public void SuccessfulAcquire_HoldsFullDelay() - { - var map = Map.Maps[1]; - Assert.NotNull(map); - map.GetAverageZ(1500, 1600, out _, out var z, out _); - - var bc = Spawn(map, new Point3D(1500, 1600, (sbyte)z)); - - var target = new TargetStub(); - target.DefaultMobileInit(); - target.MoveToWorld(new Point3D(1497, 1600, (sbyte)z), map); - _created.Add(target); - - bc.NextReacquireTime = Core.TickCount; - - Assert.True(bc.AIObject.AcquireFocusMob(bc.RangePerception, FightMode.Closest, false, false, true)); - Assert.Equal(target, bc.FocusMob); - Assert.True(bc.NextReacquireTime - Core.TickCount > 5000); - } -} diff --git a/Projects/UOContent.Tests/Tests/Mobiles/AI/ApproachTargetTests.cs b/Projects/UOContent.Tests/Tests/Mobiles/AI/ApproachTargetTests.cs index a952252a6..923f4aff9 100644 --- a/Projects/UOContent.Tests/Tests/Mobiles/AI/ApproachTargetTests.cs +++ b/Projects/UOContent.Tests/Tests/Mobiles/AI/ApproachTargetTests.cs @@ -40,7 +40,7 @@ public class ApproachTargetTests for (var i = 0; i < maxTicks; i++) { ai.NextMove = 0; - ai.WalkMobileRange(target, 1, 1, 2); + ai.WalkMobileRange(target, 1, false, 1, 2); if (bc.InRange(target, arriveDist)) { return true; @@ -123,7 +123,7 @@ public class ApproachTargetTests for (var i = 0; i < 200; i++) { ai.NextMove = 0; - ai.MoveTo(target, 1); + ai.MoveTo(target, false, 1); if (bc.InRange(target, 1)) { arrived = true; @@ -154,7 +154,7 @@ public class ApproachTargetTests for (var i = 0; i < 60; i++) { ai.NextMove = 0; - ai.MoveTo(target, 1); + ai.MoveTo(target, true, 1); // Target walks west every other tick for its first several steps, then stops, // so a same-speed chaser eventually closes the gap. @@ -214,7 +214,7 @@ public class ApproachTargetTests for (var i = 0; i < 120; i++) { ai.NextMove = 0; - ai.MoveTo(target, 1); + ai.MoveTo(target, false, 1); } // After giving up, the creature must idle (not oscillate) while the goal is still. @@ -223,7 +223,7 @@ public class ApproachTargetTests for (var i = 0; i < 20; i++) { ai.NextMove = 0; - ai.MoveTo(target, 1); + ai.MoveTo(target, false, 1); if (bc.Location != idleStart) { stayedIdle = false; diff --git a/Projects/UOContent.Tests/Tests/Mobiles/AI/GuardFollowTests.cs b/Projects/UOContent.Tests/Tests/Mobiles/AI/GuardFollowTests.cs deleted file mode 100644 index db5225359..000000000 --- a/Projects/UOContent.Tests/Tests/Mobiles/AI/GuardFollowTests.cs +++ /dev/null @@ -1,96 +0,0 @@ -using System.Collections.Generic; -using Server; -using Server.Mobiles; -using Xunit; - -namespace UOContent.Tests.Mobiles.AI; - -// Guard-following may pathfind, so this shares the pathfinding collection. -[Collection("Sequential Pathfinding Tests")] -public class GuardFollowTests -{ - [Fact] - public void GuardFollow_StepsTowardMaster_AndRegistersMoveIntent() - { - var map = Map.Maps[1]; - Assert.NotNull(map); - map.GetAverageZ(1500, 1600, out _, out var z, out _); - - var master = new PlayerMobile(World.NewMobile); - master.DefaultMobileInit(); - master.MoveToWorld(new Point3D(1494, 1600, (sbyte)z), map); - - var pet = new PetTestStub(); - pet.MoveToWorld(new Point3D(1500, 1600, (sbyte)z), map); // 6 tiles east, open terrain - pet.SetControlMaster(master); - - var ai = pet.AIObject; - ai.AITimer?.Stop(); // drive manually - pet.ControlOrder = OrderType.Guard; - ai.AITimer?.Stop(); // the order change may restart the timer - - var start = pet.Location; - ai.NextMove = 0; - ai.Obey(); - - var moved = pet.Location != start; - var hasIntent = ai.TryGetMoveWake(out _); - var currentSpeed = pet.CurrentSpeed; - var currentMoveSpeed = pet.CurrentMoveSpeed; - - pet.Delete(); - master.Delete(); - - Assert.True(moved, "a guarding pet beyond guard range must step toward its master"); - // Without a move intent, guard-following only steps on the think grid. - Assert.True(hasIntent, "guard-following must register a move intent"); - - // AOS return sprint on both clocks; the per-step speed flip must not undo it. - Assert.Equal(0.1, currentSpeed); - Assert.Equal(0.1, currentMoveSpeed); - } - - [Fact] - public void GuardReturn_PreAOS_RunsActive() - { - var previous = Core.Expansion; - - try - { - Core.Expansion = Expansion.UOR; - - var map = Map.Maps[1]; - Assert.NotNull(map); - map.GetAverageZ(1500, 1600, out _, out var z, out _); - - var master = new PlayerMobile(World.NewMobile); - master.DefaultMobileInit(); - master.MoveToWorld(new Point3D(1494, 1600, (sbyte)z), map); - - var pet = new PetTestStub(); - pet.MoveToWorld(new Point3D(1500, 1600, (sbyte)z), map); - pet.SetControlMaster(master); - - var ai = pet.AIObject; - ai.AITimer?.Stop(); - pet.ControlOrder = OrderType.Guard; - ai.AITimer?.Stop(); - pet.SetCurrentSpeedToPassive(); // a stale passive state must not persist - - ai.NextMove = 0; - ai.Obey(); - - var currentSpeed = pet.CurrentSpeed; - - pet.Delete(); - master.Delete(); - - // No sprint pre-AOS: the return runs active. - Assert.Equal(0.2, currentSpeed); - } - finally - { - Core.Expansion = previous; - } - } -} diff --git a/Projects/UOContent.Tests/Tests/Mobiles/AI/GuardOrderTests.cs b/Projects/UOContent.Tests/Tests/Mobiles/AI/GuardOrderTests.cs deleted file mode 100644 index 7d572786a..000000000 --- a/Projects/UOContent.Tests/Tests/Mobiles/AI/GuardOrderTests.cs +++ /dev/null @@ -1,137 +0,0 @@ -using System; -using System.Collections.Generic; -using Server; -using Server.Mobiles; -using Xunit; - -namespace UOContent.Tests.Mobiles.AI; - -// A guarding pet fights without leaving the Guard order, retargets toward the master's -// closest aggressor, and stands down when nothing threatens. Scene: the open -// (1495..1500, 1600) Trammel segment; targets are adjacent so no pathfinding runs. -[Collection("Sequential UOContent Tests")] -public class GuardOrderTests : IDisposable -{ - private readonly List _created = new(); - - private sealed class AggressorStub : Mobile - { - public AggressorStub() => Body = 0xC9; - } - - public void Dispose() - { - foreach (var m in _created) - { - m?.Delete(); - } - - _created.Clear(); - } - - private (PlayerMobile master, PetTestStub pet) SpawnGuardingPet(out Map map, out int z) - { - map = Map.Maps[1]; - Assert.NotNull(map); - map.GetAverageZ(1500, 1600, out _, out z, out _); - - var master = new PlayerMobile(World.NewMobile); - master.DefaultMobileInit(); - master.MoveToWorld(new Point3D(1500, 1600, (sbyte)z), map); - _created.Add(master); - - var pet = new PetTestStub(); - pet.MoveToWorld(new Point3D(1499, 1600, (sbyte)z), map); - pet.SetControlMaster(master); - _created.Add(pet); - - pet.AIObject.AITimer?.Stop(); // drive manually - pet.ControlOrder = OrderType.Guard; - pet.AIObject.AITimer?.Stop(); // the order change restarts the timer - - return (master, pet); - } - - private AggressorStub SpawnAggressor(PetTestStub pet, Point3D loc, Mobile attacking) - { - var aggr = new AggressorStub(); - aggr.MoveToWorld(loc, pet.Map); - _created.Add(aggr); - - // Setup guard: the scene must stay LOS-clear and the combatant must not be vetoed. - Assert.True(pet.InLOS(aggr), $"no LOS from pet to aggressor at {loc}"); - - if (attacking != null) - { - aggr.Combatant = attacking; - Assert.Same(attacking, aggr.Combatant); - } - - return aggr; - } - - [Fact] - public void GuardEngage_KeepsGuardOrder() - { - var (master, pet) = SpawnGuardingPet(out _, out var z); - var aggr = SpawnAggressor(pet, new Point3D(1498, 1600, (sbyte)z), master); - - pet.AIObject.Obey(); - - Assert.Same(aggr, pet.Combatant); - Assert.Equal(OrderType.Guard, pet.ControlOrder); - Assert.Equal(OrderType.Guard, pet.AIObject.PersistentOrder); - } - - [Fact] - public void Guard_RetargetsToAggressorClosestToMaster() - { - var (master, pet) = SpawnGuardingPet(out _, out var z); - var far = SpawnAggressor(pet, new Point3D(1495, 1600, (sbyte)z), master); - var near = SpawnAggressor(pet, new Point3D(1498, 1600, (sbyte)z), master); - - pet.Combatant = far; // already fighting the far aggressor - - pet.AIObject.Obey(); - - Assert.Same(near, pet.Combatant); // defends the master, not the current fight - Assert.Equal(OrderType.Guard, pet.ControlOrder); - } - - [Fact] - public void ExplicitAttack_ResumesGuard_WithoutChainingIntoAttack() - { - var (master, pet) = SpawnGuardingPet(out _, out var z); - - // Explicit kill order on a target that then becomes invalid. - var victim = SpawnAggressor(pet, new Point3D(1498, 1600, (sbyte)z), null); - pet.ControlTarget = victim; - pet.ControlOrder = OrderType.Attack; - victim.Hidden = true; - - // A second aggressor is still after the master; FightMode.Closest would chain it. - var aggr2 = SpawnAggressor(pet, new Point3D(1497, 1600, (sbyte)z), master); - - pet.AIObject.Obey(); // attack completes -> resume the persistent Guard - - Assert.Equal(OrderType.Guard, pet.ControlOrder); - - pet.AIObject.Obey(); // the guard scan engages the remaining aggressor in-order - - Assert.Same(aggr2, pet.Combatant); - Assert.Equal(OrderType.Guard, pet.ControlOrder); - } - - [Fact] - public void PeacefulGuard_StandsDown() - { - var (_, pet) = SpawnGuardingPet(out _, out _); - Assert.True(pet.Warmode); // the guard order opens in war stance - - pet.AIObject.Obey(); // nothing to guard against - - Assert.False(pet.Warmode); - Assert.Null(pet.Combatant); - Assert.Null(pet.FocusMob); - } -} diff --git a/Projects/UOContent.Tests/Tests/Mobiles/AI/MoveSpeedTests.cs b/Projects/UOContent.Tests/Tests/Mobiles/AI/MoveSpeedTests.cs deleted file mode 100644 index 7d9243a29..000000000 --- a/Projects/UOContent.Tests/Tests/Mobiles/AI/MoveSpeedTests.cs +++ /dev/null @@ -1,219 +0,0 @@ -using System; -using System.Collections.Generic; -using Server; -using Server.Mobiles; -using Xunit; - -namespace UOContent.Tests.Mobiles.AI; - -// Pins the CurrentMoveSpeed classification (verbatim active/passive maps to the matching -// move value; bespoke stays fused), SetSpeed's one-clock guarantee, and the v22 tail. -[Collection("Sequential UOContent Tests")] -public class MoveSpeedTests : IDisposable -{ - // Delete spawned stubs so they don't linger in the shared static World. - private readonly List _created = new(); - - public void Dispose() - { - for (var i = 0; i < _created.Count; i++) - { - _created[i].Delete(); - } - } - - private sealed class SpeedStub : BaseCreature - { - // Stands in for the npc-speeds table (unconfigured in the test fixture). - public double TableActiveMove; - public double TablePassiveMove; - - public SpeedStub() : base(AIType.AI_Animal) => Body = 0xC9; - - public SpeedStub(Serial serial) : base(serial) => Body = 0xC9; - - public override void GetSpeeds(out double activeSpeed, out double passiveSpeed) - { - activeSpeed = 0.3; - passiveSpeed = 0.6; - } - - public override void GetMoveSpeeds(out double activeMoveSpeed, out double passiveMoveSpeed) - { - activeMoveSpeed = TableActiveMove; - passiveMoveSpeed = TablePassiveMove; - } - } - - private SpeedStub NewCreature() - { - var bc = new SpeedStub(); - _created.Add(bc); - return bc; - } - - [Fact] - public void MoveSpeeds_InheritThinkValues_ByDefault() - { - var bc = NewCreature(); - - Assert.Equal(0.3, bc.ActiveMoveSpeed); - Assert.Equal(0.6, bc.PassiveMoveSpeed); - Assert.Equal(bc.CurrentSpeed, bc.CurrentMoveSpeed); - } - - [Fact] - public void CurrentMoveSpeed_ResolvesPerMode_WhenOverridden() - { - var bc = NewCreature(); - bc.SetMoveSpeed(0.45, 0.9); - - // SetSpeed left the creature passive; the think clock is untouched. - Assert.Equal(0.6, bc.CurrentSpeed); - Assert.Equal(0.9, bc.CurrentMoveSpeed); - - bc.SetCurrentSpeedToActive(); - Assert.Equal(0.3, bc.CurrentSpeed); - Assert.Equal(0.45, bc.CurrentMoveSpeed); - } - - [Fact] - public void CurrentMoveSpeed_BespokePace_StaysFused() - { - var bc = NewCreature(); - bc.SetMoveSpeed(0.45, 0.9); - - // Neither think value verbatim, so both clocks run it. - bc.CurrentSpeed = 0.11; - Assert.Equal(0.11, bc.CurrentMoveSpeed); - } - - [Fact] - public void SetSpeed_ClearsMoveOverrides() - { - var bc = NewCreature(); - bc.SetMoveSpeed(0.45, 0.9); - - bc.SetSpeed(0.2, 0.4); - - Assert.Equal(0.2, bc.ActiveMoveSpeed); - Assert.Equal(0.4, bc.PassiveMoveSpeed); - } - - [Fact] - public void NonPositiveMoveSpeed_ClearsThatOverride() - { - var bc = NewCreature(); - bc.SetMoveSpeed(0.45, 0.9); - - bc.ActiveMoveSpeed = 0; - - Assert.Equal(0.3, bc.ActiveMoveSpeed); // inheriting again - Assert.Equal(0.9, bc.PassiveMoveSpeed); // other override untouched - } - - [Fact] - public void ScaleMoveSpeed_ScalesOverrides_LeavesInheritAlone() - { - var bc = NewCreature(); - bc.ActiveMoveSpeed = 0.6; // passive left inheriting - - bc.ScaleMoveSpeed(1.0 / 1.2); - - Assert.Equal(0.5, bc.ActiveMoveSpeed); - Assert.Equal(bc.PassiveSpeed, bc.PassiveMoveSpeed); // still inheriting, not 0 * scalar - } - - [Fact] - public void Herding_DrivesMoveClock_ThinkUntouched() - { - var bc = NewCreature(); // think 0.3/0.6, passive - bc.SetMoveSpeed(0.45, 1.05); - - bc.TargetLocation = new Point2D(10, 10); - - Assert.Equal(0.6, bc.CurrentSpeed); // think clock unaffected by herding - Assert.Equal(0.3, bc.CurrentMoveSpeed); // fixed herding pace, not 1.05 - - bc.TargetLocation = null; - Assert.Equal(1.05, bc.CurrentMoveSpeed); - } - - [Fact] - public void SnapSpeedsToTable_UndoesScalingDrift_KeepsTunedValues() - { - var bc = NewCreature(); - bc.TableActiveMove = 0.45; - bc.TablePassiveMove = 0.9; - bc.SetMoveSpeed(0.45, 0.9); - - // 0.45 and 0.9 do not survive /1.2 then *1.2 bit-exactly. - bc.ScaleMoveSpeed(1.0 / 1.2); - bc.ScaleMoveSpeed(1.2); - Assert.NotEqual(0.45, bc.ActiveMoveSpeed); - - bc.SnapSpeedsToTable(); - Assert.Equal(0.45, bc.ActiveMoveSpeed); - Assert.Equal(0.9, bc.PassiveMoveSpeed); - - // A hand-tuned value is nowhere near the epsilon and must keep. - bc.SetMoveSpeed(0.7, 0.9); - bc.SnapSpeedsToTable(); - Assert.Equal(0.7, bc.ActiveMoveSpeed); - } - - [Fact] - public void Migration_MatchingThinkSpeeds_AdoptTableMoveValues() - { - var bc = NewCreature(); // think 0.3/0.6, matching its table entry - bc.TableActiveMove = 0.45; - bc.TablePassiveMove = 0.9; - - bc.MigrateMoveSpeeds(); - - Assert.Equal(0.45, bc.ActiveMoveSpeed); - Assert.Equal(0.9, bc.PassiveMoveSpeed); - } - - [Fact] - public void Migration_TunedThinkSpeeds_KeepInheriting() - { - var bc = NewCreature(); - bc.SetSpeed(0.35, 0.6); // hand-tuned: no longer matches the table entry - bc.TableActiveMove = 0.45; - bc.TablePassiveMove = 0.9; - - bc.MigrateMoveSpeeds(); - - Assert.Equal(0.35, bc.ActiveMoveSpeed); - Assert.Equal(0.6, bc.PassiveMoveSpeed); - } - - [Theory] - [InlineData(true)] - [InlineData(false)] - public void MoveSpeedOverrides_SurviveSerialization(bool overridden) - { - var bc = NewCreature(); - if (overridden) - { - bc.SetMoveSpeed(0.45, 0.9); - } - - var writer = new BufferWriter(true); - bc.Serialize(writer); - - var buffer = new byte[writer.Position]; - writer.Buffer.AsSpan(0, (int)writer.Position).CopyTo(buffer); - - var copy = new SpeedStub(World.NewMobile); - _created.Add(copy); - var reader = new BufferReader(buffer); - copy.Deserialize(reader); - - // The v22 tail is the last block; exact consumption catches any offset mistake. - Assert.Equal(buffer.Length, reader.Position); - Assert.Equal(overridden ? 0.45 : 0.3, copy.ActiveMoveSpeed); - Assert.Equal(overridden ? 0.9 : 0.6, copy.PassiveMoveSpeed); - } -} diff --git a/Projects/UOContent.Tests/Tests/Mobiles/AI/PetPacingTests.cs b/Projects/UOContent.Tests/Tests/Mobiles/AI/PetPacingTests.cs deleted file mode 100644 index 217049b3d..000000000 --- a/Projects/UOContent.Tests/Tests/Mobiles/AI/PetPacingTests.cs +++ /dev/null @@ -1,222 +0,0 @@ -using System; -using System.Collections.Generic; -using Server; -using Server.Mobiles; -using Xunit; - -namespace UOContent.Tests.Mobiles.AI; - -// Pet order handlers own the speed clocks; combat chases and herding keep their own pacing. -[Collection("Sequential UOContent Tests")] -public class PetPacingTests : IDisposable -{ - private readonly List _created = new(); - - private (PlayerMobile master, PetTestStub pet) Spawn(Point3D masterLoc, Point3D petLoc) - { - var pair = PetTestSetup.SpawnControlledPet(masterLoc, petLoc); - _created.Add(pair.master); - _created.Add(pair.pet); - return pair; - } - - public void Dispose() - { - foreach (var m in _created) - { - m?.Delete(); - } - - _created.Clear(); - } - - // Movement orders run active, resting orders run passive; the move clock follows. - [Fact] - public void OrderIssue_SetsThinkClock() - { - var (master, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1001, 1000, 0)); - pet.SetMoveSpeed(0.3, 0.9); - pet.SetCurrentSpeedToPassive(); - - pet.ControlOrder = OrderType.Come; - Assert.Equal(0.2, pet.CurrentSpeed); - Assert.Equal(0.3, pet.CurrentMoveSpeed); // verbatim active -> activeMove - - pet.ControlOrder = OrderType.Stay; - Assert.Equal(0.4, pet.CurrentSpeed); - Assert.Equal(0.9, pet.CurrentMoveSpeed); - - pet.ControlTarget = master; - pet.ControlOrder = OrderType.Follow; - Assert.Equal(0.2, pet.CurrentSpeed); - - pet.ControlOrder = OrderType.Guard; - Assert.Equal(0.2, pet.CurrentSpeed); - } - - // AOS: following the master sprints at a bespoke 0.1 on both clocks. - [Fact] - public void FollowMaster_ObeySprints() - { - var (master, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1001, 1000, 0)); - pet.SetMoveSpeed(0.3, 0.9); - pet.AIObject.AITimer?.Stop(); - - pet.ControlTarget = master; - pet.ControlOrder = OrderType.Follow; // fixture era is EJ - pet.AIObject.Obey(); - - Assert.Equal(0.1, pet.CurrentSpeed); - Assert.Equal(0.1, pet.CurrentMoveSpeed); - } - - // At the master's side a guarding pet stays active: no stale-warmode passive, no sprint. - [Fact] - public void GuardAtMastersSide_IsActive() - { - var (_, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1001, 1000, 0)); - pet.SetMoveSpeed(0.3, 0.9); - pet.AIObject.AITimer?.Stop(); - pet.SetCurrentSpeedToPassive(); - - pet.ControlOrder = OrderType.Guard; - pet.AIObject.Obey(); // nothing to guard against, master adjacent - - Assert.Equal(0.2, pet.CurrentSpeed); - Assert.Equal(0.3, pet.CurrentMoveSpeed); - } - - // A pet chasing a combatant keeps the move table. - [Fact] - public void CombatChasingPet_KeepsMoveTable() - { - var (_, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1001, 1000, 0)); - var target = new PetTestStub(); - target.MoveToWorld(new Point3D(1003, 1000, 0), Map.Felucca); - _created.Add(target); - - pet.SetMoveSpeed(0.3, 0.9); - pet.ControlOrder = OrderType.Guard; - pet.Combatant = target; - pet.SetCurrentSpeedToActive(); - - Assert.Equal(0.3, pet.CurrentMoveSpeed); - } - - // Herding overrides order pacing. - [Fact] - public void HerdedObeyingPet_KeepsHerdingPace() - { - var (_, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1001, 1000, 0)); - pet.SetMoveSpeed(0.45, 0.9); - pet.SetCurrentSpeedToPassive(); - - pet.TargetLocation = new Point2D(1010, 1010); - - Assert.Equal(0.3, pet.CurrentMoveSpeed); // fixed herding pace - } - - private sealed class ThinkProbe : PetTestStub - { - public int Thinks; - - public override void OnThink() - { - Thinks++; - base.OnThink(); - } - } - - private (PlayerMobile master, ThinkProbe pet) SpawnProbe() - { - var master = new PlayerMobile(World.NewMobile); - master.DefaultMobileInit(); - master.MoveToWorld(new Point3D(1000, 1000, 0), Map.Felucca); - _created.Add(master); - - var pet = new ThinkProbe(); - pet.MoveToWorld(new Point3D(1001, 1000, 0), Map.Felucca); - pet.SetControlMaster(master); - _created.Add(pet); - - return (master, pet); - } - - // Advances time in 8ms lockstep so the wheel and Core.TickCount stay in sync. - private static void RunFor(long ms) - { - var deadline = Core._tickCount + ms; - - while (Core._tickCount < deadline) - { - Core._tickCount += 8; - Timer.Slice(Core._tickCount); - } - } - - private static bool RunUntil(Func condition, long maxMs) - { - var deadline = Core._tickCount + maxMs; - - while (Core._tickCount < deadline) - { - if (condition()) - { - return true; - } - - Core._tickCount += 8; - Timer.Slice(Core._tickCount); - } - - return condition(); - } - - // Runs past the spawn stagger; returns right after a think with the next 0.4s away. - private ThinkProbe SettledProbe(out PlayerMobile master) - { - Core._tickCount = 0; - Timer.Init(0); - - var (m, pet) = SpawnProbe(); - master = m; - pet.ForceIdle = true; // no wandering; pure cadence - pet.ControlOrder = OrderType.Stay; - - var settled = RunUntil(() => pet.Thinks >= 2, 8000); - Assert.True(settled, "the AI must reach a steady think cadence"); - - return pet; - } - - [Fact] - public void OrderChange_WakesStaleThinkTimer() - { - var pet = SettledProbe(out var master); - var thinksBefore = pet.Thinks; - - RunFor(200); // mid-wait, next think ~200ms out - Assert.Equal(thinksBefore, pet.Thinks); - - pet.ControlTarget = master; - pet.ControlOrder = OrderType.Follow; - - RunFor(80); - Assert.True(pet.Thinks > thinksBefore, "a fresh order must wake the AI promptly"); - } - - [Fact] - public void SpeedUp_ReschedulesPendingWake() - { - var pet = SettledProbe(out _); - var thinksBefore = pet.Thinks; - - RunFor(200); // mid-wait, next think ~200ms out - Assert.Equal(thinksBefore, pet.Thinks); - - pet.CurrentSpeed = 0.1; - - RunFor(120); - Assert.True(pet.Thinks > thinksBefore, "a speed-up must reschedule the pending wake"); - } -} diff --git a/Projects/UOContent.Tests/Tests/Mobiles/AI/RunFlagTests.cs b/Projects/UOContent.Tests/Tests/Mobiles/AI/RunFlagTests.cs deleted file mode 100644 index a421aaba2..000000000 --- a/Projects/UOContent.Tests/Tests/Mobiles/AI/RunFlagTests.cs +++ /dev/null @@ -1,162 +0,0 @@ -using System.Collections.Generic; -using Server; -using Server.Mobiles; -using Xunit; - -namespace UOContent.Tests.Mobiles.AI; - -// The Running bit is derived from the step pace: a step shorter than the client's walk -// interpolation (400ms on foot, 200ms mounted/flying) is flagged as a run. -[Collection("Sequential Pathfinding Tests")] -public class RunFlagTests : System.IDisposable -{ - private readonly List _created = new(); - - private PetTestStub Spawn(double activeMove) - { - var pet = new PetTestStub(); - pet.MoveToWorld(new Point3D(1000, 1000, 0), Map.Felucca); - pet.AIObject.AITimer?.Stop(); - pet.SetMoveSpeed(activeMove, activeMove * 3); - pet.SetCurrentSpeedToActive(); - pet.LastMoveTime = Core.TickCount; // mid-cadence unless a test says otherwise - _created.Add(pet); - return pet; - } - - public void Dispose() - { - foreach (var m in _created) - { - m?.Delete(); - } - - _created.Clear(); - } - - [Theory] - [InlineData(0.3, true)] - [InlineData(0.125, true)] - [InlineData(0.4, false)] - [InlineData(0.45, false)] - [InlineData(1.05, false)] - public void FootCreature_RunsOnlyWhenFasterThanWalk(double activeMove, bool expected) - { - var pet = Spawn(activeMove); - - Assert.Equal(activeMove, pet.CurrentMoveSpeed); - Assert.Equal(expected, pet.AIObject.ShouldRun()); - } - - [Theory] - [InlineData(0.3, false)] - [InlineData(0.15, true)] - public void FlyingCreature_UsesMountThresholds(double activeMove, bool expected) - { - var pet = Spawn(activeMove); - pet.Flying = true; - - Assert.Equal(expected, pet.AIObject.ShouldRun()); - } - - [Fact] - public void BadlyHurt_SlowsBelowWalk_DropsToWalk() - { - var pet = Spawn(0.35); - Assert.True(pet.AIObject.ShouldRun()); - - // The hurt inflation is on the observed step pace, so the flag follows it. - pet.SetHits(100); - pet.Hits = 5; - pet.SetStam(100); - pet.Stam = 5; - - Assert.False(pet.AIObject.ShouldRun()); - } - - [Theory] - [InlineData(0.3, true)] - [InlineData(0.45, false)] - public void DoMove_StampsRunningBit(double activeMove, bool expected) - { - var map = Map.Maps[1]; - Assert.NotNull(map); - map.GetAverageZ(1500, 1600, out _, out var z, out _); - - var pet = Spawn(activeMove); - pet.MoveToWorld(new Point3D(1500, 1600, (sbyte)z), map); - - var ai = pet.AIObject; - ai.NextMove = 0; - var start = pet.Location; - - Assert.True(ai.DoMove(Direction.West)); - Assert.NotEqual(start, pet.Location); - Assert.Equal(expected, (pet.Direction & Direction.Running) != 0); - } - - // An isolated step (after standing at least a walk interval) renders alone and darts - // if run-flagged, so it walks; continuing cadences and true sprinters keep the flag. - [Fact] - public void IsolatedStep_DropsToWalk() - { - var pet = Spawn(0.3); - pet.LastMoveTime = Core.TickCount - 1000; - - Assert.False(pet.AIObject.ShouldRun()); - } - - [Fact] - public void IsolatedStep_SprinterStillRuns() - { - var pet = Spawn(0.125); - pet.LastMoveTime = Core.TickCount - 1000; - - Assert.True(pet.AIObject.ShouldRun()); - } - - [Fact] - public void StallDoesNotBankCatchUpSteps() - { - var map = Map.Maps[1]; - Assert.NotNull(map); - map.GetAverageZ(1500, 1600, out _, out var z, out _); - - var pet = Spawn(0.3); - pet.MoveToWorld(new Point3D(1500, 1600, (sbyte)z), map); - - var ai = pet.AIObject; - ai.NextMove = Core.TickCount - 1000; - - Assert.True(ai.DoMove(Direction.West)); - - // A stall must restart the cadence at full pace: banked catch-up steps - // release as a burst the client renders as a sprint/teleport. - Assert.False(ai.CanMoveNow(out _)); - Assert.True(ai.NextMove - Core.TickCount > 250); - } - - [Fact] - public void LateStepDoesNotEarnAQuickerFollowUp() - { - var map = Map.Maps[1]; - Assert.NotNull(map); - map.GetAverageZ(1500, 1600, out _, out var z, out _); - - var pet = Spawn(0.3); - pet.MoveToWorld(new Point3D(1500, 1600, (sbyte)z), map); - - pet.Warmode = true; // keep the active move clock through the step - - var ai = pet.AIObject; - // The step lands 200ms past the budget — under one period, the reactive - // mirroring case (think grid vs budget deadline misalignment). - ai.NextMove = Core.TickCount - 200; - - Assert.True(ai.DoMove(Direction.West)); - - // The debt must not be repaid: a sub-period catch-up step follows ~100ms - // behind and renders as a dart pair beside the player. - Assert.True(ai.NextMove - Core.TickCount > 250); - } -} diff --git a/Projects/UOContent.Tests/Tests/Mobiles/LootingRightsTests.cs b/Projects/UOContent.Tests/Tests/Mobiles/LootingRightsTests.cs deleted file mode 100644 index e02abece7..000000000 --- a/Projects/UOContent.Tests/Tests/Mobiles/LootingRightsTests.cs +++ /dev/null @@ -1,146 +0,0 @@ -using System.Collections.Generic; -using Server.Mobiles; -using Xunit; - -namespace Server.Tests; - -/// -/// Pins the looting-rights rules that the inline damage entry list has to keep producing: the -/// returned stores are sorted by damage descending, the first (least recent) damager takes the -/// 1.25x bonus, the hitsMax band decides who clears the threshold, and a pet's damage is credited -/// to its damage master rather than to the pet. -/// -[Collection("Sequential UOContent Tests")] -public class LootingRightsTests -{ - private class TestMobile : Mobile - { - } - - private class PetMobile : Mobile - { - public Mobile Master { get; set; } - - public override Mobile GetDamageMaster(Mobile damagee) => Master; - } - - // GetLootingRights only ever credits mobiles flagged as players. - private static TestMobile NewPlayer() => new() { Player = true }; - - private static DamageStore FindStore(List rights, Mobile m) - { - for (var i = 0; i < rights.Count; i++) - { - if (rights[i].m_Mobile == m) - { - return rights[i]; - } - } - - return null; - } - - [Fact] - public void TwoPlayerDamagers_SortDescending_AndTheFirstDamagerTakesTheBonus() - { - var victim = new TestMobile(); - var first = NewPlayer(); - var second = NewPlayer(); - - try - { - victim.RegisterDamage(100, first); - victim.RegisterDamage(40, second); // second is the most recent, first is the "first damager" - - // hitsMax < 200 puts the bar at topDamage / 2. - var rights = BaseCreature.GetLootingRights(victim.DamageEntries, 100); - - Assert.Equal(2, rights.Count); - - // Sorted by damage descending. - Assert.True(rights[0].m_Damage >= rights[1].m_Damage); - Assert.Same(first, rights[0].m_Mobile); - Assert.Same(second, rights[1].m_Mobile); - - // The first damager - the least recent entry - gets the 1.25x bonus; nobody else does. - Assert.Equal(125, rights[0].m_Damage); - Assert.Equal(40, rights[1].m_Damage); - - // topDamage 125 / 2 = 62, so 40 is below the bar. - Assert.True(rights[0].m_HasRight); - Assert.False(rights[1].m_HasRight); - } - finally - { - victim.Delete(); - first.Delete(); - second.Delete(); - } - } - - [Fact] - public void HitsMaxBand_MovesTheRightsThreshold() - { - var victim = new TestMobile(); - var first = NewPlayer(); - var second = NewPlayer(); - - try - { - victim.RegisterDamage(100, first); - victim.RegisterDamage(40, second); - - // hitsMax >= 200 drops the bar to topDamage / 4 = 31, which 40 clears. - var rights = BaseCreature.GetLootingRights(victim.DamageEntries, 200); - - Assert.Equal(2, rights.Count); - Assert.True(rights[0].m_HasRight); - Assert.True(rights[1].m_HasRight); - Assert.Same(second, rights[1].m_Mobile); - } - finally - { - victim.Delete(); - first.Delete(); - second.Delete(); - } - } - - [Fact] - public void PetDamage_CreditsTheMaster_NotThePet() - { - var victim = new TestMobile(); - var master = NewPlayer(); - var pet = new PetMobile { Master = master }; - var wild = new TestMobile(); // no damage master, and not a player - - try - { - victim.RegisterDamage(50, pet); - victim.RegisterDamage(20, wild); - - var rights = BaseCreature.GetLootingRights(victim.DamageEntries, 100); - - // The master is credited through the entry's Responsible sub-entry, and is the only one. - Assert.Single(rights); - - var masterStore = FindStore(rights, master); - Assert.NotNull(masterStore); - Assert.Equal(62, masterStore.m_Damage); // 50, then the first-damager 1.25x bonus - Assert.True(masterStore.m_HasRight); - - // The pet's own damage was fully handed to the master, so it earns no store. - Assert.Null(FindStore(rights, pet)); - - // A non-player damager earns nothing even when its damage was never reassigned. - Assert.Null(FindStore(rights, wild)); - } - finally - { - victim.Delete(); - master.Delete(); - pet.Delete(); - wild.Delete(); - } - } -} diff --git a/Projects/UOContent.Tests/Tests/Multis/SpreadsheetTests.cs b/Projects/UOContent.Tests/Tests/Multis/SpreadsheetTests.cs deleted file mode 100644 index 3be9d9a0d..000000000 --- a/Projects/UOContent.Tests/Tests/Multis/SpreadsheetTests.cs +++ /dev/null @@ -1,114 +0,0 @@ -using System; -using System.IO; -using Server.Multis; -using Xunit; - -namespace UOContent.Tests; - -public class SpreadsheetTests : IDisposable -{ - // misc.txt's shape: 13 columns, the last being the Comment the client never reads. - private const string Types = "int\tint\tint\tint\tint\tint\tint\tint\tint\tint\tint\tint\tstring"; - - private const string Names = - "Category\tStyle\tTID\tPiece1\tPiece2\tPiece3\tPiece4\tPiece5\tPiece6\tPiece7\tPiece8\tFeatureMask\tComment"; - - private readonly string _path = Path.Combine(Path.GetTempPath(), $"muo-sheet-{Guid.NewGuid():N}.txt"); - - public void Dispose() - { - if (File.Exists(_path)) - { - File.Delete(_path); - } - } - - private Spreadsheet Write(params string[] rows) - { - var lines = new string[rows.Length + 2]; - lines[0] = Types; - lines[1] = Names; - rows.CopyTo(lines, 2); - - File.WriteAllLines(_path, lines); - return new Spreadsheet(_path); - } - - [Fact] - public void RowMissingTrailingCommentIsStillRead() - { - // An empty Comment written without a trailing tab leaves the row one field short. - var ss = Write("0\t0\t1060056\t44\t0\t41\t40\t42\t0\t43\t29\t8"); - - var record = Assert.Single(ss.Records); - - Assert.Equal(44, record.GetInt32(ss.GetColumnID("Piece1"))); - Assert.Equal(29, record.GetInt32(ss.GetColumnID("Piece8"))); - Assert.Equal(8, record.GetInt32(ss.GetColumnID("FeatureMask"))); - } - - [Fact] - public void ShortRowDoesNotDropLaterRows() - { - var ss = Write( - "0\t0\t1060056\t44\t0\t41\t40\t42\t0\t43\t29\t0", - "0\t1\t1060057\t45\t0\t0\t0\t0\t0\t0\t0\t0\tFieldstone Arches" - ); - - Assert.Equal(2, ss.Records.Length); - Assert.Equal(44, ss.Records[0].GetInt32(ss.GetColumnID("Piece1"))); - Assert.Equal(45, ss.Records[1].GetInt32(ss.GetColumnID("Piece1"))); - } - - [Fact] - public void TabOnlySeparatorLinesAreNotMistakenForHeaderRows() - { - // The retail client's doors.txt separates its header rows this way. - var separator = new string('\t', 12); - - File.WriteAllLines( - _path, - [ - Types, - separator, - Names, - separator, - "0\t0\t1060056\t44\t0\t41\t40\t42\t0\t43\t29\t0\tFieldstone Archways" - ] - ); - - var ss = new Spreadsheet(_path); - - Assert.Equal(3, ss.GetColumnID("Piece1")); - Assert.Equal(11, ss.GetColumnID("FeatureMask")); - - var record = Assert.Single(ss.Records); - Assert.Equal(44, record.GetInt32(ss.GetColumnID("Piece1"))); - } - - [Fact] - public void MissingHeaderRowsThrowsInsteadOfNullReference() - { - File.WriteAllLines(_path, [Types]); - - Assert.Throws(() => new Spreadsheet(_path)); - } - - [Fact] - public void HeaderWithFewerNamesThanTypesIsTolerated() - { - File.WriteAllLines( - _path, - [ - Types, - "Category\tStyle\tTID\tPiece1", - "0\t0\t1060056\t44\t0\t41\t40\t42\t0\t43\t29\t0\tFieldstone Archways" - ] - ); - - var ss = new Spreadsheet(_path); - - Assert.Equal(44, ss.Records[0].GetInt32(ss.GetColumnID("Piece1"))); - Assert.Equal(-1, ss.GetColumnID("FeatureMask")); - } -} diff --git a/Projects/UOContent.Tests/Tests/Network/AutoDenylist/AutoDenylistTests.cs b/Projects/UOContent.Tests/Tests/Network/AutoDenylist/AutoDenylistTests.cs index ae926794d..3931eb3e4 100644 --- a/Projects/UOContent.Tests/Tests/Network/AutoDenylist/AutoDenylistTests.cs +++ b/Projects/UOContent.Tests/Tests/Network/AutoDenylist/AutoDenylistTests.cs @@ -20,8 +20,8 @@ using Xunit; namespace Server.Tests.Network.AutoDenylists; -// Static store, so every test resets it first and none may run alongside another. -// Addresses come from TEST-NET-2 (198.51.100.0/24). +// Static store, so every test resets it first. Addresses come from TEST-NET-2 (198.51.100.0/24). +// Sequential: the cap tests reach Sweep, which rents from STArrayPool, which is not thread-safe. [Collection("Sequential UOContent Tests")] public class AutoDenylistTests { @@ -62,11 +62,8 @@ public class AutoDenylistTests Assert.Equal(0, AutoDenylist.Count); } - // Not refreshed on purpose: it is what keeps insertion order equal to expiry order, so retiring lapsed - // entries costs the number expiring instead of the number held. A flooder whose hold lapses trips the - // rate limiter on its next attempt -- which runs ahead of the connection filters -- and is held again. [Fact] - public void Repeat_detection_does_not_extend_the_hold() + public void Repeat_detection_extends_the_hold() { Reset(); var ip = IPAddress.Parse("198.51.100.13"); @@ -74,72 +71,8 @@ public class AutoDenylistTests AutoDenylist.Hold(ip, BanReasons.SilentConnect, Now); AutoDenylist.Hold(ip, BanReasons.SilentConnect, Now + DurationMs - 1); - Assert.Equal(1, AutoDenylist.Count); // no duplicate - Assert.True(AutoDenylist.IsDenied(ip, Now + DurationMs - 1)); - Assert.False(AutoDenylist.IsDenied(ip, Now + DurationMs + 1)); // lapses from the FIRST detection - } - - // The ring carries the expiry and the set carries membership; if they ever disagree, an address is - // either denied forever or retired early. - [Fact] - public void Ring_and_set_stay_in_step() - { - Reset(maxEntries: 4); - - for (var i = 0; i < 8; i++) - { - AutoDenylist.Hold(IPAddress.Parse($"198.51.100.{70 + i}"), BanReasons.InvalidSeed, Now); - } - - Assert.Equal(4, AutoDenylist.Count); - Assert.Equal(AutoDenylist.Count, AutoDenylist.RingCount); - - AutoDenylist.Release(IPAddress.Parse("198.51.100.71")); - Assert.Equal(3, AutoDenylist.Count); - Assert.Equal(AutoDenylist.Count, AutoDenylist.RingCount); - - AutoDenylist.Drain(Now + DurationMs + 1); - Assert.Equal(0, AutoDenylist.Count); - Assert.Equal(0, AutoDenylist.RingCount); - } - - // The ring grows in doublings but is capped at maxEntries, which is not a power of two. Filling exactly - // to it must land on the last slot rather than off the end. - [Fact] - public void Ring_fills_exactly_to_a_non_power_of_two_cap() - { - Reset(maxEntries: 100); - - for (var i = 0; i < 120; i++) - { - AutoDenylist.Hold(IPAddress.Parse($"198.51.100.{i}"), BanReasons.InvalidSeed, Now); - } - - Assert.Equal(100, AutoDenylist.Count); - Assert.Equal(100, AutoDenylist.RingCount); - - // And the whole ring still drains, so no slot was stranded by a wrapped write. - AutoDenylist.Drain(Now + DurationMs + 1); - Assert.Equal(0, AutoDenylist.Count); - Assert.Equal(0, AutoDenylist.RingCount); - } - - // Releasing leaves no ring record behind, so a re-detection is not retired by the old one. - [Fact] - public void Release_then_re_hold_is_not_retired_by_the_stale_record() - { - Reset(); - var ip = IPAddress.Parse("198.51.100.15"); - - AutoDenylist.Hold(ip, BanReasons.RateLimit, Now); - AutoDenylist.Release(ip); - - var later = Now + DurationMs - 1; - AutoDenylist.Hold(ip, BanReasons.RateLimit, later); - - // The first hold's expiry has passed; the second must survive it. - Assert.True(AutoDenylist.IsDenied(ip, Now + DurationMs + 1)); - Assert.Equal(1, AutoDenylist.RingCount); + Assert.True(AutoDenylist.IsDenied(ip, Now + DurationMs + 1)); // would have lapsed without the second + Assert.Equal(1, AutoDenylist.Count); // and did not add a duplicate } [Fact] diff --git a/Projects/UOContent.Tests/Tests/Network/BanExemptionsTests.cs b/Projects/UOContent.Tests/Tests/Network/BanExemptionsTests.cs index b38364373..7e930fbdf 100644 --- a/Projects/UOContent.Tests/Tests/Network/BanExemptionsTests.cs +++ b/Projects/UOContent.Tests/Tests/Network/BanExemptionsTests.cs @@ -28,15 +28,15 @@ public class BanExemptionsTests private static readonly IPAddress _listed = IPAddress.Parse("192.0.2.10"); private static readonly IPAddress _unlisted = IPAddress.Parse("192.0.2.11"); - private static void WithManualAllowlist(string contents) => - ManualAllowlist.LoadForTesting(BlocklistSnapshot.Build(Encoding.ASCII.GetBytes(contents), out _, out _)); + private static void WithFileAllowlist(string contents) => + FileAllowlist.LoadForTesting(BlocklistSnapshot.Build(Encoding.ASCII.GetBytes(contents), out _, out _)); - private static void WithEmptyManualAllowlist() => ManualAllowlist.LoadForTesting(BlocklistSnapshot.Empty); + private static void WithEmptyFileAllowlist() => FileAllowlist.LoadForTesting(BlocklistSnapshot.Empty); [Fact] - public void Manual_allowlist_exempts_behavioral_contributions() + public void File_allowlist_exempts_behavioral_contributions() { - WithManualAllowlist("192.0.2.10"); + WithFileAllowlist("192.0.2.10"); // Subtracting from the blocklist does nothing for behavioural detections, which never consult it. Assert.True(BanExemptions.IsExempt(_listed, BanReasons.ForeignProtocol, NeverCalled)); @@ -46,10 +46,10 @@ public class BanExemptionsTests } [Fact] - public void Manual_allowlist_covers_cidr_entries() + public void File_allowlist_covers_cidr_entries() { // Carve-outs are CIDRs, so a shared-CGNAT player is only covered if ranges work here. - WithManualAllowlist("192.0.2.0/24"); + WithFileAllowlist("192.0.2.0/24"); Assert.True(BanExemptions.IsExempt(_listed, BanReasons.RateLimit, NeverCalled)); Assert.True(BanExemptions.IsExempt(IPAddress.Parse("192.0.2.254"), BanReasons.RateLimit, NeverCalled)); @@ -57,9 +57,9 @@ public class BanExemptionsTests } [Fact] - public void Manual_bans_are_never_exempt_even_when_allowlisted() + public void Manual_bans_are_never_exempt_even_when_file_allowlisted() { - WithManualAllowlist("192.0.2.10"); + WithFileAllowlist("192.0.2.10"); // An explicit decision outranks the operator's own carve-out, and must not cost a strike. Assert.False(BanExemptions.IsExempt(_listed, BanReasons.Manual, NeverCalled)); @@ -68,16 +68,16 @@ public class BanExemptionsTests [Fact] public void Unopted_reasons_are_never_exempt() { - WithManualAllowlist("192.0.2.10"); + WithFileAllowlist("192.0.2.10"); Assert.False(BanExemptions.IsExempt(_listed, BanReasons.Blocklist, NeverCalled)); Assert.False(BanExemptions.IsExempt(_listed, "some-future-reason", NeverCalled)); } [Fact] - public void Manual_allowlist_does_not_spend_the_earned_lists_strikes() + public void File_allowlist_does_not_spend_the_earned_lists_strikes() { - WithManualAllowlist("192.0.2.10"); + WithFileAllowlist("192.0.2.10"); // Unconditional, so the revocable list must not be consulted -- that would burn a strike. Assert.True(BanExemptions.IsExempt(_listed, BanReasons.RateLimit, NeverCalled)); @@ -86,7 +86,7 @@ public class BanExemptionsTests [Fact] public void Falls_through_to_the_login_allowlist_when_not_file_listed() { - WithEmptyManualAllowlist(); + WithEmptyFileAllowlist(); var consulted = 0; @@ -107,7 +107,7 @@ public class BanExemptionsTests [Fact] public void Null_address_is_never_exempt() { - WithEmptyManualAllowlist(); + WithEmptyFileAllowlist(); Assert.False(BanExemptions.IsExempt(null, BanReasons.RateLimit, NeverCalled)); } diff --git a/Projects/UOContent.Tests/Tests/Network/Bans/Blocklist/BlocklistConfigurationTests.cs b/Projects/UOContent.Tests/Tests/Network/Bans/Blocklist/BlocklistConfigurationTests.cs index 3d81c8d87..6464a1bed 100644 --- a/Projects/UOContent.Tests/Tests/Network/Bans/Blocklist/BlocklistConfigurationTests.cs +++ b/Projects/UOContent.Tests/Tests/Network/Bans/Blocklist/BlocklistConfigurationTests.cs @@ -30,7 +30,6 @@ public class BlocklistConfigurationTests { var original = new BlocklistSettings { - Enabled = true, File = "D:/shared/ip-blocklist.txt", ReloadInterval = TimeSpan.FromMinutes(5), ReportHits = false, @@ -40,7 +39,6 @@ public class BlocklistConfigurationTests var json = JsonConfig.Serialize(original); - Assert.Contains("\"enabled\"", json); Assert.Contains("\"file\"", json); Assert.Contains("\"reloadInterval\"", json); Assert.Contains("\"reportHits\"", json); @@ -50,7 +48,6 @@ public class BlocklistConfigurationTests var restored = JsonSerializer.Deserialize(json, JsonConfig.DefaultOptions); Assert.NotNull(restored); - Assert.Equal(original.Enabled, restored.Enabled); Assert.Equal(original.File, restored.File); Assert.Equal(original.ReloadInterval, restored.ReloadInterval); Assert.Equal(original.ReportHits, restored.ReportHits); @@ -58,13 +55,6 @@ public class BlocklistConfigurationTests Assert.Equal(original.PromoteSuppression, restored.PromoteSuppression); } - // The point of the flag: a shard that never opts in must not start the reload poll. - [Fact] - public void Blocklist_is_off_by_default() - { - Assert.False(new BlocklistSettings().Enabled); - } - // The generator (tools/Export-IpBlocklist.ps1) writes to this path by default; if one side moves // without the other, a shard silently enforces nothing. [Fact] diff --git a/Projects/UOContent.Tests/Tests/Network/ManualAllowlist/ManualAllowlistConfigurationTests.cs b/Projects/UOContent.Tests/Tests/Network/ManualAllowlist/ManualAllowlistConfigurationTests.cs deleted file mode 100644 index 1146414b5..000000000 --- a/Projects/UOContent.Tests/Tests/Network/ManualAllowlist/ManualAllowlistConfigurationTests.cs +++ /dev/null @@ -1,66 +0,0 @@ -/************************************************************************* - * ModernUO * - * Copyright 2019-2026 - ModernUO Development Team * - * Email: hi@modernuo.com * - * File: ManualAllowlistConfigurationTests.cs * - * * - * This program is free software: you can redistribute it and/or modify * - * it under the terms of the GNU General Public License as published by * - * the Free Software Foundation, either version 3 of the License, or * - * (at your option) any later version. * - * * - * You should have received a copy of the GNU General Public License * - * along with this program. If not, see . * - *************************************************************************/ - -using System; -using System.Text.Json; -using Server.Json; -using Server.Network.Bans; -using Xunit; - -namespace Server.Tests.Network.ManualAllowlists; - -public class ManualAllowlistConfigurationTests -{ - // Locks the JsonConfig casing contract: JsonConfig's options are case-SENSITIVE, so every settings - // member must carry an explicit [JsonPropertyName("camelCase")] or it silently binds nothing. - [Fact] - public void ManualAllowlistSettings_RoundTripsThroughJsonConfig() - { - var original = new ManualAllowlistSettings - { - Enabled = true, - Files = ["D:/shared/ip-allowlist*.txt"], - ReloadInterval = TimeSpan.FromMinutes(5) - }; - - var json = JsonConfig.Serialize(original); - - Assert.Contains("\"enabled\"", json); - Assert.Contains("\"files\"", json); - Assert.Contains("\"reloadInterval\"", json); - - var restored = JsonSerializer.Deserialize(json, JsonConfig.DefaultOptions); - - Assert.NotNull(restored); - Assert.Equal(original.Enabled, restored.Enabled); - Assert.Equal(original.Files, restored.Files); - Assert.Equal(original.ReloadInterval, restored.ReloadInterval); - } - - // The point of the flag: a shard that never opts in must not start the reload poll. - [Fact] - public void Manual_allowlist_is_off_by_default() - { - Assert.False(new ManualAllowlistSettings().Enabled); - } - - // The generator creates ip-allowlist.txt beside the blocklist; the wildcard is what picks up a - // carve-out file (-RefreshCarveouts writes ip-allowlist-starlink.txt) with no config edit. - [Fact] - public void Default_pattern_matches_the_generator_output_path() - { - Assert.Equal(["Configuration/ip-allowlist*.txt"], new ManualAllowlistSettings().Files); - } -} diff --git a/Projects/UOContent.Tests/Tests/Network/Packets/AuthIdTests.cs b/Projects/UOContent.Tests/Tests/Network/Packets/AuthIdTests.cs deleted file mode 100644 index 456eb174a..000000000 --- a/Projects/UOContent.Tests/Tests/Network/Packets/AuthIdTests.cs +++ /dev/null @@ -1,382 +0,0 @@ -using System; -using System.Net; -using Server.Accounting; -using Server.Accounting.Security; -using Server.Network; -using Server.Tests.Network; -using Xunit; - -namespace Server.Tests.Network.Packets; - -[Collection("Sequential UOContent Tests")] -public class AuthIdTests : IDisposable -{ - private static readonly IPAddress AddressX = IPAddress.Parse("203.0.113.10"); - private static readonly IPAddress AddressY = IPAddress.Parse("203.0.113.11"); - - private readonly PasswordProtectionAlgorithm _originalAlgorithm = AccountSecurity.CurrentAlgorithm; - - public AuthIdTests() - { - AccountSecurity.CurrentAlgorithm = PasswordProtectionAlgorithm.Argon2; - IncomingAccountPackets.ClearAuthIdWindow(); - } - - public void Dispose() - { - IncomingAccountPackets.ClearAuthIdWindow(); - AccountSecurity.CurrentAlgorithm = _originalAlgorithm; - } - - private static IAccount CreateAccount(string username) => - Accounts.GetAccount(username) ?? new Account(username, "hunter2"); - - private static int Register(IAccount account, IPAddress address) => - IncomingAccountPackets.RegisterAuthId(account, address, new ClientVersion(7, 0, 0, 0)); - - [Fact] - public void VouchesForTheAccountAndAddressItWasIssuedTo() - { - var account = CreateAccount("authid-match-user"); - var authId = Register(account, AddressX); - - var result = IncomingAccountPackets.ConsumeAuthId(authId, account.Username, AddressX, out var entry); - - Assert.Equal(IncomingAccountPackets.AuthIdResult.Vouched, result); - Assert.Same(account, entry.Account); - } - - [Fact] - public void RejectsADifferentAccount() - { - var issued = CreateAccount("authid-owner-user"); - var other = CreateAccount("authid-other-user"); - var authId = Register(issued, AddressX); - - Assert.Equal( - IncomingAccountPackets.AuthIdResult.Rejected, - IncomingAccountPackets.ConsumeAuthId(authId, other.Username, AddressX, out _) - ); - } - - [Fact] - public void RejectsADifferentAddress() - { - var account = CreateAccount("authid-switch-user"); - var authId = Register(account, AddressX); - - Assert.Equal( - IncomingAccountPackets.AuthIdResult.Rejected, - IncomingAccountPackets.ConsumeAuthId(authId, account.Username, AddressY, out _) - ); - } - - [Fact] - public void MatchesTheUsernameCaseInsensitively() - { - var account = CreateAccount("AuthId-Case-User"); - var authId = Register(account, AddressX); - - Assert.Equal( - IncomingAccountPackets.AuthIdResult.Vouched, - IncomingAccountPackets.ConsumeAuthId(authId, "authid-case-user", AddressX, out _) - ); - } - - [Fact] - public void MatchesAnIPv4MappedIPv6Address() - { - var account = CreateAccount("authid-mapped-user"); - var authId = Register(account, AddressX); - - Assert.Equal( - IncomingAccountPackets.AuthIdResult.Vouched, - IncomingAccountPackets.ConsumeAuthId(authId, account.Username, AddressX.MapToIPv6(), out _) - ); - } - - [Fact] - public void RejectsAnUnknownAuthId() - { - var account = CreateAccount("authid-unknown-user"); - var authId = Register(account, AddressX); - - Assert.Equal( - IncomingAccountPackets.AuthIdResult.Rejected, - IncomingAccountPackets.ConsumeAuthId(authId + 1, account.Username, AddressX, out _) - ); - } - - [Fact] - public void IsSingleUseAfterASuccess() - { - var account = CreateAccount("authid-once-user"); - var authId = Register(account, AddressX); - - Assert.Equal( - IncomingAccountPackets.AuthIdResult.Vouched, - IncomingAccountPackets.ConsumeAuthId(authId, account.Username, AddressX, out _) - ); - Assert.Equal( - IncomingAccountPackets.AuthIdResult.Rejected, - IncomingAccountPackets.ConsumeAuthId(authId, account.Username, AddressX, out _) - ); - } - - // A rejected attempt must not consume the id, or anyone landing on a live one could burn it and - // force its owner to log in again. - [Fact] - public void SurvivesAnAttemptFromTheWrongAddress() - { - var account = CreateAccount("authid-not-burned-address-user"); - var authId = Register(account, AddressX); - - Assert.Equal( - IncomingAccountPackets.AuthIdResult.Rejected, - IncomingAccountPackets.ConsumeAuthId(authId, account.Username, AddressY, out _) - ); - - Assert.Equal(1, IncomingAccountPackets.AuthIdWindowCount); - Assert.Equal( - IncomingAccountPackets.AuthIdResult.Vouched, - IncomingAccountPackets.ConsumeAuthId(authId, account.Username, AddressX, out _) - ); - } - - [Fact] - public void SurvivesAnAttemptForTheWrongAccount() - { - var account = CreateAccount("authid-not-burned-account-user"); - var authId = Register(account, AddressX); - - Assert.Equal( - IncomingAccountPackets.AuthIdResult.Rejected, - IncomingAccountPackets.ConsumeAuthId(authId, "not-the-owner", AddressX, out _) - ); - - Assert.Equal(1, IncomingAccountPackets.AuthIdWindowCount); - Assert.Equal( - IncomingAccountPackets.AuthIdResult.Vouched, - IncomingAccountPackets.ConsumeAuthId(authId, account.Username, AddressX, out _) - ); - } - - [Fact] - public void ARejectedAttemptYieldsNoEntry() - { - var account = CreateAccount("authid-no-leak-user"); - var authId = Register(account, AddressX); - - IncomingAccountPackets.ConsumeAuthId(authId, "not-the-owner", AddressX, out var entry); - - Assert.Null(entry.Account); - } - - [Fact] - public void AnExpiredIdIsSpentByItsOwner() - { - var account = CreateAccount("authid-expired-spent-user"); - var authId = Register(account, AddressX); - - var now = Core._now; - - try - { - Core._now = now + TimeSpan.FromMinutes(30.0); - - Assert.Equal( - IncomingAccountPackets.AuthIdResult.Expired, - IncomingAccountPackets.ConsumeAuthId(authId, account.Username, AddressX, out _) - ); - Assert.Equal(0, IncomingAccountPackets.AuthIdWindowCount); - } - finally - { - Core._now = now; - } - } - - // Expiry is not a lockout. The game login always verified the password before any of this - // existed, so falling back to that verify is the behaviour we started from. - [Fact] - public void ExpiresIntoAPasswordVerifyRatherThanARejection() - { - var account = CreateAccount("authid-expired-user"); - var authId = Register(account, AddressX); - - var now = Core._now; - - try - { - Core._now = now + TimeSpan.FromMinutes(30.0); - - Assert.Equal( - IncomingAccountPackets.AuthIdResult.Expired, - IncomingAccountPackets.ConsumeAuthId(authId, account.Username, AddressX, out var entry) - ); - - // Still carries the client version the game login needs. - Assert.Equal(new ClientVersion(7, 0, 0, 0), entry.Version); - } - finally - { - Core._now = now; - } - } - - [Fact] - public void AnExpiredIdFromAnotherAddressIsStillRejected() - { - var account = CreateAccount("authid-expired-elsewhere-user"); - var authId = Register(account, AddressX); - - var now = Core._now; - - try - { - Core._now = now + TimeSpan.FromMinutes(30.0); - - Assert.Equal( - IncomingAccountPackets.AuthIdResult.Rejected, - IncomingAccountPackets.ConsumeAuthId(authId, account.Username, AddressY, out _) - ); - } - finally - { - Core._now = now; - } - } - - private static int Ensure(int existingAuthId, IAccount account, IPAddress address) => - IncomingAccountPackets.EnsureAuthId( - existingAuthId, - account, - address, - new ClientVersion(7, 0, 0, 0) - ); - - [Fact] - public void IssuesAnIdWhenTheConnectionHasNone() - { - var account = CreateAccount("authid-first-select-user"); - - var authId = Ensure(0, account, AddressX); - - Assert.NotEqual(0, authId); - Assert.Equal(1, IncomingAccountPackets.AuthIdWindowCount); - } - - // Handing the same id back rather than minting another is what makes an orphan impossible, - // instead of something to clean up afterwards. - [Fact] - public void ReSelectingReturnsTheSameIdAndAddsNothingToTheWindow() - { - var account = CreateAccount("authid-reselect-user"); - var first = Ensure(0, account, AddressX); - - for (var i = 0; i < 10; i++) - { - Assert.Equal(first, Ensure(first, account, AddressX)); - } - - Assert.Equal(1, IncomingAccountPackets.AuthIdWindowCount); - Assert.Equal( - IncomingAccountPackets.AuthIdResult.Vouched, - IncomingAccountPackets.ConsumeAuthId(first, account.Username, AddressX, out _) - ); - } - - [Fact] - public void AbandonedIdsAreSweptWhenNewOnesAreIssued() - { - var abandoned = CreateAccount("authid-abandoned-user"); - var live = CreateAccount("authid-live-user"); - - var now = Core._now; - - try - { - for (var i = 0; i < 128; i++) - { - Register(abandoned, AddressX); - } - - Assert.Equal(128, IncomingAccountPackets.AuthIdWindowCount); - - Core._now = now + TimeSpan.FromMinutes(30.0); - - var liveId = Register(live, AddressX); - - Assert.Equal(1, IncomingAccountPackets.AuthIdWindowCount); - Assert.Equal( - IncomingAccountPackets.AuthIdResult.Vouched, - IncomingAccountPackets.ConsumeAuthId(liveId, live.Username, AddressX, out _) - ); - } - finally - { - Core._now = now; - } - } - - // A login rush is not a backlog. Every id belongs to a client on its way to redeem it, so none - // may be discarded to hold the window at some arbitrary size. - [Fact] - public void ALoginRushDoesNotEvictAnyonesAuthId() - { - var account = CreateAccount("authid-rush-user"); - var ids = new int[800]; - - for (var i = 0; i < ids.Length; i++) - { - ids[i] = Register(account, AddressX); - } - - Assert.Equal(ids.Length, IncomingAccountPackets.AuthIdWindowCount); - - // Every id issued during the rush is still redeemable, including the first one. - for (var i = 0; i < ids.Length; i++) - { - Assert.Equal( - IncomingAccountPackets.AuthIdResult.Vouched, - IncomingAccountPackets.ConsumeAuthId(ids[i], account.Username, AddressX, out _) - ); - } - } - - [Fact] - public void PreAuthenticatedGameLogin_SkipsThePasswordCheck() - { - var account = CreateAccount("authid-preauth-user"); - using var ns = PacketTestUtilities.CreateTestNetState(); - - // A wrong password is accepted only because the auth id already vouched for the account. - var e = new GameServer.GameLoginEventArgs(ns, account.Username, "wrong-password", true); - GameServer.GameServerLoginEvent(e); - - Assert.True(e.Accepted); - } - - [Fact] - public void GameLoginWithoutPreAuthentication_StillChecksThePassword() - { - var account = CreateAccount("authid-nopreauth-user"); - using var ns = PacketTestUtilities.CreateTestNetState(); - - var wrong = new GameServer.GameLoginEventArgs(ns, account.Username, "wrong-password", false); - GameServer.GameServerLoginEvent(wrong); - Assert.False(wrong.Accepted); - - var right = new GameServer.GameLoginEventArgs(ns, account.Username, "hunter2", false); - GameServer.GameServerLoginEvent(right); - Assert.True(right.Accepted); - } - - [Fact] - public void GeneratesDistinctAuthIds() - { - var account = CreateAccount("authid-distinct-user"); - - Assert.NotEqual(Register(account, AddressX), Register(account, AddressX)); - } -} diff --git a/Projects/UOContent.Tests/UOContent.Tests.csproj b/Projects/UOContent.Tests/UOContent.Tests.csproj index 1c73f6eb4..712246f72 100644 --- a/Projects/UOContent.Tests/UOContent.Tests.csproj +++ b/Projects/UOContent.Tests/UOContent.Tests.csproj @@ -4,9 +4,9 @@ Debug;Release;Analyze - + - + all runtime; build; native; contentfiles; analyzers; buildtransitive @@ -15,6 +15,7 @@ + diff --git a/Projects/UOContent/Accounting/Account.cs b/Projects/UOContent/Accounting/Account.cs index b583585ee..d79d70b1a 100644 --- a/Projects/UOContent/Accounting/Account.cs +++ b/Projects/UOContent/Accounting/Account.cs @@ -378,54 +378,28 @@ public partial class Account : IAccount, IComparable public void SetPassword(string plainPassword) { + var phrase = _passwordAlgorithm is PasswordProtectionAlgorithm.SHA1 or PasswordProtectionAlgorithm.SHA2 + ? $"{_username}{plainPassword}" + : plainPassword; + + Password = AccountSecurity.CurrentPasswordProtection.EncryptPassword(phrase); PasswordAlgorithm = AccountSecurity.CurrentAlgorithm; - Password = AccountSecurity.CurrentPasswordProtection.EncryptPassword( - AccountSecurity.DerivePhrase(PasswordAlgorithm, _username, plainPassword) - ); - } - - /// The phrase that verifies against the currently stored hash. - internal string GetVerifyPhrase(string plainPassword) => - AccountSecurity.DerivePhrase(_passwordAlgorithm, _username, plainPassword); - - /// The phrase a rehash to the configured algorithm would be derived from. - internal string GetRehashPhrase(string plainPassword) => - AccountSecurity.DerivePhrase(AccountSecurity.CurrentAlgorithm, _username, plainPassword); - - /// - /// Whether a successful login should rewrite the stored hash, because the algorithm changed or - /// its cost parameters moved. - /// - internal bool NeedsPasswordUpgrade() => - _passwordAlgorithm != AccountSecurity.CurrentAlgorithm || - AccountSecurity.CurrentPasswordProtection.NeedsRehash(Password); - - /// - /// Applies a hash derived off the game loop. Distinct from the private UpgradePassword - /// below, which adopts a legacy hash when loading pre-binary XML accounts. - /// - /// Unguarded: dispatch is on the loop, one worker drains FIFO, and results return through the - /// loop context in that order, so last dispatched is last applied. A second worker would need - /// ordering reintroduced here. - /// - internal void ApplyPasswordWrite(string newEncrypted, PasswordProtectionAlgorithm algorithm) - { - PasswordAlgorithm = algorithm; - Password = newEncrypted; } public bool CheckPassword(string plainPassword) { - var ok = AccountSecurity.GetPasswordProtection(_passwordAlgorithm) - .ValidatePassword(Password, GetVerifyPhrase(plainPassword)); + var phrase = _passwordAlgorithm is PasswordProtectionAlgorithm.SHA1 or PasswordProtectionAlgorithm.SHA2 + ? $"{_username}{plainPassword}" + : plainPassword; + var ok = AccountSecurity.GetPasswordProtection(_passwordAlgorithm).ValidatePassword(Password, phrase); if (!ok) { return false; } // Upgrade the password protection in case we change the algorithm - if (NeedsPasswordUpgrade()) + if (_passwordAlgorithm != AccountSecurity.CurrentAlgorithm) { SetPassword(plainPassword); } diff --git a/Projects/UOContent/Accounting/AccountHandler.cs b/Projects/UOContent/Accounting/AccountHandler.cs index eea0dc069..ea0c4b9e0 100644 --- a/Projects/UOContent/Accounting/AccountHandler.cs +++ b/Projects/UOContent/Accounting/AccountHandler.cs @@ -5,7 +5,6 @@ using System.Net; using System.Runtime.CompilerServices; using ModernUO.CodeGeneratedEvents; using Server.Accounting; -using Server.Accounting.Security; using Server.Engines.CharacterCreation; using Server.Engines.Help; using Server.Logging; @@ -70,9 +69,6 @@ public static class AccountHandler public static void Initialize() { EventSink.AccountLogin += EventSink_AccountLogin; - - EventSink.Shutdown += PasswordWorker.Stop; - EventSink.ServerCrashed += PasswordWorker.OnCrashed; } [Usage("Password ")] @@ -143,12 +139,8 @@ public static class AccountHandler if (accessList[0].MatchClassC(ipAddress)) { - // Confirmed from the callback: off-loop the write has not landed yet here. - PasswordWorker.SetPassword( - acct, - pass, - _ => from.SendMessage("The password to your account has changed.") - ); + acct.SetPassword(pass); + from.SendMessage("The password to your account has changed."); } else { @@ -315,129 +307,25 @@ public static class AccountHandler logger.Information("Login: {NetState} Access denied for '{Username}'", e.State, un); e.RejectReason = LockdownLevel > AccessLevel.Player ? ALRReason.BadComm : ALRReason.BadPass; } - else + else if (!acct.CheckPassword(pw)) { - HandlePasswordCheck(e, acct, pw); - } - } - - /// - /// Separate from the caller's else-if chain because two outcomes are not verdicts: the off-loop - /// path has none yet, and a full queue must reject rather than fall through and verify. - /// - private static void HandlePasswordCheck(AccountLoginEventArgs e, Account acct, string pw) - { - switch (DispatchPasswordCheck(e, acct, pw)) - { - case PasswordCheckDispatch.Deferred: - { - e.Deferred = true; - return; - } - case PasswordCheckDispatch.Saturated: - { - // Reject rather than verify inline: steering work back onto the loop is what a - // flood wants. - logger.Warning( - "Login: {NetState} Password verification queue full, rejecting '{Username}'", - e.State, - acct.Username - ); - - e.RejectReason = ALRReason.BadComm; - return; - } - } - - if (!acct.CheckPassword(pw)) - { - logger.Information("Login: {NetState} Invalid password for '{Username}'", e.State, acct.Username); + logger.Information("Login: {NetState} Invalid password for '{Username}'", e.State, un); e.RejectReason = ALRReason.BadPass; - return; } - - ApplyVerifiedLogin(e, acct); - } - - /// Everything after the password is known good, shared so an off-loop verdict lands - /// in the same state as an inline one. - private static void ApplyVerifiedLogin(AccountLoginEventArgs e, Account acct) - { - if (acct.Banned) + else if (acct.Banned) { - logger.Information("Login: {NetState} Banned account '{Username}'", e.State, acct.Username); + logger.Information("Login: {NetState} Banned account '{Username}'", e.State, un); e.RejectReason = ALRReason.Blocked; - return; - } - - logger.Information("Login: {NetState} Valid credentials for '{Username}'", e.State, acct.Username); - e.State.Account = acct; - e.Accepted = true; - - acct.LogAccess(e.State); - LoginAllowlist.RecordLogin(e.State?.Address); - } - - private enum PasswordCheckDispatch - { - /// Verify on the loop. - Inline, - - /// Handed to the worker; no verdict yet. - Deferred, - - /// The queue is full. - Saturated - } - - /// - /// Hands the password check to the worker, whatever algorithm it uses. Every protection is safe - /// off the loop, so there is no carve-out, and a cheap digest does not need one either: - /// AccountSecurity.Configure refuses anything below SHA2 as the configured algorithm, so - /// MD5 and SHA1 only appear as a stored hash awaiting migration. That makes - /// NeedsPasswordUpgrade true, and the upgrade hash dominates the job. - /// - private static PasswordCheckDispatch DispatchPasswordCheck(AccountLoginEventArgs e, Account acct, string pw) - { - if (!PasswordWorker.Enabled) - { - return PasswordCheckDispatch.Inline; - } - - var job = new PasswordJob - { - Account = acct, - State = e.State, - StoredHash = acct.Password, - StoredAlgorithm = acct.PasswordAlgorithm, - VerifyPhrase = acct.GetVerifyPhrase(pw), - HashPhrase = acct.NeedsPasswordUpgrade() ? acct.GetRehashPhrase(pw) : null, - TargetAlgorithm = AccountSecurity.CurrentAlgorithm, - OnComplete = static (j, outcome) => - CompleteDeferredAccountLogin(j.State, j.Account, outcome.Verified) - }; - - return PasswordWorker.TryEnqueue(job) - ? PasswordCheckDispatch.Deferred - : PasswordCheckDispatch.Saturated; - } - - /// Resumes a login whose password check ran on the verification thread. - internal static void CompleteDeferredAccountLogin(NetState state, Account acct, bool verified) - { - var e = new AccountLoginEventArgs(state, acct.Username, null); - - if (verified) - { - ApplyVerifiedLogin(e, acct); } else { - logger.Information("Login: {NetState} Invalid password for '{Username}'", state, acct.Username); - e.RejectReason = ALRReason.BadPass; - } + logger.Information("Login: {NetState} Valid credentials for '{Username}'", e.State, un); + e.State.Account = acct; + e.Accepted = true; - IncomingAccountPackets.CompleteAccountLogin(state, e.Accepted, e.RejectReason); + acct.LogAccess(e.State); + LoginAllowlist.RecordLogin(e.State?.Address); + } } [OnEvent(nameof(GameServer.GameServerLoginEvent))] @@ -455,9 +343,7 @@ public static class AccountHandler logger.Information("Login: {NetState} Access denied for '{Username}'", e.State, un); e.Accepted = false; } - // The auth id was only issued after the account login packet verified this password, so - // re-deriving the hash costs a second Argon2 verify to answer the same question. - else if (!e.PreAuthenticated && !acct.CheckPassword(pw)) + else if (!acct.CheckPassword(pw)) { logger.Information("Login: {NetState} Invalid password for '{Username}'", e.State, un); e.Accepted = false; diff --git a/Projects/UOContent/Accounting/IPasswordProtection.cs b/Projects/UOContent/Accounting/IPasswordProtection.cs index f5590a148..0fdd6ced9 100644 --- a/Projects/UOContent/Accounting/IPasswordProtection.cs +++ b/Projects/UOContent/Accounting/IPasswordProtection.cs @@ -4,12 +4,5 @@ namespace Server.Accounting { string EncryptPassword(string plainPassword); bool ValidatePassword(string encryptedPassword, string plainPassword); - - /// - /// True when was produced with parameters that differ - /// from the ones this protection currently uses, so a successful login should rewrite it. - /// Algorithms whose cost is not embedded in the stored value never need this. - /// - bool NeedsRehash(string encryptedPassword) => false; } } diff --git a/Projects/UOContent/Accounting/Security/AccountSecurity.cs b/Projects/UOContent/Accounting/Security/AccountSecurity.cs index deae4c8e0..9f7faeafc 100644 --- a/Projects/UOContent/Accounting/Security/AccountSecurity.cs +++ b/Projects/UOContent/Accounting/Security/AccountSecurity.cs @@ -51,17 +51,6 @@ public static class AccountSecurity } } - /// - /// The string actually fed to the KDF. SHA1 and SHA2 salt by username; everything else hashes - /// the password alone. Verification must derive with the algorithm the stored hash was made - /// with, and a rehash with the one it is moving to -- deriving with the wrong one produces a - /// hash that verifies once and never again. - /// - public static string DerivePhrase(PasswordProtectionAlgorithm algorithm, string username, string plainPassword) - => algorithm is PasswordProtectionAlgorithm.SHA1 or PasswordProtectionAlgorithm.SHA2 - ? $"{username}{plainPassword}" - : plainPassword; - public static IPasswordProtection GetPasswordProtection(PasswordProtectionAlgorithm algorithm) { var passwordProtection = algorithm switch diff --git a/Projects/UOContent/Accounting/Security/Argon2PasswordProtection.cs b/Projects/UOContent/Accounting/Security/Argon2PasswordProtection.cs index 0a952117d..99f320a78 100644 --- a/Projects/UOContent/Accounting/Security/Argon2PasswordProtection.cs +++ b/Projects/UOContent/Accounting/Security/Argon2PasswordProtection.cs @@ -21,37 +21,11 @@ public class Argon2PasswordProtection : IPasswordProtection { public static IPasswordProtection Instance = new Argon2PasswordProtection(); - // 16 MiB at t=1 is cheaper than 8 MiB at t=3 (8.5 ms vs 10.1 ms) and twice as memory-hard, which - // is what resists GPU and ASIC cracking. p=1: native argon2 spawns a thread per lane. - private readonly Argon2PasswordHasher _passwordHasher = new( - time: 1, - memory: 16384, - parallel: 1, - type: Argon2Type.Argon2id, - rng: RandomNumberGenerator.Create() - ); + private readonly Argon2PasswordHasher m_PasswordHasher = new(rng: RandomNumberGenerator.Create()); public string EncryptPassword(string plainPassword) => - _passwordHasher.Hash(plainPassword); + m_PasswordHasher.Hash(plainPassword); public bool ValidatePassword(string encryptedPassword, string plainPassword) => - _passwordHasher.Verify(encryptedPassword, plainPassword); - - // Verification uses the parameters embedded in the PHC string, not the configured ones, so - // comparing them is what lets a parameter change reach existing accounts. - public bool NeedsRehash(string encryptedPassword) - { - // Unparseable but verified: a format this build does not understand, so rewrite it. - if (!Argon2PasswordHasher.TryExtractMetadataValues(encryptedPassword, out var values)) - { - return true; - } - - return values.ArgonType != _passwordHasher.ArgonType - || values.MemoryCost != _passwordHasher.MemoryCost - || values.TimeCost != _passwordHasher.TimeCost - || values.Parallelism != _passwordHasher.Parallelism - || values.HashLength != (int)_passwordHasher.HashLength - || values.SaltLength != (int)_passwordHasher.SaltLength; - } + m_PasswordHasher.Verify(encryptedPassword, plainPassword); } diff --git a/Projects/UOContent/Accounting/Security/HashAlgorithmPasswordProtection.cs b/Projects/UOContent/Accounting/Security/HashAlgorithmPasswordProtection.cs index 63d0ac7a8..6d6e579cd 100644 --- a/Projects/UOContent/Accounting/Security/HashAlgorithmPasswordProtection.cs +++ b/Projects/UOContent/Accounting/Security/HashAlgorithmPasswordProtection.cs @@ -19,47 +19,19 @@ using Server.Text; namespace Server.Accounting.Security; -/// -/// The obsolete unsalted digests, kept only so imported accounts can log in once and be upgraded. -/// -/// Hashing goes through the one-shot static APIs rather than a retained . -/// A instance carries the running digest across HashCore/HashFinal, so -/// two threads sharing one corrupt each other's result -- and these are process-wide singletons. -/// The static form has no such state, allocates nothing, and produces identical bytes. -/// public class HashAlgorithmPasswordProtection : IPasswordProtection { - private enum Kind - { - MD5, - SHA1, - SHA512 - } + public static IPasswordProtection MD5Instance = new HashAlgorithmPasswordProtection(MD5.Create()); + public static IPasswordProtection SHA1Instance = new HashAlgorithmPasswordProtection(SHA1.Create()); + public static IPasswordProtection SHA2Instance = new HashAlgorithmPasswordProtection(SHA512.Create()); + private readonly HashAlgorithm _hashAlgorithm; - public static readonly IPasswordProtection MD5Instance = new HashAlgorithmPasswordProtection(Kind.MD5); - public static readonly IPasswordProtection SHA1Instance = new HashAlgorithmPasswordProtection(Kind.SHA1); - public static readonly IPasswordProtection SHA2Instance = new HashAlgorithmPasswordProtection(Kind.SHA512); - - private const int MaxDigestLength = 64; // SHA512, the largest of the three. - - private readonly Kind _kind; - - private HashAlgorithmPasswordProtection(Kind kind) => _kind = kind; + public HashAlgorithmPasswordProtection(HashAlgorithm hashAlgorithm) => _hashAlgorithm = hashAlgorithm; public string EncryptPassword(string plainPassword) { var bytes = plainPassword.AsSpan(0, Math.Min(256, plainPassword.Length)).GetBytesAscii(); - - Span digest = stackalloc byte[MaxDigestLength]; - - var written = _kind switch - { - Kind.MD5 => MD5.HashData(bytes, digest), - Kind.SHA1 => SHA1.HashData(bytes, digest), - _ => SHA512.HashData(bytes, digest) - }; - - return digest[..written].ToHexString(); + return _hashAlgorithm.ComputeHash(bytes).ToHexString(); } public bool ValidatePassword(string encryptedPassword, string plainPassword) => diff --git a/Projects/UOContent/Accounting/Security/PBKDF2PasswordProtection.cs b/Projects/UOContent/Accounting/Security/PBKDF2PasswordProtection.cs index 96706c030..7ae464021 100644 --- a/Projects/UOContent/Accounting/Security/PBKDF2PasswordProtection.cs +++ b/Projects/UOContent/Accounting/Security/PBKDF2PasswordProtection.cs @@ -22,24 +22,23 @@ namespace Server.Accounting.Security; public class PBKDF2PasswordProtection : IPasswordProtection { - private const ushort MinIterations = 1024; - private const ushort MaxIterations = 1536; - private const int SaltSize = 8; - private const int HashSize = 32; - private const int OutputSize = 2 + SaltSize + HashSize; + private const ushort m_MinIterations = 1024; + private const ushort m_MaxIterations = 1536; + private const int m_SaltSize = 8; + private const int m_HashSize = 32; + private const int m_OutputSize = 2 + m_SaltSize + m_HashSize; public static readonly IPasswordProtection Instance = new PBKDF2PasswordProtection(); public string EncryptPassword(string plainPassword) { - Span output = stackalloc byte[OutputSize]; - - var iterations = RandomNumberGenerator.GetInt32(MinIterations, MaxIterations + 1); + Span output = stackalloc byte[m_OutputSize]; + var iterations = Utility.RandomMinMax(m_MinIterations, m_MaxIterations); BinaryPrimitives.WriteUInt16LittleEndian(output[..2], (ushort)iterations); - var salt = output.Slice(2, SaltSize); + var salt = output.Slice(2, m_SaltSize); RandomNumberGenerator.Fill(salt); - var hash = output.Slice(2 + SaltSize, HashSize); + var hash = output.Slice(2 + m_SaltSize, m_HashSize); Rfc2898DeriveBytes.Pbkdf2(plainPassword, salt, hash, iterations, HashAlgorithmName.SHA256); return output.ToHexString(); @@ -47,15 +46,15 @@ public class PBKDF2PasswordProtection : IPasswordProtection public bool ValidatePassword(string encryptedPassword, string plainPassword) { - Span encryptedBytes = stackalloc byte[OutputSize]; + Span encryptedBytes = stackalloc byte[m_OutputSize]; encryptedPassword.GetBytes(encryptedBytes); var iterations = BinaryPrimitives.ReadUInt16LittleEndian(encryptedBytes[..2]); - var salt = encryptedBytes.Slice(2, SaltSize); + var salt = encryptedBytes.Slice(2, m_SaltSize); - Span hash = stackalloc byte[HashSize]; + Span hash = stackalloc byte[m_HashSize]; Rfc2898DeriveBytes.Pbkdf2(plainPassword, salt, hash, iterations, HashAlgorithmName.SHA256); - return hash.SequenceEqual(encryptedBytes[(SaltSize + 2)..]); + return hash.SequenceEqual(encryptedBytes[(m_SaltSize + 2)..]); } } diff --git a/Projects/UOContent/Accounting/Security/PasswordWorker.cs b/Projects/UOContent/Accounting/Security/PasswordWorker.cs deleted file mode 100644 index 83e9207f1..000000000 --- a/Projects/UOContent/Accounting/Security/PasswordWorker.cs +++ /dev/null @@ -1,293 +0,0 @@ -/************************************************************************* - * ModernUO * - * Copyright 2019-2026 - ModernUO Development Team * - * Email: hi@modernuo.com * - * File: PasswordWorker.cs * - * * - * This program is free software: you can redistribute it and/or modify * - * it under the terms of the GNU General Public License as published by * - * the Free Software Foundation, either version 3 of the License, or * - * (at your option) any later version. * - * * - * You should have received a copy of the GNU General Public License * - * along with this program. If not, see . * - *************************************************************************/ - -using System; -using System.Collections.Concurrent; -using System.Threading; -using Server.Logging; -using Server.Network; - -namespace Server.Accounting.Security; - -/// -/// Work handed to the password thread, which reads no game state and writes none. -/// -/// Verify and hash are independently optional: a login verifies and may rehash, an explicit change -/// only hashes. -/// -internal sealed class PasswordJob -{ - public Account Account; - - /// Ties the job to a connection. Null when the work is not gated on one, such as a - /// password change by an admin. - public NetState State; - - /// Hash to verify against, with . - public string StoredHash; - - /// Algorithm was written with. Both algorithms are resolved on - /// the loop; AccountSecurity.CurrentAlgorithm is mutable state the worker must not read. - public PasswordProtectionAlgorithm StoredAlgorithm; - - /// Phrase to verify, or null to skip verification. - public string VerifyPhrase; - - /// Phrase to hash, or null when nothing needs writing. - public string HashPhrase; - - public PasswordProtectionAlgorithm TargetAlgorithm; - - /// Runs on the game loop with the result. Free to touch game state. - public Action OnComplete; -} - -internal readonly struct PasswordOutcome -{ - /// True when no verification was asked for, or it succeeded. - public readonly bool Verified; - - /// The derived hash, or null when nothing was hashed or verification failed. - public readonly string Hash; - - public PasswordOutcome(bool verified, string hash) - { - Verified = verified; - Hash = hash; - } -} - -/// -/// Runs password hashing off the game loop. An Argon2 verify costs ~8.9 ms of frozen world per -/// login attempt, successful or not. -/// -/// Exactly one worker, and that is load-bearing three times over. It cannot cost the loop more than -/// an inline verify under any scheduling regime, because at worst it takes an equal share of one -/// core -- which is what lets the measurement hold on hardware we cannot inspect. It caps live -/// hashing arenas at one. And writes apply in dispatch order only because a single thread drains -/// FIFO, so a second would need ordering reintroduced. -/// -/// ~110 verifies/sec, which is ample: only loop time matters, not login latency. -/// -internal sealed class PasswordWorker -{ - private static readonly ILogger logger = LogFactory.GetLogger(typeof(PasswordWorker)); - - /// - /// Backstop, not a flood defense. SentFirstPacket holds a connection to one pending - /// verify and the engine caps connections at 4096 (NetState.Network.cs), so this matches - /// that bound and can only trip if that invariant breaks. A cap low enough to blunt an attack - /// would reject real players first; flood defense belongs at the connection layer. - /// - private const int MaxPending = 4096; - - // Nothing signals the worker when a save freeze ends, so it re-checks on this interval -- but - // only while a save is in progress, never in steady state. - private const int SaveGatePollMs = 50; - - private static PasswordWorker _instance; - - // Needs a spare core to move work to, which a 1-2 core host does not have. Off in DEBUG, where - // logins are rare and the inline path is easier to follow. - internal static readonly bool Enabled = -#if DEBUG - false; -#else - Environment.ProcessorCount >= 4; -#endif - - private readonly Thread _thread; - private readonly AutoResetEvent _work = new(false); - private readonly ConcurrentQueue _queue = []; - - private int _pending; - private volatile bool _exit; - - private PasswordWorker() - { - _thread = new Thread(Execute) - { - IsBackground = true, - Name = "Password Worker" - }; - - _thread.Start(); - } - - private static PasswordWorker Instance => _instance ??= new PasswordWorker(); - - /// Queues a job. False when full, and the caller must then reject without verifying. - internal static bool TryEnqueue(PasswordJob job) => Instance.TryEnqueueCore(job); - - private bool TryEnqueueCore(PasswordJob job) - { - if (Volatile.Read(ref _pending) >= MaxPending) - { - return false; - } - - Interlocked.Increment(ref _pending); - _queue.Enqueue(job); - _work.Set(); - - return true; - } - - /// - /// Checked before each job, which bounds a save overlap to whichever hash was already running: - /// the freeze holds the loop, so nothing new can be queued during it. PendingSave counts too -- - /// the serialization threads are already awake and spinning on an empty queue by then. - /// - private static bool CanRunNow() => World.WorldState is WorldState.Running or WorldState.WritingSave; - - private void Execute() - { - while (!_exit) - { - if (_queue.IsEmpty) - { - // A kernel block at zero CPU. Set() during a hash leaves the event signalled, so a - // wake arriving mid-job is not lost. - _work.WaitOne(); - continue; - } - - if (!CanRunNow()) - { - _work.WaitOne(SaveGatePollMs); - continue; - } - - if (!_queue.TryDequeue(out var job)) - { - continue; - } - - Interlocked.Decrement(ref _pending); - - // Gone while it waited: skip it rather than hash for a verdict nobody receives. Running - // only goes true -> false, so a stale read wastes a hash but never skips a live one. A - // null State is a job with no connection to lose, and still runs. - if (job.State?.Running == false) - { - continue; - } - - PasswordOutcome outcome; - - try - { - outcome = Compute(job); - } - catch (Exception ex) - { - // A verdict must still come back, or the connection never gets a reply. - logger.Error(ex, "Password work failed for {Username}", job.Account?.Username); - outcome = new PasswordOutcome(false, null); - } - - Core.LoopContext.Post(() => Apply(job, outcome)); - } - } - - private static PasswordOutcome Compute(PasswordJob job) - { - if (job.VerifyPhrase != null && - !AccountSecurity.GetPasswordProtection(job.StoredAlgorithm) - .ValidatePassword(job.StoredHash, job.VerifyPhrase)) - { - return new PasswordOutcome(false, null); - } - - return new PasswordOutcome( - true, - job.HashPhrase == null - ? null : AccountSecurity.GetPasswordProtection(job.TargetAlgorithm).EncryptPassword(job.HashPhrase) - ); - } - - private static void Apply(PasswordJob job, PasswordOutcome outcome) - { - // Re-checked: a connection can drop while the result sits in the loop queue. - if (job.State?.Running == false) - { - return; - } - - if (outcome.Verified && outcome.Hash != null) - { - job.Account.ApplyPasswordWrite(outcome.Hash, job.TargetAlgorithm); - } - - job.OnComplete?.Invoke(job, outcome); - } - - /// - /// Sets a password, off the loop where available and inline otherwise, invoking - /// on the loop either way. - /// - /// Confirm from , not the call site: off-loop the write has not - /// happened when this returns. - /// - internal static void SetPassword(Account account, string plainPassword, Action onDone) - { - if (!Enabled) - { - account.SetPassword(plainPassword); - onDone?.Invoke(true); - return; - } - - var job = new PasswordJob - { - Account = account, - HashPhrase = account.GetRehashPhrase(plainPassword), - TargetAlgorithm = AccountSecurity.CurrentAlgorithm, - OnComplete = (_, outcome) => onDone?.Invoke(outcome.Hash != null) - }; - - if (!TryEnqueue(job)) - { - // Saturated. Unlike a login, a password change must not be dropped, so it pays the - // hash on the loop instead. - account.SetPassword(plainPassword); - onDone?.Invoke(true); - } - } - - /// Runs a job on the calling thread. The seam the tests drive. - internal static PasswordOutcome ComputeInline(PasswordJob job) => Compute(job); - - /// - /// Stops the worker on shutdown or crash. Pending jobs are dropped rather than finished: - /// nothing saves the world after this point, so a write applied here would reach no disk. - /// - /// Draining the loop context is not this type's business either. That belongs in the core - /// shutdown path, before subscriber events run -- a subscriber pumping the shared context would - /// execute other subscribers' work at an arbitrary point in the event order. - /// - internal static void Stop() => _instance?.StopThread(); - - // HandleClosed skips InvokeShutdown when the server crashed, so the crash path needs its own - // subscription. - internal static void OnCrashed(ServerCrashedEventArgs e) => Stop(); - - private void StopThread() - { - _exit = true; - _work.Set(); - _thread.Join(TimeSpan.FromSeconds(5)); - } -} diff --git a/Projects/UOContent/Commands/LoopStats.cs b/Projects/UOContent/Commands/LoopStats.cs deleted file mode 100644 index 012de68d8..000000000 --- a/Projects/UOContent/Commands/LoopStats.cs +++ /dev/null @@ -1,117 +0,0 @@ -#if EVENT_LOOP_PROFILING -using System; -using System.Globalization; -using System.IO; -using Server.Logging; - -namespace Server.Commands; - -/// -/// Reports the event-loop time decomposition recorded by . -/// Only compiled when the server is built with -p:EventLoopProfiling=true. -/// See dev-docs/debugging-event-loop.md for how to read the output. -/// -public static class LoopStats -{ - private static readonly ILogger logger = LogFactory.GetLogger(typeof(LoopStats)); - - public static void Configure() - { - CommandSystem.Register("LoopStats", AccessLevel.Administrator, LoopStats_OnCommand); - } - - [Usage("LoopStats")] - [Description("Summarizes the last minute of event-loop time accounting and writes the full history to a CSV.")] - private static void LoopStats_OnCommand(CommandEventArgs e) - { - var history = EventLoopProfiler.History(); - if (history.Length == 0) - { - e.Mobile.SendMessage("No samples recorded yet."); - return; - } - - var window = Math.Min(60, history.Length); - - double wall = 0, sleep = 0, gc = 0, stolen = 0, stolenMax = 0; - long iterations = 0, sleeps = 0, lateWakes = 0, wheelLagMax = 0; - Span phases = stackalloc double[EventLoopProfiler.PhaseCount]; - Span phaseMax = stackalloc double[EventLoopProfiler.PhaseCount]; - - for (var i = history.Length - window; i < history.Length; i++) - { - ref var s = ref history[i]; - wall += s.WallMs; - sleep += s.SleepMs; - gc += s.GcPauseMs; - stolen += s.StolenMs; - iterations += s.Iterations; - sleeps += s.Sleeps; - lateWakes += s.LateWakes; - - if (s.StolenMs > stolenMax) - { - stolenMax = s.StolenMs; - } - - if (s.WheelLagMaxMs > wheelLagMax) - { - wheelLagMax = s.WheelLagMaxMs; - } - - for (var p = 0; p < EventLoopProfiler.PhaseCount; p++) - { - phases[p] += s.Phases[p]; - if (s.Phases[p] > phaseMax[p]) - { - phaseMax[p] = s.Phases[p]; - } - } - } - - e.Mobile.SendMessage($"Loop, last {window}s of wall time {wall:F0}ms:"); - e.Mobile.SendMessage($" sleep {100 * sleep / wall:F1}%, gc {100 * gc / wall:F1}%, stolen {100 * stolen / wall:F1}% (worst {stolenMax:F0}ms/s)"); - - for (var p = 0; p < EventLoopProfiler.PhaseCount; p++) - { - e.Mobile.SendMessage($" {(LoopPhase)p}: {100 * phases[p] / wall:F1}% (worst {phaseMax[p]:F0}ms/s)"); - } - - e.Mobile.SendMessage($" {iterations} iterations, {sleeps} sleeps, {lateWakes} late wakes, worst wheel lag {wheelLagMax}ms"); - - var path = Path.Combine(Core.BaseDirectory, $"loopstats-{Core.Now:yyyyMMdd-HHmmss}.csv"); - WriteCsv(path, history); - e.Mobile.SendMessage($"Full history ({history.Length} samples) written to {path}"); - logger.Information("Loop stats dumped to {Path}", path); - } - - private static void WriteCsv(string path, EventLoopProfiler.Sample[] history) - { - using var writer = new StreamWriter(path); - writer.Write("wallStart,wallMs,iterations,sleeps,sleepMs,sleepOvershootMaxMs,lateWakes,wheelLagMaxMs,wakesIssued,wakesElided,gcPauseMs,gen0,gen1,gen2,stolenMs"); - for (var p = 0; p < EventLoopProfiler.PhaseCount; p++) - { - writer.Write(','); - writer.Write((LoopPhase)p); - } - - writer.WriteLine(); - - for (var i = 0; i < history.Length; i++) - { - ref var s = ref history[i]; - writer.Write(string.Create( - CultureInfo.InvariantCulture, - $"{s.WallStart},{s.WallMs},{s.Iterations},{s.Sleeps},{s.SleepMs:F2},{s.SleepOvershootMaxMs:F2},{s.LateWakes},{s.WheelLagMaxMs},{s.WakesIssued},{s.WakesElided},{s.GcPauseMs:F2},{s.Gen0},{s.Gen1},{s.Gen2},{s.StolenMs:F2}" - )); - for (var p = 0; p < EventLoopProfiler.PhaseCount; p++) - { - writer.Write(','); - writer.Write(string.Create(CultureInfo.InvariantCulture, $"{s.Phases[p]:F2}")); - } - - writer.WriteLine(); - } - } -} -#endif diff --git a/Projects/UOContent/Commands/VisibilityList.cs b/Projects/UOContent/Commands/VisibilityList.cs index ddad258c2..3e4a0dc2a 100644 --- a/Projects/UOContent/Commands/VisibilityList.cs +++ b/Projects/UOContent/Commands/VisibilityList.cs @@ -103,8 +103,9 @@ namespace Server.Commands { var list = pm.VisibilityList; - if (list.Remove(targ)) + if (list.Contains(targ)) { + list.Remove(targ); pm.SendMessage($"{targ.Name} has been removed from your visibility list."); } else diff --git a/Projects/UOContent/Engines/Advanced Search/AdvancedSearchUtilities.cs b/Projects/UOContent/Engines/Advanced Search/AdvancedSearchUtilities.cs index 6775dcaef..2c349847d 100644 --- a/Projects/UOContent/Engines/Advanced Search/AdvancedSearchUtilities.cs +++ b/Projects/UOContent/Engines/Advanced Search/AdvancedSearchUtilities.cs @@ -1,5 +1,6 @@ using System; using System.Buffers; +using System.Collections.Generic; using System.Globalization; using System.Numerics; using System.Runtime.CompilerServices; diff --git a/Projects/UOContent/Engines/Bulk Orders/Books/BOBFilter.cs b/Projects/UOContent/Engines/Bulk Orders/Books/BOBFilter.cs index f63e4f8d4..962c3d8e3 100644 --- a/Projects/UOContent/Engines/Bulk Orders/Books/BOBFilter.cs +++ b/Projects/UOContent/Engines/Bulk Orders/Books/BOBFilter.cs @@ -6,27 +6,27 @@ namespace Server.Engines.BulkOrders; public partial class BOBFilter { [SerializableField(0)] - [SaveFlag(nameof(ShouldSerializeType))] private int _type; + [SerializableFieldSaveFlag(0)] private bool ShouldSerializeType() => _type != 0; [SerializableField(1)] - [SaveFlag(nameof(ShouldSerializeQuality))] private int _quality; + [SerializableFieldSaveFlag(1)] private bool ShouldSerializeQuality() => _quality != 0; [SerializableField(2)] - [SaveFlag(nameof(ShouldSerializeMaterial))] private int _material; + [SerializableFieldSaveFlag(2)] private bool ShouldSerializeMaterial() => _material != 0; [SerializableField(3)] - [SaveFlag(nameof(ShouldSerializeQuantity))] private int _quantity; + [SerializableFieldSaveFlag(3)] private bool ShouldSerializeQuantity() => _quantity != 0; private void Deserialize(IGenericReader reader, int version) diff --git a/Projects/UOContent/Engines/CannedEvil/ChampionSkullBrazier.cs b/Projects/UOContent/Engines/CannedEvil/ChampionSkullBrazier.cs index 2632fd954..9ff8dc633 100755 --- a/Projects/UOContent/Engines/CannedEvil/ChampionSkullBrazier.cs +++ b/Projects/UOContent/Engines/CannedEvil/ChampionSkullBrazier.cs @@ -33,13 +33,17 @@ public partial class ChampionSkullBrazier : AddonComponent [SerializedCommandProperty(AccessLevel.GameMaster)] private ChampionSkullPlatform _platform; - [SerializableField(2, fieldChanged: nameof(OnSkullChanged))] - [SerializedCommandProperty(AccessLevel.GameMaster)] - private Item _skull; - - private void OnSkullChanged(Item oldValue, Item newValue) + [SerializableProperty(2)] + [CommandProperty(AccessLevel.GameMaster)] + public Item Skull { - _platform?.Validate(); + get => _skull; + set + { + _skull = value; + this.MarkDirty(); + _platform?.Validate(); + } } public override int LabelNumber => 1049489 + (int)_type; diff --git a/Projects/UOContent/Engines/CannedEvil/ChampionSpawn.cs b/Projects/UOContent/Engines/CannedEvil/ChampionSpawn.cs index 3a024576b..a26bcc0f9 100755 --- a/Projects/UOContent/Engines/CannedEvil/ChampionSpawn.cs +++ b/Projects/UOContent/Engines/CannedEvil/ChampionSpawn.cs @@ -18,7 +18,6 @@ using System.Net; using System.Collections.Generic; using System.Runtime.InteropServices; using ModernUO.Serialization; -using Server.Collections; using Server.Engines.Virtues; using Server.Gumps; using Server.Items; @@ -28,44 +27,16 @@ using Server.Logging; namespace Server.Engines.CannedEvil; -[SerializationGenerator(11, false)] +[SerializationGenerator(10, false)] public partial class ChampionSpawn : Item { - private void MigrateFrom(V10Content content) - { - _level = content.Level; - _activatedByProximity = content.ActivatedByProximity; - _nextProximityTime = content.NextProximityTime; - _maxLevel = content.MaxLevel; - _activatedByValor = content.ActivatedByValor; - _damageEntries = content.DamageEntries; - _confinedRoaming = content.ConfinedRoaming; - _idol = content.Idol; - _hasBeenAdvanced = content.HasBeenAdvanced; - _spawnArea = content.SpawnArea; - _randomizeType = content.RandomizeType; - _kills = content.Kills; - _active = content.Active; - _type = content.Type; - _creatures = content.Creatures; - _redSkulls = content.RedSkulls; - _whiteSkulls = content.WhiteSkulls; - _platform = content.Platform; - _altar = content.Altar; - _expireDelay = content.ExpireDelay; - _expireTime = content.ExpireTime; - _champion = content.Champion; - _restartDelay = content.RestartDelay; - _restartTime = content.RestartTime; - } - private static readonly ILogger logger = LogFactory.GetLogger(typeof(ChampionSpawn)); [SerializableField(1)] [SerializedCommandProperty(AccessLevel.GameMaster)] private bool _activatedByProximity; - [AnchoredDateTime] + [DeltaDateTime] [SerializableField(2)] [SerializedCommandProperty(AccessLevel.GameMaster)] private DateTime _nextProximityTime; @@ -125,7 +96,7 @@ public partial class ChampionSpawn : Item [SerializedCommandProperty(AccessLevel.GameMaster)] private TimeSpan _expireDelay; - [AnchoredDateTime] + [DeltaDateTime] [SerializableField(20)] [SerializedCommandProperty(AccessLevel.GameMaster)] private DateTime _expireTime; @@ -138,7 +109,7 @@ public partial class ChampionSpawn : Item [SerializedCommandProperty(AccessLevel.GameMaster)] private TimeSpan _restartDelay; - [AnchoredDateTime] + [DeltaDateTime] [SerializableField(23, setter: "private")] [SerializedCommandProperty(AccessLevel.GameMaster)] private DateTime _restartTime; @@ -232,38 +203,47 @@ public partial class ChampionSpawn : Item } } - [SerializableField(3, allowFieldChange: nameof(AllowMaxLevelChange))] - [SerializedCommandProperty(AccessLevel.GameMaster, AccessLevel.Administrator)] - private int _maxLevel; - - private bool AllowMaxLevelChange(ref int value) + [SerializableProperty(3)] + [CommandProperty(AccessLevel.GameMaster, AccessLevel.Administrator)] + public int MaxLevel { - value = Math.Clamp(value, 0, 18); - return true; + get => _maxLevel; + set => _maxLevel = Math.Clamp(value, 0, 18); } - [SerializableField(9, fieldChanged: nameof(OnSpawnAreaChanged))] - [SerializedCommandProperty(AccessLevel.GameMaster)] - [InvalidateProperties] - private Rectangle2D _spawnArea; - - private void OnSpawnAreaChanged(Rectangle2D oldValue, Rectangle2D newValue) + [SerializableProperty(9)] + [CommandProperty(AccessLevel.GameMaster)] + public Rectangle2D SpawnArea { - UpdateRegion(); - } - - [SerializableField(11, fieldChanged: nameof(OnKillsChanged))] - [SerializedCommandProperty(AccessLevel.GameMaster)] - [InvalidateProperties] - private int _kills; - - private void OnKillsChanged(int oldValue, int newValue) - { - var n = _kills / (double)MaxKills; - var p = (int)(n * 100); - if (p < 90) + get => _spawnArea; + set { - SetWhiteSkullCount(p / 20); + _spawnArea = value; + this.MarkDirty(); + InvalidateProperties(); + UpdateRegion(); + } + } + + [SerializableProperty(11)] + [CommandProperty(AccessLevel.GameMaster)] + public int Kills + { + get => _kills; + set + { + _kills = value; + this.MarkDirty(); + + var n = _kills / (double)MaxKills; + var p = (int)(n * 100); + + if (p < 90) + { + SetWhiteSkullCount(p / 20); + } + + InvalidateProperties(); } } @@ -1182,6 +1162,11 @@ public partial class ChampionSpawn : Item foreach (var de in m.DamageEntries) { + if (de.HasExpired) + { + continue; + } + var damager = de.Damager; var master = damager.GetDamageMaster(m); diff --git a/Projects/UOContent/Engines/CannedEvil/ChampionTitleContext.cs b/Projects/UOContent/Engines/CannedEvil/ChampionTitleContext.cs index 644e2b965..bfd00c0bf 100644 --- a/Projects/UOContent/Engines/CannedEvil/ChampionTitleContext.cs +++ b/Projects/UOContent/Engines/CannedEvil/ChampionTitleContext.cs @@ -51,9 +51,9 @@ public partial class ChampionTitleContext } [SerializableField(1)] - [SaveFlag(nameof(ShouldSerializeAbyss))] private ChampionTitle _abyss; + [SerializableFieldSaveFlag(1)] private bool ShouldSerializeAbyss() => _abyss != null; [CommandProperty(AccessLevel.GameMaster)] @@ -71,9 +71,9 @@ public partial class ChampionTitleContext } [SerializableField(2)] - [SaveFlag(nameof(ShouldSerializeArachnid))] private ChampionTitle _arachnid; + [SerializableFieldSaveFlag(2)] private bool ShouldSerializeArachnid() => _arachnid != null; [CommandProperty(AccessLevel.GameMaster)] @@ -91,9 +91,9 @@ public partial class ChampionTitleContext } [SerializableField(3)] - [SaveFlag(nameof(ShouldSerializeColdBlood))] private ChampionTitle _coldBlood; + [SerializableFieldSaveFlag(3)] private bool ShouldSerializeColdBlood() => _coldBlood != null; [CommandProperty(AccessLevel.GameMaster)] @@ -111,9 +111,9 @@ public partial class ChampionTitleContext } [SerializableField(4)] - [SaveFlag(nameof(ShouldSerializeForestLord))] private ChampionTitle _forestLord; + [SerializableFieldSaveFlag(4)] private bool ShouldSerializeForestLord() => _forestLord != null; [CommandProperty(AccessLevel.GameMaster)] @@ -131,9 +131,9 @@ public partial class ChampionTitleContext } [SerializableField(5)] - [SaveFlag(nameof(ShouldSerializeVerminHorde))] private ChampionTitle _verminHorde; + [SerializableFieldSaveFlag(5)] private bool ShouldSerializeVerminHorde() => _verminHorde != null; [CommandProperty(AccessLevel.GameMaster)] @@ -151,9 +151,9 @@ public partial class ChampionTitleContext } [SerializableField(6)] - [SaveFlag(nameof(ShouldSerializeUnholyTerror))] private ChampionTitle _unholyTerror; + [SerializableFieldSaveFlag(6)] private bool ShouldSerializeUnholyTerror() => _unholyTerror != null; [CommandProperty(AccessLevel.GameMaster)] @@ -171,9 +171,9 @@ public partial class ChampionTitleContext } [SerializableField(7)] - [SaveFlag(nameof(ShouldSerializeSleepingDragon))] private ChampionTitle _sleepingDragon; + [SerializableFieldSaveFlag(7)] private bool ShouldSerializeSleepingDragon() => _sleepingDragon != null; [CommandProperty(AccessLevel.GameMaster)] @@ -191,9 +191,9 @@ public partial class ChampionTitleContext } [SerializableField(8)] - [SaveFlag(nameof(ShouldSerializeCorrupt))] private ChampionTitle _corrupt; + [SerializableFieldSaveFlag(8)] private bool ShouldSerializeCorrupt() => _corrupt != null; [CommandProperty(AccessLevel.GameMaster)] @@ -211,9 +211,9 @@ public partial class ChampionTitleContext } [SerializableField(9)] - [SaveFlag(nameof(ShouldSerializeGlade))] private ChampionTitle _glade; + [SerializableFieldSaveFlag(9)] private bool ShouldSerializeGlade() => _glade != null; [CommandProperty(AccessLevel.GameMaster)] diff --git a/Projects/UOContent/Engines/CannedEvil/ChampionTitleSystem.cs b/Projects/UOContent/Engines/CannedEvil/ChampionTitleSystem.cs index 6160c4595..d82bfaf15 100644 --- a/Projects/UOContent/Engines/CannedEvil/ChampionTitleSystem.cs +++ b/Projects/UOContent/Engines/CannedEvil/ChampionTitleSystem.cs @@ -167,13 +167,20 @@ public class ChampionTitleSystem : GenericPersistence return; } + using var queue = PooledRefQueue.Create(); + foreach (var context in _championTitleContexts.Values) { if (!context.CheckAtrophy()) { - _championTitleContexts.Remove(context.Player); + queue.Enqueue(context.Player); } } + + while (queue.Count > 0) + { + _championTitleContexts.Remove((PlayerMobile)queue.Dequeue()); + } } } } diff --git a/Projects/UOContent/Engines/CannedEvil/StarRoomGate.cs b/Projects/UOContent/Engines/CannedEvil/StarRoomGate.cs index 88acd587e..a5426e7a5 100644 --- a/Projects/UOContent/Engines/CannedEvil/StarRoomGate.cs +++ b/Projects/UOContent/Engines/CannedEvil/StarRoomGate.cs @@ -3,21 +3,15 @@ using ModernUO.Serialization; namespace Server.Items; -[SerializationGenerator(2, false)] +[SerializationGenerator(1, false)] public partial class StarRoomGate : Moongate { - private void MigrateFrom(V1Content content) - { - _decays = content.Decays; - _decayTime = content.DecayTime; - } - private static TimeSpan GateDuration = TimeSpan.FromMinutes(2.0); [SerializableField(0)] private bool _decays; - [AnchoredDateTime] + [DeltaDateTime] [SerializableField(1)] private DateTime _decayTime; diff --git a/Projects/UOContent/Engines/Chat/Channel.cs b/Projects/UOContent/Engines/Chat/Channel.cs index 69f33ddcf..09b8f6cb3 100644 --- a/Projects/UOContent/Engines/Chat/Channel.cs +++ b/Projects/UOContent/Engines/Chat/Channel.cs @@ -146,8 +146,15 @@ namespace Server.Engines.Chat m_Users.Remove(user); user.CurrentChannel = null; - m_Moderators.Remove(user); - m_Voices.Remove(user); + if (m_Moderators.Contains(user)) + { + m_Moderators.Remove(user); + } + + if (m_Voices.Contains(user)) + { + m_Voices.Remove(user); + } SendCommand(ChatCommand.RemoveUserFromChannel, user, user.Username); ChatSystem.SendCommandTo(user.Mobile, ChatCommand.LeaveChannel); @@ -176,7 +183,10 @@ namespace Server.Engines.Chat public void RemoveBan(ChatUser user) { - m_Banned.Remove(user); + if (m_Banned.Contains(user)) + { + m_Banned.Remove(user); + } } public void Kick(ChatUser user, ChatUser moderator = null) diff --git a/Projects/UOContent/Engines/ConPVP/Games/BombingRun.cs b/Projects/UOContent/Engines/ConPVP/Games/BombingRun.cs index 6ab4fffea..29e0b9949 100644 --- a/Projects/UOContent/Engines/ConPVP/Games/BombingRun.cs +++ b/Projects/UOContent/Engines/ConPVP/Games/BombingRun.cs @@ -716,7 +716,10 @@ public partial class BRBomb : Item m.Target = new BombTarget(this, m); - m_Helpers.Remove(m); + if (m_Helpers.Contains(m)) + { + m_Helpers.Remove(m); + } if (m_Helpers.Count > 0) { @@ -830,9 +833,10 @@ public partial class BRBomb : Item [SerializationGenerator(0, false)] public partial class BRGoal : BaseAddon { - [SerializableField(0, fieldChanged: nameof(OnNorthChanged))] + [SerializableField(0)] private bool _north; + [SerializableFieldChanged(0)] [MethodImpl(MethodImplOptions.AggressiveInlining)] private void OnNorthChanged(bool oldValue, bool newValue) => Remake(); diff --git a/Projects/UOContent/Engines/ConPVP/Games/KingOfTheHill.cs b/Projects/UOContent/Engines/ConPVP/Games/KingOfTheHill.cs index 6aec6f328..6f73a0949 100644 --- a/Projects/UOContent/Engines/ConPVP/Games/KingOfTheHill.cs +++ b/Projects/UOContent/Engines/ConPVP/Games/KingOfTheHill.cs @@ -252,9 +252,10 @@ public partial class HillOfTheKing : Item public partial class KHBoard : Item { [SerializedCommandProperty(AccessLevel.GameMaster)] - [SerializableField(0, fieldChanged: nameof(OnControllerChanged))] + [SerializableField(0)] private KHController _controller; + [SerializableFieldChanged(0)] private void OnControllerChanged(KHController oldValue, KHController newValue) { oldValue?.RemoveBoard(this); diff --git a/Projects/UOContent/Engines/ConPVP/Ruleset.cs b/Projects/UOContent/Engines/ConPVP/Ruleset.cs index 895496c43..fb4a1ec8b 100644 --- a/Projects/UOContent/Engines/ConPVP/Ruleset.cs +++ b/Projects/UOContent/Engines/ConPVP/Ruleset.cs @@ -56,11 +56,12 @@ namespace Server.Engines.ConPVP public void RemoveFlavor(Ruleset flavor) { - if (!Flavors.Remove(flavor)) + if (!Flavors.Contains(flavor)) { return; } + Flavors.Remove(flavor); Options.And(flavor.Options.Not()); flavor.Options.Not(); } diff --git a/Projects/UOContent/Engines/ConPVP/Trophy.cs b/Projects/UOContent/Engines/ConPVP/Trophy.cs index 603b52ef3..fa712de33 100644 --- a/Projects/UOContent/Engines/ConPVP/Trophy.cs +++ b/Projects/UOContent/Engines/ConPVP/Trophy.cs @@ -64,13 +64,17 @@ public partial class Trophy : Item UpdateStyle(); } - [SerializableField(1, fieldChanged: nameof(OnRankChanged))] - [SerializedCommandProperty(AccessLevel.GameMaster)] - private TrophyRank _rank; - - private void OnRankChanged(TrophyRank oldValue, TrophyRank newValue) + [SerializableProperty(1)] + [CommandProperty(AccessLevel.GameMaster)] + public TrophyRank Rank { - UpdateStyle(); + get => _rank; + set + { + _rank = value; + UpdateStyle(); + this.MarkDirty(); + } } private void Deserialize(IGenericReader reader, int version) diff --git a/Projects/UOContent/Engines/Craft/Core/CraftItem.cs b/Projects/UOContent/Engines/Craft/Core/CraftItem.cs index e99760bbc..5a31aec3c 100644 --- a/Projects/UOContent/Engines/Craft/Core/CraftItem.cs +++ b/Projects/UOContent/Engines/Craft/Core/CraftItem.cs @@ -1451,9 +1451,6 @@ namespace Server.Engines.Craft if (item != null) { - // Stamped here, not in OnCraft: most craftables do not implement ICraftable. - item.PlayerConstructed = true; - if (item is ICraftable craftable) { endquality = craftable.OnCraft(quality, makersMark, from, craftSystem, typeRes, tool, this, resHue); @@ -1745,9 +1742,6 @@ namespace Server.Engines.Craft if (item != null) { - // Stamped here, not in OnCraft: most craftables do not implement ICraftable. - item.PlayerConstructed = true; - if (item is ICraftable craftable) { endquality = craftable.OnCraft(quality, makersMark, from, craftSystem, typeRes, tool, this, resHue); diff --git a/Projects/UOContent/Engines/Ethics/Core/Player.cs b/Projects/UOContent/Engines/Ethics/Core/Player.cs index d8aad9eb3..f21fe9eb9 100644 --- a/Projects/UOContent/Engines/Ethics/Core/Player.cs +++ b/Projects/UOContent/Engines/Ethics/Core/Player.cs @@ -5,20 +5,9 @@ using Server.Mobiles; namespace Server.Ethics; [PropertyObject] -[SerializationGenerator(2)] +[SerializationGenerator(1)] public partial class Player : EthicsEntity { - private void MigrateFrom(V1Content content) - { - _mobile = content.Mobile; - _power = content.Power; - _history = content.History; - _steed = content.Steed; - _familiar = content.Familiar; - _shield = content.Shield; - _ethic = content.Ethic; - } - [SerializableField(0, setter: "private")] private Mobile _mobile; @@ -38,7 +27,7 @@ public partial class Player : EthicsEntity [SerializedCommandProperty(AccessLevel.GameMaster, AccessLevel.Administrator)] private Mobile _familiar; - [AnchoredDateTime] + [DeltaDateTime] [SerializableField(5, setter: "private")] private DateTime _shield; diff --git a/Projects/UOContent/Engines/Factions/Mobiles/Guards/GuardAI.cs b/Projects/UOContent/Engines/Factions/Mobiles/Guards/GuardAI.cs index f0fb2386f..32e8566cb 100644 --- a/Projects/UOContent/Engines/Factions/Mobiles/Guards/GuardAI.cs +++ b/Projects/UOContent/Engines/Factions/Mobiles/Guards/GuardAI.cs @@ -359,14 +359,14 @@ namespace Server.Factions { if (m_Mobile.InRange( m, 1 )) RunFrom( m ); - else if (!m_Mobile.InRange( m, m_Mobile.RangeFight > 2 ? m_Mobile.RangeFight : 2 ) && !MoveTo(m, 1)) + else if (!m_Mobile.InRange( m, m_Mobile.RangeFight > 2 ? m_Mobile.RangeFight : 2 ) && !MoveTo( m, true, 1 )) OnFailedMove(); } else {*/ if (!Mobile.InRange(m, Mobile.RangeFight)) { - if (!MoveTo(m, 1)) + if (!MoveTo(m, true, 1)) { OnFailedMove(); } diff --git a/Projects/UOContent/Engines/Harvest/Core/HarvestSystem.cs b/Projects/UOContent/Engines/Harvest/Core/HarvestSystem.cs index b67665d04..b82040134 100644 --- a/Projects/UOContent/Engines/Harvest/Core/HarvestSystem.cs +++ b/Projects/UOContent/Engines/Harvest/Core/HarvestSystem.cs @@ -219,7 +219,7 @@ namespace Server.Engines.Harvest } else { - bonusItem?.Delete(); + item.Delete(); } } diff --git a/Projects/UOContent/Engines/Khaldun/PuzzleChest.cs b/Projects/UOContent/Engines/Khaldun/PuzzleChest.cs index 53932c8f0..a5872e437 100644 --- a/Projects/UOContent/Engines/Khaldun/PuzzleChest.cs +++ b/Projects/UOContent/Engines/Khaldun/PuzzleChest.cs @@ -162,15 +162,10 @@ namespace Server.Items } } - [SerializationGenerator(1)] + [SerializationGenerator(0)] public partial class PuzzleChestSolutionAndTime : PuzzleChestSolution { - private void MigrateFrom(V0Content content) - { - _when = content.When; - } - - [AnchoredDateTime] + [DeltaDateTime] [SerializableField(0)] private DateTime _when; @@ -242,12 +237,16 @@ namespace Server.Items } } - [SerializableField(0, fieldChanged: nameof(OnSolutionChanged))] - private PuzzleChestSolution _solution; - - private void OnSolutionChanged(PuzzleChestSolution oldValue, PuzzleChestSolution newValue) + [SerializableProperty(0)] + public PuzzleChestSolution Solution { - InitHints(); + get => _solution; + set + { + _solution = value; + InitHints(); + this.MarkDirty(); + } } public PuzzleChestCylinder FirstHint @@ -551,14 +550,21 @@ namespace Server.Items return; } + using var toDelete = PooledRefQueue.Create(); + foreach (var (key, value) in _guesses) { if (Core.Now - value.When > CleanupTime) { - _guesses.Remove(key); + toDelete.Enqueue(key); } } + while (toDelete.Count > 0) + { + _guesses.Remove(toDelete.Dequeue()); + } + if (_guesses.Count == 0) { _guesses = null; diff --git a/Projects/UOContent/Engines/ML Quests/Gumps/RaceChangeGump.cs b/Projects/UOContent/Engines/ML Quests/Gumps/RaceChangeGump.cs index a0de2461d..6e37f88f5 100644 --- a/Projects/UOContent/Engines/ML Quests/Gumps/RaceChangeGump.cs +++ b/Projects/UOContent/Engines/ML Quests/Gumps/RaceChangeGump.cs @@ -116,9 +116,10 @@ namespace Server.Engines.MLQuests.Gumps private static void CloseCurrent(NetState ns) { - if (m_Pending.Remove(ns, out var state)) + if (m_Pending.TryGetValue(ns, out var state)) { state._timeoutToken.Cancel(); + m_Pending.Remove(ns); } ns.SendCloseRaceChanger(); diff --git a/Projects/UOContent/Engines/ML Quests/MLQuestPersistence.cs b/Projects/UOContent/Engines/ML Quests/MLQuestPersistence.cs index d3e39c09f..5d846d595 100644 --- a/Projects/UOContent/Engines/ML Quests/MLQuestPersistence.cs +++ b/Projects/UOContent/Engines/ML Quests/MLQuestPersistence.cs @@ -19,7 +19,7 @@ namespace Server.Engines.MLQuests { base.Serialize(writer); - writer.Write(3); // version + writer.Write(2); // version writer.Write(MLQuestSystem.Contexts.Count); foreach (var context in MLQuestSystem.Contexts.Values) diff --git a/Projects/UOContent/Engines/ML Quests/Objectives/BaseObjective.cs b/Projects/UOContent/Engines/ML Quests/Objectives/BaseObjective.cs index 77c3350f5..2838c7736 100644 --- a/Projects/UOContent/Engines/ML Quests/Objectives/BaseObjective.cs +++ b/Projects/UOContent/Engines/ML Quests/Objectives/BaseObjective.cs @@ -119,7 +119,7 @@ namespace Server.Engines.MLQuests.Objectives if (IsTimed) { writer.Write(true); - writer.WriteAnchoredTime(EndTime); + writer.WriteDeltaTime(EndTime); } else { @@ -135,7 +135,7 @@ namespace Server.Engines.MLQuests.Objectives { if (reader.ReadBool()) { - var endTime = version >= 3 ? reader.ReadAnchoredTime() : reader.ReadDeltaTime(); + var endTime = reader.ReadDeltaTime(); if (objInstance != null) { diff --git a/Projects/UOContent/Engines/Pathing/Cache/StepCache.cs b/Projects/UOContent/Engines/Pathing/Cache/StepCache.cs index b4c5eba7c..adf178927 100644 --- a/Projects/UOContent/Engines/Pathing/Cache/StepCache.cs +++ b/Projects/UOContent/Engines/Pathing/Cache/StepCache.cs @@ -727,14 +727,20 @@ public sealed class StepCache var window = MissPromotionWindowMs; var beforeCount = _chunkMissTracker.Count; + using var toRemove = PooledRefQueue.Create(); foreach (var kvp in _chunkMissTracker) { if (now - kvp.Value.LastMissTickStamp > window) { - _chunkMissTracker.Remove(kvp.Key); + toRemove.Enqueue(kvp.Key); } } + while (toRemove.Count > 0) + { + _chunkMissTracker.Remove(toRemove.Dequeue()); + } + if (_chunkMissTracker.Count == beforeCount) { _chunkMissTracker.Clear(); diff --git a/Projects/UOContent/Engines/Pathing/MovementPath.cs b/Projects/UOContent/Engines/Pathing/MovementPath.cs index d1ef874fc..32aa8ba11 100644 --- a/Projects/UOContent/Engines/Pathing/MovementPath.cs +++ b/Projects/UOContent/Engines/Pathing/MovementPath.cs @@ -1,5 +1,7 @@ using System; using System.Diagnostics; +using Server.Engines.Pathing; +using Server.Engines.Pathing.Cache; using Server.Items; using Server.PathAlgorithms; using Server.Spells; diff --git a/Projects/UOContent/Engines/Pathing/PathFollower.cs b/Projects/UOContent/Engines/Pathing/PathFollower.cs index 04aaf0148..3a0a56fa5 100644 --- a/Projects/UOContent/Engines/Pathing/PathFollower.cs +++ b/Projects/UOContent/Engines/Pathing/PathFollower.cs @@ -83,7 +83,7 @@ public class PathFollower public static bool Check(Point3D loc, Point3D goal, int range) => Utility.InRange(loc, goal, range) && (range > 1 || (loc.Z - goal.Z).Abs() < 16); - public bool Follow(int range) + public bool Follow(bool run, int range) { var goal = GetGoalLocation(); Direction d; @@ -97,13 +97,13 @@ public class PathFollower if (!(Enabled && m_Path.Success)) { - d = m_From.GetDirectionTo(goal); + d = m_From.GetDirectionTo(goal, run); m_From.SetDirection(d); return Move(d) is MoveResult.Success or MoveResult.SuccessAutoTurn && Check(m_From.Location, goal, range); } - d = m_From.GetDirectionTo(m_Next); + d = m_From.GetDirectionTo(m_Next, run); m_From.SetDirection(d); var res = Move(d); diff --git a/Projects/UOContent/Engines/Plants/PlantItem.cs b/Projects/UOContent/Engines/Plants/PlantItem.cs index 5f66b1a7a..60451f53b 100644 --- a/Projects/UOContent/Engines/Plants/PlantItem.cs +++ b/Projects/UOContent/Engines/Plants/PlantItem.cs @@ -37,17 +37,17 @@ public partial class PlantItem : Item, ISecurable [SerializedIgnoreDupe] [SerializableField(0)] - [SaveFlag(nameof(ShouldSerializeSecureLevel))] [SerializedCommandProperty(AccessLevel.GameMaster)] private SecureLevel _level; + [SerializableFieldSaveFlag(0)] private bool ShouldSerializeSecureLevel() => (int)_level != 0; [SerializedIgnoreDupe] [SerializableField(5, setter: "private")] - [SaveFlag(nameof(ShouldSerializePlantSystem))] private PlantSystem _plantSystem; + [SerializableFieldSaveFlag(5)] private bool ShouldSerializePlantSystem() => _plantStatus < PlantStatus.DecorativePlant; // For clients older than 7.0.12.0 @@ -82,7 +82,6 @@ public partial class PlantItem : Item, ISecurable [CommandProperty(AccessLevel.GameMaster)] [SerializableProperty(1)] - [SaveFlag(nameof(ShouldSerializePlantStatus))] public PlantStatus PlantStatus { get => _plantStatus; @@ -121,38 +120,53 @@ public partial class PlantItem : Item, ISecurable } } + [SerializableFieldSaveFlag(1)] private bool ShouldSerializePlantStatus() => _plantStatus != PlantStatus.BowlOfDirt; - [SerializableField(2, fieldChanged: nameof(OnPlantTypeChanged))] - [SaveFlag(nameof(ShouldSerializePlantType))] - [SerializedCommandProperty(AccessLevel.GameMaster)] - private PlantType _plantType; - - private void OnPlantTypeChanged(PlantType oldValue, PlantType newValue) + [SerializableProperty(2)] + [CommandProperty(AccessLevel.GameMaster)] + public PlantType PlantType { - Update(); + get => _plantType; + set + { + _plantType = value; + Update(); + } } + [SerializableFieldSaveFlag(2)] private bool ShouldSerializePlantType() => (int)_plantType != 0; - [SerializableField(3, fieldChanged: nameof(OnPlantHueChanged))] - [SaveFlag(nameof(ShouldSerializePlantHue))] - [SerializedCommandProperty(AccessLevel.GameMaster)] - private PlantHue _plantHue; - - private void OnPlantHueChanged(PlantHue oldValue, PlantHue newValue) + [SerializableProperty(3)] + [CommandProperty(AccessLevel.GameMaster)] + public PlantHue PlantHue { - Update(); + get => _plantHue; + set + { + _plantHue = value; + Update(); + } } + [SerializableFieldSaveFlag(3)] private bool ShouldSerializePlantHue() => _plantHue != PlantHue.None; - [SerializableField(4)] - [SaveFlag(nameof(ShouldSerializeShowType))] - [SerializedCommandProperty(AccessLevel.GameMaster)] - [InvalidateProperties] - private bool _showType; + [SerializableProperty(4)] + [CommandProperty(AccessLevel.GameMaster)] + public bool ShowType + { + get => _showType; + set + { + _showType = value; + InvalidateProperties(); + this.MarkDirty(); + } + } + [SerializableFieldSaveFlag(4)] private bool ShouldSerializeShowType() => _showType; [CommandProperty(AccessLevel.GameMaster)] diff --git a/Projects/UOContent/Engines/Plants/PlantSystem.cs b/Projects/UOContent/Engines/Plants/PlantSystem.cs index 4b202ef63..29b9f48be 100644 --- a/Projects/UOContent/Engines/Plants/PlantSystem.cs +++ b/Projects/UOContent/Engines/Plants/PlantSystem.cs @@ -33,24 +33,24 @@ namespace Server.Engines.Plants private PlantItem _plant; [SerializableField(0)] - [SaveFlag(nameof(ShouldSerializeFertileDirt))] private bool _fertileDirt; + [SerializableFieldSaveFlag(0)] private bool ShouldSerializeFertileDirt() => _fertileDirt; [SerializableField(1)] private DateTime _nextGrowth; [SerializableField(2, setter: "private")] - [SaveFlag(nameof(ShouldSerializeGrowthIndicator))] private PlantGrowthIndicator _growthIndicator; + [SerializableFieldSaveFlag(2)] private bool ShouldSerializeGrowthIndicator() => _growthIndicator != PlantGrowthIndicator.None; [SerializableField(13)] - [SaveFlag(nameof(ShouldSerializePollinated))] private bool _pollinated; + [SerializableFieldSaveFlag(13)] private bool ShouldSerializePollinated() => _pollinated; public PlantSystem(PlantItem plant) @@ -97,42 +97,45 @@ namespace Server.Engines.Plants public bool IsFullWater => _water >= 4; - [SerializableField(3, fieldChanged: nameof(OnWaterChanged), allowFieldChange: nameof(AllowWaterChange))] - [SaveFlag(nameof(ShouldSerializeWater))] - private int _water; - - private bool AllowWaterChange(ref int value) + [SerializableProperty(3)] + public int Water { - value = Math.Clamp(value, 0, 4); - return true; - } - - private void OnWaterChanged(int oldValue, int newValue) - { - Plant.InvalidateProperties(); + get => _water; + set + { + _water = Math.Clamp(value, 0, 4); + Plant.InvalidateProperties(); + MarkDirty(); + } } + [SerializableFieldSaveFlag(3)] private bool ShouldSerializeWater() => _water != 0; - [SerializableField(4, fieldChanged: nameof(OnHitsChanged), allowFieldChange: nameof(AllowHitsChange))] - [SaveFlag(nameof(ShouldSerializeHits))] - private int _hits; - - private bool AllowHitsChange(ref int value) + [SerializableProperty(4)] + public int Hits { - value = Math.Clamp(value, 0, MaxHits); - return true; - } - - private void OnHitsChanged(int oldValue, int newValue) - { - if (_hits == 0) + get => _hits; + set { - Plant.Die(); + if (_hits == value) + { + return; + } + + _hits = Math.Clamp(value, 0, MaxHits); + + if (_hits == 0) + { + Plant.Die(); + } + + Plant.InvalidateProperties(); + MarkDirty(); } - Plant.InvalidateProperties(); } + [SerializableFieldSaveFlag(4)] private bool ShouldSerializeHits() => _hits != 0; public int MaxHits => 10 + (int)Plant.PlantStatus * 2; @@ -146,108 +149,124 @@ namespace Server.Engines.Plants _ => PlantHealth.Vibrant }; - [SerializableField(5, allowFieldChange: nameof(AllowInfestationChange))] - [SaveFlag(nameof(ShouldSerializeInfestation))] - private int _infestation; - - private bool AllowInfestationChange(ref int value) + [SerializableProperty(5)] + public int Infestation { - value = Math.Clamp(value, 0, 2); - return true; + get => _infestation; + set + { + _infestation = Math.Clamp(value, 0, 2); + MarkDirty(); + } } + [SerializableFieldSaveFlag(5)] private bool ShouldSerializeInfestation() => _infestation != 0; - [SerializableField(6, allowFieldChange: nameof(AllowFungusChange))] - [SaveFlag(nameof(ShouldSerializeFungus))] - private int _fungus; - - private bool AllowFungusChange(ref int value) + [SerializableProperty(6)] + public int Fungus { - value = Math.Clamp(value, 0, 2); - return true; + get => _fungus; + set + { + _fungus = Math.Clamp(value, 0, 2); + MarkDirty(); + } } + [SerializableFieldSaveFlag(6)] private bool ShouldSerializeFungus() => _fungus != 0; - [SerializableField(7, allowFieldChange: nameof(AllowPoisonChange))] - [SaveFlag(nameof(ShouldSerializePoison))] - private int _poison; - - private bool AllowPoisonChange(ref int value) + [SerializableProperty(7)] + public int Poison { - value = Math.Clamp(value, 0, 2); - return true; + get => _poison; + set + { + _poison = Math.Clamp(value, 0, 2); + MarkDirty(); + } } + [SerializableFieldSaveFlag(7)] private bool ShouldSerializePoison() => _poison != 0; - [SerializableField(8, allowFieldChange: nameof(AllowDiseaseChange))] - [SaveFlag(nameof(ShouldSerializeDisease))] - private int _disease; - - private bool AllowDiseaseChange(ref int value) + [SerializableProperty(8)] + public int Disease { - value = Math.Clamp(value, 0, 2); - return true; + get => _disease; + set + { + _disease = Math.Clamp(value, 0, 2); + MarkDirty(); + } } + [SerializableFieldSaveFlag(8)] private bool ShouldSerializeDisease() => _disease != 0; public bool IsFullPoisonPotion => _poisonPotion >= 2; - [SerializableField(9, allowFieldChange: nameof(AllowPoisonPotionChange))] - [SaveFlag(nameof(ShouldSerializePoisonPotion))] - private int _poisonPotion; - - private bool AllowPoisonPotionChange(ref int value) + [SerializableProperty(9)] + public int PoisonPotion { - value = Math.Clamp(value, 0, 2); - return true; + get => _poisonPotion; + set + { + _poisonPotion = Math.Clamp(value, 0, 2); + MarkDirty(); + } } + [SerializableFieldSaveFlag(9)] private bool ShouldSerializePoisonPotion() => _poisonPotion != 0; public bool IsFullCurePotion => _curePotion >= 2; - [SerializableField(10, allowFieldChange: nameof(AllowCurePotionChange))] - [SaveFlag(nameof(ShouldSerializeCurePotion))] - private int _curePotion; - - private bool AllowCurePotionChange(ref int value) + [SerializableProperty(10)] + public int CurePotion { - value = Math.Clamp(value, 0, 2); - return true; + get => _curePotion; + set + { + _curePotion = Math.Clamp(value, 0, 2); + MarkDirty(); + } } + [SerializableFieldSaveFlag(10)] private bool ShouldSerializeCurePotion() => _curePotion != 0; public bool IsFullHealPotion => _healPotion >= 2; - [SerializableField(11, allowFieldChange: nameof(AllowHealPotionChange))] - [SaveFlag(nameof(ShouldSerializeHealPotion))] - private int _healPotion; - - private bool AllowHealPotionChange(ref int value) + [SerializableProperty(11)] + public int HealPotion { - value = Math.Clamp(value, 0, 2); - return true; + get => _healPotion; + set + { + _healPotion = Math.Clamp(value, 0, 2); + MarkDirty(); + } } + [SerializableFieldSaveFlag(11)] private bool ShouldSerializeHealPotion() => _healPotion != 0; public bool IsFullStrengthPotion => _strengthPotion >= 2; - [SerializableField(12, allowFieldChange: nameof(AllowStrengthPotionChange))] - [SaveFlag(nameof(ShouldSerializeStrengthPotion))] - private int _strengthPotion; - - private bool AllowStrengthPotionChange(ref int value) + [SerializableProperty(12)] + public int StrengthPotion { - value = Math.Clamp(value, 0, 2); - return true; + get => _strengthPotion; + set + { + _strengthPotion = Math.Clamp(value, 0, 2); + MarkDirty(); + } } + [SerializableFieldSaveFlag(12)] private bool ShouldSerializeStrengthPotion() => _strengthPotion != 0; public bool HasMaladies => Infestation > 0 || Fungus > 0 || Poison > 0 || Disease > 0 || Water != 2; @@ -255,7 +274,6 @@ namespace Server.Engines.Plants public bool PollenProducing => Plant.IsCrossable && Plant.PlantStatus >= PlantStatus.FullGrownPlant; [SerializableProperty(14)] - [SaveFlag(nameof(ShouldSerializeSeedType))] public PlantType SeedType { get => Pollinated ? _seedType : Plant.PlantType; @@ -266,10 +284,10 @@ namespace Server.Engines.Plants } } + [SerializableFieldSaveFlag(14)] private bool ShouldSerializeSeedType() => _pollinated; [SerializableProperty(15)] - [SaveFlag(nameof(ShouldSerializeSeedHue))] public PlantHue SeedHue { get => Pollinated ? _seedHue : Plant.PlantHue; @@ -280,58 +298,53 @@ namespace Server.Engines.Plants } } + [SerializableFieldSaveFlag(15)] private bool ShouldSerializeSeedHue() => _pollinated; - [SerializableField(16, allowFieldChange: nameof(AllowAvailableSeedsChange))] - [SaveFlag(nameof(ShouldSerializeAvailableSeeds))] - private int _availableSeeds; - - private bool AllowAvailableSeedsChange(ref int value) + [SerializableProperty(16)] + public int AvailableSeeds { - value = Math.Max(value, 0); - return true; + get => _availableSeeds; + set => _availableSeeds = Math.Max(value, 0); } + [SerializableFieldSaveFlag(16)] private bool ShouldSerializeAvailableSeeds() => _availableSeeds != 0; - [SerializableField(17, allowFieldChange: nameof(AllowLeftSeedsChange))] - [SaveFlag(nameof(ShouldSerializeLeftSeeds), nameof(LeftSeedsDefaultValue))] - private int _leftSeeds; - - private bool AllowLeftSeedsChange(ref int value) + [SerializableProperty(17)] + public int LeftSeeds { - value = Math.Max(value, 0); - return true; + get => _leftSeeds; + set => _leftSeeds = Math.Max(value, 0); } + [SerializableFieldSaveFlag(17)] private bool ShouldSerializeLeftSeeds() => _leftSeeds != 8; + [SerializableFieldDefault(17)] private int LeftSeedsDefaultValue() => 8; - [SerializableField(18, allowFieldChange: nameof(AllowAvailableResourcesChange))] - [SaveFlag(nameof(ShouldSerializeAvailableResources))] - private int _availableResources; - - private bool AllowAvailableResourcesChange(ref int value) + [SerializableProperty(18)] + public int AvailableResources { - value = Math.Max(value, 0); - return true; + get => _availableResources; + set => _availableResources = Math.Max(value, 0); } + [SerializableFieldSaveFlag(18)] private bool ShouldSerializeAvailableResources() => _availableResources != 0; - [SerializableField(19, allowFieldChange: nameof(AllowLeftResourcesChange))] - [SaveFlag(nameof(ShouldSerializeLeftResources), nameof(LeftResourcesDefaultValue))] - private int _leftResources; - - private bool AllowLeftResourcesChange(ref int value) + [SerializableProperty(19)] + public int LeftResources { - value = Math.Max(value, 0); - return true; + get => _leftResources; + set => _leftResources = Math.Max(value, 0); } + [SerializableFieldSaveFlag(19)] private bool ShouldSerializeLeftResources() => _leftResources != 8; + [SerializableFieldDefault(19)] private int LeftResourcesDefaultValue() => 8; public void Reset(bool potions) diff --git a/Projects/UOContent/Engines/Plants/Seed.cs b/Projects/UOContent/Engines/Plants/Seed.cs index ed6f13b7b..24a290f0b 100644 --- a/Projects/UOContent/Engines/Plants/Seed.cs +++ b/Projects/UOContent/Engines/Plants/Seed.cs @@ -35,14 +35,18 @@ public partial class Seed : Item public override double DefaultWeight => 1.0; - [SerializableField(1, fieldChanged: nameof(OnPlantHueChanged))] - [SerializedCommandProperty(AccessLevel.GameMaster)] - [InvalidateProperties] - private PlantHue _plantHue; - - private void OnPlantHueChanged(PlantHue oldValue, PlantHue newValue) + [CommandProperty(AccessLevel.GameMaster)] + [SerializableProperty(1)] + public PlantHue PlantHue { - Hue = PlantHueInfo.GetInfo(newValue).Hue; + get => _plantHue; + set + { + _plantHue = value; + Hue = PlantHueInfo.GetInfo(value).Hue; + InvalidateProperties(); + this.MarkDirty(); + } } public override int LabelNumber => 1060810; // seed diff --git a/Projects/UOContent/Engines/Player Murder System/MurderContext.cs b/Projects/UOContent/Engines/Player Murder System/MurderContext.cs index 384c91c98..cecd7bf7e 100644 --- a/Projects/UOContent/Engines/Player Murder System/MurderContext.cs +++ b/Projects/UOContent/Engines/Player Murder System/MurderContext.cs @@ -16,14 +16,12 @@ public partial class MurderContext [SerializedCommandProperty(AccessLevel.GameMaster)] private TimeSpan _longTermElapse; - [SerializableField(2, allowFieldChange: nameof(AllowShortTermMurdersChange))] - [SerializedCommandProperty(AccessLevel.GameMaster)] - private int _shortTermMurders; - - private bool AllowShortTermMurdersChange(ref int value) + [SerializableProperty(2)] + [CommandProperty(AccessLevel.GameMaster)] + public int ShortTermMurders { - value = Math.Max(value, 0); - return true; + get => _shortTermMurders; + set => _shortTermMurders = Math.Max(value, 0); } [SerializableField(3)] diff --git a/Projects/UOContent/Engines/Player Murder System/PlayerMurderSystem.cs b/Projects/UOContent/Engines/Player Murder System/PlayerMurderSystem.cs index d1be82aa9..b5a1b77d5 100644 --- a/Projects/UOContent/Engines/Player Murder System/PlayerMurderSystem.cs +++ b/Projects/UOContent/Engines/Player Murder System/PlayerMurderSystem.cs @@ -403,20 +403,27 @@ public class PlayerMurderSystem : GenericPersistence return; } + using var queue = PooledRefQueue.Create(); + foreach (var context in _contextTerms) { context.DecayKills(); if (!context.CheckStart()) { - var pm = context.Player; - if (_murderContexts.TryGetValue(pm, out var ctx)) + queue.Enqueue(context.Player); + } + } + + while (queue.Count > 0) + { + var pm = (PlayerMobile)queue.Dequeue(); + if (_murderContexts.TryGetValue(pm, out var ctx)) + { + if (ctx.CanRemove()) { - if (ctx.CanRemove()) - { - _murderContexts.Remove(pm); - } - _contextTerms.Remove(ctx); + _murderContexts.Remove(pm); } + _contextTerms.Remove(ctx); } } } diff --git a/Projects/UOContent/Engines/Quests/The Summoning/Items/SummoningAltar.cs b/Projects/UOContent/Engines/Quests/The Summoning/Items/SummoningAltar.cs index 4e3e5e237..d5ec6f24a 100644 --- a/Projects/UOContent/Engines/Quests/The Summoning/Items/SummoningAltar.cs +++ b/Projects/UOContent/Engines/Quests/The Summoning/Items/SummoningAltar.cs @@ -12,12 +12,16 @@ public partial class SummoningAltar : AbbatoirAddon { } - [SerializableField(0, fieldChanged: nameof(OnDaemonChanged))] - private BoneDemon _daemon; - - private void OnDaemonChanged(BoneDemon oldValue, BoneDemon newValue) + [SerializableProperty(0)] + public BoneDemon Daemon { - CheckDaemon(); + get => _daemon; + set + { + _daemon = value; + CheckDaemon(); + this.MarkDirty(); + } } public void CheckDaemon() diff --git a/Projects/UOContent/Engines/Spawners/BaseSpawner.cs b/Projects/UOContent/Engines/Spawners/BaseSpawner.cs index 8efc54b8c..7ceeb00f6 100644 --- a/Projects/UOContent/Engines/Spawners/BaseSpawner.cs +++ b/Projects/UOContent/Engines/Spawners/BaseSpawner.cs @@ -54,10 +54,10 @@ public abstract partial class BaseSpawner : Item, ISpawner [SerializedCommandProperty(AccessLevel.Developer)] private Guid _guid; + [SerializableFieldSaveFlag(1)] private bool ShouldSerializeReturnOnDeactivate() => _returnOnDeactivate; [SerializableField(1)] - [SaveFlag(nameof(ShouldSerializeReturnOnDeactivate))] [SerializedCommandProperty(AccessLevel.Developer)] private bool _returnOnDeactivate; @@ -67,46 +67,48 @@ public abstract partial class BaseSpawner : Item, ISpawner private int _walkingRange = -1; + [SerializableFieldSaveFlag(4)] private bool ShouldSerializeWayPoint() => _wayPoint != null; [SerializableField(4)] - [SaveFlag(nameof(ShouldSerializeWayPoint))] [SerializedCommandProperty(AccessLevel.Developer)] private WayPoint _wayPoint; + [SerializableFieldSaveFlag(5)] private bool ShouldSerializeGroup() => _group; [InvalidateProperties] [SerializableField(5)] - [SaveFlag(nameof(ShouldSerializeGroup))] [SerializedCommandProperty(AccessLevel.Developer)] private bool _group; + [SerializableFieldSaveFlag(6)] private bool ShouldSerializeMinDelay() => _minDelay != DefaultMinDelay; + [SerializableFieldDefault(6)] private TimeSpan MinDelayDefault() => DefaultMinDelay; [InvalidateProperties] [SerializableField(6)] - [SaveFlag(nameof(ShouldSerializeMinDelay), nameof(MinDelayDefault))] [SerializedCommandProperty(AccessLevel.Developer)] private TimeSpan _minDelay; + [SerializableFieldSaveFlag(7)] private bool ShouldSerializeMaxDelay() => _maxDelay != DefaultMaxDelay; + [SerializableFieldDefault(7)] private TimeSpan MaxDelayDefault() => DefaultMaxDelay; [InvalidateProperties] [SerializableField(7)] - [SaveFlag(nameof(ShouldSerializeMaxDelay), nameof(MaxDelayDefault))] [SerializedCommandProperty(AccessLevel.Developer)] private TimeSpan _maxDelay; + [SerializableFieldSaveFlag(9)] private bool ShouldSerializeTeam() => _team != 0; [InvalidateProperties] [SerializableField(9)] - [SaveFlag(nameof(ShouldSerializeTeam))] [SerializedCommandProperty(AccessLevel.Developer)] private int _team; @@ -123,29 +125,29 @@ public abstract partial class BaseSpawner : Item, ISpawner /// If true, the home location of the spawn is the location where it spawned /// If false, the home location of the spawn is the location of the spawner /// + [SerializableFieldSaveFlag(11)] private bool ShouldSerializeSpawnLocationIsHome() => _spawnLocationIsHome; [InvalidateProperties] [SerializableField(11)] - [SaveFlag(nameof(ShouldSerializeSpawnLocationIsHome))] [SerializedCommandProperty(AccessLevel.Developer)] private bool _spawnLocationIsHome; + [SerializableFieldSaveFlag(12)] private bool ShouldSerializeEnd() => _end != default; [SerializableField(12)] - [SaveFlag(nameof(ShouldSerializeEnd))] [SerializedCommandProperty(AccessLevel.Developer)] private DateTime _end; /// /// Controls how spawn position optimization is handled. /// + [SerializableFieldSaveFlag(13)] private bool ShouldSerializeSpawnPositionMode() => _spawnPositionMode is not SpawnPositionMode.Automatic and not SpawnPositionMode.Abandoned; [SerializableField(13)] - [SaveFlag(nameof(ShouldSerializeSpawnPositionMode))] [SerializedCommandProperty(AccessLevel.Developer)] private SpawnPositionMode _spawnPositionMode; @@ -154,12 +156,13 @@ public abstract partial class BaseSpawner : Item, ISpawner /// /// Maximum number of random position attempts before engaging optimization. /// + [SerializableFieldSaveFlag(14)] private bool ShouldSerializeMaxSpawnAttempts() => _maxSpawnAttempts != DefaultMaxSpawnAttempts; + [SerializableFieldDefault(14)] private int MaxSpawnAttemptsDefault() => DefaultMaxSpawnAttempts; [SerializableField(14)] - [SaveFlag(nameof(ShouldSerializeMaxSpawnAttempts), nameof(MaxSpawnAttemptsDefault))] [SerializedCommandProperty(AccessLevel.Developer)] private int _maxSpawnAttempts; @@ -311,20 +314,26 @@ public abstract partial class BaseSpawner : Item, ISpawner } } - [SerializableField(8, fieldChanged: nameof(OnCountChanged))] - [SerializedCommandProperty(AccessLevel.Developer)] - [InvalidateProperties] - private int _count; - - private void OnCountChanged(int oldValue, int newValue) + [SerializableProperty(8)] + [CommandProperty(AccessLevel.Developer)] + public int Count { - if (IsFull) + get => _count; + set { - _timer?.Stop(); - } - else if (_timer?.Running != true) - { - DoTimer(); + _count = value; + + if (IsFull) + { + _timer?.Stop(); + } + else if (_timer?.Running != true) + { + DoTimer(); + } + + InvalidateProperties(); + this.MarkDirty(); } } diff --git a/Projects/UOContent/Engines/Spawners/Spawner.cs b/Projects/UOContent/Engines/Spawners/Spawner.cs index db25cab55..7c07d2681 100644 --- a/Projects/UOContent/Engines/Spawners/Spawner.cs +++ b/Projects/UOContent/Engines/Spawners/Spawner.cs @@ -10,17 +10,17 @@ public partial class Spawner : BaseSpawner /// When true, enables proactive spiral scanning to find valid spawn positions. /// Only relevant when SpawnPositionMode is Automatic or Enabled. /// + [SerializableFieldSaveFlag(0)] private bool ShouldSerializeUseSpiralScan() => _useSpiralScan; [SerializableField(0)] - [SaveFlag(nameof(ShouldSerializeUseSpiralScan))] [SerializedCommandProperty(AccessLevel.Developer)] private bool _useSpiralScan; + [SerializableFieldSaveFlag(1)] private bool ShouldSerializeSpawnBounds() => _spawnBounds != default; [SerializableProperty(1)] - [SaveFlag(nameof(ShouldSerializeSpawnBounds))] [CommandProperty(AccessLevel.Developer)] public override Rectangle3D SpawnBounds { diff --git a/Projects/UOContent/Engines/Stealables/StealableArtifacts.cs b/Projects/UOContent/Engines/Stealables/StealableArtifacts.cs index 4cd8d8c63..396b87dcd 100644 --- a/Projects/UOContent/Engines/Stealables/StealableArtifacts.cs +++ b/Projects/UOContent/Engines/Stealables/StealableArtifacts.cs @@ -248,7 +248,7 @@ public class StealableArtifacts : GenericPersistence public override void Serialize(IGenericWriter writer) { - writer.WriteEncodedInt(2); // version + writer.WriteEncodedInt(1); // version writer.Write(_enabled); @@ -261,7 +261,7 @@ public class StealableArtifacts : GenericPersistence var si = _artifacts[i]; writer.Write(si.Item); - writer.WriteAnchoredTime(si.NextRespawn); + writer.WriteDeltaTime(si.NextRespawn); } } } @@ -282,7 +282,7 @@ public class StealableArtifacts : GenericPersistence for (var i = 0; i < length; i++) { var item = reader.ReadEntity(); - var nextRespawn = version >= 2 ? reader.ReadAnchoredTime() : reader.ReadDeltaTime(); + var nextRespawn = reader.ReadDeltaTime(); if (i < _artifacts.Length) { diff --git a/Projects/UOContent/Engines/Treasures of Tokuno/GreaterArtifacts.cs b/Projects/UOContent/Engines/Treasures of Tokuno/GreaterArtifacts.cs index c42cfae4e..005688e1e 100644 --- a/Projects/UOContent/Engines/Treasures of Tokuno/GreaterArtifacts.cs +++ b/Projects/UOContent/Engines/Treasures of Tokuno/GreaterArtifacts.cs @@ -332,22 +332,27 @@ public partial class PigmentsOfTokuno : BasePigmentsOfTokuno [Constructible] public PigmentsOfTokuno(PigmentType type, int uses) : base(uses) => Type = type; - [SerializableField(0, fieldChanged: nameof(OnTypeChanged))] - [SerializedCommandProperty(AccessLevel.GameMaster)] - private PigmentType _type; - - private void OnTypeChanged(PigmentType oldValue, PigmentType newValue) + [SerializableProperty(0)] + [CommandProperty(AccessLevel.GameMaster)] + public PigmentType Type { - var v = (int)_type; - if (v >= 0 && v < _table.Length) + get => _type; + set { - Hue = _table[v][0]; - Label = _table[v][1]; - } - else - { - Hue = 0; - Label = -1; + _type = value; + + var v = (int)_type; + + if (v >= 0 && v < _table.Length) + { + Hue = _table[v][0]; + Label = _table[v][1]; + } + else + { + Hue = 0; + Label = -1; + } } } diff --git a/Projects/UOContent/Engines/Treasures of Tokuno/LesserArtifacts.cs b/Projects/UOContent/Engines/Treasures of Tokuno/LesserArtifacts.cs index 22c6af344..54d026df4 100644 --- a/Projects/UOContent/Engines/Treasures of Tokuno/LesserArtifacts.cs +++ b/Projects/UOContent/Engines/Treasures of Tokuno/LesserArtifacts.cs @@ -617,22 +617,27 @@ public partial class LesserPigmentsOfTokuno : BasePigmentsOfTokuno [Constructible] public LesserPigmentsOfTokuno(LesserPigmentType type) : base(1) => Type = type; - [SerializableField(0, fieldChanged: nameof(OnTypeChanged))] - [SerializedCommandProperty(AccessLevel.GameMaster)] - private LesserPigmentType _type; - - private void OnTypeChanged(LesserPigmentType oldValue, LesserPigmentType newValue) + [SerializableProperty(0)] + [CommandProperty(AccessLevel.GameMaster)] + public LesserPigmentType Type { - var v = (int)_type; - if (v >= 0 && v < _table.Length) + get => _type; + set { - Hue = _table[v][0]; - Label = _table[v][1]; - } - else - { - Hue = 0; - Label = -1; + _type = value; + + var v = (int)_type; + + if (v >= 0 && v < _table.Length) + { + Hue = _table[v][0]; + Label = _table[v][1]; + } + else + { + Hue = 0; + Label = -1; + } } } diff --git a/Projects/UOContent/Engines/Veteran Rewards/Character Statue Maker/CharacterStatue.cs b/Projects/UOContent/Engines/Veteran Rewards/Character Statue Maker/CharacterStatue.cs index 7a62cc735..ff665ec53 100644 --- a/Projects/UOContent/Engines/Veteran Rewards/Character Statue Maker/CharacterStatue.cs +++ b/Projects/UOContent/Engines/Veteran Rewards/Character Statue Maker/CharacterStatue.cs @@ -79,33 +79,45 @@ public partial class CharacterStatue : Mobile, IRewardItem InvalidateHues(); } - [SerializableField(0, fieldChanged: nameof(OnStatueTypeChanged))] - [SerializedCommandProperty(AccessLevel.GameMaster)] - private StatueType _statueType; - - private void OnStatueTypeChanged(StatueType oldValue, StatueType newValue) + [SerializableProperty(0)] + [CommandProperty(AccessLevel.GameMaster)] + public StatueType StatueType { - InvalidateHues(); - InvalidatePose(); + get => _statueType; + set + { + _statueType = value; + InvalidateHues(); + InvalidatePose(); + this.MarkDirty(); + } } - [SerializableField(1, fieldChanged: nameof(OnPoseChanged))] - [SerializedCommandProperty(AccessLevel.GameMaster)] - private StatuePose _pose; - - private void OnPoseChanged(StatuePose oldValue, StatuePose newValue) + [SerializableProperty(1)] + [CommandProperty(AccessLevel.GameMaster)] + public StatuePose Pose { - InvalidatePose(); + get => _pose; + set + { + _pose = value; + InvalidatePose(); + this.MarkDirty(); + } } - [SerializableField(2, fieldChanged: nameof(OnMaterialChanged))] - [SerializedCommandProperty(AccessLevel.GameMaster)] - private StatueMaterial _material; - - private void OnMaterialChanged(StatueMaterial oldValue, StatueMaterial newValue) + [SerializableProperty(2)] + [CommandProperty(AccessLevel.GameMaster)] + public StatueMaterial Material { - InvalidateHues(); - InvalidatePose(); + get => _material; + set + { + _material = value; + InvalidateHues(); + InvalidatePose(); + this.MarkDirty(); + } } public override void OnDoubleClick(Mobile from) diff --git a/Projects/UOContent/Engines/Veteran Rewards/Character Statue Maker/CharacterStatueMaker.cs b/Projects/UOContent/Engines/Veteran Rewards/Character Statue Maker/CharacterStatueMaker.cs index 48ae30ae9..a028b1c85 100644 --- a/Projects/UOContent/Engines/Veteran Rewards/Character Statue Maker/CharacterStatueMaker.cs +++ b/Projects/UOContent/Engines/Veteran Rewards/Character Statue Maker/CharacterStatueMaker.cs @@ -25,13 +25,17 @@ public partial class CharacterStatueMaker : Item, IRewardItem public override int LabelNumber => 1076173; // Character Statue Maker - [SerializableField(1, fieldChanged: nameof(OnStatueTypeChanged))] - [SerializedCommandProperty(AccessLevel.GameMaster)] - private StatueType _statueType; - - private void OnStatueTypeChanged(StatueType oldValue, StatueType newValue) + [SerializableProperty(1)] + [CommandProperty(AccessLevel.GameMaster)] + public StatueType StatueType { - InvalidateHue(); + get => _statueType; + set + { + _statueType = value; + InvalidateHue(); + this.MarkDirty(); + } } public override void OnDoubleClick(Mobile from) diff --git a/Projects/UOContent/Engines/Virtues/VirtueContext.cs b/Projects/UOContent/Engines/Virtues/VirtueContext.cs index 7524f77ed..3b26b5294 100644 --- a/Projects/UOContent/Engines/Virtues/VirtueContext.cs +++ b/Projects/UOContent/Engines/Virtues/VirtueContext.cs @@ -5,121 +5,102 @@ using Server.Mobiles; namespace Server.Engines.Virtues; [PropertyObject] -[SerializationGenerator(1)] +[SerializationGenerator(0)] public partial class VirtueContext { - private void MigrateFrom(V0Content content) - { - // Save-flagged values arrive as nullables; unset flags fall back to the same - // defaults the old deserialize left in place. - _lastSacrificeGain = content.LastSacrificeGain ?? default; - _lastSacrificeLoss = content.LastSacrificeLoss ?? default; - _availableResurrects = content.AvailableResurrects ?? 0; - _lastJusticeLoss = content.LastJusticeLoss ?? default; - _lastCompassionLoss = content.LastCompassionLoss ?? default; - _nextCompassionDay = content.NextCompassionDay ?? default; - _compassionGains = content.CompassionGains ?? 0; - _lastValorLoss = content.LastValorLoss ?? default; - _lastHonorUse = content.LastHonorUse ?? default; - _honorActive = content.HonorActive; - _justiceProtection = content.JusticeProtection; - _justiceStatus = content.JusticeStatus ?? JusticeProtectorStatus.None; - _values = content.Values; - } - - [AnchoredDateTime] + [DeltaDateTime] [SerializableField(0)] - [SaveFlag(nameof(ShouldSerializeLastSacrificeGain))] [SerializedCommandProperty(AccessLevel.GameMaster, AccessLevel.Administrator)] private DateTime _lastSacrificeGain; + [SerializableFieldSaveFlag(0)] private bool ShouldSerializeLastSacrificeGain() => !SacrificeVirtue.CanGain(this); - [AnchoredDateTime] + [DeltaDateTime] [SerializableField(1)] - [SaveFlag(nameof(ShouldSerializeLastSacrificeLoss))] [SerializedCommandProperty(AccessLevel.GameMaster, AccessLevel.Administrator)] private DateTime _lastSacrificeLoss; + [SerializableFieldSaveFlag(1)] private bool ShouldSerializeLastSacrificeLoss() => !SacrificeVirtue.CanAtrophy(this); [SerializableField(2)] - [SaveFlag(nameof(ShouldSerializeAvailableResurrects))] [SerializedCommandProperty(AccessLevel.GameMaster)] private int _availableResurrects; + [SerializableFieldSaveFlag(2)] private bool ShouldSerializeAvailableResurrects() => _availableResurrects > 0; - [AnchoredDateTime] + [DeltaDateTime] [SerializableField(3)] - [SaveFlag(nameof(ShouldSerializeLastJusticeLoss))] [SerializedCommandProperty(AccessLevel.GameMaster, AccessLevel.Administrator)] private DateTime _lastJusticeLoss; + [SerializableFieldSaveFlag(3)] private bool ShouldSerializeLastJusticeLoss() => !JusticeVirtue.CanAtrophy(this); - [AnchoredDateTime] + [DeltaDateTime] [SerializableField(4)] - [SaveFlag(nameof(ShouldSerializeLastCompassionLoss))] [SerializedCommandProperty(AccessLevel.GameMaster, AccessLevel.Administrator)] private DateTime _lastCompassionLoss; + [SerializableFieldSaveFlag(4)] private bool ShouldSerializeLastCompassionLoss() => !CompassionVirtue.CanAtrophy(this); - [AnchoredDateTime] + [DeltaDateTime] [SerializableField(5)] - [SaveFlag(nameof(ShouldSerializeNextCompassionDay))] [SerializedCommandProperty(AccessLevel.GameMaster)] private DateTime _nextCompassionDay; + [SerializableFieldSaveFlag(5)] private bool ShouldSerializeNextCompassionDay() => _nextCompassionDay > Core.Now; [SerializableField(6)] - [SaveFlag(nameof(ShouldSerializeCompassionGains))] [SerializedCommandProperty(AccessLevel.GameMaster)] private int _compassionGains; + [SerializableFieldSaveFlag(6)] private bool ShouldSerializeCompassionGains() => _compassionGains > 0; - [AnchoredDateTime] + [DeltaDateTime] [SerializableField(7)] - [SaveFlag(nameof(ShouldSerializeValorLoss))] [SerializedCommandProperty(AccessLevel.GameMaster, AccessLevel.Administrator)] private DateTime _lastValorLoss; + [SerializableFieldSaveFlag(7)] private bool ShouldSerializeValorLoss() => !ValorVirtue.CanAtrophy(this); - [AnchoredDateTime] + [DeltaDateTime] [SerializableField(8)] - [SaveFlag(nameof(ShouldSerializeLastHonorUse))] [SerializedCommandProperty(AccessLevel.GameMaster)] private DateTime _lastHonorUse; + [SerializableFieldSaveFlag(8)] private bool ShouldSerializeLastHonorUse() => !HonorVirtue.CanUse(this); [SerializableField(9)] - [SaveFlag(nameof(ShouldSerializeHonorActive))] [SerializedCommandProperty(AccessLevel.GameMaster, AccessLevel.Administrator)] private bool _honorActive; + [SerializableFieldSaveFlag(9)] private bool ShouldSerializeHonorActive() => _honorActive; [SerializableField(10)] - [SaveFlag(nameof(ShouldSerializeJusticeProtection))] private PlayerMobile _justiceProtection; + [SerializableFieldSaveFlag(10)] private bool ShouldSerializeJusticeProtection() => _justiceProtection != null && _justiceStatus != JusticeProtectorStatus.None; [SerializableField(11)] - [SaveFlag(nameof(ShouldSerializeJusticeStatus))] private JusticeProtectorStatus _justiceStatus; + [SerializableFieldSaveFlag(11)] private bool ShouldSerializeJusticeStatus() => _justiceProtection != null && _justiceStatus != JusticeProtectorStatus.None; [SerializableField(12, setter: "private")] - [SaveFlag(nameof(ShouldSerializeValues))] private int[] _values; + [SerializableFieldSaveFlag(12)] private bool ShouldSerializeValues() { if (_values == null) diff --git a/Projects/UOContent/Engines/Virtues/VirtueSystem.cs b/Projects/UOContent/Engines/Virtues/VirtueSystem.cs index e57a2d386..89c5fa3b5 100644 --- a/Projects/UOContent/Engines/Virtues/VirtueSystem.cs +++ b/Projects/UOContent/Engines/Virtues/VirtueSystem.cs @@ -372,6 +372,8 @@ public class VirtueSystem : GenericPersistence return; } + using var queue = PooledRefQueue.Create(); + // This is not particularly efficient. If it gets too slow, then use a different architecture. foreach (var (player, virtues) in _playerVirtues) { @@ -379,9 +381,14 @@ public class VirtueSystem : GenericPersistence if (!virtues.IsUsed()) { - _playerVirtues.Remove(player); + queue.Enqueue(player); } } + + while (queue.Count > 0) + { + _playerVirtues.Remove((PlayerMobile)queue.Dequeue()); + } } ~VirtueTimer() diff --git a/Projects/UOContent/Gumps/AdminGump.cs b/Projects/UOContent/Gumps/AdminGump.cs index a4fbe0f18..1fe917b55 100644 --- a/Projects/UOContent/Gumps/AdminGump.cs +++ b/Projects/UOContent/Gumps/AdminGump.cs @@ -5,7 +5,6 @@ using System.Net; using System.Runtime.InteropServices; using System.Threading; using Server.Accounting; -using Server.Accounting.Security; using Server.Collections; using Server.Commands; using Server.Maps; @@ -227,12 +226,9 @@ namespace Server.Gumps } case AdminGumpPage.Information_Perf: { - 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:"); - AddLabel(40, 150, LabelHue, loopStatus); + AddLabel(20, 130, LabelHue, "Cycles Per Second:"); + AddLabel(40, 150, LabelHue, $"Current: {Core.CyclesPerSecond:N2}"); + AddLabel(40, 170, LabelHue, $"Average: {Core.AverageCPS:N2}"); using var sb = ValueStringBuilder.Create(); @@ -2907,7 +2903,7 @@ namespace Server.Gumps else { notice = "The password has been changed."; - PasswordWorker.SetPassword(a, password, null); + a.SetPassword(password); page = AdminGumpPage.AccountDetails_Information; CommandLogging.WriteLine( from, diff --git a/Projects/UOContent/Gumps/Base/BaseGump.cs b/Projects/UOContent/Gumps/Base/BaseGump.cs index 05770608c..6d844735d 100644 --- a/Projects/UOContent/Gumps/Base/BaseGump.cs +++ b/Projects/UOContent/Gumps/Base/BaseGump.cs @@ -13,7 +13,6 @@ * along with this program. If not, see . * *************************************************************************/ -using Server.Logging; using Server.Network; using System; using System.Buffers; @@ -24,12 +23,10 @@ namespace Server.Gumps; public abstract class BaseGump { private static readonly byte[] _packetBuffer = GC.AllocateUninitializedArray(0x10000); - private static readonly ILogger _logger = LogFactory.GetLogger(typeof(BaseGump)); private static Serial nextSerial = (Serial)1; public int TypeID { get; protected set; } public Serial Serial { get; protected set; } - protected bool HasVisualElements { get; set; } public abstract int Switches { get; } public abstract int TextEntries { get; } @@ -59,11 +56,6 @@ public abstract class BaseGump var writer = new SpanWriter(_packetBuffer); Compile(ref writer); - if (!HasVisualElements) - { - _logger.Warning("Sending empty gump {GumpType}", GetType().FullName); - } - ns.Send(writer.Span); writer.Dispose(); diff --git a/Projects/UOContent/Gumps/Base/DynamicGump.cs b/Projects/UOContent/Gumps/Base/DynamicGump.cs index de894da68..f1b3c064a 100644 --- a/Projects/UOContent/Gumps/Base/DynamicGump.cs +++ b/Projects/UOContent/Gumps/Base/DynamicGump.cs @@ -47,7 +47,6 @@ public abstract class DynamicGump : BaseGump BuildLayout(ref gumpBuilder); gumpBuilder.FinalizeLayout(); - HasVisualElements = gumpBuilder.HasVisualElements; _switches = gumpBuilder.Switches; _textEntries = gumpBuilder.TextEntries; diff --git a/Projects/UOContent/Gumps/Base/DynamicGumpBuilder.cs b/Projects/UOContent/Gumps/Base/DynamicGumpBuilder.cs index 9238e6e04..e98c8c6df 100644 --- a/Projects/UOContent/Gumps/Base/DynamicGumpBuilder.cs +++ b/Projects/UOContent/Gumps/Base/DynamicGumpBuilder.cs @@ -36,7 +36,6 @@ public ref struct DynamicGumpBuilder public int Switches => _gumpBuilder._switches; public int TextEntries => _gumpBuilder._textEntries; - internal bool HasVisualElements => _gumpBuilder._hasVisualElements; [MethodImpl(MethodImplOptions.AggressiveInlining)] public DynamicGumpBuilder() diff --git a/Projects/UOContent/Gumps/Base/GumpLayoutBuilder.cs b/Projects/UOContent/Gumps/Base/GumpLayoutBuilder.cs index 4bd5c7d8f..7f0ca5dff 100644 --- a/Projects/UOContent/Gumps/Base/GumpLayoutBuilder.cs +++ b/Projects/UOContent/Gumps/Base/GumpLayoutBuilder.cs @@ -30,7 +30,6 @@ public ref struct GumpLayoutBuilder internal GumpFlags _flags; internal int _switches; internal int _textEntries; - internal bool _hasVisualElements; internal Span LayoutData => _layoutBuffer.AsSpan(0, _bytesWritten); @@ -96,7 +95,6 @@ public ref struct GumpLayoutBuilder public void AddBackground(int x, int y, int width, int height, int gumpId) { - _hasVisualElements = true; GrowIfNeeded(9 + 9 + 45); WriteStart("resizepic"u8); WriteValue(x); @@ -111,7 +109,6 @@ public ref struct GumpLayoutBuilder int x, int y, int normalId, int pressedId, int buttonId, GumpButtonType type = GumpButtonType.Reply, int param = 0 ) { - _hasVisualElements = true; GrowIfNeeded(11 + 6 + 54 + 2); WriteStart("button"u8); WriteValue(x); @@ -126,7 +123,6 @@ public ref struct GumpLayoutBuilder public void AddCheckbox(int x, int y, int inactiveId, int activeId, bool selected, int switchId) { - _hasVisualElements = true; GrowIfNeeded(10 + 8 + 45 + 2); WriteStart("checkbox"u8); WriteValue(x); @@ -158,7 +154,6 @@ public ref struct GumpLayoutBuilder public int AddHtmlPlaceholder(int x, int y, int width, int height, bool background = false, bool scrollbar = false) { - _hasVisualElements = true; GrowIfNeeded(11 + 8 + 36 + 10); WriteStart("htmlgump"u8); WriteValue(x); @@ -177,7 +172,6 @@ public ref struct GumpLayoutBuilder public void AddHtml(int x, int y, int width, int height, int text, bool background = false, bool scrollbar = false) { - _hasVisualElements = true; GrowIfNeeded(11 + 8 + 45 + 4); WriteStart("htmlgump"u8); WriteValue(x); @@ -194,7 +188,6 @@ public ref struct GumpLayoutBuilder int x, int y, int width, int height, int number, bool background = false, bool scrollbar = false ) { - _hasVisualElements = true; GrowIfNeeded(11 + 11 + 45 + 4); WriteStart("xmfhtmlgump"u8); WriteValue(x); @@ -211,7 +204,6 @@ public ref struct GumpLayoutBuilder int x, int y, int width, int height, int number, int color, bool background = false, bool scrollbar = false ) { - _hasVisualElements = true; GrowIfNeeded(12 + 16 + 45 + 5 + 4); WriteStart("xmfhtmlgumpcolor"u8); WriteValue(x); @@ -228,7 +220,6 @@ public ref struct GumpLayoutBuilder public void AddHtmlLocalized(int x, int y, int width, int height, int number, ReadOnlySpan args, int color, bool background = false, bool scrollbar = false) { - _hasVisualElements = true; GrowIfNeeded(12 + 10 + 45 + 5 + 4 + (args.Length > 0 ? 3 + args.Length : 0)); WriteStart("xmfhtmltok"u8); WriteValue(x); @@ -263,7 +254,6 @@ public ref struct GumpLayoutBuilder public void AddImage(int x, int y, int gumpId, int hue = 0, ReadOnlySpan cls = default) { - _hasVisualElements = true; GrowIfNeeded(7 + 7 + 36 + (hue != 0 ? 14 : 0) + (cls.Length > 0 ? 7 + cls.Length : 0)); WriteStart("gumppic"u8); WriteValue(x); @@ -304,7 +294,6 @@ public ref struct GumpLayoutBuilder public void AddImageTiledButton(int x, int y, int normalId, int pressedId, int buttonId, GumpButtonType type, int param, int itemId, int hue, int width, int height, int localizedTooltip = -1) { - _hasVisualElements = true; GrowIfNeeded(15 + 13 + 90 + 2); WriteStart("buttontileart"u8); WriteValue(x); @@ -330,7 +319,6 @@ public ref struct GumpLayoutBuilder public void AddImageTiled(int x, int y, int width, int height, int gumpId) { - _hasVisualElements = true; GrowIfNeeded(9 + 12 + 45); WriteStart("gumppictiled"u8); WriteValue(x); @@ -343,7 +331,6 @@ public ref struct GumpLayoutBuilder public void AddItem(int x, int y, int itemId, int hue = 0) { - _hasVisualElements = true; GrowIfNeeded(7 + 36 + (hue != 0 ? 20 : 7)); WriteStart(hue == 0 ? "tilepic"u8 : "tilepichue"u8); WriteValue(x); @@ -368,7 +355,6 @@ public ref struct GumpLayoutBuilder public int AddLabelPlaceholder(int x, int y, int hue) { - _hasVisualElements = true; GrowIfNeeded(8 + 4 + 27 + 6); WriteStart("text"u8); WriteValue(x); @@ -382,7 +368,6 @@ public ref struct GumpLayoutBuilder public void AddLabel(int x, int y, int hue, int text) { - _hasVisualElements = true; GrowIfNeeded(8 + 4 + 36 + 6); WriteStart("text"u8); WriteValue(x); @@ -394,7 +379,6 @@ public ref struct GumpLayoutBuilder public int AddLabelCroppedPlaceholder(int x, int y, int width, int height, int hue) { - _hasVisualElements = true; GrowIfNeeded(10 + 11 + 45 + 6); WriteStart("croppedtext"u8); WriteValue(x); @@ -410,7 +394,6 @@ public ref struct GumpLayoutBuilder public void AddLabelCropped(int x, int y, int width, int height, int hue, int text) { - _hasVisualElements = true; GrowIfNeeded(10 + 11 + 54 + 6); WriteStart("croppedtext"u8); WriteValue(x); @@ -447,7 +430,6 @@ public ref struct GumpLayoutBuilder public void AddRadio(int x, int y, int inactiveId, int activeId, bool selected, int switchId) { - _hasVisualElements = true; GrowIfNeeded(10 + 5 + 45 + 2); WriteStart("radio"u8); WriteValue(x); @@ -463,7 +445,6 @@ public ref struct GumpLayoutBuilder public void AddSpriteImage(int x, int y, int gumpId, int width, int height, int sx, int sy) { - _hasVisualElements = true; GrowIfNeeded(11 + 8 + 63); WriteStart("picinpic"u8); WriteValue(x); @@ -480,7 +461,6 @@ public ref struct GumpLayoutBuilder int x, int y, int width, int height, int hue, int entryId ) { - _hasVisualElements = true; GrowIfNeeded(11 + 9 + 54 + 6); WriteStart("textentry"u8); WriteValue(x); @@ -501,7 +481,6 @@ public ref struct GumpLayoutBuilder int x, int y, int width, int height, int hue, int entryId, int initialText ) { - _hasVisualElements = true; GrowIfNeeded(11 + 9 + 63 + 6); WriteStart("textentry"u8); WriteValue(x); @@ -520,7 +499,6 @@ public ref struct GumpLayoutBuilder int x, int y, int width, int height, int hue, int entryId, int size = 0 ) { - _hasVisualElements = true; GrowIfNeeded(12 + 16 + 63 + 6); WriteStart("textentrylimited"u8); WriteValue(x); @@ -542,7 +520,6 @@ public ref struct GumpLayoutBuilder int x, int y, int width, int height, int hue, int entryId, int initialText, int size = 0 ) { - _hasVisualElements = true; GrowIfNeeded(12 + 16 + 72); WriteStart("textentrylimited"u8); WriteValue(x); diff --git a/Projects/UOContent/Gumps/Base/Legacy/Gump.cs b/Projects/UOContent/Gumps/Base/Legacy/Gump.cs index 3be1ddebf..5cfb1f14a 100644 --- a/Projects/UOContent/Gumps/Base/Legacy/Gump.cs +++ b/Projects/UOContent/Gumps/Base/Legacy/Gump.cs @@ -249,7 +249,6 @@ public class Gump : BaseGump { _textEntries = 0; _switches = 0; - HasVisualElements = false; var layoutWriter = new SpanWriter(_layoutBuffer); @@ -275,7 +274,6 @@ public class Gump : BaseGump foreach (var entry in Entries) { - HasVisualElements |= IsVisualEntry(entry); entry.AppendTo(ref layoutWriter, _stringsList, ref _textEntries, ref _switches); } @@ -313,9 +311,6 @@ public class Gump : BaseGump } } - private static bool IsVisualEntry(GumpEntry entry) => - entry is not (GumpAlphaRegion or GumpECHandleInput or GumpGroup or GumpItemProperty or GumpMasterGump or GumpPage or GumpTooltip); - protected void Reset() { _switches = 0; diff --git a/Projects/UOContent/Gumps/Base/StaticGump.cs b/Projects/UOContent/Gumps/Base/StaticGump.cs index 0ab2df5e0..a10491618 100644 --- a/Projects/UOContent/Gumps/Base/StaticGump.cs +++ b/Projects/UOContent/Gumps/Base/StaticGump.cs @@ -30,7 +30,6 @@ public abstract class StaticGump : BaseGump where TSelf : StaticGump : BaseGump where TSelf : StaticGump : BaseGump where TSelf : StaticGump _gumpBuilder._switches; public int TextEntries => _gumpBuilder._textEntries; - internal bool HasVisualElements => _gumpBuilder._hasVisualElements; [MethodImpl(MethodImplOptions.AggressiveInlining)] public StaticGumpBuilder() diff --git a/Projects/UOContent/Items/Addons/BaseAddon.cs b/Projects/UOContent/Items/Addons/BaseAddon.cs index f2a662f6a..6b2abbc66 100644 --- a/Projects/UOContent/Items/Addons/BaseAddon.cs +++ b/Projects/UOContent/Items/Addons/BaseAddon.cs @@ -63,14 +63,22 @@ namespace Server.Items } } - [SerializableField(1, fieldChanged: nameof(OnResourceChanged))] - [SerializedCommandProperty(AccessLevel.GameMaster)] - [InvalidateProperties] - private CraftResource _resource; - - private void OnResourceChanged(CraftResource oldValue, CraftResource newValue) + [SerializableProperty(1)] + [CommandProperty(AccessLevel.GameMaster)] + public CraftResource Resource { - Hue = CraftResources.GetHue(_resource); + get => _resource; + set + { + if (_resource != value) + { + _resource = value; + Hue = CraftResources.GetHue(_resource); + + InvalidateProperties(); + this.MarkDirty(); + } + } } Item IAddon.Deed => Deed; diff --git a/Projects/UOContent/Items/Addons/BaseAddonContainer.cs b/Projects/UOContent/Items/Addons/BaseAddonContainer.cs index 8554ad4e5..ad655c24e 100644 --- a/Projects/UOContent/Items/Addons/BaseAddonContainer.cs +++ b/Projects/UOContent/Items/Addons/BaseAddonContainer.cs @@ -41,14 +41,22 @@ namespace Server.Items } } - [SerializableField(1, fieldChanged: nameof(OnResourceChanged))] - [SerializedCommandProperty(AccessLevel.GameMaster)] - [InvalidateProperties] - private CraftResource _resource; - - private void OnResourceChanged(CraftResource oldValue, CraftResource newValue) + [SerializableProperty(1)] + [CommandProperty(AccessLevel.GameMaster)] + public CraftResource Resource { - Hue = CraftResources.GetHue(_resource); + get => _resource; + set + { + if (_resource != value) + { + _resource = value; + Hue = CraftResources.GetHue(_resource); + + InvalidateProperties(); + this.MarkDirty(); + } + } } public virtual bool RetainDeedHue => false; diff --git a/Projects/UOContent/Items/Addons/BaseAddonContainerDeed.cs b/Projects/UOContent/Items/Addons/BaseAddonContainerDeed.cs index 7d5014dee..c7e08e955 100644 --- a/Projects/UOContent/Items/Addons/BaseAddonContainerDeed.cs +++ b/Projects/UOContent/Items/Addons/BaseAddonContainerDeed.cs @@ -23,14 +23,22 @@ public abstract partial class BaseAddonContainerDeed : Item, ICraftable public abstract BaseAddonContainer Addon { get; } - [SerializableField(0, fieldChanged: nameof(OnResourceChanged))] - [SerializedCommandProperty(AccessLevel.GameMaster)] - [InvalidateProperties] - private CraftResource _resource; - - private void OnResourceChanged(CraftResource oldValue, CraftResource newValue) + [SerializableProperty(0)] + [CommandProperty(AccessLevel.GameMaster)] + public CraftResource Resource { - Hue = CraftResources.GetHue(_resource); + get => _resource; + set + { + if (_resource != value) + { + _resource = value; + Hue = CraftResources.GetHue(_resource); + + InvalidateProperties(); + this.MarkDirty(); + } + } } public virtual int OnCraft( diff --git a/Projects/UOContent/Items/Addons/FlourMillEastAddon.cs b/Projects/UOContent/Items/Addons/FlourMillEastAddon.cs index decf3ca65..f84c959cf 100644 --- a/Projects/UOContent/Items/Addons/FlourMillEastAddon.cs +++ b/Projects/UOContent/Items/Addons/FlourMillEastAddon.cs @@ -48,19 +48,17 @@ namespace Server.Items [CommandProperty(AccessLevel.GameMaster)] public int MaxFlour => 2; - [SerializableField(0, fieldChanged: nameof(OnCurFlourChanged), allowFieldChange: nameof(AllowCurFlourChange))] - [SerializedCommandProperty(AccessLevel.GameMaster)] - private int _curFlour; - - private bool AllowCurFlourChange(ref int value) + [SerializableProperty(0)] + [CommandProperty(AccessLevel.GameMaster)] + public int CurFlour { - value = Math.Clamp(value, 0, MaxFlour); - return true; - } - - private void OnCurFlourChanged(int oldValue, int newValue) - { - UpdateStage(); + get => _curFlour; + set + { + _curFlour = Math.Clamp(value, 0, MaxFlour); + UpdateStage(); + this.MarkDirty(); + } } public void StartWorking(Mobile from) diff --git a/Projects/UOContent/Items/Addons/FlourMillSouthAddon.cs b/Projects/UOContent/Items/Addons/FlourMillSouthAddon.cs index fa48aa83c..cbd0e2cc3 100644 --- a/Projects/UOContent/Items/Addons/FlourMillSouthAddon.cs +++ b/Projects/UOContent/Items/Addons/FlourMillSouthAddon.cs @@ -35,19 +35,16 @@ namespace Server.Items [CommandProperty(AccessLevel.GameMaster)] public int MaxFlour => 2; - [SerializableField(0, fieldChanged: nameof(OnCurFlourChanged), allowFieldChange: nameof(AllowCurFlourChange))] - [SerializedCommandProperty(AccessLevel.GameMaster)] - private int _curFlour; - - private bool AllowCurFlourChange(ref int value) + [SerializableProperty(0)] + [CommandProperty(AccessLevel.GameMaster)] + public int CurFlour { - value = Math.Max(0, Math.Min(value, MaxFlour)); - return true; - } - - private void OnCurFlourChanged(int oldValue, int newValue) - { - UpdateStage(); + get => _curFlour; + set + { + _curFlour = Math.Max(0, Math.Min(value, MaxFlour)); + UpdateStage(); + } } public void StartWorking(Mobile from) diff --git a/Projects/UOContent/Items/Addons/SHTeleporter.cs b/Projects/UOContent/Items/Addons/SHTeleporter.cs index e6ce612b4..f34f9cb85 100644 --- a/Projects/UOContent/Items/Addons/SHTeleporter.cs +++ b/Projects/UOContent/Items/Addons/SHTeleporter.cs @@ -25,27 +25,35 @@ namespace Server.Items _teleOffset = offset; } - [SerializableField(0, fieldChanged: nameof(OnActiveChanged))] - [SerializedCommandProperty(AccessLevel.GameMaster)] - private bool _active; - - private void OnActiveChanged(bool oldValue, bool newValue) + [SerializableProperty(0)] + [CommandProperty(AccessLevel.GameMaster)] + public bool Active { - if (Addon is SHTeleporter sourceAddon) + get => _active; + set { - sourceAddon.ChangeActive(newValue); + _active = value; + + if (Addon is SHTeleporter sourceAddon) + { + sourceAddon.ChangeActive(value); + } } } - [SerializableField(1, fieldChanged: nameof(OnTeleDestChanged))] - [SerializedCommandProperty(AccessLevel.GameMaster)] - private SHTeleComponent _teleDest; - - private void OnTeleDestChanged(SHTeleComponent oldValue, SHTeleComponent newValue) + [SerializableProperty(1)] + [CommandProperty(AccessLevel.GameMaster)] + public SHTeleComponent TeleDest { - if (Addon is SHTeleporter sourceAddon) + get => _teleDest; + set { - sourceAddon.ChangeDest(newValue); + _teleDest = value; + + if (Addon is SHTeleporter sourceAddon) + { + sourceAddon.ChangeDest(value); + } } } diff --git a/Projects/UOContent/Items/Aquarium/Aquarium.cs b/Projects/UOContent/Items/Aquarium/Aquarium.cs index 054fea588..749c677c2 100644 --- a/Projects/UOContent/Items/Aquarium/Aquarium.cs +++ b/Projects/UOContent/Items/Aquarium/Aquarium.cs @@ -31,9 +31,9 @@ namespace Server.Items private bool m_EvaluateDay; [SerializableField(0, setter: "private")] - [DeserializeTimer(nameof(DeserializeEvaluateTimer), wallClock: true)] private Timer _evaluateTimer; + [DeserializeTimerField(0)] private void DeserializeEvaluateTimer(TimeSpan delay) { _evaluateTimer = Timer.DelayCall(delay, EvaluationInterval, Evaluate); diff --git a/Projects/UOContent/Items/Aquarium/AquariumState.cs b/Projects/UOContent/Items/Aquarium/AquariumState.cs index 75fe6fc6f..cdd59d9d9 100644 --- a/Projects/UOContent/Items/Aquarium/AquariumState.cs +++ b/Projects/UOContent/Items/Aquarium/AquariumState.cs @@ -30,14 +30,19 @@ namespace Server.Items public AquariumState(Aquarium parent) => _aquarium = parent; - [SerializableField(0, allowFieldChange: nameof(AllowStateChange))] - [SerializedCommandProperty(AccessLevel.GameMaster)] - private int _state; - - private bool AllowStateChange(ref int value) + [SerializableProperty(0)] + [CommandProperty(AccessLevel.GameMaster)] + public int State { - value = Math.Clamp(value, 0, 4); - return true; + get => _state; + set + { + if (_state != value) + { + _state = Math.Clamp(value, 0, 4); + MarkDirty(); + } + } } [SerializableField(1)] diff --git a/Projects/UOContent/Items/Armor/BaseArmor.Migrations.cs b/Projects/UOContent/Items/Armor/BaseArmor.Migrations.cs index f15a7fd6a..3135850a9 100644 --- a/Projects/UOContent/Items/Armor/BaseArmor.Migrations.cs +++ b/Projects/UOContent/Items/Armor/BaseArmor.Migrations.cs @@ -1,40 +1,9 @@ -using System; using AMA = Server.Items.ArmorMeditationAllowance; namespace Server.Items; public partial class BaseArmor { - // PlayerConstructed moved onto Item - private void MigrateFrom(V9Content content) - { - _attributes = content.Attributes ?? AttributesDefaultValue(); - _armorAttributes = content.ArmorAttributes ?? ArmorAttributesDefaultValue(); - _physicalBonus = content.PhysicalBonus ?? 0; - _fireBonus = content.FireBonus ?? 0; - _coldBonus = content.ColdBonus ?? 0; - _poisonBonus = content.PoisonBonus ?? 0; - _energyBonus = content.EnergyBonus ?? 0; - _identified = content.Identified; - _maxHitPoints = content.MaxHitPoints ?? 0; - _hitPoints = content.HitPoints ?? 0; - _crafter = content.Crafter; - _quality = content.Quality ?? ArmorQuality.Regular; - _durability = content.Durability ?? ArmorDurabilityLevel.Regular; - _protectionLevel = content.ProtectionLevel ?? ArmorProtectionLevel.Regular; - _resource = content.Resource ?? DefaultResource; - _armorBase = content.BaseArmorRating ?? -1; - _strBonus = content.StrBonus ?? -1; - _dexBonus = content.DexBonus ?? -1; - _intBonus = content.IntBonus ?? -1; - _strReq = content.StrRequirement ?? -1; - _dexReq = content.DexRequirement ?? -1; - _intReq = content.IntRequirement ?? -1; - _meditate = content.MeditationAllowance ?? (AMA)(-1); - _skillBonuses = content.SkillBonuses ?? SkillBonusesDefaultValue(); - PlayerConstructed = content.PlayerConstructed; - } - private void MigrateFrom(V8Content content) { _attributes = content.Attributes ?? AttributesDefaultValue(); @@ -60,7 +29,7 @@ public partial class BaseArmor _intReq = content.IntRequirement ?? -1; _meditate = content.MeditationAllowance ?? (AMA)(-1); _skillBonuses = content.SkillBonuses ?? SkillBonusesDefaultValue(); - PlayerConstructed = content.PlayerConstructed; + _playerConstructed = content.PlayerConstructed; } // Version 7 (pre-codegen) @@ -193,37 +162,4 @@ public partial class BaseArmor PlayerConstructed = GetSaveFlag(flags, OldSaveFlag.PlayerConstructed); } - - private static bool GetSaveFlag(OldSaveFlag flags, OldSaveFlag toGet) => (flags & toGet) != 0; - - [Flags] - private enum OldSaveFlag - { - None = 0x00000000, - Attributes = 0x00000001, - ArmorAttributes = 0x00000002, - PhysicalBonus = 0x00000004, - FireBonus = 0x00000008, - ColdBonus = 0x00000010, - PoisonBonus = 0x00000020, - EnergyBonus = 0x00000040, - Identified = 0x00000080, - MaxHitPoints = 0x00000100, - HitPoints = 0x00000200, - Crafter = 0x00000400, - Quality = 0x00000800, - Durability = 0x00001000, - Protection = 0x00002000, - Resource = 0x00004000, - BaseArmor = 0x00008000, - StrBonus = 0x00010000, - DexBonus = 0x00020000, - IntBonus = 0x00040000, - StrReq = 0x00080000, - DexReq = 0x00100000, - IntReq = 0x00200000, - MedAllowance = 0x00400000, - SkillBonuses = 0x00800000, - PlayerConstructed = 0x01000000 - } } diff --git a/Projects/UOContent/Items/Armor/BaseArmor.cs b/Projects/UOContent/Items/Armor/BaseArmor.cs index f4062326d..7f01648a0 100644 --- a/Projects/UOContent/Items/Armor/BaseArmor.cs +++ b/Projects/UOContent/Items/Armor/BaseArmor.cs @@ -12,98 +12,101 @@ using AMT = Server.Items.ArmorMaterialType; namespace Server.Items { - [SerializationGenerator(10, false)] + [SerializationGenerator(9, false)] public abstract partial class BaseArmor : Item, IScissorable, IFactionItem, ICraftable, IWearableDurability, IAosItem, IIdentifiable { [SerializedIgnoreDupe] [SerializableField(0, setter: "private")] - [SaveFlag(nameof(ShouldSerializeAosAttributes), nameof(AttributesDefaultValue))] [SerializedCommandProperty(AccessLevel.GameMaster, canModify: true)] private AosAttributes _attributes; + [SerializableFieldSaveFlag(0)] private bool ShouldSerializeAosAttributes() => !_attributes.IsEmpty; + [SerializableFieldDefault(0)] private AosAttributes AttributesDefaultValue() => new(this); [SerializedIgnoreDupe] [SerializableField(1, setter: "private")] - [SaveFlag(nameof(ShouldSerializeArmorAttributes), nameof(ArmorAttributesDefaultValue))] [SerializedCommandProperty(AccessLevel.GameMaster, canModify: true)] private AosArmorAttributes _armorAttributes; + [SerializableFieldSaveFlag(1)] private bool ShouldSerializeArmorAttributes() => !_armorAttributes.IsEmpty; + [SerializableFieldDefault(1)] private AosArmorAttributes ArmorAttributesDefaultValue() => new(this); [EncodedInt] [InvalidateProperties] [SerializableField(2)] - [SaveFlag(nameof(ShouldSerializePhysicalBonus))] [SerializedCommandProperty(AccessLevel.GameMaster)] private int _physicalBonus; + [SerializableFieldSaveFlag(2)] private bool ShouldSerializePhysicalBonus() => _physicalBonus != 0; [EncodedInt] [InvalidateProperties] [SerializableField(3)] - [SaveFlag(nameof(ShouldSerializeFireBonus))] [SerializedCommandProperty(AccessLevel.GameMaster)] private int _fireBonus; + [SerializableFieldSaveFlag(3)] private bool ShouldSerializeFireBonus() => _fireBonus != 0; [EncodedInt] [InvalidateProperties] [SerializableField(4)] - [SaveFlag(nameof(ShouldSerializeColdBonus))] [SerializedCommandProperty(AccessLevel.GameMaster)] private int _coldBonus; + [SerializableFieldSaveFlag(4)] private bool ShouldSerializeColdBonus() => _coldBonus != 0; [EncodedInt] [InvalidateProperties] [SerializableField(5)] - [SaveFlag(nameof(ShouldSerializePoisonBonus))] [SerializedCommandProperty(AccessLevel.GameMaster)] private int _poisonBonus; + [SerializableFieldSaveFlag(5)] private bool ShouldSerializePoisonBonus() => _poisonBonus != 0; [EncodedInt] [InvalidateProperties] [SerializableField(6)] - [SaveFlag(nameof(ShouldSerializeEnergyBonus))] [SerializedCommandProperty(AccessLevel.GameMaster)] private int _energyBonus; + [SerializableFieldSaveFlag(6)] private bool ShouldSerializeEnergyBonus() => _energyBonus != 0; [SerializableField(7)] - [SaveFlag(nameof(ShouldSerializeIdentified))] [SerializedCommandProperty(AccessLevel.GameMaster)] private bool _identified; + [SerializableFieldSaveFlag(7)] private bool ShouldSerializeIdentified() => _identified; [EncodedInt] [SerializableField(8)] - [SaveFlag(nameof(ShouldSerializeMaxHitPoints))] [SerializedCommandProperty(AccessLevel.GameMaster)] private int _maxHitPoints; + [SerializableFieldSaveFlag(8)] private bool ShouldSerializeMaxHitPoints() => _maxHitPoints != 0; [InvalidateProperties] [SerializableField(10)] - [SaveFlag(nameof(ShouldSerializeCrafter))] [SerializedCommandProperty(AccessLevel.GameMaster)] private string _crafter; + [SerializableFieldSaveFlag(10)] private bool ShouldSerializeCrafter() => !string.IsNullOrEmpty(_crafter); + [SerializableFieldSaveFlag(14)] private bool ShouldSerializeResource() => _resource != DefaultResource; // Field 15 @@ -132,14 +135,22 @@ namespace Server.Items [SerializedIgnoreDupe] [SerializableField(23, setter: "private")] - [SaveFlag(nameof(ShouldSerializeSkillBonuses), nameof(SkillBonusesDefaultValue))] [SerializedCommandProperty(AccessLevel.GameMaster, canModify: true)] public AosSkillBonuses _skillBonuses; + [SerializableFieldSaveFlag(23)] private bool ShouldSerializeSkillBonuses() => !_skillBonuses.IsEmpty; + [SerializableFieldDefault(23)] private AosSkillBonuses SkillBonusesDefaultValue() => new(this); + [SerializableField(24)] + [SerializedCommandProperty(AccessLevel.GameMaster)] + public bool _playerConstructed; + + [SerializableFieldSaveFlag(24)] + private bool ShouldSerializePlayerConstructed() => _playerConstructed; + private FactionItem m_FactionState; public BaseArmor(int itemID) : base(itemID) @@ -186,7 +197,6 @@ namespace Server.Items public virtual int OldIntReq => 0; [SerializableProperty(11)] - [SaveFlag(nameof(ShouldSerializeArmorQuality), nameof(QualityDefaultValue))] [CommandProperty(AccessLevel.GameMaster)] public ArmorQuality Quality { @@ -199,12 +209,13 @@ namespace Server.Items } } + [SerializableFieldSaveFlag(11)] private bool ShouldSerializeArmorQuality() => _quality != ArmorQuality.Regular; + [SerializableFieldDefault(11)] private ArmorQuality QualityDefaultValue() => ArmorQuality.Regular; [SerializableProperty(12)] - [SaveFlag(nameof(ShouldSerializeDurability))] [CommandProperty(AccessLevel.GameMaster)] public ArmorDurabilityLevel Durability { @@ -217,24 +228,33 @@ namespace Server.Items } } + [SerializableFieldSaveFlag(12)] private bool ShouldSerializeDurability() => _durability != ArmorDurabilityLevel.Regular; - [SerializableField(13, fieldChanged: nameof(OnProtectionLevelChanged))] - [SaveFlag(nameof(ShouldSerializeProtectionLevel))] - [SerializedCommandProperty(AccessLevel.GameMaster)] - [InvalidateProperties] - private ArmorProtectionLevel _protectionLevel; - - private void OnProtectionLevelChanged(ArmorProtectionLevel oldValue, ArmorProtectionLevel newValue) + [SerializableProperty(13)] + [CommandProperty(AccessLevel.GameMaster)] + public ArmorProtectionLevel ProtectionLevel { - Invalidate(); - (Parent as Mobile)?.UpdateResistances(); + get => _protectionLevel; + set + { + if (_protectionLevel != value) + { + _protectionLevel = value; + + Invalidate(); + InvalidateProperties(); + + (Parent as Mobile)?.UpdateResistances(); + this.MarkDirty(); + } + } } + [SerializableFieldSaveFlag(13)] private bool ShouldSerializeProtectionLevel() => _protectionLevel != ArmorProtectionLevel.Regular; [SerializableProperty(14)] - [SaveFlag(nameof(ShouldSerializeResource), nameof(ResourceDefaultValue))] [CommandProperty(AccessLevel.GameMaster)] public CraftResource Resource { @@ -260,11 +280,11 @@ namespace Server.Items } } + [SerializableFieldDefault(14)] private CraftResource ResourceDefaultValue() => DefaultResource; [EncodedInt] [SerializableProperty(15, useField: nameof(_armorBase))] - [SaveFlag(nameof(ShouldSerializeArmorBase), nameof(ArmorBaseDefaultValue))] [CommandProperty(AccessLevel.GameMaster)] public int BaseArmorRating { @@ -277,8 +297,10 @@ namespace Server.Items } } + [SerializableFieldSaveFlag(15)] private bool ShouldSerializeArmorBase() => _armorBase != -1; + [SerializableFieldDefault(15)] private int ArmorBaseDefaultValue() => -1; public double BaseArmorRatingScaled => BaseArmorRating * ArmorScalar; @@ -328,7 +350,6 @@ namespace Server.Items [EncodedInt] [SerializableProperty(16, useField: nameof(_strBonus))] - [SaveFlag(nameof(ShouldSerializeStrBonus), nameof(StrBonusDefaultValue))] [CommandProperty(AccessLevel.GameMaster)] public int StrBonus { @@ -341,13 +362,14 @@ namespace Server.Items } } + [SerializableFieldSaveFlag(16)] private bool ShouldSerializeStrBonus() => _strBonus != -1; + [SerializableFieldDefault(16)] private int StrBonusDefaultValue() => -1; [EncodedInt] [SerializableProperty(17, useField: nameof(_dexBonus))] - [SaveFlag(nameof(ShouldSerializeDexBonus), nameof(DexBonusDefaultValue))] [CommandProperty(AccessLevel.GameMaster)] public int DexBonus { @@ -360,13 +382,14 @@ namespace Server.Items } } + [SerializableFieldSaveFlag(17)] private bool ShouldSerializeDexBonus() => _dexBonus != -1; + [SerializableFieldDefault(17)] private int DexBonusDefaultValue() => -1; [EncodedInt] [SerializableProperty(18, useField: nameof(_intBonus))] - [SaveFlag(nameof(ShouldSerializeIntBonus), nameof(IntBonusDefaultValue))] [CommandProperty(AccessLevel.GameMaster)] public int IntBonus { @@ -379,13 +402,14 @@ namespace Server.Items } } + [SerializableFieldSaveFlag(18)] private bool ShouldSerializeIntBonus() => _intBonus != -1; + [SerializableFieldDefault(18)] private int IntBonusDefaultValue() => -1; [EncodedInt] [SerializableProperty(19, useField: nameof(_strReq))] - [SaveFlag(nameof(ShouldSerializeStrReq), nameof(StrReqDefaultValue))] [CommandProperty(AccessLevel.GameMaster)] public int StrRequirement { @@ -398,13 +422,14 @@ namespace Server.Items } } + [SerializableFieldSaveFlag(19)] private bool ShouldSerializeStrReq() => _strReq != -1; + [SerializableFieldDefault(19)] private int StrReqDefaultValue() => -1; [EncodedInt] [SerializableProperty(20, useField: nameof(_dexReq))] - [SaveFlag(nameof(ShouldSerializeDexReq), nameof(DexReqDefaultValue))] [CommandProperty(AccessLevel.GameMaster)] public int DexRequirement { @@ -417,13 +442,14 @@ namespace Server.Items } } + [SerializableFieldSaveFlag(20)] private bool ShouldSerializeDexReq() => _dexReq != -1; + [SerializableFieldDefault(20)] private int DexReqDefaultValue() => -1; [EncodedInt] [SerializableProperty(21, useField: nameof(_intReq))] - [SaveFlag(nameof(ShouldSerializeIntReq), nameof(IntReqDefaultValue))] [CommandProperty(AccessLevel.GameMaster)] public int IntRequirement { @@ -436,12 +462,13 @@ namespace Server.Items } } + [SerializableFieldSaveFlag(21)] private bool ShouldSerializeIntReq() => _intReq != -1; + [SerializableFieldDefault(21)] private int IntReqDefaultValue() => -1; [SerializableProperty(22, useField: nameof(_meditate))] - [SaveFlag(nameof(ShouldSerializeMeditationAllowance))] [CommandProperty(AccessLevel.GameMaster)] public AMA MeditationAllowance { @@ -453,6 +480,7 @@ namespace Server.Items } } + [SerializableFieldSaveFlag(22)] private bool ShouldSerializeMeditationAllowance() => _meditate >= AMA.All; public virtual double ArmorScalar @@ -542,6 +570,7 @@ namespace Server.Items var resourceType = typeRes ?? craftItem.Resources[0].ItemType; Resource = CraftResources.GetFromType(resourceType); + PlayerConstructed = true; Identified = true; var context = craftSystem.GetContext(from); @@ -668,7 +697,6 @@ namespace Server.Items [EncodedInt] [SerializableProperty(9)] - [SaveFlag(nameof(ShouldSerializeHitPoints))] [CommandProperty(AccessLevel.GameMaster)] public int HitPoints { @@ -696,6 +724,7 @@ namespace Server.Items } } + [SerializableFieldSaveFlag(9)] private bool ShouldSerializeHitPoints() => _hitPoints != 0; public virtual int InitMinHits => 0; @@ -997,6 +1026,8 @@ namespace Server.Items (Parent as Mobile)?.Delta(MobileDelta.Armor); // Tell them armor rating has changed } + private static bool GetSaveFlag(OldSaveFlag flags, OldSaveFlag toGet) => (flags & toGet) != 0; + [AfterDeserialization] private void AfterDeserialization() { @@ -1483,5 +1514,35 @@ namespace Server.Items }; } + [Flags] + private enum OldSaveFlag + { + None = 0x00000000, + Attributes = 0x00000001, + ArmorAttributes = 0x00000002, + PhysicalBonus = 0x00000004, + FireBonus = 0x00000008, + ColdBonus = 0x00000010, + PoisonBonus = 0x00000020, + EnergyBonus = 0x00000040, + Identified = 0x00000080, + MaxHitPoints = 0x00000100, + HitPoints = 0x00000200, + Crafter = 0x00000400, + Quality = 0x00000800, + Durability = 0x00001000, + Protection = 0x00002000, + Resource = 0x00004000, + BaseArmor = 0x00008000, + StrBonus = 0x00010000, + DexBonus = 0x00020000, + IntBonus = 0x00040000, + StrReq = 0x00080000, + DexReq = 0x00100000, + IntReq = 0x00200000, + MedAllowance = 0x00400000, + SkillBonuses = 0x00800000, + PlayerConstructed = 0x01000000 + } } } diff --git a/Projects/UOContent/Items/Armor/Glasses/ElvenGlasses.cs b/Projects/UOContent/Items/Armor/Glasses/ElvenGlasses.cs index e44967f0b..4b0fb791f 100644 --- a/Projects/UOContent/Items/Armor/Glasses/ElvenGlasses.cs +++ b/Projects/UOContent/Items/Armor/Glasses/ElvenGlasses.cs @@ -31,12 +31,13 @@ namespace Server.Items public override ArmorMeditationAllowance DefMedAllowance => ArmorMeditationAllowance.All; [SerializableField(0, setter: "private")] - [SaveFlag(nameof(ShouldSerializeWeaponAttributes), nameof(WeaponAttributesDefaultValue))] [SerializedCommandProperty(AccessLevel.GameMaster, canModify: true)] public AosWeaponAttributes _weaponAttributes; + [SerializableFieldSaveFlag(0)] private bool ShouldSerializeWeaponAttributes() => !_weaponAttributes.IsEmpty; + [SerializableFieldDefault(0)] private AosWeaponAttributes WeaponAttributesDefaultValue() => new(this); public override void AppendChildNameProperties(IPropertyList list) diff --git a/Projects/UOContent/Items/Armor/Leather/LeafGloves.cs b/Projects/UOContent/Items/Armor/Leather/LeafGloves.cs index aa023cdc3..f9abd485b 100644 --- a/Projects/UOContent/Items/Armor/Leather/LeafGloves.cs +++ b/Projects/UOContent/Items/Armor/Leather/LeafGloves.cs @@ -33,26 +33,32 @@ namespace Server.Items public override ArmorMeditationAllowance DefMedAllowance => ArmorMeditationAllowance.All; - [SerializableField(0, fieldChanged: nameof(OnCurArcaneChargesChanged))] [EncodedInt] - [SerializedCommandProperty(AccessLevel.GameMaster)] - [InvalidateProperties] - private int _curArcaneCharges; - - private void OnCurArcaneChargesChanged(int oldValue, int newValue) + [SerializableProperty(0)] + [CommandProperty(AccessLevel.GameMaster)] + public int CurArcaneCharges { - Update(); + get => _curArcaneCharges; + set + { + _curArcaneCharges = value; + InvalidateProperties(); + Update(); + } } - [SerializableField(1, fieldChanged: nameof(OnMaxArcaneChargesChanged))] [EncodedInt] - [SerializedCommandProperty(AccessLevel.GameMaster)] - [InvalidateProperties] - private int _maxArcaneCharges; - - private void OnMaxArcaneChargesChanged(int oldValue, int newValue) + [SerializableProperty(1)] + [CommandProperty(AccessLevel.GameMaster)] + public int MaxArcaneCharges { - Update(); + get => _maxArcaneCharges; + set + { + _maxArcaneCharges = value; + InvalidateProperties(); + Update(); + } } [CommandProperty(AccessLevel.GameMaster)] diff --git a/Projects/UOContent/Items/Armor/Leather/LeatherGloves.cs b/Projects/UOContent/Items/Armor/Leather/LeatherGloves.cs index 297bd8ed1..a1ded151b 100644 --- a/Projects/UOContent/Items/Armor/Leather/LeatherGloves.cs +++ b/Projects/UOContent/Items/Armor/Leather/LeatherGloves.cs @@ -32,26 +32,32 @@ namespace Server.Items public override ArmorMeditationAllowance DefMedAllowance => ArmorMeditationAllowance.All; - [SerializableField(0, fieldChanged: nameof(OnCurArcaneChargesChanged))] [EncodedInt] - [SerializedCommandProperty(AccessLevel.GameMaster)] - [InvalidateProperties] - private int _curArcaneCharges; - - private void OnCurArcaneChargesChanged(int oldValue, int newValue) + [SerializableProperty(0)] + [CommandProperty(AccessLevel.GameMaster)] + public int CurArcaneCharges { - Update(); + get => _curArcaneCharges; + set + { + _curArcaneCharges = value; + InvalidateProperties(); + Update(); + } } - [SerializableField(1, fieldChanged: nameof(OnMaxArcaneChargesChanged))] [EncodedInt] - [SerializedCommandProperty(AccessLevel.GameMaster)] - [InvalidateProperties] - private int _maxArcaneCharges; - - private void OnMaxArcaneChargesChanged(int oldValue, int newValue) + [SerializableProperty(1)] + [CommandProperty(AccessLevel.GameMaster)] + public int MaxArcaneCharges { - Update(); + get => _maxArcaneCharges; + set + { + _maxArcaneCharges = value; + InvalidateProperties(); + Update(); + } } [CommandProperty(AccessLevel.GameMaster)] diff --git a/Projects/UOContent/Items/Books/BaseBook.cs b/Projects/UOContent/Items/Books/BaseBook.cs index 3d9b9f7b8..47f368754 100644 --- a/Projects/UOContent/Items/Books/BaseBook.cs +++ b/Projects/UOContent/Items/Books/BaseBook.cs @@ -19,38 +19,41 @@ namespace Server.Items [InternString] [InvalidateProperties] [SerializableField(1)] - [SaveFlag(nameof(ShouldSerializeTitle), nameof(TitleDefaultValue))] [SerializedCommandProperty(AccessLevel.GameMaster)] private string _title; + [SerializableFieldSaveFlag(1)] private bool ShouldSerializeTitle() => _title != DefaultContent?.Title; + [SerializableFieldDefault(1)] private string TitleDefaultValue() => DefaultContent?.Title; [InvalidateProperties] [SerializableField(2)] - [SaveFlag(nameof(ShouldSerializeAuthor), nameof(AuthorDefaultValue))] [SerializedCommandProperty(AccessLevel.GameMaster)] private string _author; + [SerializableFieldSaveFlag(2)] private bool ShouldSerializeAuthor() => _author != DefaultContent?.Author; + [SerializableFieldDefault(2)] private string AuthorDefaultValue() => DefaultContent?.Author; [SerializableField(3)] - [SaveFlag(nameof(ShouldSerializeWritable))] [SerializedCommandProperty(AccessLevel.GameMaster)] private bool _writable; + [SerializableFieldSaveFlag(3)] private bool ShouldSerializeWritable() => _writable; [SerializedIgnoreDupe] [SerializableField(4, setter: "protected")] - [SaveFlag(nameof(ShouldSerializePages), nameof(PagesDefaultValue))] private BookPageInfo[] _pages; + [SerializableFieldSaveFlag(4)] private bool ShouldSerializePages() => DefaultContent?.IsMatch(_pages) != true; + [SerializableFieldDefault(4)] private BookPageInfo[] PagesDefaultValue() => DefaultContent?.Copy() ?? Array.Empty(); [Constructible] diff --git a/Projects/UOContent/Items/Clothing/BaseClothing.Migrations.cs b/Projects/UOContent/Items/Clothing/BaseClothing.Migrations.cs index 7c431f076..e267cd1aa 100644 --- a/Projects/UOContent/Items/Clothing/BaseClothing.Migrations.cs +++ b/Projects/UOContent/Items/Clothing/BaseClothing.Migrations.cs @@ -1,25 +1,7 @@ -using System; - namespace Server.Items; public partial class BaseClothing { - // PlayerConstructed moved onto Item - private void MigrateFrom(V7Content content) - { - _resource = content.Resource ?? DefaultResource; - _attributes = content.Attributes ?? AttributesDefaultValue(); - _clothingAttributes = content.ClothingAttributes ?? ClothingAttributesDefaultValue(); - _skillBonuses = content.SkillBonuses ?? SkillBonusesDefaultValue(); - _resistances = content.Resistances ?? ResistancesDefaultValue(); - _maxHitPoints = content.MaxHitPoints ?? 0; - _hitPoints = content.HitPoints ?? 0; - PlayerConstructed = content.PlayerConstructed; - _crafter = content.Crafter; - _quality = content.Quality ?? ClothingQuality.Regular; - _strReq = content.StrRequirement ?? -1; - } - private void MigrateFrom(V6Content content) { _resource = content.RawResource ?? DefaultResource; @@ -28,7 +10,7 @@ public partial class BaseClothing _skillBonuses = content.SkillBonuses ?? SkillBonusesDefaultValue(); _resistances = content.Resistances ?? ResistancesDefaultValue(); _maxHitPoints = content.MaxHitPoints ?? 0; - PlayerConstructed = content.PlayerConstructed; + _playerConstructed = content.PlayerConstructed; Timer.DelayCall((item, crafter) => item._crafter = crafter?.RawName, this, content.Crafter); _quality = content.Quality ?? ClothingQuality.Regular; _strReq = content.StrRequirement ?? -1; @@ -103,23 +85,4 @@ public partial class BaseClothing PlayerConstructed = GetSaveFlag(flags, OldSaveFlag.PlayerConstructed); } - - private static bool GetSaveFlag(OldSaveFlag flags, OldSaveFlag toGet) => (flags & toGet) != 0; - - [Flags] - private enum OldSaveFlag - { - None = 0x00000000, - Resource = 0x00000001, - Attributes = 0x00000002, - ClothingAttributes = 0x00000004, - SkillBonuses = 0x00000008, - Resistances = 0x00000010, - MaxHitPoints = 0x00000020, - HitPoints = 0x00000040, - PlayerConstructed = 0x00000080, - Crafter = 0x00000100, - Quality = 0x00000200, - StrReq = 0x00000400 - } } diff --git a/Projects/UOContent/Items/Clothing/BaseClothing.cs b/Projects/UOContent/Items/Clothing/BaseClothing.cs index 983568d2d..725eed100 100644 --- a/Projects/UOContent/Items/Clothing/BaseClothing.cs +++ b/Projects/UOContent/Items/Clothing/BaseClothing.cs @@ -22,78 +22,90 @@ namespace Server.Items int MaxArcaneCharges { get; set; } } - [SerializationGenerator(8, false)] + [SerializationGenerator(7, false)] public abstract partial class BaseClothing : Item, IDyable, IScissorable, IFactionItem, ICraftable, IWearableDurability, IAosItem { + [SerializableFieldSaveFlag(0)] private bool ShouldSerializeResource() => _resource != DefaultResource; [SerializedIgnoreDupe] [SerializableField(1, setter: "private")] - [SaveFlag(nameof(ShouldSerializeAttributes), nameof(AttributesDefaultValue))] [SerializedCommandProperty(AccessLevel.GameMaster, canModify: true)] private AosAttributes _attributes; + [SerializableFieldSaveFlag(1)] private bool ShouldSerializeAttributes() => !_attributes.IsEmpty; + [SerializableFieldDefault(1)] private AosAttributes AttributesDefaultValue() => new(this); [SerializedIgnoreDupe] [SerializableField(2, setter: "private")] - [SaveFlag(nameof(ShouldSerializeClothingAttributes), nameof(ClothingAttributesDefaultValue))] [SerializedCommandProperty(AccessLevel.GameMaster, canModify: true)] private AosArmorAttributes _clothingAttributes; + [SerializableFieldSaveFlag(2)] private bool ShouldSerializeClothingAttributes() => !_clothingAttributes.IsEmpty; + [SerializableFieldDefault(2)] private AosArmorAttributes ClothingAttributesDefaultValue() => new(this); [SerializedIgnoreDupe] [SerializableField(3, setter: "private")] - [SaveFlag(nameof(ShouldSerializeSkillBonuses), nameof(SkillBonusesDefaultValue))] [SerializedCommandProperty(AccessLevel.GameMaster, canModify: true)] private AosSkillBonuses _skillBonuses; + [SerializableFieldSaveFlag(3)] private bool ShouldSerializeSkillBonuses() => !_skillBonuses.IsEmpty; + [SerializableFieldDefault(3)] private AosSkillBonuses SkillBonusesDefaultValue() => new(this); [SerializedIgnoreDupe] [SerializableField(4, setter: "private")] - [SaveFlag(nameof(ShouldSerializeResistances), nameof(ResistancesDefaultValue))] [SerializedCommandProperty(AccessLevel.GameMaster, canModify: true)] private AosElementAttributes _resistances; + [SerializableFieldSaveFlag(4)] private bool ShouldSerializeResistances() => !_resistances.IsEmpty; + [SerializableFieldDefault(4)] private AosElementAttributes ResistancesDefaultValue() => new(this); [EncodedInt] [InvalidateProperties] [SerializableField(5)] - [SaveFlag(nameof(ShouldSerializeMaxHitPoints))] [SerializedCommandProperty(AccessLevel.GameMaster)] private int _maxHitPoints; + [SerializableFieldSaveFlag(5)] private bool ShouldSerializeMaxHitPoints() => _maxHitPoints != 0; - [InvalidateProperties] [SerializableField(7)] - [SaveFlag(nameof(ShouldSerializeCrafter))] [SerializedCommandProperty(AccessLevel.GameMaster)] - private string _crafter; + private bool _playerConstructed; - private bool ShouldSerializeCrafter() => !string.IsNullOrEmpty(_crafter); + [SerializableFieldSaveFlag(7)] + private bool ShouldSerializePlayerConstructed() => _playerConstructed; [InvalidateProperties] [SerializableField(8)] - [SaveFlag(nameof(ShouldSerializeQuality))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private string _crafter; + + [SerializableFieldSaveFlag(8)] + private bool ShouldSerializeCrafter() => !string.IsNullOrEmpty(_crafter); + + [InvalidateProperties] + [SerializableField(9)] [SerializedCommandProperty(AccessLevel.GameMaster)] private ClothingQuality _quality = ClothingQuality.Regular; + [SerializableFieldSaveFlag(9)] private bool ShouldSerializeQuality() => _quality != ClothingQuality.Regular; - // Field 9 + // Field 10 private int _strReq = -1; private FactionItem _factionState; @@ -113,19 +125,21 @@ namespace Server.Items Resistances = new AosElementAttributes(this); } - [SerializableField(0, fieldChanged: nameof(OnResourceChanged))] - [SaveFlag(nameof(ShouldSerializeResource))] - [SerializedCommandProperty(AccessLevel.GameMaster)] - [InvalidateProperties] - private CraftResource _resource; - - private void OnResourceChanged(CraftResource oldValue, CraftResource newValue) + [SerializableProperty(0)] + [CommandProperty(AccessLevel.GameMaster)] + public CraftResource Resource { - Hue = CraftResources.GetHue(_resource); + get => _resource; + set + { + _resource = value; + Hue = CraftResources.GetHue(_resource); + InvalidateProperties(); + this.MarkDirty(); + } } - [SerializableProperty(9, useField: nameof(_strReq))] - [SaveFlag(nameof(ShouldSerializeStrReq))] + [SerializableProperty(10, useField: nameof(_strReq))] [CommandProperty(AccessLevel.GameMaster)] public int StrRequirement { @@ -138,6 +152,7 @@ namespace Server.Items } } + [SerializableFieldSaveFlag(10)] private bool ShouldSerializeStrReq() => _strReq != -1; public virtual CraftResource DefaultResource => CraftResource.None; @@ -192,6 +207,8 @@ namespace Server.Items Hue = resHue; } + PlayerConstructed = true; + var context = craftSystem.GetContext(from); if (context?.DoNotColor == true) @@ -291,7 +308,6 @@ namespace Server.Items [EncodedInt] [SerializableProperty(6)] - [SaveFlag(nameof(ShouldSerializeHitPoints))] [CommandProperty(AccessLevel.GameMaster)] public int HitPoints { @@ -317,6 +333,7 @@ namespace Server.Items } } + [SerializableFieldSaveFlag(6)] private bool ShouldSerializeHitPoints() => _hitPoints != 0; public virtual int InitMinHits => 0; @@ -872,6 +889,8 @@ namespace Server.Items InvalidateProperties(); } + private static bool GetSaveFlag(OldSaveFlag flags, OldSaveFlag toGet) => (flags & toGet) != 0; + [AfterDeserialization] private void AfterDeserialization() { @@ -892,5 +911,21 @@ namespace Server.Items } } + [Flags] + private enum OldSaveFlag + { + None = 0x00000000, + Resource = 0x00000001, + Attributes = 0x00000002, + ClothingAttributes = 0x00000004, + SkillBonuses = 0x00000008, + Resistances = 0x00000010, + MaxHitPoints = 0x00000020, + HitPoints = 0x00000040, + PlayerConstructed = 0x00000080, + Crafter = 0x00000100, + Quality = 0x00000200, + StrReq = 0x00000400 + } } } diff --git a/Projects/UOContent/Items/Clothing/Cloaks.cs b/Projects/UOContent/Items/Clothing/Cloaks.cs index a92e8fc8c..67f572d9d 100644 --- a/Projects/UOContent/Items/Clothing/Cloaks.cs +++ b/Projects/UOContent/Items/Clothing/Cloaks.cs @@ -22,26 +22,34 @@ namespace Server.Items public override double DefaultWeight => 5.0; - [SerializableField(0, fieldChanged: nameof(OnCurArcaneChargesChanged))] [EncodedInt] - [SerializedCommandProperty(AccessLevel.GameMaster)] - [InvalidateProperties] - private int _curArcaneCharges; - - private void OnCurArcaneChargesChanged(int oldValue, int newValue) + [SerializableProperty(0)] + [CommandProperty(AccessLevel.GameMaster)] + public int CurArcaneCharges { - Update(); + get => _curArcaneCharges; + set + { + _curArcaneCharges = value; + this.MarkDirty(); + InvalidateProperties(); + Update(); + } } - [SerializableField(1, fieldChanged: nameof(OnMaxArcaneChargesChanged))] [EncodedInt] - [SerializedCommandProperty(AccessLevel.GameMaster)] - [InvalidateProperties] - private int _maxArcaneCharges; - - private void OnMaxArcaneChargesChanged(int oldValue, int newValue) + [SerializableProperty(1)] + [CommandProperty(AccessLevel.GameMaster)] + public int MaxArcaneCharges { - Update(); + get => _maxArcaneCharges; + set + { + _maxArcaneCharges = value; + this.MarkDirty(); + InvalidateProperties(); + Update(); + } } [CommandProperty(AccessLevel.GameMaster)] diff --git a/Projects/UOContent/Items/Clothing/OuterTorso.cs b/Projects/UOContent/Items/Clothing/OuterTorso.cs index f3b0b077f..4fda45bb5 100644 --- a/Projects/UOContent/Items/Clothing/OuterTorso.cs +++ b/Projects/UOContent/Items/Clothing/OuterTorso.cs @@ -37,22 +37,21 @@ namespace Server.Items public override double DefaultWeight => 3.0; } - [SerializationGenerator(4, false)] + [SerializationGenerator(3, false)] public partial class DeathRobe : Robe { private static readonly TimeSpan m_DefaultDecayTime = TimeSpan.FromMinutes(1.0); + [TimerDrift] [SerializableField(0)] - [DeserializeTimer(nameof(DeserializeDecayTimer))] private Timer _decayTimer; - private void DeserializeDecayTimer(TimeSpan delay) => BeginDecay(delay); - - private void MigrateFrom(V3Content content) + [DeserializeTimerField(0)] + private void DeserializeDecayTimer(TimeSpan delay) { - if (content.DecayTimerDelay != TimeSpan.MinValue) + if (delay != TimeSpan.MinValue) { - DeserializeDecayTimer(content.DecayTimerDelay); + BeginDecay(delay); } } @@ -325,26 +324,34 @@ namespace Server.Items public override double DefaultWeight => 3.0; - [SerializableField(0, fieldChanged: nameof(OnCurArcaneChargesChanged))] [EncodedInt] - [SerializedCommandProperty(AccessLevel.GameMaster)] - [InvalidateProperties] - private int _curArcaneCharges; - - private void OnCurArcaneChargesChanged(int oldValue, int newValue) + [SerializableProperty(0)] + [CommandProperty(AccessLevel.GameMaster)] + public int CurArcaneCharges { - Update(); + get => _curArcaneCharges; + set + { + _curArcaneCharges = value; + InvalidateProperties(); + Update(); + this.MarkDirty(); + } } - [SerializableField(1, fieldChanged: nameof(OnMaxArcaneChargesChanged))] [EncodedInt] - [SerializedCommandProperty(AccessLevel.GameMaster)] - [InvalidateProperties] - private int _maxArcaneCharges; - - private void OnMaxArcaneChargesChanged(int oldValue, int newValue) + [SerializableProperty(1)] + [CommandProperty(AccessLevel.GameMaster)] + public int MaxArcaneCharges { - Update(); + get => _maxArcaneCharges; + set + { + _maxArcaneCharges = value; + InvalidateProperties(); + Update(); + this.MarkDirty(); + } } [CommandProperty(AccessLevel.GameMaster)] diff --git a/Projects/UOContent/Items/Clothing/Shoes.cs b/Projects/UOContent/Items/Clothing/Shoes.cs index f791b3a62..4d80b4699 100644 --- a/Projects/UOContent/Items/Clothing/Shoes.cs +++ b/Projects/UOContent/Items/Clothing/Shoes.cs @@ -54,10 +54,11 @@ namespace Server.Items { [EncodedInt] [InvalidateProperties] - [SerializableField(0, fieldChanged: nameof(OnCurArcaneChargesChanged))] + [SerializableField(0)] [SerializedCommandProperty(AccessLevel.GameMaster)] private int _curArcaneCharges; + [SerializableFieldChanged(0)] [MethodImpl(MethodImplOptions.AggressiveInlining)] private void OnCurArcaneChargesChanged(int oldValue, int newValue) => Update(); @@ -70,15 +71,19 @@ namespace Server.Items public override CraftResource DefaultResource => CraftResource.RegularLeather; - [SerializableField(1, fieldChanged: nameof(OnMaxArcaneChargesChanged))] [EncodedInt] - [SerializedCommandProperty(AccessLevel.GameMaster)] - [InvalidateProperties] - private int _maxArcaneCharges; - - private void OnMaxArcaneChargesChanged(int oldValue, int newValue) + [SerializableProperty(1)] + [CommandProperty(AccessLevel.GameMaster)] + public int MaxArcaneCharges { - Update(); + get => _maxArcaneCharges; + set + { + _maxArcaneCharges = value; + InvalidateProperties(); + Update(); + this.MarkDirty(); + } } [CommandProperty(AccessLevel.GameMaster)] diff --git a/Projects/UOContent/Items/Construction/Doors/BaseDoor.cs b/Projects/UOContent/Items/Construction/Doors/BaseDoor.cs index ef779760b..dc3449c24 100644 --- a/Projects/UOContent/Items/Construction/Doors/BaseDoor.cs +++ b/Projects/UOContent/Items/Construction/Doors/BaseDoor.cs @@ -74,31 +74,43 @@ public abstract partial class BaseDoor : Item, ILockable, ITelekinesisable Movable = false; } - [SerializableField(1, fieldChanged: nameof(OnOpenChanged))] - [SerializedCommandProperty(AccessLevel.GameMaster)] - private bool _open; - - private void OnOpenChanged(bool oldValue, bool newValue) + [SerializableProperty(1)] + [CommandProperty(AccessLevel.GameMaster)] + public bool Open { - ItemID = _open ? _openedId : _closedId; - if (_open) + get => _open; + set { - Location = new Point3D(X + _offset.X, Y + _offset.Y, Z + _offset.Z); - } - else - { - Location = new Point3D(X - _offset.X, Y - _offset.Y, Z - _offset.Z); - } - Effects.PlaySound(this, _open ? OpenedSound : ClosedSound); - if (_open) - { - _timer ??= new InternalTimer(this); - _timer.Start(); - } - else - { - _timer.Stop(); - _timer = null; + if (_open != value) + { + _open = value; + + ItemID = _open ? _openedId : _closedId; + + if (_open) + { + Location = new Point3D(X + _offset.X, Y + _offset.Y, Z + _offset.Z); + } + else + { + Location = new Point3D(X - _offset.X, Y - _offset.Y, Z - _offset.Z); + } + + Effects.PlaySound(this, _open ? OpenedSound : ClosedSound); + + if (_open) + { + _timer ??= new InternalTimer(this); + _timer.Start(); + } + else + { + _timer.Stop(); + _timer = null; + } + + this.MarkDirty(); + } } } diff --git a/Projects/UOContent/Items/Containers/Fillable Containers/FillableContainer.cs b/Projects/UOContent/Items/Containers/Fillable Containers/FillableContainer.cs index da4d8b77f..a9da67dbb 100644 --- a/Projects/UOContent/Items/Containers/Fillable Containers/FillableContainer.cs +++ b/Projects/UOContent/Items/Containers/Fillable Containers/FillableContainer.cs @@ -3,22 +3,19 @@ using ModernUO.Serialization; namespace Server.Items; -[SerializationGenerator(3, false)] +[SerializationGenerator(2, false)] public abstract partial class FillableContainer : LockableContainer { + [TimerDrift] [SerializableField(1)] - [DeserializeTimer(nameof(DeserializeRespawnTimer))] private Timer _respawnTimer; - private void DeserializeRespawnTimer(TimeSpan delay) => _respawnTimer = Timer.DelayCall(delay, Respawn); - - private void MigrateFrom(V2Content content) + [DeserializeTimerField(1)] + private void DeserializeRespawnTimer(TimeSpan delay) { - _contentType = content.ContentType; - - if (content.RespawnTimerDelay != TimeSpan.MinValue) + if (delay > TimeSpan.MinValue) { - DeserializeRespawnTimer(content.RespawnTimerDelay); + _respawnTimer = Timer.DelayCall(delay, Respawn); } } diff --git a/Projects/UOContent/Items/Containers/MarkContainer.cs b/Projects/UOContent/Items/Containers/MarkContainer.cs index 04e7c89ea..fa3f7e440 100644 --- a/Projects/UOContent/Items/Containers/MarkContainer.cs +++ b/Projects/UOContent/Items/Containers/MarkContainer.cs @@ -3,13 +3,14 @@ using ModernUO.Serialization; namespace Server.Items; -[SerializationGenerator(1, false)] +[SerializationGenerator(0, false)] public partial class MarkContainer : LockableContainer { + [TimerDrift] [SerializableField(1, getter: "private", setter: "private")] - [DeserializeTimer(nameof(DeserializeRelockTimer))] private InternalTimer _relockTimer; + [DeserializeTimerField(1)] private void DeserializeRelockTimer(TimeSpan delay) { if (!Locked && _autoLock) @@ -18,19 +19,6 @@ public partial class MarkContainer : LockableContainer } } - private void MigrateFrom(V0Content content) - { - _autoLock = content.AutoLock; - _targetMap = content.TargetMap; - _target = content.Target; - _description = content.Description; - - if (content.RelockTimerDelay != TimeSpan.MinValue) - { - DeserializeRelockTimer(content.RelockTimerDelay); - } - } - [SerializableField(2)] [SerializedCommandProperty(AccessLevel.GameMaster)] private Map _targetMap; @@ -62,19 +50,23 @@ public partial class MarkContainer : LockableContainer } } - [SerializableField(0, fieldChanged: nameof(OnAutoLockChanged))] - [SerializedCommandProperty(AccessLevel.GameMaster)] - private bool _autoLock; - - private void OnAutoLockChanged(bool oldValue, bool newValue) + [SerializableProperty(0)] + [CommandProperty(AccessLevel.GameMaster)] + public bool AutoLock { - if (!_autoLock) + get => _autoLock; + set { - StopTimer(); - } - else if (!Locked) - { - _relockTimer ??= new InternalTimer(this); + _autoLock = value; + + if (!_autoLock) + { + StopTimer(); + } + else if (!Locked) + { + _relockTimer ??= new InternalTimer(this); + } } } diff --git a/Projects/UOContent/Items/Containers/TreasureMapChest.cs b/Projects/UOContent/Items/Containers/TreasureMapChest.cs index a1aafa0b3..b59911a8a 100644 --- a/Projects/UOContent/Items/Containers/TreasureMapChest.cs +++ b/Projects/UOContent/Items/Containers/TreasureMapChest.cs @@ -9,11 +9,10 @@ using Server.Network; namespace Server.Items; -[SerializationGenerator(4, false)] +[SerializationGenerator(2, false)] public partial class TreasureMapChest : LockableContainer { [Tidy] - [CanBeNull] [SerializableField(0, setter: "private")] private List _guardians; @@ -29,11 +28,12 @@ public partial class TreasureMapChest : LockableContainer [SerializedCommandProperty(AccessLevel.GameMaster)] private int _level; + [TimerDrift] [SerializableField(4)] [SerializedCommandProperty(AccessLevel.GameMaster)] - [DeserializeTimer(nameof(DeserializeExpireTimer))] private Timer _expireTimer; + [DeserializeTimerField(4)] private void DeserializeExpireTimer(TimeSpan delay) { if (!_temporary) @@ -42,29 +42,11 @@ public partial class TreasureMapChest : LockableContainer } } - private void MigrateFrom(V3Content content) - { - _guardians = content.Guardians; - _temporary = content.Temporary; - _owner = content.Owner; - _level = content.Level; - _lifted = content.Lifted; - - if (content.ExpireTimerDelay != TimeSpan.MinValue) - { - DeserializeExpireTimer(content.ExpireTimerDelay); - } - } - [Tidy] - [CanBeNull] [SerializableField(5, setter: "private")] [SerializedCommandProperty(AccessLevel.GameMaster)] private HashSet _lifted; - // False only while the constructor fills the chest; deserialized chests are always filled. - private bool _filled; - [Constructible] public TreasureMapChest(int level) : this(null, level) { @@ -76,10 +58,10 @@ public partial class TreasureMapChest : LockableContainer _level = level; _temporary = temporary; + _guardians = []; _expireTimer = Timer.DelayCall(TimeSpan.FromHours(3.0), Delete); Fill(this, level); - _filled = true; } public override int LabelNumber => 3000541; @@ -200,6 +182,7 @@ public partial class TreasureMapChest : LockableContainer 2 => 76, 3 => 84, 4 => 92, + 5 => 100, _ => 100 }; @@ -317,17 +300,14 @@ public partial class TreasureMapChest : LockableContainer if (_level == 0 && from.AccessLevel < AccessLevel.GameMaster) { - if (_guardians != null) + for (var i = 0; i < _guardians.Count; i++) { - for (var i = 0; i < _guardians.Count; i++) + var m = _guardians[i]; + if (m.Alive) { - var m = _guardians[i]; - if (m.Alive) - { - // You must first kill the guardians before you may open this chest. - from.SendLocalizedMessage(1046448); - return true; - } + // You must first kill the guardians before you may open this chest. + from.SendLocalizedMessage(1046448); + return true; } } @@ -381,21 +361,12 @@ public partial class TreasureMapChest : LockableContainer public override bool CheckLift(Mobile from, Item item, ref LRReason reject) => CheckLoot(from, true) && base.CheckLift(from, item, ref reject); - public override void OnItemAdded(Item item) - { - base.OnItemAdded(item); - - if (_filled) - { - _lifted ??= []; - _lifted.Add(item); - } - } - public override void OnItemLifted(Mobile from, Item item) { + var notYetLifted = _lifted?.Contains(item) != true; from.RevealingAction(); - if (_lifted?.Contains(item) != true) + + if (notYetLifted) { _lifted ??= []; _lifted.Add(item); @@ -422,6 +393,8 @@ public partial class TreasureMapChest : LockableContainer private void Deserialize(IGenericReader reader, int version) { + _guardians = []; + _owner = reader.ReadEntity(); _level = reader.ReadInt(); var expireTimerNext = reader.ReadDeltaTime(); @@ -429,34 +402,12 @@ public partial class TreasureMapChest : LockableContainer _lifted = reader.ReadEntitySet(); } - private void MigrateFrom(V2Content content) - { - _guardians = content.Guardians; - if (_guardians.Count == 0) - { - _guardians = null; - } - _temporary = content.Temporary; - _owner = content.Owner; - _level = content.Level; - _lifted = content.Lifted; - if (_lifted.Count == 0) - { - _lifted = null; - } - - var expireTimerDelay = content.ExpireTimerDelay; - DeserializeExpireTimer(expireTimerDelay); - } - - [AfterDeserialization] + [AfterDeserialization(false)] private void AfterDeserialization() { - _filled = true; - if (_expireTimer == null) { - Timer.DelayCall(Delete); + Delete(); } } diff --git a/Projects/UOContent/Items/Deeds/DragonBardingDeed.cs b/Projects/UOContent/Items/Deeds/DragonBardingDeed.cs index 5ed370153..6599e6c56 100644 --- a/Projects/UOContent/Items/Deeds/DragonBardingDeed.cs +++ b/Projects/UOContent/Items/Deeds/DragonBardingDeed.cs @@ -28,14 +28,17 @@ public partial class DragonBardingDeed : Item, ICraftable public override int LabelNumber => _exceptional ? 1053181 : 1053012; // dragon barding deed - [SerializableField(2, fieldChanged: nameof(OnResourceChanged))] - [SerializedCommandProperty(AccessLevel.GameMaster)] - [InvalidateProperties] - private CraftResource _resource; - - private void OnResourceChanged(CraftResource oldValue, CraftResource newValue) + [SerializableProperty(2)] + [CommandProperty(AccessLevel.GameMaster)] + public CraftResource Resource { - Hue = CraftResources.GetHue(newValue); + get => _resource; + set + { + _resource = value; + Hue = CraftResources.GetHue(value); + InvalidateProperties(); + } } public int OnCraft( diff --git a/Projects/UOContent/Items/Food/Beverage.cs b/Projects/UOContent/Items/Food/Beverage.cs index 85eecf17f..3a9f50297 100644 --- a/Projects/UOContent/Items/Food/Beverage.cs +++ b/Projects/UOContent/Items/Food/Beverage.cs @@ -324,21 +324,27 @@ public abstract partial class BaseBeverage : Item, IHasQuantity [CommandProperty(AccessLevel.GameMaster)] public bool IsFull => _quantity >= MaxQuantity; - [SerializableField(2, fieldChanged: nameof(OnContentChanged))] - [SerializedCommandProperty(AccessLevel.GameMaster)] - [InvalidateProperties] - private BeverageType _content; - - private void OnContentChanged(BeverageType oldValue, BeverageType newValue) + [SerializableProperty(2)] + [CommandProperty(AccessLevel.GameMaster)] + public BeverageType Content { - var itemID = ComputeItemID(); - if (itemID > 0) + get => _content; + set { - ItemID = itemID; - } - else - { - Delete(); + _content = value; + + InvalidateProperties(); + + var itemID = ComputeItemID(); + + if (itemID > 0) + { + ItemID = itemID; + } + else + { + Delete(); + } } } diff --git a/Projects/UOContent/Items/Food/Cooking.cs b/Projects/UOContent/Items/Food/Cooking.cs index cd010432d..6cff16d72 100644 --- a/Projects/UOContent/Items/Food/Cooking.cs +++ b/Projects/UOContent/Items/Food/Cooking.cs @@ -76,25 +76,25 @@ public partial class SackFlour : Item, IHasQuantity public override double DefaultWeight => 5.0; - [SerializableField(0, fieldChanged: nameof(OnQuantityChanged), allowFieldChange: nameof(AllowQuantityChange))] - [SerializedCommandProperty(AccessLevel.GameMaster)] - private int _quantity; - - private bool AllowQuantityChange(ref int value) + [SerializableProperty(0)] + [CommandProperty(AccessLevel.GameMaster)] + public int Quantity { - value = Math.Min(20, Math.Max(0, value)); - return true; - } + get => _quantity; + set + { + _quantity = Math.Min(20, Math.Max(0, value)); - private void OnQuantityChanged(int oldValue, int newValue) - { - if (_quantity == 0) - { - Delete(); - } - else if (_quantity < 20 && ItemID is 0x1039 or 0x1045) - { - ++ItemID; + if (_quantity == 0) + { + Delete(); + } + else if (_quantity < 20 && ItemID is 0x1039 or 0x1045) + { + ++ItemID; + } + + this.MarkDirty(); } } diff --git a/Projects/UOContent/Items/Games/Mahjong/MahjongGame.cs b/Projects/UOContent/Items/Games/Mahjong/MahjongGame.cs index 89e4d6f8f..de7e663b7 100644 --- a/Projects/UOContent/Items/Games/Mahjong/MahjongGame.cs +++ b/Projects/UOContent/Items/Games/Mahjong/MahjongGame.cs @@ -55,33 +55,55 @@ public partial class MahjongGame : Item, ISecurable public override double DefaultWeight => 5.0; - [SerializableField(6, fieldChanged: nameof(OnShowScoresChanged))] - [SerializedCommandProperty(AccessLevel.GameMaster)] - private bool _showScores; - - private void OnShowScoresChanged(bool oldValue, bool newValue) + [CommandProperty(AccessLevel.GameMaster)] + [SerializableProperty(6)] + public bool ShowScores { - if (newValue) + get => _showScores; + set { - _players.SendPlayersPacket(true, true); + if (_showScores == value) + { + return; + } + + _showScores = value; + + if (value) + { + _players.SendPlayersPacket(true, true); + } + + _players.SendGeneralPacket(true, true); + _players.SendLocalizedMessage(value ? 1062777 : 1062778); // The dealer has enabled/disabled score display. + this.MarkDirty(); } - _players.SendGeneralPacket(true, true); - _players.SendLocalizedMessage(newValue ? 1062777 : 1062778); // The dealer has enabled/disabled score display. } - [SerializableField(7, fieldChanged: nameof(OnSpectatorVisionChanged))] - [SerializedCommandProperty(AccessLevel.GameMaster)] - [InvalidateProperties] - private bool _spectatorVision; - - private void OnSpectatorVisionChanged(bool oldValue, bool newValue) + [CommandProperty(AccessLevel.GameMaster)] + [SerializableProperty(7)] + public bool SpectatorVision { - if (_players.IsInGamePlayer(_players.DealerPosition)) + get => _spectatorVision; + set { - _players.Dealer.NetState.SendMahjongGeneralInfo(this); + if (_spectatorVision == value) + { + return; + } + + _spectatorVision = value; + + if (_players.IsInGamePlayer(_players.DealerPosition)) + { + _players.Dealer.NetState.SendMahjongGeneralInfo(this); + } + + _players.SendTilesPacket(false, true); + _players.SendLocalizedMessage(value ? 1062715 : 1062716); // The dealer has enabled/disabled Spectator Vision. + InvalidateProperties(); + this.MarkDirty(); } - _players.SendTilesPacket(false, true); - _players.SendLocalizedMessage(newValue ? 1062715 : 1062716); // The dealer has enabled/disabled Spectator Vision. } private void BuildHorizontalWall( diff --git a/Projects/UOContent/Items/Jewels/BaseJewel.cs b/Projects/UOContent/Items/Jewels/BaseJewel.cs index e693c0aaa..4eb01cbe0 100644 --- a/Projects/UOContent/Items/Jewels/BaseJewel.cs +++ b/Projects/UOContent/Items/Jewels/BaseJewel.cs @@ -93,13 +93,16 @@ public abstract partial class BaseJewel : Item, ICraftable, IAosItem } } - [SerializableField(2, fieldChanged: nameof(OnResourceChanged))] - [SerializedCommandProperty(AccessLevel.GameMaster)] - private CraftResource _resource; - - private void OnResourceChanged(CraftResource oldValue, CraftResource newValue) + [SerializableProperty(2)] + [CommandProperty(AccessLevel.GameMaster)] + public CraftResource Resource { - Hue = CraftResources.GetHue(_resource); + get => _resource; + set + { + _resource = value; + Hue = CraftResources.GetHue(_resource); + } } public override int PhysicalResistance => Resistances.Physical; diff --git a/Projects/UOContent/Items/Lights/BaseLight.cs b/Projects/UOContent/Items/Lights/BaseLight.cs index 4bd63ba63..d3bff4af5 100644 --- a/Projects/UOContent/Items/Lights/BaseLight.cs +++ b/Projects/UOContent/Items/Lights/BaseLight.cs @@ -3,7 +3,7 @@ using ModernUO.Serialization; namespace Server.Items; -[SerializationGenerator(2, false)] +[SerializationGenerator(1, false)] public abstract partial class BaseLight : Item { public static readonly bool Burnout = false; @@ -16,10 +16,11 @@ public abstract partial class BaseLight : Item [SerializedCommandProperty(AccessLevel.GameMaster)] private bool _protected; + [TimerDrift] [SerializableField(4, getter: "private", setter: "private")] - [DeserializeTimer(nameof(DeserializeTimer))] private Timer _burnTimer; + [DeserializeTimerField(4)] private void DeserializeTimer(TimeSpan delay) { if (_burning && _duration != TimeSpan.Zero) @@ -28,19 +29,6 @@ public abstract partial class BaseLight : Item } } - private void MigrateFrom(V1Content content) - { - _burntOut = content.BurntOut; - _burning = content.Burning; - _duration = content.Duration; - _protected = content.Protected; - - if (content.BurnTimerDelay != TimeSpan.MinValue) - { - DeserializeTimer(content.BurnTimerDelay); - } - } - [Constructible] public BaseLight(int itemID) : base(itemID) { diff --git a/Projects/UOContent/Items/Misc/CommunicationCrystals.cs b/Projects/UOContent/Items/Misc/CommunicationCrystals.cs index 07df233e9..a52b1a12d 100644 --- a/Projects/UOContent/Items/Misc/CommunicationCrystals.cs +++ b/Projects/UOContent/Items/Misc/CommunicationCrystals.cs @@ -275,16 +275,8 @@ public partial class BroadcastCrystal : Item [SerializationGenerator(0)] public partial class ReceiverCrystal : Item { - [SerializableField(0, fieldChanged: nameof(OnSenderChanged))] - [SerializedCommandProperty(AccessLevel.GameMaster)] private BroadcastCrystal _sender; - private void OnSenderChanged(BroadcastCrystal oldValue, BroadcastCrystal newValue) - { - oldValue?.RemoveReceiver(this); - newValue?.AddReceiver(this); - } - [Constructible] public ReceiverCrystal() : base(0x1ED0) => Light = LightType.Circle150; @@ -305,6 +297,20 @@ public partial class ReceiverCrystal : Item } } + [SerializableProperty(0, useField: nameof(_sender))] + [CommandProperty(AccessLevel.GameMaster)] + public BroadcastCrystal Sender + { + get => _sender; + set + { + _sender?.RemoveReceiver(this); + _sender = value; + value?.AddReceiver(this); + this.MarkDirty(); + } + } + public override void GetProperties(IPropertyList list) { base.GetProperties(list); diff --git a/Projects/UOContent/Items/Misc/Corpses/Corpse.Migrations.cs b/Projects/UOContent/Items/Misc/Corpses/Corpse.Migrations.cs deleted file mode 100644 index bc9ce047e..000000000 --- a/Projects/UOContent/Items/Misc/Corpses/Corpse.Migrations.cs +++ /dev/null @@ -1,179 +0,0 @@ -using System; - -namespace Server.Items; - -public partial class Corpse -{ - // Decay timer and TimeOfDeath moved from delta time to anchored time - private void MigrateFrom(V18Content content) - { - _restoreEquip = content.RestoreEquip; - _flags = content.Flags; - _timeOfDeath = content.TimeOfDeath; - _restoreTable = content.RestoreTable; - _looters = content.Looters; - _killer = content.Killer; - _aggressors = content.Aggressors; - _owner = content.Owner; - _corpseName = content.CorpseName; - _accessLevel = content.AccessLevel; - _guild = content.Guild; - _equipItems = content.EquipItems; - _hairItemId = content.HairItemId; - _hairHue = content.HairHue; - _facialHairItemId = content.FacialHairItemId; - _facialHairHue = content.FacialHairHue; - - if (content.DecayTimerDelay != TimeSpan.MinValue) - { - DeserializeDecayTimer(content.DecayTimerDelay); - } - } - - // Decay timer moved from [TimerDrift]/[DeserializeTimerField] to [DeserializeTimer] - private void MigrateFrom(V17Content content) - { - _restoreEquip = content.RestoreEquip; - _flags = content.Flags; - _timeOfDeath = content.TimeOfDeath; - _restoreTable = content.RestoreTable; - _looters = content.Looters; - _killer = content.Killer; - _aggressors = content.Aggressors; - _owner = content.Owner; - _corpseName = content.CorpseName; - _accessLevel = content.AccessLevel; - _guild = content.Guild; - _equipItems = content.EquipItems; - _hairItemId = content.HairItemId; - _hairHue = content.HairHue; - _facialHairItemId = content.FacialHairItemId; - _facialHairHue = content.FacialHairHue; - - if (content.DecayTimerDelay != TimeSpan.MinValue) - { - DeserializeDecayTimer(content.DecayTimerDelay); - } - } - - // Decomposed VirtualHairInfo into discrete int fields (hair/facial hair item id + hue) - private void MigrateFrom(V16Content content) - { - _restoreEquip = content.RestoreEquip; - _flags = content.Flags; - _timeOfDeath = content.TimeOfDeath; - _restoreTable = content.RestoreTable; - _decayTimer = new InternalTimer(this, content.DecayTimerDelay); - _decayTimer.Start(); - _looters = content.Looters; - _killer = content.Killer; - _aggressors = content.Aggressors; - _owner = content.Owner; - _corpseName = content.CorpseName; - _accessLevel = content.AccessLevel; - _guild = content.Guild; - _equipItems = content.EquipItems; - if (content.Hair != null) - { - _hairItemId = content.Hair.ItemId; - _hairHue = content.Hair.Hue; - } - - if (content.FacialHair != null) - { - _facialHairItemId = content.FacialHair.ItemId; - _facialHairHue = content.FacialHair.Hue; - } - } - - // Folded Murderer bool field into CorpseFlag.Murderer - private void MigrateFrom(V15Content content) - { - _restoreEquip = content.RestoreEquip; - _flags = content.Flags; - if (content.Murderer) - { - _flags |= CorpseFlag.Murderer; - } - _timeOfDeath = content.TimeOfDeath; - _restoreTable = content.RestoreTable; - _decayTimer = new InternalTimer(this, content.DecayTimerDelay); - _decayTimer.Start(); - _looters = content.Looters; - _killer = content.Killer; - _aggressors = content.Aggressors; - _owner = content.Owner; - _corpseName = content.CorpseName; - _accessLevel = content.AccessLevel; - _guild = content.Guild; - _equipItems = content.EquipItems; - if (content.Hair != null) - { - _hairItemId = content.Hair.ItemId; - _hairHue = content.Hair.Hue; - } - - if (content.FacialHair != null) - { - _facialHairItemId = content.FacialHair.ItemId; - _facialHairHue = content.FacialHair.Hue; - } - } - - // Replaced int Kills snapshot with bool Murderer snapshot - private void MigrateFrom(V14Content content) - { - _restoreEquip = content.RestoreEquip; - _flags = content.Flags; - if (content.Kills >= 5) - { - _flags |= CorpseFlag.Murderer; - } - _timeOfDeath = content.TimeOfDeath; - _restoreTable = content.RestoreTable; - _decayTimer = new InternalTimer(this, content.DecayTimerDelay); - _decayTimer.Start(); - _looters = content.Looters; - _killer = content.Killer; - _aggressors = content.Aggressors; - _owner = content.Owner; - _corpseName = content.CorpseName; - _accessLevel = content.AccessLevel; - _guild = content.Guild; - _equipItems = content.EquipItems; - if (content.Hair != null) - { - _hairItemId = content.Hair.ItemId; - _hairHue = content.Hair.Hue; - } - - if (content.FacialHair != null) - { - _facialHairItemId = content.FacialHair.ItemId; - _facialHairHue = content.FacialHair.Hue; - } - } - - // Added corpse hair and corpse facial hair - private void MigrateFrom(V13Content content) - { - _restoreEquip = content.RestoreEquip; - _flags = content.Flags; - if (content.Kills >= 5) - { - _flags |= CorpseFlag.Murderer; - } - _timeOfDeath = content.TimeOfDeath; - _restoreTable = content.RestoreTable; - _decayTimer = new InternalTimer(this, content.DecayTimerDelay); - _decayTimer.Start(); - _looters = content.Looters; - _killer = content.Killer; - _aggressors = content.Aggressors; - _owner = content.Owner; - _corpseName = content.CorpseName; - _accessLevel = content.AccessLevel; - _guild = content.Guild; - _equipItems = content.EquipItems; - } -} diff --git a/Projects/UOContent/Items/Misc/Corpses/Corpse.cs b/Projects/UOContent/Items/Misc/Corpses/Corpse.cs index 04e9806c9..1ee04170c 100644 --- a/Projects/UOContent/Items/Misc/Corpses/Corpse.cs +++ b/Projects/UOContent/Items/Misc/Corpses/Corpse.cs @@ -86,7 +86,7 @@ public enum CorpseFlag OwnerWasAnimatedDead = 0x00000800 } -[SerializationGenerator(19, false)] +[SerializationGenerator(17, false)] public partial class Corpse : Container, ICarvable { public static readonly TimeSpan MonsterLootRightSacrifice = TimeSpan.FromMinutes(2.0); @@ -106,7 +106,7 @@ public partial class Corpse : Container, ICarvable [SerializableField(1)] private CorpseFlag _flags; - [AnchoredDateTime] + [DeltaDateTime] [SerializableField(2)] [SerializedCommandProperty(AccessLevel.GameMaster)] private DateTime _timeOfDeath; @@ -114,10 +114,11 @@ public partial class Corpse : Container, ICarvable [SerializableField(3, getter: "private", setter: "private")] private Dictionary _restoreTable; + [TimerDrift] [SerializableField(4, getter: "private", setter: "private")] - [DeserializeTimer(nameof(DeserializeDecayTimer))] private Timer _decayTimer; + [DeserializeTimerField(4)] private void DeserializeDecayTimer(TimeSpan delay) => BeginDecay(delay); [SerializableField(5, setter: "private")] @@ -317,6 +318,127 @@ public partial class Corpse : Container, ICarvable DevourCorpse(); } + // Decomposed VirtualHairInfo into discrete int fields (hair/facial hair item id + hue) + private void MigrateFrom(V16Content content) + { + _restoreEquip = content.RestoreEquip; + _flags = content.Flags; + _timeOfDeath = content.TimeOfDeath; + _restoreTable = content.RestoreTable; + _decayTimer = new InternalTimer(this, content.DecayTimerDelay); + _decayTimer.Start(); + _looters = content.Looters; + _killer = content.Killer; + _aggressors = content.Aggressors; + _owner = content.Owner; + _corpseName = content.CorpseName; + _accessLevel = content.AccessLevel; + _guild = content.Guild; + _equipItems = content.EquipItems; + if (content.Hair != null) + { + _hairItemId = content.Hair.ItemId; + _hairHue = content.Hair.Hue; + } + + if (content.FacialHair != null) + { + _facialHairItemId = content.FacialHair.ItemId; + _facialHairHue = content.FacialHair.Hue; + } + } + + // Folded Murderer bool field into CorpseFlag.Murderer + private void MigrateFrom(V15Content content) + { + _restoreEquip = content.RestoreEquip; + _flags = content.Flags; + if (content.Murderer) + { + _flags |= CorpseFlag.Murderer; + } + _timeOfDeath = content.TimeOfDeath; + _restoreTable = content.RestoreTable; + _decayTimer = new InternalTimer(this, content.DecayTimerDelay); + _decayTimer.Start(); + _looters = content.Looters; + _killer = content.Killer; + _aggressors = content.Aggressors; + _owner = content.Owner; + _corpseName = content.CorpseName; + _accessLevel = content.AccessLevel; + _guild = content.Guild; + _equipItems = content.EquipItems; + if (content.Hair != null) + { + _hairItemId = content.Hair.ItemId; + _hairHue = content.Hair.Hue; + } + + if (content.FacialHair != null) + { + _facialHairItemId = content.FacialHair.ItemId; + _facialHairHue = content.FacialHair.Hue; + } + } + + // Replaced int Kills snapshot with bool Murderer snapshot + private void MigrateFrom(V14Content content) + { + _restoreEquip = content.RestoreEquip; + _flags = content.Flags; + if (content.Kills >= 5) + { + _flags |= CorpseFlag.Murderer; + } + _timeOfDeath = content.TimeOfDeath; + _restoreTable = content.RestoreTable; + _decayTimer = new InternalTimer(this, content.DecayTimerDelay); + _decayTimer.Start(); + _looters = content.Looters; + _killer = content.Killer; + _aggressors = content.Aggressors; + _owner = content.Owner; + _corpseName = content.CorpseName; + _accessLevel = content.AccessLevel; + _guild = content.Guild; + _equipItems = content.EquipItems; + if (content.Hair != null) + { + _hairItemId = content.Hair.ItemId; + _hairHue = content.Hair.Hue; + } + + if (content.FacialHair != null) + { + _facialHairItemId = content.FacialHair.ItemId; + _facialHairHue = content.FacialHair.Hue; + } + } + + // Added corpse hair and corpse facial hair + private void MigrateFrom(V13Content content) + { + _restoreEquip = content.RestoreEquip; + _flags = content.Flags; + if (content.Kills >= 5) + { + _flags |= CorpseFlag.Murderer; + } + _timeOfDeath = content.TimeOfDeath; + _restoreTable = content.RestoreTable; + _decayTimer = new InternalTimer(this, content.DecayTimerDelay); + _decayTimer.Start(); + _looters = content.Looters; + _killer = content.Killer; + _aggressors = content.Aggressors; + _owner = content.Owner; + _corpseName = content.CorpseName; + _accessLevel = content.AccessLevel; + _guild = content.Guild; + _equipItems = content.EquipItems; + } + [CommandProperty(AccessLevel.GameMaster)] public virtual bool InstancedCorpse => Core.SE && Core.Now < TimeOfDeath + InstancedCorpseTime; diff --git a/Projects/UOContent/Items/Misc/Corpses/DecayedCorpse.cs b/Projects/UOContent/Items/Misc/Corpses/DecayedCorpse.cs index 17267a6cc..ead812e2f 100644 --- a/Projects/UOContent/Items/Misc/Corpses/DecayedCorpse.cs +++ b/Projects/UOContent/Items/Misc/Corpses/DecayedCorpse.cs @@ -3,25 +3,18 @@ using ModernUO.Serialization; namespace Server.Items; -[SerializationGenerator(3, false)] +[SerializationGenerator(2, false)] public partial class DecayedCorpse : Container { private static TimeSpan _defaultDecayTime = TimeSpan.FromMinutes(7.0); + [TimerDrift] [SerializableField(0, getter: "private", setter: "private")] - [DeserializeTimer(nameof(DeserializeDecayTimer))] private Timer _decayTimer; + [DeserializeTimerField(0)] private void DeserializeDecayTimer(TimeSpan delay) => BeginDecay(delay); - private void MigrateFrom(V2Content content) - { - if (content.DecayTimerDelay != TimeSpan.MinValue) - { - DeserializeDecayTimer(content.DecayTimerDelay); - } - } - public DecayedCorpse(string name) : base(Utility.Random(0xECA, 9)) { Movable = false; diff --git a/Projects/UOContent/Items/Misc/MorphItem.cs b/Projects/UOContent/Items/Misc/MorphItem.cs index 7e5a74d8b..c5dd9c0de 100644 --- a/Projects/UOContent/Items/Misc/MorphItem.cs +++ b/Projects/UOContent/Items/Misc/MorphItem.cs @@ -30,24 +30,20 @@ public partial class MorphItem : Item _outsideRange = outRange; } - [SerializableField(0, allowFieldChange: nameof(AllowOutsideRangeChange))] - [SerializedCommandProperty(AccessLevel.GameMaster)] - private int _outsideRange; - - private bool AllowOutsideRangeChange(ref int value) + [SerializableProperty(0)] + [CommandProperty(AccessLevel.GameMaster)] + public int OutsideRange { - value = Math.Clamp(value, 0, 18); - return true; + get => _outsideRange; + set => _outsideRange = Math.Clamp(value, 0, 18); } - [SerializableField(3, allowFieldChange: nameof(AllowInsideRangeChange))] - [SerializedCommandProperty(AccessLevel.GameMaster)] - private int _insideRange; - - private bool AllowInsideRangeChange(ref int value) + [SerializableProperty(3)] + [CommandProperty(AccessLevel.GameMaster)] + public int InsideRange { - value = Math.Clamp(value, 0, 18); - return true; + get => _insideRange; + set => _insideRange = Math.Clamp(value, 0, 18); } [CommandProperty(AccessLevel.GameMaster)] diff --git a/Projects/UOContent/Items/Misc/ProjectedItem.cs b/Projects/UOContent/Items/Misc/ProjectedItem.cs index 03e93d0ea..e86025613 100644 --- a/Projects/UOContent/Items/Misc/ProjectedItem.cs +++ b/Projects/UOContent/Items/Misc/ProjectedItem.cs @@ -126,14 +126,20 @@ public partial class ProjectedItem : Item private static void OnTick() { + using var queue = PooledRefQueue.Create(); foreach (var item in _active) { if (!item.SendEffect()) { - _active.Remove(item); + queue.Enqueue(item); } } + while (queue.Count > 0) + { + _active.Remove(queue.Dequeue() as ProjectedItem); + } + if (_active.Count == 0) { _timer?.Stop(); diff --git a/Projects/UOContent/Items/Misc/WarningItem.cs b/Projects/UOContent/Items/Misc/WarningItem.cs index 3779e4900..5d0b491a4 100644 --- a/Projects/UOContent/Items/Misc/WarningItem.cs +++ b/Projects/UOContent/Items/Misc/WarningItem.cs @@ -14,16 +14,8 @@ public partial class WarningItem : Item private TextDefinition _warningMessage; // Field 1 - [SerializableField(1, allowFieldChange: nameof(AllowRangeChange))] - [SerializedCommandProperty(AccessLevel.GameMaster)] private int _range; - private bool AllowRangeChange(ref int value) - { - value = Math.Min(value, 18); - return true; - } - [SerializableField(2)] private TimeSpan _resetDelay; @@ -47,6 +39,18 @@ public partial class WarningItem : Item _range = Math.Min(range, 18); } + [CommandProperty(AccessLevel.GameMaster)] + [SerializableProperty(1, useField: nameof(_range))] + public int Range + { + get => _range; + set + { + _range = Math.Min(value, 18); + this.MarkDirty(); + } + } + public virtual bool OnlyToTriggerer => false; public virtual int NeighborRange => 5; diff --git a/Projects/UOContent/Items/Quivers/BaseQuiver.cs b/Projects/UOContent/Items/Quivers/BaseQuiver.cs index 70824c9f6..427871f16 100644 --- a/Projects/UOContent/Items/Quivers/BaseQuiver.cs +++ b/Projects/UOContent/Items/Quivers/BaseQuiver.cs @@ -11,62 +11,64 @@ public partial class BaseQuiver : Container, ICraftable, IAosItem [SerializedIgnoreDupe] [SerializableField(0, setter: "private")] - [SaveFlag(nameof(ShouldSerializeAosAttributes), nameof(AttributesDefaultValue))] [SerializedCommandProperty(AccessLevel.GameMaster, canModify: true)] private AosAttributes _attributes; + [SerializableFieldSaveFlag(0)] private bool ShouldSerializeAosAttributes() => !_attributes.IsEmpty; + [SerializableFieldDefault(0)] private AosAttributes AttributesDefaultValue() => new(this); [InvalidateProperties] [SerializableField(1)] - [SaveFlag(nameof(ShouldSerializeLowerAmmoCost))] [SerializedCommandProperty(AccessLevel.GameMaster)] private int _lowerAmmoCost; + [SerializableFieldSaveFlag(1)] private bool ShouldSerializeLowerAmmoCost() => _lowerAmmoCost != 0; [InvalidateProperties] [SerializableField(2)] - [SaveFlag(nameof(ShouldSerializeWeightReduction))] [SerializedCommandProperty(AccessLevel.GameMaster)] private int _weightReduction; + [SerializableFieldSaveFlag(2)] private bool ShouldSerializeWeightReduction() => _weightReduction != 0; [InvalidateProperties] [SerializableField(3)] - [SaveFlag(nameof(ShouldSerializeDamageIncrease))] [SerializedCommandProperty(AccessLevel.GameMaster)] private int _damageIncrease; + [SerializableFieldSaveFlag(3)] private bool ShouldSerializeDamageIncrease() => _damageIncrease != 0; [InvalidateProperties] [SerializableField(4)] - [SaveFlag(nameof(ShouldSerializeCrafter))] [SerializedCommandProperty(AccessLevel.GameMaster)] private string _crafter; + [SerializableFieldSaveFlag(4)] private bool ShouldSerializeCrafter() => !string.IsNullOrEmpty(_crafter); [InvalidateProperties] [SerializableField(5)] - [SaveFlag(nameof(ShouldSerializeQuality), nameof(QualityDefaultValue))] [SerializedCommandProperty(AccessLevel.GameMaster)] private ClothingQuality _quality; + [SerializableFieldSaveFlag(5)] private bool ShouldSerializeQuality() => _quality != ClothingQuality.Regular; + [SerializableFieldDefault(5)] private ClothingQuality QualityDefaultValue() => ClothingQuality.Regular; [InvalidateProperties] [SerializableField(6)] - [SaveFlag(nameof(ShouldSerializeCapacity))] [SerializedCommandProperty(AccessLevel.GameMaster)] private int _capacity; + [SerializableFieldSaveFlag(6)] private bool ShouldSerializeCapacity() => _capacity != 0; public BaseQuiver(int itemID = 0x2FB7) : base(itemID) diff --git a/Projects/UOContent/Items/Resources/Blacksmithing/Ingots.cs b/Projects/UOContent/Items/Resources/Blacksmithing/Ingots.cs index 8170b286d..ce772da40 100644 --- a/Projects/UOContent/Items/Resources/Blacksmithing/Ingots.cs +++ b/Projects/UOContent/Items/Resources/Blacksmithing/Ingots.cs @@ -16,14 +16,22 @@ public abstract partial class BaseIngot : Item, ICommodity public override double DefaultWeight => 0.1; - [SerializableField(0, fieldChanged: nameof(OnResourceChanged))] - [SerializedCommandProperty(AccessLevel.GameMaster)] - [InvalidateProperties] - private CraftResource _resource; - - private void OnResourceChanged(CraftResource oldValue, CraftResource newValue) + [SerializableProperty(0)] + [CommandProperty(AccessLevel.GameMaster)] + public CraftResource Resource { - Hue = CraftResources.GetHue(newValue); + get => _resource; + set + { + if (_resource != value) + { + _resource = value; + Hue = CraftResources.GetHue(value); + + InvalidateProperties(); + this.MarkDirty(); + } + } } public override int LabelNumber diff --git a/Projects/UOContent/Items/Resources/Blacksmithing/Ore.cs b/Projects/UOContent/Items/Resources/Blacksmithing/Ore.cs index 6ae0c71c3..3116d7136 100644 --- a/Projects/UOContent/Items/Resources/Blacksmithing/Ore.cs +++ b/Projects/UOContent/Items/Resources/Blacksmithing/Ore.cs @@ -17,14 +17,22 @@ public abstract partial class BaseOre : Item _resource = resource; } - [SerializableField(0, fieldChanged: nameof(OnResourceChanged))] - [SerializedCommandProperty(AccessLevel.GameMaster)] - [InvalidateProperties] - private CraftResource _resource; - - private void OnResourceChanged(CraftResource oldValue, CraftResource newValue) + [SerializableProperty(0)] + [CommandProperty(AccessLevel.GameMaster)] + public CraftResource Resource { - Hue = CraftResources.GetHue(newValue); + get => _resource; + set + { + if (_resource != value) + { + _resource = value; + Hue = CraftResources.GetHue(value); + + InvalidateProperties(); + this.MarkDirty(); + } + } } public override int LabelNumber diff --git a/Projects/UOContent/Items/Resources/Blacksmithing/Scales.cs b/Projects/UOContent/Items/Resources/Blacksmithing/Scales.cs index 277ed569d..fa63d902c 100644 --- a/Projects/UOContent/Items/Resources/Blacksmithing/Scales.cs +++ b/Projects/UOContent/Items/Resources/Blacksmithing/Scales.cs @@ -14,14 +14,22 @@ public abstract partial class BaseScales : Item, ICommodity _resource = resource; } - [SerializableField(0, fieldChanged: nameof(OnResourceChanged))] - [SerializedCommandProperty(AccessLevel.GameMaster)] - [InvalidateProperties] - private CraftResource _resource; - - private void OnResourceChanged(CraftResource oldValue, CraftResource newValue) + [SerializableProperty(0)] + [CommandProperty(AccessLevel.GameMaster)] + public CraftResource Resource { - Hue = CraftResources.GetHue(newValue); + get => _resource; + set + { + if (_resource != value) + { + _resource = value; + Hue = CraftResources.GetHue(value); + + InvalidateProperties(); + this.MarkDirty(); + } + } } public override int LabelNumber => 1053139; // dragon scales diff --git a/Projects/UOContent/Items/Resources/Masonry/Granite.cs b/Projects/UOContent/Items/Resources/Masonry/Granite.cs index 089181ebe..ae4f215d7 100644 --- a/Projects/UOContent/Items/Resources/Masonry/Granite.cs +++ b/Projects/UOContent/Items/Resources/Masonry/Granite.cs @@ -15,14 +15,22 @@ public abstract partial class BaseGranite : Item public override double DefaultWeight => Core.ML ? 1.0 : 10.0; - [SerializableField(0, fieldChanged: nameof(OnResourceChanged))] - [SerializedCommandProperty(AccessLevel.GameMaster)] - [InvalidateProperties] - private CraftResource _resource; - - private void OnResourceChanged(CraftResource oldValue, CraftResource newValue) + [SerializableProperty(0)] + [CommandProperty(AccessLevel.GameMaster)] + public CraftResource Resource { - Hue = CraftResources.GetHue(newValue); + get => _resource; + set + { + if (_resource != value) + { + _resource = value; + Hue = CraftResources.GetHue(value); + + InvalidateProperties(); + this.MarkDirty(); + } + } } public override int LabelNumber => 1044607; // high quality granite diff --git a/Projects/UOContent/Items/Resources/Tailor/Hides.cs b/Projects/UOContent/Items/Resources/Tailor/Hides.cs index d64eb2f0b..df6423fb2 100644 --- a/Projects/UOContent/Items/Resources/Tailor/Hides.cs +++ b/Projects/UOContent/Items/Resources/Tailor/Hides.cs @@ -15,14 +15,22 @@ public abstract partial class BaseHides : Item, ICommodity public override double DefaultWeight => 5.0; - [SerializableField(0, fieldChanged: nameof(OnResourceChanged))] - [SerializedCommandProperty(AccessLevel.GameMaster)] - [InvalidateProperties] - private CraftResource _resource; - - private void OnResourceChanged(CraftResource oldValue, CraftResource newValue) + [SerializableProperty(0)] + [CommandProperty(AccessLevel.GameMaster)] + public CraftResource Resource { - Hue = CraftResources.GetHue(newValue); + get => _resource; + set + { + if (_resource != value) + { + _resource = value; + Hue = CraftResources.GetHue(value); + + InvalidateProperties(); + this.MarkDirty(); + } + } } public override int LabelNumber diff --git a/Projects/UOContent/Items/Resources/Tailor/Leathers.cs b/Projects/UOContent/Items/Resources/Tailor/Leathers.cs index afb757e1f..173e2dae0 100644 --- a/Projects/UOContent/Items/Resources/Tailor/Leathers.cs +++ b/Projects/UOContent/Items/Resources/Tailor/Leathers.cs @@ -15,14 +15,22 @@ public abstract partial class BaseLeather : Item, ICommodity public override double DefaultWeight => 1.0; - [SerializableField(0, fieldChanged: nameof(OnResourceChanged))] - [SerializedCommandProperty(AccessLevel.GameMaster)] - [InvalidateProperties] - private CraftResource _resource; - - private void OnResourceChanged(CraftResource oldValue, CraftResource newValue) + [SerializableProperty(0)] + [CommandProperty(AccessLevel.GameMaster)] + public CraftResource Resource { - Hue = CraftResources.GetHue(newValue); + get => _resource; + set + { + if (_resource != value) + { + _resource = value; + Hue = CraftResources.GetHue(value); + + InvalidateProperties(); + this.MarkDirty(); + } + } } public override int LabelNumber diff --git a/Projects/UOContent/Items/Skill Items/Carpenter Items/Board.cs b/Projects/UOContent/Items/Skill Items/Carpenter Items/Board.cs index 53c8077f1..9ff0d8386 100644 --- a/Projects/UOContent/Items/Skill Items/Carpenter Items/Board.cs +++ b/Projects/UOContent/Items/Skill Items/Carpenter Items/Board.cs @@ -21,14 +21,22 @@ public partial class Board : Item, ICommodity Hue = CraftResources.GetHue(resource); } - [SerializableField(0, fieldChanged: nameof(OnResourceChanged))] - [SerializedCommandProperty(AccessLevel.GameMaster)] - [InvalidateProperties] - private CraftResource _resource; - - private void OnResourceChanged(CraftResource oldValue, CraftResource newValue) + [SerializableProperty(0)] + [CommandProperty(AccessLevel.GameMaster)] + public CraftResource Resource { - Hue = CraftResources.GetHue(newValue); + get => _resource; + set + { + if (_resource != value) + { + _resource = value; + Hue = CraftResources.GetHue(value); + + InvalidateProperties(); + this.MarkDirty(); + } + } } int ICommodity.DescriptionNumber diff --git a/Projects/UOContent/Items/Skill Items/Fishing/Misc/MessageInABottle.cs b/Projects/UOContent/Items/Skill Items/Fishing/Misc/MessageInABottle.cs index bf626c2fd..5d6062099 100644 --- a/Projects/UOContent/Items/Skill Items/Fishing/Misc/MessageInABottle.cs +++ b/Projects/UOContent/Items/Skill Items/Fishing/Misc/MessageInABottle.cs @@ -25,14 +25,16 @@ public partial class MessageInABottle : Item public override int LabelNumber => 1041080; // a message in a bottle - [SerializableField(0, allowFieldChange: nameof(AllowLevelChange))] - [SerializedCommandProperty(AccessLevel.GameMaster)] - private int _level; - - private bool AllowLevelChange(ref int value) + [SerializableProperty(0)] + [CommandProperty(AccessLevel.GameMaster)] + public int Level { - value = Math.Max(1, Math.Min(value, 4)); - return true; + get => _level; + set + { + _level = Math.Max(1, Math.Min(value, 4)); + this.MarkDirty(); + } } public static int GetRandomLevel() diff --git a/Projects/UOContent/Items/Skill Items/Fishing/Misc/SOS.cs b/Projects/UOContent/Items/Skill Items/Fishing/Misc/SOS.cs index 5374f8716..f8450e63f 100644 --- a/Projects/UOContent/Items/Skill Items/Fishing/Misc/SOS.cs +++ b/Projects/UOContent/Items/Skill Items/Fishing/Misc/SOS.cs @@ -103,20 +103,18 @@ public partial class SOS : Item [CommandProperty(AccessLevel.GameMaster)] public bool IsAncient => _level >= 4; - [SerializableField(0, fieldChanged: nameof(OnLevelChanged), allowFieldChange: nameof(AllowLevelChange))] - [SerializedCommandProperty(AccessLevel.GameMaster)] - [InvalidateProperties] - private int _level; - - private bool AllowLevelChange(ref int value) + [SerializableProperty(0)] + [CommandProperty(AccessLevel.GameMaster)] + public int Level { - value = Math.Max(1, Math.Min(value, 4)); - return true; - } - - private void OnLevelChanged(int oldValue, int newValue) - { - UpdateHue(); + get => _level; + set + { + _level = Math.Max(1, Math.Min(value, 4)); + UpdateHue(); + InvalidateProperties(); + this.MarkDirty(); + } } public void UpdateHue() => Hue = IsAncient ? 0x481 : 0; diff --git a/Projects/UOContent/Items/Skill Items/Lumberjack/Log.cs b/Projects/UOContent/Items/Skill Items/Lumberjack/Log.cs index 2a06f535a..2873957d5 100644 --- a/Projects/UOContent/Items/Skill Items/Lumberjack/Log.cs +++ b/Projects/UOContent/Items/Skill Items/Lumberjack/Log.cs @@ -28,14 +28,22 @@ public partial class Log : Item, ICommodity, IAxe public override double DefaultWeight => 2.0; - [SerializableField(0, fieldChanged: nameof(OnResourceChanged))] - [SerializedCommandProperty(AccessLevel.GameMaster)] - [InvalidateProperties] - private CraftResource _resource; - - private void OnResourceChanged(CraftResource oldValue, CraftResource newValue) + [SerializableProperty(0)] + [CommandProperty(AccessLevel.GameMaster)] + public CraftResource Resource { - Hue = CraftResources.GetHue(newValue); + get => _resource; + set + { + if (_resource != value) + { + _resource = value; + Hue = CraftResources.GetHue(value); + + InvalidateProperties(); + this.MarkDirty(); + } + } } public virtual bool Axe(Mobile from, BaseAxe axe) => TryCreateBoards(from, 0, new Board()); diff --git a/Projects/UOContent/Items/Skill Items/Magical/Misc/RecallRune.cs b/Projects/UOContent/Items/Skill Items/Magical/Misc/RecallRune.cs index 14db7ac1e..68b6f324a 100644 --- a/Projects/UOContent/Items/Skill Items/Magical/Misc/RecallRune.cs +++ b/Projects/UOContent/Items/Skill Items/Magical/Misc/RecallRune.cs @@ -47,24 +47,36 @@ public partial class RecallRune : Item } } - [SerializableField(2, fieldChanged: nameof(OnMarkedChanged))] - [SerializedCommandProperty(AccessLevel.Counselor, AccessLevel.GameMaster)] - [InvalidateProperties] - private bool _marked; - - private void OnMarkedChanged(bool oldValue, bool newValue) + [SerializableProperty(2)] + [CommandProperty(AccessLevel.Counselor, AccessLevel.GameMaster)] + public bool Marked { - CalculateHue(); + get => _marked; + set + { + if (_marked != value) + { + _marked = value; + CalculateHue(); + InvalidateProperties(); + } + } } - [SerializableField(4, fieldChanged: nameof(OnTargetMapChanged))] - [SerializedCommandProperty(AccessLevel.Counselor, AccessLevel.GameMaster)] - [InvalidateProperties] - private Map _targetMap; - - private void OnTargetMapChanged(Map oldValue, Map newValue) + [SerializableProperty(4)] + [CommandProperty(AccessLevel.Counselor, AccessLevel.GameMaster)] + public Map TargetMap { - CalculateHue(); + get => _targetMap; + set + { + if (_targetMap != value) + { + _targetMap = value; + CalculateHue(); + InvalidateProperties(); + } + } } private void Deserialize(IGenericReader reader, int version) diff --git a/Projects/UOContent/Items/Skill Items/Magical/Runebook.cs b/Projects/UOContent/Items/Skill Items/Magical/Runebook.cs index f9ecdfa22..0a5a0f308 100644 --- a/Projects/UOContent/Items/Skill Items/Magical/Runebook.cs +++ b/Projects/UOContent/Items/Skill Items/Magical/Runebook.cs @@ -371,27 +371,27 @@ public partial class RunebookEntry private Runebook _runebook; [SerializableField(0)] - [SaveFlag(nameof(ShouldSerializeHouse))] private BaseHouse _house; + [SerializableFieldSaveFlag(0)] public bool ShouldSerializeHouse() => _house?.Deleted == false; [SerializableField(1)] - [SaveFlag(nameof(ShouldSerializeLocation))] private Point3D _location; + [SerializableFieldSaveFlag(1)] public bool ShouldSerializeLocation() => _house?.Deleted != false; [SerializableField(2)] - [SaveFlag(nameof(ShouldSerializeMap))] private Map _map; + [SerializableFieldSaveFlag(2)] public bool ShouldSerializeMap() => _house?.Deleted != false; [SerializableField(3)] - [SaveFlag(nameof(ShouldSerializeDesc))] private string _description; + [SerializableFieldSaveFlag(3)] public bool ShouldSerializeDesc() => _house?.Deleted != false; public RunebookEntry( diff --git a/Projects/UOContent/Items/Skill Items/Magical/Spellbook.cs b/Projects/UOContent/Items/Skill Items/Magical/Spellbook.cs index 9447fddfa..f1ced7308 100644 --- a/Projects/UOContent/Items/Skill Items/Magical/Spellbook.cs +++ b/Projects/UOContent/Items/Skill Items/Magical/Spellbook.cs @@ -133,19 +133,28 @@ public partial class Spellbook : Item, ICraftable, ISlayer, IAosItem public virtual int BookOffset => 0; public virtual int BookCount => 64; - [SerializableField(7, fieldChanged: nameof(OnContentChanged))] - [SerializedCommandProperty(AccessLevel.GameMaster)] - [InvalidateProperties] - private ulong _content; - - private void OnContentChanged(ulong oldValue, ulong newValue) + [CommandProperty(AccessLevel.GameMaster)] + [SerializableProperty(7)] + public ulong Content { - // This assignment will mark it as dirty - SpellCount = 0; - while (newValue > 0) + get => _content; + set { - _spellCount += (int)(newValue & 0x1); - newValue >>= 1; + if (_content != value) + { + _content = value; + + // This assignment will mark it as dirty + SpellCount = 0; + + while (value > 0) + { + _spellCount += (int)(value & 0x1); + value >>= 1; + } + + InvalidateProperties(); + } } } diff --git a/Projects/UOContent/Items/Skill Items/Misc/RepairDeed.cs b/Projects/UOContent/Items/Skill Items/Misc/RepairDeed.cs index e4d486d10..dbb8df68c 100644 --- a/Projects/UOContent/Items/Skill Items/Misc/RepairDeed.cs +++ b/Projects/UOContent/Items/Skill Items/Misc/RepairDeed.cs @@ -62,15 +62,17 @@ public partial class RepairDeed : Item public override bool DisplayLootType => false; - [SerializableField(1, allowFieldChange: nameof(AllowSkillLevelChange))] - [SerializedCommandProperty(AccessLevel.GameMaster)] - [InvalidateProperties] - private double _skillLevel; - - private bool AllowSkillLevelChange(ref double value) + [CommandProperty(AccessLevel.GameMaster)] + [SerializableProperty(1)] + public double SkillLevel { - value = Math.Clamp(value, 0, 120.0); - return true; + get => _skillLevel; + set + { + _skillLevel = Math.Clamp(value, 0, 120.0); + InvalidateProperties(); + this.MarkDirty(); + } } public override void AddNameProperty(IPropertyList list) diff --git a/Projects/UOContent/Items/Skill Items/Musical Instruments/BaseInstrument.cs b/Projects/UOContent/Items/Skill Items/Musical Instruments/BaseInstrument.cs index f7716a4a1..b01416211 100644 --- a/Projects/UOContent/Items/Skill Items/Musical Instruments/BaseInstrument.cs +++ b/Projects/UOContent/Items/Skill Items/Musical Instruments/BaseInstrument.cs @@ -74,13 +74,16 @@ public abstract partial class BaseInstrument : Item, ICraftable, ISlayer } } - [SerializableField(1, fieldChanged: nameof(OnLastReplenishedChanged))] - [SerializedCommandProperty(AccessLevel.GameMaster)] - private DateTime _lastReplenished; - - private void OnLastReplenishedChanged(DateTime oldValue, DateTime newValue) + [SerializableProperty(1)] + [CommandProperty(AccessLevel.GameMaster)] + public DateTime LastReplenished { - CheckReplenishUses(); + get => _lastReplenished; + set + { + _lastReplenished = value; + CheckReplenishUses(); + } } [SerializableProperty(3)] diff --git a/Projects/UOContent/Items/Skill Items/Tailor Items/Dyetubs/DyeTub.cs b/Projects/UOContent/Items/Skill Items/Tailor Items/Dyetubs/DyeTub.cs index e35cca471..6b557b7be 100644 --- a/Projects/UOContent/Items/Skill Items/Tailor Items/Dyetubs/DyeTub.cs +++ b/Projects/UOContent/Items/Skill Items/Tailor Items/Dyetubs/DyeTub.cs @@ -41,13 +41,20 @@ namespace Server.Items public virtual bool AllowDyables => true; - [SerializableField(2, allowFieldChange: nameof(AllowDyedHueChange), fieldChanged: nameof(OnDyedHueChanged))] - [SerializedCommandProperty(AccessLevel.GameMaster)] - private int _dyedHue; - - private bool AllowDyedHueChange(ref int value) => _redyable; - - private void OnDyedHueChanged(int oldValue, int newValue) => Hue = newValue; + [SerializableProperty(2)] + [CommandProperty(AccessLevel.GameMaster)] + public int DyedHue + { + get => _dyedHue; + set + { + if (_redyable) + { + _dyedHue = value; + Hue = value; + } + } + } // Three metallic tubs now. public virtual bool MetallicHues => false; diff --git a/Projects/UOContent/Items/Skill Items/Tools/BaseRunicTool.cs b/Projects/UOContent/Items/Skill Items/Tools/BaseRunicTool.cs index 595b6b11d..6010ec376 100644 --- a/Projects/UOContent/Items/Skill Items/Tools/BaseRunicTool.cs +++ b/Projects/UOContent/Items/Skill Items/Tools/BaseRunicTool.cs @@ -60,14 +60,17 @@ public abstract partial class BaseRunicTool : BaseTool public BaseRunicTool(CraftResource resource, int uses, int itemID) : base(uses, itemID) => _resource = resource; - [SerializableField(0, fieldChanged: nameof(OnResourceChanged))] - [SerializedCommandProperty(AccessLevel.GameMaster)] - [InvalidateProperties] - private CraftResource _resource; - - private void OnResourceChanged(CraftResource oldValue, CraftResource newValue) + [SerializableProperty(0)] + [CommandProperty(AccessLevel.GameMaster)] + public CraftResource Resource { - Hue = CraftResources.GetHue(_resource); + get => _resource; + set + { + _resource = value; + Hue = CraftResources.GetHue(_resource); + InvalidateProperties(); + } } private void Deserialize(IGenericReader reader, int version) diff --git a/Projects/UOContent/Items/Special/8th Anniversary Items/FountainOfLife.cs b/Projects/UOContent/Items/Special/8th Anniversary Items/FountainOfLife.cs index 404fc9012..3bdfd00fa 100644 --- a/Projects/UOContent/Items/Special/8th Anniversary Items/FountainOfLife.cs +++ b/Projects/UOContent/Items/Special/8th Anniversary Items/FountainOfLife.cs @@ -31,7 +31,6 @@ public partial class FountainOfLife : BaseAddonContainer public const int MaxCharges = 10; [SerializableField(1)] - [DeserializeTimer(nameof(DeserializeTimer), wallClock: true)] private Timer _timer; [Constructible] @@ -40,6 +39,7 @@ public partial class FountainOfLife : BaseAddonContainer _charges = charges; } + [DeserializeTimerField(1)] private void DeserializeTimer(TimeSpan delay) { _timer = Timer.DelayCall(Utility.Max(delay, TimeSpan.Zero), RechargeTime, Recharge); @@ -54,15 +54,17 @@ public partial class FountainOfLife : BaseAddonContainer public override int DefaultDropSound => 66; public override int DefaultMaxItems => 125; - [SerializableField(0, allowFieldChange: nameof(AllowChargesChange))] - [SerializedCommandProperty(AccessLevel.GameMaster)] - [InvalidateProperties] - private int _charges; - - private bool AllowChargesChange(ref int value) + [SerializableProperty(0)] + [CommandProperty(AccessLevel.GameMaster)] + public int Charges { - value = Math.Min(value, MaxCharges); - return true; + get => _charges; + set + { + _charges = Math.Min(value, MaxCharges); + InvalidateProperties(); + this.MarkDirty(); + } } public override bool OnDragLift(Mobile from) => false; @@ -181,14 +183,16 @@ public partial class FountainOfLifeDeed : BaseAddonContainerDeed public override int LabelNumber => 1075197; // Fountain of Life public override BaseAddonContainer Addon => new FountainOfLife(_charges); - [SerializableField(0, allowFieldChange: nameof(AllowChargesChange))] - [SerializedCommandProperty(AccessLevel.GameMaster)] - [InvalidateProperties] - private int _charges; - - private bool AllowChargesChange(ref int value) + [SerializableProperty(0)] + [CommandProperty(AccessLevel.GameMaster)] + public int Charges { - value = Math.Min(value, FountainOfLife.MaxCharges); - return true; + get => _charges; + set + { + _charges = Math.Min(value, FountainOfLife.MaxCharges); + InvalidateProperties(); + this.MarkDirty(); + } } } diff --git a/Projects/UOContent/Items/Special/Heritage Items/FruitTrees.cs b/Projects/UOContent/Items/Special/Heritage Items/FruitTrees.cs index fabb756d8..6a7e5af6f 100644 --- a/Projects/UOContent/Items/Special/Heritage Items/FruitTrees.cs +++ b/Projects/UOContent/Items/Special/Heritage Items/FruitTrees.cs @@ -14,14 +14,12 @@ public abstract partial class BaseFruitTreeAddon : BaseAddon public abstract override BaseAddonDeed Deed { get; } public abstract Item Fruit { get; } - [SerializableField(0, allowFieldChange: nameof(AllowFruitsChange))] - [SerializedCommandProperty(AccessLevel.GameMaster)] - private int _fruits; - - private bool AllowFruitsChange(ref int value) + [SerializableProperty(0)] + [CommandProperty(AccessLevel.GameMaster)] + public int Fruits { - value = Math.Max(value, 0); - return true; + get => _fruits; + set => _fruits = Math.Max(value, 0); } public override void OnComponentUsed(AddonComponent c, Mobile from) diff --git a/Projects/UOContent/Items/Special/House Raffle/HouseRaffleStone.cs b/Projects/UOContent/Items/Special/House Raffle/HouseRaffleStone.cs index 59e2b8277..c24985f9f 100644 --- a/Projects/UOContent/Items/Special/House Raffle/HouseRaffleStone.cs +++ b/Projects/UOContent/Items/Special/House Raffle/HouseRaffleStone.cs @@ -146,24 +146,34 @@ public partial class HouseRaffleStone : Item } } - [SerializableField(3, fieldChanged: nameof(OnPlotBoundsChanged))] - [SerializedCommandProperty(AccessLevel.GameMaster, AccessLevel.Seer)] - [InvalidateProperties] - private Rectangle2D _plotBounds; - - private void OnPlotBoundsChanged(Rectangle2D oldValue, Rectangle2D newValue) + [SerializableProperty(3)] + [CommandProperty(AccessLevel.GameMaster, AccessLevel.Seer)] + public Rectangle2D PlotBounds { - InvalidateRegion(); + get => _plotBounds; + set + { + _plotBounds = value; + + InvalidateRegion(); + InvalidateProperties(); + this.MarkDirty(); + } } - [SerializableField(4, fieldChanged: nameof(OnPlotFacetChanged))] - [SerializedCommandProperty(AccessLevel.GameMaster, AccessLevel.Seer)] - [InvalidateProperties] - private Map _plotFacet; - - private void OnPlotFacetChanged(Map oldValue, Map newValue) + [SerializableProperty(4)] + [CommandProperty(AccessLevel.GameMaster, AccessLevel.Seer)] + public Map PlotFacet { - InvalidateRegion(); + get => _plotFacet; + set + { + _plotFacet = value; + + InvalidateRegion(); + InvalidateProperties(); + this.MarkDirty(); + } } [CommandProperty(AccessLevel.GameMaster)] @@ -180,15 +190,17 @@ public partial class HouseRaffleStone : Item } } - [SerializableField(6, allowFieldChange: nameof(AllowTicketPriceChange))] - [SerializedCommandProperty(AccessLevel.GameMaster, AccessLevel.Seer)] - [InvalidateProperties] - private int _ticketPrice; - - private bool AllowTicketPriceChange(ref int value) + [SerializableProperty(6)] + [CommandProperty(AccessLevel.GameMaster, AccessLevel.Seer)] + public int TicketPrice { - value = Math.Max(0, value); - return true; + get => _ticketPrice; + set + { + _ticketPrice = Math.Max(0, value); + InvalidateProperties(); + this.MarkDirty(); + } } public override string DefaultName => "a house raffle stone"; diff --git a/Projects/UOContent/Items/Special/MonsterStatuette.cs b/Projects/UOContent/Items/Special/MonsterStatuette.cs index fd26a0060..8d49dd0e4 100644 --- a/Projects/UOContent/Items/Special/MonsterStatuette.cs +++ b/Projects/UOContent/Items/Special/MonsterStatuette.cs @@ -162,15 +162,21 @@ public partial class MonsterStatuette : Item, IRewardItem, IGumpToggleItem _ => fallback }; - [SerializableField(0, fieldChanged: nameof(OnTypeChanged))] - [SerializedCommandProperty(AccessLevel.GameMaster)] - [InvalidateProperties] - private MonsterStatuetteType _type; - - private void OnTypeChanged(MonsterStatuetteType oldValue, MonsterStatuetteType newValue) + [SerializableProperty(0)] + [CommandProperty(AccessLevel.GameMaster)] + public MonsterStatuetteType Type { - ItemID = MonsterStatuetteInfo.GetInfo(_type).ItemID; - Hue = GetStatuetteHue(_type, Hue); + get => _type; + set + { + _type = value; + ItemID = MonsterStatuetteInfo.GetInfo(_type).ItemID; + + Hue = GetStatuetteHue(_type, Hue); + + InvalidateProperties(); + this.MarkDirty(); + } } public override int LabelNumber => MonsterStatuetteInfo.GetInfo(_type).LabelNumber; diff --git a/Projects/UOContent/Items/Special/Solen Items/BagOfSending.cs b/Projects/UOContent/Items/Special/Solen Items/BagOfSending.cs index 9441fd5c7..f2772b342 100644 --- a/Projects/UOContent/Items/Special/Solen Items/BagOfSending.cs +++ b/Projects/UOContent/Items/Special/Solen Items/BagOfSending.cs @@ -36,41 +36,50 @@ public partial class BagOfSending : Item, TranslocationItem public override int LabelNumber => 1054104; // a bag of sending - [SerializableField(0, fieldChanged: nameof(OnBagOfSendingHueChanged))] - [SerializedCommandProperty(AccessLevel.GameMaster)] - private BagOfSendingHue _bagOfSendingHue; - - private void OnBagOfSendingHueChanged(BagOfSendingHue oldValue, BagOfSendingHue newValue) + [SerializableProperty(0)] + [CommandProperty(AccessLevel.GameMaster)] + public BagOfSendingHue BagOfSendingHue { - Hue = newValue switch + get => _bagOfSendingHue; + set { - BagOfSendingHue.Yellow => 0x8A5, - BagOfSendingHue.Blue => 0x8AD, - BagOfSendingHue.Red => 0x89B, - _ => Hue - }; + _bagOfSendingHue = value; + + Hue = value switch + { + BagOfSendingHue.Yellow => 0x8A5, + BagOfSendingHue.Blue => 0x8AD, + BagOfSendingHue.Red => 0x89B, + _ => Hue + }; + this.MarkDirty(); + } } - [SerializableField(1, allowFieldChange: nameof(AllowChargesChange))] - [SerializedCommandProperty(AccessLevel.GameMaster)] - [InvalidateProperties] - private int _charges; - - private bool AllowChargesChange(ref int value) + [SerializableProperty(1)] + [CommandProperty(AccessLevel.GameMaster)] + public int Charges { - value = Math.Clamp(value, 0, MaxCharges); - return true; + get => _charges; + set + { + _charges = Math.Clamp(value, 0, MaxCharges); + InvalidateProperties(); + this.MarkDirty(); + } } - [SerializableField(2, allowFieldChange: nameof(AllowRechargesChange))] - [SerializedCommandProperty(AccessLevel.GameMaster)] - [InvalidateProperties] - private int _recharges; - - private bool AllowRechargesChange(ref int value) + [SerializableProperty(2)] + [CommandProperty(AccessLevel.GameMaster)] + public int Recharges { - value = Math.Clamp(value, 0, MaxRecharges); - return true; + get => _recharges; + set + { + _recharges = Math.Clamp(value, 0, MaxRecharges); + InvalidateProperties(); + this.MarkDirty(); + } } [CommandProperty(AccessLevel.GameMaster)] diff --git a/Projects/UOContent/Items/Special/Solen Items/BallOfSummoning.cs b/Projects/UOContent/Items/Special/Solen Items/BallOfSummoning.cs index 070a802b1..a76833e30 100644 --- a/Projects/UOContent/Items/Special/Solen Items/BallOfSummoning.cs +++ b/Projects/UOContent/Items/Special/Solen Items/BallOfSummoning.cs @@ -29,26 +29,30 @@ public partial class BallOfSummoning : Item, TranslocationItem public override double DefaultWeight => 10.0; - [SerializableField(0, allowFieldChange: nameof(AllowRechargesChange))] - [SerializedCommandProperty(AccessLevel.GameMaster)] - [InvalidateProperties] - private int _recharges; - - private bool AllowRechargesChange(ref int value) + [SerializableProperty(0)] + [CommandProperty(AccessLevel.GameMaster)] + public int Recharges { - value = Math.Clamp(value, 0, MaxRecharges); - return true; + get => _recharges; + set + { + _recharges = Math.Clamp(value, 0, MaxRecharges); + InvalidateProperties(); + this.MarkDirty(); + } } - [SerializableField(1, allowFieldChange: nameof(AllowChargesChange))] - [SerializedCommandProperty(AccessLevel.GameMaster)] - [InvalidateProperties] - private int _charges; - - private bool AllowChargesChange(ref int value) + [SerializableProperty(1)] + [CommandProperty(AccessLevel.GameMaster)] + public int Charges { - value = Math.Clamp(value, 0, MaxCharges); - return true; + get => _charges; + set + { + _charges = Math.Clamp(value, 0, MaxCharges); + InvalidateProperties(); + this.MarkDirty(); + } } [SerializableProperty(2)] diff --git a/Projects/UOContent/Items/Special/Solen Items/BraceletOfBinding.cs b/Projects/UOContent/Items/Special/Solen Items/BraceletOfBinding.cs index 1d508c4ac..2f82791c2 100644 --- a/Projects/UOContent/Items/Special/Solen Items/BraceletOfBinding.cs +++ b/Projects/UOContent/Items/Special/Solen Items/BraceletOfBinding.cs @@ -27,26 +27,30 @@ public partial class BraceletOfBinding : BaseBracelet, TranslocationItem public override double DefaultWeight => 1.0; - [SerializableField(0, allowFieldChange: nameof(AllowRechargesChange))] - [SerializedCommandProperty(AccessLevel.GameMaster)] - [InvalidateProperties] - private int _recharges; - - private bool AllowRechargesChange(ref int value) + [SerializableProperty(0)] + [CommandProperty(AccessLevel.GameMaster)] + public int Recharges { - value = Math.Clamp(value, 0, MaxRecharges); - return true; + get => _recharges; + set + { + _recharges = Math.Clamp(value, 0, MaxRecharges); + InvalidateProperties(); + this.MarkDirty(); + } } - [SerializableField(1, allowFieldChange: nameof(AllowChargesChange))] - [SerializedCommandProperty(AccessLevel.GameMaster)] - [InvalidateProperties] - private int _charges; - - private bool AllowChargesChange(ref int value) + [SerializableProperty(1)] + [CommandProperty(AccessLevel.GameMaster)] + public int Charges { - value = Math.Clamp(value, 0, MaxCharges); - return true; + get => _charges; + set + { + _charges = Math.Clamp(value, 0, MaxCharges); + InvalidateProperties(); + this.MarkDirty(); + } } [SerializableProperty(3)] diff --git a/Projects/UOContent/Items/Special/SoulStone.cs b/Projects/UOContent/Items/Special/SoulStone.cs index d26f6792c..bcad4a749 100644 --- a/Projects/UOContent/Items/Special/SoulStone.cs +++ b/Projects/UOContent/Items/Special/SoulStone.cs @@ -83,14 +83,20 @@ public partial class SoulStone : Item, ISecurable } } - [SerializableField(6, fieldChanged: nameof(OnSkillValueChanged))] - [SerializedCommandProperty(AccessLevel.GameMaster)] - [InvalidateProperties] - private double _skillValue; - - private void OnSkillValueChanged(double oldValue, double newValue) + [SerializableProperty(6)] + [CommandProperty(AccessLevel.GameMaster)] + public double SkillValue { - ItemID = IsEmpty ? _inactiveItemID : _activeItemID; + get => _skillValue; + set + { + _skillValue = value; + + ItemID = IsEmpty ? _inactiveItemID : _activeItemID; + + InvalidateProperties(); + this.MarkDirty(); + } } [CommandProperty(AccessLevel.GameMaster)] diff --git a/Projects/UOContent/Items/Suits/BaseSuit.cs b/Projects/UOContent/Items/Suits/BaseSuit.cs index dd1eb00f6..018c010a2 100644 --- a/Projects/UOContent/Items/Suits/BaseSuit.cs +++ b/Projects/UOContent/Items/Suits/BaseSuit.cs @@ -17,9 +17,20 @@ public abstract partial class BaseSuit : Item public override double DefaultWeight => 1.0; - [SerializableField(0, fieldChanged: nameof(OnAccessLevelChanged))] - [InvalidateProperties] - private AccessLevel _accessLevel; + [SerializableProperty(0)] + public AccessLevel AccessLevel + { + get => _accessLevel; + set + { + var oldAccessLevel = _accessLevel; + _accessLevel = value; + InvalidateProperties(); + this.MarkDirty(); + + OnAccessLevelChanged(oldAccessLevel, _accessLevel); + } + } public virtual void OnAccessLevelChanged(AccessLevel oldAccessLevel, AccessLevel accessLevel) { diff --git a/Projects/UOContent/Items/Talismans/BaseTalisman.cs b/Projects/UOContent/Items/Talismans/BaseTalisman.cs index b3f0c2ed4..632587523 100644 --- a/Projects/UOContent/Items/Talismans/BaseTalisman.cs +++ b/Projects/UOContent/Items/Talismans/BaseTalisman.cs @@ -128,131 +128,136 @@ public partial class BaseTalisman : Item, IAosItem [SerializedIgnoreDupe] [SerializableField(0, setter: "private")] - [SaveFlag(nameof(ShouldSerializeAttributes), nameof(AttributesDefaultValue))] [SerializedCommandProperty(AccessLevel.GameMaster, canModify: true)] private AosAttributes _attributes; + [SerializableFieldSaveFlag(0)] public bool ShouldSerializeAttributes() => !_attributes.IsEmpty; + [SerializableFieldDefault(0)] private AosAttributes AttributesDefaultValue() => new(this); [SerializedIgnoreDupe] [SerializableField(1, setter: "private")] - [SaveFlag(nameof(ShouldSerializeSkillBonuses), nameof(SkillBonusesDefaultValue))] [SerializedCommandProperty(AccessLevel.GameMaster, canModify: true)] private AosSkillBonuses _skillBonuses; + [SerializableFieldSaveFlag(1)] public bool ShouldSerializeSkillBonuses() => !_skillBonuses.IsEmpty; + [SerializableFieldDefault(1)] private AosSkillBonuses SkillBonusesDefaultValue() => new(this); [SerializedIgnoreDupe] [InvalidateProperties] [SerializableField(2)] - [SaveFlag(nameof(ShouldSerializeProtection), nameof(ProtectionDefaultValue))] [SerializedCommandProperty(AccessLevel.GameMaster, canModify: true)] private TalismanAttribute _protection; + [SerializableFieldSaveFlag(2)] public bool ShouldSerializeProtection() => !_protection.IsEmpty; + [SerializableFieldDefault(2)] private TalismanAttribute ProtectionDefaultValue() => new(); [SerializedIgnoreDupe] [InvalidateProperties] [SerializableField(3)] - [SaveFlag(nameof(ShouldSerializeKiller), nameof(KillerDefaultValue))] [SerializedCommandProperty(AccessLevel.GameMaster, canModify: true)] private TalismanAttribute _killer; + [SerializableFieldSaveFlag(3)] public bool ShouldSerializeKiller() => !_killer.IsEmpty; + [SerializableFieldDefault(3)] private TalismanAttribute KillerDefaultValue() => new(); [SerializedIgnoreDupe] [InvalidateProperties] [SerializableField(4)] - [SaveFlag(nameof(ShouldSerializeSummoner), nameof(SummonerDefaultValue))] [SerializedCommandProperty(AccessLevel.GameMaster, canModify: true)] private TalismanAttribute _summoner; + [SerializableFieldSaveFlag(4)] public bool ShouldSerializeSummoner() => !_summoner.IsEmpty; + [SerializableFieldDefault(4)] private TalismanAttribute SummonerDefaultValue() => new(); [InvalidateProperties] [SerializableField(5)] - [SaveFlag(nameof(ShouldSerializeRemoval))] [SerializedCommandProperty(AccessLevel.GameMaster)] private TalismanRemoval _removal; + [SerializableFieldSaveFlag(5)] public bool ShouldSerializeRemoval() => _removal != TalismanRemoval.None; [InvalidateProperties] [SerializableField(6)] - [SaveFlag(nameof(ShouldSerializeSkill))] [SerializedCommandProperty(AccessLevel.GameMaster)] private SkillName _skill; + [SerializableFieldSaveFlag(6)] public bool ShouldSerializeSkill() => (int)_skill != 0; [EncodedInt] [InvalidateProperties] [SerializableField(7)] - [SaveFlag(nameof(ShouldSerializeSuccessBonus))] [SerializedCommandProperty(AccessLevel.GameMaster)] private int _successBonus; + [SerializableFieldSaveFlag(7)] public bool ShouldSerializeSuccessBonus() => _successBonus != 0; [EncodedInt] [InvalidateProperties] [SerializableField(8)] - [SaveFlag(nameof(ShouldSerializeExceptionalBonus))] [SerializedCommandProperty(AccessLevel.GameMaster)] private int _exceptionalBonus; + [SerializableFieldSaveFlag(8)] public bool ShouldSerializeExceptionalBonus() => _exceptionalBonus != 0; [EncodedInt] [InvalidateProperties] [SerializableField(9)] - [SaveFlag(nameof(ShouldSerializeMaxCharges))] [SerializedCommandProperty(AccessLevel.GameMaster)] private int _maxCharges; + [SerializableFieldSaveFlag(9)] public bool ShouldSerializeMaxCharges() => _maxCharges != 0; [EncodedInt] [InvalidateProperties] [SerializableField(11)] - [SaveFlag(nameof(ShouldSerializeMaxChargeTime))] [SerializedCommandProperty(AccessLevel.GameMaster)] private int _maxChargeTime; + [SerializableFieldSaveFlag(11)] public bool ShouldSerializeMaxChargeTime() => _maxChargeTime != 0; [EncodedInt] [InvalidateProperties] [SerializableField(12)] - [SaveFlag(nameof(ShouldSerializeChargeTime))] private int _chargeTime; + [SerializableFieldSaveFlag(12)] public bool ShouldSerializeChargeTime() => _chargeTime != 0; [InvalidateProperties] [SerializableField(13)] - [SaveFlag(nameof(ShouldSerializeBlessed))] [SerializedCommandProperty(AccessLevel.GameMaster)] private bool _blessed; + [SerializableFieldSaveFlag(13)] public bool ShouldSerializeBlessed() => _blessed; [InvalidateProperties] [SerializableField(14)] - [SaveFlag(nameof(ShouldSerializeSlayer))] [SerializedCommandProperty(AccessLevel.GameMaster)] private TalismanSlayerName _slayer; + [SerializableFieldSaveFlag(14)] public bool ShouldSerializeSlayer() => _slayer != TalismanSlayerName.None; private BaseCreature _creature; @@ -279,20 +284,26 @@ public partial class BaseTalisman : Item, IAosItem public override int LabelNumber => 1071023; // Talisman public virtual bool ForceShowName => false; // used to override default summoner/removal name - [SerializableField(10, fieldChanged: nameof(OnChargesChanged))] - [SaveFlag(nameof(ShouldSerializeCharges))] - [SerializedCommandProperty(AccessLevel.GameMaster)] - [InvalidateProperties] - private int _charges; - - private void OnChargesChanged(int oldValue, int newValue) + [SerializableProperty(10)] + [CommandProperty(AccessLevel.GameMaster)] + public int Charges { - if (_chargeTime > 0) + get => _charges; + set { - StartTimer(); + _charges = value; + + if (_chargeTime > 0) + { + StartTimer(); + } + + InvalidateProperties(); + this.MarkDirty(); } } + [SerializableFieldSaveFlag(10)] public bool ShouldSerializeCharges() => _charges != 0; public static void Configure() diff --git a/Projects/UOContent/Items/Weapons/BaseWeapon.Migrations.cs b/Projects/UOContent/Items/Weapons/BaseWeapon.Migrations.cs deleted file mode 100644 index 3385598d9..000000000 --- a/Projects/UOContent/Items/Weapons/BaseWeapon.Migrations.cs +++ /dev/null @@ -1,311 +0,0 @@ -using System; -using Server.Engines.Craft; - -namespace Server.Items; - -public partial class BaseWeapon -{ - // PlayerConstructed moved onto Item - private void MigrateFrom(V10Content content) - { - _damageLevel = content.DamageLevel ?? WeaponDamageLevel.Regular; - _accuracyLevel = content.AccuracyLevel ?? WeaponAccuracyLevel.Regular; - _durabilityLevel = content.DurabilityLevel ?? WeaponDurabilityLevel.Regular; - _quality = content.Quality ?? WeaponQuality.Regular; - _hitPoints = content.HitPoints ?? 0; - _maxHitPoints = content.MaxHitPoints ?? 0; - _slayer = content.Slayer ?? SlayerName.None; - _poison = content.Poison; - _poisonCharges = content.PoisonCharges ?? 0; - _crafter = content.Crafter; - _identified = content.Identified; - _strRequirement = content.StrRequirement ?? -1; - _dexRequirement = content.DexRequirement ?? -1; - _intRequirement = content.IntRequirement ?? -1; - _minDamage = content.MinDamage ?? -1; - _maxDamage = content.MaxDamage ?? -1; - _hitSound = content.HitSound ?? -1; - _missSound = content.MissSound ?? -1; - _speed = content.Speed ?? -1; - _maxRange = content.MaxRange ?? -1; - _skill = content.Skill ?? (SkillName)(-1); - _type = content.Type ?? (WeaponType)(-1); - _animation = content.Animation ?? (WeaponAnimation)(-1); - _resource = content.Resource ?? CraftResource.Iron; - _attributes = content.Attributes ?? AttributesDefaultValue(); - _weaponAttributes = content.WeaponAttributes ?? WeaponAttributesDefaultValue(); - PlayerConstructed = content.PlayerConstructed; - _skillBonuses = content.SkillBonuses ?? SkillBonusesDefaultValue(); - _slayer2 = content.Slayer2 ?? SlayerName.None; - _aosElementDamages = content.AosElementDamages ?? AosElementAttributesDefaultValue(); - _engravedText = content.EngravedText; - } - - // Version 9 (pre-codegen) - private void Deserialize(IGenericReader reader, int version) - { - var flags = (OldSaveFlag)reader.ReadInt(); - - if (GetSaveFlag(flags, OldSaveFlag.DamageLevel)) - { - _damageLevel = (WeaponDamageLevel)reader.ReadInt(); - } - - if (GetSaveFlag(flags, OldSaveFlag.AccuracyLevel)) - { - _accuracyLevel = (WeaponAccuracyLevel)reader.ReadInt(); - } - - if (GetSaveFlag(flags, OldSaveFlag.DurabilityLevel)) - { - _durabilityLevel = (WeaponDurabilityLevel)reader.ReadInt(); - } - - if (GetSaveFlag(flags, OldSaveFlag.Quality)) - { - _quality = (WeaponQuality)reader.ReadInt(); - } - else - { - _quality = WeaponQuality.Regular; - } - - if (GetSaveFlag(flags, OldSaveFlag.Hits)) - { - _hitPoints = reader.ReadInt(); - } - - if (GetSaveFlag(flags, OldSaveFlag.MaxHits)) - { - _maxHitPoints = reader.ReadInt(); - } - - if (GetSaveFlag(flags, OldSaveFlag.Slayer)) - { - _slayer = (SlayerName)reader.ReadInt(); - } - - if (GetSaveFlag(flags, OldSaveFlag.Poison)) - { - _poison = reader.ReadPoison(); - } - - if (GetSaveFlag(flags, OldSaveFlag.PoisonCharges)) - { - _poisonCharges = reader.ReadInt(); - } - - if (GetSaveFlag(flags, OldSaveFlag.Crafter)) - { - Timer.DelayCall(crafter => _crafter = crafter?.RawName, reader.ReadEntity()); - } - - if (GetSaveFlag(flags, OldSaveFlag.Identified)) - { - _identified = version >= 6 || reader.ReadBool(); - } - - if (GetSaveFlag(flags, OldSaveFlag.StrReq)) - { - _strRequirement = reader.ReadInt(); - } - else - { - _strRequirement = -1; - } - - if (GetSaveFlag(flags, OldSaveFlag.DexReq)) - { - _dexRequirement = reader.ReadInt(); - } - else - { - _dexRequirement = -1; - } - - if (GetSaveFlag(flags, OldSaveFlag.IntReq)) - { - _intRequirement = reader.ReadInt(); - } - else - { - _intRequirement = -1; - } - - if (GetSaveFlag(flags, OldSaveFlag.MinDamage)) - { - _minDamage = reader.ReadInt(); - } - else - { - _minDamage = -1; - } - - if (GetSaveFlag(flags, OldSaveFlag.MaxDamage)) - { - _maxDamage = reader.ReadInt(); - } - else - { - _maxDamage = -1; - } - - if (GetSaveFlag(flags, OldSaveFlag.HitSound)) - { - _hitSound = reader.ReadInt(); - } - else - { - _hitSound = -1; - } - - if (GetSaveFlag(flags, OldSaveFlag.MissSound)) - { - _missSound = reader.ReadInt(); - } - else - { - _missSound = -1; - } - - if (GetSaveFlag(flags, OldSaveFlag.Speed)) - { - if (version < 9) - { - _speed = reader.ReadInt(); - } - else - { - _speed = reader.ReadFloat(); - } - } - else - { - _speed = -1; - } - - if (GetSaveFlag(flags, OldSaveFlag.MaxRange)) - { - _maxRange = reader.ReadInt(); - } - else - { - _maxRange = -1; - } - - if (GetSaveFlag(flags, OldSaveFlag.Skill)) - { - _skill = (SkillName)reader.ReadInt(); - } - else - { - _skill = (SkillName)(-1); - } - - if (GetSaveFlag(flags, OldSaveFlag.Type)) - { - _type = (WeaponType)reader.ReadInt(); - } - else - { - _type = (WeaponType)(-1); - } - - if (GetSaveFlag(flags, OldSaveFlag.Animation)) - { - _animation = (WeaponAnimation)reader.ReadInt(); - } - else - { - _animation = (WeaponAnimation)(-1); - } - - if (GetSaveFlag(flags, OldSaveFlag.Resource)) - { - _resource = (CraftResource)reader.ReadInt(); - } - else - { - _resource = CraftResource.Iron; - } - - Attributes = new AosAttributes(this); - - if (GetSaveFlag(flags, OldSaveFlag.Attributes)) - { - Attributes.Deserialize(reader); - } - - WeaponAttributes = new AosWeaponAttributes(this); - - if (GetSaveFlag(flags, OldSaveFlag.WeaponAttributes)) - { - WeaponAttributes.Deserialize(reader); - } - - PlayerConstructed = GetSaveFlag(flags, OldSaveFlag.PlayerConstructed); - - SkillBonuses = new AosSkillBonuses(this); - - if (GetSaveFlag(flags, OldSaveFlag.SkillBonuses)) - { - SkillBonuses.Deserialize(reader); - } - - if (GetSaveFlag(flags, OldSaveFlag.Slayer2)) - { - _slayer2 = (SlayerName)reader.ReadInt(); - } - - AosElementDamages = new AosElementAttributes(this); - - if (GetSaveFlag(flags, OldSaveFlag.ElementalDamages)) - { - AosElementDamages.Deserialize(reader); - } - - if (GetSaveFlag(flags, OldSaveFlag.EngravedText)) - { - _engravedText = reader.ReadString(); - } - } - - private static bool GetSaveFlag(OldSaveFlag flags, OldSaveFlag toGet) => (flags & toGet) != 0; - - [Flags] - private enum OldSaveFlag - { - None = 0x00000000, - DamageLevel = 0x00000001, - AccuracyLevel = 0x00000002, - DurabilityLevel = 0x00000004, - Quality = 0x00000008, - Hits = 0x00000010, - MaxHits = 0x00000020, - Slayer = 0x00000040, - Poison = 0x00000080, - PoisonCharges = 0x00000100, - Crafter = 0x00000200, - Identified = 0x00000400, - StrReq = 0x00000800, - DexReq = 0x00001000, - IntReq = 0x00002000, - MinDamage = 0x00004000, - MaxDamage = 0x00008000, - HitSound = 0x00010000, - MissSound = 0x00020000, - Speed = 0x00040000, - MaxRange = 0x00080000, - Skill = 0x00100000, - Type = 0x00200000, - Animation = 0x00400000, - Resource = 0x00800000, - Attributes = 0x01000000, - WeaponAttributes = 0x02000000, - PlayerConstructed = 0x04000000, - SkillBonuses = 0x08000000, - Slayer2 = 0x10000000, - ElementalDamages = 0x20000000, - EngravedText = 0x40000000 - } -} diff --git a/Projects/UOContent/Items/Weapons/BaseWeapon.cs b/Projects/UOContent/Items/Weapons/BaseWeapon.cs index d48824670..b28f0d371 100644 --- a/Projects/UOContent/Items/Weapons/BaseWeapon.cs +++ b/Projects/UOContent/Items/Weapons/BaseWeapon.cs @@ -27,7 +27,7 @@ public interface ISlayer SlayerName Slayer2 { get; set; } } -[SerializationGenerator(11, false)] +[SerializationGenerator(10, false)] public abstract partial class BaseWeapon : Item, IWeapon, IFactionItem, ICraftable, ISlayer, IDurability, IAosItem, IIdentifiable { @@ -56,126 +56,138 @@ public abstract partial class BaseWeapon [InvalidateProperties] [SerializableField(0)] - [SaveFlag(nameof(ShouldSerializeDamageLevel))] [SerializedCommandProperty(AccessLevel.GameMaster)] private WeaponDamageLevel _damageLevel; + [SerializableFieldSaveFlag(0)] [MethodImpl(MethodImplOptions.AggressiveInlining)] private bool ShouldSerializeDamageLevel() => _damageLevel != WeaponDamageLevel.Regular; [InvalidateProperties] [SerializableField(5)] - [SaveFlag(nameof(ShouldSerializeMaxHitPoints))] [SerializedCommandProperty(AccessLevel.GameMaster)] private int _maxHitPoints; + [SerializableFieldSaveFlag(5)] [MethodImpl(MethodImplOptions.AggressiveInlining)] private bool ShouldSerializeMaxHitPoints() => _maxHitPoints != 0; [InvalidateProperties] [SerializableField(6)] - [SaveFlag(nameof(ShouldSerializeSlayer))] [SerializedCommandProperty(AccessLevel.GameMaster)] private SlayerName _slayer; + [SerializableFieldSaveFlag(6)] [MethodImpl(MethodImplOptions.AggressiveInlining)] private bool ShouldSerializeSlayer() => _slayer != SlayerName.None; [InvalidateProperties] [SerializableField(7)] - [SaveFlag(nameof(ShouldSerializePoison))] [SerializedCommandProperty(AccessLevel.GameMaster)] private Poison _poison; + [SerializableFieldSaveFlag(7)] [MethodImpl(MethodImplOptions.AggressiveInlining)] private bool ShouldSerializePoison() => _poison != null; [InvalidateProperties] [SerializableField(8)] - [SaveFlag(nameof(ShouldSerializePoisonCharges))] [SerializedCommandProperty(AccessLevel.GameMaster)] private int _poisonCharges; + [SerializableFieldSaveFlag(8)] [MethodImpl(MethodImplOptions.AggressiveInlining)] private bool ShouldSerializePoisonCharges() => _poisonCharges > 0; [InvalidateProperties] [SerializableField(9)] - [SaveFlag(nameof(ShouldSerializeCrafter))] [SerializedCommandProperty(AccessLevel.GameMaster)] private string _crafter; + [SerializableFieldSaveFlag(9)] [MethodImpl(MethodImplOptions.AggressiveInlining)] private bool ShouldSerializeCrafter() => !string.IsNullOrEmpty(_crafter); [InvalidateProperties] [SerializableField(10)] - [SaveFlag(nameof(ShouldSerializeIdentified))] [SerializedCommandProperty(AccessLevel.GameMaster)] private bool _identified; + [SerializableFieldSaveFlag(10)] [MethodImpl(MethodImplOptions.AggressiveInlining)] private bool ShouldSerializeIdentified() => _identified; [SerializedIgnoreDupe] [SerializableField(24, setter: "private")] - [SaveFlag(nameof(ShouldSerializeAttributes), nameof(AttributesDefaultValue))] [SerializedCommandProperty(AccessLevel.GameMaster, canModify: true)] private AosAttributes _attributes; + [SerializableFieldSaveFlag(24)] [MethodImpl(MethodImplOptions.AggressiveInlining)] private bool ShouldSerializeAttributes() => !_attributes.IsEmpty; + [SerializableFieldDefault(24)] private AosAttributes AttributesDefaultValue() => new(this); [SerializedIgnoreDupe] [SerializableField(25, setter: "private")] - [SaveFlag(nameof(ShouldSerializeWeaponAttributes), nameof(WeaponAttributesDefaultValue))] [SerializedCommandProperty(AccessLevel.GameMaster, canModify: true)] private AosWeaponAttributes _weaponAttributes; + [SerializableFieldSaveFlag(25)] [MethodImpl(MethodImplOptions.AggressiveInlining)] private bool ShouldSerializeWeaponAttributes() => !_weaponAttributes.IsEmpty; + [SerializableFieldDefault(25)] private AosWeaponAttributes WeaponAttributesDefaultValue() => new(this); + [SerializableField(26)] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private bool _playerConstructed; + + [SerializableFieldSaveFlag(26)] + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private bool ShouldSerializePlayerConstructed() => _playerConstructed; + [SerializedIgnoreDupe] - [SerializableField(26, setter: "private")] - [SaveFlag(nameof(ShouldSerializeSkillBonuses), nameof(SkillBonusesDefaultValue))] + [SerializableField(27, setter: "private")] [SerializedCommandProperty(AccessLevel.GameMaster, canModify: true)] private AosSkillBonuses _skillBonuses; + [SerializableFieldSaveFlag(27)] [MethodImpl(MethodImplOptions.AggressiveInlining)] private bool ShouldSerializeSkillBonuses() => !_skillBonuses.IsEmpty; + [SerializableFieldDefault(27)] private AosSkillBonuses SkillBonusesDefaultValue() => new(this); [InvalidateProperties] - [SerializableField(27)] - [SaveFlag(nameof(ShouldSerializeSlayer2))] + [SerializableField(28)] [SerializedCommandProperty(AccessLevel.GameMaster)] private SlayerName _slayer2; + [SerializableFieldSaveFlag(28)] [MethodImpl(MethodImplOptions.AggressiveInlining)] private bool ShouldSerializeSlayer2() => _slayer2 != SlayerName.None; [SerializedIgnoreDupe] - [SerializableField(28, setter: "private")] - [SaveFlag(nameof(ShouldSerializeElementAttributes), nameof(AosElementAttributesDefaultValue))] + [SerializableField(29, setter: "private")] [SerializedCommandProperty(AccessLevel.GameMaster, canModify: true)] private AosElementAttributes _aosElementDamages; + [SerializableFieldSaveFlag(29)] [MethodImpl(MethodImplOptions.AggressiveInlining)] private bool ShouldSerializeElementAttributes() => !_aosElementDamages.IsEmpty; + [SerializableFieldDefault(29)] private AosElementAttributes AosElementAttributesDefaultValue() => new(this); [InvalidateProperties] - [SerializableField(29)] - [SaveFlag(nameof(ShouldSerializeEngravedText))] + [SerializableField(30)] [SerializedCommandProperty(AccessLevel.GameMaster)] private string _engravedText; + [SerializableFieldSaveFlag(30)] [MethodImpl(MethodImplOptions.AggressiveInlining)] private bool ShouldSerializeEngravedText() => !string.IsNullOrEmpty(_engravedText); @@ -282,7 +294,6 @@ public abstract partial class BaseWeapon public bool Consecrated { get; set; } [SerializableProperty(1)] - [SaveFlag(nameof(ShouldSerializeWeaponAccuracy))] [CommandProperty(AccessLevel.GameMaster)] public WeaponAccuracyLevel AccuracyLevel { @@ -318,10 +329,10 @@ public abstract partial class BaseWeapon } } + [SerializableFieldSaveFlag(1)] private bool ShouldSerializeWeaponAccuracy() => _accuracyLevel != WeaponAccuracyLevel.Regular; [SerializableProperty(2)] - [SaveFlag(nameof(ShouldSerializeDurabilityLevel))] [CommandProperty(AccessLevel.GameMaster)] public WeaponDurabilityLevel DurabilityLevel { @@ -336,10 +347,10 @@ public abstract partial class BaseWeapon } } + [SerializableFieldSaveFlag(2)] private bool ShouldSerializeDurabilityLevel() => _durabilityLevel != WeaponDurabilityLevel.Regular; [SerializableProperty(3)] - [SaveFlag(nameof(ShouldSerializeQuality), nameof(QualityDefaultValue))] [CommandProperty(AccessLevel.GameMaster)] public WeaponQuality Quality { @@ -354,12 +365,13 @@ public abstract partial class BaseWeapon } } + [SerializableFieldSaveFlag(3)] private bool ShouldSerializeQuality() => _quality != WeaponQuality.Regular; + [SerializableFieldDefault(3)] private WeaponQuality QualityDefaultValue() => WeaponQuality.Regular; [SerializableProperty(4)] - [SaveFlag(nameof(ShouldSerializeHitPoints))] [CommandProperty(AccessLevel.GameMaster)] public int HitPoints { @@ -383,10 +395,10 @@ public abstract partial class BaseWeapon } } + [SerializableFieldSaveFlag(4)] private bool ShouldSerializeHitPoints() => _hitPoints > 0; [SerializableProperty(11)] - [SaveFlag(nameof(ShouldSerializeStrReq), nameof(StrReqDefaultValue))] [CommandProperty(AccessLevel.GameMaster)] public int StrRequirement { @@ -399,12 +411,13 @@ public abstract partial class BaseWeapon } } + [SerializableFieldSaveFlag(11)] private bool ShouldSerializeStrReq() => _strRequirement != -1; + [SerializableFieldDefault(11)] private int StrReqDefaultValue() => -1; [SerializableProperty(12)] - [SaveFlag(nameof(ShouldSerializeDexReq), nameof(DexReqDefaultValue))] [CommandProperty(AccessLevel.GameMaster)] public int DexRequirement { @@ -417,12 +430,13 @@ public abstract partial class BaseWeapon } } + [SerializableFieldSaveFlag(12)] private bool ShouldSerializeDexReq() => _dexRequirement != -1; + [SerializableFieldDefault(12)] private int DexReqDefaultValue() => -1; [SerializableProperty(13)] - [SaveFlag(nameof(ShouldSerializeIntReq), nameof(IntReqDefaultValue))] [CommandProperty(AccessLevel.GameMaster)] public int IntRequirement { @@ -435,12 +449,13 @@ public abstract partial class BaseWeapon } } + [SerializableFieldSaveFlag(13)] private bool ShouldSerializeIntReq() => _intRequirement != -1; + [SerializableFieldDefault(13)] private int IntReqDefaultValue() => -1; [SerializableProperty(14)] - [SaveFlag(nameof(ShouldSerializeMinDamage), nameof(MinDamageDefaultValue))] [CommandProperty(AccessLevel.GameMaster)] public int MinDamage { @@ -453,12 +468,13 @@ public abstract partial class BaseWeapon } } + [SerializableFieldSaveFlag(14)] private bool ShouldSerializeMinDamage() => _minDamage != -1; + [SerializableFieldDefault(14)] private int MinDamageDefaultValue() => -1; [SerializableProperty(15)] - [SaveFlag(nameof(ShouldSerializeMaxDamage), nameof(MaxDamageDefaultValue))] [CommandProperty(AccessLevel.GameMaster)] public int MaxDamage { @@ -471,12 +487,13 @@ public abstract partial class BaseWeapon } } + [SerializableFieldSaveFlag(15)] private bool ShouldSerializeMaxDamage() => _maxDamage != -1; + [SerializableFieldDefault(15)] private int MaxDamageDefaultValue() => -1; [SerializableProperty(16)] - [SaveFlag(nameof(ShouldSerializeHitSound), nameof(HitSoundDefaultValue))] [CommandProperty(AccessLevel.GameMaster)] public int HitSound { @@ -488,12 +505,13 @@ public abstract partial class BaseWeapon } } + [SerializableFieldSaveFlag(16)] private bool ShouldSerializeHitSound() => _hitSound != -1; + [SerializableFieldDefault(16)] private int HitSoundDefaultValue() => -1; [SerializableProperty(17)] - [SaveFlag(nameof(ShouldSerializeMissSound), nameof(MissSoundDefaultValue))] [CommandProperty(AccessLevel.GameMaster)] public int MissSound { @@ -505,12 +523,13 @@ public abstract partial class BaseWeapon } } + [SerializableFieldSaveFlag(17)] private bool ShouldSerializeMissSound() => _missSound != -1; + [SerializableFieldDefault(17)] private int MissSoundDefaultValue() => -1; [SerializableProperty(18)] - [SaveFlag(nameof(ShouldSerializeSpeed), nameof(SpeedDefaultValue))] [CommandProperty(AccessLevel.GameMaster)] public float Speed { @@ -541,12 +560,13 @@ public abstract partial class BaseWeapon } } + [SerializableFieldSaveFlag(18)] private bool ShouldSerializeSpeed() => _speed != -1; + [SerializableFieldDefault(18)] private float SpeedDefaultValue() => -1; [SerializableProperty(19)] - [SaveFlag(nameof(ShouldSerializeMaxRange), nameof(MaxRangeDefaultValue))] [CommandProperty(AccessLevel.GameMaster)] public int MaxRange { @@ -559,12 +579,13 @@ public abstract partial class BaseWeapon } } + [SerializableFieldSaveFlag(19)] private bool ShouldSerializeMaxRange() => _maxRange != -1; + [SerializableFieldDefault(19)] private int MaxRangeDefaultValue() => -1; [SerializableProperty(20)] - [SaveFlag(nameof(ShouldSerializeSkill), nameof(SkillNameDefaultValue))] [CommandProperty(AccessLevel.GameMaster)] public SkillName Skill { @@ -577,12 +598,13 @@ public abstract partial class BaseWeapon } } + [SerializableFieldSaveFlag(20)] private bool ShouldSerializeSkill() => _skill != (SkillName)(-1); + [SerializableFieldDefault(20)] private SkillName SkillNameDefaultValue() => (SkillName)(-1); [SerializableProperty(21)] - [SaveFlag(nameof(ShouldSerializeType), nameof(TypeDefaultValue))] [CommandProperty(AccessLevel.GameMaster)] public WeaponType Type { @@ -594,12 +616,13 @@ public abstract partial class BaseWeapon } } + [SerializableFieldSaveFlag(21)] private bool ShouldSerializeType() => _type != (WeaponType)(-1); + [SerializableFieldDefault(21)] private WeaponType TypeDefaultValue() => (WeaponType)(-1); [SerializableProperty(22)] - [SaveFlag(nameof(ShouldSerializeAnimation), nameof(AnimationDefaultValue))] [CommandProperty(AccessLevel.GameMaster)] public WeaponAnimation Animation { @@ -611,12 +634,13 @@ public abstract partial class BaseWeapon } } + [SerializableFieldSaveFlag(22)] private bool ShouldSerializeAnimation() => _animation != (WeaponAnimation)(-1); + [SerializableFieldDefault(22)] private WeaponAnimation AnimationDefaultValue() => (WeaponAnimation)(-1); [SerializableProperty(23)] - [SaveFlag(nameof(ShouldSerializeResource), nameof(ResourceDefaultValue))] [CommandProperty(AccessLevel.GameMaster)] public CraftResource Resource { @@ -632,8 +656,10 @@ public abstract partial class BaseWeapon } } + [SerializableFieldSaveFlag(23)] private bool ShouldSerializeResource() => _resource != CraftResource.Iron; + [SerializableFieldDefault(23)] private CraftResource ResourceDefaultValue() => CraftResource.Iron; public virtual int OnCraft( @@ -648,6 +674,7 @@ public abstract partial class BaseWeapon Crafter = from.RawName; } + PlayerConstructed = true; Identified = true; var resourceType = typeRes ?? craftItem.Resources[0].ItemType; @@ -3553,6 +3580,236 @@ public abstract partial class BaseWeapon } } + private static bool GetSaveFlag(OldSaveFlag flags, OldSaveFlag toGet) => (flags & toGet) != 0; + + private void Deserialize(IGenericReader reader, int version) + { + var flags = (OldSaveFlag)reader.ReadInt(); + + if (GetSaveFlag(flags, OldSaveFlag.DamageLevel)) + { + _damageLevel = (WeaponDamageLevel)reader.ReadInt(); + } + + if (GetSaveFlag(flags, OldSaveFlag.AccuracyLevel)) + { + _accuracyLevel = (WeaponAccuracyLevel)reader.ReadInt(); + } + + if (GetSaveFlag(flags, OldSaveFlag.DurabilityLevel)) + { + _durabilityLevel = (WeaponDurabilityLevel)reader.ReadInt(); + } + + if (GetSaveFlag(flags, OldSaveFlag.Quality)) + { + _quality = (WeaponQuality)reader.ReadInt(); + } + else + { + _quality = WeaponQuality.Regular; + } + + if (GetSaveFlag(flags, OldSaveFlag.Hits)) + { + _hitPoints = reader.ReadInt(); + } + + if (GetSaveFlag(flags, OldSaveFlag.MaxHits)) + { + _maxHitPoints = reader.ReadInt(); + } + + if (GetSaveFlag(flags, OldSaveFlag.Slayer)) + { + _slayer = (SlayerName)reader.ReadInt(); + } + + if (GetSaveFlag(flags, OldSaveFlag.Poison)) + { + _poison = reader.ReadPoison(); + } + + if (GetSaveFlag(flags, OldSaveFlag.PoisonCharges)) + { + _poisonCharges = reader.ReadInt(); + } + + if (GetSaveFlag(flags, OldSaveFlag.Crafter)) + { + Timer.DelayCall(crafter => _crafter = crafter?.RawName, reader.ReadEntity()); + } + + if (GetSaveFlag(flags, OldSaveFlag.Identified)) + { + _identified = version >= 6 || reader.ReadBool(); + } + + if (GetSaveFlag(flags, OldSaveFlag.StrReq)) + { + _strRequirement = reader.ReadInt(); + } + else + { + _strRequirement = -1; + } + + if (GetSaveFlag(flags, OldSaveFlag.DexReq)) + { + _dexRequirement = reader.ReadInt(); + } + else + { + _dexRequirement = -1; + } + + if (GetSaveFlag(flags, OldSaveFlag.IntReq)) + { + _intRequirement = reader.ReadInt(); + } + else + { + _intRequirement = -1; + } + + if (GetSaveFlag(flags, OldSaveFlag.MinDamage)) + { + _minDamage = reader.ReadInt(); + } + else + { + _minDamage = -1; + } + + if (GetSaveFlag(flags, OldSaveFlag.MaxDamage)) + { + _maxDamage = reader.ReadInt(); + } + else + { + _maxDamage = -1; + } + + if (GetSaveFlag(flags, OldSaveFlag.HitSound)) + { + _hitSound = reader.ReadInt(); + } + else + { + _hitSound = -1; + } + + if (GetSaveFlag(flags, OldSaveFlag.MissSound)) + { + _missSound = reader.ReadInt(); + } + else + { + _missSound = -1; + } + + if (GetSaveFlag(flags, OldSaveFlag.Speed)) + { + if (version < 9) + { + _speed = reader.ReadInt(); + } + else + { + _speed = reader.ReadFloat(); + } + } + else + { + _speed = -1; + } + + if (GetSaveFlag(flags, OldSaveFlag.MaxRange)) + { + _maxRange = reader.ReadInt(); + } + else + { + _maxRange = -1; + } + + if (GetSaveFlag(flags, OldSaveFlag.Skill)) + { + _skill = (SkillName)reader.ReadInt(); + } + else + { + _skill = (SkillName)(-1); + } + + if (GetSaveFlag(flags, OldSaveFlag.Type)) + { + _type = (WeaponType)reader.ReadInt(); + } + else + { + _type = (WeaponType)(-1); + } + + if (GetSaveFlag(flags, OldSaveFlag.Animation)) + { + _animation = (WeaponAnimation)reader.ReadInt(); + } + else + { + _animation = (WeaponAnimation)(-1); + } + + if (GetSaveFlag(flags, OldSaveFlag.Resource)) + { + _resource = (CraftResource)reader.ReadInt(); + } + else + { + _resource = CraftResource.Iron; + } + + Attributes = new AosAttributes(this); + + if (GetSaveFlag(flags, OldSaveFlag.Attributes)) + { + Attributes.Deserialize(reader); + } + + WeaponAttributes = new AosWeaponAttributes(this); + + if (GetSaveFlag(flags, OldSaveFlag.WeaponAttributes)) + { + WeaponAttributes.Deserialize(reader); + } + + PlayerConstructed = GetSaveFlag(flags, OldSaveFlag.PlayerConstructed); + + SkillBonuses = new AosSkillBonuses(this); + + if (GetSaveFlag(flags, OldSaveFlag.SkillBonuses)) + { + SkillBonuses.Deserialize(reader); + } + + if (GetSaveFlag(flags, OldSaveFlag.Slayer2)) + { + _slayer2 = (SlayerName)reader.ReadInt(); + } + + AosElementDamages = new AosElementAttributes(this); + + if (GetSaveFlag(flags, OldSaveFlag.ElementalDamages)) + { + AosElementDamages.Deserialize(reader); + } + + if (GetSaveFlag(flags, OldSaveFlag.EngravedText)) + { + _engravedText = reader.ReadString(); + } + } + [AfterDeserialization] private void AfterDeserialization() { @@ -3618,6 +3875,42 @@ public abstract partial class BaseWeapon } } + [Flags] + private enum OldSaveFlag + { + None = 0x00000000, + DamageLevel = 0x00000001, + AccuracyLevel = 0x00000002, + DurabilityLevel = 0x00000004, + Quality = 0x00000008, + Hits = 0x00000010, + MaxHits = 0x00000020, + Slayer = 0x00000040, + Poison = 0x00000080, + PoisonCharges = 0x00000100, + Crafter = 0x00000200, + Identified = 0x00000400, + StrReq = 0x00000800, + DexReq = 0x00001000, + IntReq = 0x00002000, + MinDamage = 0x00004000, + MaxDamage = 0x00008000, + HitSound = 0x00010000, + MissSound = 0x00020000, + Speed = 0x00040000, + MaxRange = 0x00080000, + Skill = 0x00100000, + Type = 0x00200000, + Animation = 0x00400000, + Resource = 0x00800000, + Attributes = 0x01000000, + WeaponAttributes = 0x02000000, + PlayerConstructed = 0x04000000, + SkillBonuses = 0x08000000, + Slayer2 = 0x10000000, + ElementalDamages = 0x20000000, + EngravedText = 0x40000000 + } } public enum CheckSlayerResult diff --git a/Projects/UOContent/Migrations/Server.Engines.CannedEvil.ChampionSpawn.v11.json b/Projects/UOContent/Migrations/Server.Engines.CannedEvil.ChampionSpawn.v11.json deleted file mode 100644 index defcbe100..000000000 --- a/Projects/UOContent/Migrations/Server.Engines.CannedEvil.ChampionSpawn.v11.json +++ /dev/null @@ -1,189 +0,0 @@ -{ - "version": 11, - "type": "Server.Engines.CannedEvil.ChampionSpawn", - "properties": [ - { - "name": "Level", - "type": "int", - "rule": "PrimitiveTypeMigrationRule", - "ruleArguments": [ - "" - ] - }, - { - "name": "ActivatedByProximity", - "type": "bool", - "rule": "PrimitiveTypeMigrationRule", - "ruleArguments": [ - "" - ] - }, - { - "name": "NextProximityTime", - "type": "System.DateTime", - "rule": "PrimitiveTypeMigrationRule", - "ruleArguments": [ - "AnchoredTime" - ] - }, - { - "name": "MaxLevel", - "type": "int", - "rule": "PrimitiveTypeMigrationRule", - "ruleArguments": [ - "" - ] - }, - { - "name": "ActivatedByValor", - "type": "bool", - "rule": "PrimitiveTypeMigrationRule", - "ruleArguments": [ - "" - ] - }, - { - "name": "DamageEntries", - "type": "System.Collections.Generic.Dictionary\u003CServer.Mobile, int\u003E", - "rule": "DictionaryMigrationRule", - "ruleArguments": [ - "Server.Mobile", - "SerializableInterfaceMigrationRule", - "0", - "int", - "PrimitiveTypeMigrationRule", - "1", - "" - ] - }, - { - "name": "ConfinedRoaming", - "type": "bool", - "rule": "PrimitiveTypeMigrationRule", - "ruleArguments": [ - "" - ] - }, - { - "name": "Idol", - "type": "Server.Engines.CannedEvil.IdolOfTheChampion", - "rule": "SerializableInterfaceMigrationRule" - }, - { - "name": "HasBeenAdvanced", - "type": "bool", - "rule": "PrimitiveTypeMigrationRule", - "ruleArguments": [ - "" - ] - }, - { - "name": "SpawnArea", - "type": "Server.Rectangle2D", - "rule": "PrimitiveUOTypeMigrationRule", - "ruleArguments": [ - "Rect2D" - ] - }, - { - "name": "RandomizeType", - "type": "bool", - "rule": "PrimitiveTypeMigrationRule", - "ruleArguments": [ - "" - ] - }, - { - "name": "Kills", - "type": "int", - "rule": "PrimitiveTypeMigrationRule", - "ruleArguments": [ - "" - ] - }, - { - "name": "Active", - "type": "bool", - "rule": "PrimitiveTypeMigrationRule", - "ruleArguments": [ - "" - ] - }, - { - "name": "Type", - "type": "Server.Engines.CannedEvil.ChampionSpawnType", - "rule": "EnumMigrationRule" - }, - { - "name": "Creatures", - "type": "System.Collections.Generic.List\u003CServer.Mobile\u003E", - "rule": "ListMigrationRule", - "ruleArguments": [ - "@Tidy", - "Server.Mobile", - "SerializableInterfaceMigrationRule" - ] - }, - { - "name": "RedSkulls", - "type": "System.Collections.Generic.List\u003CServer.Item\u003E", - "rule": "ListMigrationRule", - "ruleArguments": [ - "@Tidy", - "Server.Item", - "SerializableInterfaceMigrationRule" - ] - }, - { - "name": "WhiteSkulls", - "type": "System.Collections.Generic.List\u003CServer.Item\u003E", - "rule": "ListMigrationRule", - "ruleArguments": [ - "@Tidy", - "Server.Item", - "SerializableInterfaceMigrationRule" - ] - }, - { - "name": "Platform", - "type": "Server.Engines.CannedEvil.ChampionPlatform", - "rule": "SerializableInterfaceMigrationRule" - }, - { - "name": "Altar", - "type": "Server.Engines.CannedEvil.ChampionAltar", - "rule": "SerializableInterfaceMigrationRule" - }, - { - "name": "ExpireDelay", - "type": "System.TimeSpan", - "rule": "PrimitiveTypeMigrationRule" - }, - { - "name": "ExpireTime", - "type": "System.DateTime", - "rule": "PrimitiveTypeMigrationRule", - "ruleArguments": [ - "AnchoredTime" - ] - }, - { - "name": "Champion", - "type": "Server.Mobile", - "rule": "SerializableInterfaceMigrationRule" - }, - { - "name": "RestartDelay", - "type": "System.TimeSpan", - "rule": "PrimitiveTypeMigrationRule" - }, - { - "name": "RestartTime", - "type": "System.DateTime", - "rule": "PrimitiveTypeMigrationRule", - "ruleArguments": [ - "AnchoredTime" - ] - } - ] -} \ No newline at end of file diff --git a/Projects/UOContent/Migrations/Server.Engines.Virtues.VirtueContext.v1.json b/Projects/UOContent/Migrations/Server.Engines.Virtues.VirtueContext.v1.json deleted file mode 100644 index 956de91db..000000000 --- a/Projects/UOContent/Migrations/Server.Engines.Virtues.VirtueContext.v1.json +++ /dev/null @@ -1,119 +0,0 @@ -{ - "version": 1, - "type": "Server.Engines.Virtues.VirtueContext", - "properties": [ - { - "name": "LastSacrificeGain", - "type": "System.DateTime", - "usesSaveFlag": true, - "rule": "PrimitiveTypeMigrationRule", - "ruleArguments": [ - "AnchoredTime" - ] - }, - { - "name": "LastSacrificeLoss", - "type": "System.DateTime", - "usesSaveFlag": true, - "rule": "PrimitiveTypeMigrationRule", - "ruleArguments": [ - "AnchoredTime" - ] - }, - { - "name": "AvailableResurrects", - "type": "int", - "usesSaveFlag": true, - "rule": "PrimitiveTypeMigrationRule", - "ruleArguments": [ - "" - ] - }, - { - "name": "LastJusticeLoss", - "type": "System.DateTime", - "usesSaveFlag": true, - "rule": "PrimitiveTypeMigrationRule", - "ruleArguments": [ - "AnchoredTime" - ] - }, - { - "name": "LastCompassionLoss", - "type": "System.DateTime", - "usesSaveFlag": true, - "rule": "PrimitiveTypeMigrationRule", - "ruleArguments": [ - "AnchoredTime" - ] - }, - { - "name": "NextCompassionDay", - "type": "System.DateTime", - "usesSaveFlag": true, - "rule": "PrimitiveTypeMigrationRule", - "ruleArguments": [ - "AnchoredTime" - ] - }, - { - "name": "CompassionGains", - "type": "int", - "usesSaveFlag": true, - "rule": "PrimitiveTypeMigrationRule", - "ruleArguments": [ - "" - ] - }, - { - "name": "LastValorLoss", - "type": "System.DateTime", - "usesSaveFlag": true, - "rule": "PrimitiveTypeMigrationRule", - "ruleArguments": [ - "AnchoredTime" - ] - }, - { - "name": "LastHonorUse", - "type": "System.DateTime", - "usesSaveFlag": true, - "rule": "PrimitiveTypeMigrationRule", - "ruleArguments": [ - "AnchoredTime" - ] - }, - { - "name": "HonorActive", - "type": "bool", - "usesSaveFlag": true, - "rule": "PrimitiveTypeMigrationRule", - "ruleArguments": [ - "" - ] - }, - { - "name": "JusticeProtection", - "type": "Server.Mobiles.PlayerMobile", - "usesSaveFlag": true, - "rule": "SerializableInterfaceMigrationRule" - }, - { - "name": "JusticeStatus", - "type": "Server.Engines.Virtues.JusticeProtectorStatus", - "usesSaveFlag": true, - "rule": "EnumMigrationRule" - }, - { - "name": "Values", - "type": "int[]", - "usesSaveFlag": true, - "rule": "ArrayMigrationRule", - "ruleArguments": [ - "int", - "PrimitiveTypeMigrationRule", - "" - ] - } - ] -} \ No newline at end of file diff --git a/Projects/UOContent/Migrations/Server.Ethics.Player.v2.json b/Projects/UOContent/Migrations/Server.Ethics.Player.v2.json deleted file mode 100644 index 04f4a0199..000000000 --- a/Projects/UOContent/Migrations/Server.Ethics.Player.v2.json +++ /dev/null @@ -1,50 +0,0 @@ -{ - "version": 2, - "type": "Server.Ethics.Player", - "properties": [ - { - "name": "Mobile", - "type": "Server.Mobile", - "rule": "SerializableInterfaceMigrationRule" - }, - { - "name": "Power", - "type": "int", - "rule": "PrimitiveTypeMigrationRule", - "ruleArguments": [ - "" - ] - }, - { - "name": "History", - "type": "int", - "rule": "PrimitiveTypeMigrationRule", - "ruleArguments": [ - "" - ] - }, - { - "name": "Steed", - "type": "Server.Mobile", - "rule": "SerializableInterfaceMigrationRule" - }, - { - "name": "Familiar", - "type": "Server.Mobile", - "rule": "SerializableInterfaceMigrationRule" - }, - { - "name": "Shield", - "type": "System.DateTime", - "rule": "PrimitiveTypeMigrationRule", - "ruleArguments": [ - "AnchoredTime" - ] - }, - { - "name": "Ethic", - "type": "Server.Ethics.Ethic", - "rule": "SerializableInterfaceMigrationRule" - } - ] -} \ No newline at end of file diff --git a/Projects/UOContent/Migrations/Server.Items.BaseArmor.v10.json b/Projects/UOContent/Migrations/Server.Items.BaseArmor.v10.json deleted file mode 100644 index d7844e116..000000000 --- a/Projects/UOContent/Migrations/Server.Items.BaseArmor.v10.json +++ /dev/null @@ -1,207 +0,0 @@ -{ - "version": 10, - "type": "Server.Items.BaseArmor", - "properties": [ - { - "name": "Attributes", - "type": "Server.AosAttributes", - "usesSaveFlag": true, - "rule": "RawSerializableMigrationRule", - "ruleArguments": [ - "DeserializationRequiresParent" - ] - }, - { - "name": "ArmorAttributes", - "type": "Server.AosArmorAttributes", - "usesSaveFlag": true, - "rule": "RawSerializableMigrationRule", - "ruleArguments": [ - "DeserializationRequiresParent" - ] - }, - { - "name": "PhysicalBonus", - "type": "int", - "usesSaveFlag": true, - "rule": "PrimitiveTypeMigrationRule", - "ruleArguments": [ - "EncodedInt" - ] - }, - { - "name": "FireBonus", - "type": "int", - "usesSaveFlag": true, - "rule": "PrimitiveTypeMigrationRule", - "ruleArguments": [ - "EncodedInt" - ] - }, - { - "name": "ColdBonus", - "type": "int", - "usesSaveFlag": true, - "rule": "PrimitiveTypeMigrationRule", - "ruleArguments": [ - "EncodedInt" - ] - }, - { - "name": "PoisonBonus", - "type": "int", - "usesSaveFlag": true, - "rule": "PrimitiveTypeMigrationRule", - "ruleArguments": [ - "EncodedInt" - ] - }, - { - "name": "EnergyBonus", - "type": "int", - "usesSaveFlag": true, - "rule": "PrimitiveTypeMigrationRule", - "ruleArguments": [ - "EncodedInt" - ] - }, - { - "name": "Identified", - "type": "bool", - "usesSaveFlag": true, - "rule": "PrimitiveTypeMigrationRule", - "ruleArguments": [ - "" - ] - }, - { - "name": "MaxHitPoints", - "type": "int", - "usesSaveFlag": true, - "rule": "PrimitiveTypeMigrationRule", - "ruleArguments": [ - "EncodedInt" - ] - }, - { - "name": "HitPoints", - "type": "int", - "usesSaveFlag": true, - "rule": "PrimitiveTypeMigrationRule", - "ruleArguments": [ - "EncodedInt" - ] - }, - { - "name": "Crafter", - "type": "string", - "usesSaveFlag": true, - "rule": "PrimitiveTypeMigrationRule", - "ruleArguments": [ - "" - ] - }, - { - "name": "Quality", - "type": "Server.Items.ArmorQuality", - "usesSaveFlag": true, - "rule": "EnumMigrationRule" - }, - { - "name": "Durability", - "type": "Server.Items.ArmorDurabilityLevel", - "usesSaveFlag": true, - "rule": "EnumMigrationRule" - }, - { - "name": "ProtectionLevel", - "type": "Server.Items.ArmorProtectionLevel", - "usesSaveFlag": true, - "rule": "EnumMigrationRule" - }, - { - "name": "Resource", - "type": "Server.Items.CraftResource", - "usesSaveFlag": true, - "rule": "EnumMigrationRule" - }, - { - "name": "BaseArmorRating", - "type": "int", - "usesSaveFlag": true, - "rule": "PrimitiveTypeMigrationRule", - "ruleArguments": [ - "EncodedInt" - ] - }, - { - "name": "StrBonus", - "type": "int", - "usesSaveFlag": true, - "rule": "PrimitiveTypeMigrationRule", - "ruleArguments": [ - "EncodedInt" - ] - }, - { - "name": "DexBonus", - "type": "int", - "usesSaveFlag": true, - "rule": "PrimitiveTypeMigrationRule", - "ruleArguments": [ - "EncodedInt" - ] - }, - { - "name": "IntBonus", - "type": "int", - "usesSaveFlag": true, - "rule": "PrimitiveTypeMigrationRule", - "ruleArguments": [ - "EncodedInt" - ] - }, - { - "name": "StrRequirement", - "type": "int", - "usesSaveFlag": true, - "rule": "PrimitiveTypeMigrationRule", - "ruleArguments": [ - "EncodedInt" - ] - }, - { - "name": "DexRequirement", - "type": "int", - "usesSaveFlag": true, - "rule": "PrimitiveTypeMigrationRule", - "ruleArguments": [ - "EncodedInt" - ] - }, - { - "name": "IntRequirement", - "type": "int", - "usesSaveFlag": true, - "rule": "PrimitiveTypeMigrationRule", - "ruleArguments": [ - "EncodedInt" - ] - }, - { - "name": "MeditationAllowance", - "type": "Server.Items.ArmorMeditationAllowance", - "usesSaveFlag": true, - "rule": "EnumMigrationRule" - }, - { - "name": "SkillBonuses", - "type": "Server.AosSkillBonuses", - "usesSaveFlag": true, - "rule": "RawSerializableMigrationRule", - "ruleArguments": [ - "DeserializationRequiresParent" - ] - } - ] -} \ No newline at end of file diff --git a/Projects/UOContent/Migrations/Server.Items.BaseClothing.v8.json b/Projects/UOContent/Migrations/Server.Items.BaseClothing.v8.json deleted file mode 100644 index 09d5e07be..000000000 --- a/Projects/UOContent/Migrations/Server.Items.BaseClothing.v8.json +++ /dev/null @@ -1,90 +0,0 @@ -{ - "version": 8, - "type": "Server.Items.BaseClothing", - "properties": [ - { - "name": "Resource", - "type": "Server.Items.CraftResource", - "usesSaveFlag": true, - "rule": "EnumMigrationRule" - }, - { - "name": "Attributes", - "type": "Server.AosAttributes", - "usesSaveFlag": true, - "rule": "RawSerializableMigrationRule", - "ruleArguments": [ - "DeserializationRequiresParent" - ] - }, - { - "name": "ClothingAttributes", - "type": "Server.AosArmorAttributes", - "usesSaveFlag": true, - "rule": "RawSerializableMigrationRule", - "ruleArguments": [ - "DeserializationRequiresParent" - ] - }, - { - "name": "SkillBonuses", - "type": "Server.AosSkillBonuses", - "usesSaveFlag": true, - "rule": "RawSerializableMigrationRule", - "ruleArguments": [ - "DeserializationRequiresParent" - ] - }, - { - "name": "Resistances", - "type": "Server.AosElementAttributes", - "usesSaveFlag": true, - "rule": "RawSerializableMigrationRule", - "ruleArguments": [ - "DeserializationRequiresParent" - ] - }, - { - "name": "MaxHitPoints", - "type": "int", - "usesSaveFlag": true, - "rule": "PrimitiveTypeMigrationRule", - "ruleArguments": [ - "EncodedInt" - ] - }, - { - "name": "HitPoints", - "type": "int", - "usesSaveFlag": true, - "rule": "PrimitiveTypeMigrationRule", - "ruleArguments": [ - "EncodedInt" - ] - }, - { - "name": "Crafter", - "type": "string", - "usesSaveFlag": true, - "rule": "PrimitiveTypeMigrationRule", - "ruleArguments": [ - "" - ] - }, - { - "name": "Quality", - "type": "Server.Items.ClothingQuality", - "usesSaveFlag": true, - "rule": "EnumMigrationRule" - }, - { - "name": "StrRequirement", - "type": "int", - "usesSaveFlag": true, - "rule": "PrimitiveTypeMigrationRule", - "ruleArguments": [ - "" - ] - } - ] -} \ No newline at end of file diff --git a/Projects/UOContent/Migrations/Server.Items.BaseLight.v2.json b/Projects/UOContent/Migrations/Server.Items.BaseLight.v2.json deleted file mode 100644 index c04a03f3b..000000000 --- a/Projects/UOContent/Migrations/Server.Items.BaseLight.v2.json +++ /dev/null @@ -1,43 +0,0 @@ -{ - "version": 2, - "type": "Server.Items.BaseLight", - "properties": [ - { - "name": "BurntOut", - "type": "bool", - "rule": "PrimitiveTypeMigrationRule", - "ruleArguments": [ - "" - ] - }, - { - "name": "Burning", - "type": "bool", - "rule": "PrimitiveTypeMigrationRule", - "ruleArguments": [ - "" - ] - }, - { - "name": "Duration", - "type": "System.TimeSpan", - "rule": "PrimitiveTypeMigrationRule" - }, - { - "name": "Protected", - "type": "bool", - "rule": "PrimitiveTypeMigrationRule", - "ruleArguments": [ - "" - ] - }, - { - "name": "BurnTimer", - "type": "Server.Timer", - "rule": "TimerMigrationRule", - "ruleArguments": [ - "@AnchoredTimer" - ] - } - ] -} \ No newline at end of file diff --git a/Projects/UOContent/Migrations/Server.Items.BaseWeapon.v11.json b/Projects/UOContent/Migrations/Server.Items.BaseWeapon.v11.json deleted file mode 100644 index 009666433..000000000 --- a/Projects/UOContent/Migrations/Server.Items.BaseWeapon.v11.json +++ /dev/null @@ -1,246 +0,0 @@ -{ - "version": 11, - "type": "Server.Items.BaseWeapon", - "properties": [ - { - "name": "DamageLevel", - "type": "Server.Items.WeaponDamageLevel", - "usesSaveFlag": true, - "rule": "EnumMigrationRule" - }, - { - "name": "AccuracyLevel", - "type": "Server.Items.WeaponAccuracyLevel", - "usesSaveFlag": true, - "rule": "EnumMigrationRule" - }, - { - "name": "DurabilityLevel", - "type": "Server.Items.WeaponDurabilityLevel", - "usesSaveFlag": true, - "rule": "EnumMigrationRule" - }, - { - "name": "Quality", - "type": "Server.Items.WeaponQuality", - "usesSaveFlag": true, - "rule": "EnumMigrationRule" - }, - { - "name": "HitPoints", - "type": "int", - "usesSaveFlag": true, - "rule": "PrimitiveTypeMigrationRule", - "ruleArguments": [ - "" - ] - }, - { - "name": "MaxHitPoints", - "type": "int", - "usesSaveFlag": true, - "rule": "PrimitiveTypeMigrationRule", - "ruleArguments": [ - "" - ] - }, - { - "name": "Slayer", - "type": "Server.Items.SlayerName", - "usesSaveFlag": true, - "rule": "EnumMigrationRule" - }, - { - "name": "Poison", - "type": "Server.Poison", - "usesSaveFlag": true, - "rule": "PrimitiveUOTypeMigrationRule", - "ruleArguments": [ - "Poison" - ] - }, - { - "name": "PoisonCharges", - "type": "int", - "usesSaveFlag": true, - "rule": "PrimitiveTypeMigrationRule", - "ruleArguments": [ - "" - ] - }, - { - "name": "Crafter", - "type": "string", - "usesSaveFlag": true, - "rule": "PrimitiveTypeMigrationRule", - "ruleArguments": [ - "" - ] - }, - { - "name": "Identified", - "type": "bool", - "usesSaveFlag": true, - "rule": "PrimitiveTypeMigrationRule", - "ruleArguments": [ - "" - ] - }, - { - "name": "StrRequirement", - "type": "int", - "usesSaveFlag": true, - "rule": "PrimitiveTypeMigrationRule", - "ruleArguments": [ - "" - ] - }, - { - "name": "DexRequirement", - "type": "int", - "usesSaveFlag": true, - "rule": "PrimitiveTypeMigrationRule", - "ruleArguments": [ - "" - ] - }, - { - "name": "IntRequirement", - "type": "int", - "usesSaveFlag": true, - "rule": "PrimitiveTypeMigrationRule", - "ruleArguments": [ - "" - ] - }, - { - "name": "MinDamage", - "type": "int", - "usesSaveFlag": true, - "rule": "PrimitiveTypeMigrationRule", - "ruleArguments": [ - "" - ] - }, - { - "name": "MaxDamage", - "type": "int", - "usesSaveFlag": true, - "rule": "PrimitiveTypeMigrationRule", - "ruleArguments": [ - "" - ] - }, - { - "name": "HitSound", - "type": "int", - "usesSaveFlag": true, - "rule": "PrimitiveTypeMigrationRule", - "ruleArguments": [ - "" - ] - }, - { - "name": "MissSound", - "type": "int", - "usesSaveFlag": true, - "rule": "PrimitiveTypeMigrationRule", - "ruleArguments": [ - "" - ] - }, - { - "name": "Speed", - "type": "float", - "usesSaveFlag": true, - "rule": "PrimitiveTypeMigrationRule", - "ruleArguments": [ - "" - ] - }, - { - "name": "MaxRange", - "type": "int", - "usesSaveFlag": true, - "rule": "PrimitiveTypeMigrationRule", - "ruleArguments": [ - "" - ] - }, - { - "name": "Skill", - "type": "Server.SkillName", - "usesSaveFlag": true, - "rule": "EnumMigrationRule" - }, - { - "name": "Type", - "type": "Server.Items.WeaponType", - "usesSaveFlag": true, - "rule": "EnumMigrationRule" - }, - { - "name": "Animation", - "type": "Server.Items.WeaponAnimation", - "usesSaveFlag": true, - "rule": "EnumMigrationRule" - }, - { - "name": "Resource", - "type": "Server.Items.CraftResource", - "usesSaveFlag": true, - "rule": "EnumMigrationRule" - }, - { - "name": "Attributes", - "type": "Server.AosAttributes", - "usesSaveFlag": true, - "rule": "RawSerializableMigrationRule", - "ruleArguments": [ - "DeserializationRequiresParent" - ] - }, - { - "name": "WeaponAttributes", - "type": "Server.AosWeaponAttributes", - "usesSaveFlag": true, - "rule": "RawSerializableMigrationRule", - "ruleArguments": [ - "DeserializationRequiresParent" - ] - }, - { - "name": "SkillBonuses", - "type": "Server.AosSkillBonuses", - "usesSaveFlag": true, - "rule": "RawSerializableMigrationRule", - "ruleArguments": [ - "DeserializationRequiresParent" - ] - }, - { - "name": "Slayer2", - "type": "Server.Items.SlayerName", - "usesSaveFlag": true, - "rule": "EnumMigrationRule" - }, - { - "name": "AosElementDamages", - "type": "Server.AosElementAttributes", - "usesSaveFlag": true, - "rule": "RawSerializableMigrationRule", - "ruleArguments": [ - "DeserializationRequiresParent" - ] - }, - { - "name": "EngravedText", - "type": "string", - "usesSaveFlag": true, - "rule": "PrimitiveTypeMigrationRule", - "ruleArguments": [ - "" - ] - } - ] -} \ No newline at end of file diff --git a/Projects/UOContent/Migrations/Server.Items.Corpse.v18.json b/Projects/UOContent/Migrations/Server.Items.Corpse.v18.json deleted file mode 100644 index 452e3ba7c..000000000 --- a/Projects/UOContent/Migrations/Server.Items.Corpse.v18.json +++ /dev/null @@ -1,137 +0,0 @@ -{ - "version": 18, - "type": "Server.Items.Corpse", - "properties": [ - { - "name": "RestoreEquip", - "type": "System.Collections.Generic.List\u003CServer.Item\u003E", - "rule": "ListMigrationRule", - "ruleArguments": [ - "Server.Item", - "SerializableInterfaceMigrationRule" - ] - }, - { - "name": "Flags", - "type": "Server.Items.CorpseFlag", - "rule": "EnumMigrationRule" - }, - { - "name": "TimeOfDeath", - "type": "System.DateTime", - "rule": "PrimitiveTypeMigrationRule", - "ruleArguments": [ - "DeltaTime" - ] - }, - { - "name": "RestoreTable", - "type": "System.Collections.Generic.Dictionary\u003CServer.Item, Server.Point3D\u003E", - "rule": "DictionaryMigrationRule", - "ruleArguments": [ - "Server.Item", - "SerializableInterfaceMigrationRule", - "0", - "Server.Point3D", - "PrimitiveUOTypeMigrationRule", - "1", - "Point3D" - ] - }, - { - "name": "DecayTimer", - "type": "Server.Timer", - "rule": "TimerMigrationRule", - "ruleArguments": [ - "@AnchoredTimer" - ] - }, - { - "name": "Looters", - "type": "System.Collections.Generic.HashSet\u003CServer.Mobile\u003E", - "rule": "HashSetMigrationRule", - "ruleArguments": [ - "Server.Mobile", - "SerializableInterfaceMigrationRule" - ] - }, - { - "name": "Killer", - "type": "Server.Mobile", - "rule": "SerializableInterfaceMigrationRule" - }, - { - "name": "Aggressors", - "type": "System.Collections.Generic.List\u003CServer.Mobile\u003E", - "rule": "ListMigrationRule", - "ruleArguments": [ - "Server.Mobile", - "SerializableInterfaceMigrationRule" - ] - }, - { - "name": "Owner", - "type": "Server.Mobile", - "rule": "SerializableInterfaceMigrationRule" - }, - { - "name": "CorpseName", - "type": "string", - "rule": "PrimitiveTypeMigrationRule", - "ruleArguments": [ - "" - ] - }, - { - "name": "AccessLevel", - "type": "Server.AccessLevel", - "rule": "EnumMigrationRule" - }, - { - "name": "Guild", - "type": "Server.Guilds.Guild", - "rule": "SerializableInterfaceMigrationRule" - }, - { - "name": "EquipItems", - "type": "System.Collections.Generic.List\u003CServer.Item\u003E", - "rule": "ListMigrationRule", - "ruleArguments": [ - "Server.Item", - "SerializableInterfaceMigrationRule" - ] - }, - { - "name": "HairItemId", - "type": "int", - "rule": "PrimitiveTypeMigrationRule", - "ruleArguments": [ - "" - ] - }, - { - "name": "HairHue", - "type": "int", - "rule": "PrimitiveTypeMigrationRule", - "ruleArguments": [ - "" - ] - }, - { - "name": "FacialHairItemId", - "type": "int", - "rule": "PrimitiveTypeMigrationRule", - "ruleArguments": [ - "" - ] - }, - { - "name": "FacialHairHue", - "type": "int", - "rule": "PrimitiveTypeMigrationRule", - "ruleArguments": [ - "" - ] - } - ] -} \ No newline at end of file diff --git a/Projects/UOContent/Migrations/Server.Items.Corpse.v19.json b/Projects/UOContent/Migrations/Server.Items.Corpse.v19.json deleted file mode 100644 index 2cccb267a..000000000 --- a/Projects/UOContent/Migrations/Server.Items.Corpse.v19.json +++ /dev/null @@ -1,137 +0,0 @@ -{ - "version": 19, - "type": "Server.Items.Corpse", - "properties": [ - { - "name": "RestoreEquip", - "type": "System.Collections.Generic.List\u003CServer.Item\u003E", - "rule": "ListMigrationRule", - "ruleArguments": [ - "Server.Item", - "SerializableInterfaceMigrationRule" - ] - }, - { - "name": "Flags", - "type": "Server.Items.CorpseFlag", - "rule": "EnumMigrationRule" - }, - { - "name": "TimeOfDeath", - "type": "System.DateTime", - "rule": "PrimitiveTypeMigrationRule", - "ruleArguments": [ - "AnchoredTime" - ] - }, - { - "name": "RestoreTable", - "type": "System.Collections.Generic.Dictionary\u003CServer.Item, Server.Point3D\u003E", - "rule": "DictionaryMigrationRule", - "ruleArguments": [ - "Server.Item", - "SerializableInterfaceMigrationRule", - "0", - "Server.Point3D", - "PrimitiveUOTypeMigrationRule", - "1", - "Point3D" - ] - }, - { - "name": "DecayTimer", - "type": "Server.Timer", - "rule": "TimerMigrationRule", - "ruleArguments": [ - "@AnchoredTimer" - ] - }, - { - "name": "Looters", - "type": "System.Collections.Generic.HashSet\u003CServer.Mobile\u003E", - "rule": "HashSetMigrationRule", - "ruleArguments": [ - "Server.Mobile", - "SerializableInterfaceMigrationRule" - ] - }, - { - "name": "Killer", - "type": "Server.Mobile", - "rule": "SerializableInterfaceMigrationRule" - }, - { - "name": "Aggressors", - "type": "System.Collections.Generic.List\u003CServer.Mobile\u003E", - "rule": "ListMigrationRule", - "ruleArguments": [ - "Server.Mobile", - "SerializableInterfaceMigrationRule" - ] - }, - { - "name": "Owner", - "type": "Server.Mobile", - "rule": "SerializableInterfaceMigrationRule" - }, - { - "name": "CorpseName", - "type": "string", - "rule": "PrimitiveTypeMigrationRule", - "ruleArguments": [ - "" - ] - }, - { - "name": "AccessLevel", - "type": "Server.AccessLevel", - "rule": "EnumMigrationRule" - }, - { - "name": "Guild", - "type": "Server.Guilds.Guild", - "rule": "SerializableInterfaceMigrationRule" - }, - { - "name": "EquipItems", - "type": "System.Collections.Generic.List\u003CServer.Item\u003E", - "rule": "ListMigrationRule", - "ruleArguments": [ - "Server.Item", - "SerializableInterfaceMigrationRule" - ] - }, - { - "name": "HairItemId", - "type": "int", - "rule": "PrimitiveTypeMigrationRule", - "ruleArguments": [ - "" - ] - }, - { - "name": "HairHue", - "type": "int", - "rule": "PrimitiveTypeMigrationRule", - "ruleArguments": [ - "" - ] - }, - { - "name": "FacialHairItemId", - "type": "int", - "rule": "PrimitiveTypeMigrationRule", - "ruleArguments": [ - "" - ] - }, - { - "name": "FacialHairHue", - "type": "int", - "rule": "PrimitiveTypeMigrationRule", - "ruleArguments": [ - "" - ] - } - ] -} \ No newline at end of file diff --git a/Projects/UOContent/Migrations/Server.Items.DeathRobe.v4.json b/Projects/UOContent/Migrations/Server.Items.DeathRobe.v4.json deleted file mode 100644 index ef81d79cd..000000000 --- a/Projects/UOContent/Migrations/Server.Items.DeathRobe.v4.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "version": 4, - "type": "Server.Items.DeathRobe", - "properties": [ - { - "name": "DecayTimer", - "type": "Server.Timer", - "rule": "TimerMigrationRule", - "ruleArguments": [ - "@AnchoredTimer" - ] - } - ] -} \ No newline at end of file diff --git a/Projects/UOContent/Migrations/Server.Items.DecayedCorpse.v3.json b/Projects/UOContent/Migrations/Server.Items.DecayedCorpse.v3.json deleted file mode 100644 index adcf02ff6..000000000 --- a/Projects/UOContent/Migrations/Server.Items.DecayedCorpse.v3.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "version": 3, - "type": "Server.Items.DecayedCorpse", - "properties": [ - { - "name": "DecayTimer", - "type": "Server.Timer", - "rule": "TimerMigrationRule", - "ruleArguments": [ - "@AnchoredTimer" - ] - } - ] -} \ No newline at end of file diff --git a/Projects/UOContent/Migrations/Server.Items.FillableContainer.v3.json b/Projects/UOContent/Migrations/Server.Items.FillableContainer.v3.json deleted file mode 100644 index 2384676a3..000000000 --- a/Projects/UOContent/Migrations/Server.Items.FillableContainer.v3.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "version": 3, - "type": "Server.Items.FillableContainer", - "properties": [ - { - "name": "ContentType", - "type": "Server.Items.FillableContentType", - "rule": "EnumMigrationRule" - }, - { - "name": "RespawnTimer", - "type": "Server.Timer", - "rule": "TimerMigrationRule", - "ruleArguments": [ - "@AnchoredTimer" - ] - } - ] -} \ No newline at end of file diff --git a/Projects/UOContent/Migrations/Server.Items.MarkContainer.v1.json b/Projects/UOContent/Migrations/Server.Items.MarkContainer.v1.json deleted file mode 100644 index 267ca4709..000000000 --- a/Projects/UOContent/Migrations/Server.Items.MarkContainer.v1.json +++ /dev/null @@ -1,46 +0,0 @@ -{ - "version": 1, - "type": "Server.Items.MarkContainer", - "properties": [ - { - "name": "AutoLock", - "type": "bool", - "rule": "PrimitiveTypeMigrationRule", - "ruleArguments": [ - "" - ] - }, - { - "name": "RelockTimer", - "type": "Server.Items.MarkContainer.InternalTimer", - "rule": "TimerMigrationRule", - "ruleArguments": [ - "@AnchoredTimer" - ] - }, - { - "name": "TargetMap", - "type": "Server.Map", - "rule": "PrimitiveUOTypeMigrationRule", - "ruleArguments": [ - "Map" - ] - }, - { - "name": "Target", - "type": "Server.Point3D", - "rule": "PrimitiveUOTypeMigrationRule", - "ruleArguments": [ - "Point3D" - ] - }, - { - "name": "Description", - "type": "string", - "rule": "PrimitiveTypeMigrationRule", - "ruleArguments": [ - "" - ] - } - ] -} \ No newline at end of file diff --git a/Projects/UOContent/Migrations/Server.Items.PuzzleChestSolutionAndTime.v1.json b/Projects/UOContent/Migrations/Server.Items.PuzzleChestSolutionAndTime.v1.json deleted file mode 100644 index 8424276f6..000000000 --- a/Projects/UOContent/Migrations/Server.Items.PuzzleChestSolutionAndTime.v1.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "version": 1, - "type": "Server.Items.PuzzleChestSolutionAndTime", - "properties": [ - { - "name": "When", - "type": "System.DateTime", - "rule": "PrimitiveTypeMigrationRule", - "ruleArguments": [ - "AnchoredTime" - ] - } - ] -} \ No newline at end of file diff --git a/Projects/UOContent/Migrations/Server.Items.StarRoomGate.v2.json b/Projects/UOContent/Migrations/Server.Items.StarRoomGate.v2.json deleted file mode 100644 index 0a61f7bfe..000000000 --- a/Projects/UOContent/Migrations/Server.Items.StarRoomGate.v2.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "version": 2, - "type": "Server.Items.StarRoomGate", - "properties": [ - { - "name": "Decays", - "type": "bool", - "rule": "PrimitiveTypeMigrationRule", - "ruleArguments": [ - "" - ] - }, - { - "name": "DecayTime", - "type": "System.DateTime", - "rule": "PrimitiveTypeMigrationRule", - "ruleArguments": [ - "AnchoredTime" - ] - } - ] -} \ No newline at end of file diff --git a/Projects/UOContent/Migrations/Server.Items.TransientItem.v2.json b/Projects/UOContent/Migrations/Server.Items.TransientItem.v2.json deleted file mode 100644 index 3745c4428..000000000 --- a/Projects/UOContent/Migrations/Server.Items.TransientItem.v2.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "version": 2, - "type": "Server.Items.TransientItem", - "properties": [ - { - "name": "Expiration", - "type": "System.DateTime", - "rule": "PrimitiveTypeMigrationRule", - "ruleArguments": [ - "AnchoredTime" - ] - } - ] -} \ No newline at end of file diff --git a/Projects/UOContent/Migrations/Server.Items.TreasureMapChest.v3.json b/Projects/UOContent/Migrations/Server.Items.TreasureMapChest.v3.json deleted file mode 100644 index 805bbb6d1..000000000 --- a/Projects/UOContent/Migrations/Server.Items.TreasureMapChest.v3.json +++ /dev/null @@ -1,57 +0,0 @@ -{ - "version": 3, - "type": "Server.Items.TreasureMapChest", - "properties": [ - { - "name": "Guardians", - "type": "System.Collections.Generic.List\u003CServer.Mobile\u003E", - "rule": "ListMigrationRule", - "ruleArguments": [ - "@Tidy", - "@CanBeNull", - "Server.Mobile", - "SerializableInterfaceMigrationRule" - ] - }, - { - "name": "Temporary", - "type": "bool", - "rule": "PrimitiveTypeMigrationRule", - "ruleArguments": [ - "" - ] - }, - { - "name": "Owner", - "type": "Server.Mobile", - "rule": "SerializableInterfaceMigrationRule" - }, - { - "name": "Level", - "type": "int", - "rule": "PrimitiveTypeMigrationRule", - "ruleArguments": [ - "" - ] - }, - { - "name": "ExpireTimer", - "type": "Server.Timer", - "rule": "TimerMigrationRule", - "ruleArguments": [ - "@TimerDrift" - ] - }, - { - "name": "Lifted", - "type": "System.Collections.Generic.HashSet\u003CServer.Item\u003E", - "rule": "HashSetMigrationRule", - "ruleArguments": [ - "@Tidy", - "@CanBeNull", - "Server.Item", - "SerializableInterfaceMigrationRule" - ] - } - ] -} \ No newline at end of file diff --git a/Projects/UOContent/Migrations/Server.Items.TreasureMapChest.v4.json b/Projects/UOContent/Migrations/Server.Items.TreasureMapChest.v4.json deleted file mode 100644 index b4d1f85d4..000000000 --- a/Projects/UOContent/Migrations/Server.Items.TreasureMapChest.v4.json +++ /dev/null @@ -1,57 +0,0 @@ -{ - "version": 4, - "type": "Server.Items.TreasureMapChest", - "properties": [ - { - "name": "Guardians", - "type": "System.Collections.Generic.List\u003CServer.Mobile\u003E", - "rule": "ListMigrationRule", - "ruleArguments": [ - "@Tidy", - "@CanBeNull", - "Server.Mobile", - "SerializableInterfaceMigrationRule" - ] - }, - { - "name": "Temporary", - "type": "bool", - "rule": "PrimitiveTypeMigrationRule", - "ruleArguments": [ - "" - ] - }, - { - "name": "Owner", - "type": "Server.Mobile", - "rule": "SerializableInterfaceMigrationRule" - }, - { - "name": "Level", - "type": "int", - "rule": "PrimitiveTypeMigrationRule", - "ruleArguments": [ - "" - ] - }, - { - "name": "ExpireTimer", - "type": "Server.Timer", - "rule": "TimerMigrationRule", - "ruleArguments": [ - "@AnchoredTimer" - ] - }, - { - "name": "Lifted", - "type": "System.Collections.Generic.HashSet\u003CServer.Item\u003E", - "rule": "HashSetMigrationRule", - "ruleArguments": [ - "@Tidy", - "@CanBeNull", - "Server.Item", - "SerializableInterfaceMigrationRule" - ] - } - ] -} \ No newline at end of file diff --git a/Projects/UOContent/Migrations/Server.Mobiles.BaseEscortable.v3.json b/Projects/UOContent/Migrations/Server.Mobiles.BaseEscortable.v3.json deleted file mode 100644 index 5149f4347..000000000 --- a/Projects/UOContent/Migrations/Server.Mobiles.BaseEscortable.v3.json +++ /dev/null @@ -1,43 +0,0 @@ -{ - "version": 3, - "type": "Server.Mobiles.BaseEscortable", - "properties": [ - { - "name": "DestinationString", - "type": "string", - "rule": "PrimitiveTypeMigrationRule", - "ruleArguments": [ - "" - ] - }, - { - "name": "DeleteTimer", - "type": "Server.Timer", - "rule": "TimerMigrationRule", - "ruleArguments": [ - "@AnchoredTimer" - ] - }, - { - "name": "MlQuestType", - "type": "System.Type", - "rule": "PrimitiveTypeMigrationRule" - }, - { - "name": "MlQuestDestinationMessage", - "type": "Server.TextDefinition", - "rule": "PrimitiveUOTypeMigrationRule", - "ruleArguments": [ - "TextDefinition" - ] - }, - { - "name": "MlQuestPaymentMessage", - "type": "Server.TextDefinition", - "rule": "PrimitiveUOTypeMigrationRule", - "ruleArguments": [ - "TextDefinition" - ] - } - ] -} \ No newline at end of file diff --git a/Projects/UOContent/Migrations/Server.Mobiles.PlayerVendor.v4.json b/Projects/UOContent/Migrations/Server.Mobiles.PlayerVendor.v4.json deleted file mode 100644 index 6b9c9eb1c..000000000 --- a/Projects/UOContent/Migrations/Server.Mobiles.PlayerVendor.v4.json +++ /dev/null @@ -1,62 +0,0 @@ -{ - "version": 4, - "type": "Server.Mobiles.PlayerVendor", - "properties": [ - { - "name": "ShopName", - "type": "string", - "rule": "PrimitiveTypeMigrationRule", - "ruleArguments": [ - "" - ] - }, - { - "name": "NextPayTime", - "type": "System.DateTime", - "rule": "PrimitiveTypeMigrationRule", - "ruleArguments": [ - "AnchoredTime" - ] - }, - { - "name": "House", - "type": "Server.Multis.BaseHouse", - "rule": "SerializableInterfaceMigrationRule" - }, - { - "name": "Owner", - "type": "Server.Mobile", - "rule": "SerializableInterfaceMigrationRule" - }, - { - "name": "BankAccount", - "type": "int", - "rule": "PrimitiveTypeMigrationRule", - "ruleArguments": [ - "" - ] - }, - { - "name": "HoldGold", - "type": "int", - "rule": "PrimitiveTypeMigrationRule", - "ruleArguments": [ - "" - ] - }, - { - "name": "SellItems", - "type": "System.Collections.Generic.Dictionary\u003CServer.Item, Server.Mobiles.VendorItem\u003E", - "rule": "DictionaryMigrationRule", - "ruleArguments": [ - "Server.Item", - "SerializableInterfaceMigrationRule", - "0", - "Server.Mobiles.VendorItem", - "RawSerializableMigrationRule", - "1", - "" - ] - } - ] -} \ No newline at end of file diff --git a/Projects/UOContent/Migrations/Server.Mobiles.RentedVendor.v1.json b/Projects/UOContent/Migrations/Server.Mobiles.RentedVendor.v1.json deleted file mode 100644 index b42fa33eb..000000000 --- a/Projects/UOContent/Migrations/Server.Mobiles.RentedVendor.v1.json +++ /dev/null @@ -1,62 +0,0 @@ -{ - "version": 1, - "type": "Server.Mobiles.RentedVendor", - "properties": [ - { - "name": "RentalDurationId", - "type": "int", - "rule": "PrimitiveTypeMigrationRule", - "ruleArguments": [ - "" - ] - }, - { - "name": "RentalPrice", - "type": "int", - "rule": "PrimitiveTypeMigrationRule", - "ruleArguments": [ - "" - ] - }, - { - "name": "LandlordRenew", - "type": "bool", - "rule": "PrimitiveTypeMigrationRule", - "ruleArguments": [ - "" - ] - }, - { - "name": "RenterRenew", - "type": "bool", - "rule": "PrimitiveTypeMigrationRule", - "ruleArguments": [ - "" - ] - }, - { - "name": "RenewalPrice", - "type": "int", - "rule": "PrimitiveTypeMigrationRule", - "ruleArguments": [ - "" - ] - }, - { - "name": "RentalGold", - "type": "int", - "rule": "PrimitiveTypeMigrationRule", - "ruleArguments": [ - "" - ] - }, - { - "name": "RentalExpireTime", - "type": "System.DateTime", - "rule": "PrimitiveTypeMigrationRule", - "ruleArguments": [ - "AnchoredTime" - ] - } - ] -} \ No newline at end of file diff --git a/Projects/UOContent/Migrations/Server.Mobiles.Sheep.v1.json b/Projects/UOContent/Migrations/Server.Mobiles.Sheep.v1.json deleted file mode 100644 index bd224f77f..000000000 --- a/Projects/UOContent/Migrations/Server.Mobiles.Sheep.v1.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "version": 1, - "type": "Server.Mobiles.Sheep", - "properties": [ - { - "name": "NextWoolTime", - "type": "System.DateTime", - "rule": "PrimitiveTypeMigrationRule", - "ruleArguments": [ - "AnchoredTime" - ] - } - ] -} \ No newline at end of file diff --git a/Projects/UOContent/Migrations/Server.Multis.BaseBoat.v5.json b/Projects/UOContent/Migrations/Server.Multis.BaseBoat.v5.json deleted file mode 100644 index 140d3dd64..000000000 --- a/Projects/UOContent/Migrations/Server.Multis.BaseBoat.v5.json +++ /dev/null @@ -1,73 +0,0 @@ -{ - "version": 5, - "type": "Server.Multis.BaseBoat", - "properties": [ - { - "name": "MapItem", - "type": "Server.Items.MapItem", - "rule": "SerializableInterfaceMigrationRule" - }, - { - "name": "NextNavPoint", - "type": "int", - "rule": "PrimitiveTypeMigrationRule", - "ruleArguments": [ - "" - ] - }, - { - "name": "Facing", - "type": "Server.Direction", - "rule": "EnumMigrationRule" - }, - { - "name": "TimeOfDecay", - "type": "System.DateTime", - "rule": "PrimitiveTypeMigrationRule", - "ruleArguments": [ - "AnchoredTime" - ] - }, - { - "name": "Owner", - "type": "Server.Mobile", - "rule": "SerializableInterfaceMigrationRule" - }, - { - "name": "PPlank", - "type": "Server.Items.Plank", - "rule": "SerializableInterfaceMigrationRule" - }, - { - "name": "SPlank", - "type": "Server.Items.Plank", - "rule": "SerializableInterfaceMigrationRule" - }, - { - "name": "TillerMan", - "type": "Server.Items.TillerMan", - "rule": "SerializableInterfaceMigrationRule" - }, - { - "name": "Hold", - "type": "Server.Items.Hold", - "rule": "SerializableInterfaceMigrationRule" - }, - { - "name": "Anchored", - "type": "bool", - "rule": "PrimitiveTypeMigrationRule", - "ruleArguments": [ - "" - ] - }, - { - "name": "ShipName", - "type": "string", - "rule": "PrimitiveTypeMigrationRule", - "ruleArguments": [ - "" - ] - } - ] -} \ No newline at end of file diff --git a/Projects/UOContent/Migrations/Server.Multis.BaseCamp.v2.json b/Projects/UOContent/Migrations/Server.Multis.BaseCamp.v2.json deleted file mode 100644 index 471c645f1..000000000 --- a/Projects/UOContent/Migrations/Server.Multis.BaseCamp.v2.json +++ /dev/null @@ -1,34 +0,0 @@ -{ - "version": 2, - "type": "Server.Multis.BaseCamp", - "properties": [ - { - "name": "Items", - "type": "System.Collections.Generic.List\u003CServer.Item\u003E", - "rule": "ListMigrationRule", - "ruleArguments": [ - "@Tidy", - "Server.Item", - "SerializableInterfaceMigrationRule" - ] - }, - { - "name": "Mobiles", - "type": "System.Collections.Generic.List\u003CServer.Mobile\u003E", - "rule": "ListMigrationRule", - "ruleArguments": [ - "@Tidy", - "Server.Mobile", - "SerializableInterfaceMigrationRule" - ] - }, - { - "name": "DecayTime", - "type": "System.DateTime", - "rule": "PrimitiveTypeMigrationRule", - "ruleArguments": [ - "AnchoredTime" - ] - } - ] -} \ No newline at end of file diff --git a/Projects/UOContent/Migrations/Server.Spells.Fifth.PoisonField.v1.json b/Projects/UOContent/Migrations/Server.Spells.Fifth.PoisonField.v1.json deleted file mode 100644 index 1f16a4d7f..000000000 --- a/Projects/UOContent/Migrations/Server.Spells.Fifth.PoisonField.v1.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "version": 1, - "type": "Server.Spells.Fifth.PoisonField", - "properties": [ - { - "name": "Caster", - "type": "Server.Mobile", - "rule": "SerializableInterfaceMigrationRule" - }, - { - "name": "End", - "type": "System.DateTime", - "rule": "PrimitiveTypeMigrationRule", - "ruleArguments": [ - "AnchoredTime" - ] - } - ] -} \ No newline at end of file diff --git a/Projects/UOContent/Migrations/Server.Spells.Fourth.FireFieldItem.v1.json b/Projects/UOContent/Migrations/Server.Spells.Fourth.FireFieldItem.v1.json deleted file mode 100644 index 3e91c7a4d..000000000 --- a/Projects/UOContent/Migrations/Server.Spells.Fourth.FireFieldItem.v1.json +++ /dev/null @@ -1,27 +0,0 @@ -{ - "version": 1, - "type": "Server.Spells.Fourth.FireFieldItem", - "properties": [ - { - "name": "Damage", - "type": "int", - "rule": "PrimitiveTypeMigrationRule", - "ruleArguments": [ - "" - ] - }, - { - "name": "Caster", - "type": "Server.Mobile", - "rule": "SerializableInterfaceMigrationRule" - }, - { - "name": "End", - "type": "System.DateTime", - "rule": "PrimitiveTypeMigrationRule", - "ruleArguments": [ - "AnchoredTime" - ] - } - ] -} \ No newline at end of file diff --git a/Projects/UOContent/Migrations/Server.Spells.Seventh.EnergyField.v2.json b/Projects/UOContent/Migrations/Server.Spells.Seventh.EnergyField.v2.json deleted file mode 100644 index dd202da03..000000000 --- a/Projects/UOContent/Migrations/Server.Spells.Seventh.EnergyField.v2.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "version": 2, - "type": "Server.Spells.Seventh.EnergyField", - "properties": [ - { - "name": "Caster", - "type": "Server.Mobile", - "rule": "SerializableInterfaceMigrationRule" - }, - { - "name": "End", - "type": "System.DateTime", - "rule": "PrimitiveTypeMigrationRule", - "ruleArguments": [ - "AnchoredTime" - ] - } - ] -} \ No newline at end of file diff --git a/Projects/UOContent/Migrations/Server.Spells.Sixth.ParalyzeField.v1.json b/Projects/UOContent/Migrations/Server.Spells.Sixth.ParalyzeField.v1.json deleted file mode 100644 index 3e834a6b1..000000000 --- a/Projects/UOContent/Migrations/Server.Spells.Sixth.ParalyzeField.v1.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "version": 1, - "type": "Server.Spells.Sixth.ParalyzeField", - "properties": [ - { - "name": "Caster", - "type": "Server.Mobile", - "rule": "SerializableInterfaceMigrationRule" - }, - { - "name": "End", - "type": "System.DateTime", - "rule": "PrimitiveTypeMigrationRule", - "ruleArguments": [ - "AnchoredTime" - ] - } - ] -} \ No newline at end of file diff --git a/Projects/UOContent/Migrations/Server.Spells.Third.WallOfStone.v1.json b/Projects/UOContent/Migrations/Server.Spells.Third.WallOfStone.v1.json deleted file mode 100644 index eafcc5945..000000000 --- a/Projects/UOContent/Migrations/Server.Spells.Third.WallOfStone.v1.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "version": 1, - "type": "Server.Spells.Third.WallOfStone", - "properties": [ - { - "name": "Caster", - "type": "Server.Mobile", - "rule": "SerializableInterfaceMigrationRule" - }, - { - "name": "End", - "type": "System.DateTime", - "rule": "PrimitiveTypeMigrationRule", - "ruleArguments": [ - "AnchoredTime" - ] - } - ] -} \ No newline at end of file diff --git a/Projects/UOContent/Misc/ShardPoller.cs b/Projects/UOContent/Misc/ShardPoller.cs index 8516e6678..762a0be9c 100644 --- a/Projects/UOContent/Misc/ShardPoller.cs +++ b/Projects/UOContent/Misc/ShardPoller.cs @@ -36,14 +36,16 @@ public partial class ShardPoller : Item Movable = false; } - [SerializableField(0, allowFieldChange: nameof(AllowTitleChange))] - [SerializedCommandProperty(AccessLevel.GameMaster, AccessLevel.Administrator)] - private string _title; - - private bool AllowTitleChange(ref string value) + [SerializableProperty(0)] + [CommandProperty(AccessLevel.GameMaster, AccessLevel.Administrator)] + public string Title { - value = ShardPollPrompt.UrlToHref(value); - return true; + get => _title; + set + { + _title = ShardPollPrompt.UrlToHref(value); + this.MarkDirty(); + } } [CommandProperty(AccessLevel.GameMaster, AccessLevel.Administrator)] @@ -52,20 +54,31 @@ public partial class ShardPoller : Item ? TimeSpan.Zero : Utility.Max(StartTime + Duration - Core.Now, TimeSpan.Zero); - [SerializableField(3, fieldChanged: nameof(OnActiveChanged))] - [SerializedCommandProperty(AccessLevel.GameMaster, AccessLevel.Administrator)] - private bool _active; - - private void OnActiveChanged(bool oldValue, bool newValue) + [SerializableProperty(3)] + [CommandProperty(AccessLevel.GameMaster, AccessLevel.Administrator)] + public bool Active { - if (_active) + get => _active; + set { - StartTime = Core.Now; - _activePollers.Add(this); - } - else - { - _activePollers.Remove(this); + if (_active == value) + { + return; + } + + _active = value; + + if (_active) + { + StartTime = Core.Now; + _activePollers.Add(this); + } + else + { + _activePollers.Remove(this); + } + + this.MarkDirty(); } } @@ -237,12 +250,15 @@ public partial class ShardPollOption } } - [SerializableField(0, fieldChanged: nameof(OnTitleChanged))] - private string _title; - - private void OnTitleChanged(string oldValue, string newValue) + [SerializableProperty(0)] + public string Title { - _lineBreaks = -1; + get => _title; + set + { + _title = value; + _lineBreaks = -1; + } } public int Votes => Voters.Length; diff --git a/Projects/UOContent/Misc/StaminaSystem.cs b/Projects/UOContent/Misc/StaminaSystem.cs index fcd5d46b0..ed8074cba 100644 --- a/Projects/UOContent/Misc/StaminaSystem.cs +++ b/Projects/UOContent/Misc/StaminaSystem.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using System.Runtime.CompilerServices; using System.Runtime.InteropServices; using ModernUO.CodeGeneratedEvents; +using Server.Collections; using Server.Logging; using Server.Mobiles; using Server.Spells.Ninjitsu; @@ -59,16 +60,22 @@ public static class StaminaSystem EventSink.Logout += Logout; // Credit idle time + using var queue = PooledRefQueue.Create(); foreach (var m in _stepsTaken.Keys) { - // Keeps the ref valid for the check below. + // We cannot remove since we are iterating. ref var stepsTaken = ref RegenSteps(m, out var exists, removeOnInvalidation: false); if (exists && stepsTaken.Steps <= 0) { - _stepsTaken.Remove(m); + queue.Enqueue(m); } } + + while (queue.Count > 0) + { + _stepsTaken.Remove(queue.Dequeue()); + } } [OnEvent(nameof(PlayerMobile.PlayerDeletedEvent))] @@ -318,7 +325,7 @@ public static class StaminaSystem { var from = e.Mobile; var running = (e.Direction & Direction.Running) != 0; - + if (CannotWalkWhenFatigued && from.Stam <= 0) { from.SendLocalizedMessage(500110); // You are too fatigued to move. @@ -474,13 +481,27 @@ public static class StaminaSystem if (_resetHash.Count > 0) { + using var queue = PooledRefQueue.Create(); + ref var stepsTaken = ref Unsafe.NullRef(); foreach (var m in _resetHash) { stepsTaken = ref GetStepsTaken(m, out var exists); if (!exists || Core.Now >= stepsTaken.IdleStartTime + ResetDuration) { - _resetHash.Remove(m); + queue.Enqueue(m); + } + } + + if (_resetHash.Count == queue.Count) + { + _resetHash.Clear(); + } + else + { + while (queue.Count > 0) + { + _resetHash.Remove(queue.Dequeue()); } } } diff --git a/Projects/UOContent/Mobiles/AI/AnimalAI.cs b/Projects/UOContent/Mobiles/AI/AnimalAI.cs index e5a1670e3..a9d410483 100644 --- a/Projects/UOContent/Mobiles/AI/AnimalAI.cs +++ b/Projects/UOContent/Mobiles/AI/AnimalAI.cs @@ -38,7 +38,7 @@ public class AnimalAI : BaseAI return true; } - if (!WalkMobileRange(combatant, 1, Mobile.RangeFight, Mobile.RangeFight)) + if (!WalkMobileRange(combatant, 1, false, Mobile.RangeFight, Mobile.RangeFight)) { if (Mobile.GetDistanceToSqrt(combatant) > Mobile.RangePerception + 1) { diff --git a/Projects/UOContent/Mobiles/AI/ArcherAI.cs b/Projects/UOContent/Mobiles/AI/ArcherAI.cs index 803587ceb..dd461a03f 100644 --- a/Projects/UOContent/Mobiles/AI/ArcherAI.cs +++ b/Projects/UOContent/Mobiles/AI/ArcherAI.cs @@ -17,7 +17,7 @@ public class ArcherAI : BaseAI if (AcquireFocusMob(Mobile.RangePerception, Mobile.FightMode, false, false, true)) { - this.DebugSayFormatted($"I have detected {Mobile.FocusMob.Name}, attacking"); + this.DebugSayFormatted($"I have detected {Mobile.FocusMob.Name} and I will attack"); Mobile.Combatant = Mobile.FocusMob; Action = ActionType.Combat; @@ -43,11 +43,11 @@ public class ArcherAI : BaseAI return true; } - if (!WalkMobileRange(combatant, 1, Mobile.RangeFight, Mobile.Weapon.MaxRange)) + if (!WalkMobileRange(combatant, 1, false, Mobile.RangeFight, Mobile.Weapon.MaxRange)) { this.DebugSayFormatted($"I am still not in range of {combatant.Name}"); - if (!Mobile.InRange(combatant, Mobile.ChaseLeashRange)) + if ((int)Mobile.GetDistanceToSqrt(combatant) > Mobile.RangePerception + 1) { this.DebugSayFormatted($"I have lost {combatant.Name}"); diff --git a/Projects/UOContent/Mobiles/AI/BaseAI/AIGroupMovement.cs b/Projects/UOContent/Mobiles/AI/BaseAI/AIGroupMovement.cs index 4463bf4de..af6c00038 100644 --- a/Projects/UOContent/Mobiles/AI/BaseAI/AIGroupMovement.cs +++ b/Projects/UOContent/Mobiles/AI/BaseAI/AIGroupMovement.cs @@ -27,43 +27,28 @@ public abstract partial class BaseAI private static void CleanupReservedPositions() { + using var toRemove = PooledRefQueue.Create(); + foreach (var (m, p) in _reservedPositions) { if (m?.Deleted != false || m.GetDistanceToSqrt(p) < 1) { - _reservedPositions.Remove(m); + toRemove.Enqueue(m); } } + + while (toRemove.Count > 0) + { + _reservedPositions.Remove(toRemove.Dequeue()); + } } - /// - /// Crowding refinement for the final approach: engages only near the target when allies - /// contest the ring, so creatures spread instead of stacking. Chasing any real distance - /// always uses the pathfinding approach primitive. - /// - private bool UseGroupMovement(Mobile target, int range) => + private bool UseGroupMovement(Mobile target) => Mobile.Combatant == target && !Mobile.Controlled - && Mobile.InRange(target, range + 2) - && CountCrowdingAllies(target, range) > 0; + && CountNearbyAllies(target) > 0; - private int CountCrowdingAllies(Mobile target, int range) - { - var crowding = 0; - - foreach (var m in target.GetMobilesInRange(range + 1)) - { - if (m != Mobile && m.Combatant == target && m is BaseCreature { Controlled: false } bc - && bc.Team == Mobile.Team) - { - crowding++; - } - } - - return crowding; - } - - public static bool MoveToWithGroup(BaseAI ai, Mobile target, int range) + public static bool MoveToWithGroup(BaseAI ai, Mobile target, bool run, int range) { if (Core.TickCount - _lastGroupUpdateTime > 1000) { @@ -78,8 +63,7 @@ public abstract partial class BaseAI { if (optimalPosition == Point3D.Zero) { - - return ai.MoveToWithCollisionAvoidance(target, range); + return ai.MoveToWithCollisionAvoidance(target, run, range); } _reservedPositions[mobile] = optimalPosition; @@ -91,15 +75,7 @@ public abstract partial class BaseAI direction = GetAdjustedDirection(direction); } - var res = ai.DoMoveImpl(direction, true); - - if (res is MoveResult.Success or MoveResult.BadState) - { - return true; - } - - // A blocked or wall-slid step is not progress — route around the obstacle. - return ai.ApproachTarget(target, range); + return ai.DoMove(direction, true); } finally { @@ -107,6 +83,21 @@ public abstract partial class BaseAI } } + private int CountNearbyAllies(Mobile target) + { + var allies = 0; + foreach (var m in Mobile.GetMobilesInRange(8)) + { + if (m != Mobile && m.Combatant == target && m is BaseCreature { Controlled: false } bc + && bc.Team == Mobile.Team) + { + allies++; + } + } + + return allies; + } + private PooledRefList GetNearbyAllies(Mobile target) { var allies = PooledRefList.Create(); diff --git a/Projects/UOContent/Mobiles/AI/BaseAI/AIMovement.cs b/Projects/UOContent/Mobiles/AI/BaseAI/AIMovement.cs index 8b2a16cf2..1063dd70a 100644 --- a/Projects/UOContent/Mobiles/AI/BaseAI/AIMovement.cs +++ b/Projects/UOContent/Mobiles/AI/BaseAI/AIMovement.cs @@ -13,12 +13,10 @@ * along with this program. If not, see . * ************************************************************************/ -using System; using System.Runtime.CompilerServices; using Server.Collections; using Server.Items; using MoveImpl = Server.Movement.MovementImpl; -using Moves = Server.Movement.Movement; namespace Server.Mobiles; @@ -39,17 +37,7 @@ public abstract partial class BaseAI private bool _approachGaveUp; private Point3D _approachGaveUpGoalLoc; - // --- Move intent (see ContinueMove) ------------------------------------------------ - // Durable movement goal renewed by en-route ApproachTarget/MoveToPoint calls; while - // live, the AITimer wakes at NextMove between think ticks to advance the step. - private Mobile _moveIntentTarget; - private IPoint3D _moveIntentPoint; - private int _moveIntentRange; - private long _moveIntentExpire; - - // Inflates a step delay while badly hurt; computed from the passed base so it cannot - // compound across steps. Damage slows steps, never decisions. - public static double BadlyHurtMoveDelay(BaseCreature bc, double delay) + public static double BadlyHurtMoveDelay(BaseCreature bc) { var statMin = Core.HS ? bc.Stam : bc.Hits; var statMax = Core.HS ? bc.StamMax : bc.HitsMax; @@ -57,59 +45,20 @@ public abstract partial class BaseAI if (!bc.IsDeadPet && (bc.ReduceSpeedWithDamage || bc.IsSubdued) && statMax > 0 && statMin < statMax * 0.3) { - var stat = (double)statMin / statMax; + var hits = (double)statMin / statMax; - if (stat < 0.1) { return delay + 0.15; } - if (stat < 0.2) { return delay + 0.1; } - - return delay + 0.05; + if (hits < 0.1) { return bc.CurrentSpeed + 0.15; } + if (hits < 0.2) { return bc.CurrentSpeed + 0.1; } + if (hits < 0.3) { return bc.CurrentSpeed + 0.05; } } - return delay; + return bc.CurrentSpeed; } public bool CanMoveNow(out double delay) { delay = 0.0; - return Core.TickCount - NextMove >= 0; - } - - // Seconds per step as the client observes it: the move clock plus the hurt inflation. - private double EffectiveStepDelay() - { - var stepDelay = Mobile.CurrentMoveSpeed; - - return Core.AOS && IsFollowingMaster() ? stepDelay : BadlyHurtMoveDelay(Mobile, stepDelay); - } - - // The Running bit only selects the client's per-step interpolation (walk 400ms / run - // 200ms on foot, 200/100 mounted). A step shorter than the walk time must run or the - // client falls behind and snaps — but an isolated step (after standing at least a walk - // interval) renders alone and darts if run-flagged, so it goes out as a walk. A true - // sprinter always runs: a walk-rendered first step would flood the client's queue. - public bool ShouldRun() - { - var mounted = Mobile.Mounted || Mobile.Flying; - var walkDelay = mounted ? Moves.WalkMountDelay : Moves.WalkFootDelay; - var pace = EffectiveStepDelay() * 1000; - - if (pace >= walkDelay) - { - return false; - } - - var runDelay = mounted ? Moves.RunMountDelay : Moves.RunFootDelay; - - return pace < runDelay || Core.TickCount - Mobile.LastMoveTime < walkDelay; - } - - // One step per period, paced from the step just taken — no debt accrual: repaying a - // late step with a quicker follow-up puts two steps ~100ms apart, which renders as a - // dart. In continuous pursuit the move-wake lands within wheel resolution of this - // deadline, so the only cost is single-digit-ms drift per step. - private void ConsumeMoveBudget() - { - NextMove = Core.TickCount + Math.Max(50, (long)(EffectiveStepDelay() * 1000)); + return Core.TickCount >= NextMove; } public virtual bool CheckMove() => !(Mobile.Deleted || Mobile.DisallowAllMoves); @@ -127,8 +76,6 @@ public abstract partial class BaseAI return MoveResult.BadState; } - d = (d & Direction.Mask) | (ShouldRun() ? Direction.Running : 0); - if ((Mobile.Direction & Direction.Mask) != (d & Direction.Mask)) { Mobile.Direction = d; @@ -139,20 +86,22 @@ public abstract partial class BaseAI if (TryMove(d)) { - // Obeying pets are paced by their order handlers. - if (!IsObeyingMoveOrder()) + if (Core.AOS && IsFollowingMaster()) { - if (Mobile.Warmode || Mobile.Combatant != null) - { - Mobile.SetCurrentSpeedToActive(); - } - else - { - Mobile.SetCurrentSpeedToPassive(); - } + Mobile.CurrentSpeed = 0.1; + } + else if (Mobile.Hits < Mobile.HitsMax * 0.3) + { + Mobile.CurrentSpeed = BadlyHurtMoveDelay(Mobile); + } + else if (Mobile.Warmode || Mobile.Combatant != null) + { + Mobile.CurrentSpeed = Mobile.ActiveSpeed; + } + else + { + Mobile.CurrentSpeed = Mobile.PassiveSpeed; } - - ConsumeMoveBudget(); return MoveResult.Success; } @@ -202,7 +151,6 @@ public abstract partial class BaseAI if (Mobile.Move(Mobile.Direction)) { - ConsumeMoveBudget(); return MoveResult.SuccessAutoTurn; } } @@ -355,18 +303,16 @@ public abstract partial class BaseAI /// best-distance stall counter idles the creature if an in-range goal is genuinely /// unreachable, without ever abandoning a real chase or detour. /// - protected bool ApproachTarget(Mobile target, int range) + protected bool ApproachTarget(Mobile target, bool run, int range) { if (Mobile.Deleted || Mobile.DisallowAllMoves || target?.Deleted != false) { - ClearMoveIntent(); return false; } if (Mobile.InRange(target, range)) { ResetApproach(); - ClearMoveIntent(); return true; } @@ -375,15 +321,12 @@ public abstract partial class BaseAI { if (target.Location == _approachGaveUpGoalLoc) { - ClearMoveIntent(); return false; } ResetApproach(); // target moved — try again fresh } - RenewMoveIntent(target, null, range); - // FAST PATH: greedy step toward the target, counted as success ONLY when the move // fully succeeded (not an auto-turn sidestep) and actually got us closer. An // auto-turn sidestep can reduce Euclidean distance while moving in the wrong @@ -394,87 +337,35 @@ public abstract partial class BaseAI if (Path == null && Mobile.InLOS(target)) { var distBefore = Mobile.GetDistanceToSqrt(target); - var res = DoMoveImpl(Mobile.GetDirectionTo(target), true); + var res = DoMoveImpl(Mobile.GetDirectionTo(target, run), true); if (res == MoveResult.BadState) { - return true; // not allowed to move this tick (frozen/casting/throttled); not a failure + return false; // not allowed to move this tick; not a stall } if (res == MoveResult.Success && Mobile.GetDistanceToSqrt(target) < distBefore) { - ResetApproach(); - return true; // healthy en-route progress + return Mobile.InRange(target, range); } - // else: fall through; let the PathFollower route around the obstacle. } // PLANNING PATH: a persistent PathFollower, never discarded by a greedy step. if (Path == null || Path.Goal != target) { - Path = new PathFollower(Mobile, target) { Mover = DoMoveImpl }; } - // Sample move-eligibility BEFORE the attempt: a successful step consumes the move - // budget, which would mask stall accounting and the progress signal. - var couldMove = CanMoveNow(out _) && !IsInBadState(); - var locBefore = Mobile.Location; - - if (Path.Follow(range)) + if (Path.Follow(run, range)) { ResetApproach(); return true; } - TrackApproachProgress(target, couldMove); - - // En-route progress is success; failure only when a move-eligible tick took no step - // (no working path), or the approach has given up. - var progressed = !_approachGaveUp && (Mobile.Location != locBefore || !couldMove); - - return progressed; - } - - /// - /// Walks toward a fixed point (e.g. a target's last-known position), pathfinding around - /// obstacles. Returns false on arrival or when genuinely unable to make progress. - /// - public bool MoveToPoint(IPoint3D goal) - { - if (Mobile.Deleted || Mobile.DisallowAllMoves || goal == null) - { - ClearMoveIntent(); - return false; - } - - if (Path?.Goal != goal) - { - Path = new PathFollower(Mobile, goal) { Mover = DoMoveImpl }; - } - - RenewMoveIntent(null, goal, 1); - - var couldMove = CanMoveNow(out _) && !IsInBadState(); - var locBefore = Mobile.Location; - - if (Path.Follow(1)) - { - Path = null; - ClearMoveIntent(); - return false; // arrived - } - - var progressed = Mobile.Location != locBefore || !couldMove; - - if (!progressed) - { - ClearMoveIntent(); - } - - return progressed; + TrackApproachProgress(target); + return false; } /// @@ -485,11 +376,11 @@ public abstract partial class BaseAI /// gives up and idles. A MOVING goal (an active chase) resets the baseline every tick, /// so chases never give up even when the gap holds constant. /// - private void TrackApproachProgress(Mobile target, bool couldMove) + private void TrackApproachProgress(Mobile target) { - if (!couldMove) + if (!CanMoveNow(out _)) { - return; // a tick that was never allowed to move (stun, stall) is not a stall + return; // a not-yet-due move (stun) is not a stall } var dist = Mobile.GetDistanceToSqrt(target); @@ -520,7 +411,6 @@ public abstract partial class BaseAI _approachGaveUp = true; _approachGaveUpGoalLoc = goalLoc; Path = null; - ClearMoveIntent(); } } @@ -536,73 +426,30 @@ public abstract partial class BaseAI _approachGaveUp = false; } - private void RenewMoveIntent(Mobile target, IPoint3D point, int range) - { - _moveIntentTarget = target; - _moveIntentPoint = point; - _moveIntentRange = range; - - // A live pursuit renews every think tick; unrenewed intent dies on its own. - _moveIntentExpire = Core.TickCount + (long)(Mobile.CurrentSpeed * 2000) + 250; - } - - public void ClearMoveIntent() - { - _moveIntentTarget = null; - _moveIntentPoint = null; - } - - /// - /// True while a durable movement goal is live; is the tick - /// the movement budget elapses. - /// - public bool TryGetMoveWake(out long nextMove) - { - nextMove = NextMove; - - return (_moveIntentTarget != null || _moveIntentPoint != null) && Core.TickCount - _moveIntentExpire < 0; - } - - /// - /// Advances the current pursuit/investigation by one step on a movement-clock wake; - /// no decisions run. - /// - public void ContinueMove() - { - if (!TryGetMoveWake(out var nextMove) || Core.TickCount - nextMove < 0) - { - return; - } - - if (_moveIntentTarget != null) - { - ApproachTarget(_moveIntentTarget, _moveIntentRange); - } - else - { - MoveToPoint(_moveIntentPoint); - } - } - - public virtual bool MoveTo(Mobile m, int range) + public virtual bool MoveTo(Mobile m, bool run, int range) { if (Mobile.Deleted || Mobile.DisallowAllMoves || m?.Deleted != false) { return false; } + var distance = (int)Mobile.GetDistanceToSqrt(m); + var distanceThreshold = Core.AOS && IsFollowingMaster() ? 1 : 5; + + var shouldRun = run && distance > distanceThreshold; + if (Mobile.InRange(m, range)) { ResetApproach(); return true; } - if (UseGroupMovement(m, range)) + if (UseGroupMovement(m)) { - return MoveToWithGroup(this, m, range); + return MoveToWithGroup(this, m, shouldRun, range); } - return ApproachTarget(m, range); + return ApproachTarget(m, shouldRun, range); } [MethodImpl(MethodImplOptions.AggressiveInlining)] @@ -612,22 +459,15 @@ public abstract partial class BaseAI Mobile.ControlTarget == Mobile.ControlMaster && Mobile.Combatant == null; - // A pet executing a movement order outside combat; its order handler owns its speed. - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public bool IsObeyingMoveOrder() => - Mobile.Controlled && - Mobile.Combatant == null && - Mobile.ControlOrder is OrderType.Come or OrderType.Follow or OrderType.Guard; - - private bool MoveToWithCollisionAvoidance(Mobile target, int range) + private bool MoveToWithCollisionAvoidance(Mobile target, bool run, int range) { + var distance = (int)Mobile.GetDistanceToSqrt(target); + + var shouldRun = run && distance > 5; + var direction = Mobile.GetDirectionTo(target); - // Wall-slide auto-turns must not count as progress, or a creature pinned on - // geometry reports success forever. - var res = DoMoveImpl(direction, true); - - if (res is MoveResult.Success or MoveResult.BadState) + if (DoMove(direction, true)) { return true; } @@ -636,14 +476,14 @@ public abstract partial class BaseAI { var clockwise = (Direction)(((int)direction + i) % 8); - if (DoMoveImpl(clockwise, true) == MoveResult.Success) + if (DoMove(clockwise, true)) { return true; } var counterclockwise = (Direction)(((int)direction - i + 8) % 8); - if (DoMoveImpl(counterclockwise, true) == MoveResult.Success) + if (DoMove(counterclockwise, true)) { return true; } @@ -651,10 +491,10 @@ public abstract partial class BaseAI // Tactical sidesteps exhausted — route around the obstacle via the centralized // approach primitive (persistent PathFollower, no oscillation). - return ApproachTarget(target, range); + return ApproachTarget(target, shouldRun, range); } - public virtual bool WalkMobileRange(Mobile m, int iSteps, int iWantDistMin, int iWantDistMax) + public virtual bool WalkMobileRange(Mobile m, int iSteps, bool run, int iWantDistMin, int iWantDistMax) { if (Mobile.Deleted || Mobile.DisallowAllMoves || m == null) { @@ -665,12 +505,14 @@ public abstract partial class BaseAI { var iCurrDist = (int)Mobile.GetDistanceToSqrt(m); + var shouldRun = run && iCurrDist > 5; + if (iCurrDist >= iWantDistMin && iCurrDist <= iWantDistMax) { return true; } - if (!MoveTowardsOrAwayFrom(m, iCurrDist, iWantDistMax)) + if (!MoveTowardsOrAwayFrom(m, shouldRun, iCurrDist, iWantDistMax)) { return false; } @@ -681,16 +523,18 @@ public abstract partial class BaseAI return dist >= iWantDistMin && dist <= iWantDistMax; } - private bool MoveTowardsOrAwayFrom(Mobile m, int iCurrDist, int iWantDistMax) + private bool MoveTowardsOrAwayFrom(Mobile m, bool run, int iCurrDist, int iWantDistMax) { + var shouldRun = run && iCurrDist > 5; + if (iCurrDist > iWantDistMax) { // Too far: approach via the centralized progress-based primitive. - return ApproachTarget(m, iWantDistMax); + return ApproachTarget(m, shouldRun, iWantDistMax); } // Too close: back away. Retreat keeps the simple greedy behavior (out of scope). - if (DoMove(m.GetDirectionTo(Mobile), true)) + if (DoMove(m.GetDirectionTo(Mobile, shouldRun), true)) { Path = null; return true; diff --git a/Projects/UOContent/Mobiles/AI/BaseAI/AITimer.cs b/Projects/UOContent/Mobiles/AI/BaseAI/AITimer.cs index e11268e87..3c08d944b 100644 --- a/Projects/UOContent/Mobiles/AI/BaseAI/AITimer.cs +++ b/Projects/UOContent/Mobiles/AI/BaseAI/AITimer.cs @@ -17,116 +17,26 @@ using System; namespace Server.Mobiles; -/// -/// Drives an AI on two clocks: decisions at , plus -/// move-only wakes at while a pursuit is live. Each tick -/// schedules the earlier of the two deadlines. -/// public sealed class AITimer : Timer { private readonly BaseAI _owner; - private long _nextThink; - private long _nextWake; // when the pending wheel entry fires - private bool _inTick; private int _detectHiddenMinDelay; private int _detectHiddenMaxDelay; - // The initial delay is irrelevant: Activate is the only start path and sets its own. - public AITimer(BaseAI owner) : base(TimeSpan.Zero, TimeSpan.FromSeconds(owner.Mobile.CurrentSpeed)) + public AITimer(BaseAI owner) : base(TimeSpan.FromMilliseconds(Utility.Random(3000)), + TimeSpan.FromSeconds(owner.Mobile.CurrentSpeed)) { _owner = owner; _owner._nextDetectHidden = Core.TickCount; - _nextThink = Core.TickCount; } public void Activate() { - _nextThink = Core.TickCount; - - if (Running) - { - return; - } - - // Short random spread: the creature responds within a think while a sector's - // worth of timers avoids a same-tick burst; the idle think jitter keeps the - // cohort apart from there. - Delay = TimeSpan.FromMilliseconds(Utility.Random(256)); + Interval = TimeSpan.FromSeconds(_owner.Mobile.CurrentSpeed); Start(); - _nextWake = Core.TickCount + (long)Delay.TotalMilliseconds; - } - - // Think now. A think grants no action: steps, swings, casts, and abilities keep their own gates. - public void Prod() - { - _nextThink = Core.TickCount; - - if (Running) - { - Reschedule(); - return; - } - - Delay = TimeSpan.Zero; - Start(); - _nextWake = Core.TickCount + (long)Delay.TotalMilliseconds; - } - - // A speed-up must not wait out a stale, longer think deadline. - public void OnSpeedChanged() - { - var candidate = Core.TickCount + (long)(_owner.Mobile.CurrentSpeed * 1000); - - if (candidate - _nextThink < 0) - { - _nextThink = candidate; - Reschedule(); - } - } - - // Moves the pending wake earlier. Interval is only read after the next fire, - // so this needs Stop, Delay = remaining, Start. - private void Reschedule() - { - if (_inTick || !Running) - { - return; // ScheduleNext handles it at tick end - } - - var now = Core.TickCount; - var deadline = _nextThink; - - if (_owner.TryGetMoveWake(out var nextMove) && nextMove - now > 0 && nextMove - deadline < 0) - { - deadline = nextMove; - } - - if (deadline - _nextWake >= 0) - { - return; // pending wake is already early enough - } - - Stop(); - Delay = TimeSpan.FromMilliseconds(Math.Max(0, deadline - now)); - Start(); - _nextWake = now + (long)Delay.TotalMilliseconds; } protected override void OnTick() - { - _inTick = true; - - try - { - OnTickCore(); - } - finally - { - _inTick = false; - } - } - - private void OnTickCore() { if (ShouldStop()) { @@ -134,64 +44,23 @@ public sealed class AITimer : Timer return; } - if (Core.TickCount - _nextThink >= 0) + _owner.Mobile.OnThink(); + + if (ShouldStop()) { - _owner.Mobile.OnThink(); - - if (ShouldStop()) - { - Stop(); - return; - } - - HandleBardEffects(); - - if (_owner.Mobile.Controlled ? _owner.Obey() : _owner.Think()) - { - HandleDetectHidden(); - } - - // Cadence from the post-decision speed (decisions may flip active/passive). - var period = (long)(_owner.Mobile.CurrentSpeed * 1000); - _nextThink = Core.TickCount + period; - - // Idle cadence drifts: a zero-mean jitter random-walks think phases apart, so - // creatures spawned or woken together cannot stay in lock-step (a one-shot - // spread can collide and identical periods never separate). Engaged cadence - // stays exact — pursuit timing anchors to real step times. - if (_owner.Mobile.CurrentSpeed == _owner.Mobile.PassiveSpeed) - { - var jitter = (int)(period >> 3); - _nextThink += Utility.RandomMinMax(-jitter, jitter); - } - } - else - { - _owner.ContinueMove(); + Stop(); + return; } - ScheduleNext(); - } + Interval = TimeSpan.FromSeconds(_owner.Mobile.CurrentSpeed); + HandleBardEffects(); - private void ScheduleNext() - { - var now = Core.TickCount; - var delay = _nextThink - now; - - if (_owner.TryGetMoveWake(out var nextMove)) + if (_owner.Mobile.Controlled ? !_owner.Obey() : !_owner.Think()) { - var moveDelay = nextMove - now; - - // Only a future budget is a wake — a blocked creature must not spin the timer. - if (moveDelay > 0 && moveDelay < delay) - { - delay = moveDelay; - } + return; } - // The wheel rounds up to its 8ms resolution; a non-positive delay becomes one turn. - Interval = TimeSpan.FromMilliseconds(delay); - _nextWake = now + (long)Interval.TotalMilliseconds; + HandleDetectHidden(); } private bool ShouldStop() diff --git a/Projects/UOContent/Mobiles/AI/BaseAI/BaseAI.cs b/Projects/UOContent/Mobiles/AI/BaseAI/BaseAI.cs index 4b8753d10..57b6f6a25 100644 --- a/Projects/UOContent/Mobiles/AI/BaseAI/BaseAI.cs +++ b/Projects/UOContent/Mobiles/AI/BaseAI/BaseAI.cs @@ -26,26 +26,11 @@ namespace Server.Mobiles; public abstract partial class BaseAI { - // Last-known-position tracking: recorded while the combatant is in LOS; drives the - // guard-time investigation and the instant re-engage. - private const int GuardGraceDuration = 10_000; - private const int LkpFreshDuration = 30_000; - private const int InvestigateDuration = 15_000; - private ActionType _action; public long _nextDetectHidden; public DateTime _lastOrder = DateTime.MinValue; public Mobile _commandIssuer; - private Mobile _lkpTarget; - private Point3D _lkpLocation; - private IPoint3D _lkpGoal; // boxed _lkpLocation handed to the PathFollower - private IPoint3D _herdGoal; // boxed herding goal handed to the PathFollower - private long _lkpExpireTick; - private long _guardStopTick; - private long _investigateStopTick; - private bool _investigating; - public PathFollower Path { get; protected set; } public AITimer AITimer { get; } public long NextMove { get; set; } @@ -59,12 +44,11 @@ public abstract partial class BaseAI public BaseAI(BaseCreature m) { Mobile = m; - NextMove = Core.TickCount; AITimer = new AITimer(this); if (!m.PlayerRangeSensitive || !World.Loading && m.Map != null && m.Map != Map.Internal && m.Map.GetSector(m.Location).Active) { - AITimer.Activate(); + AITimer.Start(); } if (Action != ActionType.Wander) @@ -249,15 +233,6 @@ public abstract partial class BaseAI return true; } - if (_action == ActionType.Combat) - { - UpdateLastKnownLocation(); - } - else if (_action is ActionType.Wander or ActionType.Guard) - { - TryReengageLastKnown(); - } - switch (Action) { case ActionType.Wander: @@ -297,9 +272,6 @@ public abstract partial class BaseAI public virtual void OnActionChanged() { - // A change of course invalidates between-think movement continuation. - ClearMoveIntent(); - switch (Action) { case ActionType.Wander: @@ -351,15 +323,6 @@ public abstract partial class BaseAI { Mobile.Warmode = true; Mobile.Combatant = null; - - // Investigate a fresh last-seen position that is not already in view; the guard - // grace period begins once the investigation ends. - _investigating = _lkpTarget != null && Core.TickCount - _lkpExpireTick < 0 && - !(Mobile.InRange(_lkpLocation, 1) || - Mobile.InLOS(_lkpLocation) && Mobile.InRange(_lkpLocation, Mobile.RangePerception)); - _investigateStopTick = Core.TickCount + InvestigateDuration; - _guardStopTick = Core.TickCount + GuardGraceDuration; - _lkpGoal = null; } private void HandleFleeAction() @@ -442,7 +405,7 @@ public abstract partial class BaseAI var master = Mobile.SummonMaster; if (master != null && master.Map == Mobile.Map && master.InRange(Mobile, Mobile.RangePerception)) { - MoveTo(master, 1); + MoveTo(master, false, 1); } } @@ -490,118 +453,18 @@ public abstract partial class BaseAI public virtual bool DoActionGuard() { - if (_investigating) + if (Mobile.Combatant == null) { - if (InvestigateLastKnown()) - { - return true; - } - - _investigating = false; - _guardStopTick = Core.TickCount + GuardGraceDuration; + DebugSay("No threats found. Going home..."); + Action = ActionType.Wander; } - if (Core.TickCount - _guardStopTick < 0) - { - DebugSay("I am on guard."); - - if (Utility.Random(8) == 0) - { - Mobile.Direction = (Direction)Utility.Random(8); - } - - return true; - } - - DebugSay("I stopped being on guard. Going home..."); + DebugSay("I stopped being on guard."); Action = ActionType.Wander; return true; } - /// - /// Records the combatant's position while it is visible and in line of sight. - /// - private void UpdateLastKnownLocation() - { - var combatant = Mobile.Combatant; - - if (combatant?.Deleted == false && combatant.Map == Mobile.Map && - Mobile.CanSee(combatant) && Mobile.InLOS(combatant)) - { - _lkpTarget = combatant; - _lkpLocation = combatant.Location; - _lkpExpireTick = Core.TickCount + LkpFreshDuration; - } - } - - /// - /// Re-engages the last-seen target when it returns to view within perception range, - /// bypassing the reacquire throttle. - /// - private bool TryReengageLastKnown() - { - var target = _lkpTarget; - - if (target == null) - { - return false; - } - - if (target.Deleted || !target.Alive || target.Map != Mobile.Map || - target is BaseCreature { IsDeadPet: true } || Core.TickCount - _lkpExpireTick >= 0) - { - ClearLastKnown(); - return false; - } - - if (Mobile.Controlled || Mobile.BardPacified || Mobile.BardProvoked || Mobile.FightMode == FightMode.None) - { - return false; - } - - if (!Mobile.InRange(target, Mobile.RangePerception) || !Mobile.CanSee(target) || - !Mobile.InLOS(target) || !Mobile.CanBeHarmful(target, false)) - { - return false; - } - - DebugSay("There you are!"); - Mobile.Combatant = target; - Mobile.FocusMob = null; - Action = ActionType.Combat; - return true; - } - - /// - /// Walks toward the last-seen position until it is in view, reached, timed out, or - /// unreachable. Returns false when the investigation is finished. - /// - private bool InvestigateLastKnown() - { - if (_lkpTarget == null || Core.TickCount - _investigateStopTick >= 0) - { - return false; - } - - if (Mobile.InRange(_lkpLocation, 1) || - Mobile.InLOS(_lkpLocation) && Mobile.InRange(_lkpLocation, Mobile.RangePerception)) - { - DebugSay("They truly disappeared..."); - return false; - } - - _lkpGoal ??= _lkpLocation; - return MoveToPoint(_lkpGoal); - } - - private void ClearLastKnown() - { - _lkpTarget = null; - _lkpGoal = null; - _investigating = false; - } - public virtual bool DoActionFlee() { var from = Mobile.Combatant; @@ -628,7 +491,6 @@ public abstract partial class BaseAI if (target == null) { - _herdGoal = null; return false; } @@ -636,15 +498,7 @@ public abstract partial class BaseAI if (distance >= 1 && distance <= 15) { - // A cached boxed goal keeps the PathFollower persistent across ticks; walking - // through MoveToPoint paces herding on the movement clock and paths around - // obstacles. - if (_herdGoal == null || _herdGoal.X != target.X || _herdGoal.Y != target.Y) - { - _herdGoal = new Point3D(target.X, target.Y, Mobile.Map?.GetAverageZ(target.X, target.Y) ?? Mobile.Z); - } - - MoveToPoint(_herdGoal); + DoMove(Mobile.GetDirectionTo(target)); return true; } @@ -654,7 +508,6 @@ public abstract partial class BaseAI } Mobile.TargetLocation = null; - _herdGoal = null; return false; } @@ -798,7 +651,7 @@ public abstract partial class BaseAI { if (AcquireFocusMob(Mobile.RangePerception * 2, FightMode.Closest, true, false, true)) { - if (WalkMobileRange(Mobile.FocusMob, 1, Mobile.RangePerception, Mobile.RangePerception * 2)) + if (WalkMobileRange(Mobile.FocusMob, 1, false, Mobile.RangePerception, Mobile.RangePerception * 2)) { DebugSay("I backed off to safety. Wandering..."); @@ -850,24 +703,22 @@ public abstract partial class BaseAI return false; } - var reacquireDelay = (long)Mobile.ReacquireDelay.TotalMilliseconds; - var gateRemaining = Mobile.NextReacquireTime - Core.TickCount; - - if (gateRemaining > 0 && gateRemaining <= reacquireDelay) + if (Core.TickCount - Mobile.NextReacquireTime < 0) { Mobile.FocusMob = null; return false; } - DebugSay("Acquiring new target...", 0); + Mobile.NextReacquireTime = Core.TickCount + (int)Mobile.ReacquireDelay.TotalMilliseconds; - var acquired = AcquireNewFocusMob(Mobile.Map, iRange, acqType, bPlayerOnly, bFacFriend, bFacFoe); + DebugSay("Acquiring new target..."); - // Reaction time is the approach path (BaseCreature.ScheduleAcquireOnApproach), - // not this poll — every scan honors the full delay. - Mobile.NextReacquireTime = Core.TickCount + reacquireDelay; + if (Mobile.Map == null) + { + return Mobile.FocusMob != null; + } - return acquired; + return AcquireNewFocusMob(Mobile.Map, iRange, acqType, bPlayerOnly, bFacFriend, bFacFoe); } private bool HandleBardProvoked() @@ -943,10 +794,8 @@ public abstract partial class BaseAI private bool AcquireNewFocusMob(Map map, int iRange, FightMode acqType, bool bPlayerOnly, bool bFacFriend, bool bFacFoe) { - Mobile newFocusMob = null; - Mobile enemySummonMob = null; - var val = double.MinValue; - var enemySummonVal = double.MinValue; + Mobile newFocusMob = null, enemySummonMob = null; + double val = double.MinValue, enemySummonVal = double.MinValue; foreach (var m in map.GetMobilesInRange(Mobile.Location, iRange)) { @@ -1150,6 +999,6 @@ public abstract partial class BaseAI public virtual void OnCurrentSpeedChanged() { - AITimer.OnSpeedChanged(); + AITimer.Interval = TimeSpan.FromSeconds(Mobile.CurrentSpeed); } } diff --git a/Projects/UOContent/Mobiles/AI/BaseAI/PetOrderHandlers.cs b/Projects/UOContent/Mobiles/AI/BaseAI/PetOrderHandlers.cs index 862c78649..ecb456d2e 100644 --- a/Projects/UOContent/Mobiles/AI/BaseAI/PetOrderHandlers.cs +++ b/Projects/UOContent/Mobiles/AI/BaseAI/PetOrderHandlers.cs @@ -26,7 +26,7 @@ public abstract partial class BaseAI return; } - AITimer.Prod(); + Activate(); switch (Mobile.ControlOrder) { @@ -36,10 +36,6 @@ public abstract partial class BaseAI break; } case OrderType.Come: - { - Mobile.SetCurrentSpeedToActive(); - break; - } case OrderType.Drop: case OrderType.Friend: case OrderType.Unfriend: @@ -139,7 +135,6 @@ public abstract partial class BaseAI Mobile.FocusMob = null; Mobile.Warmode = false; Mobile.Combatant = null; - Mobile.SetCurrentSpeedToPassive(); } private void HandleTransferOrder() @@ -153,7 +148,6 @@ public abstract partial class BaseAI Mobile.FocusMob = null; Mobile.Warmode = false; Mobile.Combatant = null; - Mobile.SetCurrentSpeedToPassive(); Mobile.PlaySound(Mobile.GetIdleSound()); _commandIssuer = null; } @@ -168,16 +162,9 @@ public abstract partial class BaseAI _commandIssuer?.RevealingAction(); Mobile.FocusMob = null; Mobile.Warmode = true; - Mobile.SetCurrentSpeedToActive(); - - // Resuming the persistent order must not replay the flourish. - if (!_resolvingOrder) - { - Mobile.PlaySound(Mobile.GetAttackSound()); - Mobile.ControlMaster?.SendLocalizedMessage(1049671, Mobile.Name); - // ~1_NAME~ is now guarding you. - } - + Mobile.PlaySound(Mobile.GetAttackSound()); + Mobile.ControlMaster?.SendLocalizedMessage(1049671, Mobile.Name); + // ~1_NAME~ is now guarding you. _commandIssuer = null; } @@ -204,7 +191,6 @@ public abstract partial class BaseAI } Mobile.Warmode = true; - Mobile.SetCurrentSpeedToActive(); Mobile.PlaySound(Mobile.GetAttackSound()); _commandIssuer = null; } @@ -220,7 +206,6 @@ public abstract partial class BaseAI Mobile.FocusMob = null; Mobile.Warmode = false; Mobile.Combatant = null; - Mobile.SetCurrentSpeedToActive(); Mobile.PlaySound(Mobile.GetIdleSound()); _commandIssuer = null; } @@ -236,7 +221,6 @@ public abstract partial class BaseAI Mobile.FocusMob = null; Mobile.Warmode = false; Mobile.Combatant = null; - Mobile.SetCurrentSpeedToPassive(); Mobile.PlaySound(Mobile.GetIdleSound()); _commandIssuer = null; // Home (the stay anchor) is owned by SetPersistentOrder, not this handler. diff --git a/Projects/UOContent/Mobiles/AI/BaseAI/PetOrders.cs b/Projects/UOContent/Mobiles/AI/BaseAI/PetOrders.cs index e6c48850d..1b7139a96 100644 --- a/Projects/UOContent/Mobiles/AI/BaseAI/PetOrders.cs +++ b/Projects/UOContent/Mobiles/AI/BaseAI/PetOrders.cs @@ -84,7 +84,7 @@ public abstract partial class BaseAI return true; } - WalkMobileRange(Mobile.ControlMaster, 1, 1, 2); + WalkMobileRange(Mobile.ControlMaster, 1, false, 1, 2); if (Mobile.GetDistanceToSqrt(Mobile.ControlMaster) <= 2) { @@ -128,15 +128,9 @@ public abstract partial class BaseAI this.DebugSayFormatted($"I am ordered to follow {Mobile.ControlTarget?.Name}."); - // AOS: sprint after the master (bespoke 0.1 paces both clocks). - if (Core.AOS && Mobile.ControlTarget == Mobile.ControlMaster && Mobile.Combatant == null) - { - Mobile.CurrentSpeed = 0.1; - } - if (currentDistance > 1) { - WalkMobileRange(Mobile.ControlTarget, 1, 1, 2); + WalkMobileRange(Mobile.ControlTarget, 1, currentDistance > 2, 1, 2); } } @@ -297,13 +291,14 @@ public abstract partial class BaseAI return true; } - var combatant = FindGuardTarget(); + FindCombatant(); - if (combatant != null) + if (IsValidCombatant(Mobile.Combatant)) { + var combatant = Mobile.Combatant; + this.DebugSayFormatted($"Attacking target: {combatant.Name}"); - // Engage without leaving the Guard order so tags, recall handling, and retargeting persist. Mobile.Combatant = combatant; Mobile.FocusMob = combatant; Action = ActionType.Combat; @@ -314,30 +309,16 @@ public abstract partial class BaseAI { this.DebugSayFormatted($"Guarding my master, {controlMaster.Name}."); - // Stand down; a stale Warmode would skew the return pace. - Mobile.FocusMob = null; - Mobile.Warmode = false; - Mobile.Combatant = null; + var guardLocation = controlMaster.Location; - var distance = (int)Mobile.GetDistanceToSqrt(controlMaster); + var distance = (int)Mobile.GetDistanceToSqrt(guardLocation); if (distance > 3) { - // AOS: sprint back (bespoke 0.1 paces both clocks); earlier eras run active. - if (Core.AOS) - { - Mobile.CurrentSpeed = 0.1; - } - else - { - Mobile.SetCurrentSpeedToActive(); - } - - WalkMobileRange(controlMaster, 1, 1, 3); + DoMove(Mobile.GetDirectionTo(guardLocation)); } else { - Mobile.SetCurrentSpeedToActive(); // alert at the master's side WalkRandom(3, 1, 1); } } @@ -378,83 +359,67 @@ public abstract partial class BaseAI Mobile.ControlTarget = Mobile.ControlMaster; ResumePersistentOrder(); - // A resumed Guard engages through its own scan; other fallbacks chain an explicit Attack. - if (Mobile.ControlOrder == OrderType.Guard || - Mobile.FightMode is not (FightMode.Closest or FightMode.Aggressor)) + if (Mobile.FightMode is FightMode.Closest or FightMode.Aggressor) { - return; - } - - var next = FindGuardTarget(); - - if (next != null) - { - Mobile.ControlTarget = next; - Mobile.ControlOrder = OrderType.Attack; - Mobile.Combatant = next; - - this.DebugSayFormatted($"{next.Name} is still hostile! Engaging..."); - - Think(); + FindCombatant(); } } - /// - /// Selects the aggressor closest to the master. The current combatant is kept - /// unless a strictly closer one exists. Never mutates order state. - /// - private Mobile FindGuardTarget() + private void FindCombatant() { var controlMaster = Mobile.ControlMaster; - var anchor = controlMaster ?? Mobile; - - var current = Mobile.Combatant; - var best = current != controlMaster && IsValidCombatant(current) ? current : null; - var bestDist = best?.GetDistanceToSqrt(anchor) ?? double.MaxValue; foreach (var aggr in Mobile.GetMobilesInRange(Mobile.RangePerception)) { - if (aggr == best || aggr == Mobile || aggr == controlMaster || - aggr.IsDeadBondedPet || !aggr.Alive || - aggr.Combatant != Mobile && (controlMaster == null || aggr.Combatant != controlMaster)) + if (!Mobile.CanSee(aggr) || aggr.IsDeadBondedPet || !aggr.Alive) { continue; } - var dist = aggr.GetDistanceToSqrt(anchor); + var isAttackingPet = aggr.Combatant == Mobile; + var isAttackingMaster = controlMaster != null && aggr.Combatant == controlMaster; - if (dist < bestDist && Mobile.CanSee(aggr) && Mobile.InLOS(aggr)) + if (isAttackingPet || isAttackingMaster) { - best = aggr; - bestDist = dist; + if (Mobile.InLOS(aggr)) + { + Mobile.ControlTarget = aggr; + Mobile.ControlOrder = OrderType.Attack; + Mobile.Combatant = aggr; + + var target = isAttackingMaster ? "master" : "me"; + this.DebugSayFormatted($"{aggr.Name} is attacking my {target}! Engaging..."); + + Think(); + return; + } } } - var aggressors = controlMaster?.Aggressors; - - if (aggressors != null) + if (controlMaster?.Aggressors != null) { - for (var i = 0; i < aggressors.Count; i++) + for (var i = 0; i < controlMaster.Aggressors.Count; i++) { - var aggressor = aggressors[i].Attacker; + var aggressor = controlMaster.Aggressors[i].Attacker; - if (aggressor == best || aggressor?.Deleted != false || !aggressor.Alive || - aggressor.IsDeadBondedPet || !Mobile.InRange(aggressor, Mobile.RangePerception)) + if (aggressor?.Deleted != false || !aggressor.Alive || aggressor.IsDeadBondedPet) { continue; } - var dist = aggressor.GetDistanceToSqrt(anchor); - - if (dist < bestDist && Mobile.CanSee(aggressor) && Mobile.InLOS(aggressor)) + if (Mobile.InRange(aggressor, Mobile.RangePerception) && Mobile.CanSee(aggressor) && Mobile.InLOS(aggressor)) { - best = aggressor; - bestDist = dist; + Mobile.ControlTarget = aggressor; + Mobile.ControlOrder = OrderType.Attack; + Mobile.Combatant = aggressor; + + this.DebugSayFormatted($"{aggressor.Name} recently attacked my master! Retaliating..."); + + Think(); + return; } } } - - return best; } public virtual bool DoOrderRelease() diff --git a/Projects/UOContent/Mobiles/AI/BerserkAI.cs b/Projects/UOContent/Mobiles/AI/BerserkAI.cs index 8a2015f1a..4663ae8d0 100644 --- a/Projects/UOContent/Mobiles/AI/BerserkAI.cs +++ b/Projects/UOContent/Mobiles/AI/BerserkAI.cs @@ -12,7 +12,7 @@ public class BerserkAI : BaseAI if (AcquireFocusMob(Mobile.RangePerception, FightMode.Closest, false, true, true)) { - this.DebugSayFormatted($"I have detected {Mobile.FocusMob.Name}, attacking"); + this.DebugSayFormatted($"I have detected {Mobile.FocusMob.Name} and I will attack"); Mobile.Combatant = Mobile.FocusMob; Action = ActionType.Combat; @@ -38,7 +38,7 @@ public class BerserkAI : BaseAI return true; } - if (!WalkMobileRange(combatant, 1, Mobile.RangeFight, Mobile.RangeFight)) + if (!WalkMobileRange(combatant, 1, false, Mobile.RangeFight, Mobile.RangeFight)) { this.DebugSayFormatted($"I am still not in range of {combatant.Name}"); diff --git a/Projects/UOContent/Mobiles/AI/HealerAI.cs b/Projects/UOContent/Mobiles/AI/HealerAI.cs index cc54c73c3..2a1127b43 100644 --- a/Projects/UOContent/Mobiles/AI/HealerAI.cs +++ b/Projects/UOContent/Mobiles/AI/HealerAI.cs @@ -81,7 +81,7 @@ public class HealerAI : BaseAI return true; } - WalkMobileRange(Mobile.FocusMob, 1, 4, 7); + WalkMobileRange(Mobile.FocusMob, 1, false, 4, 7); // TODO: Should it be able to do this? if (Mobile.TriggerAbility(MonsterAbilityTrigger.CombatAction, Mobile.Combatant)) diff --git a/Projects/UOContent/Mobiles/AI/MageAI.cs b/Projects/UOContent/Mobiles/AI/MageAI.cs index 1ed8cbdda..e9450db98 100644 --- a/Projects/UOContent/Mobiles/AI/MageAI.cs +++ b/Projects/UOContent/Mobiles/AI/MageAI.cs @@ -171,7 +171,7 @@ public class MageAI : BaseAI { if (!SmartAI) { - if (!MoveTo(m, Mobile.RangeFight)) + if (!MoveTo(m, false, Mobile.RangeFight)) { OnFailedMove(); } @@ -185,14 +185,14 @@ public class MageAI : BaseAI { RunFrom(m); } - else if (!Mobile.InRange(m, Math.Max(Mobile.RangeFight, 2)) && !MoveTo(m, 1)) + else if (!Mobile.InRange(m, Math.Max(Mobile.RangeFight, 2)) && !MoveTo(m, false, 1)) { OnFailedMove(); } } else if (!Mobile.InRange(m, Mobile.RangeFight)) { - if (!MoveTo(m, 1)) + if (!MoveTo(m, false, 1)) { OnFailedMove(); } @@ -679,7 +679,7 @@ public class MageAI : BaseAI Mobile.Combatant = Mobile.FocusMob; Mobile.FocusMob = null; } - else if (!Mobile.InRange(c, Mobile.ChaseLeashRange)) + else if (!Mobile.InRange(c, Mobile.RangePerception * 3)) { Mobile.Combatant = null; } @@ -695,23 +695,6 @@ public class MageAI : BaseAI } } - // Geometry (not hiding — CanSee passed above) is blocking the shot: close in until - // line of sight returns. Poisoned mages still fall through to cure. - if (!Mobile.Poisoned && Mobile.Spell?.IsCasting != true && !Mobile.InLOS(c)) - { - DebugSay("I cannot see my target, moving to regain line of sight"); - - if (!MoveTo(c, 1)) - { - OnFailedMove(); - } - - _lastTarget = c; - _lastTargetLoc = c.Location; - - return true; - } - if (Mobile.TriggerAbility(MonsterAbilityTrigger.CombatAction, c)) { DebugSay("I used my abilities!"); @@ -1035,16 +1018,7 @@ public class MageAI : BaseAI if (toTarget != null) { - // Without line of sight the stand-off is pointless — close in so the held - // target can be invoked. - if (!Mobile.InLOS(toTarget)) - { - MoveTo(toTarget, 1); - } - else - { - RunTo(toTarget); - } + RunTo(toTarget); } } diff --git a/Projects/UOContent/Mobiles/AI/MeleeAI.cs b/Projects/UOContent/Mobiles/AI/MeleeAI.cs index a6d0a9bf9..aa262caee 100644 --- a/Projects/UOContent/Mobiles/AI/MeleeAI.cs +++ b/Projects/UOContent/Mobiles/AI/MeleeAI.cs @@ -1,5 +1,3 @@ -using System.Runtime.CompilerServices; - namespace Server.Mobiles; public class MeleeAI : BaseAI @@ -16,7 +14,6 @@ public class MeleeAI : BaseAI if (AcquireFocusMob(Mobile.RangePerception, Mobile.FightMode, false, false, true)) { this.DebugSayFormatted($"I have detected {Mobile.FocusMob.Name}, attacking"); - Mobile.Combatant = Mobile.FocusMob; Action = ActionType.Combat; } @@ -68,9 +65,13 @@ public class MeleeAI : BaseAI return true; } - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private bool IsValidCombatant(Mobile combatant) => - combatant?.Deleted == false && combatant.Map == Mobile.Map && combatant.Alive && !combatant.IsDeadBondedPet; + private bool IsValidCombatant(Mobile combatant) + { + return combatant?.Deleted == false + && combatant.Map == Mobile.Map + && combatant.Alive + && !combatant.IsDeadBondedPet; + } private bool HandleOutOfRangeCombatant(Mobile combatant) { @@ -81,7 +82,7 @@ public class MeleeAI : BaseAI return true; } - if (!Mobile.InRange(combatant, Mobile.ChaseLeashRange)) + if (!Mobile.InRange(combatant, Mobile.RangePerception * 3)) { Mobile.Combatant = null; } @@ -98,7 +99,7 @@ public class MeleeAI : BaseAI private bool AttemptMoveToCombatant(Mobile combatant) { - if (MoveTo(combatant, Mobile.RangeFight)) + if (MoveTo(combatant, false, Mobile.RangeFight)) { return true; } @@ -126,8 +127,7 @@ public class MeleeAI : BaseAI { if (AcquireFocusMob(Mobile.RangePerception, Mobile.FightMode, false, false, true)) { - this.DebugSayFormatted($"I have detected {Mobile.FocusMob.Name}, attacking"); - + this.DebugSayFormatted($"I have detected {Mobile.FocusMob.Name}, attacking."); Mobile.Combatant = Mobile.FocusMob; Action = ActionType.Combat; } diff --git a/Projects/UOContent/Mobiles/AI/PredatorAI.cs b/Projects/UOContent/Mobiles/AI/PredatorAI.cs index b1ea3a638..5e0e01520 100644 --- a/Projects/UOContent/Mobiles/AI/PredatorAI.cs +++ b/Projects/UOContent/Mobiles/AI/PredatorAI.cs @@ -41,7 +41,7 @@ public class PredatorAI : BaseAI return true; } - if (!WalkMobileRange(combatant, 1, Mobile.RangeFight, Mobile.RangeFight)) + if (!WalkMobileRange(combatant, 1, false, Mobile.RangeFight, Mobile.RangeFight)) { if (Mobile.GetDistanceToSqrt(combatant) > Mobile.RangePerception + 1) { @@ -70,7 +70,7 @@ public class PredatorAI : BaseAI } else if (AcquireFocusMob(Mobile.RangePerception * 2, FightMode.Closest, true, false, true)) { - if (WalkMobileRange(Mobile.FocusMob, 1, Mobile.RangePerception, Mobile.RangePerception * 2)) + if (WalkMobileRange(Mobile.FocusMob, 1, false, Mobile.RangePerception, Mobile.RangePerception * 2)) { DebugSay("Well, here I am safe"); diff --git a/Projects/UOContent/Mobiles/AI/ThiefAI.cs b/Projects/UOContent/Mobiles/AI/ThiefAI.cs index a9209dfbb..0fa37bbc9 100644 --- a/Projects/UOContent/Mobiles/AI/ThiefAI.cs +++ b/Projects/UOContent/Mobiles/AI/ThiefAI.cs @@ -43,7 +43,7 @@ public class ThiefAI : BaseAI return true; } - if (!WalkMobileRange(combatant, 1, Mobile.RangeFight, Mobile.RangeFight)) + if (!WalkMobileRange(combatant, 1, false, Mobile.RangeFight, Mobile.RangeFight)) { this.DebugSayFormatted($"I should be closer to {combatant.Name}"); } diff --git a/Projects/UOContent/Mobiles/Animals/Misc/Sheep.cs b/Projects/UOContent/Mobiles/Animals/Misc/Sheep.cs index 741093110..b43c34272 100644 --- a/Projects/UOContent/Mobiles/Animals/Misc/Sheep.cs +++ b/Projects/UOContent/Mobiles/Animals/Misc/Sheep.cs @@ -5,14 +5,9 @@ using System.Runtime.CompilerServices; namespace Server.Mobiles { - [SerializationGenerator(1, false)] + [SerializationGenerator(0, false)] public partial class Sheep : BaseCreature, ICarvable { - private void MigrateFrom(V0Content content) - { - _nextWoolTime = content.NextWoolTime; - } - [Constructible] public Sheep() : base(AIType.AI_Animal, FightMode.Aggressor) { @@ -48,14 +43,18 @@ namespace Server.Mobiles public override string CorpseName => "a sheep corpse"; - [SerializableField(0, fieldChanged: nameof(OnNextWoolTimeChanged))] - [AnchoredDateTime] - [SerializedCommandProperty(AccessLevel.GameMaster)] - private DateTime _nextWoolTime; - - private void OnNextWoolTimeChanged(DateTime oldValue, DateTime newValue) + [DeltaDateTime] + [SerializableProperty(0)] + [CommandProperty(AccessLevel.GameMaster)] + public DateTime NextWoolTime { - SheepBody(); + get => _nextWoolTime; + set + { + _nextWoolTime = value; + SheepBody(); + this.MarkDirty(); + } } [MethodImpl(MethodImplOptions.AggressiveInlining)] diff --git a/Projects/UOContent/Mobiles/Animals/Mounts/Ethereals.cs b/Projects/UOContent/Mobiles/Animals/Mounts/Ethereals.cs index f6dc13b40..f13cebcbe 100644 --- a/Projects/UOContent/Mobiles/Animals/Mounts/Ethereals.cs +++ b/Projects/UOContent/Mobiles/Animals/Mounts/Ethereals.cs @@ -11,19 +11,19 @@ namespace Server.Mobiles public partial class EtherealMount : Item, IMount, IMountItem, IRewardItem { [SerializableField(0)] - [SaveFlag(nameof(ShouldSerializeIsDonationItem))] [SerializedCommandProperty(AccessLevel.GameMaster, AccessLevel.Administrator)] public bool _isDonationItem; [MethodImpl(MethodImplOptions.AggressiveInlining)] + [SerializableFieldSaveFlag(0)] public bool ShouldSerializeIsDonationItem() => _isDonationItem; [SerializableField(1)] - [SaveFlag(nameof(ShouldSerializeIsRewardItem))] [SerializedCommandProperty(AccessLevel.GameMaster)] public bool _isRewardItem; [MethodImpl(MethodImplOptions.AggressiveInlining)] + [SerializableFieldSaveFlag(1)] public bool ShouldSerializeIsRewardItem() => _isRewardItem; [Constructible] @@ -40,27 +40,43 @@ namespace Server.Mobiles public override double DefaultWeight => 1.0; - [SerializableField(2, fieldChanged: nameof(OnMountedIDChanged))] - [SerializedCommandProperty(AccessLevel.GameMaster)] - private int _mountedID; - - private void OnMountedIDChanged(int oldValue, int newValue) + [SerializableProperty(2)] + [CommandProperty(AccessLevel.GameMaster)] + public int MountedID { - if (_rider != null) + get => _mountedID; + set { - ItemID = newValue; + if (_mountedID != value) + { + _mountedID = value; + + if (_rider != null) + { + ItemID = value; + } + this.MarkDirty(); + } } } - [SerializableField(3, fieldChanged: nameof(OnRegularIDChanged))] - [SerializedCommandProperty(AccessLevel.GameMaster)] - private int _regularID; - - private void OnRegularIDChanged(int oldValue, int newValue) + [SerializableProperty(3)] + [CommandProperty(AccessLevel.GameMaster)] + public int RegularID { - if (_rider == null) + get => _regularID; + set { - ItemID = newValue; + if (_regularID != value) + { + _regularID = value; + + if (_rider == null) + { + ItemID = value; + } + this.MarkDirty(); + } } } @@ -71,7 +87,6 @@ namespace Server.Mobiles public virtual int EtherealHue => 0x4001; [SerializableProperty(4)] - [SaveFlag(nameof(ShouldSerializeRider))] [CommandProperty(AccessLevel.GameMaster)] public Mobile Rider { @@ -109,19 +124,22 @@ namespace Server.Mobiles } } + [SerializableFieldSaveFlag(4)] private bool ShouldSerializeRider() => _rider != null; - [SerializableField(5, allowFieldChange: nameof(AllowStepsChange))] - [SerializedCommandProperty(AccessLevel.GameMaster)] - [SaveFlag(nameof(ShouldSerializeSteps))] - private int _steps; - - private bool AllowStepsChange(ref int value) + [CommandProperty(AccessLevel.GameMaster)] + [SerializableProperty(5)] + public int Steps { - value = Math.Clamp(value, 0, StepsMax); - return true; + get => _steps; + set + { + _steps = Math.Clamp(value, 0, StepsMax); + this.MarkDirty(); + } } + [SerializableFieldSaveFlag(5)] private bool ShouldSerializeSteps() => _steps != StepsMax; public virtual int StepsMax => 3840; // Should be same as horse diff --git a/Projects/UOContent/Mobiles/Animals/Mounts/SwampDragon.cs b/Projects/UOContent/Mobiles/Animals/Mounts/SwampDragon.cs index 19a061af2..69bd9ce56 100644 --- a/Projects/UOContent/Mobiles/Animals/Mounts/SwampDragon.cs +++ b/Projects/UOContent/Mobiles/Animals/Mounts/SwampDragon.cs @@ -62,37 +62,48 @@ namespace Server.Mobiles [SerializableField(3)] private int _bardingHP; - [SerializableField(2, fieldChanged: nameof(OnHasBardingChanged))] - [SerializedCommandProperty(AccessLevel.GameMaster)] - [InvalidateProperties] - private bool _hasBarding; - - private void OnHasBardingChanged(bool oldValue, bool newValue) + [CommandProperty(AccessLevel.GameMaster)] + [SerializableProperty(2)] + public bool HasBarding { - if (_hasBarding) + get => _hasBarding; + set { - Hue = CraftResources.GetHue(_bardingResource); - Body = 0x31F; - ItemID = 0x3EBE; - } - else - { - Hue = 0x851; - Body = 0x31A; - ItemID = 0x3EBD; + _hasBarding = value; + + if (_hasBarding) + { + Hue = CraftResources.GetHue(_bardingResource); + Body = 0x31F; + ItemID = 0x3EBE; + } + else + { + Hue = 0x851; + Body = 0x31A; + ItemID = 0x3EBD; + } + InvalidateProperties(); + this.MarkDirty(); } } - [SerializableField(4, fieldChanged: nameof(OnBardingResourceChanged))] - [SerializedCommandProperty(AccessLevel.GameMaster)] - [InvalidateProperties] - private CraftResource _bardingResource; - - private void OnBardingResourceChanged(CraftResource oldValue, CraftResource newValue) + [CommandProperty(AccessLevel.GameMaster)] + [SerializableProperty(4)] + public CraftResource BardingResource { - if (_hasBarding) + get => _bardingResource; + set { - Hue = CraftResources.GetHue(newValue); + _bardingResource = value; + + if (_hasBarding) + { + Hue = CraftResources.GetHue(value); + } + + InvalidateProperties(); + this.MarkDirty(); } } diff --git a/Projects/UOContent/Mobiles/BaseCreature.cs b/Projects/UOContent/Mobiles/BaseCreature.cs index 404aaede8..af85a195a 100644 --- a/Projects/UOContent/Mobiles/BaseCreature.cs +++ b/Projects/UOContent/Mobiles/BaseCreature.cs @@ -265,12 +265,8 @@ namespace Server.Mobiles private double _passiveSpeed; private double _currentSpeed; - // Movement clock (seconds per step); 0 = inherit the matching think value. - private double _activeMoveSpeed; - private double _passiveMoveSpeed; - - // Herding - forces the mob to walk to a specific location, paced by the movement - // clock at HerdingMoveSpeed. Thinking is unaffected. + // Herding - Overrides the AI to force the mob to move to a specific location + // Thinking: 0.3s, Movement: 0.6s. private IPoint2D _targetLocation; private int m_DamageMax = -1; @@ -346,7 +342,6 @@ namespace Server.Mobiles FightMode = mode; GetSpeeds(out var activeSpeed, out var passiveSpeed); - GetMoveSpeeds(out _activeMoveSpeed, out _passiveMoveSpeed); ActiveSpeed = activeSpeed; PassiveSpeed = passiveSpeed; @@ -665,20 +660,12 @@ namespace Server.Mobiles [CommandProperty(AccessLevel.GameMaster)] public int RangePerception { get; set; } - /// - /// How far a chase may stretch before the creature gives up its combatant. Between - /// RangePerception and this leash it keeps chasing but may switch to closer targets. - /// - [CommandProperty(AccessLevel.GameMaster)] - public virtual int ChaseLeashRange => RangePerception * 2; - [CommandProperty(AccessLevel.GameMaster)] public int RangeFight { get; set; } [CommandProperty(AccessLevel.GameMaster)] public int RangeHome { get; set; } = 10; - /// Seconds per AI decision while engaged; see for movement pace. [CommandProperty(AccessLevel.GameMaster)] public virtual double ActiveSpeed { @@ -692,7 +679,6 @@ namespace Server.Mobiles } } - /// Seconds per AI decision while idle; see for movement pace. [CommandProperty(AccessLevel.GameMaster)] public virtual double PassiveSpeed { @@ -707,37 +693,21 @@ namespace Server.Mobiles } } - /// Seconds per step while engaged. Inherits ; set 0 to re-inherit. - [CommandProperty(AccessLevel.GameMaster)] - public virtual double ActiveMoveSpeed - { - get => _activeMoveSpeed > 0 ? _activeMoveSpeed : _activeSpeed; - set => _activeMoveSpeed = value > 0 ? value : 0; - } - - /// Seconds per step while idle. Inherits ; set 0 to re-inherit. - [CommandProperty(AccessLevel.GameMaster)] - public virtual double PassiveMoveSpeed - { - get => _passiveMoveSpeed > 0 ? _passiveMoveSpeed : _passiveSpeed; - set => _passiveMoveSpeed = value > 0 ? value : 0; - } - - // Herded creatures walk at a fixed standard pace regardless of their own speed - // (RunUO's forced 0.3, without its TransformMoveDelay inflation to 0.6). - private const double HerdingMoveSpeed = 0.3; - [CommandProperty(AccessLevel.GameMaster)] public IPoint2D TargetLocation { get => _targetLocation; - set => _targetLocation = value; + set + { + _targetLocation = value; + AIObject?.OnCurrentSpeedChanged(); + } } [CommandProperty(AccessLevel.GameMaster)] public double CurrentSpeed { - get => _currentSpeed; + get => _targetLocation != null ? 0.3 : _currentSpeed; set { if (Math.Abs(_currentSpeed - value) > 0.0001) @@ -748,27 +718,8 @@ namespace Server.Mobiles } } - /// - /// Resolved seconds per step: a verbatim active/passive - /// maps to the matching movement value; a bespoke pace (e.g. the pet-order 0.1 sprint) - /// stays fused to both clocks. A herded creature is always driven at - /// . - /// [CommandProperty(AccessLevel.GameMaster)] - public double CurrentMoveSpeed - { - get - { - if (_targetLocation != null) - { - return HerdingMoveSpeed; - } - - return _currentSpeed == _activeSpeed ? ActiveMoveSpeed - : _currentSpeed == _passiveSpeed ? PassiveMoveSpeed - : _currentSpeed; - } - } + public double MoveSpeedMod { get; set; } [CommandProperty(AccessLevel.GameMaster)] public Point3D Home @@ -846,8 +797,6 @@ namespace Server.Mobiles [CommandProperty(AccessLevel.GameMaster)] public Point3D ControlDest { get; set; } - // Fires on every assignment, not only changes: a reissued order is a command - // (retarget, break off combat, re-anchor Home). Handlers receive the previous order. [CommandProperty(AccessLevel.GameMaster)] public OrderType ControlOrder { @@ -936,49 +885,20 @@ namespace Server.Mobiles public virtual bool GivesMLMinorArtifact => false; + /* To save on cpu usage, RunUO creatures only reacquire creatures under the following circumstances: + * - 10 seconds have elapsed since the last time it tried + * - The creature was attacked + * - Some creatures, like dragons, will reacquire when they see someone move + * + * This functionality appears to be implemented on OSI as well + */ + public long NextReacquireTime { get; set; } public virtual TimeSpan ReacquireDelay => TimeSpan.FromSeconds(10.0); - - // Reaction-time gradient: an enemy moving inside AcquireOnApproachRange pulls the - // next scan to at most this far away. Zero (paragons) scans on the very next - // think; larger is dumber; pure ReacquireDelay is the oblivious floor. - public virtual TimeSpan AcquireOnApproachDelay => m_Paragon ? TimeSpan.Zero : TimeSpan.FromSeconds(2.0); - - // Reactive range is tighter than the periodic scan's RangePerception: approach - // aggro starts on-screen; the ReacquireDelay poll keeps the wide ambient sweep. - public virtual int AcquireOnApproachRange => 10; - - // Clamps the scan deadline rather than opening the gate: repeated steps cannot - // shorten it further, so an armed creature scans once per delay period. - private void ScheduleAcquireOnApproach() - { - var delay = (long)AcquireOnApproachDelay.TotalMilliseconds; - var deadline = Core.TickCount + delay; - - if (deadline - NextReacquireTime < 0) - { - NextReacquireTime = deadline; - } - - if (delay <= 0) - { - // Zero: think now — the ranked scan engages within a wheel turn. Prod is - // spam-safe; the Combatant == null guard stops the prods once engaged. - AIObject?.AITimer?.Prod(); - } - } - - // IsEnemy first — it cheaply rejects the common case (a same-team wild creature - // wandering past); CanBeHarmful covers hidden movers via CanSee. - private bool ShouldAcquireOnApproach(Mobile m) => - Combatant == null && - !Controlled && !Summoned && !BardPacified && - FightMode != FightMode.None && FightMode != FightMode.Aggressor && - InRange(m.Location, AcquireOnApproachRange) && - IsEnemy(m) && CanBeHarmful(m, false); - public virtual bool ReacquireOnMovement => false; + public virtual bool AcquireOnApproach => m_Paragon; + public virtual int AcquireOnApproachRange => 10; public static bool Summoning { get; set; } @@ -1923,7 +1843,7 @@ namespace Server.Mobiles { base.Serialize(writer); - writer.Write(22); // version + writer.Write(20); // version writer.Write((int)m_CurrentAI); writer.Write((int)m_DefaultAI); @@ -1960,7 +1880,7 @@ namespace Server.Mobiles if (_summoned) { - writer.WriteAnchoredTime(SummonEnd); + writer.WriteDeltaTime(SummonEnd); } writer.Write(ControlSlots); @@ -2043,18 +1963,12 @@ namespace Server.Mobiles // Version 19 writer.Write(HomeMap); - - // Version 22 (0 = inherit the matching think value) - writer.Write(_activeMoveSpeed); - writer.Write(_passiveMoveSpeed); } public override void Deserialize(IGenericReader reader) { base.Deserialize(reader); - NextReacquireTime = Core.TickCount; - var version = reader.ReadInt(); m_CurrentAI = (AIType)reader.ReadInt(); @@ -2121,7 +2035,7 @@ namespace Server.Mobiles if (_summoned) { - SummonEnd = version >= 21 ? reader.ReadAnchoredTime() : reader.ReadDeltaTime(); + SummonEnd = reader.ReadDeltaTime(); new UnsummonTimer(this, SummonEnd - Core.Now).Start(); } @@ -2252,16 +2166,6 @@ namespace Server.Mobiles HomeMap = reader.ReadMap(); } - if (version >= 22) - { - _activeMoveSpeed = reader.ReadDouble(); - _passiveMoveSpeed = reader.ReadDouble(); - } - else - { - MigrateMoveSpeeds(); - } - if (version <= 14 && m_Paragon && Hue == 0x31) { Hue = Paragon.Hue; // Paragon hue fixed, should now be 0x501. @@ -2886,9 +2790,15 @@ namespace Server.Mobiles public override void OnMovement(Mobile m, Point3D oldLocation) { - if (ShouldAcquireOnApproach(m)) + if (AcquireOnApproach && !Controlled && !Summoned && !BardPacified && FightMode != FightMode.Aggressor) { - ScheduleAcquireOnApproach(); + if (InRange(m.Location, AcquireOnApproachRange) && !InRange(oldLocation, AcquireOnApproachRange) && + CanBeHarmful(m) && IsEnemy(m)) + { + Combatant = FocusMob = m; + AIObject?.MoveTo(m, true, 1); + DoHarmful(m); + } } else if (ReacquireOnMovement) { @@ -3123,12 +3033,14 @@ namespace Server.Mobiles return base.OnBeforeDeath(); } - public int ComputeBonusDamage(in ValueLinkList list, Mobile m) + public int ComputeBonusDamage(List list, Mobile m) { var bonus = 0; - foreach (var de in list.ByDescending()) + for (var i = list.Count - 1; i >= 0; --i) { + var de = list[i]; + if (de.Damager == m || de.Damager is not BaseCreature bc) { continue; @@ -3165,15 +3077,26 @@ namespace Server.Mobiles Combatant is PlayerMobile || Combatant is BaseCreature { Controlled: true } bc && bc.GetMaster() is PlayerMobile; - // Iterates most recent first, matching the previous reverse-indexed loop. The list is - // already pruned of expired entries by the Mobile.DamageEntries getter. - public static List GetLootingRights(in ValueLinkList damageEntries, int hitsMax) + public static List GetLootingRights(List damageEntries, int hitsMax) { var rights = new List(); DamageStore firstDamager = null; - foreach (var de in damageEntries.ByDescending()) + for (var i = damageEntries.Count - 1; i >= 0; --i) { + if (i >= damageEntries.Count) + { + continue; + } + + var de = damageEntries[i]; + + if (de.HasExpired) + { + damageEntries.RemoveAt(i); + continue; + } + var damage = de.DamageGiven; var respList = de.Responsible; @@ -4656,78 +4579,13 @@ namespace Server.Mobiles return false; } - /// - /// Sets the think clock and clears movement overrides (legacy one-clock semantics); - /// use for an independent movement pace. - /// public void SetSpeed(double active, double passive, bool isPassive = true) { ActiveSpeed = active; PassiveSpeed = passive; - ClearMoveSpeed(); CurrentSpeed = isPassive ? PassiveSpeed : ActiveSpeed; } - /// Sets only the movement clock (seconds per step). - public void SetMoveSpeed(double active, double passive) - { - ActiveMoveSpeed = active; - PassiveMoveSpeed = passive; - } - - /// Clears movement overrides; steps pace off the think clock again. - public void ClearMoveSpeed() - { - _activeMoveSpeed = 0; - _passiveMoveSpeed = 0; - } - - /// - /// Scales movement overrides (paragon and similar buffs). Inheriting values stay - /// inheriting — they already follow the scaled think clock. - /// - public void ScaleMoveSpeed(double scalar) - { - if (_activeMoveSpeed > 0) - { - _activeMoveSpeed *= scalar; - } - - if (_passiveMoveSpeed > 0) - { - _passiveMoveSpeed *= scalar; - } - } - - /// - /// Snaps speeds within rounding distance of the creature's table values back to - /// exact. A scaling buff that divides then multiplies can drift by an ulp (e.g. - /// 0.9 and 0.45 through 1.2), which would read as hand-tuned; call after undoing - /// such a buff. Genuinely tuned speeds are nowhere near the epsilon and keep. - /// - public void SnapSpeedsToTable() - { - GetSpeeds(out var activeSpeed, out var passiveSpeed); - - if (Math.Abs(_activeSpeed - activeSpeed) < 0.0001 && Math.Abs(_passiveSpeed - passiveSpeed) < 0.0001) - { - _activeSpeed = activeSpeed; - _passiveSpeed = passiveSpeed; - } - - GetMoveSpeeds(out var activeMoveSpeed, out var passiveMoveSpeed); - - if (activeMoveSpeed > 0 && Math.Abs(_activeMoveSpeed - activeMoveSpeed) < 0.0001) - { - _activeMoveSpeed = activeMoveSpeed; - } - - if (passiveMoveSpeed > 0 && Math.Abs(_passiveMoveSpeed - passiveMoveSpeed) < 0.0001) - { - _passiveMoveSpeed = passiveMoveSpeed; - } - } - [MethodImpl(MethodImplOptions.AggressiveInlining)] public void SetCurrentSpeedToActive() => CurrentSpeed = ActiveSpeed; @@ -5041,25 +4899,6 @@ namespace Server.Mobiles NPCSpeeds.GetSpeeds(this, out activeSpeed, out passiveSpeed); } - public virtual void GetMoveSpeeds(out double activeMoveSpeed, out double passiveMoveSpeed) - { - NPCSpeeds.GetMoveSpeeds(this, out activeMoveSpeed, out passiveMoveSpeed); - } - - // Pre-v22 saves carry no movement clock. A creature whose serialized think speeds - // still match what it would spawn with today was never hand-tuned: adopt today's - // move values so existing worlds (and pets) pick up npc-speeds pacing without a - // respawn. Tuned creatures keep movement inheriting their think clock. - internal void MigrateMoveSpeeds() - { - GetSpeeds(out var activeSpeed, out var passiveSpeed); - - if (_activeSpeed == activeSpeed && _passiveSpeed == passiveSpeed) - { - GetMoveSpeeds(out _activeMoveSpeed, out _passiveMoveSpeed); - } - } - public virtual void DropBackpack() { var backpack = Backpack; diff --git a/Projects/UOContent/Mobiles/Familiars/BaseFamiliar.cs b/Projects/UOContent/Mobiles/Familiars/BaseFamiliar.cs index 46d939f3b..d3b119523 100644 --- a/Projects/UOContent/Mobiles/Familiars/BaseFamiliar.cs +++ b/Projects/UOContent/Mobiles/Familiars/BaseFamiliar.cs @@ -92,7 +92,7 @@ public abstract partial class BaseFamiliar : BaseCreature Hidden = m_LastHidden = master.Hidden; } - if (AIObject?.WalkMobileRange(master, 5, 1, 1) == true) + if (AIObject?.WalkMobileRange(master, 5, false, 1, 1) == true) { Warmode = master.Warmode; Combatant = master.Combatant; diff --git a/Projects/UOContent/Mobiles/Hireables/BaseHire.cs b/Projects/UOContent/Mobiles/Hireables/BaseHire.cs index 3f96e5d36..e3fbadd97 100644 --- a/Projects/UOContent/Mobiles/Hireables/BaseHire.cs +++ b/Projects/UOContent/Mobiles/Hireables/BaseHire.cs @@ -23,14 +23,19 @@ public partial class BaseHire : BaseCreature public int GoldOnDeath { get; set; } - [SerializableField(1, fieldChanged: nameof(OnIsHiredChanged))] - [SerializedCommandProperty(AccessLevel.GameMaster)] - [InvalidateProperties] - private bool _isHired; - - private void OnIsHiredChanged(bool oldValue, bool newValue) + [SerializableProperty(1)] + [CommandProperty(AccessLevel.GameMaster)] + public bool IsHired { - Delta(MobileDelta.Noto); + get => _isHired; + set + { + _isHired = value; + + Delta(MobileDelta.Noto); + InvalidateProperties(); + this.MarkDirty(); + } } public BaseHire(AIType AI) : base(AI, FightMode.Aggressor) diff --git a/Projects/UOContent/Mobiles/Monsters/LBR/Meers/EnragedCreatures.cs b/Projects/UOContent/Mobiles/Monsters/LBR/Meers/EnragedCreatures.cs index ce4f3526d..91367a5f0 100644 --- a/Projects/UOContent/Mobiles/Monsters/LBR/Meers/EnragedCreatures.cs +++ b/Projects/UOContent/Mobiles/Monsters/LBR/Meers/EnragedCreatures.cs @@ -108,7 +108,7 @@ namespace Server.Mobiles */ else if (!Combat(this)) { - AIObject?.MoveTo(SummonMaster, 5); + AIObject?.MoveTo(SummonMaster, false, 5); } /* On OSI, if the summon attacks a mobile, the summoner meer also diff --git a/Projects/UOContent/Mobiles/NPCSpeeds.cs b/Projects/UOContent/Mobiles/NPCSpeeds.cs index 8f5e908bb..a61ce70c6 100644 --- a/Projects/UOContent/Mobiles/NPCSpeeds.cs +++ b/Projects/UOContent/Mobiles/NPCSpeeds.cs @@ -38,22 +38,6 @@ public static class NPCSpeeds passiveSpeed = sp.PassiveSpeed; } - // Move speeds are optional (0 = inherit), so this tolerates a missing entry or table. - public static void GetMoveSpeeds(BaseCreature bc, out double activeMoveSpeed, out double passiveMoveSpeed) - { - if ((bc.SpeedClass == SpeedLevel.None || !_speedsByLevel.TryGetValue(bc.SpeedClass, out var sp)) && - !_speedsByType.TryGetValue(bc.GetType(), out sp) && - !_speedsByLevel.TryGetValue(SpeedLevel.Medium, out sp)) - { - activeMoveSpeed = 0; - passiveMoveSpeed = 0; - return; - } - - activeMoveSpeed = sp.ActiveMoveSpeed; - passiveMoveSpeed = sp.PassiveMoveSpeed; - } - public static void RegisterSpeed(SpeedClassEntry entry) { _speedsByLevel[entry.Level] = entry; @@ -94,13 +78,6 @@ public static class NPCSpeeds [JsonPropertyName("passive")] public double PassiveSpeed { get; init; } - // Movement clock (seconds per step); absent/0 = inherit the matching think value. - [JsonPropertyName("activeMove")] - public double ActiveMoveSpeed { get; init; } - - [JsonPropertyName("passiveMove")] - public double PassiveMoveSpeed { get; init; } - [JsonPropertyName("types")] public HashSet Types { get; init; } } diff --git a/Projects/UOContent/Mobiles/Special/Harrower.cs b/Projects/UOContent/Mobiles/Special/Harrower.cs index 8d87943bc..ee3fa1873 100644 --- a/Projects/UOContent/Mobiles/Special/Harrower.cs +++ b/Projects/UOContent/Mobiles/Special/Harrower.cs @@ -1,7 +1,6 @@ using System; using System.Collections.Generic; using ModernUO.Serialization; -using Server.Collections; using Server.Engines.CannedEvil; using Server.Engines.Virtues; using Server.Items; diff --git a/Projects/UOContent/Mobiles/Special/Paragon.cs b/Projects/UOContent/Mobiles/Special/Paragon.cs index cbd0a30c7..825609e22 100644 --- a/Projects/UOContent/Mobiles/Special/Paragon.cs +++ b/Projects/UOContent/Mobiles/Special/Paragon.cs @@ -79,7 +79,6 @@ public static class Paragon bc.PassiveSpeed /= SpeedBuff; bc.ActiveSpeed /= SpeedBuff; - bc.ScaleMoveSpeed(1.0 / SpeedBuff); bc.CurrentSpeed = bc.PassiveSpeed; bc.DamageMin += DamageBuff; @@ -144,8 +143,6 @@ public static class Paragon bc.PassiveSpeed *= SpeedBuff; bc.ActiveSpeed *= SpeedBuff; - bc.ScaleMoveSpeed(SpeedBuff); - bc.SnapSpeedsToTable(); // an ulp of scaling drift must not read as hand-tuned bc.CurrentSpeed = bc.PassiveSpeed; bc.DamageMin -= DamageBuff; diff --git a/Projects/UOContent/Mobiles/Townfolk/BaseEscortable.cs b/Projects/UOContent/Mobiles/Townfolk/BaseEscortable.cs index cf439a76c..462307be3 100644 --- a/Projects/UOContent/Mobiles/Townfolk/BaseEscortable.cs +++ b/Projects/UOContent/Mobiles/Townfolk/BaseEscortable.cs @@ -17,7 +17,7 @@ using EDI = Server.Mobiles.EscortDestinationInfo; namespace Server.Mobiles; -[SerializationGenerator(3, false)] +[SerializationGenerator(2, false)] public partial class BaseEscortable : BaseCreature { private static readonly ILogger logger = LogFactory.GetLogger(typeof(BaseEscortable)); @@ -158,22 +158,16 @@ public partial class BaseEscortable : BaseCreature [SerializableField(0, setter: "private")] private string _destinationString; + [TimerDrift] [SerializableField(1)] - [DeserializeTimer(nameof(DeserializeDeleteTimer))] private Timer _deleteTimer; - private void DeserializeDeleteTimer(TimeSpan delay) => Timer.DelayCall(delay, Delete); - - private void MigrateFrom(V2Content content) + [DeserializeTimerField(1)] + private void DeserializeDeleteTimer(TimeSpan delay) { - _destinationString = content.DestinationString; - _mlQuestType = content.MlQuestType; - _mlQuestDestinationMessage = content.MlQuestDestinationMessage; - _mlQuestPaymentMessage = content.MlQuestPaymentMessage; - - if (content.DeleteTimerDelay != TimeSpan.MinValue) + if (delay >= TimeSpan.Zero) { - DeserializeDeleteTimer(content.DeleteTimerDelay); + Timer.DelayCall(delay, Delete); } } diff --git a/Projects/UOContent/Mobiles/Vendors/Barkeeper/PlayerBarkeeper.cs b/Projects/UOContent/Mobiles/Vendors/Barkeeper/PlayerBarkeeper.cs index bbbc3ff6f..d80c60dcc 100644 --- a/Projects/UOContent/Mobiles/Vendors/Barkeeper/PlayerBarkeeper.cs +++ b/Projects/UOContent/Mobiles/Vendors/Barkeeper/PlayerBarkeeper.cs @@ -148,13 +148,18 @@ public partial class PlayerBarkeeper : BaseVendor LoadSBInfo(); } - [SerializableField(0, fieldChanged: nameof(OnHouseChanged))] - private BaseHouse _house; - - private void OnHouseChanged(BaseHouse oldValue, BaseHouse newValue) + [SerializableProperty(0)] + public BaseHouse House { - oldValue?.PlayerBarkeepers.Remove(this); - newValue?.PlayerBarkeepers.Add(this); + get => _house; + set + { + _house?.PlayerBarkeepers.Remove(this); + value?.PlayerBarkeepers.Add(this); + + _house = value; + this.MarkDirty(); + } } public override bool IsActiveBuyer => false; diff --git a/Projects/UOContent/Mobiles/Vendors/PlayerVendor.cs b/Projects/UOContent/Mobiles/Vendors/PlayerVendor.cs index 9b8f5a2f3..2ff6eb812 100644 --- a/Projects/UOContent/Mobiles/Vendors/PlayerVendor.cs +++ b/Projects/UOContent/Mobiles/Vendors/PlayerVendor.cs @@ -22,20 +22,9 @@ public class PlayerVendorTargetAttribute : Attribute; * Next, uncomment the MigrateFrom function and change the `V3Content` type to match the serialization version * before it was bumped. Then run publish.cmd to generate the migration file. */ -[SerializationGenerator(4, false)] +[SerializationGenerator(3, false)] public partial class PlayerVendor : Mobile { - private void MigrateFrom(V3Content content) - { - _shopName = content.ShopName; - _nextPayTime = content.NextPayTime; - _house = content.House; - _owner = content.Owner; - _bankAccount = content.BankAccount; - _holdGold = content.HoldGold; - _sellItems = content.SellItems; - } - private Timer _payTimer; [InvalidateProperties] @@ -43,7 +32,7 @@ public partial class PlayerVendor : Mobile [SerializedCommandProperty(AccessLevel.GameMaster)] private string _shopName; - [AnchoredDateTime] + [DeltaDateTime] [SerializableField(1, setter: "private")] [SerializedCommandProperty(AccessLevel.GameMaster)] private DateTime _nextPayTime; @@ -105,13 +94,18 @@ public partial class PlayerVendor : Mobile public PlayerVendorPlaceholder Placeholder { get; set; } - [SerializableField(2, fieldChanged: nameof(OnHouseChanged))] - private BaseHouse _house; - - private void OnHouseChanged(BaseHouse oldValue, BaseHouse newValue) + [SerializableProperty(2)] + public BaseHouse House { - oldValue?.PlayerVendors.Remove(this); - newValue?.PlayerVendors.Add(this); + get => _house; + set + { + _house?.PlayerVendors.Remove(this); + value?.PlayerVendors.Add(this); + + _house = value; + this.MarkDirty(); + } } public int ChargePerDay diff --git a/Projects/UOContent/Mobiles/Vendors/RentedVendor.cs b/Projects/UOContent/Mobiles/Vendors/RentedVendor.cs index d7d0a3ab6..99d818826 100644 --- a/Projects/UOContent/Mobiles/Vendors/RentedVendor.cs +++ b/Projects/UOContent/Mobiles/Vendors/RentedVendor.cs @@ -46,20 +46,9 @@ public class VendorRentalDuration } } -[SerializationGenerator(1)] +[SerializationGenerator(0)] public partial class RentedVendor : PlayerVendor { - private void MigrateFrom(V0Content content) - { - _rentalDurationId = content.RentalDurationId; - _rentalPrice = content.RentalPrice; - _landlordRenew = content.LandlordRenew; - _renterRenew = content.RenterRenew; - _renewalPrice = content.RenewalPrice; - _rentalGold = content.RentalGold; - _rentalExpireTime = content.RentalExpireTime; - } - private Timer _rentalExpireTimer; public RentedVendor( @@ -104,7 +93,7 @@ public partial class RentedVendor : PlayerVendor [SerializedCommandProperty(AccessLevel.GameMaster)] private int _rentalGold; - [AnchoredDateTime] + [DeltaDateTime] [SerializableField(6)] [SerializedCommandProperty(AccessLevel.GameMaster)] private DateTime _rentalExpireTime; diff --git a/Projects/UOContent/Mobiles/Vendors/VendorInventory.cs b/Projects/UOContent/Mobiles/Vendors/VendorInventory.cs index 8f0a1fba8..d4d5ce4bd 100644 --- a/Projects/UOContent/Mobiles/Vendors/VendorInventory.cs +++ b/Projects/UOContent/Mobiles/Vendors/VendorInventory.cs @@ -37,7 +37,7 @@ namespace Server.Mobiles Items = reader.ReadEntityList(); Gold = reader.ReadInt(); - ExpireTime = version >= 1 ? reader.ReadAnchoredTime() : reader.ReadDeltaTime(); + ExpireTime = reader.ReadDeltaTime(); if (Items.Count == 0 && Gold == 0) { @@ -88,7 +88,7 @@ namespace Server.Mobiles public void Serialize(IGenericWriter writer) { - writer.WriteEncodedInt(1); // version + writer.WriteEncodedInt(0); // version writer.Write(Owner); writer.Write(VendorName); @@ -98,7 +98,7 @@ namespace Server.Mobiles writer.Write(Items); writer.Write(Gold); - writer.WriteAnchoredTime(ExpireTime); + writer.WriteDeltaTime(ExpireTime); } private class ExpireTimer : Timer diff --git a/Projects/UOContent/Mobiles/Vendors/VendorItem.cs b/Projects/UOContent/Mobiles/Vendors/VendorItem.cs index 99d1446e6..14af3656a 100644 --- a/Projects/UOContent/Mobiles/Vendors/VendorItem.cs +++ b/Projects/UOContent/Mobiles/Vendors/VendorItem.cs @@ -32,20 +32,18 @@ public partial class VendorItem public string FormattedPrice => Core.ML ? Price.ToString("N0", CultureInfo.GetCultureInfo("en-US")) : Price.ToString(); - [SerializableField(2, fieldChanged: nameof(OnDescriptionChanged), allowFieldChange: nameof(AllowDescriptionChange))] - private string _description; - - private bool AllowDescriptionChange(ref string value) + [SerializableProperty(2)] + public string Description { - value = value ?? ""; - return true; - } - - private void OnDescriptionChanged(string oldValue, string newValue) - { - if (Valid) + get => _description; + set { - Item.InvalidateProperties(); + _description = value ?? ""; + + if (Valid) + { + Item.InvalidateProperties(); + } } } diff --git a/Projects/UOContent/Multis/Boats/BaseBoat.cs b/Projects/UOContent/Multis/Boats/BaseBoat.cs index ed618b91a..2082056da 100644 --- a/Projects/UOContent/Multis/Boats/BaseBoat.cs +++ b/Projects/UOContent/Multis/Boats/BaseBoat.cs @@ -19,24 +19,9 @@ namespace Server.Multis Single } - [SerializationGenerator(5, false)] + [SerializationGenerator(4, false)] public abstract partial class BaseBoat : BaseMulti { - private void MigrateFrom(V4Content content) - { - _mapItem = content.MapItem; - _nextNavPoint = content.NextNavPoint; - _facing = content.Facing; - _timeOfDecay = content.TimeOfDecay; - _owner = content.Owner; - _pPlank = content.PPlank; - _sPlank = content.SPlank; - _tillerMan = content.TillerMan; - _hold = content.Hold; - _anchored = content.Anchored; - _shipName = content.ShipName; - } - public enum DryDockResult { Valid, @@ -151,23 +136,31 @@ namespace Server.Multis } } - [SerializableField(3, fieldChanged: nameof(OnTimeOfDecayChanged))] - [AnchoredDateTime] - [SerializedCommandProperty(AccessLevel.GameMaster)] - private DateTime _timeOfDecay; - - private void OnTimeOfDecayChanged(DateTime oldValue, DateTime newValue) + [DeltaDateTime] + [SerializableProperty(3)] + [CommandProperty(AccessLevel.GameMaster)] + public DateTime TimeOfDecay { - TillerMan?.InvalidateProperties(); + get => _timeOfDecay; + set + { + _timeOfDecay = value; + TillerMan?.InvalidateProperties(); + this.MarkDirty(); + } } - [SerializableField(10, fieldChanged: nameof(OnShipNameChanged))] - [SerializedCommandProperty(AccessLevel.GameMaster)] - private string _shipName; - - private void OnShipNameChanged(string oldValue, string newValue) + [SerializableProperty(10)] + [CommandProperty(AccessLevel.GameMaster)] + public string ShipName { - TillerMan?.InvalidateProperties(); + get => _shipName; + set + { + _shipName = value; + TillerMan?.InvalidateProperties(); + this.MarkDirty(); + } } [CommandProperty(AccessLevel.GameMaster)] diff --git a/Projects/UOContent/Multis/Camps/BaseCamp.cs b/Projects/UOContent/Multis/Camps/BaseCamp.cs index e60e3e46e..e3d10dd0b 100644 --- a/Projects/UOContent/Multis/Camps/BaseCamp.cs +++ b/Projects/UOContent/Multis/Camps/BaseCamp.cs @@ -6,16 +6,9 @@ using Server.Mobiles; namespace Server.Multis; -[SerializationGenerator(2, false)] +[SerializationGenerator(1, false)] public abstract partial class BaseCamp : BaseMulti { - private void MigrateFrom(V1Content content) - { - _items = content.Items; - _mobiles = content.Mobiles; - _decayTime = content.DecayTime; - } - [Tidy] [SerializableField(0, setter: "private")] private List _items; @@ -24,7 +17,7 @@ public abstract partial class BaseCamp : BaseMulti [SerializableField(1, setter: "private")] private List _mobiles; - [AnchoredDateTime] + [DeltaDateTime] [SerializableField(2, setter: "private")] private DateTime _decayTime; diff --git a/Projects/UOContent/Multis/ComponentVerification.cs b/Projects/UOContent/Multis/ComponentVerification.cs index dda094aa4..967fed1bc 100644 --- a/Projects/UOContent/Multis/ComponentVerification.cs +++ b/Projects/UOContent/Multis/ComponentVerification.cs @@ -4,14 +4,11 @@ using System.Collections.Generic; using System.IO; using System.IO.Compression; using Server.Compression; -using Server.Logging; namespace Server.Multis; public static class ComponentVerification { - private static readonly ILogger logger = LogFactory.GetLogger(typeof(ComponentVerification)); - private static int[] _itemTable; private static int[] _multiTable; private static bool _loaded; @@ -23,15 +20,6 @@ public static class ComponentVerification // encodes them) while AOS/SE/ML/... line up unchanged. private const int HousingTierMask = (int)HousingFlags.HousingEJ; - // Table sentinels. Slots start as NotAComponent, which CheckValidity rejects, so a piece that - // never gets registered can never be placed. NoFeatureRequired is a piece with no expansion - // requirement: walls.txt encodes pre-AOS base pieces as 0, housing.bin collapses to 0 under - // HousingTierMask. - private const int NotAComponent = -1; - private const int NoFeatureRequired = 0; - - private const string FeatureMaskColumn = "FeatureMask"; - public static bool IsItemValid(int itemID) { EnsureLoaded(); @@ -45,8 +33,7 @@ public static class ComponentVerification } private static bool CheckValidity(int val) => - val != NotAComponent && - (val == NoFeatureRequired || ((int)ExpansionInfo.CoreExpansion.HousingFlags & val) != 0); + val != -1 && (val == 0 || ((int)ExpansionInfo.CoreExpansion.HousingFlags & val) != 0); private static void EnsureLoaded() { @@ -55,50 +42,22 @@ public static class ComponentVerification return; } - // Set before loading: this runs from the design packet handler, so a bad file must not - // re-read every sheet on each later placement attempt. Sheets below fail independently. _loaded = true; _itemTable = CreateTable(TileData.MaxItemValue); _multiTable = CreateTable(0x4000); var housingPath = MultiData.HousingUOPPath; - if (housingPath != null && TryLoadFromHousingBin(housingPath)) + if (housingPath != null) { + var entry = MultiData.HousingEntry; + LoadFromHousingBin(ReadUOPEntry(housingPath, entry)); return; } LoadFromTxtFiles(); } - private static bool TryLoadFromHousingBin(string path) - { - try - { - var data = ReadUOPEntry(path, MultiData.HousingEntry); - if (data != null) - { - LoadFromHousingBin(data); - return true; - } - - logger.Warning( - "Could not decompress housing.bin from {Path}. Falling back to the component sheets", - path - ); - } - catch (Exception ex) - { - logger.Warning( - ex, - "Failed to read housing.bin from {Path}. Falling back to the component sheets", - path - ); - } - - return false; - } - private static byte[] ReadUOPEntry(string path, UOPEntry entry) { using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read); @@ -264,55 +223,16 @@ public static class ComponentVerification return; } - Spreadsheet ss; + var ss = new Spreadsheet(path); - try - { - ss = new Spreadsheet(path); - } - catch (Exception ex) - { - // One unreadable sheet must not take the others down with it. - logger.Error(ex, "Could not read house components from {Path}", path); - return; - } - - // GetInt32 on a missing column yields NoFeatureRequired, which would register every piece in - // the sheet as unconditionally placeable. Refuse the sheet instead. - var featureCID = ss.GetColumnID(FeatureMaskColumn); - if (featureCID < 0) - { - logger.Error( - "House component sheet {Path} has no {Column} column. Its pieces will not be registered", - path, - FeatureMaskColumn - ); - return; - } - - // An individual missing column is expected - older sheets predate walls.txt's - // SecondAltWindowS/E - but a sheet matching none of them is not the sheet we expect. var tileCIDs = new int[tileColumns.Length]; - var matchedColumns = 0; for (var i = 0; i < tileColumns.Length; ++i) { tileCIDs[i] = ss.GetColumnID(tileColumns[i]); - - if (tileCIDs[i] >= 0) - { - matchedColumns++; - } } - if (matchedColumns == 0) - { - logger.Error( - "House component sheet {Path} has none of its expected tile columns. Its pieces will not be registered", - path - ); - return; - } + var featureCID = ss.GetColumnID("FeatureMask"); for (var i = 0; i < ss.Records.Length; ++i) { @@ -340,7 +260,7 @@ public static class ComponentVerification for (var i = 0; i < table.Length; ++i) { - table[i] = NotAComponent; + table[i] = -1; } return table; @@ -357,18 +277,11 @@ public class Spreadsheet var types = ReadLine(ip); var names = ReadLine(ip); - if (types == null || names == null) - { - throw new InvalidDataException($"House component sheet '{path}' is missing its header rows."); - } - m_Columns = new ColumnInfo[types.Length]; for (var i = 0; i < m_Columns.Length; ++i) { - // A names row shorter than the types row leaves the extras unnamed, so nothing resolves - // to them. - m_Columns[i] = new ColumnInfo(i, types[i], i < names.Length ? names[i] : ""); + m_Columns[i] = new ColumnInfo(i, types[i], names[i]); } var records = new List(); @@ -381,15 +294,10 @@ public class Spreadsheet { var ci = m_Columns[i]; - // Client sheets write an empty trailing Comment as a plain newline, leaving the row - // one field short. The client only requires the columns up to FeatureMask, so treat - // the missing field as empty rather than dropping a row that lists real pieces. - var value = ci.m_DataIndex < values.Length ? values[ci.m_DataIndex] : null; - data[i] = ci.m_Type switch { - "int" => Utility.ToInt32(value), - "string" => value, + "int" => Utility.ToInt32(values[ci.m_DataIndex]), + "string" => values[ci.m_DataIndex], _ => data[i] }; } @@ -419,10 +327,7 @@ public class Spreadsheet { while (ip.ReadLine() is { } line) { - // Whitespace-only, not merely empty: the retail client's doors.txt separates its header - // rows with lines of bare tabs, and accepting one as the names row leaves every column - // unnamed, so no door resolves. The client skips them the same way. - if (!string.IsNullOrWhiteSpace(line)) + if (line.Length > 0) { return line.Split('\t'); } diff --git a/Projects/UOContent/Network/AutoDenylist/AutoDenylist.cs b/Projects/UOContent/Network/AutoDenylist/AutoDenylist.cs index 7b3c44ae4..a55280e1a 100644 --- a/Projects/UOContent/Network/AutoDenylist/AutoDenylist.cs +++ b/Projects/UOContent/Network/AutoDenylist/AutoDenylist.cs @@ -17,6 +17,7 @@ using System; using System.Collections.Generic; using System.Net; using System.Threading; +using Server.Collections; using Server.Logging; using Server.Network.Bans; @@ -28,30 +29,16 @@ namespace Server.Network; /// /// The local half of promotion. Contributing to CrowdSec only helps once an OS bouncer reacts; until then /// every reconnect costs a socket, a buffer and a NetState slot — and the verdicts that matter most -/// are reachable only after reading bytes, like a zero seed. It is also the whole defense on a shard running +/// are reachable only after reading bytes, like a zero seed. It is also the whole defence on a shard running /// no bouncer, which is the default. Not persisted, by design: a holding pen that survives restarts is a ban /// without a ban's review. Only verdicts are held. /// -/// -/// A hold is never refreshed, so every expiry is insertion + duration and the ring is sorted by -/// construction. Retiring lapsed entries is therefore the number expiring rather than the number held, which -/// is what lets the cap be sized for the flood instead of for a scan. -/// public static class AutoDenylist { private static readonly ILogger logger = LogFactory.GetLogger(typeof(AutoDenylist)); - // Membership only (normalized v6 bits). Loop-only. The expiry lives beside the key in the ring, so - // there is exactly one copy of it and the two cannot disagree. - private static readonly HashSet _held = []; - - // The same keys in expiry order. Parallel arrays rather than an array of structs: UInt128 forces - // 16-byte alignment, so a packed (key, expiry) struct costs 32 bytes where these cost 24 -- and the - // drain reads only the long[], 8 sequential bytes per entry. - private static UInt128[] _ringKeys = []; - private static long[] _ringExpiry = []; - private static int _ringHead; - private static int _ringCount; + // Address (normalized v6 bits) -> Core.TickCount at which the hold lapses. Loop-only. + private static readonly Dictionary _held = []; private static bool _enabled; private static long _durationMs; @@ -60,9 +47,6 @@ public static class AutoDenylist public static int Count => _held.Count; - // Test seam: the ring and the set hold the same entries, and nothing else may assume it. - internal static int RingCount => _ringCount; - public static void Configure() { AutoDenylistConfiguration.Load(); @@ -94,47 +78,35 @@ public static class AutoDenylist return false; } - Drain(nowTicks); - var key = address.ToUInt128(); - // Deliberately not refreshed: the first detection sets the expiry and later ones leave it alone. - // That keeps insertion order equal to expiry order, which is why the drain can stop at the first - // live record. A flooder whose hold lapses trips the rate limiter on its next attempt -- which - // runs ahead of the connection filters -- and is held again. - if (!_held.Add(key)) + // An address already held is just extended, so no cap check is needed. + if (!_held.ContainsKey(key) && _held.Count >= _maxEntries) { - return true; - } + Sweep(nowTicks); - // Drain already reclaimed everything reclaimable, so being over now means genuinely full. - if (_held.Count > _maxEntries) - { - _held.Remove(key); - - if (!_warnedFull) + if (_held.Count >= _maxEntries) { - _warnedFull = true; - logger.Warning( - "Auto-denylist is full at {Max} addresses; further detections are disconnected but not held", - _maxEntries - ); - } + if (!_warnedFull) + { + _warnedFull = true; + logger.Warning( + "Auto-denylist is full at {Max} addresses; further detections are disconnected but not held", + _maxEntries + ); + } - return false; + return false; + } } - Push(key, nowTicks + _durationMs); + _held[key] = nowTicks + _durationMs; return true; } public static bool IsDenied(IPAddress address) => IsDenied(address, Core.TickCount); - /// - /// The accept-path decision, split out so the policy can be tested without a clock. Drains first: the - /// expiry lives in the ring, not beside the membership, so a lapsed hold has to be retired here rather - /// than expired on read. One array read when nothing has lapsed. - /// + /// The pure decision, split out so the accept-path policy can be tested without a clock. internal static bool IsDenied(IPAddress address, long nowTicks) { if (!_enabled || address == null) @@ -142,136 +114,50 @@ public static class AutoDenylist return false; } - Drain(nowTicks); - return _held.Contains(address.ToUInt128()); + // Decided on read, so a lapsed hold cannot deny even before the sweep. Subtraction: TickCount wraps. + return _held.TryGetValue(address.ToUInt128(), out var expires) && expires - nowTicks > 0; } /// Releases an address early, e.g. when an operator retracts a ban. public static void Release(IPAddress address) { - if (!_enabled || address == null) + if (_enabled && address != null) + { + _held.Remove(address.ToUInt128()); + } + } + + internal static void Sweep(long nowTicks) + { + if (_held.Count == 0) { return; } - var key = address.ToUInt128(); - if (_held.Remove(key)) - { - // The ring record has to go too. Nothing records that this key was released, so if it were - // detected again before the old record lapsed, that record would retire the new hold early. - // O(n), but this is an operator retraction, not the accept path. - PurgeRing(key); - } - } + using var lapsed = new PooledRefList(16); - /// - /// Retires everything that has lapsed. Expiries only ever increase along the ring, so the first live - /// record ends the scan and the cost is the number actually expiring, not the number held. - /// - internal static void Drain(long nowTicks) - { - var before = _ringCount; - - // Subtraction, never a direct compare: tick counts wrap. See dev-docs/tick-counts.md. - while (_ringCount > 0 && _ringExpiry[_ringHead] - nowTicks <= 0) + foreach (var (address, expires) in _held) { - _held.Remove(_ringKeys[_ringHead]); - _ringHead = _ringHead + 1 == _ringKeys.Length ? 0 : _ringHead + 1; - _ringCount--; + if (expires - nowTicks <= 0) + { + lapsed.Add(address); + } } - if (_ringCount != before) + for (var i = 0; i < lapsed.Count; i++) + { + _held.Remove(lapsed[i]); + } + + if (lapsed.Count > 0) { _warnedFull = false; } } - private static void Push(UInt128 key, long expiry) - { - if (_ringCount == _ringKeys.Length) - { - Grow(); - } - - var tail = _ringHead + _ringCount; - if (tail >= _ringKeys.Length) - { - tail -= _ringKeys.Length; - } - - _ringKeys[tail] = key; - _ringExpiry[tail] = expiry; - _ringCount++; - } - - private static void Grow() - { - // Capped at the entry cap: Push only runs below it, so the ring never needs more, and doubling - // past it would reserve roughly twice the slots it can ever use. - var size = Math.Min(Math.Max(64, _ringKeys.Length * 2), _maxEntries); - var keys = new UInt128[size]; - var expiry = new long[size]; - - for (var i = 0; i < _ringCount; i++) - { - var from = _ringHead + i; - if (from >= _ringKeys.Length) - { - from -= _ringKeys.Length; - } - - keys[i] = _ringKeys[from]; - expiry[i] = _ringExpiry[from]; - } - - _ringKeys = keys; - _ringExpiry = expiry; - _ringHead = 0; - } - - private static void PurgeRing(UInt128 key) - { - var capacity = _ringKeys.Length; - - for (var i = 0; i < _ringCount; i++) - { - var at = _ringHead + i; - if (at >= capacity) - { - at -= capacity; - } - - if (_ringKeys[at] != key) - { - continue; - } - - // Close the gap so the ring stays contiguous and expiry-ordered. - for (var j = i; j < _ringCount - 1; j++) - { - var to = _ringHead + j; - if (to >= capacity) - { - to -= capacity; - } - - var from = to + 1 == capacity ? 0 : to + 1; - _ringKeys[to] = _ringKeys[from]; - _ringExpiry[to] = _ringExpiry[from]; - } - - _ringCount--; - return; - } - } - internal static void LoadForTesting(bool enabled, long durationMs, int maxEntries) { _held.Clear(); - _ringKeys = []; - _ringExpiry = []; - _ringHead = 0; - _ringCount = 0; _enabled = enabled; _durationMs = durationMs; _maxEntries = maxEntries; @@ -282,8 +168,6 @@ public static class AutoDenylist /// Accept-path gate for . public sealed class AutoDenylistFilter : IConnectionFilter { - private Timer _sweepTimer; - public string Name => "auto-denylist"; public void Register() @@ -292,20 +176,12 @@ public sealed class AutoDenylistFilter : IConnectionFilter public void Start(CancellationToken token) { - // Only reclaims memory: Hold and IsDenied both drain, so this matters on a shard that has gone - // quiet after a flood and would otherwise hold the ring until someone next connects. - _sweepTimer = Timer.DelayCall( - TimeSpan.FromMinutes(1), - TimeSpan.FromMinutes(1), - () => AutoDenylist.Drain(Core.TickCount) - ); + // Only an optimisation: IsDenied expires on read. + Timer.DelayCall(TimeSpan.FromMinutes(1), TimeSpan.FromMinutes(1), () => AutoDenylist.Sweep(Core.TickCount)); } public void Stop() { - // Recurring, so an uncancelled sweep survives Stop and the next Start adds a second one. - _sweepTimer?.Stop(); - _sweepTimer = null; } public bool ShouldDeny(IPAddress address) => AutoDenylist.IsDenied(address); diff --git a/Projects/UOContent/Network/AutoDenylist/AutoDenylistConfiguration.cs b/Projects/UOContent/Network/AutoDenylist/AutoDenylistConfiguration.cs index fd13ef003..4d45da350 100644 --- a/Projects/UOContent/Network/AutoDenylist/AutoDenylistConfiguration.cs +++ b/Projects/UOContent/Network/AutoDenylist/AutoDenylistConfiguration.cs @@ -21,8 +21,9 @@ using Server.Json; namespace Server.Network; /// -/// Loads the from Configuration/auto-denylist.json. Loaded once; -/// a missing file writes a template so operators have something to edit. +/// Loads the from Configuration/auto-denylist.json (matching the +/// per-feature JSON config pattern used by BlocklistConfiguration). Loaded once; a missing file writes +/// a template so operators have something to edit. /// public static class AutoDenylistConfiguration { @@ -75,14 +76,6 @@ public record AutoDenylistSettings /// stops it becoming the exhaustion it prevents. At the cap new addresses are not tracked, but are still /// disconnected by whichever gate detected them. /// - /// - /// A HashSet capacity, not a round number. Grown from empty it steps 36,353 → 75,431 → 156,437 - /// → 324,449, so this fills one exactly instead of stranding slots: 65,536 sat just past a resize and - /// left 9,895 of them unusable. Sized to cover the 50k–250k distinct-source floods seen in practice, - /// for ~19 MB — 36 bytes a set slot plus 24 for the ring record. Raising it is bounded by memory - /// rather than by a scan, since holds are retired from the ring in expiry order; a flood past it wants - /// upstream scrubbing rather than a larger cap. - /// [JsonPropertyName("maxEntries")] - public int MaxEntries { get; set; } = 324_449; + public int MaxEntries { get; set; } = 65536; } diff --git a/Projects/UOContent/Network/BanExemptions.cs b/Projects/UOContent/Network/BanExemptions.cs index c5f6b726a..5566bd5e7 100644 --- a/Projects/UOContent/Network/BanExemptions.cs +++ b/Projects/UOContent/Network/BanExemptions.cs @@ -20,7 +20,7 @@ using Server.Network.Bans; namespace Server.Network; /// -/// Combines and into the one answer +/// Combines and into the one answer /// asks for, so neither source has to know about the other. /// public static class BanExemptions @@ -51,7 +51,7 @@ public static class BanExemptions } // Deliberate and unconditional, so it wins and must not spend the earned list's strikes. - if (ManualAllowlist.Contains(address)) + if (FileAllowlist.Contains(address)) { return true; } diff --git a/Projects/UOContent/Network/Blocklist/BlocklistConfiguration.cs b/Projects/UOContent/Network/Blocklist/BlocklistConfiguration.cs index 19541636f..8a2df5ca6 100644 --- a/Projects/UOContent/Network/Blocklist/BlocklistConfiguration.cs +++ b/Projects/UOContent/Network/Blocklist/BlocklistConfiguration.cs @@ -21,8 +21,9 @@ using Server.Json; namespace Server.Network.Bans; /// -/// Loads the from Configuration/blocklist.json. Loaded once; a -/// missing file writes a template so operators have something to edit. +/// Loads the from Configuration/blocklist.json (matching the +/// per-feature JSON config pattern used by AssistantConfiguration). Loaded once; a missing file +/// writes a template so operators have something to edit. /// public static class BlocklistConfiguration { @@ -52,19 +53,12 @@ public static class BlocklistConfiguration } /// -/// Bound configuration for . The filter is inert unless -/// is set and points at a list that exists, so a shard that never runs the generator -/// pays nothing for the defaults. +/// Bound configuration for . The filter is inert unless +/// points at a list that actually exists, so the shipped defaults are safe on a shard that never runs +/// the generator. /// public record BlocklistSettings { - /// - /// Whether the accept-path gate runs at all. Off by default: the reload poll runs for the whole - /// uptime, which no shard should pay before an operator has chosen to run a blocklist. - /// - [JsonPropertyName("enabled")] - public bool Enabled { get; set; } - /// /// Path to the blocklist. A relative path resolves against ; an /// absolute path is used as-is (handy when several shards share one generated list). Set to @@ -73,6 +67,19 @@ public record BlocklistSettings [JsonPropertyName("file")] public string File { get; set; } = "Configuration/ip-blocklist.txt"; + /// + /// Addresses that must never be blocked and never escalated, in the blocklist's own format. The same + /// files tools/Export-IpBlocklist.ps1 subtracts at generation time; the shard reads them so an + /// entry also suppresses ban contributions, which the generator alone cannot do. See + /// . + /// + /// + /// The filename may contain wildcards, which is how the default picks up a carve-out an admin adds + /// without anyone editing this file. + /// + [JsonPropertyName("allowlistFiles")] + public string[] AllowlistFiles { get; set; } = ["Configuration/ip-allowlist*.txt"]; + /// How often the file is checked for changes. Reloads only happen when it actually changed. [JsonPropertyName("reloadInterval")] public TimeSpan ReloadInterval { get; set; } = TimeSpan.FromSeconds(60); diff --git a/Projects/UOContent/Network/Blocklist/BlocklistFilter.cs b/Projects/UOContent/Network/Blocklist/BlocklistFilter.cs index 030997803..36354160b 100644 --- a/Projects/UOContent/Network/Blocklist/BlocklistFilter.cs +++ b/Projects/UOContent/Network/Blocklist/BlocklistFilter.cs @@ -25,8 +25,8 @@ namespace Server.Network.Bans; /// /// Accept-path gate for a large, file-sourced IP blocklist, hydrated from the file a generator /// (tools/Export-IpBlocklist.ps1) writes on a schedule. Holds an immutable snapshot swapped -/// atomically by an off-loop reload poll, so accept-path reads are lock-free. Opt-in via -/// blocklist.json's enabled; inert when off, or when no file is configured or present. +/// atomically by an off-loop reload poll, so accept-path reads are lock-free. Inert when no file is +/// configured or present. /// /// /// This is the demand-paging half of the design: an OS firewall cannot hold millions of entries on @@ -38,13 +38,12 @@ public sealed class BlocklistFilter : IConnectionFilter { private static readonly ILogger logger = LogFactory.GetLogger(typeof(BlocklistFilter)); - // Written by the reload poll (off-loop), read by the accept path (game loop). One volatile reference - // swap is the whole synchronization story: readers see the old or the new snapshot, whole. + // Written by the reload poll (off-loop), read by the accept path (game loop): a single volatile + // reference swap is the whole synchronization story — readers see the old or the new snapshot, whole. private volatile BlocklistSnapshot _snapshot = BlocklistSnapshot.Empty; private readonly PromotedGuard _guard = new(); - private bool _enabled; private string _path; private TimeSpan _interval; private bool _reportHits; @@ -53,7 +52,6 @@ public sealed class BlocklistFilter : IConnectionFilter private string _lastGenerated; private DateTime _lastWriteUtc; private CancellationTokenSource _cts; - private Timer _sweepTimer; public string Name => "blocklist"; @@ -70,7 +68,6 @@ public sealed class BlocklistFilter : IConnectionFilter var s = BlocklistConfiguration.Settings; _path = ResolvePath(s.File); - _enabled = s.Enabled && _path != null; _interval = s.ReloadInterval <= TimeSpan.Zero ? TimeSpan.FromSeconds(60) : s.ReloadInterval; _reportHits = s.ReportHits; _banDuration = s.BanDuration; @@ -94,26 +91,16 @@ public sealed class BlocklistFilter : IConnectionFilter public void Start(CancellationToken token) { - if (!_enabled) + if (_path == null) { - LogWhyDisabled(); + logger.Information("Blocklist disabled (\"file\" empty in blocklist.json)"); return; } - // The operator's override on this gate, opted into separately. Without it only the generator's - // subtraction covers carve-outs, and that does not cover ban contributions. - if (!ManualAllowlist.Enabled) - { - logger.Warning( - "Blocklist is on but the manual allowlist is not; set \"enabled\" in ip-allowlist.json so a " + - "carve-out also suppresses ban contributions" - ); - } - _cts = CancellationTokenSource.CreateLinkedTokenSource(token); - // A missing file is not an error: the gate stays inert until the poll picks up whatever the - // generator first writes. No restart needed. + // A missing file is the shipped default, not an error: the gate stays inert until the poll picks + // up whatever the generator first writes. No restart needed. if (File.Exists(_path)) { Reload(); // synchronous prime; empty on failure (fail-open) @@ -123,47 +110,17 @@ public sealed class BlocklistFilter : IConnectionFilter logger.Information("Blocklist inert: no list at \"{Path}\"; polling every {Interval}", _path, _interval); } - // Sweep the promote-guard so a distinct-IP flood cannot grow it unbounded. Only marked when hits - // are reported, so there is nothing to sweep otherwise. - if (_reportHits) - { - _sweepTimer = Timer.DelayCall(TimeSpan.FromMinutes(1), TimeSpan.FromMinutes(1), SweepGuard); - } + // Sweep the promote-guard so a distinct-IP flood cannot grow it unbounded. + Timer.DelayCall(TimeSpan.FromMinutes(1), TimeSpan.FromMinutes(1), SweepGuard); _ = Task.Run(() => PollLoop(_cts.Token), _cts.Token); } - private void LogWhyDisabled() - { - if (_path == null) - { - logger.Information("Blocklist disabled (\"file\" empty in blocklist.json)"); - } - else if (File.Exists(_path)) - { - // An upgraded shard has a list on disk but no "enabled" key, so say so rather than silently - // dropping a gate it was relying on. - logger.Warning( - "Blocklist is off (\"enabled\" false in blocklist.json) but a list is present at \"{Path}\"; " + - "no addresses will be denied", - _path - ); - } - else - { - logger.Information("Blocklist disabled (\"enabled\" false in blocklist.json)"); - } - } - public void Stop() { _cts?.Cancel(); _cts?.Dispose(); _cts = null; - - // Recurring, so an uncancelled sweep survives Stop and the next Start adds a second one. - _sweepTimer?.Stop(); - _sweepTimer = null; } public bool ShouldDeny(IPAddress address) @@ -196,9 +153,9 @@ public sealed class BlocklistFilter : IConnectionFilter } // Both are asked only once the list has matched, so they cost the common accept nothing. The file - // list is usually redundant because the generator subtracts it — except right after an operator - // adds an entry without regenerating, which is when someone is waiting to get back in. - if (ManualAllowlist.Contains(address)) + // list is usually redundant because the generator subtracts it — except right after an operator adds + // an entry without regenerating, which is exactly when someone is waiting to get back in. + if (FileAllowlist.Contains(address)) { return false; } @@ -291,8 +248,9 @@ public sealed class BlocklistFilter : IConnectionFilter private void Reload() { - // Capture the mtime/header BEFORE Load() so they describe the version being parsed. Capturing - // after could skip a version the producer swapped in mid-parse; stale markers only cost a reload. + // Capture the mtime/header BEFORE Load() so the markers describe the version being parsed, not + // one the producer swapped in mid-parse. Stale markers only cost an extra reload next poll; + // capturing after could skip a version entirely. var writeUtc = default(DateTime); try { diff --git a/Projects/UOContent/Network/Blocklist/BlocklistSnapshot.cs b/Projects/UOContent/Network/Blocklist/BlocklistSnapshot.cs index d62df0752..29b2e5176 100644 --- a/Projects/UOContent/Network/Blocklist/BlocklistSnapshot.cs +++ b/Projects/UOContent/Network/Blocklist/BlocklistSnapshot.cs @@ -46,7 +46,8 @@ public sealed class BlocklistSnapshot /// Parses a blocklist directly from its UTF-8/ASCII file bytes — one line at a time, splitting on /// '\n' with no per-line string allocation. IPv4 singles and CIDRs are parsed straight from the /// byte span; IPv6 (the rare path) decodes the single address token and defers to the framework parser. - /// Malformed lines increment and never throw. + /// Malformed lines increment and never throw. Build-time intermediates use + /// the multithreaded pool because this runs off the game loop on the reload/bootstrap thread. /// public static BlocklistSnapshot Build(ReadOnlySpan data, out int parsed, out int skipped) { @@ -182,7 +183,7 @@ public sealed class BlocklistSnapshot } /// - /// Plain set membership, for callers whose set is an ALLOWlist (see ) and for + /// Plain set membership, for callers whose set is an ALLOWlist (see ) and for /// whom would read backwards. The interval machinery is direction-agnostic. /// public bool Contains(IPAddress ip) => IsBanned(ip); diff --git a/Projects/UOContent/Network/ManualAllowlist/ManualAllowlist.cs b/Projects/UOContent/Network/Blocklist/FileAllowlist.cs similarity index 83% rename from Projects/UOContent/Network/ManualAllowlist/ManualAllowlist.cs rename to Projects/UOContent/Network/Blocklist/FileAllowlist.cs index 20e351457..0d5582713 100644 --- a/Projects/UOContent/Network/ManualAllowlist/ManualAllowlist.cs +++ b/Projects/UOContent/Network/Blocklist/FileAllowlist.cs @@ -2,7 +2,7 @@ * ModernUO * * Copyright 2019-2026 - ModernUO Development Team * * Email: hi@modernuo.com * - * File: ManualAllowlist.cs * + * File: FileAllowlist.cs * * * * This program is free software: you can redistribute it and/or modify * * it under the terms of the GNU General Public License as published by * @@ -32,15 +32,15 @@ namespace Server.Network.Bans; /// Behavioural detections never consult the blocklist, so without reading the files here a carve-out is /// quietly routed around: one scanner behind a shared CGNAT address is enough to get the whole address /// contributed and firewalled. Reading them also means an entry applies on the next reload rather than the -/// next regeneration. Opt-in via ip-allowlist.json's enabled, since the poll runs for the -/// whole uptime; no shield against a manual ban either — see . +/// next regeneration. Unconditional, unlike , but still no shield against a +/// manual ban — see . /// -public static class ManualAllowlist +public static class FileAllowlist { - private static readonly ILogger logger = LogFactory.GetLogger(typeof(ManualAllowlist)); + private static readonly ILogger logger = LogFactory.GetLogger(typeof(FileAllowlist)); - // Written by the reload poll (off-loop), read by the accept path (game loop). One volatile reference - // swap is the whole synchronization story: readers see the old or the new snapshot, whole. + // Written by the reload poll (off-loop), read by the accept path (game loop): a single volatile + // reference swap is the whole synchronization story — readers see the old or the new snapshot, whole. private static volatile BlocklistSnapshot _snapshot = BlocklistSnapshot.Empty; private static string[] _patterns = []; @@ -53,51 +53,24 @@ public static class ManualAllowlist /// True when an operator listed this address. Safe before . public static bool Contains(IPAddress address) => address != null && _snapshot.Contains(address); - /// True when the shard is reading allowlist files. Safe before . - public static bool Enabled { get; private set; } - public static void Initialize() { - ManualAllowlistConfiguration.Load(); - var settings = ManualAllowlistConfiguration.Settings; + // BlocklistFilter.Register ran during the Configure sweep, so the settings are populated. + var settings = BlocklistConfiguration.Settings; if (settings == null) { return; } - _patterns = ResolvePaths(settings.Files); + _patterns = ResolvePaths(settings.AllowlistFiles); _interval = settings.ReloadInterval <= TimeSpan.Zero ? TimeSpan.FromSeconds(60) : settings.ReloadInterval; - if (!settings.Enabled) - { - // The generator still subtracts these files, so a carve-out an operator already wrote looks - // like it works right up until a behavioural detection contributes the address anyway. - var present = ExpandPaths().Length; - _patterns = []; - - if (present > 0) - { - logger.Warning( - "Manual allowlist is off (\"enabled\" false in ip-allowlist.json) but {Count} allowlist file(s) " + - "are present; those carve-outs will not suppress ban contributions", - present - ); - } - else - { - logger.Information("Manual allowlist disabled (\"enabled\" false in ip-allowlist.json)"); - } - - return; - } - if (_patterns.Length == 0) { - logger.Information("Manual allowlist disabled (\"files\" empty in ip-allowlist.json)"); + logger.Information("File allowlist disabled (\"allowlistFiles\" empty in blocklist.json)"); return; } - Enabled = true; Reload(); _cts = CancellationTokenSource.CreateLinkedTokenSource(Core.ClosingTokenSource.Token); @@ -224,7 +197,7 @@ public static class ManualAllowlist } catch (Exception e) { - logger.Warning(e, "Manual allowlist reload check failed; keeping last snapshot ({Count})", Count); + logger.Warning(e, "File allowlist reload check failed; keeping last snapshot ({Count})", Count); } } } @@ -273,7 +246,7 @@ public static class ManualAllowlist _lastStamp = stamp; logger.Information( - "Manual allowlist loaded {Count} range(s) from {Files} file(s)", + "File allowlist loaded {Count} range(s) from {Files} file(s)", next.Count, files ); diff --git a/Projects/UOContent/Network/Blocklist/PromotedGuard.cs b/Projects/UOContent/Network/Blocklist/PromotedGuard.cs index bba8e3ee5..4f820b16e 100644 --- a/Projects/UOContent/Network/Blocklist/PromotedGuard.cs +++ b/Projects/UOContent/Network/Blocklist/PromotedGuard.cs @@ -39,13 +39,17 @@ public sealed class PromotedGuard { return; } - + using var dead = Collections.PooledRefQueue.Create(); foreach (var (ip, exp) in _expiry) { if (exp - nowTicks <= 0) { - _expiry.Remove(ip); + dead.Enqueue(ip); } } + while (dead.Count > 0) + { + _expiry.Remove(dead.Dequeue()); + } } } diff --git a/Projects/UOContent/Network/CrowdSec/CrowdSecConfiguration.cs b/Projects/UOContent/Network/CrowdSec/CrowdSecConfiguration.cs index 5855633f9..b1376b6a0 100644 --- a/Projects/UOContent/Network/CrowdSec/CrowdSecConfiguration.cs +++ b/Projects/UOContent/Network/CrowdSec/CrowdSecConfiguration.cs @@ -21,8 +21,9 @@ using Server.Json; namespace Server.Network.Bans.CrowdSec; /// -/// Loads the from Configuration/crowdsec.json. Loaded once; a -/// missing file writes a disabled-by-default template so operators have something to edit. +/// Loads the from Configuration/crowdsec.json (matching the +/// per-feature JSON config pattern used by AssistantConfiguration). Loaded once; a missing file +/// writes a disabled-by-default template so operators have something to edit. /// public static class CrowdSecConfiguration { diff --git a/Projects/UOContent/Network/CrowdSec/CrowdSecReporter.cs b/Projects/UOContent/Network/CrowdSec/CrowdSecReporter.cs index c900eb90f..385534d2a 100644 --- a/Projects/UOContent/Network/CrowdSec/CrowdSecReporter.cs +++ b/Projects/UOContent/Network/CrowdSec/CrowdSecReporter.cs @@ -304,10 +304,12 @@ public sealed class CrowdSecReporter : IBanReporter } /// - /// Up to 3 attempts (1 initial + 2 retries) backing off 1s then 2s, for transient LAPI failures - /// (network blips, 5xx). Backoff uses so it never blocks the thread, and a - /// cancellation during it propagates so the drain loop exits cleanly. Returns false rather than - /// throwing once attempts are exhausted, so the caller counts the drop and keeps draining. + /// Sends with up to 3 attempts total (1 initial + 2 retries), backing off 1s then 2s between + /// attempts, for transient LAPI failures (network blips, 5xx). Backoff uses + /// so it never blocks the thread; a cancellation during backoff propagates as + /// so the drain loop exits cleanly. Returns false (never + /// throws for a send failure) once attempts are exhausted, so the caller can count the drop and keep + /// draining instead of losing the rest of the batch/queue. /// private static async ValueTask SendWithBoundedRetryAsync(Func send, CancellationToken token) { diff --git a/Projects/UOContent/Network/Firewall/Firewall.cs b/Projects/UOContent/Network/Firewall/Firewall.cs index 36c11bbf9..075b47ec3 100644 --- a/Projects/UOContent/Network/Firewall/Firewall.cs +++ b/Projects/UOContent/Network/Firewall/Firewall.cs @@ -28,10 +28,10 @@ namespace Server.Network; public static class Firewall { // Single-threaded: the accept path, admin gump/command, TTL expiry timer, and boot load all run on - // the main game loop, so no locks, caches, or version counters are needed. _entries is the - // authoritative store; _index is a derived, rebuild-on-demand SortedRangeIndex used only for the - // accept-path IsBlocked lookup, over the same primitive the blocklist uses (see BlocklistSnapshot). - // See dev-docs/ip-bans-and-allowlists.md. + // the main game loop. No locks, caches, or version counters are needed. See the ban-channel design doc. + // _entries is the authoritative store (gump/persistence/TTL/command all work against it); _index is a + // derived, rebuild-on-demand SortedRangeIndex used only for the accept-path IsBlocked lookup, shared + // with the same sorted-range binary-search primitive the blocklist uses (see BlocklistSnapshot). private static readonly List _entries = []; // Entries with a TTL: entry -> absolute expiry tick (Core.TickCount). Permanent entries are absent. @@ -152,7 +152,7 @@ public static class Firewall } /// - /// Removes every entry whose TTL has elapsed. Called from the main-thread maintenance timer. + /// Removes every entry whose TTL has elapsed. Called from the main-thread maintenance timer (Task 2). /// internal static void ExpireEntries(long nowTicks) { diff --git a/Projects/UOContent/Network/GameServer.cs b/Projects/UOContent/Network/GameServer.cs index b9de09515..9fc170d69 100644 --- a/Projects/UOContent/Network/GameServer.cs +++ b/Projects/UOContent/Network/GameServer.cs @@ -6,21 +6,13 @@ public static partial class GameServer { public class GameLoginEventArgs { - public GameLoginEventArgs(NetState state, string un, string pw, bool preAuthenticated) + public GameLoginEventArgs(NetState state, string un, string pw) { State = state; Username = un; Password = pw; - PreAuthenticated = preAuthenticated; } - /// - /// The auth id presented on this game login was issued to this account, from this address, - /// after the account login packet verified the password. Read-only so a subscriber cannot - /// grant itself the skip. - /// - public bool PreAuthenticated { get; } - public NetState State { get; } public string Username { get; } diff --git a/Projects/UOContent/Network/LoginAllowlist/LoginAllowlist.cs b/Projects/UOContent/Network/LoginAllowlist/LoginAllowlist.cs index 23075f7ff..ed65b5ec6 100644 --- a/Projects/UOContent/Network/LoginAllowlist/LoginAllowlist.cs +++ b/Projects/UOContent/Network/LoginAllowlist/LoginAllowlist.cs @@ -19,8 +19,8 @@ using System.Globalization; using System.IO; using System.Net; using System.Text; -using System.Threading; using System.Threading.Tasks; +using Server.Collections; using Server.Logging; using Server.Network.Bans; @@ -31,11 +31,16 @@ namespace Server.Network; /// blocked and a flaky connection cannot get one globally banned. /// /// -/// Consulted only after the blocklist has already matched, so a normal accept pays nothing for it. An entry -/// is evidence rather than a licence: enough strikes inside the window revokes it. It cannot bootstrap, so -/// it does not replace . Both dictionaries are game-loop state; only the file -/// write runs off-loop, over a snapshot taken on the loop. -/// See dev-docs/ip-bans-and-allowlists.md. +/// +/// Consulted only after the blocklist has already matched, and again before a ban is contributed, so a +/// normal accept pays nothing for it. An entry is evidence rather than a licence: enough strikes inside the +/// window revokes it. It cannot bootstrap, so it hedges stable addresses and does not replace +/// . See dev-docs/ip-bans-and-allowlists.md. +/// +/// +/// Both dictionaries are game-loop state. Only the file write runs off-loop, over a snapshot taken on the +/// loop. +/// /// public static class LoginAllowlist { @@ -48,11 +53,6 @@ public static class LoginAllowlist // _allowed and cannot be grown by an attacker. private static readonly Dictionary _strikes = []; - // Reused: past ~5,300 entries a fresh UInt128[] is an LOH allocation, once per flush. Grown - // geometrically, never shrunk. - private static UInt128[] _addressBuffer = []; - private static long[] _stampBuffer = []; - private static bool _enabled; private static string _path; private static long _ttlSeconds; @@ -60,10 +60,6 @@ public static class LoginAllowlist private static long _strikeWindowSeconds; private static bool _dirty; - // Loop-only. The writer owns the buffers until it posts completion back, so a flush landing mid-write - // waits rather than overwriting them. - private static bool _writing; - public static int Count => _allowed.Count; private struct Strike @@ -102,14 +98,10 @@ public static class LoginAllowlist var interval = LoginAllowlistConfiguration.Settings.FlushInterval; if (interval <= TimeSpan.Zero) { - interval = TimeSpan.FromHours(1); + interval = TimeSpan.FromMinutes(1); } Timer.DelayCall(interval, interval, Flush); - - // HandleClosed skips InvokeShutdown when the server crashed, so the crash path needs its own. - EventSink.Shutdown += OnShutdown; - EventSink.ServerCrashed += OnCrashed; } /// @@ -206,8 +198,6 @@ public static class LoginAllowlist { _allowed.Clear(); _strikes.Clear(); - _writing = false; - _dirty = false; _enabled = enabled; _ttlSeconds = ttlSeconds; _escalateAfterStrikes = escalateAfterStrikes; @@ -219,105 +209,57 @@ public static class LoginAllowlist private static void Flush() { + if (!_enabled || !_dirty) + { + return; + } + // A save owns the disk and nothing here is urgent. _dirty stays set, so skipping loses nothing. // See the threading policy in CLAUDE.md (rules #3 and #10). - if (!_enabled || !_dirty || _writing || World.Saving || World.WorldState == WorldState.PendingSave) + if (World.Saving || World.WorldState == WorldState.PendingSave) { return; } - var count = Snapshot(out var dropped); - var addresses = _addressBuffer; - var stamps = _stampBuffer; - var path = _path; - - _dirty = false; - _writing = true; - - _ = Task.Run( - () => - { - var written = Write(path, addresses, stamps, count, dropped); - - // _writing and _dirty are loop state, so the writer hands the release back. Rule #10. - Core.LoopContext.Post( - () => - { - _writing = false; - if (!written) - { - _dirty = true; // nothing reached disk; the next flush retries - } - } - ); - } - ); - } - - /// - /// A crash is the case the flush interval cannot cover, so write on the way down. Runs on whichever - /// thread faulted, and the dictionaries are loop state, so it only writes when that is the loop. - /// - private static void OnCrashed(ServerCrashedEventArgs e) - { - if (Thread.CurrentThread == Core.Thread) - { - OnShutdown(); - } - } - - /// Synchronous: nothing schedules after this, so a handed-off write would reach no disk. - private static void OnShutdown() - { - // A write already in flight holds the buffers and has all but the last moments of the list. - if (!_enabled || !_dirty || _writing) - { - return; - } - - var count = Snapshot(out var dropped); - _dirty = false; - - Write(_path, _addressBuffer, _stampBuffer, count, dropped); - } - - /// - /// Prunes expired entries and copies what survives into the shared buffers. Returns the live count; the - /// buffers run longer and everything past it is stale. - /// - private static int Snapshot(out int dropped) - { var nowUnix = ToUnixSeconds(Core.Now); var cutoff = nowUnix - _ttlSeconds; - if (_addressBuffer.Length < _allowed.Count) - { - // Geometric so a shard adding addresses one at a time does not reallocate every flush. - var size = Math.Max(_allowed.Count, Math.Max(64, _addressBuffer.Length * 2)); - _addressBuffer = new UInt128[size]; - _stampBuffer = new long[size]; - } - + // Prune and snapshot in one loop-side pass; the writer only sees private copies. Not pooled: + // STArrayPool is single-threaded and these escape to another thread. + var addresses = new UInt128[_allowed.Count]; + var stamps = new long[_allowed.Count]; var count = 0; - dropped = 0; + + using var expired = new PooledRefList(16); foreach (var (address, stamp) in _allowed) { if (stamp < cutoff) { - _allowed.Remove(address); - _strikes.Remove(address); - dropped++; + expired.Add(address); continue; } - _addressBuffer[count] = address; - _stampBuffer[count] = stamp; + addresses[count] = address; + stamps[count] = stamp; count++; } + for (var i = 0; i < expired.Count; i++) + { + _allowed.Remove(expired[i]); + _strikes.Remove(expired[i]); + } + PruneStaleStrikes(nowUnix); - return count; + + _dirty = false; + + var path = _path; + var total = count; + var dropped = expired.Count; + + _ = Task.Run(() => Write(path, addresses, stamps, total, dropped)); } /// Drops tallies whose window has closed. @@ -328,17 +270,23 @@ public static class LoginAllowlist return; } + using var stale = new PooledRefList(16); + foreach (var (address, strike) in _strikes) { if (nowUnix - strike.WindowStart > _strikeWindowSeconds) { - _strikes.Remove(address); + stale.Add(address); } } + + for (var i = 0; i < stale.Count; i++) + { + _strikes.Remove(stale[i]); + } } - /// Writes the list out. Returns false when nothing reached disk, so the caller can retry. - private static bool Write(string path, UInt128[] addresses, long[] stamps, int count, int dropped) + private static void Write(string path, UInt128[] addresses, long[] stamps, int count, int dropped) { try { @@ -374,14 +322,11 @@ public static class LoginAllowlist { logger.Information("Login allowlist wrote {Count} entr(ies), dropped {Dropped} past TTL", count, dropped); } - - return true; } catch (Exception e) { // Recoverable: entries are still in memory and the next flush retries. logger.Warning(e, "Could not write the login allowlist to \"{Path}\"", path); - return false; } } diff --git a/Projects/UOContent/Network/LoginAllowlist/LoginAllowlistConfiguration.cs b/Projects/UOContent/Network/LoginAllowlist/LoginAllowlistConfiguration.cs index 5c673eb10..b3242a85f 100644 --- a/Projects/UOContent/Network/LoginAllowlist/LoginAllowlistConfiguration.cs +++ b/Projects/UOContent/Network/LoginAllowlist/LoginAllowlistConfiguration.cs @@ -21,8 +21,9 @@ using Server.Json; namespace Server.Network; /// -/// Loads the from Configuration/login-allowlist.json. Loaded -/// once; a missing file writes a template so operators have something to edit. +/// Loads the from Configuration/login-allowlist.json (matching +/// the per-feature JSON config pattern used by BlocklistConfiguration). Loaded once; a missing file +/// writes a template so operators have something to edit. /// public static class LoginAllowlistConfiguration { @@ -81,19 +82,18 @@ public record LoginAllowlistSettings public TimeSpan Ttl { get; set; } = TimeSpan.FromDays(90); /// - /// How often a changed list is written out. A clean shutdown always writes, so this only bounds what a - /// crash loses — and an entry is re-earned by the next login. Hourly against a 90-day TTL, because each - /// flush walks the whole list on the game loop. + /// How often a changed list is written out. A crash loses at most this much, and an entry is re-earned by + /// the next login. /// [JsonPropertyName("flushInterval")] - public TimeSpan FlushInterval { get; set; } = TimeSpan.FromHours(1); + public TimeSpan FlushInterval { get; set; } = TimeSpan.FromMinutes(1); /// /// How many suppressed contributions inside revoke an address's entry. Past /// this it escalates like anything else until it earns a new entry by logging in again. /// /// - /// Generous on purpose: local defenses never stop applying, so a high threshold only delays the external + /// Generous on purpose: local defences never stop applying, so a high threshold only delays the external /// ban. A bad line might trip a gate a few times an hour; a host being used to flood burns through this /// in seconds. Set to 0 to never revoke. /// diff --git a/Projects/UOContent/Network/ManualAllowlist/ManualAllowlistConfiguration.cs b/Projects/UOContent/Network/ManualAllowlist/ManualAllowlistConfiguration.cs deleted file mode 100644 index dd21fe97c..000000000 --- a/Projects/UOContent/Network/ManualAllowlist/ManualAllowlistConfiguration.cs +++ /dev/null @@ -1,83 +0,0 @@ -/************************************************************************* - * ModernUO * - * Copyright 2019-2026 - ModernUO Development Team * - * Email: hi@modernuo.com * - * File: ManualAllowlistConfiguration.cs * - * * - * This program is free software: you can redistribute it and/or modify * - * it under the terms of the GNU General Public License as published by * - * the Free Software Foundation, either version 3 of the License, or * - * (at your option) any later version. * - * * - * You should have received a copy of the GNU General Public License * - * along with this program. If not, see . * - *************************************************************************/ - -using System; -using System.IO; -using System.Text.Json.Serialization; -using Server.Json; - -namespace Server.Network.Bans; - -/// -/// Loads the from Configuration/ip-allowlist.json. Loaded once; -/// a missing file writes a template so operators have something to edit. -/// -public static class ManualAllowlistConfiguration -{ - private const string _path = "Configuration/ip-allowlist.json"; - - public static ManualAllowlistSettings Settings { get; private set; } - - public static void Load() - { - var path = Path.Join(Core.BaseDirectory, _path); - - if (File.Exists(path)) - { - Settings = JsonConfig.Deserialize(path); - } - else - { - Settings = new ManualAllowlistSettings(); - Save(); - } - } - - private static void Save() - { - JsonConfig.Serialize(Path.Join(Core.BaseDirectory, _path), Settings); - } -} - -/// -/// Bound configuration for . Its own file rather than a corner of -/// blocklist.json: the blocklist is only one of two consumers, and the other -/// () works on a shard that runs no blocklist at all. -/// -public record ManualAllowlistSettings -{ - /// - /// Whether the shard reads at all. Off by default: reading them costs a poll for - /// the whole uptime, which no shard should pay before an operator has written a carve-out. - /// - [JsonPropertyName("enabled")] - public bool Enabled { get; set; } - - /// - /// Addresses that must never be blocked and never escalated, in the blocklist's own format. The same - /// files tools/Export-IpBlocklist.ps1 subtracts at generation time; the shard reads them so an - /// entry also suppresses ban contributions, which the generator alone cannot do. - /// - /// - /// The filename may contain wildcards, which is how the default picks up a carve-out an admin adds - /// without anyone editing this file. - /// - [JsonPropertyName("files")] - public string[] Files { get; set; } = ["Configuration/ip-allowlist*.txt"]; - - /// How often the files are checked for changes. Reloads only happen when one actually changed. - [JsonPropertyName("reloadInterval")] - public TimeSpan ReloadInterval { get; set; } = TimeSpan.FromSeconds(60); -} diff --git a/Projects/UOContent/Network/Packets/IncomingAccountPackets.cs b/Projects/UOContent/Network/Packets/IncomingAccountPackets.cs index 792ed880b..07a444d03 100644 --- a/Projects/UOContent/Network/Packets/IncomingAccountPackets.cs +++ b/Projects/UOContent/Network/Packets/IncomingAccountPackets.cs @@ -17,9 +17,6 @@ using System; using System.Buffers; using System.Collections.Generic; using System.IO; -using System.Net; -using System.Security.Cryptography; -using Server.Accounting; using Server.Engines.CharacterCreation; using Server.Misc; using Server.Mobiles; @@ -28,16 +25,7 @@ namespace Server.Network; public static class IncomingAccountPackets { - // Initial capacity and the point at which issuing sweeps expired ids. Not a cap; the window - // grows rather than evicting a live id. private const int _authIDWindowSize = 128; - - private static int _authIdPurgeThreshold = _authIDWindowSize; - - // The gap between PlayServerAck and the game login is seconds. Bounds how long a stolen id - // stays usable. - private static readonly TimeSpan _authIDLifetime = TimeSpan.FromMinutes(2.0); - private static readonly Dictionary _authIDWindow = new(_authIDWindowSize); @@ -46,33 +34,13 @@ public static class IncomingAccountPackets public DateTime Age; public readonly ClientVersion Version; - // GameLogin skips its password verify when both match, so the id is a bearer token and has - // to be bound to whatever earned it. - public readonly IAccount Account; - public readonly IPAddress Address; - - public AuthIDPersistence(ClientVersion v, IAccount account, IPAddress address) + public AuthIDPersistence(ClientVersion v) { Age = Core.Now; Version = v; - Account = account; - Address = Utility.Intern(address); } } - internal enum AuthIdResult - { - // No such id, or it was issued for a different account or address. - Rejected, - - // Right account and address, too old to stand in for the verify. Idling on the server list - // is normal, so this falls back to the password check rather than becoming a lockout. - Expired, - - // Issued to this account, from this address, recently. Stands in for the password verify. - Vouched - } - public static unsafe void Configure() { IncomingPackets.Register(0x00, &CreateCharacter, 104, outgameOnly: true); @@ -344,92 +312,42 @@ public static class IncomingAccountPackets } } - private static int GenerateAuthID(this NetState state) => - EnsureAuthId(state.AuthId, state.Account, state.Address, state.Version); - - /// - /// One id per connection, by construction. Choosing a server queues a disconnect that is not - /// drained until the next slice, so a client pipelining another select into the same buffer - /// arrives here again; handing back the id it already holds cannot orphan one. - /// - internal static int EnsureAuthId(int existingAuthId, IAccount account, IPAddress address, ClientVersion version) - => existingAuthId != 0 ? existingAuthId : RegisterAuthId(account, address, version); - - internal static int RegisterAuthId(IAccount account, IPAddress address, ClientVersion version) + private static int GenerateAuthID(this NetState state) { - // Sweep the ids left behind by clients that picked a server and never arrived, but never - // evict a live one to make room -- the client holding it is on its way to redeem it. If all - // are live the window grows, which is a login rush, not a backlog. Each entry costs a - // successful password verify, so the size is self-limiting. - if (_authIDWindow.Count >= _authIdPurgeThreshold) + if (_authIDWindow.Count == _authIDWindowSize) { - PurgeExpiredAuthIds(); - _authIdPurgeThreshold = Math.Max(_authIDWindowSize, _authIDWindow.Count * 2); + var oldestID = 0; + var oldest = DateTime.MaxValue; + + foreach (var (key, authId) in _authIDWindow) + { + if (authId.Age < oldest) + { + oldestID = key; + oldest = authId.Age; + } + } + + _authIDWindow.Remove(oldestID); } int authID; - // The id stands in for a password verify, so it has to be unguessable. Zero is reserved: - // GameLogin reads state.AuthId == 0 as "no auth id was issued". do { - authID = RandomNumberGenerator.GetInt32(int.MinValue, int.MaxValue); - } while (authID == 0 || _authIDWindow.ContainsKey(authID)); + authID = Utility.Random(1, int.MaxValue - 1); - _authIDWindow[authID] = new AuthIDPersistence(version, account, address); + if (Utility.RandomBool()) + { + authID |= 1 << 31; + } + } while (_authIDWindow.ContainsKey(authID)); + + _authIDWindow[authID] = new AuthIDPersistence(state.Version); return authID; } - /// - /// Spends an auth id, but only for the account and address it was issued to. An address - /// mismatch is rather than a fallback: network switching - /// mid-login is not supported. - /// - internal static AuthIdResult ConsumeAuthId(int authId, string username, IPAddress address, out AuthIDPersistence entry) - { - if (!_authIDWindow.TryGetValue(authId, out entry)) - { - return AuthIdResult.Rejected; - } - - // Look, then take: removing before ownership is proven would let anyone landing on a live id - // burn it, leaving its owner to log in again. Address before username, so a remote guesser - // never learns whether a username matched. - if (!Utility.Intern(address).Equals(entry.Address) - || entry.Account == null || !username.InsensitiveEquals(entry.Account.Username)) - { - entry = default; - return AuthIdResult.Rejected; - } - - // Theirs, so spend it. Expired counts as spent; it has done all it is ever going to do. - _authIDWindow.Remove(authId); - - return Core.Now - entry.Age > _authIDLifetime ? AuthIdResult.Expired : AuthIdResult.Vouched; - } - - private static void PurgeExpiredAuthIds() - { - var now = Core.Now; - - foreach (var (key, entry) in _authIDWindow) - { - if (now - entry.Age > _authIDLifetime) - { - _authIDWindow.Remove(key); - } - } - } - - internal static void ClearAuthIdWindow() - { - _authIDWindow.Clear(); - _authIdPurgeThreshold = _authIDWindowSize; - } - - internal static int AuthIdWindowCount => _authIDWindow.Count; - public static void GameLogin(NetState state, SpanReader reader) { if (state.SentFirstPacket) @@ -442,6 +360,12 @@ public static class IncomingAccountPackets var authId = reader.ReadInt32(); + if (!_authIDWindow.TryGetValue(authId, out var ap)) + { + state.LogInfo("Invalid client detected, disconnecting..."); + state.Disconnect("Unable to find auth id."); + } + if (state.AuthId != 0 && authId != state.AuthId || state.AuthId == 0 && authId != state.Seed) { state.LogInfo("Invalid client detected, disconnecting..."); @@ -449,28 +373,14 @@ public static class IncomingAccountPackets return; } - var username = reader.ReadLatin1Safe(30); - var password = reader.ReadLatin1Safe(30); - - var authResult = ConsumeAuthId(authId, username, state.Address, out var ap); - - if (authResult == AuthIdResult.Rejected) - { - state.LogInfo("Invalid client detected, disconnecting..."); - state.Disconnect("Unable to find auth id."); - return; - } - + _authIDWindow.Remove(authId); state.Version = ap.Version; state.Seeded = true; - // Expired carries a usable entry; only the password verify skip is withheld. - var e = new GameServer.GameLoginEventArgs( - state, - username, - password, - authResult == AuthIdResult.Vouched - ); + var username = reader.ReadLatin1Safe(30); + var password = reader.ReadLatin1Safe(30); + + var e = new GameServer.GameLoginEventArgs(state, username, password); GameServer.GameServerLoginEvent(e); @@ -492,14 +402,6 @@ public static class IncomingAccountPackets public static void PlayServer(NetState state, SpanReader reader) { - // A server is picked once per connection. Picking again hands back an id this connection may - // already have spent on a game login, which the client could never redeem. - if (state.AuthId != 0) - { - state.Disconnect("Duplicate play server packet sent."); - return; - } - int index = reader.ReadInt16(); var info = state.ServerInfo; var a = state.Account; @@ -512,7 +414,7 @@ public static class IncomingAccountPackets { var si = info[index]; - state.AuthId = state.GenerateAuthID(); + state.AuthId = GenerateAuthID(state); state.SentFirstPacket = false; state.SendPlayServerAck(si, state.AuthId); @@ -521,14 +423,6 @@ public static class IncomingAccountPackets public static void LoginServerSeed(NetState state, SpanReader reader) { - // Seeding happens once per connection. A second one restarts a handshake this connection - // already completed, which no real client does. - if (state.Seeded) - { - state.Disconnect("Duplicate login server seed packet sent."); - return; - } - state.Seed = reader.ReadInt32(); state.Seeded = true; @@ -564,22 +458,7 @@ public static class IncomingAccountPackets EventSink.InvokeAccountLogin(accountLoginEventArgs); - // The password check moved off the loop; whoever took it replies when the verdict lands. - if (accountLoginEventArgs.Deferred) - { - return; - } - - CompleteAccountLogin(state, accountLoginEventArgs.Accepted, accountLoginEventArgs.RejectReason); - } - - /// - /// Replies to an account login. Split out so a verdict produced off the loop reaches the client - /// through exactly the same path as one produced inline. - /// - internal static void CompleteAccountLogin(NetState state, bool accepted, ALRReason rejectReason) - { - if (accepted) + if (accountLoginEventArgs.Accepted) { var serverListEventArgs = new GatewayServer.ServerListEventArgs(state, state.Account); @@ -599,7 +478,7 @@ public static class IncomingAccountPackets else { state.Account = null; - AccountLogin_ReplyRej(state, rejectReason); + AccountLogin_ReplyRej(state, accountLoginEventArgs.RejectReason); } } diff --git a/Projects/UOContent/Network/Packets/IncomingPlayerPackets.cs b/Projects/UOContent/Network/Packets/IncomingPlayerPackets.cs index 384e468b9..a6297837d 100644 --- a/Projects/UOContent/Network/Packets/IncomingPlayerPackets.cs +++ b/Projects/UOContent/Network/Packets/IncomingPlayerPackets.cs @@ -86,17 +86,11 @@ public static class IncomingPlayerPackets public static void HuePickerResponse(NetState state, SpanReader reader) { var serial = reader.ReadUInt32(); - reader.ReadInt16(); // Item ID + _ = reader.ReadInt16(); // Item ID var hue = Utility.ClipDyedHue(reader.ReadInt16() & 0x3FFF); - if (state.HuePickers == null) + foreach (var huePicker in state.HuePickers) { - return; - } - - for (var i = 0; i < state.HuePickers.Count; i++) - { - var huePicker = state.HuePickers[i]; if (huePicker.Serial == serial) { state.RemoveHuePicker(huePicker); @@ -293,18 +287,13 @@ public static class IncomingPlayerPackets public static void MenuResponse(NetState state, SpanReader reader) { var serial = reader.ReadUInt32(); - reader.ReadInt16(); // menu id + int menuID = reader.ReadInt16(); int index = reader.ReadInt16(); - reader.ReadInt16(); // item id - reader.ReadInt16(); // hue + int itemID = reader.ReadInt16(); + int hue = reader.ReadInt16(); index -= 1; // convert from 1-based to 0-based - if (state.Menus == null) - { - return; - } - for (var i = 0; i < state.Menus.Count; i++) { var menu = state.Menus[i]; diff --git a/Projects/UOContent/Regions/BaseRegion.cs b/Projects/UOContent/Regions/BaseRegion.cs index 776ec269d..b18c7fa18 100644 --- a/Projects/UOContent/Regions/BaseRegion.cs +++ b/Projects/UOContent/Regions/BaseRegion.cs @@ -113,7 +113,7 @@ public class BaseRegion : Region m_RectBuffer2.RemoveAt(k); var sz = rect.Start.Z; - var ez = rect.End.Z; + var ez = rect.End.X; if (l1 < l2) { diff --git a/Projects/UOContent/Skills/AntiMacroSystem.cs b/Projects/UOContent/Skills/AntiMacroSystem.cs index e37431ddd..60fcc899f 100644 --- a/Projects/UOContent/Skills/AntiMacroSystem.cs +++ b/Projects/UOContent/Skills/AntiMacroSystem.cs @@ -120,17 +120,23 @@ public static class AntiMacroSystem var now = Core.Now; + using var toRemove = PooledRefQueue.Create(); foreach (var (m, antiMacro) in _antiMacroTable) { if (antiMacro._lastExpiration <= now) { - _antiMacroTable.Remove(m); + toRemove.Enqueue(m); } else { antiMacro.CleanExpired(); } } + + while (toRemove.Count > 0) + { + _antiMacroTable.Remove(toRemove.Dequeue()); + } } [OnEvent(nameof(PlayerMobile.PlayerLoginEvent))] @@ -253,13 +259,20 @@ public static class AntiMacroSystem { var now = Core.Now; + using var toRemove = PooledRefQueue<(Skill, object)>.Create(); + foreach (var (key, countAndTimeStamp) in _antiMacroTracking) { if (countAndTimeStamp._count <= 0 || countAndTimeStamp._expiration <= now) { - _antiMacroTracking.Remove(key); + toRemove.Enqueue(key); } } + + while (toRemove.Count > 0) + { + _antiMacroTracking.Remove(toRemove.Dequeue()); + } } } diff --git a/Projects/UOContent/Skills/DetectHidden.cs b/Projects/UOContent/Skills/DetectHidden.cs index 487eda693..b1b0a02fb 100644 --- a/Projects/UOContent/Skills/DetectHidden.cs +++ b/Projects/UOContent/Skills/DetectHidden.cs @@ -39,13 +39,20 @@ public static class DetectHidden // Clean up old debounce entries to prevent memory bloat private static void CleanupDebounceCache(long now) { + using var entriesToRemove = PooledRefQueue<(Mobile, Mobile)>.Create(); + foreach (var entry in PassiveDetectDebounce) { if (now - entry.Value > DebounceExpiryMs) { - PassiveDetectDebounce.Remove(entry.Key); + entriesToRemove.Enqueue(entry.Key); } } + + while (entriesToRemove.Count > 0) + { + PassiveDetectDebounce.Remove(entriesToRemove.Dequeue()); + } } // For testing: clear the debounce cache to prevent cross-test contamination diff --git a/Projects/UOContent/Spells/Fifth/PoisonField.cs b/Projects/UOContent/Spells/Fifth/PoisonField.cs index 92a797f19..3c5d2f609 100644 --- a/Projects/UOContent/Spells/Fifth/PoisonField.cs +++ b/Projects/UOContent/Spells/Fifth/PoisonField.cs @@ -64,19 +64,13 @@ public class PoisonFieldSpell : MagerySpell, ITargetingSpell } [DispellableField] -[SerializationGenerator(1, false)] +[SerializationGenerator(0, false)] public partial class PoisonField : Item { - private void MigrateFrom(V0Content content) - { - _caster = content.Caster; - _end = content.End; - } - [SerializableField(0)] private Mobile _caster; - [AnchoredDateTime] + [DeltaDateTime] [SerializableField(1)] private DateTime _end; diff --git a/Projects/UOContent/Spells/Fourth/FireField.cs b/Projects/UOContent/Spells/Fourth/FireField.cs index 8f20f995b..0b0a22129 100644 --- a/Projects/UOContent/Spells/Fourth/FireField.cs +++ b/Projects/UOContent/Spells/Fourth/FireField.cs @@ -67,23 +67,16 @@ public class FireFieldSpell : MagerySpell, ITargetingSpell } [DispellableField] -[SerializationGenerator(1, false)] +[SerializationGenerator(0, false)] public partial class FireFieldItem : Item { - private void MigrateFrom(V0Content content) - { - _damage = content.Damage; - _caster = content.Caster; - _end = content.End; - } - [SerializableField(0)] private int _damage; [SerializableField(1)] private Mobile _caster; - [AnchoredDateTime] + [DeltaDateTime] [SerializableField(2)] private DateTime _end; private Timer _timer; diff --git a/Projects/UOContent/Spells/Ninjitsu/MirrorImage.cs b/Projects/UOContent/Spells/Ninjitsu/MirrorImage.cs index 63788aa69..6d85b9d73 100644 --- a/Projects/UOContent/Spells/Ninjitsu/MirrorImage.cs +++ b/Projects/UOContent/Spells/Ninjitsu/MirrorImage.cs @@ -238,7 +238,10 @@ namespace Server.Mobiles if (master?.Map == Mobile.Map && master?.InRange(Mobile, Mobile.RangePerception) == true) { - WalkMobileRange(master, 2, 0, 1); + var iCurrDist = (int)Mobile.GetDistanceToSqrt(master); + var bRun = iCurrDist > 5; + + WalkMobileRange(master, 2, bRun, 0, 1); } else { diff --git a/Projects/UOContent/Spells/Seventh/EnergyField.cs b/Projects/UOContent/Spells/Seventh/EnergyField.cs index dbfece36d..5e3ea1149 100644 --- a/Projects/UOContent/Spells/Seventh/EnergyField.cs +++ b/Projects/UOContent/Spells/Seventh/EnergyField.cs @@ -77,19 +77,13 @@ public class EnergyFieldSpell : MagerySpell, ITargetingSpell } [DispellableField] -[SerializationGenerator(2, false)] +[SerializationGenerator(1, false)] public partial class EnergyField : Item { - private void MigrateFrom(V1Content content) - { - _caster = content.Caster; - _end = content.End; - } - [SerializableField(0)] private Mobile _caster; - [AnchoredDateTime] + [DeltaDateTime] [SerializableField(1)] private DateTime _end; diff --git a/Projects/UOContent/Spells/Sixth/ParalyzeField.cs b/Projects/UOContent/Spells/Sixth/ParalyzeField.cs index 301cee507..830f16dfe 100644 --- a/Projects/UOContent/Spells/Sixth/ParalyzeField.cs +++ b/Projects/UOContent/Spells/Sixth/ParalyzeField.cs @@ -77,19 +77,13 @@ public class ParalyzeFieldSpell : MagerySpell, ITargetingSpell } [DispellableField] -[SerializationGenerator(1, false)] +[SerializationGenerator(0, false)] public partial class ParalyzeField : Item { - private void MigrateFrom(V0Content content) - { - _caster = content.Caster; - _end = content.End; - } - [SerializableField(0)] private Mobile _caster; - [AnchoredDateTime] + [DeltaDateTime] [SerializableField(1)] private DateTime _end; diff --git a/Projects/UOContent/Spells/Spellweaving/Items/TransientItem.cs b/Projects/UOContent/Spells/Spellweaving/Items/TransientItem.cs index 49894cc95..54ac52030 100644 --- a/Projects/UOContent/Spells/Spellweaving/Items/TransientItem.cs +++ b/Projects/UOContent/Spells/Spellweaving/Items/TransientItem.cs @@ -3,17 +3,12 @@ using ModernUO.Serialization; namespace Server.Items; -[SerializationGenerator(2, false)] +[SerializationGenerator(1, false)] public partial class TransientItem : Item { - private void MigrateFrom(V1Content content) - { - _expiration = content.Expiration; - } - private TimerExecutionToken _timerToken; - [AnchoredDateTime] + [DeltaDateTime] [SerializableField(0)] [SerializedCommandProperty(AccessLevel.GameMaster)] private DateTime _expiration; diff --git a/Projects/UOContent/Spells/Third/WallOfStone.cs b/Projects/UOContent/Spells/Third/WallOfStone.cs index 1b1dd04d3..7cae3b848 100644 --- a/Projects/UOContent/Spells/Third/WallOfStone.cs +++ b/Projects/UOContent/Spells/Third/WallOfStone.cs @@ -63,19 +63,13 @@ public class WallOfStoneSpell : MagerySpell, ITargetingSpell } [DispellableField] -[SerializationGenerator(1, false)] +[SerializationGenerator(0, false)] public partial class WallOfStone : Item { - private void MigrateFrom(V0Content content) - { - _caster = content.Caster; - _end = content.End; - } - [SerializableField(0)] private Mobile _caster; - [AnchoredDateTime] + [DeltaDateTime] [SerializableField(1)] private DateTime _end; diff --git a/Projects/UOContent/UOContent.csproj b/Projects/UOContent/UOContent.csproj index 60f28ac6f..4f230902d 100644 --- a/Projects/UOContent/UOContent.csproj +++ b/Projects/UOContent/UOContent.csproj @@ -40,18 +40,19 @@ false - - + + - + - + - - + + + diff --git a/Projects/UOContent/Utilities/Types.cs b/Projects/UOContent/Utilities/Types.cs index ca5b9422d..fa669d96a 100644 --- a/Projects/UOContent/Utilities/Types.cs +++ b/Projects/UOContent/Utilities/Types.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Concurrent; +using System.Collections.Generic; using System.Globalization; using System.Reflection; using System.Runtime.CompilerServices; diff --git a/README.md b/README.md index 01a368804..5bc8e7985 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ ModernUO [![Discord](https://img.shields.io/discord/751317910504603701?logo=disc ## Requirements #### Supported Operating Systems -[![Windows 10/11/2012 R2/2016/2019/2022/2025](https://img.shields.io/badge/-server%202025-3c78d5?labelColor=222222&logo=data:image/svg%2bxml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHJvbGU9ImltZyIgdmlld0JveD0iMCAwIDI0IDI0Ij48dGl0bGU+V2luZG93czwvdGl0bGU+PHBhdGggZD0iTTAsMEgxMS4zNzdWMTEuMzcySDBaTTEyLjYyMywwSDI0VjExLjM3MkgxMi42MjNaTTAsMTIuNjIzSDExLjM3N1YyNEgwWm0xMi42MjMsMEgyNFYyNEgxMi42MjMiIGZpbGw9IiMzYzc4ZDUiLz48L3N2Zz4=)](https://www.microsoft.com/en-US/evalcenter/evaluate-windows-server-2022) +[![Windows 10/11/2012/2016/2019/2022/2025](https://img.shields.io/badge/-server%202025-3c78d5?labelColor=222222&logo=data:image/svg%2bxml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHJvbGU9ImltZyIgdmlld0JveD0iMCAwIDI0IDI0Ij48dGl0bGU+V2luZG93czwvdGl0bGU+PHBhdGggZD0iTTAsMEgxMS4zNzdWMTEuMzcySDBaTTEyLjYyMywwSDI0VjExLjM3MkgxMi42MjNaTTAsMTIuNjIzSDExLjM3N1YyNEgwWm0xMi42MjMsMEgyNFYyNEgxMi42MjMiIGZpbGw9IiMzYzc4ZDUiLz48L3N2Zz4=)](https://www.microsoft.com/en-US/evalcenter/evaluate-windows-server-2022) ![MacOS 14+](https://img.shields.io/badge/-sonoma-222222?logo=apple&logoColor=white&labelColor=222222) [![Debian 12+](https://img.shields.io/badge/-trixie-A81D33?logo=debian&logoColor=A81D33&labelColor=222222)](https://www.debian.org/distrib/) [![Ubuntu 22+ LTS](https://img.shields.io/badge/-26LTS-E95420?logo=ubuntu&logoColor=E95420&labelColor=222222)](https://ubuntu.com/download/server) @@ -37,17 +37,6 @@ ModernUO [![Discord](https://img.shields.io/discord/751317910504603701?logo=disc ##### Windows [![VC++ Redistributable v14](https://img.shields.io/badge/-Redist%20v14-00599C?logo=cplusplus&logoColor=white&labelColor=222222)](https://aka.ms/vc14/vc_redist.x64.exe) -#### Hardware - -| Use | vCPU | RAM | Storage | -|---|---|---|---| -| Development / test | 2 **dedicated** | 2 GB | SSD | -| Small live shard (< 50 concurrent) | 4 dedicated | 4 GB | NVMe | -| Medium (50–200) | 4–8 | 8 GB | NVMe | -| Large (200+) | 8+, high clock | 16 GB+ | NVMe | - -See [dev-docs/server-requirements.md](dev-docs/server-requirements.md) for more information. - #### Development [![git](https://img.shields.io/badge/-git-F05032?logo=git&logoColor=F05032&labelColor=222222)](https://git-scm.com/downloads) [![.NET](https://img.shields.io/badge/-%2010.0.100%20SDK-5C2D91?logo=.NET&logoColor=white&labelColor=222222)](https://dotnet.microsoft.com/download/dotnet/10.0) @@ -98,31 +87,18 @@ dnf install -y dnf-plugins-core dnf config-manager --set-enabled crb dnf install -y epel-release # Prerequisites -dnf install -y findutils libicu libdeflate libargon2 tzdata +dnf install -y findutils libicu libdeflate-devel zstd libargon2-devel liburing-devel ``` ### Ubuntu, Debian, etc ```shell apt-get update -y -# The ICU runtime package carries the ABI version in its name (libicu74, libicu76, …) and has no -# stable alias, so match it by pattern rather than pinning a release-specific name. -apt-get install -y '^libicu[0-9]+$' libdeflate0 libargon2-1 tzdata +apt-get install -y libicu-dev libdeflate-dev zstd libargon2-dev liburing-dev ``` -Only the runtime libraries are needed — the `-dev`/`-devel` packages are not. Run -`./build-tool --check-prereqs` to check the current machine and print the exact packages your -release needs. - -`zstd` is not listed because ZstdNet bundles `libzstd` for every platform, and `liburing` is not -listed because IORingGroup issues `io_uring` syscalls directly. - -If the shard's configured time zone is a legacy alias such as `US/Eastern`, Debian 12 and Ubuntu -24.04 also need `tzdata-legacy`. See [Platform Prerequisites](dev-docs/platform-prerequisites.md) -for what each dependency is for and what breaks without it. - ## OSX Requirements ```shell -brew install icu4c libdeflate argon2 +brew install icu4c libdeflate zstd argon2 ``` ## Running the Server diff --git a/dev-docs/claude-skills/migrate-from-runuo/migrate-items-mobiles.md b/dev-docs/claude-skills/migrate-from-runuo/migrate-items-mobiles.md index 7310c104e..8edf70a72 100644 --- a/dev-docs/claude-skills/migrate-from-runuo/migrate-items-mobiles.md +++ b/dev-docs/claude-skills/migrate-from-runuo/migrate-items-mobiles.md @@ -29,9 +29,6 @@ description: > - `BaseCreature(AI, Fight, 10, 1, 0.2, 0.4)` -> `BaseCreature(AI, Fight)` (extra params default) - `Name = "text"` -> `public override string DefaultName => "text";` - Expression-bodied overrides: `public override int Meat { get { return 1; } }` -> `public override int Meat => 1;` -- AI movement calls lose the `run` flag: `MoveTo(m, true, range)` -> `MoveTo(m, range)` (also `WalkMobileRange`, `ApproachTarget`, `MoveToPoint`, `PathFollower.Follow`); the Running bit is derived from step pace -> `dev-docs/runuo-migration-docs/09-items-mobiles-creatures.md` § AI Movement -- `AcquireOnApproach` (bool) -> `AcquireOnApproachDelay` (TimeSpan; `Zero` = old instant behavior) -> same doc § Target Acquisition -- `DamageEntries` is an inline `ref readonly ValueLinkList`, not a `List`: indexer/`Add`/`Remove`/`Clear` -> `foreach` / `.ByDescending()` (needs `using Server.Collections;`) and `ClearDamageEntries()`; `GetLootingRights` takes it by `in` -> same doc § Damage Entries ## Anti-Patterns - Using `_field--` instead of `Property--` (bypasses MarkDirty tracking) diff --git a/dev-docs/claude-skills/modernuo-code-audit.md b/dev-docs/claude-skills/modernuo-code-audit.md index d805b0578..5d5af15bb 100644 --- a/dev-docs/claude-skills/modernuo-code-audit.md +++ b/dev-docs/claude-skills/modernuo-code-audit.md @@ -200,27 +200,8 @@ mob.SendMessage($"You earned a {rank:L} trophy!"); // "gold" not "Gold" **See**: `dev-docs/property-lists.md` § "Never Invalidate From Inside `GetProperties`". -### 20. Tick-Count Math Must Be Wraparound-Safe -**Check**: Every comparison between `Core.TickCount` / `Core.GetTimestamp()` values (or fields -derived from them — names like `*Until`, `*At`, `*Next*`, `deadline`) must be in subtraction form. -Flag direct comparisons, zero/sign sentinels, and deadline fields left at their zero default. -**Bad**: `if (Core.TickCount < _deadline)`; `if (_lastEventAt > 0)` as "has happened"; -`private static long _deadline;` compared before being seeded from a real tick. -**Good**: `if (Core.TickCount - _deadline < 0)`; a separate `bool` for "has happened"; seeding -deadline fields from the first observed timestamp. -**Why**: On some hypervisors — Google Cloud specifically — the VM receives a pass-through of the -host's never-resetting counter. Tick counts are NOT zero at process start, NOT zero at OS boot, -can be enormous from the first read, and can wrap negative. Direct comparisons and sign sentinels -then fail only on those hosts, after long host uptimes — the least reproducible bug class there -is. Windows has not shown this in testing; Linux has, in production. Subtraction of two ticks -wraps correctly in two's complement. -**Note**: `DateTime`/`DateTimeOffset` comparisons are unaffected; this applies only to the -monotonic tick domain. - -**See**: `dev-docs/tick-counts.md` for the full rules and review checklist. - ## Severity Levels -- **ERROR**: Rules 3, 9, 10, 13, 19, 20 (will cause bugs, build failures, or client-side leaks) +- **ERROR**: Rules 3, 9, 10, 13, 19 (will cause bugs, build failures, or client-side leaks) - **WARNING**: Rules 1 (Tier 3 LINQ), 2, 4, 5, 6, 7, 8, 12, 14, 15, 17 (performance/convention issues) - **INFO**: Rules 1 (Tier 2 LINQ on warm paths — note it but don't flag as violation), 16 (switch patterns — suggest but don't flag) - **ASK**: Rule 11 (need user input) diff --git a/dev-docs/claude-skills/modernuo-content-patterns.md b/dev-docs/claude-skills/modernuo-content-patterns.md index 6e0902b64..12c9515ef 100644 --- a/dev-docs/claude-skills/modernuo-content-patterns.md +++ b/dev-docs/claude-skills/modernuo-content-patterns.md @@ -23,22 +23,6 @@ description: > 4. **Clean up timers and references in `OnDelete()`/`OnAfterDelete()`** 5. **No LINQ** in game logic -- use loops and `PooledRefList` 6. **File placement** matters -- follow the directory conventions below -7. **Creature speeds are delays in seconds, on two clocks** -- think - (`ActiveSpeed`/`PassiveSpeed`, seconds per AI decision) and move - (`ActiveMoveSpeed`/`PassiveMoveSpeed`, seconds per step; inherits think until - overridden). Prefer `npc-speeds.json` buckets (`SpeedClass`); `SetSpeed()` sets think - AND clears move overrides, `SetMoveSpeed()` sets move only. The client `Running` bit is - derived from the step pace (`BaseAI.ShouldRun`); movement APIs take no run argument -- - see `dev-docs/content-patterns.md` § Creature Speeds. Reaction time to approaching - enemies is `AcquireOnApproachDelay` (TimeSpan gradient; `Zero` = paragon snap, 2s - default, `ReacquireDelay`-only = oblivious) -- see § Target Acquisition -8. **`OnThink` overrides must be excess-call tolerant** -- it fires more often than the - think cadence (player commands prod it; speed-ups reschedule it). Gate consequential - work on a tick-count deadline (subtraction form) or make it idempotent; bare per-call - random rolls are cosmetics-only. `MonsterAbility` is under the same contract: the - trigger cooldown is the rate limit, `ChanceToTrigger` is per-sample jitter, and a - zero-cooldown `Think`/`CombatAction` ability triggers every sampled think -- see - `dev-docs/content-patterns.md` § OnThink: the excess-call contract ## New Item Template diff --git a/dev-docs/claude-skills/modernuo-serialization.md b/dev-docs/claude-skills/modernuo-serialization.md index 91e3d5e95..c96244656 100644 --- a/dev-docs/claude-skills/modernuo-serialization.md +++ b/dev-docs/claude-skills/modernuo-serialization.md @@ -40,31 +40,21 @@ public partial class MyItem : Item { } public partial class MigratedItem : Item { } ``` -### [SerializableField(index, getter, setter, isVirtual, fieldChanged, allowFieldChange)] +### [SerializableField(index, setter, saveIf)] Applied to `_camelCase` private fields. Generates `PascalCase` property. - `index`: Serialization order (0+) -- `getter`/`setter`: Access level -- `"private"`, `"internal"`, or omit for public -- `isVirtual`: Generate a virtual property -- `fieldChanged`: `nameof` of `void Method(T oldValue, T newValue)`, invoked by the generated setter after assignment -- `allowFieldChange`: `nameof` of `bool Method(ref T value)`, invoked before assignment -- coerce through the `ref` parameter or return `false` to reject - -Generated setter pipeline: equality check → `allowFieldChange` → assignment → `MarkDirty` → `InvalidateProperties` (if declared) → `fieldChanged`. The field still holds the old value while the gate runs. Hooks require a generated setter (SG3018 on readonly/setterless fields); a missing or wrong-shaped named method is SG3015. +- `setter`: Access level -- `"private"`, `"internal"`, or omit for public +- `saveIf`: Condition method name for conditional serialization ```csharp -[SerializableField(0, allowFieldChange: nameof(AllowChargesChange))] +[SerializableField(0)] [SerializedCommandProperty(AccessLevel.GameMaster)] -[InvalidateProperties] private int _charges; - -private bool AllowChargesChange(ref int value) -{ - value = Math.Clamp(value, 0, MaxCharges); - return true; -} +// Generates: public int Charges { get; set; } ``` ### [SerializableProperty(index, useField)] -Applied to properties with **custom getters** (fallback defaults, lazy/self-healing reads) or setter semantics the field hooks cannot express. For setters that only coerce, veto, or run post-change side effects, use `[SerializableField]` with `allowFieldChange`/`fieldChanged` instead. +Applied to properties with custom get/set logic. - `index`: Serialization order - `useField`: Backing field name if auto-detection fails @@ -73,12 +63,12 @@ Applied to properties with **custom getters** (fallback defaults, lazy/self-heal [CommandProperty(AccessLevel.GameMaster)] public int MaxItems { - get => _maxItems == -1 ? DefaultMaxItems : _maxItems; // custom getter: the reason this is a property + get => _maxItems == -1 ? DefaultMaxItems : _maxItems; set { _maxItems = value; InvalidateProperties(); - this.MarkDirty(); // REQUIRED in custom setters + this.MarkDirty(); } } ``` @@ -99,11 +89,8 @@ Exposes field to `[Props` gump for in-game editing. ### [EncodedInt] Variable-length int encoding (saves space for small values). -### [AnchoredDateTime] -Stores the absolute UTC instant; shifted by downtime at load so remaining time is preserved. Byte-stable across idle saves. Prefer for deadlines/elapsed-while-running values. - ### [DeltaDateTime] -Stores DateTime as offset from current time (handles server restarts). Legacy: rewrites bytes every save; prefer `[AnchoredDateTime]` for new fields. Converting between the two changes the wire format (version bump). +Stores DateTime as offset from current time (handles server restarts). ### [InternString] Interns strings to reduce memory for repeated values. @@ -141,32 +128,28 @@ private void AfterDeserialization() } ``` -### [DeserializeTimer(nameof(Method), wallClock)] -Required on every serializable `Timer` member (SG3008 otherwise). By default the next tick is stored as **anchored time** (downtime does not consume the remaining delay; idle saves byte-stable); `wallClock: true` stores an absolute deadline instead (delay negative if it passed during downtime). The method -- `void Method(TimeSpan delay)` -- is invoked **only when a timer was running at save**; there is no sentinel to check. +### [DeserializeTimerField(fieldIndex)] +Custom timer deserialization. Timer is saved as remaining TimeSpan. ```csharp [SerializableField(0, setter: "private")] -[DeserializeTimer(nameof(DeserializeEvaluateTimer), wallClock: true)] private Timer _evaluateTimer; +[DeserializeTimerField(0)] private void DeserializeEvaluateTimer(TimeSpan delay) { _evaluateTimer = Timer.DelayCall(delay, EvaluationInterval, Evaluate); } ``` -Switching a timer between drifting and `wallClock` changes the wire format: bump the class version and add `MigrateFrom` -- the old content struct exposes `XxxNext` (`DateTime`) and `XxxDelay` (`TimeSpan`, `TimeSpan.MinValue` when no timer ran). - -### [SaveFlag(nameof(ShouldSerializeMethod), nameof(DefaultValueMethod))] -On the serializable field/property itself. Conditional serialization -- skip fields with default values. Second method optional; when omitted, the field keeps its default at load. +### [SerializableFieldSaveFlag(fieldIndex)] / [SerializableFieldDefault(fieldIndex)] +Conditional serialization -- skip fields with default values. ```csharp -[SerializableField(0)] -[SaveFlag(nameof(ShouldSerializeMaxItems), nameof(MaxItemsDefaultValue))] -private int _maxItems; - +[SerializableFieldSaveFlag(0)] private bool ShouldSerializeMaxItems() => _maxItems != -1; +[SerializableFieldDefault(0)] private int MaxItemsDefaultValue() => -1; ``` @@ -233,35 +216,28 @@ public partial class ChargedItem : Item } ``` -### Item with Setter Hooks (coerce + side effects) +### Item with Custom Properties ```csharp [SerializationGenerator(2)] public partial class BagOfSending : Item { - [SerializableField(0, fieldChanged: nameof(OnBagOfSendingHueChanged))] - [SerializedCommandProperty(AccessLevel.GameMaster)] - private BagOfSendingHue _bagOfSendingHue; - - private void OnBagOfSendingHueChanged(BagOfSendingHue oldValue, BagOfSendingHue newValue) + [SerializableProperty(0)] + [CommandProperty(AccessLevel.GameMaster)] + public BagOfSendingHue BagOfSendingHue { - Hue = newValue switch + get => _bagOfSendingHue; + set { - BagOfSendingHue.Yellow => 0x8A5, - BagOfSendingHue.Blue => 0x8AD, - BagOfSendingHue.Red => 0x89B, - _ => Hue - }; - } - - [SerializableField(1, allowFieldChange: nameof(AllowChargesChange))] - [SerializedCommandProperty(AccessLevel.GameMaster)] - [InvalidateProperties] - private int _charges; - - private bool AllowChargesChange(ref int value) - { - value = Math.Clamp(value, 0, MaxCharges); - return true; + _bagOfSendingHue = value; + Hue = value switch + { + BagOfSendingHue.Yellow => 0x8A5, + BagOfSendingHue.Blue => 0x8AD, + BagOfSendingHue.Red => 0x89B, + _ => Hue + }; + this.MarkDirty(); + } } } ``` @@ -352,13 +328,11 @@ public partial class MagicGem ## Real Examples - Simple creature: `Projects/UOContent/Mobiles/Animals/Bears/BlackBear.cs` - Serialized fields + timer: `Projects/UOContent/Items/Weapons/Ranged/BaseRanged.cs` -- Setter hooks (allowFieldChange + fieldChanged): `Projects/UOContent/Items/Special/Solen Items/BagOfSending.cs` -- Custom getters (era fallbacks, the [SerializableProperty] use case): `Projects/UOContent/Items/Weapons/BaseWeapon.cs` +- Custom properties: `Projects/UOContent/Items/Special/Solen Items/BagOfSending.cs` - Complex with AfterDeserialization: `Projects/UOContent/Accounting/Account.cs` -- Timer deserialization (wall-clock): `Projects/UOContent/Items/Aquarium/Aquarium.cs` -- Timer deserialization (drifting/anchored + timer MigrateFrom): `Projects/UOContent/Items/Lights/BaseLight.cs` +- Timer deserialization: `Projects/UOContent/Items/Aquarium/Aquarium.cs` - Tidy + DeltaDateTime: `Projects/UOContent/Engines/CannedEvil/ChampionSpawn.cs` -- Conditional serialization ([SaveFlag]): `Projects/Server/Items/Container.cs` +- Conditional serialization: `Projects/Server/Items/Container.cs` ## Version Migration Migration schemas are JSON files in `Projects/Server/Migrations/` and `Projects/UOContent/Migrations/`: diff --git a/dev-docs/claude-skills/modernuo-threading.md b/dev-docs/claude-skills/modernuo-threading.md index 3b6507af2..800bf77d2 100644 --- a/dev-docs/claude-skills/modernuo-threading.md +++ b/dev-docs/claude-skills/modernuo-threading.md @@ -150,78 +150,6 @@ These files MAY use threading (they're server infrastructure, not game logic): - `Projects/Server/Network/` - Network I/O - `Projects/Server/Timer/Timer.Pool.cs` - Pool refill -## Exceptions: Vetted Workers in UOContent - -**A background thread is a last resort.** The forbidden list is about game logic, which is never -threaded. A dedicated worker touching no game state is the sanctioned way off the loop, and -necessarily uses `new Thread`, `ConcurrentQueue`, `Interlocked`, `AutoResetEvent` and -`volatile` **at the thread boundary only**. - -### Prove the need first - -- Measure **on-loop time**, not wall-clock. Frozen world is the cost; player latency is not. -- Off-loading creates no CPU. On 1-2 cores there is no spare core — gate on `ProcessorCount`. -- Count what stays: dispatch, continuation, and the loop slowing while the worker evicts shared L3. -- Record the measurement, or nobody can re-justify the worker later. - -### Game logic stays on the loop — chunk it - -Work needing game state cannot be threaded at any core count. Too slow for one tick? Split across -ticks, bounded by count or elapsed time — never "until done". - -```csharp -Timer.DelayCall(TimeSpan.Zero, TimeSpan.FromMilliseconds(50), () => -{ - var budget = 0; - while (_cursor < _items.Count && budget++ < 100) { Process(_items[_cursor++]); } -}); -``` - -### Vetted workers - -| Worker | Justification | -|---|---| -| `Accounting/Security/PasswordWorker.cs` | 8.9 ms/login on-loop at Argon2; 3.5-8.9 ms measured saving | -| `Engines/Advanced Search/AdvancedSearchGump.cs` | Admin-triggered full-world scan, saves disabled | - -### The six rules - -1. No game state read or written off-thread; dispatch immutable values captured on the loop. -2. Resolve policy (algorithm, salt, era branch) at dispatch — the worker holds none. -3. Park on a kernel wait, never spin. Spinning burns a core on shared hosts. -4. Run only while `WorldState is Running or WritingSave`. **Not** `World.Saving` — that misses - `PendingSave`, where serialization threads are already spinning. -5. Bounded queue, or a bound upstream named in a comment. -6. Everything the worker calls must itself be thread-safe. A singleton is not automatically safe — - `HashAlgorithm.ComputeHash` carries state, `Utility`'s RNG is a shared `System.Random` and game - state. Prefer static one-shot APIs (`SHA256.HashData`, `RandomNumberGenerator.Fill`). - -### Crossing the boundary - -Dispatch captures what the continuation will need to re-validate: - -```csharp -var job = new Job { Target = state, Expected = account.Password, Input = DerivePhrase(...) }; -if (!Worker.TryEnqueue(job)) { /* reject — never fall back to running it inline */ } -``` - -Hand back one of two ways, and no other: - -```csharp -Core.LoopContext.Post(() => Apply(job, result)); // a result for a specific caller -Volatile.Write(ref _snapshot, newTable); // a shared table rebuilt periodically -``` - -The continuation re-validates, because time passed: - -```csharp -if (job.Target?.Running != true) { return; } // gone -if (account.Password != job.Expected) { return; } // changed underneath -``` - -Always post a result, including on failure — a worker that throws silently leaves its caller -waiting forever. Use `ConfigureAwait(false)` on every await inside off-loop work. - ## Anti-Patterns | Pattern | Problem | Solution | diff --git a/dev-docs/claude-skills/modernuo-timers.md b/dev-docs/claude-skills/modernuo-timers.md index f366b3569..2c5c8168e 100644 --- a/dev-docs/claude-skills/modernuo-timers.md +++ b/dev-docs/claude-skills/modernuo-timers.md @@ -147,27 +147,18 @@ public partial class DecayingItem : Item } ``` -### [DeserializeTimer] Pattern (for Timer fields) -Required on every serializable `Timer` member. Drifting by default: the next tick is stored -as anchored time, so server downtime does not consume the remaining delay. Use -`wallClock: true` for absolute deadlines (delay is negative if it passed during downtime). -The method is invoked **only when a timer was running at save** — no sentinel to check. - +### [DeserializeTimerField] Pattern (for Timer fields) ```csharp [SerializableField(0, setter: "private")] -[DeserializeTimer(nameof(DeserializeEvaluateTimer), wallClock: true)] private Timer _evaluateTimer; +[DeserializeTimerField(0)] private void DeserializeEvaluateTimer(TimeSpan delay) { _evaluateTimer = Timer.DelayCall(delay, EvaluationInterval, Evaluate); } ``` -Switching an existing timer between drifting and `wallClock` changes the wire format — bump -the class's `[SerializationGenerator]` version and add a `MigrateFrom` (the old content -struct exposes `XxxDelay`, `TimeSpan.MinValue` when no timer ran). - ### Custom Timer Class (When You Need Complex Logic) ```csharp private class DecayTimer : Timer diff --git a/dev-docs/configuration.md b/dev-docs/configuration.md index b69349451..9ddc348a7 100644 --- a/dev-docs/configuration.md +++ b/dev-docs/configuration.md @@ -98,7 +98,6 @@ Examples from the codebase: accountHandler.enableAutoAccountCreation accountHandler.enablePlayerPasswordCommand accountHandler.maxAccountsPerIP -accountSecurity.encryptionAlgorithm autosave.enabled autosave.saveDelay world.savePath diff --git a/dev-docs/content-patterns.md b/dev-docs/content-patterns.md index 6205ec2d4..30bb942f3 100644 --- a/dev-docs/content-patterns.md +++ b/dev-docs/content-patterns.md @@ -254,120 +254,6 @@ public override int TreasureMapLevel => 3; // Drops treasure map public override double WeaponAbilityChance => 0.4; // Weapon ability chance ``` -### Creature Speeds (think vs move clocks) - -All "speed" values are **delays in seconds** (smaller = faster). A creature runs two clocks: - -- **Think clock** — `ActiveSpeed`/`PassiveSpeed`/`CurrentSpeed`: seconds per AI decision - (combat decisions, target acquisition, spell timing). -- **Move clock** — `ActiveMoveSpeed`/`PassiveMoveSpeed`/`CurrentMoveSpeed`: seconds per - step. Inherits the matching think value until overridden, so a creature configured with - only think speeds behaves as one clock. Any value is legal — steps are scheduled - independently of think ticks, so the two need not divide evenly. - -Speeds normally come from `Distribution/Data/npc-speeds.json` (via `SpeedClass` or type -lists); `activeMove`/`passiveMove` are optional per bucket. Prefer data over code: - -```csharp -public override SpeedLevel SpeedClass => SpeedLevel.Slow; // bucket in npc-speeds.json -``` - -Code-level overrides for special cases: - -```csharp -SetSpeed(0.5, 2.0); // think clock; ALSO clears move overrides (one-clock legacy semantics) -SetMoveSpeed(0.45, 0.9); // move clock only — call after SetSpeed if both are wanted -ClearMoveSpeed(); // back to inheriting the think clock -``` - -All four are `[props`-tunable per instance (move values: set `0` to re-inherit); per-instance -move overrides serialize. Being badly hurt slows steps, never decisions (RunUO parity). - -The client's `Running` bit is derived from the step pace, never passed by callers -(`BaseAI.ShouldRun`, stamped in `DoMoveImpl`): a step shorter than the client's walk -interpolation — 400 ms on foot, 200 ms mounted/flying (`Movement.WalkFootDelay` / -`WalkMountDelay`) — is flagged as a run, or the client falls behind and snaps. An isolated -step (resuming after at least a walk interval standing) goes out as a walk regardless of -pace — the client renders each step alone, so a run-flagged single step darts — unless the -pace beats the run interpolation (a true sprinter), where a walk-rendered first step would -flood the client's step queue. Movement APIs (`MoveTo`, `WalkMobileRange`, -`ApproachTarget`, `MoveToPoint`) take no run argument; to make a creature run, make it -fast. Creatures step at most once per `CurrentMoveSpeed` period, paced from the step just -taken — a stall never banks catch-up steps, so a resumed chase restarts at full pace. - -### Target Acquisition: the reaction-time gradient - -Acquisition is event-driven, not polled. The periodic scan (`AcquireFocusMob`) is gated by -`ReacquireDelay` (10 s default) and every scan re-arms it in full, success or failure — it -is target stickiness plus the fallback for what movement cannot signal (reveals, doors, -summons). Reaction time comes from `BaseCreature.OnMovement`: an enemy moving inside -`AcquireOnApproachRange` (10 — on-screen; the periodic scan keeps the wider -`RangePerception`) clamps the next scan to -at most **`AcquireOnApproachDelay`** — the intelligence gradient. `TimeSpan.Zero` -(paragons) also prods the AI, so the ranked scan engages within a timer-wheel turn; the -2 s default reads as "took a beat to notice you"; larger is dumber; a creature that -overrides the delay above `ReacquireDelay` is effectively oblivious to approach. Repeated -steps cannot shorten the clamp, so an armed creature scans once per delay period, not once -per step or think. `ReacquireOnMovement` remains the broader hook (any mover, no enemy -check, scan next think). The gate self-heals: a deadline further out than `ReacquireDelay` -is illegal and reads as open, so no wedged or wrapped value can silence acquisition beyond -one delay period. - -### OnThink: the excess-call contract - -`OnThink()` is a scheduler pass, not an action. The AI timer calls it *at least* at the -think cadence (`CurrentSpeed`), but it can and does fire more often: a player command -wakes the AI immediately (`AITimer.Prod()`), a speed-up reschedules the pending wake, and -players run command macros that drive extra thinks deliberately (order spam is spam-safe -by design — reaction, never action). RunUO had the same property (its timer restarted -with a random delay on every speed change), so this has never been a fixed-rate callback. - -**Every `OnThink` override must be excess-call tolerant.** An extra call must never grant -an extra action: - -- Gate consequential work on its own deadline field, compared in subtraction form - (`Core.TickCount - _nextX >= 0` — see `tick-counts.md`), or make it idempotent. -- Never pace a consequential action with a bare per-call `Utility.RandomDouble()` roll — - its frequency then scales with think rate, which players can influence. Per-call rolls - are acceptable only for pure cosmetics (idle animations, flavor sounds). -- The engine already gates the expensive things: steps (the `NextMove` budget), weapon - swings, spell casts, detect-hidden, and the base `BaseCreature.OnThink` actions (heal, - rummage, aura) all carry their own clocks. Follow that pattern. - -```csharp -private long _nextSpecial; - -public override void OnThink() -{ - base.OnThink(); - - if (Core.TickCount - _nextSpecial >= 0) - { - DoSpecial(); - _nextSpecial = Core.TickCount + 5000; // the real rate limit lives here - } -} -``` - -### MonsterAbility: same contract - -`MonsterAbility.CanTrigger` is sampled once per think for `Think`- and -`CombatAction`-triggered abilities, so abilities live under the same rule: - -- **`MinTriggerCooldown`/`MaxTriggerCooldown` is the real rate limit** — the floor holds - no matter how often thinks fire. Always give a triggered ability a real cooldown. -- **`ChanceToTrigger` is a per-sample roll**: above the cooldown floor, the expected - trigger delay shrinks as think rate rises. Treat the chance as flavor jitter, never as - the rate limiter, and keep cooldowns long relative to the think interval so the jitter - stays negligible (fire breath — chance 0.5, cooldown 30–45s — varies under 1% between - natural and spammed think rates). -- A **zero-cooldown ability records no cooldown at all** and triggers on every sampled - think that passes its chance — only ever correct for passive alteration hooks, never - for `Think`/`CombatAction` triggers. -- An ability that breaks pet orders (fear-style effects) must own its duration explicitly - (a hold state, or a "refuses orders until" deadline checked in the order handlers) — - pets react to re-issued commands immediately, so think latency is not a hold. - --- ## New Spell diff --git a/dev-docs/debugging-event-loop.md b/dev-docs/debugging-event-loop.md deleted file mode 100644 index c38a0e6f7..000000000 --- a/dev-docs/debugging-event-loop.md +++ /dev/null @@ -1,123 +0,0 @@ -# Debugging Event Loop Performance - -How to diagnose "the server feels slow" — written for both humans and AI assistants. Follow the -funnel in order; most incidents resolve before the last step. Do not start with dotnet-trace. - -## The model - -Every second of the main thread's wall time goes to exactly one of four places: - -1. **Work** — the loop's phases: mobile deltas, item deltas, timer callbacks (`Timer.Slice`), - network processing (`NetState.Slice`), posted tasks (`LoopContext`), world snapshots - (`WorldSnapshot` — the on-loop portion of a save). -2. **Sleep** — idle blocking in `NetState.WaitForCompletion`, bounded by the next timer tick and - `server.eventLoopIdleWaitMs`. -3. **GC pauses** — land inside whichever phase (or sleep) was running. -4. **Stolen** — the host ran something else: hypervisor scheduling, noisy neighbors, CPU credit - throttling. - -A sleep is bounded by the time to the next wheel turn, so **a correctly honoured sleep can never -cost a deadline**. The only way sleeping harms the game is the wait *returning late* — that is -stolen time, and the server measures it directly on every sleep. - -## Step 0 — Read what production already tells you - -No build changes needed. Three signals exist, all actionable: - -| Signal | Meaning | Action | -|---|---|---| -| Startup error: *host cannot honour short waits* | No high-resolution timer and `timeBeginPeriod` failed. Very old or unusual Windows. | Nothing is wrong with the server; it spins and uses a full core. Upgrade the OS or accept the core. | -| Warning: *host returned a Nms idle wait late … for the Nth time running* | The OS did not reschedule the process promptly after a 1–2ms wait, through several escalating backoffs. Shared/burstable vCPU signature. | Move to dedicated CPU, or set `server.eventLoopIdleWaitMs=0` to spin permanently. This is a **host** problem — no amount of server-side change fixes it. | -| Error: *keeps returning idle waits late and sleeping has backed off N times* | The escalation hit its 120s ceiling. The host is not going to recover. | As above, but stop waiting for it to settle. Logged once per degradation, re-armed after a clean minute. | -| Admin gump → Performance → *Event Loop* | `Healthy` / `Sleep suspended (host)` / `Spinning (configured)` / `Spinning - host cannot honor short waits` | Same as above; the last verdict is the startup error's state, not a config choice. | - -The first two backoffs of any episode log at **Debug**, not Warning: a single suspension is -recoverable and not something an operator can act on. Raise the log level if you are chasing a -marginal host and want to see them. Late wakes that coincide with a gen1-or-higher GC are not -counted at all — the GC deliberately collects during idle sleeps, so its pauses land there by -design and are not the host's fault. - -If none of these fired and the shard still feels laggy, the cause is work, GC, or something a -boot-time signal cannot see. Continue. - -## Step 1 — Flip the profiling build - -``` -dotnet build -p:EventLoopProfiling=true -``` - -This compiles in `EventLoopProfiler` (Server) and the `[LoopStats` command (UOContent). Without -the flag every hook call site is removed by the compiler (`[Conditional]`), so there is nothing to -"turn off" in normal builds and no cost to leave the hooks in the code. The profiling build's own -overhead is a handful of timestamp reads per iteration — small enough to run for days while -hunting an intermittent problem. - -**Capture a baseline first.** Run `[LoopStats` while the shard feels *fine* and keep the CSV. The -profiler also keeps ~15 minutes of history in memory, so if the problem is episodic you can wait -for an episode and the good minutes on either side are already recorded. Numbers without a -baseline are how RunUO's profiler became useless — always compare bad minutes to good minutes on -the same box, build, and world. - -## Step 2 — Read the decomposition - -`[LoopStats` prints the last minute and writes the full history CSV (one row per second). Match -the shape against these signatures: - -| Signature | Diagnosis | Next step | -|---|---|---| -| One phase consistently hot (e.g. `TimerSlice` 40%/s) | Deep processing in that subsystem | Step 3 — find the culprit in that phase | -| All phases near zero, `stolen` high, `lateWakes` > 0 | Host is stealing CPU | Host problem; see step 0 actions | -| `gcPauseMs` high, gen2 counts rising | GC pressure — something is allocating heavily | Step 3 on the allocating phase, or dotnet-counters for alloc rate | -| Iterations ≫ sleeps while shard is idle | The loop is not sleeping: a queue never drains or a wake storm | Check `IsIdle` inputs; a stuck signal in the ring is the historical example | -| Sleeps ≈ iterations, each sleep ~0ms | Spurious wake storm | Ring backend issue; count `wakesIssued` vs actual cross-thread posts | -| Everything normal, complaint persists | Not the event loop | Look at the network path, client, or DB/save timing | - -**Wheel lag vs player lag:** `wheelLagMaxMs` is how late timer callbacks fired. Receives are -handled the moment they arrive (they wake the loop), so player-felt lag with a clean wheel points -away from the loop entirely. - -## Step 3 — Find the culprit inside a hot phase - -Add a temporary culprit hook rather than reaching for a tracer. The pattern: same -`[Conditional("EVENT_LOOP_PROFILING")]` attribute, own file or the profiler file, record only the -worst offender per second (identity + duration), never a per-event log. Examples: - -- `TimerSlice` hot → time each timer callback, keep the max and its `timer.ToString()`. -- `NetworkSlice` hot → time packet handlers by packet id, keep the max. -- GC pressure → `dotnet-counters monitor --counters System.Runtime` for alloc rate first; it is - cheap and often names the culprit generation without a trace. - -Keep the hook after the hunt if it earns its cost in the profiling build; delete it otherwise. - -## Step 4 — dotnet-trace, last and targeted - -Only when a hot phase resists the culprit hook. Know the costs: EventPipe visibly slows the -process (worst exactly when things are already bad) and adds artifacts to the trace — on small -vCPU hosts the tracer's own threads appear as hotspots and Rider/PerfView hotspot views can -mislead. Mitigate by being narrow: - -- Trace the specific minutes the decomposition flagged, not "a while". -- `dotnet-trace collect --profile cpu-sampling --duration 00:00:30` is usually enough. -- Compare against a trace of a good minute (same rule as step 1: no baseline, no conclusions). - -## The RAM / GC misconception (read before declaring a leak) - -ModernUO allocates very little, and the GC collects opportunistically — mostly during idle sleeps -and world saves. Under a spinning loop (`eventLoopIdleWaitMs=0`, or the pre-2026 default) the GC -may find **no** natural pause point: memory climbs to a large fraction of physical RAM, a forced -collection eventually drops part of it, and fragmentation keeps the baseline permanently above -where it started. Task manager shows alarming numbers; the in-game numbers do not. **Performance -is unaffected — this is lazy collection working as designed, not a leak.** Idle sleeping largely -removes the effect because every sleep is a natural GC opportunity. Before investigating "a leak": -check `gen0/1/2` and `gcPauseMs` in the decomposition, and compare working set *after a world -save*, which forces the collection the spin loop never allowed. - -## Rules of thumb - -- Never trade always-on profiling for the numbers. Production carries one timestamp per sleep and - nothing else; everything heavier lives behind the build flag or on the `measure/event-loop` - branch (full harness, A/B scripts, vendored ring experiments). -- One decomposition chart beats a thousand log lines. Resist adding warnings the reader cannot - act on; the three production signals are deliberate. -- When filing or reporting: attach the baseline CSV and the episode CSV. Relative statements - ("TimerSlice went from 4% to 61% during the episode") are the useful form. diff --git a/dev-docs/ip-bans-and-allowlists.md b/dev-docs/ip-bans-and-allowlists.md index 40320efe5..5588bb19e 100644 --- a/dev-docs/ip-bans-and-allowlists.md +++ b/dev-docs/ip-bans-and-allowlists.md @@ -24,7 +24,7 @@ Filters are consulted in registration order, first denial wins: | Filter | Source | Scope | |---|---|---| | `firewall` | `Configuration/firewall.json`, mutable in-game | Admin-curated, permanent | -| `blocklist` | `Configuration/ip-blocklist.txt` (millions of entries, opt-in) | Reputation feeds | +| `blocklist` | `Configuration/ip-blocklist.txt` (millions of entries) | Reputation feeds | | `auto-denylist` | In-memory, 15 min | What this shard just caught misbehaving | ### Contributing a ban @@ -38,7 +38,7 @@ Two, with different authority: | List | Source | Revocable? | Covers | |---|---|---|---| -| `ManualAllowlist` | every `ip-allowlist*.txt` (opt-in) | No — an operator said so | Blocking **and** escalation | +| `FileAllowlist` | every `ip-allowlist*.txt` | No — an operator said so | Blocking **and** escalation | | `LoginAllowlist` | Earned by authenticating, 90-day TTL | Yes — 10 strikes/hour | Blocking **and** escalation | Both are consulted **only after the blocklist has already matched**, so a normal accept — the one an @@ -66,22 +66,16 @@ If none of those match, they may be inside a **CIDR** in the blocklist, or held ### 2. Add them to the allowlist -Set `"enabled": true` in `Configuration/ip-allowlist.json` first — it is off by default, so a shard that -has never written a carve-out does not poll for one. The shard logs a warning at startup if allowlist files -are present while the flag is off. - -Then one entry per line in `Distribution/Configuration/ip-allowlist.txt` — a bare address or a CIDR. This -file is yours; the generator creates it once and never rewrites it. +One entry per line in `Distribution/Configuration/ip-allowlist.txt` — a bare address or a CIDR. This file +is yours; the generator creates it once and never rewrites it. ``` 203.0.113.42 # shard owner, listed via a shared upstream address 198.51.100.0/24 # a whole range if the ISP rotates within it ``` -With the flag on, the shard reloads within `reloadInterval` (60s default). **No restart, and no need to -re-run the generator.** From that point the address is neither blocked nor contributed. With the flag off -the generator still subtracts the file at generation time, so the address stops being *blocked* — but a -behavioural detection can still contribute it, which is the case the flag exists to cover. +The shard reloads within `reloadInterval` (60s default). **No restart, and no need to re-run the +generator.** From that point the address is neither blocked nor contributed. ### 3. Clear any ban that already exists @@ -129,10 +123,8 @@ where your players actually are, and a carve-out names a real network, so you bu ``` That writes `ip-allowlist-starlink.txt` beside the blocklist, and every `ip-allowlist*.txt` there is -subtracted by the generator with no config edit. For the shard to read them too — which is what also stops -a carve-out address being *contributed* by a behavioural detection — set `enabled` in `ip-allowlist.json`; -it is off by default so no shard polls for files it never wrote. Starlink costs about 0.1% of the list. -Blank a file (keep the file) to reputation-block that network again; delete it to drop the carve-out. +subtracted — both by the generator and by the shard, with no config edit. Starlink costs about 0.1% of the +list. Blank a file (keep the file) to reputation-block that network again; delete it to drop the carve-out. Carve-out files carry an `asn=` marker in their header, which is how `-RefreshCarveouts` finds them. A hand-written allowlist has no marker and is never rewritten. @@ -163,12 +155,6 @@ Escalation is **immediate**, on the first detection: a 15-minute local hold plus (4h) contribution. There is no N-connection threshold; the strike counter governs only revoking a `LoginAllowlist` entry. -The local hold runs 15 minutes from the **first** detection and is never extended by later ones, so an -address that keeps trying is released on schedule rather than held indefinitely. It does not get a free -run: the rate limiter sits *ahead* of the connection filters, so a flooder is re-reported and re-held on -its next attempt. Not refreshing is what keeps the holds in expiry order, which is what makes retiring -lapsed ones cost the number expiring rather than the number held. - ### What is deliberately NOT detected **Do not add rules based on arrival framing.** TCP has no message boundaries, so the network, the OS or a @@ -191,27 +177,21 @@ firewalled off. Shortening the 5s handshake window has been tried and broke real - **An allowlist cannot bootstrap.** A `LoginAllowlist` entry is only earned by getting in, so it can never repair an existing false positive, and it is weakest on rotating CGNAT — a player whose lease moved is a - stranger again. `ManualAllowlist` is the fix for that, which is why it is manual — and opt-in, via - `ip-allowlist.json`. + stranger again. `FileAllowlist` is the fix for that, which is why it is manual. - **A never-logged-in player on a shared address can still be caught**, for up to `badConnectDuration`, if a co-tenant misbehaves. Accepted: it is 4h and self-healing. The cheapest lever is `badConnectDuration`. - **`MaxConnections` (4096) is a hard ceiling.** The accept gate runs *after* the kernel completed the TCP handshake, so a blocklist match saves the socket setup and the `NetState` slot but never the connection itself. Only an upstream L4 proxy or edge scrubbing moves that cost off the shard. -- **The `auto-denylist` stops tracking at `maxEntries`.** Past it a detection still disconnects the - connection, but the address is not held, so it pays full detection cost on every reconnect instead of a - cheap accept-gate deny. The default is sized for the 50k–250k distinct-source floods seen in practice; a - flood past it wants upstream scrubbing rather than a larger cap, which only buys a longer on-loop scan. ## Configuration | File | Controls | |---|---| | `bans.json` | `reportRateLimitTrips`, `autoBanDuration`, `reportBadConnects`, `badConnectDuration` | -| `blocklist.json` | `enabled` (default `false`), `file`, `reloadInterval`, `reportHits`, `banDuration`, `promoteSuppression` | -| `ip-allowlist.json` | `enabled` (default `false`), `files` (wildcards allowed), `reloadInterval` | +| `blocklist.json` | `file`, `allowlistFiles` (wildcards allowed), `reloadInterval`, `reportHits`, `banDuration`, `promoteSuppression` | | `login-allowlist.json` | `enabled`, `file`, `ttl`, `flushInterval`, `escalateAfterStrikes`, `strikeWindow` | -| `auto-denylist.json` | `enabled`, `duration`, `maxEntries` (default `324,449` — sized for the floods seen in practice; see the remark on the setting before raising it) | +| `auto-denylist.json` | `enabled`, `duration`, `maxEntries` | | `crowdsec.json` | `lapiUrl`, `machineId`, `password`, `origin`, `manualBanDuration`, `flushInterval`, `maxQueue` | | `firewall.json` | Admin-curated entries | @@ -228,7 +208,7 @@ A shard fronted by an upstream proxy can disable all of it and register nothing. | `Projects/Server/Network/Bans/BanReasons.cs` | Reason slugs + the behavioural opt-in set | | `Projects/UOContent/Network/BanExemptions.cs` | Combines both allowlists into one answer | | `Projects/UOContent/Network/Blocklist/BlocklistFilter.cs` | File-sourced blocklist filter | -| `Projects/UOContent/Network/Blocklist/ManualAllowlist.cs` | Operator carve-outs, read from the allowlist files | +| `Projects/UOContent/Network/Blocklist/FileAllowlist.cs` | Operator carve-outs, read from the allowlist files | | `Projects/UOContent/Network/LoginAllowlist/LoginAllowlist.cs` | Allowlist earned by authenticating | | `Projects/UOContent/Network/AutoDenylist/AutoDenylist.cs` | Short-lived local hold | | `Projects/UOContent/Network/CrowdSec/CrowdSecReporter.cs` | LAPI contribution sink | diff --git a/dev-docs/networking-packets.md b/dev-docs/networking-packets.md index 639963cfd..af47a1e06 100644 --- a/dev-docs/networking-packets.md +++ b/dev-docs/networking-packets.md @@ -511,9 +511,9 @@ Rules: Core owns the question; **every implementation lives in UOContent**. The three that ship are `firewall` (admin-curated, mutable at runtime, persisted to `Configuration/firewall.json`), `blocklist` (file-sourced, -millions of entries, demand-pages hits to CrowdSec, **opt-in**) and `auto-denylist` (in-memory, -short-lived, fed by the shard's own behavioural detections). A shard that fronts its server with an -upstream proxy or edge scrubbing can drop all of them and register nothing. +millions of entries, demand-pages hits to CrowdSec) and `auto-denylist` (in-memory, short-lived, fed by the +shard's own behavioural detections). A shard that fronts its server with an upstream proxy or edge scrubbing +can drop all of them and register nothing. The allowlists, ban contribution, behavioural detection and the operator process for exempting a false-positive address are covered separately in diff --git a/dev-docs/platform-prerequisites.md b/dev-docs/platform-prerequisites.md deleted file mode 100644 index 3e76d3b47..000000000 --- a/dev-docs/platform-prerequisites.md +++ /dev/null @@ -1,159 +0,0 @@ -# Platform Prerequisites - -OS-level dependencies ModernUO needs at runtime, why each one is required, and what breaks without -it. This page is about software packages, not hardware sizing. - -Run `./build-tool --check-prereqs` from the repository root to check the current machine. It prints -the exact install command for the detected distribution. - -## What is required - -| Dependency | Platform | Why | -|---|---|---| -| .NET 10 Runtime | all | — | -| ICU (`libicuuc`, `libicui18n`) | Linux, macOS | The runtime refuses to start without it; see below | -| tzdata | Linux | Time zone lookups; see below | -| `libdeflate` | all | `LibDeflate.Bindings` | -| `libargon2` | all | `Argon2.Bindings` (password hashing) | -| VC++ Redistributable v14 | Windows | Native bindings | - -Not required, despite appearances: - -- **zstd** — `ZstdNet` bundles `libzstd` for every RID. -- **liburing** — `IORingGroup` issues `io_uring` syscalls directly. It imports only `libc`, - `libSystem.dylib`, `kernel32.dll`, `kernelbase.dll` and `ws2_32.dll`. -- **`-dev` / `-devel` packages** — see "Runtime packages only" below. - -## Install - -```sh -# Debian / Ubuntu (ICU has no stable package alias, so match it by pattern) -sudo apt-get install -y '^libicu[0-9]+$' libdeflate0 libargon2-1 tzdata - -# Fedora / RHEL -sudo dnf install -y libdeflate libargon2 libicu tzdata - -# Alpine -apk add --no-cache libdeflate argon2-libs icu-libs tzdata - -# macOS -brew install icu4c libdeflate argon2 -``` - -CentOS additionally needs EPEL and CRB: - -```sh -sudo dnf install -y epel-release epel-next-release && sudo dnf config-manager --set-enabled crb -``` - -## Runtime packages only - -Only the runtime packages are needed. The `-dev`/`-devel` packages are **not** required. - -They used to be, because .NET's `DllImport` probing looks for the unversioned `libfoo.so`, and on -Linux that bare symlink ships only in the development package. The runtime package ships the -versioned SONAME (`libdeflate.so.0`, `libargon2.so.1`). The binding packages now probe the versioned -names as well, so the runtime package is sufficient. - -Anything still documenting `libicu-dev` or `libdeflate-dev` as a requirement is out of date. - -## ICU - -`Directory.Build.props` sets `InvariantGlobalization=false`, so ICU is mandatory. Without it the -runtime does **not** throw — it `FailFast`s: - -``` -Couldn't find a valid ICU package installed on the system. Please install libicu (or icu-libs) -using your package manager and try again. -``` - -That is `SIGABRT` (exit 134) and it cannot be caught. Note the process **starts cleanly and aborts -later**, at whatever line first touches a culture, so the crash rarely points at the cause. - -### Why invariant mode is not an option - -`InvariantGlobalization=true` would remove the ICU dependency, but it changes behaviour in ways that -corrupt data silently. Measured on .NET 10 with the repository's settings: - -| Behaviour | With ICU | Invariant mode | -|---|---|---| -| `new CultureInfo("de-DE")` | real culture | succeeds, returns invariant data | -| de-DE decimal separator | `,` | `.` | -| `1234.5` as de-DE | `1.234,5` | `1,234.5` | -| `string.Compare("a", "B", InvariantCulture)` | `-1` (linguistic) | `31` (ordinal) | -| sort `[b, A, a, B]` | `a, A, b, B` | `A, B, a, b` | -| `FindSystemTimeZoneById("Eastern Standard Time")` on Linux | resolves | `TimeZoneNotFoundException` | -| UTF-8 round-trip of non-ASCII | unaffected | unaffected | - -The dangerous row is the first. Because `Directory.Build.props` also sets -`PredefinedCulturesOnly=false`, constructing a culture in invariant mode **succeeds** instead of -throwing `CultureNotFoundException`, and hands back an object populated with invariant data. Number -parsing and formatting then produce wrong values with no error, and culture-sensitive sort order -silently becomes ordinal. - -Encoding is not affected — UTF-8 round-trips correctly in both modes. - -### Version floor - -The runtime accepts `libicuuc.so.60` and above (`MinICUVersion` in `pal_icushim.c`). The prerequisite -checker enforces the same floor, so a host carrying only an older ICU is reported missing rather than -passing and then aborting at startup. RHEL/CentOS 7 ships ICU 50 and is affected. - -ICU tracks its own release train, so the SONAME digit varies widely by distribution — `.so.74` on -Ubuntu 24.04, `.so.76` on Alpine, `.so.77` on Fedora, `.so.78` on openSUSE. There is no stable -package alias on Debian and Ubuntu, which is why the checker resolves the name via `apt-cache` -instead of hardcoding one. - -Only `libicuuc` and `libicui18n` are used; those are the two names -`libSystem.Globalization.Native.so` loads. `libicudata` arrives as a dependency of `libicuuc`, and -`libicuio`/`libicutu`/`libicutest` are never referenced. Every distribution ships all of them in a -single package, so installing ICU at all satisfies both. - -## tzdata - -The event scheduler resolves configured zone IDs through `TimeZoneInfo`, which reads -`/usr/share/zoneinfo` on Linux. This is separate from ICU: it is data, not a library, so no loader -probe finds it, and slim container images routinely omit it. - -Without tzdata every lookup except `UTC` throws: - -``` -TimeZoneNotFoundException: The time zone ID 'America/New_York' was not found on the local computer. -``` - -`TimeZoneInfo.GetSystemTimeZones()` returns 1 entry instead of ~419, and `TimeZoneInfo.Local` falls -back to UTC. - -### There is no per-zone subset - -Distributions do not package individual zones — it is one `tzdata` package, about 2 MB installed for -the full set. Subsetting is not worth pursuing. - -The one split that does exist is **`tzdata-legacy`** on Debian 12 and Ubuntu 24.04, which carries the -deprecated aliases. With plain `tzdata` alone: - -| Zone ID | `tzdata` | `tzdata-legacy` | -|---|---|---| -| `America/New_York` | present | — | -| `Europe/Kyiv` | present | — | -| `EST5EDT` | present | — | -| `US/Eastern` | **missing** | present | -| `Asia/Calcutta` | **missing** | present | - -So a shard configured with a legacy alias such as `US/Eastern` throws on a current Debian or Ubuntu -even though tzdata is installed. Either install `tzdata-legacy` or switch the configured value to the -canonical ID (`America/New_York`, `Asia/Kolkata`). - -`TZDIR` is honoured if the data lives somewhere non-standard. - -## How the check works - -`--check-prereqs` asks the loader directly — `NativeLibrary.TryLoad` on the unversioned name, then -`libfoo.so.N` descending through the accepted range. - -It deliberately does not consult a package database or `ldconfig -p`. Both answer a different -question than "will `dlopen` succeed": - -- Package queries need a hardcoded name, which does not exist for ICU. -- `ldconfig`'s cache can be stale, omits `LD_LIBRARY_PATH`, and carries no version information to - enforce the ICU floor against. On musl it exits successfully while producing nothing usable. diff --git a/dev-docs/runuo-migration-docs/02-serialization.md b/dev-docs/runuo-migration-docs/02-serialization.md index 666ebfdb4..1470792af 100644 --- a/dev-docs/runuo-migration-docs/02-serialization.md +++ b/dev-docs/runuo-migration-docs/02-serialization.md @@ -457,24 +457,16 @@ set ``` Without this, changes won't be saved. -Most RunUO custom setters only clamp the value or run side effects after assignment. Those -convert to a plain `[SerializableField]` with the `allowFieldChange`/`fieldChanged` hooks, -which handle the equality check and `MarkDirty()` for you -- reserve `[SerializableProperty]` -for custom getters (see `dev-docs/serialization.md`). - ### 3. Field Ordering The `[SerializableField(N)]` index determines serialization order. Choose a logical order and don't change it after the first save — or increment the version. ### 4. Conditional Serialization -Use `[SaveFlag]` on the serializable field to skip default values (the second method is -optional -- omit it and the field keeps its default at load): +Use `[SerializableFieldSaveFlag]` and `[SerializableFieldDefault]` to skip default values: ```csharp -[SerializableField(0)] -[SaveFlag(nameof(ShouldSerializeMaxItems), nameof(MaxItemsDefaultValue))] -private int _maxItems; - +[SerializableFieldSaveFlag(0)] private bool ShouldSerializeMaxItems() => _maxItems != -1; +[SerializableFieldDefault(0)] private int MaxItemsDefaultValue() => -1; ``` @@ -487,15 +479,12 @@ private List _followers; ``` ### 6. DateTime Fields -Use `[AnchoredDateTime]` to survive server restarts -- the value is shifted by downtime at -load, so the remaining time is preserved and idle saves stay byte-stable: +Use `[DeltaDateTime]` to survive server restarts: ```csharp -[AnchoredDateTime] +[DeltaDateTime] [SerializableField(0)] private DateTime _expireTime; ``` -(`[DeltaDateTime]` is the legacy equivalent; it rewrites bytes on every save. Converting an -existing field between the two changes the wire format and requires a version bump.) ### 7. Keeping Manual Serialization (Rare) Some edge cases still need manual serialization. If a type has complex conditional logic that can't be expressed with attributes, you can implement `ISerializable` manually. But this is rare — try attributes first. diff --git a/dev-docs/runuo-migration-docs/03-timers.md b/dev-docs/runuo-migration-docs/03-timers.md index 13596a079..29f3d1282 100644 --- a/dev-docs/runuo-migration-docs/03-timers.md +++ b/dev-docs/runuo-migration-docs/03-timers.md @@ -305,7 +305,7 @@ In RunUO, timers are commonly started in `Deserialize()`. In ModernUO, use `[Aft `_token.Cancel()` can be called on a default token, a stopped token, or an already-cancelled token. No null checks needed. ### 4. Timer.DelayCall Still Exists -`Timer.DelayCall()` is still available and returns a `Timer` object. Use it when you need the `Timer` reference (e.g., for a serialized timer field with `[DeserializeTimer]`) or state-carrying overloads. +`Timer.DelayCall()` is still available and returns a `Timer` object. Use it when you need the `Timer` reference (e.g., for `[DeserializeTimerField]`) or state-carrying overloads. ### 5. Custom Timer Classes Are Still Possible For complex timer logic (e.g., `Corpse.DecayTimer`), you can still subclass `Timer` with `OnTick()`. But prefer the fire-and-forget pattern for simple cases. diff --git a/dev-docs/runuo-migration-docs/09-items-mobiles-creatures.md b/dev-docs/runuo-migration-docs/09-items-mobiles-creatures.md index def2ecf48..c5029131c 100644 --- a/dev-docs/runuo-migration-docs/09-items-mobiles-creatures.md +++ b/dev-docs/runuo-migration-docs/09-items-mobiles-creatures.md @@ -474,88 +474,6 @@ The extra parameters (RangePerception, RangeFight, ActiveSpeed, PassiveSpeed) ha | `Name = "a creature"` in constructor | `public override string DefaultName => "a creature";` | | `get { return value; }` | `=> value;` expression-bodied | -## AI Movement: No `run` Argument - -RunUO's movement calls took a `run` flag that callers set inconsistently (`true` in -combat, `false` for pets, gated by `dist > 5` inside `MoveTo`). The flag only selects the -client's per-step animation time, so ModernUO derives it from the creature's step pace -(`BaseAI.ShouldRun`) and the parameter is gone: - -```csharp -// RunUO -MoveTo(combatant, true, m_Mobile.RangeFight); -WalkMobileRange(m_Mobile.ControlMaster, 1, false, 0, 1); - -// ModernUO -MoveTo(combatant, Mobile.RangeFight); -WalkMobileRange(Mobile.ControlMaster, 1, 0, 1); -``` - -`ApproachTarget`, `MoveToPoint` and `PathFollower.Follow` lose the argument the same way. -To make a creature run, make it fast (`SetMoveSpeed` / `npc-speeds.json`), not flagged. -An isolated step (after the creature stood for at least a walk interval) goes out as a -walk regardless of pace — only a continuing cadence, or a pace faster than the run -interpolation, flags run. - -## Target Acquisition: `AcquireOnApproach` Is a Delay - -RunUO's `AcquireOnApproach` bool (paragon insta-aggro on approach) is now -`AcquireOnApproachDelay`, a `TimeSpan` reaction-time gradient that applies to every -creature — enemy movement inside `AcquireOnApproachRange` schedules a scan within the -delay instead of waiting out the 10 s `ReacquireDelay` poll: - -```csharp -// RunUO -public override bool AcquireOnApproach => true; - -// ModernUO — Zero is the old instant behavior; larger values are dumber -public override TimeSpan AcquireOnApproachDelay => TimeSpan.Zero; -``` - -`AcquireOnApproachRange` stays 10 for all creatures (reactive aggro is on-screen; the -periodic `ReacquireDelay` scan still sweeps the full `RangePerception`). The -acquired target comes from the normal FightMode-ranked scan, not from whichever mobile -happened to move. See `content-patterns.md` § Target Acquisition. - -## Damage Entries: Inline `ValueLinkList`, Not `List` - -RunUO's `Mobile.DamageEntries` was a `List` allocated for every mobile. -ModernUO keeps damage entries in an inline `ValueLinkList` struct held by -the mobile itself, ordered least recent → most recent, so a mobile that never takes -damage owns no list object and `RegisterDamage` relinks in O(1). The property is -`ref readonly`; expired entries are pruned when it is read. - -```csharp -// RunUO -for (var i = m.DamageEntries.Count - 1; i >= 0; --i) -{ - var de = m.DamageEntries[i]; // indexer - ... -} -m.DamageEntries.Clear(); -var rights = BaseCreature.GetLootingRights(m.DamageEntries, m.HitsMax); // List - -// ModernUO — needs `using Server.Collections;` for the enumerator extensions -foreach (var de in m.DamageEntries.ByDescending()) // most recent first -{ - ... -} -foreach (var de in m.DamageEntries) // least recent first -{ - ... -} -m.ClearDamageEntries(); -var rights = BaseCreature.GetLootingRights(m.DamageEntries, m.HitsMax); // in ValueLinkList -``` - -What no longer compiles: the indexer, `.Add`, `.Remove`, `.RemoveAt`, `.Clear`, and -passing the property where a `List` is expected. `.Count`, -`FindDamageEntryFor`, `FindMostRecentDamager` and the other `Find*` methods, and -`RegisterDamage` are unchanged. `DamageEntry` now carries `Next`/`Previous`/`OnLinkList` -link fields; never set them yourself, and never call a `ValueLinkList` mutator on the -`ref readonly` property — it compiles against a copy and corrupts the node's link state. -Mutate only through `RegisterDamage` and `ClearDamageEntries`. - ## Item Name Changes ```csharp diff --git a/dev-docs/runuo-migration-docs/11-api-reference.md b/dev-docs/runuo-migration-docs/11-api-reference.md index b6c12bfe8..215dd3b06 100644 --- a/dev-docs/runuo-migration-docs/11-api-reference.md +++ b/dev-docs/runuo-migration-docs/11-api-reference.md @@ -130,13 +130,6 @@ Alphabetical by RunUO API name. Use Ctrl+F / Cmd+F to search. | `writer.WriteEncodedInt(value)` | `writer.WriteEncodedInt(value)` | Same | | `InvalidateProperties()` | `InvalidateProperties()` | Same, or use `[InvalidateProperties]` | | `this.MarkDirty()` | `this.MarkDirty()` | NEW — required in custom setters | -| `MoveTo(m, run, range)` | `MoveTo(m, range)` | `run` removed; the Running bit is derived from the step pace (`BaseAI.ShouldRun`) | -| `WalkMobileRange(m, steps, run, min, max)` | `WalkMobileRange(m, steps, min, max)` | Same | -| `PathFollower.Follow(run, range)` | `Follow(range)` | Same | -| `AcquireOnApproach` (bool) | `AcquireOnApproachDelay` (TimeSpan) | Reaction-time gradient; `Zero` = old instant behavior | -| `m.DamageEntries` (`List`) | `m.DamageEntries` (`ref readonly ValueLinkList`) | Inline, least→most recent; `foreach` / `.ByDescending()` only, needs `using Server.Collections;`; no indexer, `Add`, `Remove`, `Clear` | -| `m.DamageEntries.Clear()` | `m.ClearDamageEntries()` | | -| `GetLootingRights(List, int)` | `GetLootingRights(in ValueLinkList, int)` | Callers passing `m.DamageEntries` compile unchanged | ## Networking diff --git a/dev-docs/serialization.md b/dev-docs/serialization.md index 059fbff69..151d9d717 100644 --- a/dev-docs/serialization.md +++ b/dev-docs/serialization.md @@ -117,7 +117,7 @@ public partial class MyItem : Item { } See `dev-docs/runuo-migration-docs/02-serialization.md` for complete migration guidance. -### [SerializableField(index, getter, setter, isVirtual, fieldChanged, allowFieldChange)] +### [SerializableField(index, setter, saveIf)] **Target**: Private field (`_camelCase`) **Generates**: Public `PascalCase` property with get/set @@ -125,11 +125,8 @@ See `dev-docs/runuo-migration-docs/02-serialization.md` for complete migration g | Parameter | Type | Default | Description | |---|---|---|---| | `index` | `int` | Required | Serialization order (0-based) | -| `getter` | `string` | `"public"` | Getter accessibility | -| `setter` | `string` | `"public"` | `"private"` or `"internal"` to restrict setter | -| `isVirtual` | `bool` | `false` | Generate a `virtual` property | -| `fieldChanged` | `string` | `null` | `nameof` of a `void Method(T oldValue, T newValue)` invoked by the generated setter after assignment | -| `allowFieldChange` | `string` | `null` | `nameof` of a `bool Method(ref T value)` invoked before assignment; coerce the value through the `ref` parameter, or return `false` to reject the change | +| `setter` | `string` | `null` (public) | `"private"` or `"internal"` to restrict setter | +| `saveIf` | `string` | `null` | Method name returning bool for conditional save | ```csharp [SerializableField(0)] // Public property @@ -147,57 +144,14 @@ The generated property for `_charges` would be: public int Charges { get => _charges; - set - { - if (value != _charges) - { - _charges = value; - this.MarkDirty(); - } - } + set { _charges = value; this.MarkDirty(); } } ``` -**Setter hooks** replace most hand-written `[SerializableProperty]` setters. The generated -pipeline is: equality check → `allowFieldChange` (coerce/veto) → assignment → `MarkDirty` → -`InvalidateProperties` (if declared) → `fieldChanged`. The gate runs before assignment, so -the field itself still holds the old value inside it. - -```csharp -[SerializableField(0, allowFieldChange: nameof(AllowChargesChange))] -[SerializedCommandProperty(AccessLevel.GameMaster)] -[InvalidateProperties] -private int _charges; - -private bool AllowChargesChange(ref int value) -{ - value = Math.Clamp(value, 0, MaxCharges); // coerce, or return false to veto - return true; -} - -[SerializableField(1, fieldChanged: nameof(OnOwnerChanged))] -private Mobile _owner; - -// oldValue makes unsubscribe/resubscribe patterns trivial -private void OnOwnerChanged(Mobile oldValue, Mobile newValue) -{ - oldValue?.Followers.Remove(this); - newValue?.Followers.Add(this); -} -``` - -Both hooks require a generated setter — declaring one on a `readonly` field or with -`setter: null` is a compile-time error (SG3018), and a named method that is missing or has -the wrong signature is too (SG3015). - ### [SerializableProperty(index, useField)] **Target**: Property with custom get/set logic -**Use when**: You need a **custom getter** (fallback defaults, lazy or self-healing reads) -or setter semantics the field hooks cannot express (work that must run on *equal* -assignment, pre-assignment state capture). For setters that only coerce, veto, or run -post-change side effects, prefer `[SerializableField]` with `allowFieldChange`/`fieldChanged` -instead — the generated setter handles equality, `MarkDirty`, and ordering for you. +**Use when**: You need non-trivial getter/setter logic | Parameter | Type | Default | Description | |---|---|---|---| @@ -209,7 +163,7 @@ instead — the generated setter handles equality, `MarkDirty`, and ordering for [CommandProperty(AccessLevel.GameMaster)] public int MaxItems { - get => _maxItems == -1 ? DefaultMaxItems : _maxItems; // custom getter: the reason this is a property + get => _maxItems == -1 ? DefaultMaxItems : _maxItems; set { _maxItems = value; @@ -219,10 +173,6 @@ public int MaxItems } ``` -Note: the `fieldChanged`/`allowFieldChange` hooks are `[SerializableField]` arguments and -cannot be declared on a `[SerializableProperty]` — its setter is your own code, so call your -methods from the setter directly. - ### [InvalidateProperties] **Target**: `[SerializableField]`-decorated field @@ -258,32 +208,12 @@ Overloads: Best for fields that are usually small values (counts, IDs, indexes). -### [AnchoredDateTime] - -**Target**: `DateTime` field -**Effect**: Stores the absolute UTC instant; at load it is shifted forward by the downtime -between the save and the load (using the save-start anchor in the save's index file), so -server downtime does not consume the remaining time. `DateTime.MinValue`/`MaxValue` -sentinels pass through unshifted. - -Prefer this for deadlines and "elapsed while running" values. Unlike `[DeltaDateTime]`, the -stored bytes do not change on every save when the value is unchanged, keeping idle saves -byte-stable. - -```csharp -[AnchoredDateTime] -[SerializableField(0)] -private DateTime _expireTime; -``` - ### [DeltaDateTime] **Target**: `DateTime` field **Effect**: Stores as offset from current time rather than absolute timestamp. -Legacy encoding for surviving restarts: it rewrites the bytes on every save even when the -value has not changed. Prefer `[AnchoredDateTime]` for new fields; converting an existing -field between the two changes the wire format and requires a version bump. +This ensures timers and expiration dates survive server restarts correctly. ```csharp [DeltaDateTime] @@ -359,76 +289,40 @@ private void AfterDeserialization() } ``` -### [DeserializeTimer(nameof(Method), wallClock)] +### [DeserializeTimerField(fieldIndex)] -**Target**: `Timer`-typed `[SerializableField]` or `[SerializableProperty]` member -**Effect**: Declares how the timer is stored and restored. Required on every serializable -timer (SG3008 otherwise). - -By default the timer's next tick is stored as **anchored time**: server downtime does not -consume the remaining delay, and idle saves are byte-stable. Pass `wallClock: true` to store -an absolute deadline instead (the delay is then negative when the deadline passed during -downtime). - -The named method — `void Method(TimeSpan delay)` — is invoked **only when a timer was -actually running at save**, with the remaining delay. There is no sentinel value to check. +**Target**: Method taking `TimeSpan` parameter +**Effect**: Custom deserialization for Timer fields. The timer is saved as remaining delay. ```csharp [SerializableField(0, setter: "private")] -[DeserializeTimer(nameof(DeserializeDecayTimer))] private Timer _decayTimer; -private void DeserializeDecayTimer(TimeSpan delay) => _decayTimer = Timer.DelayCall(delay, Delete); -``` - -Switching an existing timer between drifting and `wallClock` changes the wire format — bump -the class version and add a `MigrateFrom`. The old-version content struct exposes the -timer's `XxxNext` (`DateTime`) and `XxxDelay` (`TimeSpan`, `TimeSpan.MinValue` when no timer -was running): - -```csharp -private void MigrateFrom(V3Content content) +[DeserializeTimerField(0)] +private void DeserializeDecayTimer(TimeSpan delay) { - if (content.DecayTimerDelay != TimeSpan.MinValue) - { - DeserializeDecayTimer(content.DecayTimerDelay); - } + _decayTimer = Timer.DelayCall(delay, Delete); + _decayTimer.Start(); } ``` -### [SaveFlag(nameof(ShouldSerializeMethod), nameof(DefaultValueMethod))] +### [SerializableFieldSaveFlag(fieldIndex)] / [SerializableFieldDefault(fieldIndex)] -**Target**: the serializable field or property itself **Conditional serialization** -- skip fields that have their default value. -The first method (`bool Method()`) decides whether the value is written. The optional second -method (returning the field's type, no parameters) supplies the value at load when it was -not written; when omitted, the field keeps its default value. - -```csharp -[SerializableField(0)] -[SaveFlag(nameof(ShouldSerializeCharges), nameof(ChargesDefaultValue))] -private int _charges; - -private bool ShouldSerializeCharges() => _charges != -1; - -private int ChargesDefaultValue() => -1; -``` - -Works on `[SerializableProperty]` members the same way: - ```csharp [EncodedInt] [SerializableProperty(0)] -[SaveFlag(nameof(ShouldSerializeMaxItems), nameof(MaxItemsDefaultValue))] public int MaxItems { get => _maxItems == -1 ? DefaultMaxItems : _maxItems; set { _maxItems = value; this.MarkDirty(); } } +[SerializableFieldSaveFlag(0)] private bool ShouldSerializeMaxItems() => _maxItems != -1; +[SerializableFieldDefault(0)] private int MaxItemsDefaultValue() => -1; ``` diff --git a/dev-docs/server-requirements.md b/dev-docs/server-requirements.md deleted file mode 100644 index 0b2c50569..000000000 --- a/dev-docs/server-requirements.md +++ /dev/null @@ -1,121 +0,0 @@ -# Server Requirements - -Hardware guidance for running a ModernUO shard. - -## Tiers - -| Use | vCPU | RAM | Storage | -|---|---|---|---| -| Development / test | 2 **dedicated** | 2 GB | SSD | -| Small live shard (< 50 concurrent) | 4 dedicated | 4 GB | NVMe | -| Medium (50–200) | 4–8 | 8 GB | NVMe | -| Large (200+) | 8+, high clock | 16 GB+ | NVMe | - -These are starting points. Save size drives RAM more than player count does, and single-thread -clock speed drives tick latency more than core count does. Both are explained below. - -## Dedicated vCPU, not burstable - -This matters more than any other line on this page. - -Budget VPS plans sold as "2 vCPU" are frequently shared or burstable: you get a CPU credit balance -or a cgroup quota, and once it is exhausted the hypervisor throttles you. Throttling shows up in -game as periodic freezes that correlate with nothing in your logs, and it is the single most common -cause of "ModernUO is laggy on my $3/month VPS". - -Symptoms worth checking before blaming the server: - -- Steal time above ~1% (`top`, the `%st` column on Linux) -- Lag that disappears when you move to a larger plan with the same core count -- Tick lag spikes with no matching CPU spike in the process itself - -## Cores - -Game logic is **single-threaded**. Every mobile, item, timer, and packet handler runs on one -thread, so a shard's headroom is bounded by how fast one core is. Two fast cores beat four slow -ones. - -Cores beyond the first are used by: - -- **World saves.** `world.useMultithreadedSaves` (default on) spins up `ProcessorCount - 1` - serialization workers plus one inline on the main thread. On a 2-core box that is one worker; on - a 2-core box with a large world, consider setting it to `false` so saves do not contend with the - loop. -- **The .NET runtime.** Tiered JIT compilation (heaviest in the first minutes after boot) and - background GC. -- **Everything else on the machine**, including your OS and, on Windows, antivirus. - -Since ModernUO 2026 the loop sleeps when idle, so an empty shard costs roughly 1% of a core rather -than spinning. That change disproportionately helps small hosts. - -## Memory - -Three things dominate, and only one of them scales with players. - -**World size.** A world of ~190,000 items and ~33,000 mobiles loads in about a second and is not -itself large. Items and mobiles are the cheap part. - -**Saves.** Each serialization worker pre-allocates a heap sized to its share of the last save, at -roughly 1.25× total save size, and those buffers are retained afterwards. A 400 MB save therefore -implies about 500 MB of resident serialization heap on top of the live world. **This is the reason -1 GB hosts are not viable for a real shard**, even though an empty one boots fine. - -**Map residency.** `TileMatrix` reads map blocks from disk on demand and caches them permanently — -there is no eviction. Memory climbs toward full-facet residency as players explore. Felucca's land -tiles alone are around 117 MB, and statics are larger. - -Optional systems can add substantially more. The pathfinding prebake -(`pathfinding.prebakeMaps`) peaks above 1 GB of heap while baking. Budget for it or leave it off on -small hosts. - -Network buffers are minor by comparison: 64 KB receive plus a configurable 256 KB send -(`network.sendBufferSize`) per connection, so 100 players is roughly 32 MB. - -ModernUO runs **Workstation GC**, which is the right default for small hosts. Do not switch to -Server GC on a 2-core box. - -## Storage - -Saves are write-heavy bursts. Cheap network-attached storage with throttled IOPS will stall the -save path, and `World.WaitForWriteCompletion` blocks the loop at shutdown. Use local NVMe or SSD. - -Budget disk for: the world save, plus archives and backups if `autoArchive` is enabled (retention -defaults keep 24 hourly, 30 daily, and 12 monthly copies), plus the pathfinding cache if enabled. - -## Operating systems - -See the README for the full supported list. Two things are worth calling out: - -- **Windows Server 2012 R2 and 2016 sleep via a raised timer resolution.** Sleeping for a couple - of milliseconds prefers a high-resolution waitable timer, which requires Windows 10 1803 / - Server 2019. On older versions the ring falls back to `timeBeginPeriod(1)`, which raises the - system timer resolution to 1 ms so the plain wait timeout is accurate enough. The trade-off is a - higher interrupt rate (system-wide on those versions) — an acceptable price on a dedicated game - server, and the reason the high-resolution timer is preferred where it exists. - - Only if *both* mechanisms fail does the server detect it at startup, log it, and spin instead — - the same behaviour as setting `server.eventLoopIdleWaitMs` to 0: a full core at idle, and zero - missed deadlines. A host that claims short waits but cannot deliver them is caught at runtime by - the adaptive backoff. -- **Linux kernel 6.1** or newer (Debian 12 and equivalents). io_uring is used where available, with - automatic epoll fallback. - -## Tuning for a small host - -| Setting | Default | Why change it | -|---|---|---| -| `server.eventLoopIdleWaitMs` | `2` | `0` never sleeps: ~98% of one core, but zero skipped timer slots and zero lag. The choice for a large shard on dedicated CPU that would rather spend a core than risk a late wake. Above `2` the wheel starts losing slots. | -| `server.lateWakeThreshold` | `1` | Floor for the backoff: idle waits the host may return a full tick late, per second, before the rate test below applies at all. Raise on a jittery host; set very high to disable the backoff. | -| `server.lateWakePercent` | `10` | Share of a second's idle waits that must come back late before idle sleeping backs off. An idle loop sleeps hundreds of times a second, so a bare count cannot tell a few tail outliers from a host that never schedules the process — a genuinely bad host misses *most* of its waits. `0` leaves `lateWakeThreshold` in sole charge. | -| `world.useMultithreadedSaves` | `true` | Set `false` on 2-core hosts so saves do not contend with the game loop. | -| `pathfinding.prebakeMaps` | varies | Leave off on memory-constrained hosts; it peaks above 1 GB while baking. | -| `network.sendBufferSize` | 256 KB | Lower it if you are memory-bound with many connections. | -| `autoArchive.*` retention | 24h/30d/12m | Reduce if disk is tight. | - -## Am I undersized? - -Watch the log. The server warns when the host returns idle waits late and suspends idle sleeping, -and says so at startup if the host cannot honour short waits at all. Those warnings mean the host -is not scheduling the process promptly — typical of burstable or shared vCPU plans — and no -server-side change fixes that. For anything deeper, see -[debugging-event-loop.md](debugging-event-loop.md). diff --git a/dev-docs/threading-model.md b/dev-docs/threading-model.md index d89bc1c61..09b0ec8f4 100644 --- a/dev-docs/threading-model.md +++ b/dev-docs/threading-model.md @@ -130,138 +130,6 @@ These files in `Projects/Server/` MAY use threading because they handle I/O outs - `Timer/Timer.Pool.cs` -- Async pool refill - `EventLoopTasks.cs` -- The synchronization context itself -### Exceptions: Vetted Workers in `Projects/UOContent/` - -**Take great care here. A background thread is a last resort, not a tool of first choice.** - -The table above is about **game logic**, which is never threaded. A dedicated worker that touches -no game state is the sanctioned way to move CPU-heavy or I/O work off the loop, and it necessarily -uses primitives the table forbids -- `new Thread`, `ConcurrentQueue`, `Interlocked`, -`AutoResetEvent`, `volatile`. Those are legitimate **at the thread boundary**, and nowhere else. - -#### First: prove the need - -Do not add a worker because something "looks slow". Measure, and measure the right thing: - -- **Measure on-loop time, not wall-clock.** How long a player waits does not matter; how long the - world is frozen does. A change that improves latency but not loop time buys nothing. -- **Off-loading does not create CPU.** It converts "the loop is blocked for N ms" into "the loop - competes for cores for N ms". On a 1--2 core host there is no spare core and it buys nothing at - all -- gate on `Environment.ProcessorCount`. -- **Account for what stays behind.** Dispatch, the continuation, and the loop's own work slowing - down while the worker evicts shared L3. That last one is real and is usually the largest. -- **Write the benchmark down.** A worker with no recorded measurement cannot be re-justified later, - and will be removed by someone who cannot tell whether it earns its complexity. - -#### Game logic stays on the loop -- chunk it instead - -Work that **needs** game state cannot be threaded at any core count. If it is too slow for one -tick, split it across ticks rather than across threads: - -```csharp -// Bound the work per tick, resume where it left off. -Timer.DelayCall(TimeSpan.Zero, TimeSpan.FromMilliseconds(50), () => -{ - var budget = 0; - while (_cursor < _items.Count && budget++ < 100) - { - Process(_items[_cursor++]); - } -}); -``` - -Bound by count or elapsed time, never by "until done". Threading game state is not a faster -version of this -- it is a correctness bug. - -#### Vetted workers - -| Worker | Off-loop work | Justification | -|---|---|---| -| `Accounting/Security/PasswordWorker.cs` | Password verification and hashing | `docs/handoffs/2026-08-07-off-loop-argon2-hashing.md` -- 8.9 ms/login on-loop at Argon2, measured 3.5--8.9 ms saved | -| `Engines/Advanced Search/AdvancedSearchGump.cs` | Parallel entity search | Admin-triggered full-world scan; saves disabled for its duration | - -Adding to this table needs the same bar: a measurement, and all five rules below. - -#### The six rules - -1. **No game state off-thread, read or written.** Hand the worker immutable values (strings, - structs) captured on the loop. Carrying a reference is fine only if the worker just passes it - back untouched. -2. **Decide policy on the loop, compute on the worker.** Anything rule-dependent -- which algorithm, - which salt, which era branch -- is resolved at dispatch, so the worker holds no policy it could - apply inconsistently. -3. **Park on a kernel wait; never spin.** `AutoResetEvent.WaitOne()` costs nothing while idle. - `SerializationThreadWorker` does spin, but only to await a producer mid-drain; absent that race, - spinning is a bug that burns a core on shared hosts. -4. **Yield to world saves.** Run only while `WorldState is Running or WritingSave`. `World.Saving` - is *not* the right check -- it covers only the freeze and misses `PendingSave`, where the - serialization threads are already awake and spinning on an empty queue. -5. **Bound the queue**, or rely on a bound upstream and say which one in a comment. -6. **Everything the worker calls must itself be safe off-thread.** A process-wide singleton is not - automatically safe -- look for instance state. `HashAlgorithm.ComputeHash` carries the running - digest across `HashCore`/`HashFinal`, so two threads sharing one corrupt each other. `Utility`'s - RNG is a shared `System.Random`, which is both thread-unsafe and game state. Prefer the static - one-shot forms (`SHA256.HashData`, `RandomNumberGenerator.Fill`), and if a dependency cannot be - made safe, fix it at the source rather than narrowing the worker around it. - -#### Handing work across the boundary - -**Loop → worker (dispatch).** Snapshot everything needed into immutable values. Capture any value -you intend to overwrite later, so the continuation can tell whether it changed: - -```csharp -var job = new Job -{ - Target = state, // carried, never dereferenced off-thread - Expected = account.Password, // captured so the continuation can detect a change - Input = DerivePhrase(...) // policy resolved here, on the loop -}; - -if (!Worker.TryEnqueue(job)) -{ - // Full. Reject -- do not fall back to running it inline, or a flood steers the work - // straight back onto the loop. -} -``` - -**Worker → loop (hand back).** Two sanctioned routes, and no others: - -```csharp -// 1. Marshal the apply step. Preferred when a specific result belongs to a specific caller. -Core.LoopContext.Post(() => Apply(job, result)); - -// 2. Publish an immutable snapshot behind a single volatile reference, read lock-free by the loop. -// Preferred for a shared lookup table rebuilt periodically. -Volatile.Write(ref _snapshot, newTable); -``` - -**The continuation must re-validate.** Time passed, and the loop kept running: - -```csharp -private static void Apply(Job job, Result result) -{ - // Gone? Never revive a dead NetState or a deleted entity. - if (job.Target?.Running != true) - { - return; - } - - // Changed? Do not overwrite a newer value with one derived from an older one. - if (!string.Equals(account.Password, job.Expected, StringComparison.Ordinal)) - { - return; - } - - account.Apply(result); -} -``` - -**Always post a result, including on failure.** A worker that throws and posts nothing leaves -whatever awaited it waiting forever. Catch, log, and post a failure verdict. - -**Never** call into game state from the worker, and never `await` on the loop in a way that lets a -continuation resume heavy work there -- `ConfigureAwait(false)` on every await inside off-loop work. - ## Memory Pooling ### STArrayPool diff --git a/dev-docs/tick-counts.md b/dev-docs/tick-counts.md deleted file mode 100644 index f53e223d4..000000000 --- a/dev-docs/tick-counts.md +++ /dev/null @@ -1,55 +0,0 @@ -# Tick Counts: Overflow and Huge Starting Values - -Rules for any code that compares `Core.TickCount` / `Core.GetTimestamp()` values. Getting this -wrong produces bugs that only appear on specific cloud hosts after long host uptimes — the worst -kind to reproduce. - -## Why this matters (the Linux/cloud problem) - -`Core.GetTimestamp()` is built on `Stopwatch.GetTimestamp()`, which on Linux reads the kernel's -monotonic clock — and on some hypervisors, notably **Google Cloud**, the VM receives a -**pass-through of the host's never-resetting counter**. The tick count is *not* zero when the -process starts and *not* zero when the operating system booted; it is however long the physical -host has been up, which can be months or years. We have been burned by this in production. - -Consequences: - -- Raw values are enormous from the first read. Arithmetic that would "never overflow in 292 - years" of process uptime can overflow immediately (`Core.GetTimestamp()`'s `UInt128` - conversion path exists precisely because `raw * 1000` does not fit in 64 bits for large raws). -- Wrapped values can be **negative**. Nothing may assume a tick count is positive. -- **Windows is not affected** in our testing so far, which is exactly why this class of bug - ships: it works on every dev machine and fails on a customer's GCP instance. - -## The rules - -1. **Compare by subtraction, never directly.** Subtraction of two ticks wraps correctly in two's - complement; direct comparison does not. - - ```csharp - // WRONG: fails when ticks wrap or start huge - if (Core.TickCount < deadline) - - // RIGHT: wraparound-safe - if (Core.TickCount - deadline < 0) - ``` - -2. **Durations are always subtractions of two readings** (`elapsed = end - start`). Never derive - a duration from a single absolute value. - -3. **No zero or sign sentinels.** `if (_lastEventAt > 0)` as "has this happened yet" breaks when - ticks are negative. Track "has happened" with a separate `bool` or an existing counter. - -4. **Seed deadline fields from a real tick, not from field initialization.** A `long _deadline;` - left at 0 compares wrong against a huge or negative tick. Initialize relative to the first - observed timestamp (see the schedule-state seeding in `Core.Setup`). - -5. **Store deadlines as `start + interval` only if every comparison follows rule 1.** The - addition may wrap; the subtraction comparison handles it. - -## Reviewing for it - -Grep the diff for `TickCount <`, `TickCount >`, `GetTimestamp() <`, and comparisons against any -field whose name suggests a deadline (`*Until`, `*At`, `*Next*`). Each hit must be in subtraction -form. `DateTime`/`DateTimeOffset` comparisons are unaffected; this applies only to the monotonic -tick domain. diff --git a/dev-docs/timers.md b/dev-docs/timers.md index 2c051cca6..c10f31bd6 100644 --- a/dev-docs/timers.md +++ b/dev-docs/timers.md @@ -188,19 +188,15 @@ public partial class TimedItem : Item ``` ### Pattern 4: Serializable Timer Field -Every serializable `Timer` member declares `[DeserializeTimer(nameof(Method))]` on the -field. By default the next tick is stored as anchored time (downtime does not consume the -remaining delay); pass `wallClock: true` for absolute deadlines. The method runs **only when -a timer was running at save**, with the remaining delay. - ```csharp [SerializableField(0, setter: "private")] -[DeserializeTimer(nameof(DeserializeDecayTimer))] private Timer _decayTimer; +[DeserializeTimerField(0)] private void DeserializeDecayTimer(TimeSpan delay) { _decayTimer = Timer.DelayCall(delay, Delete); + _decayTimer.Start(); } public void BeginDecay(TimeSpan delay)