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..1b3fb032b 100644 --- a/.github/workflows/build-test.yml +++ b/.github/workflows/build-test.yml @@ -40,13 +40,13 @@ jobs: with: fetch-depth: 0 # avoid shallow clone so nbgv can do its work. - name: Install .NET - uses: actions/setup-dotnet@v6 + uses: actions/setup-dotnet@v5 with: global-json-file: global.json - 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 @@ -64,7 +64,7 @@ jobs: fi - name: Upload test results on failure if: failure() - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@v4 with: name: TestResults-${{ matrix.name }} path: ./TestResults @@ -124,41 +124,17 @@ 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. - name: Install .NET - uses: actions/setup-dotnet@v6 + uses: actions/setup-dotnet@v5 with: global-json-file: global.json - name: Build @@ -174,7 +150,7 @@ jobs: fi - name: Upload test results on failure if: failure() - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@v4 with: name: TestResults-${{ matrix.name }} path: ./TestResults diff --git a/.github/workflows/build-tool-release.yml b/.github/workflows/build-tool-release.yml index 696a825dc..6cf70e799 100644 --- a/.github/workflows/build-tool-release.yml +++ b/.github/workflows/build-tool-release.yml @@ -37,7 +37,7 @@ jobs: fetch-depth: 0 # Full clone required for Nerdbank.GitVersioning - name: Install .NET - uses: actions/setup-dotnet@v6 + uses: actions/setup-dotnet@v5 with: global-json-file: global.json diff --git a/.github/workflows/create-release.yml b/.github/workflows/create-release.yml index 3c0d182a3..270b9c1c6 100644 --- a/.github/workflows/create-release.yml +++ b/.github/workflows/create-release.yml @@ -15,7 +15,7 @@ jobs: fetch-depth: 0 # avoid shallow clone so nbgv can do its work. token: ${{ secrets.PERSONAL_ACCESS_TOKEN }} - name: Install .NET - uses: actions/setup-dotnet@v6 + uses: actions/setup-dotnet@v5 with: global-json-file: global.json - name: Compute version diff --git a/.gitignore b/.gitignore index d29ee313a..7bb942e52 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,4 @@ # Distribution Files -/Distribution/Data/Files /Distribution/Logger /Distribution/Logger.* /Distribution/ModernUO @@ -10,30 +9,16 @@ /Distribution/bsdtar /Distribution/Configuration/antimacro.json /Distribution/Configuration/assistants.json -/Distribution/Configuration/auto-denylist.json -/Distribution/Configuration/bans.json -/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 -/Distribution/Configuration/ip-blocklist.txt.tmp -/Distribution/Configuration/login-allowlist.json -/Distribution/Configuration/login-allowlist.txt -/Distribution/Configuration/login-allowlist.txt.tmp /Distribution/Configuration/modernuo.json /Distribution/Configuration/email-settings.json /Distribution/Configuration/throttles.json /Distribution/Configuration/tot.json -/Distribution/Data/Pathfinding /Distribution/Logs /Distribution/Archives /Distribution/Backups /Distribution/Saves /Distribution/docs -/docs/ /Distribution/temp /Distribution/*.dylib /Distribution/*.so @@ -61,7 +46,5 @@ /packages/* /Distribution/Configuration/server-access.json -# BuildTool native binaries (downloaded from GitHub Releases). -# Ignore everything under tools/ except the operator scripts checked in below. -/tools/* -!/tools/Export-IpBlocklist.ps1 +# BuildTool native binaries (downloaded from GitHub Releases) +/tools/ diff --git a/CLAUDE.md b/CLAUDE.md index c77d39e19..5335c036d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -12,14 +12,14 @@ Apply these when writing or reviewing `.cs` files under `Projects/`. 1. **LINQ** — Tier 1 (zero-cost patterns) free on hot paths; Tier 2 (low overhead) OK on warm paths; Tier 3 (allocating) forbidden on hot paths → `dev-docs/code-standards.md` 2. **No `Console.WriteLine`** — use `LogFactory.GetLogger(typeof(MyClass))` → `logger.Information(...)` (requires `using Server.Logging;`) -3. **Threading policy** — game logic runs only on the main loop; **never** touch game state (`World`, mobiles, items, maps, timers) from a background thread. Heavy work that *needs* game state must be **chunked** across ticks, not threaded. Heavy work that does *not* need game state (large-file parse, external I/O) **must** run on a background thread **and must yield to world saves** (defer while `World.Saving`/`WorldState.PendingSave`). Publish results back to the loop as an immutable snapshot swapped via a single `volatile` reference — the only sanctioned `volatile`. No `lock`/`Mutex`/`ConcurrentDictionary` in game logic. Rule #10 covers how background work hands results back to the loop → `dev-docs/threading-model.md` +3. **No concurrency primitives** — no `lock`, `volatile`, `ConcurrentDictionary`, `Mutex`, etc. Server is single-threaded. 4. **No `World.Mobiles`/`World.Items` iteration** — use spatial queries: `map.GetMobilesInRange()`, `map.GetItemsInRange()` 5. **Clean up refs in `OnDelete()`/`OnAfterDelete()`** — null out `Item`/`Mobile` references 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()`** in game code — game logic is single-threaded event loop 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` @@ -28,8 +28,6 @@ Apply these when writing or reviewing `.cs` files under `Projects/`. 16. **Prefer switch expressions and switch-when** — use switch expressions for value mapping and switch-when for pattern matching where they improve readability. Exception: skip if unreadable or cold path → `dev-docs/code-standards.md` 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,14 +44,9 @@ 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` | | Region system | `dev-docs/regions.md` | | String handling & ValueStringBuilder | `dev-docs/string-handling.md` | | RunUO migration (overview) | `dev-docs/runuo-migration-docs/00-overview.md` | @@ -99,18 +92,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/Fixtures/TestServerInitializer.cs b/Projects/Server.Tests/Fixtures/TestServerInitializer.cs index 845efb115..e84c16509 100644 --- a/Projects/Server.Tests/Fixtures/TestServerInitializer.cs +++ b/Projects/Server.Tests/Fixtures/TestServerInitializer.cs @@ -1,4 +1,3 @@ -using System; using System.IO; using System.Reflection; using System.Threading; @@ -79,15 +78,6 @@ internal static class TestServerInitializer Core.LoopContext = new EventLoopContext(); Core.Expansion = Expansion.EJ; - // Seed the loop clock as Main.cs does before the Configure sweep; otherwise Core.Now is - // DateTime.MinValue for the whole test host. - Core._now = DateTime.UtcNow; - - // Timer wheel must exist before NetState.Configure(), which schedules a recurring - // sweep via Timer.DelayCall (matches production ordering in Main.cs: Timer.Init runs - // before AssemblyHandler.Invoke("Configure")). - Timer.Init(0); - // Configure networking (initializes RingSocketManager for tests) Server.Network.NetState.Configure(); @@ -97,6 +87,8 @@ internal static class TestServerInitializer // Configure the world World.Configure(); + Timer.Init(0); + // Load the world World.Load(); 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/Buffers/RawInterpolatedStringHandlerTests.cs b/Projects/Server.Tests/Tests/Buffers/RawInterpolatedStringHandlerTests.cs index d4777b758..0b76d3f94 100644 --- a/Projects/Server.Tests/Tests/Buffers/RawInterpolatedStringHandlerTests.cs +++ b/Projects/Server.Tests/Tests/Buffers/RawInterpolatedStringHandlerTests.cs @@ -4,7 +4,6 @@ using Xunit; namespace Server.Tests.Buffers; -[Collection("Sequential Server Tests")] public class RawInterpolatedStringHandlerTests { [Fact] 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/Bans/BanChannelTests.cs b/Projects/Server.Tests/Tests/Network/Bans/BanChannelTests.cs deleted file mode 100644 index 5eb1895d8..000000000 --- a/Projects/Server.Tests/Tests/Network/Bans/BanChannelTests.cs +++ /dev/null @@ -1,96 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Net; -using System.Threading; -using Server.Network.Bans; -using Xunit; - -namespace Server.Tests.Network.Bans; - -public class BanChannelTests -{ - private sealed class FakeReporter : IBanReporter - { - public readonly List<(IPAddress ip, TimeSpan ttl, string reason)> Reports = []; - public readonly List Retractions = []; - public bool ThrowOnReport; - - public string Name => "fake"; - public bool CanRetract => true; - public void Register() { } - public void Start(CancellationToken token) { } - public void Stop() { } - - public void Report(IPAddress address, TimeSpan ttl, string reason) - { - if (ThrowOnReport) - { - throw new InvalidOperationException("boom"); - } - - Reports.Add((address, ttl, reason)); - } - - public void Retract(IPAddress address) => Retractions.Add(address); - } - - [Fact] - public void Report_FansOutToAllReporters() - { - var a = new FakeReporter(); - var b = new FakeReporter(); - BanChannel.ConfigureForTesting([a, b]); - - BanChannel.Report(IPAddress.Parse("1.2.3.4"), TimeSpan.FromHours(1), "rate-limit"); - - Assert.Single(a.Reports); - Assert.Single(b.Reports); - Assert.Equal("rate-limit", a.Reports[0].reason); - } - - [Fact] - public void Report_SwallowsReporterException() - { - var bad = new FakeReporter { ThrowOnReport = true }; - var good = new FakeReporter(); - BanChannel.ConfigureForTesting([bad, good]); - - BanChannel.Report(IPAddress.Parse("1.2.3.4"), TimeSpan.FromHours(1), "manual"); - - Assert.Single(good.Reports); // the throwing reporter does not block the others - } - - [Fact] - public void Report_SuppressedForExemptAddress() - { - var reporter = new FakeReporter(); - BanChannel.ConfigureForTesting([reporter]); - - var exempt = IPAddress.Parse("203.0.113.7"); - BanChannel.IsExempt = (ip, _) => ip.Equals(exempt); - - try - { - BanChannel.Report(exempt, TimeSpan.FromHours(1), "rate-limit"); - Assert.Empty(reporter.Reports); // escalation suppressed; the local gate already acted - - BanChannel.Report(IPAddress.Parse("203.0.113.8"), TimeSpan.FromHours(1), "rate-limit"); - Assert.Single(reporter.Reports); // everyone else is still contributed - } - finally - { - BanChannel.IsExempt = null; - } - } - - [Fact] - public void Retract_ReachesRetractCapableReporters() - { - var a = new FakeReporter(); - BanChannel.ConfigureForTesting([a]); - - BanChannel.Retract(IPAddress.Parse("9.9.9.9")); - - Assert.Single(a.Retractions); - } -} diff --git a/Projects/Server.Tests/Tests/Network/Bans/BanConfigurationTests.cs b/Projects/Server.Tests/Tests/Network/Bans/BanConfigurationTests.cs deleted file mode 100644 index ee232b1df..000000000 --- a/Projects/Server.Tests/Tests/Network/Bans/BanConfigurationTests.cs +++ /dev/null @@ -1,44 +0,0 @@ -using System; -using System.Text.Json; -using Server.Json; -using Server.Network.Bans; -using Xunit; - -namespace Server.Tests; - -public class BanConfigurationTests -{ - // Locks the JsonConfig casing/converter contract: JsonConfig's options are case-SENSITIVE, so - // every settings member must carry an explicit [JsonPropertyName("camelCase")] or it silently - // binds nothing. These tests round-trip through the exact options the loader uses. - - [Fact] - public void BanSettings_RoundTripsThroughJsonConfig() - { - var original = new BanSettings - { - ReportRateLimitTrips = false, - AutoBanDuration = TimeSpan.FromHours(2) - }; - - var json = JsonConfig.Serialize(original); - - Assert.Contains("\"reportRateLimitTrips\"", json); - Assert.Contains("\"autoBanDuration\"", json); - - var restored = JsonSerializer.Deserialize(json, JsonConfig.DefaultOptions); - - Assert.NotNull(restored); - Assert.Equal(original.ReportRateLimitTrips, restored.ReportRateLimitTrips); - Assert.Equal(original.AutoBanDuration, restored.AutoBanDuration); // TimeSpan survives - } - - [Fact] - public void BanSettings_Defaults_AreReportRateLimitTripsFourHourAutoBan() - { - var settings = new BanSettings(); - - Assert.True(settings.ReportRateLimitTrips); - Assert.Equal(TimeSpan.FromHours(4), settings.AutoBanDuration); - } -} diff --git a/Projects/Server.Tests/Tests/Network/ConnectionFiltersTests.cs b/Projects/Server.Tests/Tests/Network/ConnectionFiltersTests.cs deleted file mode 100644 index b7e134794..000000000 --- a/Projects/Server.Tests/Tests/Network/ConnectionFiltersTests.cs +++ /dev/null @@ -1,133 +0,0 @@ -/************************************************************************* - * ModernUO * - * Copyright 2019-2026 - ModernUO Development Team * - * Email: hi@modernuo.com * - * File: ConnectionFiltersTests.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.Net; -using System.Threading; -using Server.Network; -using Xunit; - -namespace Server.Tests.Network; - -[Collection("Sequential Server Tests")] -public class ConnectionFiltersTests : IDisposable -{ - public ConnectionFiltersTests() => ConnectionFilters.ResetForTesting(); - - public void Dispose() => ConnectionFilters.ResetForTesting(); - - [Fact] - public void No_filters_denies_nothing() - { - Assert.False(ConnectionFilters.ShouldDeny(IPAddress.Parse("1.2.3.4"), out var deniedBy)); - Assert.Null(deniedBy); - } - - [Fact] - public void Register_is_idempotent_by_name() - { - ConnectionFilters.Register(new FakeFilter("dupe", deny: false)); - ConnectionFilters.Register(new FakeFilter("dupe", deny: true)); - - // The second registration is ignored, so the deny:true instance never gets consulted. - Assert.Single(ConnectionFilters.Filters); - Assert.False(ConnectionFilters.ShouldDeny(IPAddress.Parse("1.2.3.4"), out _)); - } - - [Fact] - public void First_denying_filter_short_circuits_and_is_named() - { - var first = new FakeFilter("allow-all", deny: false); - var second = new FakeFilter("deny-all", deny: true); - var third = new FakeFilter("never-reached", deny: true); - - ConnectionFilters.Register(first); - ConnectionFilters.Register(second); - ConnectionFilters.Register(third); - - Assert.True(ConnectionFilters.ShouldDeny(IPAddress.Parse("1.2.3.4"), out var deniedBy)); - Assert.Equal("deny-all", deniedBy); - Assert.Equal(1, first.Calls); - Assert.Equal(1, second.Calls); - Assert.Equal(0, third.Calls); // short-circuited - } - - // A filter that throws once throws for every subsequent connection, which would turn one bug into an - // exception per accept. It must be dropped, and the connection must fail open rather than be denied - // by a filter that never actually answered. - [Fact] - public void Throwing_filter_is_unregistered_and_fails_open() - { - var bad = new FakeFilter("bad", deny: true, throws: true); - var good = new FakeFilter("good", deny: false); - - ConnectionFilters.Register(bad); - ConnectionFilters.Register(good); - - Assert.False(ConnectionFilters.ShouldDeny(IPAddress.Parse("1.2.3.4"), out _)); - Assert.Single(ConnectionFilters.Filters); - Assert.Equal("good", ConnectionFilters.Filters[0].Name); - - // Remaining filters still run on the same pass the faulty one was dropped in. - Assert.Equal(1, good.Calls); - } - - [Fact] - public void Register_configures_immediately() - { - var filter = new FakeFilter("cfg", deny: false); - ConnectionFilters.Register(filter); - - Assert.True(filter.Configured); - } - - private sealed class FakeFilter : IConnectionFilter - { - private readonly bool _deny; - private readonly bool _throws; - - public FakeFilter(string name, bool deny, bool throws = false) - { - Name = name; - _deny = deny; - _throws = throws; - } - - public string Name { get; } - public int Calls { get; private set; } - public bool Configured { get; private set; } - - public void Register() => Configured = true; - - public void Start(CancellationToken token) - { - } - - public void Stop() - { - } - - public bool ShouldDeny(IPAddress address) - { - Calls++; - if (_throws) - { - throw new InvalidOperationException("simulated filter bug"); - } - - return _deny; - } - } -} 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/UOContent.Tests/Tests/Network/Firewall/FirewallEntryTests.cs b/Projects/Server.Tests/Tests/Network/Firewall/FirewallEntryTests.cs similarity index 100% rename from Projects/UOContent.Tests/Tests/Network/Firewall/FirewallEntryTests.cs rename to Projects/Server.Tests/Tests/Network/Firewall/FirewallEntryTests.cs diff --git a/Projects/Server.Tests/Tests/Network/Firewall/FirewallTests.cs b/Projects/Server.Tests/Tests/Network/Firewall/FirewallTests.cs new file mode 100644 index 000000000..f803ee586 --- /dev/null +++ b/Projects/Server.Tests/Tests/Network/Firewall/FirewallTests.cs @@ -0,0 +1,137 @@ + +using System.Net; +using System.Threading.Tasks; +using Server.Network; +using Xunit; + +namespace Server.Tests; + +public class FirewallTests +{ + [Fact] + public void Firewall_BlocksIPAddress_WhenAdded() + { + var ip = IPAddress.Parse("192.168.1.1"); + var entry = new SingleIpFirewallEntry("192.168.1.1"); + + Assert.False(Firewall.IsBlocked(ip)); + + Firewall.Add(entry); + + Assert.True(Firewall.IsBlocked(ip)); + } + + [Fact] + public void Firewall_DoesNotBlockIPAddress_WhenNotAdded() + { + var ip = IPAddress.Parse("192.168.1.2"); + Assert.False(Firewall.IsBlocked(ip)); + } + + [Fact] + public void Firewall_StopsBlockingIPAddress_WhenRemoved() + { + var ip = IPAddress.Parse("192.168.1.3"); + var entry = new SingleIpFirewallEntry("192.168.1.3"); + + Firewall.Add(entry); + Assert.True(Firewall.IsBlocked(ip)); + + Firewall.Remove(entry); + Assert.False(Firewall.IsBlocked(ip)); + } + + [Fact] + public void Firewall_BlocksIPRange() + { + var entry = new CidrFirewallEntry(IPAddress.Parse("10.0.0.1"), IPAddress.Parse("10.0.0.5")); + + Firewall.Add(entry); + + Assert.True(Firewall.IsBlocked(IPAddress.Parse("10.0.0.1"))); + Assert.True(Firewall.IsBlocked(IPAddress.Parse("10.0.0.3"))); + Assert.True(Firewall.IsBlocked(IPAddress.Parse("10.0.0.5"))); + + Assert.False(Firewall.IsBlocked(IPAddress.Parse("10.0.0.6"))); + } + + [Fact] + public void Firewall_CacheInvalidation_WorksOnUpdate() + { + var ip = IPAddress.Parse("192.168.1.10"); + var entry = new SingleIpFirewallEntry("192.168.1.10"); + + Firewall.Add(entry); + Assert.True(Firewall.IsBlocked(ip)); + + Firewall.Remove(entry); + Assert.False(Firewall.IsBlocked(ip)); + } + + [Fact] + public void Firewall_ReadsFirewallSetCorrectly() + { + var entry = new SingleIpFirewallEntry("172.16.0.1"); + Firewall.Add(entry); + + var found = false; + Firewall.ReadFirewallSet(set => + { + found = set.Contains(entry); + }); + + Assert.True(found); + } + + [Fact] + public void Firewall_IsThreadSafe() + { + var testIps = new IPAddress[256]; + for (var i = 0; i <= 255; i++) + { + testIps[i] = IPAddress.Parse($"192.168.0.{i}"); + } + + var entry = new CidrFirewallEntry(IPAddress.Parse("192.168.0.1"), IPAddress.Parse("192.168.0.255")); + Firewall.Add(entry); + + Parallel.ForEach(testIps, ip => + { + var shouldBlock = int.Parse(ip.ToString().Split('.')[3]) is > 0; + Assert.Equal(shouldBlock, Firewall.IsBlocked(ip)); + }); + + Firewall.Remove(entry); + + Parallel.ForEach(testIps, ip => + { + Assert.False(Firewall.IsBlocked(ip)); + }); + } + + [Fact] + public void Firewall_DoesNotThrowWhenRemovingNonExistentEntry() + { + var entry = new SingleIpFirewallEntry("203.0.113.5"); + Assert.False(Firewall.Remove(entry)); + } + + [Fact] + public void Firewall_CacheHandlesMultipleUpdates() + { + var ip = IPAddress.Parse("192.168.1.20"); + var entry = new SingleIpFirewallEntry("192.168.1.20"); + + Firewall.Add(entry); + Assert.True(Firewall.IsBlocked(ip)); + + Firewall.Remove(entry); + Assert.False(Firewall.IsBlocked(ip)); + + Firewall.Add(entry); + Assert.True(Firewall.IsBlocked(ip)); + + Firewall.Remove(entry); + Assert.False(Firewall.IsBlocked(ip)); + } +} diff --git a/Projects/Server.Tests/Tests/Network/ForeignProtocolTests.cs b/Projects/Server.Tests/Tests/Network/ForeignProtocolTests.cs deleted file mode 100644 index 95a3c4114..000000000 --- a/Projects/Server.Tests/Tests/Network/ForeignProtocolTests.cs +++ /dev/null @@ -1,116 +0,0 @@ -/************************************************************************* - * ModernUO * - * Copyright 2019-2026 - ModernUO Development Team * - * Email: hi@modernuo.com * - * File: ForeignProtocolTests.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.Text; -using Server.Network; -using Xunit; - -namespace Server.Tests.Network; - -public class ForeignProtocolTests -{ - private static ForeignProtocolMatch Identify(byte[] bytes, out ForeignProtocolKind kind) => - ForeignProtocol.Identify(bytes, out kind); - - private static byte[] Ascii(string s) => Encoding.ASCII.GetBytes(s); - - [Theory] - [InlineData("GET / HTTP/1.1\r\n")] - [InlineData("POST /a HTTP/1.1\r\n")] - [InlineData("HEAD / HTTP/1.0\r\n")] - [InlineData("OPTIONS * HTTP/1.1\r\n")] - [InlineData("CONNECT host:443 HTTP/1.1\r\n")] - [InlineData("DELETE /x HTTP/1.1\r\n")] - public void Http_requests_are_identified(string request) - { - Assert.Equal(ForeignProtocolMatch.Confirmed, Identify(Ascii(request), out var kind)); - Assert.Equal(ForeignProtocolKind.Http, kind); - } - - [Fact] - public void Ssh_banner_is_identified() - { - Assert.Equal(ForeignProtocolMatch.Confirmed, Identify(Ascii("SSH-2.0-OpenSSH_9.6"), out var kind)); - Assert.Equal(ForeignProtocolKind.Ssh, kind); - } - - [Fact] - public void Tls_client_hello_is_identified() - { - // handshake, TLS 1.2 record, length 0x0100, ClientHello - byte[] hello = [0x16, 0x03, 0x03, 0x01, 0x00, 0x01, 0x00, 0x00, 0xFC]; - - Assert.Equal(ForeignProtocolMatch.Confirmed, Identify(hello, out var kind)); - Assert.Equal(ForeignProtocolKind.Tls, kind); - } - - [Fact] - public void Tls_prefix_without_a_client_hello_is_not_foreign() - { - // A seed can spell 0x16 0x03 0x0? -- the address 22.3.x.x. Byte five is a packet id, not a - // handshake type, so it must fall through to normal parsing. - byte[] seedThenLogin = [0x16, 0x03, 0x03, 0x04, 0x00, 0x80, 0x00, 0x00]; - - Assert.Equal(ForeignProtocolMatch.None, Identify(seedThenLogin, out var kind)); - Assert.Equal(ForeignProtocolKind.None, kind); - } - - [Fact] - public void Seed_that_spells_an_http_method_falls_through() - { - // Seed 0x47455420 spells "GET ". The next byte is the 0x80 login packet id, not printable, so this - // is a real client and must not be flagged. - byte[] seedThenLogin = [(byte)'G', (byte)'E', (byte)'T', (byte)' ', 0x80, 0x00, 0x3A, 0x00]; - - Assert.Equal(ForeignProtocolMatch.None, Identify(seedThenLogin, out _)); - } - - [Theory] - [InlineData(0x80)] // login request - [InlineData(0x91)] // game server login - [InlineData(0xEF)] // new-style seed packet - public void Ordinary_uo_openings_are_not_foreign(byte secondPacketId) - { - byte[] buffer = [0x7F, 0x00, 0x00, 0x01, secondPacketId, 0x00, 0x00, 0x00]; - - Assert.Equal(ForeignProtocolMatch.None, Identify(buffer, out _)); - } - - [Fact] - public void Encrypted_login_is_not_mistaken_for_a_foreign_protocol() - { - // A legitimate client with encryption on when the shard expects none. The login cipher is a - // byte-for-byte XOR, so this is noise of exactly the right length. - byte[] buffer = [0x7F, 0x00, 0x00, 0x01, 0xC3, 0x9A, 0x04, 0xE1, 0x55, 0xB2]; - - Assert.Equal(ForeignProtocolMatch.None, Identify(buffer, out _)); - } - - [Fact] - public void Prefix_match_without_enough_bytes_waits() - { - // Framing is never assumed: "GET " split from its request line must wait. - Assert.Equal(ForeignProtocolMatch.Incomplete, Identify(Ascii("GET "), out _)); - Assert.Equal(ForeignProtocolMatch.Incomplete, Identify(Ascii("GET /"), out _)); - } - - [Fact] - public void Too_few_bytes_to_match_a_prefix_is_not_foreign() - { - // Under four bytes the caller's own short-read handling applies. - Assert.Equal(ForeignProtocolMatch.None, Identify(Ascii("GE"), out _)); - Assert.Equal(ForeignProtocolMatch.None, Identify([], out _)); - } -} diff --git a/Projects/Server.Tests/Tests/Network/NetworkCompressionBoundsTests.cs b/Projects/Server.Tests/Tests/Network/NetworkCompressionBoundsTests.cs deleted file mode 100644 index c1bc6a800..000000000 --- a/Projects/Server.Tests/Tests/Network/NetworkCompressionBoundsTests.cs +++ /dev/null @@ -1,86 +0,0 @@ -using System; -using Server.Network; -using Xunit; - -namespace Server.Tests.Network; - -/// -/// Bounds behaviour of the Huffman compressor when the destination is too small. -/// -/// This is reachable in production: NetState only checks that the send buffer has *some* writable -/// space before handing the remainder to Compress, so a nearly-full buffer can offer a span of one -/// to three bytes. The internal guard is computed as an unsigned output.Length - 4, which -/// underflows for those sizes and stops bounding the writes at all. -/// -public class NetworkCompressionBoundsTests -{ - [Theory] - [InlineData(0)] - [InlineData(1)] - [InlineData(2)] - [InlineData(3)] - public void RefusesOutputTooSmallToBound(int outputSize) - { - var input = new byte[64]; - Array.Fill(input, (byte)'A'); - - // Sentinel-filled backing array; only the middle window is offered to the compressor, so - // any write past the span shows up as a modified sentinel rather than silent corruption. - var backing = new byte[256]; - Array.Fill(backing, (byte)0xCC); - - const int windowStart = 64; - var output = backing.AsSpan(windowStart, outputSize); - - var written = NetworkCompression.Compress(input, output); - - Assert.Equal(0, written); - - for (var i = 0; i < backing.Length; i++) - { - Assert.Equal(0xCC, backing[i]); - } - } - - [Fact] - public void StillCompressesWhenOutputIsLargeEnough() - { - var input = new byte[64]; - Array.Fill(input, (byte)'A'); - - var output = new byte[256]; - - var written = NetworkCompression.Compress(input, output); - - Assert.True(written > 0); - Assert.True(written <= output.Length); - } - - [Fact] - public void ReportsFailureRatherThanOverrunningATightOutput() - { - // Large input against a small-but-bounded output: the guard is well-defined here, so this - // must fail cleanly rather than write past the end. - var input = new byte[4096]; - Array.Fill(input, (byte)'A'); - - var backing = new byte[256]; - Array.Fill(backing, (byte)0xCC); - - const int windowStart = 64; - const int windowSize = 16; - var output = backing.AsSpan(windowStart, windowSize); - - NetworkCompression.Compress(input, output); - - for (var i = 0; i < windowStart; i++) - { - Assert.Equal(0xCC, backing[i]); - } - - for (var i = windowStart + windowSize; i < backing.Length; i++) - { - Assert.Equal(0xCC, backing[i]); - } - } -} diff --git a/Projects/Server.Tests/Tests/PropertyList/ObjectPropertyListReentrancyTests.cs b/Projects/Server.Tests/Tests/PropertyList/ObjectPropertyListReentrancyTests.cs deleted file mode 100644 index a30e9c9e3..000000000 --- a/Projects/Server.Tests/Tests/PropertyList/ObjectPropertyListReentrancyTests.cs +++ /dev/null @@ -1,145 +0,0 @@ -using System; -using Xunit; - -namespace Server.Tests; - -/// -/// The interpolation buffer is rented by the handler ctor and returned by the closing Add, so every -/// hole is evaluated while it is live. A Reset()/Dispose() landing in that window used to leave the -/// next Append* spanning a null array: ArgumentNullException, parameter "array". -/// -// Sequential: building a list rents from STArrayPool, which is not thread-safe. -[Collection("Sequential Server Tests")] -public class ObjectPropertyListReentrancyTests -{ - // Stands in for a property getter that invalidates while its own tooltip is being built. - private static string ResettingHole(ObjectPropertyList list, string value) - { - list.Reset(); - return value; - } - - private static string DisposingHole(ObjectPropertyList list, string value) - { - list.Dispose(); - return value; - } - - [Fact] - public void InterpolatedAdd_ResetMidHole_DoesNotThrow() - { - var opl = new ObjectPropertyList(null); - - var ex = Record.Exception( - () => opl.Add(1060776, $"{ResettingHole(opl, "Knight")}\t{"Council of Mages"}") - ); - - Assert.Null(ex); - } - - [Fact] - public void InterpolatedAdd_DisposeMidHole_DoesNotThrow() - { - var opl = new ObjectPropertyList(null); - - var ex = Record.Exception( - () => opl.Add(1060776, $"{DisposingHole(opl, "Knight")}\t{"Council of Mages"}") - ); - - Assert.Null(ex); - } -} - -/// -/// The guard is per-list, so nested builds (a GetProperties override that reads another entity's -/// PropertyList) cannot unguard the outer one the way a single shared slot would. -/// -[Collection("Sequential Server Tests")] -public class ObjectPropertyListNestedBuildTests -{ - [Fact] - public void NestedBuild_DoesNotUnguardTheOuterList() - { - var outer = new ObjectPropertyList(null); - var inner = new ObjectPropertyList(null); - - outer.IsBuilding = true; - inner.IsBuilding = true; // another entity starts building, and finishes - inner.IsBuilding = false; - - Assert.True(outer.IsBuilding); - } - - [Fact] - public void Reset_MidInterpolation_LeavesTheListUsable() - { - var opl = new ObjectPropertyList(null); - - opl.Add(1060776, $"{Reset(opl, "Knight")}\t{"Council of Mages"}"); - opl.Add(1042971, "still working"); - opl.Terminate(); - - Assert.NotNull(opl.Buffer); - } - - private static string Reset(ObjectPropertyList list, string value) - { - list.Reset(); - return value; - } -} - - -/// -/// Invalidating from inside GetProperties is a defect in the getter, not a case to recover from: -/// DEBUG throws, RELEASE keeps a possibly stale tooltip without crashing or leaking. -/// -[Collection("Sequential Server Tests")] -public class PropertyListInvalidationDuringBuildTests -{ - private class SelfInvalidatingMobile : Mobile - { - public int Builds; - - public override void GetProperties(IPropertyList list) - { - Builds++; - base.GetProperties(list); - InvalidateProperties(); - list.Add(1060776, $"{"Knight"}\t{"Council of Mages"}"); - } - } - - private static SelfInvalidatingMobile Place(int x) - { - var m = new SelfInvalidatingMobile(); - m.MoveToWorld(new Point3D(x, 1000, 0), Map.Felucca); - return m; - } - - [Fact] - public void InvalidatingFromGetProperties_FailsLoudlyWithoutTearingDownTheBuild() - { - var wasEnabled = ObjectPropertyList.Enabled; - ObjectPropertyList.Enabled = true; - - try - { - var m = Place(1000); - -#if DEBUG - Assert.Throws(() => _ = m.PropertyList); -#else - Assert.Null(Record.Exception(() => _ = m.PropertyList)); -#endif - - // Refused, not retried. - Assert.Equal(1, m.Builds); - m.Delete(); - } - finally - { - ObjectPropertyList.Enabled = wasEnabled; - } - } -} diff --git a/Projects/Server.Tests/Tests/PropertyList/ObjectPropertyListSpanAddTests.cs b/Projects/Server.Tests/Tests/PropertyList/ObjectPropertyListSpanAddTests.cs index 97e2d9f58..408c53386 100644 --- a/Projects/Server.Tests/Tests/PropertyList/ObjectPropertyListSpanAddTests.cs +++ b/Projects/Server.Tests/Tests/PropertyList/ObjectPropertyListSpanAddTests.cs @@ -62,21 +62,6 @@ public class ObjectPropertyListSpanAddTests Assert.Equal((1070722, "Custom"), entries[0]); } - [Fact] - public void HashFormat_OnlyMarksIntegers() - { - // Integer {value:#} emits the cliloc marker "#". - var intList = new ObjectPropertyList(null); - intList.Add(1062028, $"{1043009:#}"); - Assert.Equal((1062028, "#1043009"), Decode(intList)[0]); - - // Float {value:#} is the standard '#' custom-numeric (digit-placeholder) format, not a cliloc - // marker -- so no leading '#'. - var dblList = new ObjectPropertyList(null); - dblList.Add(1062028, $"{42.0:#}"); - Assert.Equal((1062028, 42.0.ToString("#")), Decode(dblList)[0]); // "42" - } - [Fact] public void Add_TruncatesArgumentOverMaxLength() { 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/Collections/SortedRangeIndex.cs b/Projects/Server/Collections/SortedRangeIndex.cs deleted file mode 100644 index 5af37fd59..000000000 --- a/Projects/Server/Collections/SortedRangeIndex.cs +++ /dev/null @@ -1,134 +0,0 @@ -/************************************************************************* - * ModernUO * - * Copyright 2019-2026 - ModernUO Development Team * - * Email: hi@modernuo.com * - * File: SortedRangeIndex.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.Numerics; - -namespace Server.Collections; - -/// -/// Immutable, allocation-lean membership index over a set of inclusive integer ranges. Ranges are -/// stored as two parallel arrays (_mins/_maxs) sorted by minimum and coalesced into -/// disjoint runs, so a single binary search decides membership. Coalescing is required for -/// correctness: only inspects the rightmost run whose minimum is <= the -/// value, which is only sound when the runs never overlap or nest. -/// -public sealed class SortedRangeIndex where T : IBinaryInteger -{ - public static readonly SortedRangeIndex Empty = new([], []); - - /// An inclusive [Min, Max] range. Singles are represented as Min == Max. - public readonly record struct Range(T Min, T Max); - - /// Orders ranges ascending by minimum; the coalescing pass in requires this. - public static readonly Comparison ByMin = static (a, b) => a.Min.CompareTo(b.Min); - - private readonly T[] _mins; - private readonly T[] _maxs; - - private SortedRangeIndex(T[] mins, T[] maxs) - { - _mins = mins; - _maxs = maxs; - } - - public int Count => _mins.Length; - - /// - /// True when falls in any range. Binary-searches for the rightmost run - /// whose minimum is <= the value, then tests that value against that run's maximum. - /// - public bool Contains(T value) - { - var lo = 0; - var hi = _mins.Length - 1; - var found = -1; - while (lo <= hi) - { - var mid = (lo + hi) >> 1; - if (_mins[mid] <= value) - { - found = mid; - lo = mid + 1; - } - else - { - hi = mid - 1; - } - } - - return found >= 0 && value <= _maxs[found]; - } - - /// - /// Builds an index from ranges that are already sorted ascending by (sort - /// the source with first). Overlapping and nested ranges are merged into disjoint - /// runs. Two passes over the (pooled) input keep the run count exact so only the two final arrays are - /// heap-allocated: pass one counts the runs, pass two fills the exact-size arrays. - /// - public static SortedRangeIndex Build(ReadOnlySpan sortedByMin) - { - if (sortedByMin.IsEmpty) - { - return Empty; - } - - // Pass 1: count the disjoint runs so the final arrays can be sized exactly. - var runs = 1; - var curMax = sortedByMin[0].Max; - for (var i = 1; i < sortedByMin.Length; i++) - { - var r = sortedByMin[i]; - if (r.Min <= curMax) - { - if (r.Max > curMax) - { - curMax = r.Max; - } - } - else - { - runs++; - curMax = r.Max; - } - } - - // Pass 2: write the coalesced runs into the exact-size final arrays. - var mins = new T[runs]; - var maxs = new T[runs]; - var w = 0; - mins[0] = sortedByMin[0].Min; - maxs[0] = sortedByMin[0].Max; - for (var i = 1; i < sortedByMin.Length; i++) - { - var r = sortedByMin[i]; - if (r.Min <= maxs[w]) - { - if (r.Max > maxs[w]) - { - maxs[w] = r.Max; - } - } - else - { - w++; - mins[w] = r.Min; - maxs[w] = r.Max; - } - } - - return new SortedRangeIndex(mins, maxs); - } -} 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/FeatureFlags.cs b/Projects/Server/FeatureFlags.cs index a70b4e452..99d6f9226 100644 --- a/Projects/Server/FeatureFlags.cs +++ b/Projects/Server/FeatureFlags.cs @@ -10,5 +10,4 @@ public static class ServerFeatureFlags public static bool PvPCombat { get; set; } = true; public static bool BankAccess { get; set; } = true; public static bool SpeedhackDetection { get; set; } - public static bool InsuranceEnabled { get; set; } } 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..a3e546bc2 100644 --- a/Projects/Server/Items/Item.cs +++ b/Projects/Server/Items/Item.cs @@ -14,7 +14,6 @@ *************************************************************************/ using System; -using System.Diagnostics; using System.Collections.Generic; using System.Reflection; using System.Runtime.CompilerServices; @@ -335,25 +334,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 +372,7 @@ public partial class Item : IHued, IComparable, ISpawnable, IObjectPropert } Delta(ItemDelta.Update); - RestartDecay(); + UpdateDecayRegistration(); } } } @@ -407,7 +388,7 @@ public partial class Item : IHued, IComparable, ISpawnable, IObjectPropert SetFlag(ImplFlag.Movable, value); Delta(ItemDelta.Update); - RestartDecay(); + UpdateDecayRegistration(); } } } @@ -767,12 +748,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 { @@ -823,22 +798,7 @@ public partial class Item : IHued, IComparable, ISpawnable, IObjectPropert public virtual int HuedItemID => m_ItemID; - public ObjectPropertyList PropertyList - { - get - { - if (m_PropertyList == null) - { - // Publish the list before building it so a nested InvalidateProperties can see the - // build in progress and defer instead of recursing into a second throwaway list. - var list = new ObjectPropertyList(this); - m_PropertyList = list; - InitializePropertyList(list); - } - - return m_PropertyList; - } - } + public ObjectPropertyList PropertyList => m_PropertyList ??= InitializePropertyList(new ObjectPropertyList(this)); /// /// Overridable. Fills an with everything applicable. By default, this invokes @@ -863,7 +823,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 +933,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 +963,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 +1291,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 +1521,7 @@ public partial class Item : IHued, IComparable, ISpawnable, IObjectPropert if (oldValue != value) { - RestartDecay(); + UpdateDecayRegistration(); } } } @@ -1771,7 +1715,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; @@ -1941,7 +1884,7 @@ public partial class Item : IHued, IComparable, ISpawnable, IObjectPropert { list.Add(1049643); // cursed } - else if (ServerFeatureFlags.InsuranceEnabled && Insured) + else if (Insured) { list.Add(1061682); // insured } @@ -2356,64 +2299,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 +2309,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 +2341,6 @@ public partial class Item : IHued, IComparable, ISpawnable, IObjectPropert } Amount += dropped.Amount; - if (PlayerConstructed != dropped.PlayerConstructed) - { - PlayerConstructed = false; - } - dropped.Delete(); if (playSound && from != null) @@ -2554,19 +2429,9 @@ public partial class Item : IHued, IComparable, ISpawnable, IObjectPropert private ObjectPropertyList InitializePropertyList(ObjectPropertyList list) { - list.IsBuilding = true; - - try - { - GetProperties(list); - AppendChildProperties(list); - list.Terminate(); - } - finally - { - list.IsBuilding = false; - } - + GetProperties(list); + AppendChildProperties(list); + list.Terminate(); return list; } @@ -2583,26 +2448,6 @@ public partial class Item : IHued, IComparable, ISpawnable, IObjectPropert return; } - // Always a bug in the property getter, and there is no correct recovery: refuse rather than - // hide it. RELEASE keeps a possibly stale tooltip, DEBUG throws. - // See dev-docs/property-lists.md "Never Invalidate From Inside GetProperties". - if (m_PropertyList?.IsBuilding == true) - { - logger.Error( - "{Entity} called InvalidateProperties() while its property list was being built. Remove the side effect from the property getter, or defer it with Timer.DelayCall.\n{StackTrace}", - this, - new StackTrace() - ); - -#if DEBUG - throw new InvalidOperationException( - $"{this} invalidated its property list from inside GetProperties. Remove the side effect from the property getter." - ); -#else - return; -#endif - } - if (m_Map != null && m_Map != Map.Internal && !World.Loading) { int? oldHash; @@ -2766,8 +2611,6 @@ public partial class Item : IHued, IComparable, ISpawnable, IObjectPropert switch (version) { - case 11: - case 10: case 9: case 8: case 7: @@ -2775,11 +2618,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 +2636,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 +2808,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 +3279,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 +3385,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; } @@ -4397,12 +4216,12 @@ public partial class Item : IHued, IComparable, ISpawnable, IObjectPropert } public virtual bool CheckBlessed(Mobile m) => - m_LootType == LootType.Blessed || ServerFeatureFlags.InsuranceEnabled && Insured || m != null && m == BlessedFor; + m_LootType == LootType.Blessed || Mobile.InsuranceEnabled && Insured || m != null && m == BlessedFor; public virtual bool CheckNewbied() => m_LootType == LootType.Newbied; public virtual bool IsStandardLoot() => - (!ServerFeatureFlags.InsuranceEnabled || !Insured) && BlessedFor == null && m_LootType == LootType.Regular; + (!Mobile.InsuranceEnabled || !Insured) && BlessedFor == null && m_LootType == LootType.Regular; public override string ToString() => $"{Serial} \"{GetType().Name}\""; @@ -4472,8 +4291,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 +4328,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..f89fd3084 100644 --- a/Projects/Server/Main.cs +++ b/Projects/Server/Main.cs @@ -1,4 +1,4 @@ -/************************************************************************* +/************************************************************************* * ModernUO * * Copyright 2019-2026 - ModernUO Development Team * * Email: hi@modernuo.com * @@ -30,7 +30,6 @@ using Server.Compression; using Server.Json; using Server.Logging; using Server.Network; -using Server.Network.Bans; using Server.Text; namespace Server; @@ -39,181 +38,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 +110,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 +234,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) @@ -428,7 +260,7 @@ public static class Core // ignored } - if (!close && !Headless) + if (!close && !Core.Headless) { Console.WriteLine("This exception is fatal, press return to exit"); ConsoleInputHandler.ReadLine(); @@ -510,8 +342,6 @@ public static class Core World.ExitSerializationThreads(); PingServer.Shutdown(); NetState.Shutdown(); - BanChannel.Stop(); - ConnectionFilters.Stop(); if (!_crashed) { @@ -591,45 +421,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 +437,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 +450,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"); @@ -673,76 +461,44 @@ public static class Core AssemblyHandler.Invoke("Initialize"); - BanChannel.Start(ClosingTokenSource.Token); - ConnectionFilters.Start(ClosingTokenSource.Token); 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 +508,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 +548,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..3e19548c6 100644 --- a/Projects/Server/Mobiles/Mobile.cs +++ b/Projects/Server/Mobiles/Mobile.cs @@ -26,7 +26,6 @@ using Server.Network; using Server.Prompts; using Server.Targeting; using System; -using System.Diagnostics; using System.Collections.Generic; using System.Runtime.CompilerServices; using Server.Buffers; @@ -42,7 +41,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 +56,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 +376,7 @@ public partial class Mobile : IHued, IComparable, ISpawnable, IObjectPro Aggressors = new List(); Aggressed = new List(); NextSkillTime = Core.TickCount; + DamageEntries = new List(); } // Sectors @@ -958,27 +953,13 @@ public partial class Mobile : IHued, IComparable, ISpawnable, IObjectPro public static TimeSpan AutoManifestTimeout { get; set; } = TimeSpan.FromSeconds(5.0); + public static bool InsuranceEnabled { get; set; } + public static int ActionDelay { get; set; } = 500; 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 +1628,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 +2021,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) { @@ -2309,22 +2293,7 @@ public partial class Mobile : IHued, IComparable, ISpawnable, IObjectPro public int CompareTo(Mobile other) => other == null ? -1 : Serial.CompareTo(other.Serial); public virtual int HuedItemID => m_Female ? 0x2107 : 0x2106; - public ObjectPropertyList PropertyList - { - get - { - if (m_PropertyList == null) - { - // Publish the list before building it so a nested InvalidateProperties can see the - // build in progress and defer instead of recursing into a second throwaway list. - var list = new ObjectPropertyList(this); - m_PropertyList = list; - InitializePropertyList(list); - } - - return m_PropertyList; - } - } + public ObjectPropertyList PropertyList => m_PropertyList ??= InitializePropertyList(new ObjectPropertyList(this)); public virtual void GetProperties(IPropertyList list) { @@ -2341,11 +2310,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 +5234,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 +5724,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 +5754,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 +5779,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 +5809,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 +5866,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 +6129,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 +6137,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 +6447,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()) @@ -7272,18 +7227,8 @@ public partial class Mobile : IHued, IComparable, ISpawnable, IObjectPro private ObjectPropertyList InitializePropertyList(ObjectPropertyList list) { - list.IsBuilding = true; - - try - { - GetProperties(list); - list.Terminate(); - } - finally - { - list.IsBuilding = false; - } - + GetProperties(list); + list.Terminate(); return list; } @@ -7300,26 +7245,6 @@ public partial class Mobile : IHued, IComparable, ISpawnable, IObjectPro return; } - // Always a bug in the property getter, and there is no correct recovery: refuse rather than - // hide it. RELEASE keeps a possibly stale tooltip, DEBUG throws. - // See dev-docs/property-lists.md "Never Invalidate From Inside GetProperties". - if (m_PropertyList?.IsBuilding == true) - { - logger.Error( - "{Entity} called InvalidateProperties() while its property list was being built. Remove the side effect from the property getter, or defer it with Timer.DelayCall.\n{StackTrace}", - this, - new StackTrace() - ); - -#if DEBUG - throw new InvalidOperationException( - $"{this} invalidated its property list from inside GetProperties. Remove the side effect from the property getter." - ); -#else - return; -#endif - } - if (m_Map != null && m_Map != Map.Internal && !World.Loading) { int? oldHash; @@ -7827,10 +7752,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 +7790,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/Bans/BanChannel.cs b/Projects/Server/Network/Bans/BanChannel.cs deleted file mode 100644 index b682158d0..000000000 --- a/Projects/Server/Network/Bans/BanChannel.cs +++ /dev/null @@ -1,160 +0,0 @@ -/************************************************************************* - * ModernUO * - * Copyright 2019-2026 - ModernUO Development Team * - * Email: hi@modernuo.com * - * File: BanChannel.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.Generic; -using System.Net; -using System.Threading; -using Server.Logging; - -namespace Server.Network.Bans; - -/// -/// Coordinates the configured contribution sinks. Enforcement is NOT here — -/// the accept path asks . This channel only fans locally-decided bans out -/// to external systems (CrowdSec), which distribute them to OS-level bouncers. -/// -public static class BanChannel -{ - private static readonly ILogger logger = LogFactory.GetLogger(typeof(BanChannel)); - - private static IBanReporter[] _reporters = []; - - public static IReadOnlyList Reporters => _reporters; - - /// - /// Registers a contribution sink from content (inversion of control). Idempotent by - /// : a second registration of the same name is ignored. Configures the - /// reporter immediately so it is ready before . - /// - public static void Register(IBanReporter reporter) - { - if (reporter == null) - { - return; - } - - var reporters = _reporters; - for (var i = 0; i < reporters.Length; i++) - { - if (reporters[i].Name == reporter.Name) - { - return; - } - } - - reporter.Register(); - - var updated = new IBanReporter[_reporters.Length + 1]; - Array.Copy(_reporters, updated, _reporters.Length); - updated[^1] = reporter; - _reporters = updated; - - logger.Information("Ban channel registered reporter '{Name}'", reporter.Name); - } - - internal static void ConfigureForTesting(IBanReporter[] reporters) => _reporters = reporters ?? []; - - public static void Start(CancellationToken token) - { - var reporters = _reporters; - for (var i = 0; i < reporters.Length; i++) - { - var reporter = reporters[i]; - try - { - reporter.Start(token); - } - catch (Exception e) - { - // A broken contribution path must not crash boot — enforcement is local and unaffected. - logger.Error(e, "Ban reporter '{Name}' failed to start; continuing without it", reporter.Name); - } - } - } - - public static void Stop() - { - var reporters = _reporters; - for (var i = 0; i < reporters.Length; i++) - { - var reporter = reporters[i]; - try - { - reporter.Stop(); - } - catch (Exception e) - { - logger.Warning(e, "Ban reporter '{Name}' threw while stopping", reporter.Name); - } - } - } - - /// Fans a locally-decided ban out to every reporter. Non-blocking; never throws. - /// - /// Optional content-supplied exemption; true drops the contribution before any reporter sees it. This - /// withholds escalation only — the gate that reached the verdict has already acted. - /// - /// - /// An implementation that ignores reason would silently swallow manual bans. See - /// . - /// - public static Func IsExempt { get; set; } - - public static void Report(IPAddress ip, TimeSpan ttl, string reason) - { - var exempt = IsExempt; - if (exempt != null && exempt(ip, reason)) - { - logger.Debug("{Address} not contributed ('{Reason}'): exempt", ip, reason); - return; - } - - var reporters = _reporters; - for (var i = 0; i < reporters.Length; i++) - { - try - { - reporters[i].Report(ip, ttl, reason); - } - catch (Exception e) - { - logger.Warning(e, "Ban reporter '{Name}' threw during Report", reporters[i].Name); - } - } - } - - /// Fans a retraction (manual unban) out to every retract-capable reporter. - public static void Retract(IPAddress ip) - { - var reporters = _reporters; - for (var i = 0; i < reporters.Length; i++) - { - if (!reporters[i].CanRetract) - { - continue; - } - - try - { - reporters[i].Retract(ip); - } - catch (Exception e) - { - logger.Warning(e, "Ban reporter '{Name}' threw during Retract", reporters[i].Name); - } - } - } -} diff --git a/Projects/Server/Network/Bans/BanConfiguration.cs b/Projects/Server/Network/Bans/BanConfiguration.cs deleted file mode 100644 index d678a6470..000000000 --- a/Projects/Server/Network/Bans/BanConfiguration.cs +++ /dev/null @@ -1,98 +0,0 @@ -/************************************************************************* - * ModernUO * - * Copyright 2019-2026 - ModernUO Development Team * - * Email: hi@modernuo.com * - * File: BanConfiguration.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/bans.json (matching the per-feature -/// JSON config pattern used by AssistantConfiguration). Loaded once; a missing file writes a -/// local-only, fail-open template so operators have something to edit. -/// -public static class BanConfiguration -{ - private const string _path = "Configuration/bans.json"; - - private static bool _loaded; - - /// - /// Never null: the accept and reap paths read this per connection, including before - /// has run (a harness driving NetState.Slice directly), so it starts at the record's defaults. - /// - public static BanSettings Settings { get; private set; } = new(); - - public static void Configure() - { - // Idempotent; flagged rather than null-checked because Settings is non-null from the start. - if (_loaded) - { - return; - } - - _loaded = true; - - var path = Path.Join(Core.BaseDirectory, _path); - - if (File.Exists(path)) - { - Settings = JsonConfig.Deserialize(path); - } - else - { - Settings = new BanSettings - { - ReportRateLimitTrips = true, - AutoBanDuration = TimeSpan.FromHours(4) - }; - - Save(); - } - } - - private static void Save() - { - JsonConfig.Serialize(Path.Join(Core.BaseDirectory, _path), Settings); - } -} - -/// Ban-channel policy: which reporters receive contributions, and how auto-detections are handled. -public record BanSettings -{ - /// Whether IP rate-limiter trips are contributed to reporters. They never enter the local firewall set. - [JsonPropertyName("reportRateLimitTrips")] - public bool ReportRateLimitTrips { get; set; } = true; - - /// Duration reported for an auto-detected (rate-limit) ban. - [JsonPropertyName("autoBanDuration")] - public TimeSpan AutoBanDuration { get; set; } = TimeSpan.FromHours(4); - - /// - /// Whether behavioural detections are contributed to reporters. Those connections are disconnected either - /// way; this only controls escalation. - /// - /// - /// Keyed on bytes-received, never elapsed time: a connection that sent something and ran out of time is - /// far more likely a slow link than an attack. See dev-docs/ip-bans-and-allowlists.md. - /// - [JsonPropertyName("reportBadConnects")] - public bool ReportBadConnects { get; set; } = true; - - [JsonPropertyName("badConnectDuration")] - public TimeSpan BadConnectDuration { get; set; } = TimeSpan.FromHours(4); -} diff --git a/Projects/Server/Network/Bans/BanReasons.cs b/Projects/Server/Network/Bans/BanReasons.cs deleted file mode 100644 index 16708f716..000000000 --- a/Projects/Server/Network/Bans/BanReasons.cs +++ /dev/null @@ -1,48 +0,0 @@ -/************************************************************************* - * ModernUO * - * Copyright 2019-2026 - ModernUO Development Team * - * Email: hi@modernuo.com * - * File: BanReasons.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 . * - *************************************************************************/ - -namespace Server.Network.Bans; - -/// The reason slugs contributed through . Policy keys off these. -public static class BanReasons -{ - /// An operator banned this address explicitly. Never exempt, never auto-denied. - public const string Manual = "manual"; - - public const string RateLimit = "rate-limit"; - - /// Matched the reputation blocklist. Enforced by its own filter, not by behaviour. - public const string Blocklist = "blocklist"; - - /// Reaped without ever sending a byte. - public const string SilentConnect = "silent-connect"; - - /// Opened with a zero seed, which no real client sends. - public const string InvalidSeed = "invalid-seed"; - - /// Positively identified as another protocol entirely. See . - public const string ForeignProtocol = "foreign-protocol"; - - /// - /// Verdicts the shard reached by watching the connection. Only these may be exempted, and only these feed - /// the local denylist. - /// - /// - /// Opt-in rather than "everything except ", so a reason added later escalates normally - /// instead of silently inheriting an exemption. - /// - public static bool IsBehavioral(string reason) => - reason is RateLimit or SilentConnect or InvalidSeed or ForeignProtocol; -} diff --git a/Projects/Server/Network/Bans/IBanReporter.cs b/Projects/Server/Network/Bans/IBanReporter.cs deleted file mode 100644 index 190c6936e..000000000 --- a/Projects/Server/Network/Bans/IBanReporter.cs +++ /dev/null @@ -1,55 +0,0 @@ -/************************************************************************* - * ModernUO * - * Copyright 2019-2026 - ModernUO Development Team * - * Email: hi@modernuo.com * - * File: IBanReporter.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.Net; -using System.Threading; - -namespace Server.Network.Bans; - -/// -/// A contribution sink behind . Reporters receive locally-decided bans -/// (manual admin bans, rate-limit trips, blocklist promotions) and forward them to an external system -/// (e.g. CrowdSec), which distributes them to OS-level bouncers. Reporters never answer the accept-path -/// membership query — that is an 's job. -/// -public interface IBanReporter -{ - /// Stable id for logging/config (e.g. crowdsec). - string Name { get; } - - /// Reads configuration. No network or file I/O here. - void Register(); - - /// Starts background delivery. The token is cancelled on shutdown. - void Start(CancellationToken token); - - /// Flushes and tears down background delivery. - void Stop(); - - /// - /// Enqueues a ban contribution. MUST be non-blocking and safe on the accept path: it may only - /// enqueue (bounded, drop-on-overflow) and never perform synchronous I/O. - /// - /// or negative = use the reporter's default duration. - /// Short slug (manual, rate-limit) used as the scenario suffix. - void Report(IPAddress address, TimeSpan ttl, string reason); - - /// True if this reporter can retract a previously-reported ban. - bool CanRetract { get; } - - /// Enqueues a retraction (e.g. a manual unban). No-op if unsupported. - void Retract(IPAddress address); -} diff --git a/Projects/Server/Network/ConnectionFilters.cs b/Projects/Server/Network/ConnectionFilters.cs deleted file mode 100644 index c0bb0b2fb..000000000 --- a/Projects/Server/Network/ConnectionFilters.cs +++ /dev/null @@ -1,155 +0,0 @@ -/************************************************************************* - * ModernUO * - * Copyright 2019-2026 - ModernUO Development Team * - * Email: hi@modernuo.com * - * File: ConnectionFilters.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.Generic; -using System.Net; -using System.Threading; -using Server.Logging; - -namespace Server.Network; - -/// -/// Registry of the gates the accept path consults, and their lifecycle. -/// Filters are registered during the Configure sweep and the backing store is a plain array, so -/// is an indexed loop over a field read — no enumerator, no closure, no -/// allocation. The whole accept path runs on the game loop, so no synchronization is needed. -/// -public static class ConnectionFilters -{ - private static readonly ILogger logger = LogFactory.GetLogger(typeof(ConnectionFilters)); - - private static IConnectionFilter[] _filters = []; - - public static IReadOnlyList Filters => _filters; - - /// - /// Registers a gate (inversion of control, mirroring BanChannel.Register). Idempotent by - /// . Filters are consulted in registration order, so register - /// the cheapest and most selective first — core registers the firewall before content is swept. - /// - public static void Register(IConnectionFilter filter) - { - if (filter == null) - { - return; - } - - var filters = _filters; - for (var i = 0; i < filters.Length; i++) - { - if (filters[i].Name == filter.Name) - { - return; - } - } - - filter.Register(); - - var updated = new IConnectionFilter[_filters.Length + 1]; - Array.Copy(_filters, updated, _filters.Length); - updated[^1] = filter; - _filters = updated; - - logger.Information("Registered connection filter '{Name}'", filter.Name); - } - - /// - /// True when any filter denies the connection. Short-circuits on the first denial; - /// names it for logging. - /// - public static bool ShouldDeny(IPAddress address, out string deniedBy) - { - var filters = _filters; - for (var i = 0; i < filters.Length; i++) - { - // A faulty filter must not take down the accept loop for every connection. - try - { - if (filters[i].ShouldDeny(address)) - { - deniedBy = filters[i].Name; - return true; - } - } - catch (Exception e) - { - Disable(filters[i], e); - } - } - - deniedBy = null; - return false; - } - - /// - /// Drops a filter that threw on the accept path: one that faults once faults for every subsequent - /// connection, costing an exception and a log line per accept. Failing open is deliberate — a broken - /// filter must not be able to deny every connection either. - /// - private static void Disable(IConnectionFilter filter, Exception e) - { - logger.Error(e, "Connection filter '{Name}' threw on the accept path; unregistering it", filter.Name); - - var filters = _filters; - var updated = new List(filters.Length); - for (var i = 0; i < filters.Length; i++) - { - if (!ReferenceEquals(filters[i], filter)) - { - updated.Add(filters[i]); - } - } - - _filters = updated.ToArray(); - } - - public static void Start(CancellationToken token) - { - var filters = _filters; - for (var i = 0; i < filters.Length; i++) - { - var filter = filters[i]; - try - { - filter.Start(token); - } - catch (Exception e) - { - // A filter that cannot hydrate must not crash boot; it simply denies nothing. - logger.Error(e, "Connection filter '{Name}' failed to start; continuing without it", filter.Name); - } - } - } - - public static void Stop() - { - var filters = _filters; - for (var i = 0; i < filters.Length; i++) - { - var filter = filters[i]; - try - { - filter.Stop(); - } - catch (Exception e) - { - logger.Warning(e, "Connection filter '{Name}' threw while stopping", filter.Name); - } - } - } - - internal static void ResetForTesting() => _filters = []; -} diff --git a/Projects/UOContent/Network/Firewall/BaseFirewallEntry.cs b/Projects/Server/Network/Firewall/BaseFirewallEntry.cs similarity index 100% rename from Projects/UOContent/Network/Firewall/BaseFirewallEntry.cs rename to Projects/Server/Network/Firewall/BaseFirewallEntry.cs diff --git a/Projects/UOContent/Network/Firewall/CidrFirewallEntry.cs b/Projects/Server/Network/Firewall/CidrFirewallEntry.cs similarity index 70% rename from Projects/UOContent/Network/Firewall/CidrFirewallEntry.cs rename to Projects/Server/Network/Firewall/CidrFirewallEntry.cs index 06527ab71..db74cf620 100644 --- a/Projects/UOContent/Network/Firewall/CidrFirewallEntry.cs +++ b/Projects/Server/Network/Firewall/CidrFirewallEntry.cs @@ -25,15 +25,8 @@ public class CidrFirewallEntry : BaseFirewallEntry public override UInt128 MaxIpAddress { get; } public CidrFirewallEntry(string ipAddressOrCidr) + : this(ParseIPAddress(ipAddressOrCidr, out var prefixLength), prefixLength) { - // Core owns the CIDR -> normalized range parse. - if (!IPAddressUtility.TryParseCidrRange(ipAddressOrCidr, out var min, out var max)) - { - throw new ArgumentException("Invalid IP address or CIDR.", nameof(ipAddressOrCidr)); - } - - MinIpAddress = min; - MaxIpAddress = max; } public CidrFirewallEntry(IPAddress minAddress, IPAddress maxAddress) @@ -57,4 +50,26 @@ public class CidrFirewallEntry : BaseFirewallEntry MaxIpAddress = Utility.CreateCidrAddress(bytes, prefixLength, true); } + private static IPAddress ParseIPAddress(ReadOnlySpan ipString, out int prefixLength) + { + var slashIndex = ipString.IndexOf('/'); + var ipAddress = IPAddress.Parse(slashIndex > -1 ? ipString[..slashIndex] : ipString); + var maxPrefixLength = ipAddress.AddressFamily == AddressFamily.InterNetworkV6 ? 128 : 32; + + if (slashIndex == -1) + { + prefixLength = maxPrefixLength; + } + else + { + var prefixPart = ipString[(slashIndex + 1)..]; + + if (!int.TryParse(prefixPart, out prefixLength) || prefixLength < 0 || prefixLength > maxPrefixLength) + { + throw new ArgumentException("Invalid prefix length."); + } + } + + return ipAddress; + } } diff --git a/Projects/Server/Network/Firewall/Firewall.cs b/Projects/Server/Network/Firewall/Firewall.cs new file mode 100644 index 000000000..ffb778aa8 --- /dev/null +++ b/Projects/Server/Network/Firewall/Firewall.cs @@ -0,0 +1,168 @@ +/************************************************************************* + * ModernUO * + * Copyright 2019-2026 - ModernUO Development Team * + * Email: hi@modernuo.com * + * File: Firewall.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.Collections.Generic; +using System.Net; +using System.Runtime.CompilerServices; +using System.Threading; + +namespace Server.Network; + +public static class Firewall +{ + [ThreadStatic] + private static InternalValidationEntry _validationEntry; + private static readonly ConcurrentDictionary _isBlockedCache = []; + private static readonly ReaderWriterLockSlim _firewallLock = new(LockRecursionPolicy.NoRecursion); + + private static int _firewallVersion; + private static readonly SortedSet _firewallSet = []; + + public static int FirewallSetCount => _firewallSet.Count; + + public static void ReadFirewallSet(Action> callback) + { + _firewallLock.EnterReadLock(); + try + { + callback(_firewallSet); + } + finally + { + _firewallLock.ExitReadLock(); + } + } + + internal static bool IsBlocked(IPAddress address) + { + if (_isBlockedCache.TryGetValue(address, out var blockVersion) && blockVersion == _firewallVersion) + { + return true; + } + + if (_validationEntry == null) + { + _validationEntry = new InternalValidationEntry(address); + } + else + { + _validationEntry.Address = address; + } + + if (CheckBlocked(_validationEntry)) + { + _isBlockedCache[address] = _firewallVersion; + return true; + } + + return false; + } + + private static bool CheckBlocked(IFirewallEntry validationEntry) + { + if (_firewallSet.Count == 0) + { + return false; + } + + _firewallLock.EnterReadLock(); + try + { + var min = _firewallSet.Min; + if (validationEntry.CompareTo(min) < 0) + { + return false; + } + + // Get all entries that are lower than our validation entry + var view = _firewallSet.GetViewBetween(min, validationEntry); + + // Loop backward since there shouldn't be any entries where the Min address is higher than ours + foreach (var firewallEntry in view.Reverse()) + { + if (firewallEntry.IsBlocked(validationEntry.MinIpAddress)) + { + return true; + } + } + + return view.Max?.IsBlocked(validationEntry.MinIpAddress) == true; + } + finally + { + _firewallLock.ExitReadLock(); + } + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static bool Add(IFirewallEntry firewallEntry) + { + _firewallLock.EnterWriteLock(); + try + { + if (_firewallSet.Add(firewallEntry)) + { + Interlocked.Increment(ref _firewallVersion); // Update version + return true; + } + return false; + } + finally + { + _firewallLock.ExitWriteLock(); + } + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static bool Remove(IFirewallEntry entry) + { + if (entry == null) + { + return false; + } + + _firewallLock.EnterWriteLock(); + try + { + if (_firewallSet.Remove(entry)) + { + Interlocked.Increment(ref _firewallVersion); // Update version + return true; + } + return false; + } + finally + { + _firewallLock.ExitWriteLock(); + } + } + + private class InternalValidationEntry : BaseFirewallEntry + { + private UInt128 _address; + + public IPAddress Address + { + set => _address = value.ToUInt128(); + } + + public override UInt128 MinIpAddress => _address; + public override UInt128 MaxIpAddress => _address; + + public InternalValidationEntry(IPAddress ipAddress) => Address = ipAddress; + } +} diff --git a/Projects/UOContent/Network/Firewall/IFirewallEntry.cs b/Projects/Server/Network/Firewall/IFirewallEntry.cs similarity index 100% rename from Projects/UOContent/Network/Firewall/IFirewallEntry.cs rename to Projects/Server/Network/Firewall/IFirewallEntry.cs diff --git a/Projects/UOContent/Network/Firewall/SingleIpFirewallEntry.cs b/Projects/Server/Network/Firewall/SingleIpFirewallEntry.cs similarity index 100% rename from Projects/UOContent/Network/Firewall/SingleIpFirewallEntry.cs rename to Projects/Server/Network/Firewall/SingleIpFirewallEntry.cs diff --git a/Projects/Server/Network/ForeignProtocol.cs b/Projects/Server/Network/ForeignProtocol.cs deleted file mode 100644 index 059a69d3f..000000000 --- a/Projects/Server/Network/ForeignProtocol.cs +++ /dev/null @@ -1,146 +0,0 @@ -/************************************************************************* - * ModernUO * - * Copyright 2019-2026 - ModernUO Development Team * - * Email: hi@modernuo.com * - * File: ForeignProtocol.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; - -namespace Server.Network; - -public enum ForeignProtocolKind -{ - None, - Http, - Tls, - Ssh -} - -public enum ForeignProtocolMatch -{ - None, - - /// A prefix matched, but more bytes are needed to be sure. - Incomplete, - - Confirmed -} - -/// -/// Identifies traffic that is positively some OTHER protocol (HTTP, TLS, SSH), rather than deciding whether -/// a connection is a good Ultima Online client. -/// -/// -/// The direction matters. A legitimate client with encryption enabled when the shard expects none sends a -/// structurally correct connection whose payload is noise, because LoginEncryption.ClientDecrypt is a -/// byte-for-byte stream XOR: length survives, content does not. So "unreadable" cannot mean "hostile", while -/// "speaks HTTP" safely can. Nothing here assumes arrival framing; see -/// dev-docs/ip-bans-and-allowlists.md. -/// -public static class ForeignProtocol -{ - private const int RequiredBytes = 8; - private const int MaxTlsRecordLength = 16384; - - public static ForeignProtocolMatch Identify(ReadOnlySpan buffer, out ForeignProtocolKind kind) - { - kind = ForeignProtocolKind.None; - - // Too little to match a prefix; the caller's own short-read handling covers it. - if (buffer.Length < 4) - { - return ForeignProtocolMatch.None; - } - - var candidate = MatchPrefix(buffer); - if (candidate == ForeignProtocolKind.None) - { - return ForeignProtocolMatch.None; - } - - if (buffer.Length < RequiredBytes) - { - return ForeignProtocolMatch.Incomplete; - } - - if (!Confirm(buffer, candidate)) - { - return ForeignProtocolMatch.None; - } - - kind = candidate; - return ForeignProtocolMatch.Confirmed; - } - - private static ForeignProtocolKind MatchPrefix(ReadOnlySpan buffer) - { - // TLS handshake record: content type 0x16, major version 3, minor version 0..4 (SSL 3.0 - TLS 1.3). - if (buffer[0] == 0x16 && buffer[1] == 0x03 && buffer[2] <= 0x04) - { - return ForeignProtocolKind.Tls; - } - - if (StartsWith(buffer, "SSH-")) - { - return ForeignProtocolKind.Ssh; - } - - // HTTP request methods. Four bytes only selects a candidate; Confirm checks the request line. - if (StartsWith(buffer, "GET ") || StartsWith(buffer, "POST") || StartsWith(buffer, "HEAD") || - StartsWith(buffer, "PUT ") || StartsWith(buffer, "OPTI") || StartsWith(buffer, "DELE") || - StartsWith(buffer, "CONN") || StartsWith(buffer, "TRAC") || StartsWith(buffer, "PATC")) - { - return ForeignProtocolKind.Http; - } - - return ForeignProtocolKind.None; - } - - private static bool Confirm(ReadOnlySpan buffer, ForeignProtocolKind candidate) - { - if (candidate == ForeignProtocolKind.Tls) - { - // A seed can collide with the 0x16 0x03 0x0? prefix (that is just the address 22.3.x.x), so - // require a ClientHello inside a plausible record. - var recordLength = (buffer[3] << 8) | buffer[4]; - return buffer[5] == 0x01 && recordLength is >= 4 and <= MaxTlsRecordLength; - } - - // A UO client's fifth byte is a packet id (0x80, 0x91, 0xEF), none of them printable, so requiring - // the request line to continue in ASCII lets a seed that spells "GET " fall through. - for (var i = 4; i < buffer.Length && i < 16; i++) - { - if (!IsPrintableAscii(buffer[i])) - { - return false; - } - } - - return true; - } - - private static bool IsPrintableAscii(byte value) => - value is >= 0x20 and <= 0x7E or (byte)'\r' or (byte)'\n' or (byte)'\t'; - - private static bool StartsWith(ReadOnlySpan buffer, string ascii) - { - for (var i = 0; i < ascii.Length; i++) - { - if (buffer[i] != (byte)ascii[i]) - { - return false; - } - } - - return true; - } -} diff --git a/Projects/Server/Network/IConnectionFilter.cs b/Projects/Server/Network/IConnectionFilter.cs deleted file mode 100644 index 48e971c85..000000000 --- a/Projects/Server/Network/IConnectionFilter.cs +++ /dev/null @@ -1,60 +0,0 @@ -/************************************************************************* - * ModernUO * - * Copyright 2019-2026 - ModernUO Development Team * - * Email: hi@modernuo.com * - * File: IConnectionFilter.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.Net; -using System.Threading; - -namespace Server.Network; - -/// -/// A gate consulted for every inbound connection, before the socket is configured and before any -/// per-connection allocation. Implementations decide membership only — the accept path neither knows -/// nor cares where a filter's data comes from, so a filter may be a handful of admin-curated entries, -/// a millions-strong list hydrated from a file, or a query against something else entirely. Core owns -/// the question; content owns every answer (see Firewall and BlocklistFilter in UOContent). -/// -/// -/// -/// runs on the game loop once per accepted socket, which is the path that has -/// to survive a DDoS. Implementations MUST be allocation-free and O(log n) at worst, MUST NOT perform -/// I/O, and MUST NOT block. Anything expensive (parsing, reloading, reporting to an external service) -/// belongs off the loop or behind a bounded, non-blocking enqueue. -/// -/// -/// Side effects that a hit implies (contributing to BanChannel, promoting to an OS firewall, -/// suppressing duplicate reports) are the filter's own business, not the accept path's. This is why -/// returns a bare bool: the accept path asks one question and does one thing. -/// -/// -public interface IConnectionFilter -{ - /// Stable id for logging/config (e.g. firewall, blocklist). - string Name { get; } - - /// Reads configuration. Called by . No I/O beyond config. - void Register(); - - /// Starts any background hydration. The token is cancelled on shutdown. - void Start(CancellationToken token); - - /// Flushes and tears down. Called during shutdown. - void Stop(); - - /// - /// True to deny the connection. Must be allocation-free and non-blocking; see the remarks on - /// . - /// - bool ShouldDeny(IPAddress address); -} 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..9bfd39673 100644 --- a/Projects/Server/Network/NetState/NetState.Network.cs +++ b/Projects/Server/Network/NetState/NetState.Network.cs @@ -19,7 +19,6 @@ using System.Linq; using System.Net; using System.Net.NetworkInformation; using System.Network; -using System.Numerics; namespace Server.Network; @@ -29,10 +28,9 @@ namespace Server.Network; public partial class NetState { // Buffer sizes - private const int RecvBufferSize = 1024 * 64; // 64KB recv buffers - private const int DefaultSendBufferSize = 1024 * 256; // 256KB send buffers - private const int MinSendBufferSize = 1024 * 64; // Platform allocation granularity - private const int MaxConnections = 4096; // Max concurrent connections + private const int RecvBufferSize = 1024 * 64; // 64KB recv buffers + private const int SendBufferSize = 1024 * 256; // 256KB send buffers + private const int MaxConnections = 4096; // Max concurrent connections private static readonly Queue _disposed = []; private static readonly TimeSpan ConnectingSocketIdleLimit = TimeSpan.FromMilliseconds(5000); // 5 seconds @@ -43,9 +41,7 @@ public partial class NetState // NetState storage indexed by RingSocket.Id private static readonly NetState[] _netStates = new NetState[MaxConnections]; - // Events buffer for ProcessCompletions. Bounded by one event per peeked completion - // (maxSockets), doubled for headroom. Undersizing drops DataReceived events whose bytes were - // already committed, leaving them unparsed until the next recv completes. + // Events buffer for ProcessCompletions private static readonly RingSocketEvent[] _events = new RingSocketEvent[MaxConnections * 2]; // Listener management @@ -71,24 +67,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. /// @@ -110,61 +88,20 @@ public partial class NetState // Initialize IP rate limiter _ipRateLimiter = new IPRateLimiter(10, 10000, 1000, 2.0, 3_600_000, Core.ClosingTokenSource.Token); - // Sends in flight per connection; honoured by RIO only (see IIORingGroup). Costs a - // request-queue and completion-queue slot per send, not another buffer. Worst-case added - // latency is roughly completion RTT / this value. - var maxOutstandingSends = ServerConfiguration.GetOrUpdateSetting("network.maxOutstandingSends", 32); - // Initialize IORingGroup - var ring = IORingGroup.Create( - queueSize: MaxConnections * 2, - maxConnections: MaxConnections, - maxOutstandingSends: maxOutstandingSends - ); - - // Per-connection send buffer: the lever for "send buffer exhausted" disconnects, and the - // per-connection memory ceiling. - var sendBufferSize = GetSendBufferSize(); + var ring = IORingGroup.Create(queueSize: MaxConnections * 2, maxConnections: MaxConnections); // Create socket manager which handles buffer pools and socket lifecycle _socketManager = new RingSocketManager( ring, maxSockets: MaxConnections, recvBufferSize: RecvBufferSize, - sendBufferSize: sendBufferSize, + sendBufferSize: SendBufferSize, initialBufferSlabs: 8, maxBufferSlabs: 32 ); } - /// - /// Reads the configured send buffer size, coerced to a power of two of at least the platform - /// allocation granularity. IORingBuffer requires this and would otherwise throw at socket - /// creation rather than at startup. - /// - private static int GetSendBufferSize() - { - var configured = ServerConfiguration.GetOrUpdateSetting("network.sendBufferSize", DefaultSendBufferSize); - var size = Math.Max(MinSendBufferSize, configured); - - if (!BitOperations.IsPow2(size)) - { - size = (int)BitOperations.RoundUpToPowerOf2((uint)size); - } - - if (size != configured) - { - logger.Warning( - "network.sendBufferSize {Configured} is not a power of two of at least {Minimum}; using {Adjusted}", - configured, - MinSendBufferSize, - size - ); - } - - return size; - } - /// /// Starts the network server on configured listening addresses. /// @@ -287,19 +224,10 @@ public partial class NetState if (_ipRateLimiter != null && !_ipRateLimiter.Verify(remoteIP, out var totalAttempts)) { logger.Debug("{Address} Past IP limit threshold ({TotalAttempts})", remoteIP, totalAttempts); - - if (Bans.BanConfiguration.Settings.ReportRateLimitTrips) - { - // Enqueue-only contribution; NOT added to the local firewall set (the limiter already - // gates it here and the OS bouncer drops it at the kernel). - Bans.BanChannel.Report(remoteIP, Bans.BanConfiguration.Settings.AutoBanDuration, Bans.BanReasons.RateLimit); - } } - else if (ConnectionFilters.ShouldDeny(remoteIP, out var deniedBy)) + else if (Firewall.IsBlocked(remoteIP)) { - // Whatever a hit implies (persisting, promoting to an OS bouncer, contributing to the - // ban channel) is the filter's own business; the accept path just drops the socket. - logger.Debug("{Address} denied by connection filter '{Filter}'", remoteIP, deniedBy); + logger.Debug("{Address} Firewalled", remoteIP); } else { @@ -380,18 +308,6 @@ public partial class NetState // Socket must have finished the entire authentication process or be forcibly disconnected if (!ns.SentFirstPacket || !ns.Seeded) { - // Only the totally silent ones are evidence. A connection that sent SOME data and ran out of - // time is far more likely a slow link, and banning those makes the player retry, trip the - // rate limiter, and compound it into an hours-long ban. - if (!ns._receivedData && Bans.BanConfiguration.Settings.ReportBadConnects) - { - Bans.BanChannel.Report( - ns.Address, - Bans.BanConfiguration.Settings.BadConnectDuration, - Bans.BanReasons.SilentConnect - ); - } - ns.Disconnect(null); // Force immediate cleanup - these are unauthenticated connections @@ -568,11 +484,6 @@ public partial class NetState return; } - if (bytesReceived > 0) - { - ns._receivedData = true; - } - // Data is already committed to buffer by RingSocketManager // Decode if encryption is enabled ns.DecryptRecvBuffer(bytesReceived); diff --git a/Projects/Server/Network/NetState/NetState.cs b/Projects/Server/Network/NetState/NetState.cs index 04f1a5a76..6c6262ae4 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; @@ -60,10 +60,6 @@ public partial class NetState : IComparable, IValueLinkListNode, IValueLinkListNode, IValueLinkListNode Trades { get; private set; } + public List Trades { get; } public bool Seeded { get; set; } @@ -257,18 +256,8 @@ public partial class NetState : IComparable, IValueLinkListNode= 0; --i) { - if (Trades == null) - { - break; - } - if (i >= Trades.Count) { continue; @@ -287,18 +276,8 @@ public partial class NetState : IComparable, IValueLinkListNode= 0; --i) { - if (Trades != null) - { - break; - } - if (i < Trades.Count) { Trades[i].Cancel(); @@ -308,21 +287,11 @@ public partial class NetState : IComparable, IValueLinkListNode, IValueLinkListNode, IValueLinkListNode, IValueLinkListNode buffer.Length) - { - SendBufferExhausted(span.Length, buffer.Length); - return; } else { @@ -543,31 +484,6 @@ public partial class NetState : IComparable, IValueLinkListNode - /// Handles a packet that cannot be placed in the send buffer. - /// - /// - /// High unacked means a slow client holding the buffer; needed approaching capacity means the - /// buffer is too small for this shard and network.sendBufferSize should be raised. - /// - private void SendBufferExhausted(int needed, int writable) - { - var sendBuffer = _socket?.SendBuffer; - var unacked = sendBuffer?.InFlightBytes ?? 0; - var capacity = sendBuffer?.PhysicalSize ?? 0; - - logger.Warning( - "{NetState}: send buffer exhausted - needed {Needed} bytes, {Writable} writable, {Unacked} awaiting acknowledgement, {Capacity} capacity. Raise network.sendBufferSize (power of two) if this recurs on healthy connections.", - this, - needed, - writable, - unacked, - capacity - ); - - Disconnect($"Send buffer exhausted (needed {needed}, writable {writable}, unacked {unacked}, capacity {capacity})"); - } - private void StartPacketLog() { try @@ -661,37 +577,6 @@ public partial class NetState : IComparable, IValueLinkListNode, IValueLinkListNode, IValueLinkListNode, IValueLinkListNode input, Span output) { - // output.Length < 4 underflows safeOutputLength below (nuint), defeating the hot loop's - // bounds check. Reachable whenever the send buffer is nearly full. - if (input.Length > DefiniteOverflow || output.Length < 4) + if (input.Length > DefiniteOverflow) { return 0; } diff --git a/Projects/Server/PropertyList/ObjectPropertyList.cs b/Projects/Server/PropertyList/ObjectPropertyList.cs index 58b247990..73288f5e1 100644 --- a/Projects/Server/PropertyList/ObjectPropertyList.cs +++ b/Projects/Server/PropertyList/ObjectPropertyList.cs @@ -55,12 +55,6 @@ public sealed class ObjectPropertyList : IPropertyList, IDisposable private int _pos; private char[]? _arrayToReturnToPool; - /// - /// True while GetProperties is populating this list. Set by the owning entity so a nested - /// InvalidateProperties can be refused instead of Reset()ing a build already in flight. - /// - internal bool IsBuilding { get; set; } - public ObjectPropertyList(IEntity? e) { Entity = e; @@ -325,23 +319,8 @@ public sealed class ObjectPropertyList : IPropertyList, IDisposable private static int GetDefaultLength(int literalLength, int formattedCount) => Math.Max(256, literalLength + formattedCount * 11); - // Reset()/Dispose() return the scratch buffer to the pool. If either lands while a `$"..."` - // handler is still appending, re-rent rather than spanning a null array and throwing out of - // GetProperties. Mobile/Item hold the primary guard; this covers any other caller. - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private void EnsureInterpolationBuffer() - { - if (_arrayToReturnToPool == null) - { - _arrayToReturnToPool = STArrayPool.Shared.Rent(256); - _pos = 0; - } - } - public void AppendLiteral(string value) { - EnsureInterpolationBuffer(); - if (value.Length == 1) { var chars = _arrayToReturnToPool.AsSpan(); @@ -375,8 +354,6 @@ public sealed class ObjectPropertyList : IPropertyList, IDisposable public void AppendFormatted(T value) { - EnsureInterpolationBuffer(); - string? s; if (value is IFormattable) { @@ -407,11 +384,9 @@ public sealed class ObjectPropertyList : IPropertyList, IDisposable public void AppendFormatted(T value, string? format) { - EnsureInterpolationBuffer(); - - // '#' marks an integer argument as a cliloc ("#"). Integers only -- a float/double/decimal - // '#' is the standard numeric format, not a cliloc marker. - if (format == "#" && value is int or uint or long or ulong or short or ushort or byte or sbyte) + // We support localization '#' cliloc formatter for custom property lists + // This allows someone to build an IPropertyList that creates HTML using the same syntax as LocalizationInterpolationHandler + if (format == "#") { AppendLiteral("#"); format = null; @@ -467,8 +442,6 @@ public sealed class ObjectPropertyList : IPropertyList, IDisposable public void AppendFormatted(ReadOnlySpan value) { - EnsureInterpolationBuffer(); - if (value.TryCopyTo(_arrayToReturnToPool.AsSpan(_pos..))) { _pos += value.Length; @@ -481,8 +454,6 @@ public sealed class ObjectPropertyList : IPropertyList, IDisposable public void AppendFormatted(ReadOnlySpan value, int alignment = 0, string? format = null) { - EnsureInterpolationBuffer(); - var leftAlign = false; if (alignment < 0) { @@ -517,8 +488,6 @@ public sealed class ObjectPropertyList : IPropertyList, IDisposable public void AppendFormatted(string? value) { - EnsureInterpolationBuffer(); - if (value?.TryCopyTo(_arrayToReturnToPool.AsSpan(_pos..)) == true) { _pos += value.Length; diff --git a/Projects/Server/Serialization/BinaryFileReader.cs b/Projects/Server/Serialization/BinaryFileReader.cs index f8b51c182..ec76516aa 100644 --- a/Projects/Server/Serialization/BinaryFileReader.cs +++ b/Projects/Server/Serialization/BinaryFileReader.cs @@ -74,12 +74,6 @@ public sealed unsafe class BinaryFileReader : IDisposable, IGenericReader /// 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..ff9b3a30b 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/IPAddressUtility.cs b/Projects/Server/Utilities/IPAddressUtility.cs deleted file mode 100644 index 12f81e516..000000000 --- a/Projects/Server/Utilities/IPAddressUtility.cs +++ /dev/null @@ -1,240 +0,0 @@ -/************************************************************************* - * ModernUO * - * Copyright 2019-2026 - ModernUO Development Team * - * Email: hi@modernuo.com * - * File: IPAddressUtility.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.Buffers.Binary; -using System.Net; -using System.Net.Sockets; -using System.Numerics; - -namespace Server; - -/// -/// Low-level IPAddress conversion and parsing helpers shared by the firewall, ban channel, and -/// blocklist. All members are allocation-free (stack buffers only) so they are safe on hot accept -/// paths and inside tight parse loops. -/// -public static class IPAddressUtility -{ - // Converts an IPAddress to a UInt128 in IPv6 format. - // The IsIPv4MappedToIPv6 clause below looks redundant (the BCL only ever sets it on InterNetworkV6), - // but it guards the v4 -> UInt128 -> IPAddress round-trip, which can return a mapped v6 address for - // what is really a v4 one. - //TODO Rework as an explicit "to canonical v6 bits" step that needs no family check - // (see dev-docs/networking-packets.md, "IP Address Normalization") - public static UInt128 ToUInt128(this IPAddress ip) - { - if (ip.AddressFamily == AddressFamily.InterNetwork && !ip.IsIPv4MappedToIPv6) - { - Span integer = stackalloc byte[4]; - return !ip.TryWriteBytes(integer, out _) - ? (UInt128)0 - : new UInt128(0, 0xFFFF00000000UL | BinaryPrimitives.ReadUInt32BigEndian(integer)); - } - - Span bytes = stackalloc byte[16]; - if (!ip.TryWriteBytes(bytes, out _)) - { - return 0; - } - - var high = BinaryPrimitives.ReadUInt64BigEndian(bytes[..8]); - var low = BinaryPrimitives.ReadUInt64BigEndian(bytes.Slice(8, 8)); - - return new UInt128(high, low); - } - - // Converts a UInt128 in IPv6 format to an IPAddress - public static IPAddress ToIpAddress(this UInt128 value, bool mapToIpv6 = false) - { - // IPv4 mapped IPv6 address - if (!mapToIpv6 && value >= 0xFFFF00000000UL && value <= 0xFFFFFFFFFFFFUL) - { - var newAddress = IPAddress.HostToNetworkOrder((int)value); - return new IPAddress(unchecked((uint)newAddress)); - } - - Span bytes = stackalloc byte[16]; // 128 bits for IPv6 address - ((IBinaryInteger)value).WriteBigEndian(bytes); - - return new IPAddress(bytes); - } - - /// - /// Parses a.b.c.d/n, ::/n, or a bare address (treated as a single-host range) into an - /// inclusive range in normalized IPv6 form. A bare IPv4 prefix is widened by 96 - /// bits so v4 and v6 ranges are directly comparable. Returns false on anything malformed. - /// - public static bool TryParseCidrRange(ReadOnlySpan cidr, out UInt128 min, out UInt128 max) - { - min = default; - max = default; - - var slash = cidr.IndexOf('/'); - if (!IPAddress.TryParse(slash >= 0 ? cidr[..slash] : cidr, out var ip)) - { - return false; - } - - var isV6 = ip.AddressFamily == AddressFamily.InterNetworkV6; - var maxPrefixLength = isV6 ? 128 : 32; - int prefixLength; - - if (slash < 0) - { - prefixLength = maxPrefixLength; - } - else if (!int.TryParse(cidr[(slash + 1)..], out prefixLength) || - prefixLength < 0 || prefixLength > maxPrefixLength) - { - return false; - } - - if (!isV6) - { - prefixLength += 96; // 32 -> 128 - } - - Span bytes = stackalloc byte[16]; - ip.WriteMappedIPv6To(bytes); - - min = Utility.CreateCidrAddress(bytes, prefixLength, false); - max = Utility.CreateCidrAddress(bytes, prefixLength, true); - return true; - } - - /// Extracts the big-endian uint of an address. - public static bool TryV4(IPAddress ip, out uint v) - { - Span b = stackalloc byte[4]; - if (ip.TryWriteBytes(b, out var n) && n == 4) - { - v = ((uint)b[0] << 24) | ((uint)b[1] << 16) | ((uint)b[2] << 8) | b[3]; - return true; - } - - v = 0; - return false; - } - - /// - /// Extracts the embedded v4 uint from a v4-mapped-v6 address directly from the mapped bytes, - /// avoiding the allocation of . - /// - public static bool TryMappedV4(IPAddress ip, out uint v) - { - Span b = stackalloc byte[16]; - if (ip.TryWriteBytes(b, out var n) && n == 16) - { - v = ((uint)b[12] << 24) | ((uint)b[13] << 16) | ((uint)b[14] << 8) | b[15]; - return true; - } - - v = 0; - return false; - } - - /// Parses a dotted-quad IPv4 literal into a big-endian uint. Allocation-free, strict. - public static bool TryParseV4(ReadOnlySpan s, out uint v) - { - v = 0; - uint acc = 0; - int octet = 0, digits = 0, dots = 0; - for (var i = 0; i < s.Length; i++) - { - var c = s[i]; - if (c == '.') - { - if (digits == 0 || octet > 255) - { - return false; - } - - acc = (acc << 8) | (uint)octet; - dots++; - octet = 0; - digits = 0; - } - else if (c is >= '0' and <= '9') - { - octet = octet * 10 + (c - '0'); - if (++digits > 3) - { - return false; - } - } - else - { - return false; - } - } - - if (dots != 3 || digits == 0 || octet > 255) - { - return false; - } - - v = (acc << 8) | (uint)octet; - return true; - } - - /// - /// UTF-8/ASCII byte overload of , mirroring its - /// validation exactly so the blocklist can parse dotted-quads straight from file bytes with no - /// per-line string allocation. - /// - public static bool TryParseV4(ReadOnlySpan s, out uint v) - { - v = 0; - uint acc = 0; - int octet = 0, digits = 0, dots = 0; - for (var i = 0; i < s.Length; i++) - { - var c = s[i]; - if (c == (byte)'.') - { - if (digits == 0 || octet > 255) - { - return false; - } - - acc = (acc << 8) | (uint)octet; - dots++; - octet = 0; - digits = 0; - } - else if (c is >= (byte)'0' and <= (byte)'9') - { - octet = octet * 10 + (c - '0'); - if (++digits > 3) - { - return false; - } - } - else - { - return false; - } - } - - if (dots != 3 || digits == 0 || octet > 255) - { - return false; - } - - v = (acc << 8) | (uint)octet; - return true; - } -} diff --git a/Projects/Server/Utilities/NetworkUtilities.cs b/Projects/Server/Utilities/NetworkUtilities.cs index deeffb66f..7d54ddf5e 100644 --- a/Projects/Server/Utilities/NetworkUtilities.cs +++ b/Projects/Server/Utilities/NetworkUtilities.cs @@ -1,7 +1,6 @@ -using System; using System.Net; using System.Net.Sockets; -using Server.Collections; +using Server.Network; namespace Server; @@ -15,42 +14,36 @@ public static class NetworkUtilities _ => false }; - // These are constant reserved ranges, not firewall entries -- they only ever answer "is this address - // in one of these blocks?", which is exactly what SortedRangeIndex is for. Building them through the - // firewall entry types was a convenience that made core depend on the firewall for something that has - // nothing to do with banning. - private static readonly SortedRangeIndex _privateNetworkV4 = BuildIndex( - "127.0.0.1/8", - "192.168.0.0/16", - "10.0.0.0/8", - "172.16.0.0/12", - "169.254.0.0/16", - "100.64.0.0/10" - ); + private static readonly IFirewallEntry[] _privateNetworkV4 = + [ + new CidrFirewallEntry("127.0.0.1/8"), + new CidrFirewallEntry("192.168.0.0/16"), + new CidrFirewallEntry("10.0.0.0/8"), + new CidrFirewallEntry("172.16.0.0/12"), + new CidrFirewallEntry("169.254.0.0/16"), + new CidrFirewallEntry("100.64.0.0/10") + ]; - private static readonly SortedRangeIndex _privateNetworkV6 = BuildIndex( - "fc00::/7", - "fe80::/10" - ); + private static readonly IFirewallEntry[] _privateNetworkV6 = + [ + new CidrFirewallEntry("fc00::/7"), + new CidrFirewallEntry("fe80::/10") + ]; - private static SortedRangeIndex BuildIndex(params ReadOnlySpan cidrs) + public static bool IsPrivateNetworkV4(this IPAddress ip) { - var ranges = new SortedRangeIndex.Range[cidrs.Length]; - for (var i = 0; i < cidrs.Length; i++) + for (var i = 0; i < _privateNetworkV4.Length; i++) { - if (!IPAddressUtility.TryParseCidrRange(cidrs[i], out var min, out var max)) + if (_privateNetworkV4[i].IsBlocked(ip)) { - throw new ArgumentException($"Invalid reserved-network CIDR \"{cidrs[i]}\""); + return true; } - - ranges[i] = new SortedRangeIndex.Range(min, max); } - Array.Sort(ranges, SortedRangeIndex.ByMin); - return SortedRangeIndex.Build(ranges); + return false; } - public static bool IsPrivateNetworkV4(this IPAddress ip) => _privateNetworkV4.Contains(ip.ToUInt128()); - - public static bool IsPrivateNetworkV6(this IPAddress ip) => _privateNetworkV6.Contains(ip.ToUInt128()); + public static bool IsPrivateNetworkV6(this IPAddress ip) => + _privateNetworkV6[0].IsBlocked(ip) || + _privateNetworkV6[1].IsBlocked(ip); } diff --git a/Projects/Server/Utilities/Utility.cs b/Projects/Server/Utilities/Utility.cs index 9d7cd9f3b..4f33220aa 100644 --- a/Projects/Server/Utilities/Utility.cs +++ b/Projects/Server/Utilities/Utility.cs @@ -108,6 +108,45 @@ public static partial class Utility } } + // Converts an IPAddress to a UInt128 in IPv6 format + public static UInt128 ToUInt128(this IPAddress ip) + { + if (ip.AddressFamily == AddressFamily.InterNetwork && !ip.IsIPv4MappedToIPv6) + { + Span integer = stackalloc byte[4]; + return !ip.TryWriteBytes(integer, out _) + ? (UInt128)0 + : new UInt128(0, 0xFFFF00000000UL | BinaryPrimitives.ReadUInt32BigEndian(integer)); + } + + Span bytes = stackalloc byte[16]; + if (!ip.TryWriteBytes(bytes, out _)) + { + return 0; + } + + var high = BinaryPrimitives.ReadUInt64BigEndian(bytes[..8]); + var low = BinaryPrimitives.ReadUInt64BigEndian(bytes.Slice(8, 8)); + + return new UInt128(high, low); + } + + // Converts a UInt128 in IPv6 format to an IPAddress + public static IPAddress ToIpAddress(this UInt128 value, bool mapToIpv6 = false) + { + // IPv4 mapped IPv6 address + if (!mapToIpv6 && value >= 0xFFFF00000000UL && value <= 0xFFFFFFFFFFFFUL) + { + var newAddress = IPAddress.HostToNetworkOrder((int)value); + return new IPAddress(unchecked((uint)newAddress)); + } + + Span bytes = stackalloc byte[16]; // 128 bits for IPv6 address + ((IBinaryInteger)value).WriteBigEndian(bytes); + + return new IPAddress(bytes); + } + [MethodImpl(MethodImplOptions.AggressiveInlining)] public static UInt128 CreateCidrAddress(ReadOnlySpan bytes, int prefixLength, bool isMax) { @@ -1050,16 +1089,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..076f88141 100644 --- a/Projects/UOContent.Tests/Fixtures/TestServerInitializer.cs +++ b/Projects/UOContent.Tests/Fixtures/TestServerInitializer.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.IO; using System.Reflection; using System.Threading; @@ -61,15 +61,6 @@ internal static class TestServerInitializer AssemblyHandler.LoadAssemblies(["Server.dll", "UOContent.dll"]); SkillsInfo.Configure(); - - // Seed the loop clock as Main.cs does before the Configure sweep; otherwise Core.Now is - // DateTime.MinValue for the whole test host. - Core._now = DateTime.UtcNow; - - // Timer wheel must exist before NetState.Configure(), which schedules a recurring - // sweep via Timer.DelayCall (matches production ordering in Main.cs: Timer.Init runs - // before AssemblyHandler.Invoke("Configure")). - Timer.Init(0); Server.Network.NetState.Configure(); TestMapDefinitions.ConfigureTestMapDefinitions(); @@ -100,10 +91,8 @@ internal static class TestServerInitializer } World.Configure(); - // Registers the Accounts entity persistence; without it no test can construct an Account. - Server.Accounting.Accounts.Configure(); + Timer.Init(0); 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/Commands/Objects/CagTreeBuilderTests.cs b/Projects/UOContent.Tests/Tests/Commands/Objects/CagTreeBuilderTests.cs new file mode 100644 index 000000000..4716dd239 --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Commands/Objects/CagTreeBuilderTests.cs @@ -0,0 +1,80 @@ +using System.Linq; +using Server.Commands; +using Server.Items; +using Xunit; + +namespace UOContent.Tests.Commands.Objects; + +[Collection("Sequential UOContent Tests")] +public class CagTreeBuilderTests +{ + [Fact] + public void BuildTree_creates_nested_categories_and_resolves_types() + { + var index = new ObjectIndexFile + { + Objects = + [ + new ObjectIndexEntry + { + Type = "Katana", Entity = "item", Category = "Items.Weapons.Swords", + Chunk = "items.weapons.swords", ItemID = 0x13FF, Hue = 0 + } + ] + }; + + var root = CAGLoader.BuildTree(index); + + var items = Assert.IsType(FindChild(root, "Items")); + var weapons = Assert.IsType(FindChild(items, "Weapons")); + var swords = Assert.IsType(FindChild(weapons, "Swords")); + + var leaf = Assert.IsType(swords.Nodes[0]); + Assert.Equal(typeof(Katana), leaf.Type); + Assert.Equal(0x13FF, leaf.ItemID); + } + + [Fact] + public void BuildTree_keeps_multiple_objects_in_one_category() + { + var index = new ObjectIndexFile + { + Objects = + [ + new ObjectIndexEntry { Type = "Katana", Entity = "item", Category = "Items.Weapons.Swords", Chunk = "items.weapons.swords", ItemID = 0x13FF, Hue = 0 }, + new ObjectIndexEntry { Type = "Longsword", Entity = "item", Category = "Items.Weapons.Swords", Chunk = "items.weapons.swords", ItemID = 0x0F5E, Hue = 0 } + ] + }; + + var root = CAGLoader.BuildTree(index); + var swords = (CAGCategory)FindNestedCategory(root, "Items", "Weapons", "Swords"); + + var leafTypes = swords.Nodes.OfType().Select(o => o.Type).ToList(); + Assert.Contains(typeof(Katana), leafTypes); + Assert.Contains(typeof(Server.Items.Longsword), leafTypes); + } + + private static CAGNode FindChild(CAGCategory parent, string title) + { + foreach (var node in parent.Nodes) + { + if (node.Title == title) + { + return node; + } + } + + throw new Xunit.Sdk.XunitException($"No child '{title}'."); + } + + private static CAGNode FindNestedCategory(CAGCategory root, params string[] titles) + { + CAGNode current = root; + foreach (var title in titles) + { + current = FindChild((CAGCategory)current, title); + } + + return current; + } +} diff --git a/Projects/UOContent.Tests/Tests/Commands/Objects/CategorizationSyncTests.cs b/Projects/UOContent.Tests/Tests/Commands/Objects/CategorizationSyncTests.cs new file mode 100644 index 000000000..8fb4a225d --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Commands/Objects/CategorizationSyncTests.cs @@ -0,0 +1,44 @@ +using System.Collections.Generic; +using System.Linq; +using Server.Commands; +using Server.Items; +using Xunit; + +namespace UOContent.Tests.Commands.Objects; + +public class CategorizationSyncTests +{ + private static CAGJson Cat(string category, params System.Type[] types) => + new() + { + Category = category, + Objects = types.Select(t => new CAGObject { Type = t }).ToArray() + }; + + [Fact] + public void Reconcile_appends_missing_types_to_uncategorized() + { + var categorization = new List { Cat("Items.Weapons.Swords", typeof(Katana)) }; + var discovered = new List { typeof(Katana), typeof(Runebook) }; + + var (updated, report) = CategorizationSync.Reconcile(categorization, discovered); + + Assert.Contains("Runebook", report.Appended); + Assert.Empty(report.Orphaned); + + var uncategorized = Assert.Single(updated, c => c.Category == "Items.Uncategorized"); + Assert.Contains(uncategorized.Objects, o => o.Type == typeof(Runebook)); + } + + [Fact] + public void Reconcile_reports_orphans_not_in_discovered() + { + var categorization = new List { Cat("Items.Weapons.Swords", typeof(Katana)) }; + var discovered = new List { typeof(Runebook) }; + + var (_, report) = CategorizationSync.Reconcile(categorization, discovered); + + Assert.Contains("Katana", report.Orphaned); + Assert.Contains("Runebook", report.Appended); + } +} diff --git a/Projects/UOContent.Tests/Tests/Commands/Objects/ObjectCacheBuilderTests.cs b/Projects/UOContent.Tests/Tests/Commands/Objects/ObjectCacheBuilderTests.cs new file mode 100644 index 000000000..860822597 --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Commands/Objects/ObjectCacheBuilderTests.cs @@ -0,0 +1,35 @@ +using System.Collections.Generic; +using Server.Commands; +using Server.Items; +using Xunit; + +namespace UOContent.Tests.Commands.Objects; + +public class ObjectCacheBuilderTests +{ + [Fact] + public void Build_produces_index_row_and_detail_chunk() + { + var extracted = new ExtractedObject( + typeof(Runebook), + "item", + "Items.Skill Items.Magical", + new LeanMetadata(8901, 0x461, "runebook", 1041267), + [new CtorDoc()], + [new PropertyDoc { Name = "Hue", Type = "int" }], + [new OplLine { Cliloc = 1041267 }], + "Item" + ); + + var (index, chunks) = ObjectCacheBuilder.Build([extracted], "2026-07-19T00:00:00Z"); + + var row = Assert.Single(index.Objects); + Assert.Equal("Runebook", row.Type); + Assert.Equal("items.skill-items.magical", row.Chunk); + Assert.Equal(8901, row.ItemID); + + var chunk = Assert.Contains("items.skill-items.magical", chunks); + Assert.Contains("Runebook", chunk.Keys); + Assert.Equal("Item", chunk["Runebook"].BaseType); + } +} diff --git a/Projects/UOContent.Tests/Tests/Commands/Objects/ObjectCacheGeneratorTests.cs b/Projects/UOContent.Tests/Tests/Commands/Objects/ObjectCacheGeneratorTests.cs new file mode 100644 index 000000000..96521bdfd --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Commands/Objects/ObjectCacheGeneratorTests.cs @@ -0,0 +1,32 @@ +using System; +using System.Collections.Generic; +using Server.Commands; +using Server.Items; +using Xunit; + +namespace UOContent.Tests.Commands.Objects; + +[Collection("Sequential UOContent Tests")] +public class ObjectCacheGeneratorTests +{ + [Fact] + public void Generate_builds_index_and_chunks_from_extracted_objects() + { + var discovered = new List { typeof(Runebook), typeof(Katana) }; + var categorization = new List(); // empty -> both land in Items.Uncategorized + + var result = ObjectCacheGenerator.Generate(categorization, discovered); + + Assert.Equal(2, result.Index.Objects.Count); + Assert.NotEmpty(result.Chunks); + + var runebook = Assert.Single(result.Index.Objects, o => o.Type == "Runebook"); + Assert.True(runebook.ItemID > 0); + Assert.Equal(1041267, runebook.Cliloc); + Assert.Equal("items.uncategorized", runebook.Chunk); + + Assert.Contains("Runebook", result.Report.Appended); + Assert.Contains("items.uncategorized", result.Chunks.Keys); + Assert.Contains("Runebook", result.Chunks["items.uncategorized"].Keys); + } +} diff --git a/Projects/UOContent.Tests/Tests/Commands/Objects/ObjectDiscoveryTests.cs b/Projects/UOContent.Tests/Tests/Commands/Objects/ObjectDiscoveryTests.cs new file mode 100644 index 000000000..d36f8bb8a --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Commands/Objects/ObjectDiscoveryTests.cs @@ -0,0 +1,19 @@ +using Server.Commands; +using Server.Items; +using Xunit; + +namespace UOContent.Tests.Commands.Objects; + +[Collection("Sequential UOContent Tests")] +public class ObjectDiscoveryTests +{ + [Fact] + public void Discover_includes_concrete_constructibles_and_excludes_abstract() + { + var types = ObjectIntrospection.DiscoverConstructibleTypes(); + + Assert.Contains(typeof(Katana), types); + Assert.Contains(typeof(Runebook), types); + Assert.DoesNotContain(typeof(BaseWeapon), types); // abstract + } +} diff --git a/Projects/UOContent.Tests/Tests/Commands/Objects/ObjectIndexSerializationTests.cs b/Projects/UOContent.Tests/Tests/Commands/Objects/ObjectIndexSerializationTests.cs new file mode 100644 index 000000000..268ee13e0 --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Commands/Objects/ObjectIndexSerializationTests.cs @@ -0,0 +1,52 @@ +using System.IO; +using Server.Commands; +using Server.Json; +using Xunit; + +namespace UOContent.Tests.Commands.Objects; + +public class ObjectIndexSerializationTests +{ + [Fact] + public void ObjectIndexFile_round_trips_through_json() + { + var index = new ObjectIndexFile + { + GeneratedUtc = "2026-07-19T00:00:00Z", + Objects = + [ + new ObjectIndexEntry + { + Type = "Katana", + Entity = "item", + Category = "Items.Weapons.Swords", + Chunk = "items.weapons.swords", + ItemID = 8901, + Hue = 0x461, + Name = "katana", + Cliloc = 1041267 + } + ] + }; + + var tempPath = Path.GetTempFileName(); + try + { + JsonConfig.Serialize(tempPath, index); + var roundTripped = JsonConfig.Deserialize(tempPath); + + Assert.NotNull(roundTripped); + var entry = Assert.Single(roundTripped.Objects); + + Assert.Equal("Katana", entry.Type); + Assert.Equal("items.weapons.swords", entry.Chunk); + Assert.Equal(8901, entry.ItemID); + Assert.Equal(0x461, entry.Hue); + Assert.Equal(1041267, entry.Cliloc); + } + finally + { + File.Delete(tempPath); + } + } +} diff --git a/Projects/UOContent.Tests/Tests/Commands/Objects/ObjectIntrospectionCtorsTests.cs b/Projects/UOContent.Tests/Tests/Commands/Objects/ObjectIntrospectionCtorsTests.cs new file mode 100644 index 000000000..6c35df2f8 --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Commands/Objects/ObjectIntrospectionCtorsTests.cs @@ -0,0 +1,23 @@ +using Server.Commands; +using Server.Items; +using Xunit; + +namespace UOContent.Tests.Commands.Objects; + +[Collection("Sequential UOContent Tests")] +public class ObjectIntrospectionCtorsTests +{ + [Fact] + public void ExtractCtors_lists_both_constructible_runebook_overloads() + { + // Runebook has [Constructible] Runebook() and [Constructible] Runebook(int maxCharges). + var ctors = ObjectIntrospection.ExtractCtors(typeof(Runebook)); + + Assert.Equal(2, ctors.Count); + Assert.Contains(ctors, c => c.Parameters.Count == 0); + + var parameterized = Assert.Single(ctors, c => c.Parameters.Count == 1); + Assert.Equal("maxCharges", parameterized.Parameters[0].Name); + Assert.Equal("int", parameterized.Parameters[0].Type); + } +} diff --git a/Projects/UOContent.Tests/Tests/Commands/Objects/ObjectIntrospectionLeanTests.cs b/Projects/UOContent.Tests/Tests/Commands/Objects/ObjectIntrospectionLeanTests.cs new file mode 100644 index 000000000..5ddb65278 --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Commands/Objects/ObjectIntrospectionLeanTests.cs @@ -0,0 +1,31 @@ +using Server.Commands; +using Server.Items; +using Server.Tests; +using Xunit; + +namespace UOContent.Tests.Commands.Objects; + +[Collection("Sequential UOContent Tests")] +public class ObjectIntrospectionLeanTests +{ + [SkippableFact] + public void ExtractLean_reads_item_id_from_a_weapon() + { + // Requires client TileData: ExtractLean clamps itemID > TileData.MaxItemValue to 1, and + // MaxItemValue is 0 when tiledata.mul is absent (CI), so the real 0x13FF only survives with data. + TileDataRequirement.SkipIfMissing(); + + // Katana ctor is base(0x13FF) — era-independent. + var lean = ObjectIntrospection.ExtractLean(typeof(Katana)); + Assert.Equal(0x13FF, lean.ItemID); + } + + [Fact] + public void ExtractLean_reads_hue_and_cliloc_from_a_runebook() + { + // Runebook sets Hue = 0x461 and LabelNumber 1041267 regardless of era. + var lean = ObjectIntrospection.ExtractLean(typeof(Runebook)); + Assert.Equal(0x461, lean.Hue); + Assert.Equal(1041267, lean.Cliloc); + } +} diff --git a/Projects/UOContent.Tests/Tests/Commands/Objects/ObjectIntrospectionOplTests.cs b/Projects/UOContent.Tests/Tests/Commands/Objects/ObjectIntrospectionOplTests.cs new file mode 100644 index 000000000..b360a2726 --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Commands/Objects/ObjectIntrospectionOplTests.cs @@ -0,0 +1,17 @@ +using Server.Commands; +using Server.Items; +using Xunit; + +namespace UOContent.Tests.Commands.Objects; + +[Collection("Sequential UOContent Tests")] +public class ObjectIntrospectionOplTests +{ + [Fact] + public void ExtractOpl_captures_the_runebook_name_cliloc() + { + // Runebook.LabelNumber is 1041267 ("runebook") — it appears as an OPL line. + var opl = ObjectIntrospection.ExtractOpl(typeof(Runebook)); + Assert.Contains(opl, line => line.Cliloc == 1041267); + } +} diff --git a/Projects/UOContent.Tests/Tests/Commands/Objects/ObjectIntrospectionPropertiesTests.cs b/Projects/UOContent.Tests/Tests/Commands/Objects/ObjectIntrospectionPropertiesTests.cs new file mode 100644 index 000000000..1eba2a5bb --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Commands/Objects/ObjectIntrospectionPropertiesTests.cs @@ -0,0 +1,22 @@ +using Server.Commands; +using Server.Items; +using Xunit; + +namespace UOContent.Tests.Commands.Objects; + +[Collection("Sequential UOContent Tests")] +public class ObjectIntrospectionPropertiesTests +{ + [Fact] + public void ExtractProperties_includes_inherited_item_command_properties() + { + var props = ObjectIntrospection.ExtractProperties(typeof(Runebook)); + + var hue = Assert.Single(props, p => p.Name == "Hue"); + Assert.Equal("int", hue.Type); + + var lootType = Assert.Single(props, p => p.Name == "LootType"); + Assert.NotNull(lootType.EnumValues); + Assert.Contains("Blessed", lootType.EnumValues); + } +} diff --git a/Projects/UOContent.Tests/Tests/Commands/Objects/ObjectNamingTests.cs b/Projects/UOContent.Tests/Tests/Commands/Objects/ObjectNamingTests.cs new file mode 100644 index 000000000..f24216df8 --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Commands/Objects/ObjectNamingTests.cs @@ -0,0 +1,29 @@ +using System; +using Server.Commands; +using Xunit; + +namespace UOContent.Tests.Commands.Objects; + +public class ObjectNamingTests +{ + [Theory] + [InlineData("Items.Skill Items.Magical", "items.skill-items.magical")] + [InlineData("Items.Weapons.Swords", "items.weapons.swords")] + [InlineData("Mobiles.Uncategorized", "mobiles.uncategorized")] + public void ChunkKey_lowercases_and_replaces_spaces(string category, string expected) + { + Assert.Equal(expected, ObjectNaming.ChunkKey(category)); + } + + [Theory] + [InlineData(typeof(int), "int")] + [InlineData(typeof(bool), "bool")] + [InlineData(typeof(string), "string")] + [InlineData(typeof(double), "double")] + [InlineData(typeof(int?), "int?")] + [InlineData(typeof(Server.Items.WeaponQuality), "WeaponQuality")] + public void FriendlyTypeName_maps_primitives_and_keeps_enum_names(Type t, string expected) + { + Assert.Equal(expected, ObjectNaming.FriendlyTypeName(t)); + } +} diff --git a/Projects/UOContent.Tests/Tests/Engines/AdvancedSearch/AdvancedSearchPagingTests.cs b/Projects/UOContent.Tests/Tests/Engines/AdvancedSearch/AdvancedSearchPagingTests.cs deleted file mode 100644 index 5166124e5..000000000 --- a/Projects/UOContent.Tests/Tests/Engines/AdvancedSearch/AdvancedSearchPagingTests.cs +++ /dev/null @@ -1,17 +0,0 @@ -using Server.Engines.AdvancedSearch; -using Xunit; - -namespace UOContent.Tests; - -public class AdvancedSearchPagingTests -{ - [Theory] - [InlineData(20, 0, 18, 18)] // full first page - [InlineData(20, 18, 18, 2)] // partial last page -> 2 visible (bug rendered 0 in descending) - [InlineData(5, 0, 18, 5)] - [InlineData(0, 0, 18, 0)] - public void VisibleCount_IsCorrect(int total, int from, int max, int expected) - { - Assert.Equal(expected, AdvancedSearchGump.VisibleCount(total, from, max)); - } -} diff --git a/Projects/UOContent.Tests/Tests/Engines/AdvancedSearch/AdvancedSearchTypesTests.cs b/Projects/UOContent.Tests/Tests/Engines/AdvancedSearch/AdvancedSearchTypesTests.cs deleted file mode 100644 index ea14f3cf2..000000000 --- a/Projects/UOContent.Tests/Tests/Engines/AdvancedSearch/AdvancedSearchTypesTests.cs +++ /dev/null @@ -1,27 +0,0 @@ -using Server; -using Server.Engines.AdvancedSearch; -using Xunit; - -namespace UOContent.Tests; - -[Collection("Sequential UOContent Tests")] -public class AdvancedSearchTypesTests -{ - [Fact] - public void CompareValues_Poison_ReferenceTypeParsedViaTypes() - { - PoisonKinds.Configure(); // idempotent; registers Lesser..Lethal now that Core.Expansion is set - - // Poison is a reference type implementing ISpanParsable; it can't use the compile-time span - // path and routes through the shared Server.Types converter. Poison.Parse returns the - // registered singleton, so "= Lethal" is a reference-equality match — this is the case that - // previously compared a Poison against the raw string and always failed. - var prop = Poison.Lethal; - Assert.True(AdvancedSearchUtilities.CompareValues(typeof(Poison), prop, "Lethal", "=")); - Assert.False(AdvancedSearchUtilities.CompareValues(typeof(Poison), prop, "Lesser", "=")); - - var ex = Record.Exception(() => - Assert.False(AdvancedSearchUtilities.CompareValues(typeof(Poison), prop, "notapoison", "="))); - Assert.Null(ex); - } -} diff --git a/Projects/UOContent.Tests/Tests/Engines/AdvancedSearch/AdvancedSearchUtilitiesTests.cs b/Projects/UOContent.Tests/Tests/Engines/AdvancedSearch/AdvancedSearchUtilitiesTests.cs deleted file mode 100644 index 9176fae4a..000000000 --- a/Projects/UOContent.Tests/Tests/Engines/AdvancedSearch/AdvancedSearchUtilitiesTests.cs +++ /dev/null @@ -1,119 +0,0 @@ -using System; -using Server; -using Server.Engines.AdvancedSearch; -using Xunit; - -namespace UOContent.Tests; - -public class AdvancedSearchUtilitiesTests -{ - [Theory] - [InlineData("abc")] // not a number -> was FormatException - [InlineData("99999999999")] // overflows int -> was OverflowException - [InlineData("0xZZ")] // bad hex -> was FormatException - public void CompareValues_BadNumeric_ReturnsFalse_DoesNotThrow(string value) - { - var ex = Record.Exception(() => - { - var result = AdvancedSearchUtilities.CompareValues(typeof(int), 5, value, ">"); - Assert.False(result); - }); - Assert.Null(ex); - } - - [Theory] - [InlineData("Bogus")] // not a member -> was ArgumentException - [InlineData("onehandedxyz")] // not a member, even case-insensitively -> was ArgumentException - public void CompareValues_BadEnum_ReturnsFalse_DoesNotThrow(string value) - { - var ex = Record.Exception(() => - { - var result = AdvancedSearchUtilities.CompareValues(typeof(Layer), (byte)Layer.OneHanded, value, "="); - Assert.False(result); - }); - Assert.Null(ex); - } - - [Fact] - public void CompareValues_ValidEnum_IgnoresCase() - { - Assert.True(AdvancedSearchUtilities.CompareValues(typeof(Layer), (byte)Layer.OneHanded, "onehanded", "=")); - } - - [Theory] - // leaf value is "T"/"F"; evalLeaf returns leaf=="T" - [InlineData("T", true)] - [InlineData("F", false)] - [InlineData("F@F|T", true)] // (F&&F)||T = T (buggy code gave F&&(F||T)=F) - [InlineData("T|F@F", true)] // T||(F&&F) = T (buggy code gave (T||F)&&F=F) - [InlineData("T@F", false)] - [InlineData("T@T", true)] - [InlineData("F|F", false)] - public void EvaluateBoolean_Precedence(string expr, bool expected) - { - // State is unused here; the leaf evaluator just checks the span equals "T". - var result = AdvancedSearchUtilities.EvaluateBoolean(expr, 0, static (_, leaf) => leaf.SequenceEqual("T")); - Assert.Equal(expected, result); - } - - [Fact] - public void CompareValues_ReferenceType_EqualityByString_NoThrow() - { - // A reference-typed property (e.g. RootParent name-ish) compared with "=" should not throw, - // and ordering operators must return false rather than throwing. - var ex = Record.Exception(() => - { - Assert.False(AdvancedSearchUtilities.CompareValues(typeof(object), new object(), "whatever", ">")); - }); - Assert.Null(ex); - } - - [Fact] - public void CompareValues_TimeSpan_ParsesViaSpanParsable() - { - // TimeSpan is not IConvertible, so the old Convert.ChangeType fallback threw and silently - // returned no-match. ISpanParsable parses it correctly. - var prop = TimeSpan.FromMinutes(5); - Assert.True(AdvancedSearchUtilities.CompareValues(typeof(TimeSpan), prop, "00:05:00", "=")); - Assert.False(AdvancedSearchUtilities.CompareValues(typeof(TimeSpan), prop, "00:10:00", "=")); - Assert.True(AdvancedSearchUtilities.CompareValues(typeof(TimeSpan), prop, "00:01:00", ">")); - } - - [Fact] - public void CompareValues_TimeSpan_BadInput_ReturnsFalse_NoThrow() - { - var ex = Record.Exception(() => - Assert.False(AdvancedSearchUtilities.CompareValues(typeof(TimeSpan), TimeSpan.Zero, "notaspan", "="))); - Assert.Null(ex); - } - - [Fact] - public void CompareValues_Guid_ValueTypeParsedViaTypes() - { - // Guid is a value type not named by the hot paths; it's parsed via Types (IParsable) and - // compared by value. - var g = Guid.Parse("00000000-0000-0000-0000-000000000001"); - Assert.True(AdvancedSearchUtilities.CompareValues(typeof(Guid), g, "00000000-0000-0000-0000-000000000001", "=")); - Assert.False(AdvancedSearchUtilities.CompareValues(typeof(Guid), g, "00000000-0000-0000-0000-000000000002", "=")); - } - - // A reference type with a legacy RunUO-style static Parse(string) and NO IParsable<> interface — - // the Faction/Town shape. Types must still discover its Parse by reflection. - private sealed class LegacyParseType - { - public string Value { get; private init; } - public static LegacyParseType Parse(string s) => new() { Value = s }; - public override bool Equals(object obj) => obj is LegacyParseType o && o.Value == Value; - public override int GetHashCode() => Value?.GetHashCode() ?? 0; - } - - [Fact] - public void CompareValues_LegacyParseString_ParsedViaTypes() - { - // Pre-IParsable types (only a static Parse(string)) must still be searchable: Types binds the - // legacy Parse by reflection, so we compare against a real parsed instance, not the raw text. - var prop = LegacyParseType.Parse("alpha"); - Assert.True(AdvancedSearchUtilities.CompareValues(typeof(LegacyParseType), prop, "alpha", "=")); - Assert.False(AdvancedSearchUtilities.CompareValues(typeof(LegacyParseType), prop, "beta", "=")); - } -} diff --git a/Projects/UOContent.Tests/Tests/Engines/AdvancedSearch/AdvancedSearchWorkerTests.cs b/Projects/UOContent.Tests/Tests/Engines/AdvancedSearch/AdvancedSearchWorkerTests.cs deleted file mode 100644 index 2bfe94af8..000000000 --- a/Projects/UOContent.Tests/Tests/Engines/AdvancedSearch/AdvancedSearchWorkerTests.cs +++ /dev/null @@ -1,95 +0,0 @@ -using System; -using System.Collections.Concurrent; -using Server; -using Server.Engines.AdvancedSearch; -using Server.Items; -using Server.Tests; -using Xunit; - -namespace UOContent.Tests; - -[Collection("Sequential UOContent Tests")] -public class AdvancedSearchWorkerTests -{ - // An item whose property test path will throw when evaluated. - private sealed class ThrowingItem : Item - { - public ThrowingItem() : base(0x1) { } - public ThrowingItem(Serial s) : base(s) { } - public string Boom => throw new InvalidOperationException("boom"); - } - - [Fact] - public void Worker_FilterThrows_DoesNotEscape_ReturnsNoMatch() - { - var worker = new AdvancedSearchThreadWorker(); - var results = new ConcurrentQueue(); - var ignore = new ConcurrentQueue(); - var filter = new AdvancedSearchFilter - { - FilterPropertyTest = true, - PropertyTest = "Boom=1", // reflection GetValue -> throws - }; - - var item = new ThrowingItem(); - - try - { - worker.Wake(new WorldLocation(Point3D.Zero, Map.Felucca), filter, results, ignore); - worker.Push(item); - worker.Sleep(); // drains; must not crash the test process - - Assert.Empty(results); - } - finally - { - item.Delete(); - worker.Exit(); - } - } - - [Fact] - public void Worker_DeletedEntity_IsSkipped() - { - var worker = new AdvancedSearchThreadWorker(); - var results = new ConcurrentQueue(); - var ignore = new ConcurrentQueue(); - var filter = new AdvancedSearchFilter(); // no filters -> everything matches - - var item = new Item(0x1); - item.Delete(); - - try - { - worker.Wake(new WorldLocation(Point3D.Zero, Map.Felucca), filter, results, ignore); - worker.Push(item); - worker.Sleep(); - - Assert.Empty(results); - } - finally - { - worker.Exit(); - } - } - - [Fact] - public void DoSearch_IsGuarded_AgainstReentry() - { - // White-box: flip the guard, assert a second entry is rejected, then clear. - // _searchInProgress is process-global static state; release it in finally so a - // failed assert here can't leak the guard into other tests. - Assert.False(AdvancedSearchGump.IsSearchInProgress); - Assert.True(AdvancedSearchGump.TryBeginSearch()); // acquires - try - { - Assert.False(AdvancedSearchGump.TryBeginSearch()); // rejected - } - finally - { - AdvancedSearchGump.EndSearch(); // releases - } - - Assert.False(AdvancedSearchGump.IsSearchInProgress); - } -} diff --git a/Projects/UOContent.Tests/Tests/Engines/Factions/FactionRankTests.cs b/Projects/UOContent.Tests/Tests/Engines/Factions/FactionRankTests.cs deleted file mode 100644 index 2416055b1..000000000 --- a/Projects/UOContent.Tests/Tests/Engines/Factions/FactionRankTests.cs +++ /dev/null @@ -1,100 +0,0 @@ -using System.Collections.Generic; -using Server; -using Server.Factions; -using Xunit; - -namespace UOContent.Tests; - -/// -/// PlayerState.Rank is read from GetProperties, so it must stay a plain field read. These pin what -/// that requires: the rank is never null, and it is correct without anyone having read it first. -/// -[Collection("Sequential UOContent Tests")] -public class FactionRankTests -{ - // The faction ctor builds its own Definition, so no world state is needed. - private static Faction NewFaction() => new CouncilOfMages(); - - private static PlayerState AddMember(Faction faction, List owner) - { - var state = new PlayerState(new Mobile(), faction, owner); - owner.Add(state); - return state; - } - - [Fact] - public void Rank_IsPopulatedBeforeAnythingReadsIt() - { - var faction = NewFaction(); - var state = new PlayerState(new Mobile(), faction, []); - - // Nothing recomputes on read, so the ctor must leave a usable value or Rank.Title NREs. - Assert.NotNull(state.Rank); - Assert.NotNull(state.Rank.Title); - } - - [Fact] - public void Rank_IsTheLowestRank_ForAnUnrankedMember() - { - var faction = NewFaction(); - var owner = new List(); - var a = AddMember(faction, owner); - var b = AddMember(faction, owner); - - a.UpdateRank(); - b.UpdateRank(); - - var lowest = faction.Definition.Ranks[^1]; - - Assert.Equal(lowest.Rank, a.Rank.Rank); - Assert.Equal(lowest.Rank, b.Rank.Rank); - } - - [Fact] - public void SettingRankIndex_UpdatesRankWithoutAnyoneReadingIt() - { - var faction = NewFaction(); - var owner = new List(); - var top = AddMember(faction, owner); - var bottom = AddMember(faction, owner); - - faction.ZeroRankOffset = 2; - - top.RankIndex = 0; - bottom.RankIndex = 1; - - // No read triggered these, yet the ordering is reflected. - Assert.True(top.Rank.Rank > bottom.Rank.Rank); - } - - [Fact] - public void ReadingRank_IsStableAndSideEffectFree() - { - var faction = NewFaction(); - var owner = new List(); - var state = AddMember(faction, owner); - - faction.ZeroRankOffset = 1; - state.RankIndex = 0; - - var first = state.Rank; - var second = state.Rank; - - Assert.Same(first, second); - } - - [Fact] - public void RankIndexOutOfSyncWithZeroRankOffset_StillResolvesARank() - { - var faction = NewFaction(); - var owner = new List(); - var a = AddMember(faction, owner); - AddMember(faction, owner); - - // A negative percent used to match no rank at all, leaving Rank null. - faction.ZeroRankOffset = 1; - a.RankIndex = 5; - - Assert.NotNull(a.Rank); - } -} 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 deleted file mode 100644 index ae926794d..000000000 --- a/Projects/UOContent.Tests/Tests/Network/AutoDenylist/AutoDenylistTests.cs +++ /dev/null @@ -1,206 +0,0 @@ -/************************************************************************* - * ModernUO * - * Copyright 2019-2026 - ModernUO Development Team * - * Email: hi@modernuo.com * - * File: AutoDenylistTests.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.Net; -using Server.Network; -using Server.Network.Bans; -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). -[Collection("Sequential UOContent Tests")] -public class AutoDenylistTests -{ - private const long DurationMs = 900_000; // 15 minutes, the shipped default - private const long Now = 1_000_000; - - private static void Reset(bool enabled = true, int maxEntries = 1024) => - AutoDenylist.LoadForTesting(enabled, DurationMs, maxEntries); - - [Fact] - public void Behavioral_detection_is_held_then_lapses() - { - Reset(); - var ip = IPAddress.Parse("198.51.100.10"); - - Assert.True(AutoDenylist.Hold(ip, BanReasons.InvalidSeed, Now)); - - Assert.True(AutoDenylist.IsDenied(ip, Now)); - Assert.True(AutoDenylist.IsDenied(ip, Now + DurationMs - 1)); - - // Expiry is decided on read, so the hold lapses without waiting for a sweep. - Assert.False(AutoDenylist.IsDenied(ip, Now + DurationMs)); - } - - [Fact] - public void Manual_and_list_verdicts_are_not_held() - { - Reset(); - var manual = IPAddress.Parse("198.51.100.11"); - var listed = IPAddress.Parse("198.51.100.12"); - - // A manual ban belongs in the firewall; a blocklist match is enforced by the blocklist's own filter. - Assert.False(AutoDenylist.Hold(manual, BanReasons.Manual, Now)); - Assert.False(AutoDenylist.Hold(listed, BanReasons.Blocklist, Now)); - - Assert.False(AutoDenylist.IsDenied(manual, Now)); - Assert.False(AutoDenylist.IsDenied(listed, Now)); - 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() - { - Reset(); - var ip = IPAddress.Parse("198.51.100.13"); - - 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); - } - - [Fact] - public void Release_drops_the_hold_immediately() - { - Reset(); - var ip = IPAddress.Parse("198.51.100.14"); - - AutoDenylist.Hold(ip, BanReasons.RateLimit, Now); - AutoDenylist.Release(ip); - - Assert.False(AutoDenylist.IsDenied(ip, Now)); - } - - [Fact] - public void Cap_is_enforced_so_a_distinct_source_flood_cannot_grow_it() - { - Reset(maxEntries: 4); - - for (var i = 0; i < 10; i++) - { - AutoDenylist.Hold(IPAddress.Parse($"198.51.100.{100 + i}"), BanReasons.InvalidSeed, Now); - } - - Assert.Equal(4, AutoDenylist.Count); - - // The first four are still held; the rest were disconnected by their gate but not tracked. - Assert.True(AutoDenylist.IsDenied(IPAddress.Parse("198.51.100.100"), Now)); - Assert.False(AutoDenylist.IsDenied(IPAddress.Parse("198.51.100.109"), Now)); - } - - [Fact] - public void Reaching_the_cap_reclaims_lapsed_entries_first() - { - Reset(maxEntries: 2); - - AutoDenylist.Hold(IPAddress.Parse("198.51.100.20"), BanReasons.InvalidSeed, Now); - AutoDenylist.Hold(IPAddress.Parse("198.51.100.21"), BanReasons.InvalidSeed, Now); - - // Once those two have lapsed, a new detection sweeps them out rather than being refused. - var later = Now + DurationMs + 1; - Assert.True(AutoDenylist.Hold(IPAddress.Parse("198.51.100.22"), BanReasons.InvalidSeed, later)); - Assert.True(AutoDenylist.IsDenied(IPAddress.Parse("198.51.100.22"), later)); - } - - [Fact] - public void Disabled_store_denies_nobody() - { - Reset(enabled: false); - var ip = IPAddress.Parse("198.51.100.30"); - - Assert.False(AutoDenylist.Hold(ip, BanReasons.InvalidSeed, Now)); - Assert.False(AutoDenylist.IsDenied(ip, Now)); - } - - [Fact] - public void Null_address_is_handled() - { - Reset(); - - Assert.False(AutoDenylist.Hold(null, BanReasons.InvalidSeed, Now)); - Assert.False(AutoDenylist.IsDenied(null, Now)); - } -} diff --git a/Projects/UOContent.Tests/Tests/Network/BanExemptionsTests.cs b/Projects/UOContent.Tests/Tests/Network/BanExemptionsTests.cs deleted file mode 100644 index b38364373..000000000 --- a/Projects/UOContent.Tests/Tests/Network/BanExemptionsTests.cs +++ /dev/null @@ -1,122 +0,0 @@ -/************************************************************************* - * ModernUO * - * Copyright 2019-2026 - ModernUO Development Team * - * Email: hi@modernuo.com * - * File: BanExemptionsTests.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.Net; -using System.Text; -using Server.Network; -using Server.Network.Bans; -using Xunit; - -namespace Server.Tests.Network.Exemptions; - -// Addresses come from TEST-NET-1 (192.0.2.0/24) to stay clear of the other ban test classes if xUnit runs -// them concurrently. -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 WithEmptyManualAllowlist() => ManualAllowlist.LoadForTesting(BlocklistSnapshot.Empty); - - [Fact] - public void Manual_allowlist_exempts_behavioral_contributions() - { - WithManualAllowlist("192.0.2.10"); - - // Subtracting from the blocklist does nothing for behavioural detections, which never consult it. - Assert.True(BanExemptions.IsExempt(_listed, BanReasons.ForeignProtocol, NeverCalled)); - Assert.True(BanExemptions.IsExempt(_listed, BanReasons.RateLimit, NeverCalled)); - Assert.True(BanExemptions.IsExempt(_listed, BanReasons.SilentConnect, NeverCalled)); - Assert.True(BanExemptions.IsExempt(_listed, BanReasons.InvalidSeed, NeverCalled)); - } - - [Fact] - public void Manual_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"); - - Assert.True(BanExemptions.IsExempt(_listed, BanReasons.RateLimit, NeverCalled)); - Assert.True(BanExemptions.IsExempt(IPAddress.Parse("192.0.2.254"), BanReasons.RateLimit, NeverCalled)); - Assert.False(BanExemptions.IsExempt(IPAddress.Parse("192.0.3.1"), BanReasons.RateLimit, AlwaysFalse)); - } - - [Fact] - public void Manual_bans_are_never_exempt_even_when_allowlisted() - { - WithManualAllowlist("192.0.2.10"); - - // An explicit decision outranks the operator's own carve-out, and must not cost a strike. - Assert.False(BanExemptions.IsExempt(_listed, BanReasons.Manual, NeverCalled)); - } - - [Fact] - public void Unopted_reasons_are_never_exempt() - { - WithManualAllowlist("192.0.2.10"); - - Assert.False(BanExemptions.IsExempt(_listed, BanReasons.Blocklist, NeverCalled)); - Assert.False(BanExemptions.IsExempt(_listed, "some-future-reason", NeverCalled)); - } - - [Fact] - public void Manual_allowlist_does_not_spend_the_earned_lists_strikes() - { - WithManualAllowlist("192.0.2.10"); - - // Unconditional, so the revocable list must not be consulted -- that would burn a strike. - Assert.True(BanExemptions.IsExempt(_listed, BanReasons.RateLimit, NeverCalled)); - } - - [Fact] - public void Falls_through_to_the_login_allowlist_when_not_file_listed() - { - WithEmptyManualAllowlist(); - - var consulted = 0; - - var result = BanExemptions.IsExempt( - _unlisted, - BanReasons.RateLimit, - (_, _) => - { - consulted++; - return true; - } - ); - - Assert.True(result); - Assert.Equal(1, consulted); - } - - [Fact] - public void Null_address_is_never_exempt() - { - WithEmptyManualAllowlist(); - - Assert.False(BanExemptions.IsExempt(null, BanReasons.RateLimit, NeverCalled)); - } - - private static bool NeverCalled(IPAddress address, string reason) - { - Assert.Fail("The login allowlist must not be consulted once the answer is already decided."); - return false; - } - - private static bool AlwaysFalse(IPAddress address, string reason) => false; -} diff --git a/Projects/UOContent.Tests/Tests/Network/Bans/Blocklist/BlocklistConfigurationTests.cs b/Projects/UOContent.Tests/Tests/Network/Bans/Blocklist/BlocklistConfigurationTests.cs deleted file mode 100644 index 3d81c8d87..000000000 --- a/Projects/UOContent.Tests/Tests/Network/Bans/Blocklist/BlocklistConfigurationTests.cs +++ /dev/null @@ -1,75 +0,0 @@ -/************************************************************************* - * ModernUO * - * Copyright 2019-2026 - ModernUO Development Team * - * Email: hi@modernuo.com * - * File: BlocklistConfigurationTests.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.Bans.Blocklist; - -public class BlocklistConfigurationTests -{ - // 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 BlocklistSettings_RoundTripsThroughJsonConfig() - { - var original = new BlocklistSettings - { - Enabled = true, - File = "D:/shared/ip-blocklist.txt", - ReloadInterval = TimeSpan.FromMinutes(5), - ReportHits = false, - BanDuration = TimeSpan.FromHours(2), - PromoteSuppression = TimeSpan.FromSeconds(30) - }; - - var json = JsonConfig.Serialize(original); - - Assert.Contains("\"enabled\"", json); - Assert.Contains("\"file\"", json); - Assert.Contains("\"reloadInterval\"", json); - Assert.Contains("\"reportHits\"", json); - Assert.Contains("\"banDuration\"", json); - Assert.Contains("\"promoteSuppression\"", json); - - 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); - Assert.Equal(original.BanDuration, restored.BanDuration); - 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] - public void Default_file_matches_the_generator_output_path() - { - Assert.Equal("Configuration/ip-blocklist.txt", new BlocklistSettings().File); - } -} diff --git a/Projects/UOContent.Tests/Tests/Network/Bans/Blocklist/BlocklistFileTests.cs b/Projects/UOContent.Tests/Tests/Network/Bans/Blocklist/BlocklistFileTests.cs deleted file mode 100644 index 1b7a7772c..000000000 --- a/Projects/UOContent.Tests/Tests/Network/Bans/Blocklist/BlocklistFileTests.cs +++ /dev/null @@ -1,85 +0,0 @@ -/************************************************************************* - * ModernUO * - * Copyright 2019-2026 - ModernUO Development Team * - * Email: hi@modernuo.com * - * File: BlocklistFileTests.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.Net; -using Server.Network.Bans; -using Xunit; - -namespace Server.Tests.Network.Bans.Blocklist; - -public class BlocklistFileTests -{ - private static string WriteTemp(string content) - { - var p = Path.Combine(Path.GetTempPath(), "bl-" + Guid.NewGuid().ToString("N") + ".txt"); - File.WriteAllText(p, content); - return p; - } - - [Fact] - public void Reads_header_generated_and_count() - { - var p = WriteTemp("# modernuo-blocklist v1 generated=2026-07-21T09:24:23Z count=2\n1.2.3.4\n5.6.7.0/24\n"); - Assert.True(BlocklistFile.TryReadHeader(p, out var h)); - Assert.True(h.Present); - Assert.Equal("2026-07-21T09:24:23Z", h.Generated); - Assert.Equal(2, h.Count); - File.Delete(p); - } - - [Fact] - public void Missing_file_reports_absent_and_loads_empty() - { - var p = Path.Combine(Path.GetTempPath(), "does-not-exist-" + Guid.NewGuid().ToString("N")); - Assert.False(BlocklistFile.TryReadHeader(p, out var h)); - Assert.False(h.Present); - var snap = BlocklistFile.Load(p, out _, out _); - Assert.False(snap.IsBanned(IPAddress.Parse("1.2.3.4"))); - } - - // Pins the exact line tools/Export-IpBlocklist.ps1 emits: the producer adds informational tokens the - // reader doesn't know about, and the reload detector breaks silently if generated= stops being read. - [Fact] - public void Reads_generator_header_with_extra_tokens() - { - var p = WriteTemp( - "# modernuo-blocklist generated=2026-07-25T18:03:11Z count=12442 ipv4=7868 cidr=4574 feeds=2\n" + - "2.181.183.77\n223.169.0.0/16\n" - ); - - Assert.True(BlocklistFile.TryReadHeader(p, out var h)); - Assert.Equal("2026-07-25T18:03:11Z", h.Generated); - Assert.Equal(12442, h.Count); - - var snap = BlocklistFile.Load(p, out var parsed, out var skipped); - Assert.Equal(2, parsed); - Assert.Equal(0, skipped); - Assert.True(snap.IsBanned(IPAddress.Parse("2.181.183.77"))); - Assert.True(snap.IsBanned(IPAddress.Parse("223.169.4.9"))); - File.Delete(p); - } - - [Fact] - public void Load_parses_body() - { - var p = WriteTemp("# generated=x count=1\n8.8.8.0/24\n"); - var snap = BlocklistFile.Load(p, out var parsed, out _); - Assert.Equal(1, parsed); - Assert.True(snap.IsBanned(IPAddress.Parse("8.8.8.8"))); - File.Delete(p); - } -} diff --git a/Projects/UOContent.Tests/Tests/Network/Bans/Blocklist/BlocklistFilterTests.cs b/Projects/UOContent.Tests/Tests/Network/Bans/Blocklist/BlocklistFilterTests.cs deleted file mode 100644 index 4f3ee5ad1..000000000 --- a/Projects/UOContent.Tests/Tests/Network/Bans/Blocklist/BlocklistFilterTests.cs +++ /dev/null @@ -1,94 +0,0 @@ -/************************************************************************* - * ModernUO * - * Copyright 2019-2026 - ModernUO Development Team * - * Email: hi@modernuo.com * - * File: BlocklistFilterTests.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.Net; -using System.Text; -using Server.Network.Bans; -using Xunit; - -namespace Server.Tests.Network.Bans.Blocklist; - -// No [Collection] and no static reset hook: the filter is an instance, so each test owns its own -// snapshot and promote-guard. That is the point of it no longer being a static class. -public class BlocklistFilterTests -{ - private static BlocklistFilter WithList(string list, bool reportHits = true, long suppressionMs = 5000) - { - var filter = new BlocklistFilter(); - filter.LoadForTesting( - BlocklistSnapshot.Build(Encoding.ASCII.GetBytes(list), out _, out _), - reportHits, - suppressionMs - ); - - return filter; - } - - [Fact] - public void Listed_address_is_denied_and_reported_once_per_window() - { - var filter = WithList("1.2.3.4"); - var ip = IPAddress.Parse("1.2.3.4"); - - var deny1 = filter.Evaluate(ip, 1000, out var report1); - var deny2 = filter.Evaluate(ip, 1500, out var report2); - var deny3 = filter.Evaluate(ip, 1000 + 5001, out var report3); - - Assert.True(deny1); - Assert.True(report1); - - Assert.True(deny2); - Assert.False(report2); // denied again, but promotion suppressed inside the window - - Assert.True(deny3); - Assert.True(report3); // window elapsed, promotion may be retried - } - - [Fact] - public void Unlisted_address_passes() - { - var filter = WithList("1.2.3.4"); - - Assert.False(filter.Evaluate(IPAddress.Parse("9.9.9.9"), 1, out var report)); - Assert.False(report); - } - - [Fact] - public void Cidr_membership_is_honored() - { - var filter = WithList("10.20.30.0/24"); - - Assert.True(filter.Evaluate(IPAddress.Parse("10.20.30.255"), 1, out _)); - Assert.False(filter.Evaluate(IPAddress.Parse("10.20.31.0"), 1, out _)); - } - - [Fact] - public void ReportHits_disabled_still_denies_but_never_promotes() - { - var filter = WithList("1.2.3.4", reportHits: false); - - Assert.True(filter.Evaluate(IPAddress.Parse("1.2.3.4"), 1, out var report)); - Assert.False(report); - } - - [Fact] - public void Unconfigured_filter_denies_nothing() - { - var filter = new BlocklistFilter(); - - Assert.Equal(0, filter.Count); - Assert.False(filter.ShouldDeny(IPAddress.Parse("8.8.8.8"))); - } -} diff --git a/Projects/UOContent.Tests/Tests/Network/Bans/Blocklist/BlocklistSnapshotTests.cs b/Projects/UOContent.Tests/Tests/Network/Bans/Blocklist/BlocklistSnapshotTests.cs deleted file mode 100644 index abdac1d11..000000000 --- a/Projects/UOContent.Tests/Tests/Network/Bans/Blocklist/BlocklistSnapshotTests.cs +++ /dev/null @@ -1,98 +0,0 @@ -/************************************************************************* - * ModernUO * - * Copyright 2019-2026 - ModernUO Development Team * - * Email: hi@modernuo.com * - * File: BlocklistSnapshotTests.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.Net; -using Server.Network.Bans; -using Xunit; - -namespace Server.Tests.Network.Bans.Blocklist; - -public class BlocklistSnapshotTests -{ - private static BlocklistSnapshot Build(params string[] lines) => - BlocklistSnapshot.Build(System.Text.Encoding.ASCII.GetBytes(string.Join('\n', lines)), out _, out _); - - [Fact] - public void Single_ip_is_matched() - { - var s = Build("1.2.3.4"); - Assert.True(s.IsBanned(IPAddress.Parse("1.2.3.4"))); - Assert.False(s.IsBanned(IPAddress.Parse("1.2.3.5"))); - } - - [Fact] - public void Cidr_contains_and_excludes_boundaries() - { - var s = Build("10.0.0.0/24"); - Assert.True(s.IsBanned(IPAddress.Parse("10.0.0.0"))); - Assert.True(s.IsBanned(IPAddress.Parse("10.0.0.255"))); - Assert.False(s.IsBanned(IPAddress.Parse("10.0.1.0"))); - Assert.False(s.IsBanned(IPAddress.Parse("9.255.255.255"))); - } - - [Fact] - public void Comments_blanks_and_garbage_are_skipped_not_thrown() - { - var s = BlocklistSnapshot.Build( - System.Text.Encoding.ASCII.GetBytes( - string.Join('\n', "# header generated=x", "", "not-an-ip", "1.2.3.4", "::1", "5.6.7.0/24")), - out var parsed, out var skipped); - Assert.Equal(3, parsed); // 1.2.3.4 + ::1 (valid loopback) + 5.6.7.0/24 - Assert.True(skipped >= 1); // "not-an-ip"; blank/comment lines are silently skipped, not counted - Assert.True(s.IsBanned(IPAddress.Parse("5.6.7.200"))); - } - - [Fact] - public void Empty_snapshot_matches_nothing() - { - Assert.False(BlocklistSnapshot.Empty.IsBanned(IPAddress.Parse("1.2.3.4"))); - } - - [Fact] - public void Ipv6_single_and_cidr_are_matched() - { - var s = Build("2001:db8::1", "2001:db8:1::/48"); - Assert.True(s.IsBanned(IPAddress.Parse("2001:db8::1"))); - Assert.True(s.IsBanned(IPAddress.Parse("2001:db8:1::abcd"))); - Assert.False(s.IsBanned(IPAddress.Parse("2001:db8:2::1"))); - } - - [Fact] - public void Ipv4_mapped_ipv6_is_normalized_to_v4() - { - var s = Build("1.2.3.4"); - Assert.True(s.IsBanned(IPAddress.Parse("::ffff:1.2.3.4"))); // must not bypass the v4 set - } - - [Fact] - public void Nested_cidr_intervals_are_coalesced() - { - // A /32 nested inside a /24: InRange's binary search only inspects the - // rightmost interval starting <= ip, so without coalescing an IP inside - // the /24 but outside the /32 would land on the /32 and wrongly pass. - var s = Build("10.0.0.0/24", "10.0.0.5/32"); - Assert.True(s.IsBanned(IPAddress.Parse("10.0.0.100"))); // inside /24, outside /32 - Assert.True(s.IsBanned(IPAddress.Parse("10.0.0.5"))); // the nested /32 itself - Assert.False(s.IsBanned(IPAddress.Parse("10.0.1.0"))); // genuinely outside both - } - - [Fact] - public void Overlapping_cidr_intervals_are_coalesced() - { - var s = Build("10.0.0.0/25", "10.0.0.64/25"); - Assert.True(s.IsBanned(IPAddress.Parse("10.0.0.100"))); // covered by the second /25 - Assert.False(s.IsBanned(IPAddress.Parse("10.0.0.200"))); // outside both - } -} diff --git a/Projects/UOContent.Tests/Tests/Network/Bans/Blocklist/PromotedGuardTests.cs b/Projects/UOContent.Tests/Tests/Network/Bans/Blocklist/PromotedGuardTests.cs deleted file mode 100644 index 7c19b8d38..000000000 --- a/Projects/UOContent.Tests/Tests/Network/Bans/Blocklist/PromotedGuardTests.cs +++ /dev/null @@ -1,46 +0,0 @@ -/************************************************************************* - * ModernUO * - * Copyright 2019-2026 - ModernUO Development Team * - * Email: hi@modernuo.com * - * File: PromotedGuardTests.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 Server.Network.Bans; -using Xunit; - -namespace Server.Tests.Network.Bans.Blocklist; - -public class PromotedGuardTests -{ - [Fact] - public void First_mark_true_then_suppressed_until_ttl() - { - var g = new PromotedGuard(); - Assert.True(g.TryMark((UInt128)42, 1000, 5000)); - Assert.False(g.TryMark((UInt128)42, 2000, 5000)); // within TTL - Assert.True(g.TryMark((UInt128)42, 6001, 5000)); // expired → re-mark - } - - [Fact] - public void Sweep_removes_expired_entries_allowing_remark() - { - var g = new PromotedGuard(); - Assert.True(g.TryMark((UInt128)7, 0, 1000)); - Assert.False(g.TryMark((UInt128)7, 500, 1000)); // still within TTL - - g.Sweep(500); // not yet expired, sweep should not remove it - Assert.False(g.TryMark((UInt128)7, 999, 1000)); - - g.Sweep(1001); // now expired, sweep removes it - Assert.True(g.TryMark((UInt128)7, 1002, 1000)); // fresh mark, not "still marked" - } -} diff --git a/Projects/UOContent.Tests/Tests/Network/Bans/CrowdSecAlertClientTests.cs b/Projects/UOContent.Tests/Tests/Network/Bans/CrowdSecAlertClientTests.cs deleted file mode 100644 index 756e19b1d..000000000 --- a/Projects/UOContent.Tests/Tests/Network/Bans/CrowdSecAlertClientTests.cs +++ /dev/null @@ -1,62 +0,0 @@ -/************************************************************************* - * ModernUO * - * Copyright 2019-2026 - ModernUO Development Team * - * Email: hi@modernuo.com * - * File: CrowdSecAlertClientTests.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.Net; -using Server.Network.Bans.CrowdSec; -using Xunit; - -namespace Server.Tests.Network.Bans; - -public class CrowdSecAlertClientTests -{ - /// - /// LAPI's default watcher profile matches the crowdsec/ prefix and answers 401 without it, so - /// this is a protocol constraint rather than a cosmetic product string. - /// - [Fact] - public void UserAgent_IsPrefixedForTheWatcherProfile() - { - Assert.StartsWith("crowdsec/", CrowdSecAlertClient.UserAgent, StringComparison.Ordinal); - } - - [Fact] - public void BuildDeleteQuery_EscapesOrigin() - { - // origin is operator-controlled config (crowdsec.json), so it must be treated as untrusted - // input going into the URL, same as any other interpolated value. - var query = CrowdSecAlertClient.BuildDeleteQuery("modern uo/test&x=1", IPAddress.Parse("1.2.3.4")); - - Assert.Equal("/v1/decisions?origin=modern%20uo%2Ftest%26x%3D1&ip=1.2.3.4", query); - } - - [Fact] - public void BuildDeleteQuery_PlainOrigin_Ipv4() - { - var query = CrowdSecAlertClient.BuildDeleteQuery("modernuo", IPAddress.Parse("192.168.1.1")); - - Assert.Equal("/v1/decisions?origin=modernuo&ip=192.168.1.1", query); - } - - [Fact] - public void BuildDeleteQuery_Ipv6_IsEscaped() - { - // IPv6 textual form contains ':', which Uri.EscapeDataString percent-encodes like any other - // reserved character — confirms the escaping is applied uniformly, not just for IPv4. - var query = CrowdSecAlertClient.BuildDeleteQuery("modernuo", IPAddress.Parse("2001:db8::1")); - - Assert.Equal("/v1/decisions?origin=modernuo&ip=2001%3Adb8%3A%3A1", query); - } -} diff --git a/Projects/UOContent.Tests/Tests/Network/Bans/CrowdSecConfigurationTests.cs b/Projects/UOContent.Tests/Tests/Network/Bans/CrowdSecConfigurationTests.cs deleted file mode 100644 index dd331ac6d..000000000 --- a/Projects/UOContent.Tests/Tests/Network/Bans/CrowdSecConfigurationTests.cs +++ /dev/null @@ -1,82 +0,0 @@ -/************************************************************************* - * ModernUO * - * Copyright 2019-2026 - ModernUO Development Team * - * Email: hi@modernuo.com * - * File: CrowdSecConfigurationTests.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.CrowdSec; -using Xunit; - -namespace Server.Tests.Network.Bans; - -public class CrowdSecConfigurationTests -{ - // Locks the JsonConfig casing/converter contract: JsonConfig's options are case-SENSITIVE, so - // every settings member must carry an explicit [JsonPropertyName("camelCase")] or it silently - // binds nothing. These tests round-trip through the exact options the loader uses. - - [Fact] - public void CrowdSecSettings_RoundTripsThroughJsonConfig() - { - var original = new CrowdSecSettings - { - LapiUrl = "http://10.0.0.5:9090", - MachineId = "shard", - Password = "secret", - Origin = "modernuo", - ManualBanDuration = TimeSpan.FromHours(168), - FlushInterval = TimeSpan.FromSeconds(1), - MaxQueue = 10000 - }; - - var json = JsonConfig.Serialize(original); - - // camelCase property names must be present (not PascalCase) or the case-sensitive reader binds nothing. - Assert.Contains("\"lapiUrl\"", json); - Assert.Contains("\"machineId\"", json); - Assert.Contains("\"password\"", json); - Assert.Contains("\"origin\"", json); - Assert.Contains("\"manualBanDuration\"", json); - Assert.Contains("\"flushInterval\"", json); - Assert.Contains("\"maxQueue\"", json); - - var restored = JsonSerializer.Deserialize(json, JsonConfig.DefaultOptions); - - Assert.NotNull(restored); - Assert.Equal(original.LapiUrl, restored.LapiUrl); - Assert.Equal(original.MachineId, restored.MachineId); - Assert.Equal(original.Password, restored.Password); - Assert.Equal(original.Origin, restored.Origin); - Assert.Equal(original.ManualBanDuration, restored.ManualBanDuration); // TimeSpan survives - Assert.Equal(original.FlushInterval, restored.FlushInterval); // TimeSpan survives - Assert.Equal(original.MaxQueue, restored.MaxQueue); - Assert.True(restored.ReportingEnabled); - } - - [Fact] - public void CrowdSecSettings_Defaults_AreReportingDisabledLocalLoopback() - { - var settings = new CrowdSecSettings(); - - Assert.Equal("http://127.0.0.1:8080", settings.LapiUrl); - Assert.Equal("", settings.MachineId); - Assert.Equal("", settings.Password); - Assert.Equal("modernuo", settings.Origin); - Assert.Equal(TimeSpan.FromHours(168), settings.ManualBanDuration); - Assert.Equal(TimeSpan.FromSeconds(1), settings.FlushInterval); - Assert.Equal(10000, settings.MaxQueue); - Assert.False(settings.ReportingEnabled); // empty machineId/password => inert - } -} diff --git a/Projects/UOContent.Tests/Tests/Network/Bans/CrowdSecReporterTests.cs b/Projects/UOContent.Tests/Tests/Network/Bans/CrowdSecReporterTests.cs deleted file mode 100644 index 6ad850f34..000000000 --- a/Projects/UOContent.Tests/Tests/Network/Bans/CrowdSecReporterTests.cs +++ /dev/null @@ -1,285 +0,0 @@ -/************************************************************************* - * ModernUO * - * Copyright 2019-2026 - ModernUO Development Team * - * Email: hi@modernuo.com * - * File: CrowdSecReporterTests.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.Generic; -using System.Globalization; -using System.Net; -using System.Text.Json; -using System.Threading; -using System.Threading.Tasks; -using Server.Network.Bans.CrowdSec; -using Xunit; - -namespace Server.Tests.Network.Bans; - -public class CrowdSecReporterTests -{ - private static CrowdSecSettings Settings() => new() - { - MachineId = "shard", - Password = "secret", - Origin = "modernuo", - ManualBanDuration = TimeSpan.FromHours(168) - }; - - [Fact] - public void BuildAlerts_DedupsByIp() - { - var now = DateTime.UnixEpoch; - var items = new List - { - new(IPAddress.Parse("1.1.1.1"), TimeSpan.FromHours(1), "rate-limit", false), - new(IPAddress.Parse("1.1.1.1"), TimeSpan.FromHours(1), "rate-limit", false), - new(IPAddress.Parse("2.2.2.2"), TimeSpan.FromHours(1), "rate-limit", false) - }; - - var alerts = CrowdSecReporter.BuildAlerts(items, Settings(), now); - - Assert.Equal(2, alerts.Count); - Assert.All(alerts, a => Assert.Single(a.Decisions)); - Assert.Contains(alerts, a => a.Source.Value == "1.1.1.1"); - Assert.Contains(alerts, a => a.Source.Value == "2.2.2.2"); - } - - [Fact] - public void BuildAlerts_ScenarioFromReason_OriginFromSettings() - { - var alerts = CrowdSecReporter.BuildAlerts( - [new(IPAddress.Parse("3.3.3.3"), TimeSpan.FromHours(1), "manual", false)], - Settings(), - DateTime.UnixEpoch); - - var decision = Assert.Single(alerts).Decisions[0]; - Assert.Equal("modernuo/manual", alerts[0].Scenario); - Assert.Equal("modernuo", decision.Origin); - Assert.Equal("ban", decision.Type); - Assert.Equal("Ip", decision.Scope); - Assert.Equal("3.3.3.3", decision.Value); - } - - [Fact] - public void BuildAlerts_ScenarioFromReason_Blocklist() - { - var alerts = CrowdSecReporter.BuildAlerts( - [new(IPAddress.Parse("5.5.5.5"), TimeSpan.FromHours(1), "blocklist", false)], - Settings(), - DateTime.UnixEpoch); - - var decision = Assert.Single(alerts).Decisions[0]; - Assert.Equal("modernuo/blocklist", alerts[0].Scenario); - Assert.Equal("modernuo/blocklist", decision.Scenario); - } - - /// - /// LAPI dereferences scenario_hash/scenario_version unconditionally when persisting an alert, so an - /// omitted field is a 500, not a validation error. Asserted on the serialized payload rather than the - /// DTO because that is what actually goes on the wire. - /// - [Fact] - public void BuildAlerts_SerializedPayload_CarriesRequiredScenarioFields() - { - var alerts = CrowdSecReporter.BuildAlerts( - [new(IPAddress.Parse("9.9.9.9"), TimeSpan.FromHours(1), "rate-limit", false)], - Settings(), - DateTime.UnixEpoch); - - var payload = JsonSerializer.SerializeToNode(alerts)!.AsArray()[0]!.AsObject(); - - Assert.True(payload.ContainsKey("scenario_hash")); - Assert.True(payload.ContainsKey("scenario_version")); - Assert.Equal(JsonValueKind.String, payload["scenario_hash"]!.GetValue().ValueKind); - Assert.Equal(JsonValueKind.String, payload["scenario_version"]!.GetValue().ValueKind); - Assert.Equal(1, payload["capacity"]!.GetValue()); - } - - /// - /// ':' is the time-separator specifier in a custom .NET format string, so a shard under fi-FI used to - /// emit "T00.00.00.000Z" — which Go's time.RFC3339 rejects, and LAPI answers 500 for. th-TH additionally - /// shifts the year via the Buddhist calendar. - /// - [Theory] - [InlineData("fi-FI")] - [InlineData("th-TH")] - [InlineData("ar-SA")] - public void FormatTimestamp_IsIso8601_RegardlessOfCulture(string culture) - { - var previous = CultureInfo.CurrentCulture; - try - { - CultureInfo.CurrentCulture = new CultureInfo(culture); - Assert.Equal("1970-01-01T00:00:00.000Z", CrowdSecReporter.FormatTimestamp(DateTime.UnixEpoch)); - } - finally - { - CultureInfo.CurrentCulture = previous; - } - } - - /// A non-UTC input must still be stamped as UTC — the trailing 'Z' is a literal, not a claim. - [Fact] - public void FormatTimestamp_ConvertsNonUtcInput() - { - var local = new DateTimeOffset(1970, 1, 1, 2, 0, 0, TimeSpan.FromHours(2)).LocalDateTime; - - Assert.Equal("1970-01-01T00:00:00.000Z", CrowdSecReporter.FormatTimestamp(local)); - } - - [Fact] - public void FormatDuration_UsesSeconds_FloorsAtOne() - { - Assert.Equal("3600s", CrowdSecReporter.FormatDuration(TimeSpan.FromHours(1))); - Assert.Equal("1s", CrowdSecReporter.FormatDuration(TimeSpan.Zero)); - Assert.Equal("1s", CrowdSecReporter.FormatDuration(TimeSpan.FromMilliseconds(10))); - } - - [Fact] - public void Report_WhenQueueFull_DropsAndCounts() - { - var reporter = new CrowdSecReporter(new NullAlertClient(), new CrowdSecSettings - { - MachineId = "shard", - Password = "secret", - MaxQueue = 2 - }); - // Do NOT Start() the drain — so the queue fills and overflows deterministically. - - for (var i = 0; i < 10; i++) - { - reporter.Report(IPAddress.Parse("4.4.4." + i), TimeSpan.FromHours(1), "rate-limit"); - } - - Assert.True(reporter.DroppedCount >= 8); - } - - // Stop() without a prior Start() drives FlushRemainingOnStop() synchronously (no drain task, no - // Task.Delay backoff involved), so this is deterministic — no wall-clock timing dependency. - [Fact] - public void Stop_FlushesQueuedReports_ViaClient() - { - var client = new RecordingAlertClient(); - var reporter = new CrowdSecReporter(client, Settings()); - - reporter.Report(IPAddress.Parse("6.6.6.6"), TimeSpan.FromHours(1), "rate-limit"); - reporter.Stop(); - - var posted = Assert.Single(client.Posted); - Assert.Equal("6.6.6.6", Assert.Single(posted).Source.Value); - Assert.Equal(0, reporter.SendFailureCount); - } - - [Fact] - public void Stop_FlushesQueuedRetracts_ViaClient() - { - var client = new RecordingAlertClient(); - var reporter = new CrowdSecReporter(client, Settings()); - - reporter.Retract(IPAddress.Parse("8.8.8.8")); - reporter.Stop(); - - Assert.Equal(IPAddress.Parse("8.8.8.8"), Assert.Single(client.Deleted)); - Assert.Equal(0, reporter.SendFailureCount); - } - - [Fact] - public void Stop_WhenFlushSendFails_CountsSendFailure() - { - var reporter = new CrowdSecReporter(new ThrowingAlertClient(), Settings()); - - reporter.Report(IPAddress.Parse("7.7.7.7"), TimeSpan.FromHours(1), "rate-limit"); - reporter.Stop(); - - Assert.Equal(1, reporter.SendFailureCount); - } - - [Fact] - public void Stop_WithEmptyQueue_DoesNotInvokeClientOrFail() - { - var client = new RecordingAlertClient(); - var reporter = new CrowdSecReporter(client, Settings()); - - reporter.Stop(); - - Assert.Empty(client.Posted); - Assert.Equal(0, reporter.SendFailureCount); - } - - /// - /// The drain task must track the loop's lifetime, not just its first await — a ValueTask-returning - /// drain loop passed to Task.Run yields a Task<ValueTask> that completes immediately, which makes - /// Stop()'s drain-exited handshake a no-op. With an empty queue the loop parks on WaitToReadAsync, - /// so a correctly unwrapped task cannot win this race; a slow pool only under-detects. - /// - [Fact] - public async Task Start_DrainTaskSpansLoopLifetime_NotJustTheFirstAwait() - { - var reporter = new CrowdSecReporter(new NullAlertClient(), Settings()); - using var cts = new CancellationTokenSource(); - - reporter.Start(cts.Token); - - var drain = reporter.DrainTaskForTesting; - Assert.NotNull(drain); - - var first = await Task.WhenAny(drain, Task.Delay(TimeSpan.FromMilliseconds(500))); - Assert.False(ReferenceEquals(first, drain), "drain task completed while the loop was still running"); - - reporter.Stop(); - - Assert.True(drain.IsCompleted, "Stop() returned before the drain loop exited"); - } - - private sealed class NullAlertClient : ICrowdSecAlertClient - { - public ValueTask PostAlertsAsync(IReadOnlyList alerts, CancellationToken token) => - ValueTask.CompletedTask; - - public ValueTask DeleteDecisionsAsync(string origin, IPAddress ip, CancellationToken token) => - ValueTask.CompletedTask; - - public void Dispose() { } - } - - private sealed class RecordingAlertClient : ICrowdSecAlertClient - { - public List> Posted { get; } = []; - public List Deleted { get; } = []; - - public ValueTask PostAlertsAsync(IReadOnlyList alerts, CancellationToken token) - { - Posted.Add(alerts); - return ValueTask.CompletedTask; - } - - public ValueTask DeleteDecisionsAsync(string origin, IPAddress ip, CancellationToken token) - { - Deleted.Add(ip); - return ValueTask.CompletedTask; - } - - public void Dispose() { } - } - - private sealed class ThrowingAlertClient : ICrowdSecAlertClient - { - public ValueTask PostAlertsAsync(IReadOnlyList alerts, CancellationToken token) => - throw new InvalidOperationException("simulated LAPI outage"); - - public ValueTask DeleteDecisionsAsync(string origin, IPAddress ip, CancellationToken token) => - ValueTask.CompletedTask; - - public void Dispose() { } - } -} diff --git a/Projects/UOContent.Tests/Tests/Network/Firewall/FirewallPersistenceTests.cs b/Projects/UOContent.Tests/Tests/Network/Firewall/FirewallPersistenceTests.cs deleted file mode 100644 index 01691e223..000000000 --- a/Projects/UOContent.Tests/Tests/Network/Firewall/FirewallPersistenceTests.cs +++ /dev/null @@ -1,72 +0,0 @@ -/************************************************************************* - * ModernUO * - * Copyright 2019-2026 - ModernUO Development Team * - * Email: hi@modernuo.com * - * File: FirewallPersistenceTests.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.Net; -using Server.Network; -using Xunit; - -namespace Server.Tests.Network.Firewall; - -[Collection("Sequential UOContent Tests")] -public class FirewallPersistenceTests -{ - [Fact] - public void ToSettings_RoundTrips_PermanentAndTtl() - { - Server.Network.Firewall.ResetForTesting(); - Server.Network.Firewall.Add(new SingleIpFirewallEntry(IPAddress.Parse("1.2.3.4"))); - Server.Network.Firewall.Add(new SingleIpFirewallEntry(IPAddress.Parse("2.2.2.2")), TimeSpan.FromHours(1)); - - var settings = Server.Network.Firewall.ToSettings(); - - Server.Network.Firewall.ResetForTesting(); - Server.Network.Firewall.LoadFrom(settings); - - Assert.True(Server.Network.Firewall.IsBlocked(IPAddress.Parse("1.2.3.4"))); - Assert.True(Server.Network.Firewall.IsBlocked(IPAddress.Parse("2.2.2.2"))); - } - - [Fact] - public void LoadFrom_SkipsAlreadyExpired() - { - Server.Network.Firewall.ResetForTesting(); - var settings = new FirewallSettings - { - Entries = - [ - // Core.Now, matching the clock LoadFrom compares against. - new FirewallEntryRecord { Value = "9.9.9.9", Expires = Core.Now.AddHours(-1) } - ] - }; - - Server.Network.Firewall.LoadFrom(settings); - - Assert.False(Server.Network.Firewall.IsBlocked(IPAddress.Parse("9.9.9.9"))); - } - - [Fact] - public void ToSettings_OmitsExpiryForPermanent() - { - Server.Network.Firewall.ResetForTesting(); - Server.Network.Firewall.Add(new SingleIpFirewallEntry(IPAddress.Parse("1.2.3.4"))); - - var settings = Server.Network.Firewall.ToSettings(); - - Assert.Single(settings.Entries); - Assert.Null(settings.Entries[0].Expires); - Assert.Equal("1.2.3.4", settings.Entries[0].Value); - } -} diff --git a/Projects/UOContent.Tests/Tests/Network/Firewall/FirewallTests.cs b/Projects/UOContent.Tests/Tests/Network/Firewall/FirewallTests.cs deleted file mode 100644 index fabf6d4ba..000000000 --- a/Projects/UOContent.Tests/Tests/Network/Firewall/FirewallTests.cs +++ /dev/null @@ -1,89 +0,0 @@ -/************************************************************************* - * ModernUO * - * Copyright 2019-2026 - ModernUO Development Team * - * Email: hi@modernuo.com * - * File: FirewallTests.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.Net; -using Server.Network; -using Xunit; - -namespace Server.Tests.Network.Firewall; - -[Collection("Sequential UOContent Tests")] -public class FirewallTests -{ - private static IPAddress Ip(string s) => IPAddress.Parse(s); - - [Fact] - public void Add_ThenIsBlocked_SingleIp() - { - Server.Network.Firewall.ResetForTesting(); - Assert.True(Server.Network.Firewall.Add(new SingleIpFirewallEntry(Ip("1.2.3.4")))); - Assert.True(Server.Network.Firewall.IsBlocked(Ip("1.2.3.4"))); - Assert.False(Server.Network.Firewall.IsBlocked(Ip("1.2.3.5"))); - } - - [Fact] - public void Add_Range_BlocksInside_NotOutside() - { - Server.Network.Firewall.ResetForTesting(); - Server.Network.Firewall.Add(new CidrFirewallEntry(Ip("10.0.0.0"), Ip("10.0.0.255"))); - Assert.True(Server.Network.Firewall.IsBlocked(Ip("10.0.0.7"))); - Assert.False(Server.Network.Firewall.IsBlocked(Ip("10.0.1.0"))); - } - - [Fact] - public void Remove_Unblocks() - { - Server.Network.Firewall.ResetForTesting(); - var entry = new SingleIpFirewallEntry(Ip("1.2.3.4")); - Server.Network.Firewall.Add(entry); - Assert.True(Server.Network.Firewall.Remove(entry)); - Assert.False(Server.Network.Firewall.IsBlocked(Ip("1.2.3.4"))); - } - - [Fact] - public void ExpireEntries_RemovesExpired_KeepsPermanent() - { - Server.Network.Firewall.ResetForTesting(); - var permanent = new SingleIpFirewallEntry(Ip("1.1.1.1")); - var temporary = new SingleIpFirewallEntry(Ip("2.2.2.2")); - Server.Network.Firewall.Add(permanent); // no ttl - Server.Network.Firewall.Add(temporary, TimeSpan.FromMilliseconds(50)); // ttl - - Server.Network.Firewall.ExpireEntries(Core.TickCount + 100); // past the ttl - - Assert.True(Server.Network.Firewall.IsBlocked(Ip("1.1.1.1"))); - Assert.False(Server.Network.Firewall.IsBlocked(Ip("2.2.2.2"))); - } - - [Fact] - public void ToFirewallEntry_ParsesForms() - { - Assert.IsType(Server.Network.Firewall.ToFirewallEntry("1.2.3.4")); - Assert.IsType(Server.Network.Firewall.ToFirewallEntry("10.0.0.0/24")); - Assert.IsType(Server.Network.Firewall.ToFirewallEntry("10.0.0.0-10.0.0.255")); - Assert.Null(Server.Network.Firewall.ToFirewallEntry("not-an-ip")); - } - - [Fact] - public void ReadFirewallSet_SurfacesAddedEntries() - { - Server.Network.Firewall.ResetForTesting(); - var entry = new SingleIpFirewallEntry(Ip("1.2.3.4")); - Server.Network.Firewall.Add(entry); - - Server.Network.Firewall.ReadFirewallSet(set => Assert.Contains(entry, set)); - } -} diff --git a/Projects/UOContent.Tests/Tests/Network/LoginAllowlist/LoginAllowlistTests.cs b/Projects/UOContent.Tests/Tests/Network/LoginAllowlist/LoginAllowlistTests.cs deleted file mode 100644 index 7f3780e58..000000000 --- a/Projects/UOContent.Tests/Tests/Network/LoginAllowlist/LoginAllowlistTests.cs +++ /dev/null @@ -1,218 +0,0 @@ -/************************************************************************* - * ModernUO * - * Copyright 2019-2026 - ModernUO Development Team * - * Email: hi@modernuo.com * - * File: LoginAllowlistTests.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.Net; -using Server.Network; -using Server.Network.Bans; -using Xunit; - -namespace Server.Tests.Network.LoginAllowlists; - -// The list is static, so every test resets it first. Addresses come from TEST-NET-3 (203.0.113.0/24) so they -// cannot collide with the blocklist tests if xUnit runs the two classes at the same time. -public class LoginAllowlistTests -{ - private const long Ttl = 90 * 24 * 60 * 60; // 90 days, the shipped default - private const long Window = 3600; - private const long Now = 1_800_000_000; - - private static void Reset(bool enabled = true, int strikes = 0) => - LoginAllowlist.LoadForTesting(enabled, Ttl, strikes, Window); - - [Fact] - public void Recent_login_allows_its_address() - { - Reset(); - var ip = IPAddress.Parse("203.0.113.10"); - - LoginAllowlist.RecordLogin(ip, Now); - - Assert.True(LoginAllowlist.IsAllowed(ip, Now)); - Assert.True(LoginAllowlist.IsAllowed(ip, Now + Ttl / 2)); - } - - [Fact] - public void Entry_lapses_once_past_the_ttl() - { - Reset(); - var ip = IPAddress.Parse("203.0.113.11"); - - LoginAllowlist.RecordLogin(ip, Now); - - Assert.True(LoginAllowlist.IsAllowed(ip, Now + Ttl)); // the boundary still counts - Assert.False(LoginAllowlist.IsAllowed(ip, Now + Ttl + 1)); // a second later it does not - } - - [Fact] - public void Logging_in_again_renews_the_window() - { - Reset(); - var ip = IPAddress.Parse("203.0.113.12"); - - LoginAllowlist.RecordLogin(ip, Now); - LoginAllowlist.RecordLogin(ip, Now + Ttl); // still allowed here, so the entry is refreshed - - Assert.True(LoginAllowlist.IsAllowed(ip, Now + Ttl + Ttl)); - } - - [Fact] - public void Unknown_address_is_not_allowed() - { - Reset(); - LoginAllowlist.RecordLogin(IPAddress.Parse("203.0.113.13"), Now); - - Assert.False(LoginAllowlist.IsAllowed(IPAddress.Parse("203.0.113.14"), Now)); - } - - [Fact] - public void Private_addresses_are_never_recorded() - { - Reset(); - - // A LAN or loopback login says nothing about the public internet. - LoginAllowlist.RecordLogin(IPAddress.Parse("10.0.0.5"), Now); - LoginAllowlist.RecordLogin(IPAddress.Loopback, Now); - - Assert.False(LoginAllowlist.IsAllowed(IPAddress.Parse("10.0.0.5"), Now)); - Assert.False(LoginAllowlist.IsAllowed(IPAddress.Loopback, Now)); - Assert.Equal(0, LoginAllowlist.Count); - } - - [Fact] - public void Disabled_list_allows_nobody() - { - Reset(enabled: false); - var ip = IPAddress.Parse("203.0.113.15"); - - LoginAllowlist.RecordLogin(ip, Now); - - Assert.False(LoginAllowlist.IsAllowed(ip, Now)); - Assert.Equal(0, LoginAllowlist.Count); - } - - [Fact] - public void Null_address_is_handled() - { - Reset(); - - LoginAllowlist.RecordLogin(null, Now); - - Assert.False(LoginAllowlist.IsAllowed(null, Now)); - } - - // ----- escalation ------------------------------------------------------------------------------- - - [Fact] - public void Manual_bans_are_never_exempt() - { - Reset(); - var ip = IPAddress.Parse("203.0.113.20"); - LoginAllowlist.RecordLogin(ip, Now); - - // An explicit decision must reach the reporters even for an allowlisted address. - Assert.False(LoginAllowlist.IsExemptFromEscalation(ip, BanReasons.Manual, Now)); - } - - [Fact] - public void Unopted_reasons_are_never_exempt() - { - Reset(); - var ip = IPAddress.Parse("203.0.113.21"); - LoginAllowlist.RecordLogin(ip, Now); - - // A reason nobody opted into IsBehavioral escalates normally rather than inheriting an exemption. - Assert.False(LoginAllowlist.IsExemptFromEscalation(ip, "some-future-reason", Now)); - Assert.False(LoginAllowlist.IsExemptFromEscalation(ip, BanReasons.Blocklist, Now)); - } - - [Fact] - public void Behavioral_reasons_are_exempt_while_allowed() - { - Reset(); - var ip = IPAddress.Parse("203.0.113.22"); - LoginAllowlist.RecordLogin(ip, Now); - - Assert.True(LoginAllowlist.IsExemptFromEscalation(ip, BanReasons.RateLimit, Now)); - Assert.True(LoginAllowlist.IsExemptFromEscalation(ip, BanReasons.SilentConnect, Now)); - Assert.True(LoginAllowlist.IsExemptFromEscalation(ip, BanReasons.InvalidSeed, Now)); - } - - [Fact] - public void Entry_is_revoked_once_the_strikes_run_out() - { - Reset(strikes: 3); - var ip = IPAddress.Parse("203.0.113.23"); - LoginAllowlist.RecordLogin(ip, Now); - - Assert.True(LoginAllowlist.IsExemptFromEscalation(ip, BanReasons.RateLimit, Now)); - Assert.True(LoginAllowlist.IsExemptFromEscalation(ip, BanReasons.RateLimit, Now)); - - // The third strike is the one that escalates, and it takes the entry with it. - Assert.False(LoginAllowlist.IsExemptFromEscalation(ip, BanReasons.RateLimit, Now)); - Assert.False(LoginAllowlist.IsAllowed(ip, Now)); - - // Still revoked afterwards, so everything from here escalates too. - Assert.False(LoginAllowlist.IsExemptFromEscalation(ip, BanReasons.RateLimit, Now)); - } - - [Fact] - public void Quiet_window_clears_the_tally() - { - Reset(strikes: 3); - var ip = IPAddress.Parse("203.0.113.24"); - LoginAllowlist.RecordLogin(ip, Now); - - Assert.True(LoginAllowlist.IsExemptFromEscalation(ip, BanReasons.RateLimit, Now)); - Assert.True(LoginAllowlist.IsExemptFromEscalation(ip, BanReasons.RateLimit, Now)); - - // Past the window the count restarts, so an occasional tripper never accumulates to revocation. - var later = Now + Window + 1; - Assert.True(LoginAllowlist.IsExemptFromEscalation(ip, BanReasons.RateLimit, later)); - Assert.True(LoginAllowlist.IsExemptFromEscalation(ip, BanReasons.RateLimit, later)); - Assert.True(LoginAllowlist.IsAllowed(ip, later)); - } - - [Fact] - public void Logging_in_again_forgives_accumulated_strikes() - { - Reset(strikes: 3); - var ip = IPAddress.Parse("203.0.113.25"); - LoginAllowlist.RecordLogin(ip, Now); - - Assert.True(LoginAllowlist.IsExemptFromEscalation(ip, BanReasons.RateLimit, Now)); - Assert.True(LoginAllowlist.IsExemptFromEscalation(ip, BanReasons.RateLimit, Now)); - - LoginAllowlist.RecordLogin(ip, Now); // someone proved they hold an account again - - Assert.True(LoginAllowlist.IsExemptFromEscalation(ip, BanReasons.RateLimit, Now)); - Assert.True(LoginAllowlist.IsExemptFromEscalation(ip, BanReasons.RateLimit, Now)); - Assert.True(LoginAllowlist.IsAllowed(ip, Now)); - } - - [Fact] - public void Zero_threshold_disables_revocation() - { - Reset(strikes: 0); - var ip = IPAddress.Parse("203.0.113.26"); - LoginAllowlist.RecordLogin(ip, Now); - - for (var i = 0; i < 50; i++) - { - Assert.True(LoginAllowlist.IsExemptFromEscalation(ip, BanReasons.RateLimit, Now)); - } - - Assert.True(LoginAllowlist.IsAllowed(ip, Now)); - } -} 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/Tests/Utilities/TryParseTests.cs b/Projects/UOContent.Tests/Tests/Utilities/TryParseTests.cs index 314290264..fa6de64a5 100644 --- a/Projects/UOContent.Tests/Tests/Utilities/TryParseTests.cs +++ b/Projects/UOContent.Tests/Tests/Utilities/TryParseTests.cs @@ -1,4 +1,3 @@ -using System; using Xunit; namespace Server.Tests.Utility; @@ -19,29 +18,4 @@ public class TryParseTests Assert.Equal(parsedAs, constructed); } } - - [Theory] - // Parsed directly into the target type (INumber.TryParse), not via ulong + Convert.ChangeType. - [InlineData(typeof(int), "42", true, 42)] - [InlineData(typeof(int), "-5", true, -5)] // signed values parse directly now - [InlineData(typeof(int), "0xFF", true, 255)] // hex - [InlineData(typeof(int), "notanumber", false, null)] - [InlineData(typeof(byte), "255", true, (byte)255)] - [InlineData(typeof(byte), "256", false, null)] // out of the byte range - [InlineData(typeof(uint), "4294967295", true, 4294967295u)] - [InlineData(typeof(long), "-9000000000", true, -9000000000L)] - public void TestTryParseNumeric(Type type, string value, bool success, object expected) - { - var error = Server.Types.TryParse(type, value, out var constructed); - - if (success) - { - Assert.Null(error); - Assert.Equal(expected, constructed); - } - else - { - Assert.NotNull(error); - } - } } 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..1e676ed9e 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,24 @@ 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); + } } [OnEvent(nameof(GameServer.GameServerLoginEvent))] @@ -455,9 +342,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; @@ -470,7 +355,6 @@ public static class AccountHandler else { acct.LogAccess(e.State); - LoginAllowlist.RecordLogin(e.State?.Address); logger.Information("Login: {NetState} Account '{Username}' at character list", e.State, un); e.State.Account = acct; 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/GenObjects.cs b/Projects/UOContent/Commands/GenObjects.cs new file mode 100644 index 000000000..dfd6903a1 --- /dev/null +++ b/Projects/UOContent/Commands/GenObjects.cs @@ -0,0 +1,56 @@ +using System.Collections.Generic; +using System.IO; +using Server.Json; +using Server.Logging; + +namespace Server.Commands; + +public static class GenObjects +{ + private static readonly ILogger logger = LogFactory.GetLogger(typeof(GenObjects)); + + public static void Configure() + { + CommandSystem.Register("GenObjects", AccessLevel.Developer, GenObjects_OnCommand); + } + + [Usage("GenObjects")] + [Aliases("GenObjWeb")] + [Description("Generates the objects cache (index + detail chunks) and syncs categorization.json.")] + private static void GenObjects_OnCommand(CommandEventArgs e) + { + var baseDir = Core.BaseDirectory; + var categorizationPath = Path.Combine(baseDir, "Data", "categorization.json"); + var categorization = JsonConfig.Deserialize>(categorizationPath) ?? []; + var discovered = ObjectIntrospection.DiscoverConstructibleTypes(); + + var result = ObjectCacheGenerator.Generate(categorization, discovered); + + var objectsDir = Path.Combine(baseDir, "Data", "objects"); + var detailDir = Path.Combine(objectsDir, "detail"); + Directory.CreateDirectory(detailDir); + + JsonConfig.Serialize(Path.Combine(objectsDir, "index.json"), result.Index); + foreach (var (chunkKey, map) in result.Chunks) + { + JsonConfig.Serialize(Path.Combine(detailDir, $"{chunkKey}.json"), map); + } + + JsonConfig.Serialize(categorizationPath, result.UpdatedCategorization); + + e.Mobile.SendMessage( + $"Objects cache written: {result.Index.Objects.Count} objects, {result.Chunks.Count} chunks. " + + $"Appended {result.Report.Appended.Count} to Uncategorized, {result.Report.Orphaned.Count} orphaned." + ); + + if (result.Report.Appended.Count > 0) + { + logger.Information("Appended to Uncategorized: {Types}", string.Join(", ", result.Report.Appended)); + } + + if (result.Report.Orphaned.Count > 0) + { + logger.Warning("Orphaned categorization entries: {Types}", string.Join(", ", result.Report.Orphaned)); + } + } +} diff --git a/Projects/UOContent/Commands/Generic/Commands/Commands.cs b/Projects/UOContent/Commands/Generic/Commands/Commands.cs index 102d5a2aa..81d85fc0a 100644 --- a/Projects/UOContent/Commands/Generic/Commands/Commands.cs +++ b/Projects/UOContent/Commands/Generic/Commands/Commands.cs @@ -9,7 +9,6 @@ using Server.Items; using Server.Mobiles; using Server.Multis; using Server.Network; -using Server.Network.Bans; using Server.Spells; namespace Server.Commands.Generic @@ -1155,8 +1154,7 @@ namespace Server.Commands.Generic try { - Firewall.Add(new SingleIpFirewallEntry(state.Address)); - BanChannel.Report(state.Address, TimeSpan.Zero, BanReasons.Manual); + AdminFirewall.Add(state.Address); AddResponse("They have been firewalled."); } catch (Exception ex) 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/Object Creation/CAGLoader.cs b/Projects/UOContent/Commands/Object Creation/CAGLoader.cs index cbded692c..6b9c37c8b 100644 --- a/Projects/UOContent/Commands/Object Creation/CAGLoader.cs +++ b/Projects/UOContent/Commands/Object Creation/CAGLoader.cs @@ -29,6 +29,175 @@ public static class CAGLoader private static readonly ILogger logger = LogFactory.GetLogger(typeof(CAGLoader)); public static CAGCategory Load() + { + var indexPath = Path.Combine(Core.BaseDirectory, "Data/objects/index.json"); + + if (!File.Exists(indexPath)) + { + logger.Warning("objects/index.json missing — run [GenObjects. Falling back to live categorization load."); + return LoadLegacy(); + } + + var index = JsonConfig.Deserialize(indexPath); + if (index?.Objects == null) + { + throw new JsonException($"Failed to deserialize {indexPath}."); + } + + var root = BuildTree(index); + AddFallbackForStaleCache(root, index); + return root; + } + + public static CAGCategory BuildTree(ObjectIndexFile index) + { + var root = new CAGCategory("Add Menu"); + var pending = new Dictionary>(); + + foreach (var entry in index.Objects) + { + var type = AssemblyHandler.FindTypeByName(entry.Type); + if (type == null) + { + logger.Warning("Cached type {Type} no longer resolves; skipping.", entry.Type); + continue; + } + + var category = NavigateToCategory(root, entry.Category); + if (!pending.TryGetValue(category, out var objects)) + { + objects = new List(); + pending[category] = objects; + } + + objects.Add( + new CAGObject + { + Type = type, + ItemID = entry.ItemID, + Hue = entry.Hue == 0 ? null : entry.Hue, + Parent = category + } + ); + } + + foreach (var (category, objects) in pending) + { + AppendNodes(category, objects); + } + + return root; + } + + private static CAGCategory NavigateToCategory(CAGCategory root, string dotted) + { + var parent = root; + foreach (var name in dotted.Split('.')) + { + var child = FindCategory(parent, name); + if (child == null) + { + child = new CAGCategory(name, parent); + AppendNode(parent, child); + } + + parent = child; + } + + return parent; + } + + private static CAGCategory FindCategory(CAGCategory parent, string title) + { + if (parent.Nodes == null) + { + return null; + } + + foreach (var node in parent.Nodes) + { + if (node is CAGCategory cat && cat.Title == title) + { + return cat; + } + } + + return null; + } + + private static void AppendNode(CAGCategory parent, CAGNode node) + { + var nodes = parent.Nodes ?? []; + var grown = new CAGNode[nodes.Length + 1]; + Array.Copy(nodes, grown, nodes.Length); + grown[^1] = node; + parent.Nodes = grown; + } + + private static void AppendObject(CAGCategory category, CAGObject obj) => AppendNode(category, obj); + + private static void AppendNodes(CAGCategory parent, IReadOnlyList nodes) + { + var existing = parent.Nodes ?? []; + var grown = new CAGNode[existing.Length + nodes.Count]; + Array.Copy(existing, grown, existing.Length); + for (var i = 0; i < nodes.Count; i++) + { + grown[existing.Length + i] = nodes[i]; + } + + parent.Nodes = grown; + } + + private static void AddFallbackForStaleCache(CAGCategory root, ObjectIndexFile index) + { + var cached = new HashSet(); + foreach (var entry in index.Objects) + { + cached.Add(entry.Type); + } + + var categorizationPath = Path.Combine(Core.BaseDirectory, "Data/categorization.json"); + var categorization = JsonConfig.Deserialize>(categorizationPath); + if (categorization == null) + { + return; + } + + foreach (var cag in categorization) + { + foreach (var obj in cag.Objects ?? []) + { + if (obj.Type == null || cached.Contains(obj.Type.Name)) + { + continue; + } + + logger.Warning("objects cache stale for {Type} — run [GenObjects.", obj.Type.Name); + try + { + var lean = ObjectIntrospection.ExtractLean(obj.Type); + var category = NavigateToCategory(root, cag.Category); + AppendObject( + category, + new CAGObject + { + Type = obj.Type, + ItemID = lean.ItemID, + Hue = lean.Hue == 0 ? null : lean.Hue, + Parent = category + } + ); + } + catch (Exception ex) + { + logger.Warning(ex, "Failed live fallback for {Type}.", obj.Type.Name); + } + } + } + } + + private static CAGCategory LoadLegacy() { var root = new CAGCategory("Add Menu"); var path = Path.Combine(Core.BaseDirectory, "Data/categorization.json"); diff --git a/Projects/UOContent/Commands/Object Creation/CategorizationSync.cs b/Projects/UOContent/Commands/Object Creation/CategorizationSync.cs new file mode 100644 index 000000000..90b8f0169 --- /dev/null +++ b/Projects/UOContent/Commands/Object Creation/CategorizationSync.cs @@ -0,0 +1,77 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Server.Items; + +namespace Server.Commands; + +public sealed record SyncReport(List Appended, List Orphaned); + +public static class CategorizationSync +{ + public static (List updated, SyncReport report) Reconcile( + List categorization, IReadOnlyList discovered + ) + { + var categorizedTypes = new HashSet(); + foreach (var cag in categorization) + { + foreach (var obj in cag.Objects ?? []) + { + if (obj.Type != null) + { + categorizedTypes.Add(obj.Type); + } + } + } + + var discoveredSet = new HashSet(discovered); + + var orphaned = categorizedTypes + .Where(t => !discoveredSet.Contains(t)) + .Select(t => t.Name) + .OrderBy(n => n, StringComparer.Ordinal) + .ToList(); + + var updated = new List(categorization); + var appended = new List(); + var itemAppend = new List(); + var mobileAppend = new List(); + + foreach (var type in discovered) + { + if (categorizedTypes.Contains(type)) + { + continue; + } + + appended.Add(type.Name); + var target = typeof(Mobile).IsAssignableFrom(type) ? mobileAppend : itemAppend; + target.Add(new CAGObject { Type = type }); + } + + AppendUncategorized(updated, "Items.Uncategorized", itemAppend); + AppendUncategorized(updated, "Mobiles.Uncategorized", mobileAppend); + + return (updated, new SyncReport(appended, orphaned)); + } + + private static void AppendUncategorized(List updated, string category, List toAdd) + { + if (toAdd.Count == 0) + { + return; + } + + var existing = updated.FirstOrDefault(c => c.Category == category); + if (existing == null) + { + updated.Add(new CAGJson { Category = category, Objects = toAdd.ToArray() }); + return; + } + + var merged = new List(existing.Objects ?? []); + merged.AddRange(toAdd); + updated[updated.IndexOf(existing)] = existing with { Objects = merged.ToArray() }; + } +} diff --git a/Projects/UOContent/Commands/Object Creation/ObjectCacheBuilder.cs b/Projects/UOContent/Commands/Object Creation/ObjectCacheBuilder.cs new file mode 100644 index 000000000..0ce79145f --- /dev/null +++ b/Projects/UOContent/Commands/Object Creation/ObjectCacheBuilder.cs @@ -0,0 +1,60 @@ +using System; +using System.Collections.Generic; + +namespace Server.Commands; + +public sealed record ExtractedObject( + Type Type, + string Entity, + string Category, + LeanMetadata Lean, + List Ctors, + List Properties, + List Opl, + string BaseType +); + +public static class ObjectCacheBuilder +{ + public static (ObjectIndexFile index, Dictionary> chunks) Build( + IReadOnlyList objects, string generatedUtc + ) + { + var index = new ObjectIndexFile { GeneratedUtc = generatedUtc }; + var chunks = new Dictionary>(); + + foreach (var obj in objects) + { + var chunkKey = ObjectNaming.ChunkKey(obj.Category); + + index.Objects.Add( + new ObjectIndexEntry + { + Type = obj.Type.Name, + Entity = obj.Entity, + Category = obj.Category, + Chunk = chunkKey, + ItemID = obj.Lean.ItemID, + Hue = obj.Lean.Hue, + Name = obj.Lean.Name, + Cliloc = obj.Lean.Cliloc + } + ); + + if (!chunks.TryGetValue(chunkKey, out var chunk)) + { + chunks[chunkKey] = chunk = new Dictionary(); + } + + chunk[obj.Type.Name] = new ObjectDetail + { + BaseType = obj.BaseType, + Ctors = obj.Ctors, + Properties = obj.Properties, + Opl = obj.Opl + }; + } + + return (index, chunks); + } +} diff --git a/Projects/UOContent/Commands/Object Creation/ObjectCacheGenerator.cs b/Projects/UOContent/Commands/Object Creation/ObjectCacheGenerator.cs new file mode 100644 index 000000000..1f7db92b6 --- /dev/null +++ b/Projects/UOContent/Commands/Object Creation/ObjectCacheGenerator.cs @@ -0,0 +1,67 @@ +using System; +using System.Collections.Generic; +using Server.Logging; + +namespace Server.Commands; + +public sealed record ObjectCacheResult( + ObjectIndexFile Index, + Dictionary> Chunks, + List UpdatedCategorization, + SyncReport Report +); + +public static class ObjectCacheGenerator +{ + private static readonly ILogger logger = LogFactory.GetLogger(typeof(ObjectCacheGenerator)); + + public static ObjectCacheResult Generate(List categorization, IReadOnlyList discovered) + { + var (updatedCategorization, report) = CategorizationSync.Reconcile(categorization, discovered); + + var categoryByType = new Dictionary(); + foreach (var cag in updatedCategorization) + { + foreach (var obj in cag.Objects ?? []) + { + if (obj.Type != null) + { + categoryByType[obj.Type] = cag.Category; + } + } + } + + var generatedUtc = DateTime.UtcNow.ToString("O"); + var extracted = new List(); + foreach (var type in discovered) + { + try + { + var entity = typeof(Mobile).IsAssignableFrom(type) ? "mobile" : "item"; + var category = categoryByType.TryGetValue(type, out var c) + ? c + : entity == "mobile" ? "Mobiles.Uncategorized" : "Items.Uncategorized"; + + extracted.Add( + new ExtractedObject( + type, + entity, + category, + ObjectIntrospection.ExtractLean(type), + ObjectIntrospection.ExtractCtors(type), + ObjectIntrospection.ExtractProperties(type), + ObjectIntrospection.ExtractOpl(type), + type.BaseType?.Name ?? "object" + ) + ); + } + catch (Exception ex) + { + logger.Warning(ex, "Failed to introspect {Type}; skipping.", type); + } + } + + var (index, chunks) = ObjectCacheBuilder.Build(extracted, generatedUtc); + return new ObjectCacheResult(index, chunks, updatedCategorization, report); + } +} diff --git a/Projects/UOContent/Commands/Object Creation/ObjectDocs.cs b/Projects/UOContent/Commands/Object Creation/ObjectDocs.cs new file mode 100644 index 000000000..8f18dd8fc --- /dev/null +++ b/Projects/UOContent/Commands/Object Creation/ObjectDocs.cs @@ -0,0 +1,93 @@ +using System; +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace Server.Commands; + +public static class ObjectNaming +{ + public static string ChunkKey(string category) => category.ToLowerInvariant().Replace(' ', '-'); + + public static string FriendlyTypeName(Type t) + { + if (t.IsGenericType && t.GetGenericTypeDefinition() == typeof(Nullable<>)) + { + return $"{FriendlyTypeName(Nullable.GetUnderlyingType(t))}?"; + } + + return t switch + { + _ when t == typeof(int) => "int", + _ when t == typeof(uint) => "uint", + _ when t == typeof(bool) => "bool", + _ when t == typeof(string) => "string", + _ when t == typeof(double) => "double", + _ when t == typeof(float) => "float", + _ when t == typeof(long) => "long", + _ when t == typeof(ulong) => "ulong", + _ when t == typeof(short) => "short", + _ when t == typeof(ushort) => "ushort", + _ when t == typeof(byte) => "byte", + _ when t == typeof(sbyte) => "sbyte", + _ when t == typeof(char) => "char", + _ when t == typeof(decimal) => "decimal", + _ => t.Name + }; + } +} + +public sealed class ObjectIndexEntry +{ + [JsonPropertyName("type")] public string Type { get; set; } + [JsonPropertyName("entity")] public string Entity { get; set; } + [JsonPropertyName("category")] public string Category { get; set; } + [JsonPropertyName("chunk")] public string Chunk { get; set; } + [JsonPropertyName("gfx")] public int ItemID { get; set; } + [JsonPropertyName("hue")] public int Hue { get; set; } + [JsonPropertyName("name")] public string Name { get; set; } + [JsonPropertyName("cliloc")] public int? Cliloc { get; set; } +} + +public sealed class ObjectIndexFile +{ + [JsonPropertyName("generatedUtc")] public string GeneratedUtc { get; set; } + [JsonPropertyName("objects")] public List Objects { get; set; } = []; +} + +public sealed class ParamDoc +{ + [JsonPropertyName("name")] public string Name { get; set; } + [JsonPropertyName("type")] public string Type { get; set; } + [JsonPropertyName("default")] public string Default { get; set; } + [JsonPropertyName("isParams")] public bool IsParams { get; set; } +} + +public sealed class CtorDoc +{ + [JsonPropertyName("parameters")] public List Parameters { get; set; } = []; +} + +public sealed class PropertyDoc +{ + [JsonPropertyName("name")] public string Name { get; set; } + [JsonPropertyName("type")] public string Type { get; set; } + [JsonPropertyName("readLevel")] public string ReadLevel { get; set; } + [JsonPropertyName("writeLevel")] public string WriteLevel { get; set; } + [JsonPropertyName("readOnly")] public bool ReadOnly { get; set; } + [JsonPropertyName("enumValues")] public string[] EnumValues { get; set; } +} + +public sealed class OplLine +{ + [JsonPropertyName("cliloc")] public int Cliloc { get; set; } + [JsonPropertyName("args")] public string Args { get; set; } + [JsonPropertyName("text")] public string Text { get; set; } +} + +public sealed class ObjectDetail +{ + [JsonPropertyName("baseType")] public string BaseType { get; set; } + [JsonPropertyName("ctors")] public List Ctors { get; set; } = []; + [JsonPropertyName("properties")] public List Properties { get; set; } = []; + [JsonPropertyName("opl")] public List Opl { get; set; } = []; +} diff --git a/Projects/UOContent/Commands/Object Creation/ObjectIntrospection.cs b/Projects/UOContent/Commands/Object Creation/ObjectIntrospection.cs new file mode 100644 index 000000000..f0a11b31c --- /dev/null +++ b/Projects/UOContent/Commands/Object Creation/ObjectIntrospection.cs @@ -0,0 +1,228 @@ +using System; +using System.Buffers.Binary; +using System.Collections.Generic; +using System.Reflection; +using System.Text; +using Server.Items; + +namespace Server.Commands; + +public readonly record struct LeanMetadata(int ItemID, int Hue, string Name, int? Cliloc); + +public static class ObjectIntrospection +{ + public static LeanMetadata ExtractLean(Type type) + { + if (type.IsAssignableTo(typeof(Item))) + { + var item = type.CreateInstance(); + try + { + var itemID = item.ItemID; + if (item is BaseAddon addon && addon.Components.Count == 1) + { + itemID = addon.Components[0].ItemID; + } + + if (itemID > TileData.MaxItemValue) + { + itemID = 1; + } + + var hue = item.Hue & 0x7FFF; + hue = (hue & 0x4000) != 0 ? 0 : hue; + + var cliloc = item.LabelNumber > 0 ? item.LabelNumber : (int?)null; + var name = item.Name ?? (cliloc.HasValue ? Server.Localization.GetText(cliloc.Value) : null); + + return new LeanMetadata(itemID, hue, name, cliloc); + } + finally + { + item.Delete(); + } + } + + if (type.IsAssignableTo(typeof(Mobile))) + { + var m = type.CreateInstance(); + try + { + var itemID = ShrinkTable.Lookup(m, 1); + var hue = m.Hue & 0x7FFF; + hue = (hue & 0x4000) != 0 ? 0 : hue; + return new LeanMetadata(itemID, hue, m.Name, null); + } + finally + { + m.Delete(); + } + } + + throw new ArgumentException($"{type} is neither Item nor Mobile.", nameof(type)); + } + + public static List ExtractCtors(Type type) + { + var docs = new List(); + + foreach (var ctor in type.GetConstructors()) + { + if (!Attributes.IsConstructible(ctor, AccessLevel.Developer)) + { + continue; + } + + var doc = new CtorDoc(); + foreach (var p in ctor.GetParameters()) + { + doc.Parameters.Add( + new ParamDoc + { + Name = p.Name, + Type = ObjectNaming.FriendlyTypeName(p.ParameterType), + Default = p.HasDefaultValue ? p.DefaultValue?.ToString() ?? "null" : null, + IsParams = p.IsDefined(typeof(ParamArrayAttribute), false) + } + ); + } + + docs.Add(doc); + } + + return docs; + } + + public static List ExtractProperties(Type type) + { + var docs = new List(); + + var props = type.GetProperties(BindingFlags.Instance | BindingFlags.Public); + foreach (var p in props) + { + var attr = p.GetCustomAttribute(true); + if (attr == null) + { + continue; + } + + var pt = p.PropertyType; + docs.Add( + new PropertyDoc + { + Name = p.Name, + Type = ObjectNaming.FriendlyTypeName(pt), + ReadLevel = attr.ReadLevel.ToString(), + WriteLevel = attr.WriteLevel.ToString(), + ReadOnly = attr.ReadOnly || !p.CanWrite, + EnumValues = pt.IsEnum ? Enum.GetNames(pt) : null + } + ); + } + + return docs; + } + + public static List ExtractOpl(Type type) + { + if (type.IsAssignableTo(typeof(Item))) + { + var item = type.CreateInstance(); + try + { + var opl = new ObjectPropertyList(item); + item.GetProperties(opl); + return DecodeOpl(opl); + } + finally + { + item.Delete(); + } + } + + if (type.IsAssignableTo(typeof(Mobile))) + { + var m = type.CreateInstance(); + try + { + var opl = new ObjectPropertyList(m); + m.GetProperties(opl); + return DecodeOpl(opl); + } + finally + { + m.Delete(); + } + } + + return []; + } + + private static List DecodeOpl(ObjectPropertyList opl) + { + opl.Terminate(); + var buffer = opl.Buffer; + var lines = new List(); + var pos = 15; // fixed OPL header length + + while (pos + 4 <= buffer.Length) + { + var cliloc = BinaryPrimitives.ReadInt32BigEndian(buffer.AsSpan(pos)); + pos += 4; + if (cliloc == 0) + { + break; + } + + var byteLen = BinaryPrimitives.ReadUInt16BigEndian(buffer.AsSpan(pos)); + pos += 2; + var args = byteLen > 0 ? Encoding.Unicode.GetString(buffer, pos, byteLen) : null; + pos += byteLen; + + lines.Add(new OplLine { Cliloc = cliloc, Args = args, Text = Localization.GetText(cliloc) }); + } + + return lines; + } + + public static List DiscoverConstructibleTypes() + { + var results = new List(); + + foreach (var asm in AssemblyHandler.Assemblies) + { + foreach (var type in AssemblyHandler.GetTypeCache(asm).Types) + { + if (type.IsAbstract) + { + continue; + } + + if (!typeof(Item).IsAssignableFrom(type) && !typeof(Mobile).IsAssignableFrom(type)) + { + continue; + } + + if (HasConstructibleCtor(type)) + { + results.Add(type); + } + } + } + + return results; + } + + private static bool HasConstructibleCtor(Type type) + { + foreach (var ctor in type.GetConstructors()) + { + if (Attributes.IsConstructible(ctor, AccessLevel.Developer)) + { + return true; + } + } + + return false; + } +} 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/Configuration/ExpansionConfiguration.cs b/Projects/UOContent/Configuration/ExpansionConfiguration.cs index 50fec1dc3..3ae24bbca 100644 --- a/Projects/UOContent/Configuration/ExpansionConfiguration.cs +++ b/Projects/UOContent/Configuration/ExpansionConfiguration.cs @@ -7,6 +7,7 @@ namespace Server { public static void Configure() { + Mobile.InsuranceEnabled = ServerConfiguration.GetSetting("insurance.enable", Core.AOS); ObjectPropertyList.Enabled = ServerConfiguration.GetSetting("opl.enable", Core.AOS); var visibleDamage = ServerConfiguration.GetSetting("visibleDamage", Core.AOS); Mobile.VisibleDamageType = visibleDamage ? VisibleDamageType.Related : VisibleDamageType.None; diff --git a/Projects/UOContent/Engines/Advanced Search/AdvancedSearchGump.cs b/Projects/UOContent/Engines/Advanced Search/AdvancedSearchGump.cs index 7f72c02b2..58a7f73a9 100644 --- a/Projects/UOContent/Engines/Advanced Search/AdvancedSearchGump.cs +++ b/Projects/UOContent/Engines/Advanced Search/AdvancedSearchGump.cs @@ -8,7 +8,6 @@ using Server.Commands; using Server.Commands.Generic; using Server.Engines.Spawners; using Server.Gumps; -using Server.Logging; using Server.Network; using Server.Saves; @@ -29,19 +28,11 @@ public enum AdvancedSearchGumpOptions : long public class AdvancedSearchGump : Gump { - private static readonly ILogger _logger = LogFactory.GetLogger(typeof(AdvancedSearchGump)); - private const int MaxEntries = 18; private static int _threadId; private static AdvancedSearchThreadWorker[] _threadWorkers; - private static int _searchInProgress; - - internal static bool IsSearchInProgress => Volatile.Read(ref _searchInProgress) == 1; - internal static bool TryBeginSearch() => Interlocked.CompareExchange(ref _searchInProgress, 1, 0) == 0; - internal static void EndSearch() => Volatile.Write(ref _searchInProgress, 0); - private static void Configure() { EventSink.Shutdown += Shutdown; @@ -133,10 +124,6 @@ public class AdvancedSearchGump : Gump public AdvancedSearchGump() : base(50, 50) => Build(); - // Entries on the current page — bounds the paging loop so a partial last page can't read past the end. - internal static int VisibleCount(int total, int displayFrom, int maxEntries) => - Math.Clamp(total - displayFrom, 0, maxEntries); - private void Build() { const int height = 500; @@ -332,14 +319,16 @@ public class AdvancedSearchGump : Gump var allDisplayedSelected = true; - // Bound to this page's real entries so a partial last page still renders in descending mode. - var visibleCount = VisibleCount(SearchResults.Length, DisplayFrom, MaxEntries); - - for (var i = 0; i < visibleCount; i++) + for (var i = 0; i < MaxEntries; i++) { - var offset = SortDescending ? visibleCount - 1 - i : i; + var offset = SortDescending ? MaxEntries - 1 - i : i; var index = offset + DisplayFrom; + if (index >= SearchResults.Length) + { + break; + } + var entry = SearchResults[index]; if (!entry.Selected) @@ -750,103 +739,78 @@ public class AdvancedSearchGump : Gump return; } - if (!TryBeginSearch()) + var autoSave = AutoSave.SavesEnabled; + if (autoSave) { - from.SendMessage("A search is already running. Please wait for it to finish."); - return; + AutoSave.SavesEnabled = false; } - _threadId = 0; + _threadWorkers ??= new AdvancedSearchThreadWorker[Math.Max(Environment.ProcessorCount - 1, 1)]; - var autoSave = AutoSave.SavesEnabled; - AutoSave.SavesEnabled = false; + var ignoreQueue = new ConcurrentQueue(); + var results = new ConcurrentQueue(); + var worldLocation = new WorldLocation(from.Location, from.Map); - try + for (var i = 0; i < _threadWorkers.Length; i++) { - _threadWorkers ??= new AdvancedSearchThreadWorker[Math.Max(Environment.ProcessorCount - 1, 1)]; + (_threadWorkers[i] ??= new AdvancedSearchThreadWorker()).Wake(worldLocation, Filter, results, ignoreQueue); + } - var ignoreQueue = new ConcurrentQueue(); - var results = new ConcurrentQueue(); - var worldLocation = new WorldLocation(from.Location, from.Map); + var type = Filter.FilterType ? Filter.Type : null; + // Push the entities + foreach (var item in World.Items.Values) + { + if (type == null || type.IsInstanceOfType(item)) + { + PushToWorkers(item); + } + } + + foreach (var m in World.Mobiles.Values) + { + if (type == null || type.IsInstanceOfType(m)) + { + PushToWorkers(m); + } + } + + ThreadPool.QueueUserWorkItem(state => + { + // Block until everything is processed for (var i = 0; i < _threadWorkers.Length; i++) { - (_threadWorkers[i] ??= new AdvancedSearchThreadWorker()).Wake(worldLocation, Filter, results, ignoreQueue); + _threadWorkers[i].Sleep(); } - var type = Filter.FilterType ? Filter.Type : null; + var ignoredEntities = new HashSet(ignoreQueue); - // Push the entities. Workers read entity state concurrently with the main loop — - // see AdvancedSearchThreadWorker for the accepted, bounded race. - foreach (var item in World.Items.Values) + // Force the GC to collect the ignored entities + ignoreQueue.Clear(); + + var resultsList = new List(results.Count); + foreach (var result in results) { - if (type == null || type.IsInstanceOfType(item)) + if (!ignoredEntities.Contains(result.Entity)) { - PushToWorkers(item); + resultsList.Add(result); } } - foreach (var m in World.Mobiles.Values) - { - if (type == null || type.IsInstanceOfType(m)) + SearchResults = resultsList.ToArray(); + + // Force the GC to collect the results + resultsList.Clear(); + resultsList.TrimExcess(); + + // Send the gump on the main thread + Core.LoopContext.Post( + autoSaveState => { - PushToWorkers(m); - } - } - - ThreadPool.QueueUserWorkItem(state => - { - try - { - // Block until everything is processed - for (var i = 0; i < _threadWorkers.Length; i++) - { - _threadWorkers[i].Sleep(); - } - - var ignoredEntities = new HashSet(ignoreQueue); - - // Force the GC to collect the ignored entities - ignoreQueue.Clear(); - - var resultsList = new List(results.Count); - foreach (var result in results) - { - if (!ignoredEntities.Contains(result.Entity)) - { - resultsList.Add(result); - } - } - - SearchResults = resultsList.ToArray(); - - // Force the GC to collect the results - resultsList.Clear(); - resultsList.TrimExcess(); - - // Send the gump on the main thread - Core.LoopContext.Post(() => Resend(from)); - } - catch (Exception ex) - { - // A drain-phase throw here would terminate the process; the finally still - // restores autosave and releases the guard. - _logger.Warning(ex, "AdvancedSearch: search drain failed"); - } - finally - { - AutoSave.SavesEnabled = (bool)state!; - EndSearch(); - } - }, autoSave); - } - catch - { - // Setup failed before the work item took ownership of the release. - AutoSave.SavesEnabled = autoSave; - EndSearch(); - throw; - } + AutoSave.SavesEnabled = (bool)autoSaveState!; + Resend(from); + }, state); + }, autoSave); } private void SetSortSwitches(int radioSwitch) diff --git a/Projects/UOContent/Engines/Advanced Search/AdvancedSearchThreadWorker.cs b/Projects/UOContent/Engines/Advanced Search/AdvancedSearchThreadWorker.cs index 5206477c9..d63c0a442 100644 --- a/Projects/UOContent/Engines/Advanced Search/AdvancedSearchThreadWorker.cs +++ b/Projects/UOContent/Engines/Advanced Search/AdvancedSearchThreadWorker.cs @@ -3,26 +3,13 @@ using System.Collections.Concurrent; using System.Reflection; using System.Threading; using Server.Items; -using Server.Logging; using Server.Mobiles; using Server.Multis; namespace Server.Engines.AdvancedSearch; -/// -/// Filters entities on a background thread while the main loop keeps mutating them — an -/// intentional, bounded race. Reads of live / state are -/// unsynchronized, so a torn read may report stale coordinates, and any -/// getter that throws mid-read is caught per-entity in and skipped. -/// Results are best-effort and may omit a concurrently modified entity, but never fault or corrupt -/// server state. Eliminating the race would require snapshotting each read field onto the main -/// thread before handing entities off; that is deferred. -/// public class AdvancedSearchThreadWorker { - private static readonly ILogger _logger = LogFactory.GetLogger(typeof(AdvancedSearchThreadWorker)); - private static readonly ConcurrentDictionary _propCache = new(); - private readonly Thread _thread; private readonly AutoResetEvent _startEvent; // Main thread tells the thread to start working private readonly AutoResetEvent _stopEvent; // Main thread waits for the worker finish draining @@ -39,10 +26,7 @@ public class AdvancedSearchThreadWorker _startEvent = new AutoResetEvent(false); _stopEvent = new AutoResetEvent(false); _entities = new ConcurrentQueue(); - _thread = new Thread(Execute) - { - IsBackground = true - }; + _thread = new Thread(Execute); _thread.Start(this); } @@ -68,16 +52,10 @@ public class AdvancedSearchThreadWorker public void Exit() { - Volatile.Write(ref _exit, true); + _exit = true; Wake(WorldLocation.Zero, null, null, null); - - // Tolerate a worker that has already terminated (e.g. Core.Closing raced us) so - // shutdown can't deadlock waiting on a stopEvent that will never be set. - if (_thread.IsAlive) - { - Sleep(); - } + Sleep(); } public void Push(IEntity entity) @@ -110,22 +88,12 @@ public class AdvancedSearchThreadWorker worker._filter = null; break; } - else - { - // Transiently empty but not yet paused: yield rather than busy-spin. - Thread.Yield(); - } } - // The owning thread may start another cycle the moment _stopEvent is set (Exit does exactly - // that). Clear _pause and sample the exit condition before signaling, or the new cycle's - // pause request is clobbered / its Sleep orphaned. Matches SerializationThreadWorker. - var exiting = Core.Closing || Volatile.Read(ref worker._exit); - Volatile.Write(ref worker._pause, false); - worker._stopEvent.Set(); // Allow the main thread to continue now that we are finished + worker._pause = false; - if (exiting) + if (Core.Closing || worker._exit) { return; } @@ -134,28 +102,9 @@ public class AdvancedSearchThreadWorker private AdvancedSearchResult DoEntitySearch(IEntity entity) { - if (entity == null || entity.Deleted) + if (_filter.HideValidInternalMap) { - return null; - } - - try - { - return DoEntitySearchCore(entity); - } - catch (Exception ex) - { - _logger.Warning(ex, "AdvancedSearch: filter threw for {Entity}; skipping", entity); - return null; - } - } - - private AdvancedSearchResult DoEntitySearchCore(IEntity entity) - { - if (_filter == null) - { - // Exit() clears the filter; a straggler entity dequeued after teardown bails here. - return null; + HandleValidInternal(entity); } // Check for valid map @@ -189,12 +138,6 @@ public class AdvancedSearchThreadWorker return null; } - // After the cheap filters, so non-qualifying entities skip the house/keyring enumeration. - if (_filter.HideValidInternalMap) - { - HandleValidInternal(entity); - } - if (entity is Mobile mobile) { return DoMobileSearch(mobile); @@ -353,17 +296,27 @@ public class AdvancedSearchThreadWorker } } - private static bool EvaluateRecursive(IEntity entity, ReadOnlySpan span) => - AdvancedSearchUtilities.EvaluateBoolean(span, entity, static (e, leaf) => EvaluateSingleExpression(e, leaf)); + private static bool EvaluateRecursive(IEntity entity, ReadOnlySpan span) + { + var atIndex = span.IndexOf('@'); + var orIndex = span.IndexOf('|'); + + if (atIndex == -1 && orIndex == -1) + { + return EvaluateSingleExpression(entity, span); + } + + var result = atIndex != -1; + var splitIndex = result ? atIndex : orIndex; + + var left = EvaluateRecursive(entity, span.Slice(0, splitIndex)); + var right = EvaluateRecursive(entity, span.Slice(splitIndex + 1)); + + return result ? left && right : left || right; + } private static bool EvaluateSingleExpression(IEntity entity, ReadOnlySpan expression) { - expression = expression.Trim(); - if (expression.Length == 0) - { - return false; - } - var negate = false; if (expression[0] == '~') { @@ -385,7 +338,7 @@ public class AdvancedSearchThreadWorker return false; } - var properties = _propCache.GetOrAdd(entity.GetType(), static t => t.GetProperties()); + var properties = entity.GetType().GetProperties(); PropertyInfo property = null; for (var i = 0; i < properties.Length; ++i) { diff --git a/Projects/UOContent/Engines/Advanced Search/AdvancedSearchUtilities.cs b/Projects/UOContent/Engines/Advanced Search/AdvancedSearchUtilities.cs index 6775dcaef..8d7dae3c4 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; @@ -42,80 +43,77 @@ public static class AdvancedSearchUtilities if (propertyType == typeof(long)) { - return TryParseValue(valuePart, out var parsedValue) && - CompareNumeric((long)propertyValue!, parsedValue, operatorSpan); + var parsedValue = ParseValue(valuePart); + return CompareNumeric((long)propertyValue!, parsedValue, operatorSpan); } if (propertyType == typeof(ulong)) { - return TryParseValue(valuePart, out var parsedValue) && - CompareNumeric((ulong)propertyValue!, parsedValue, operatorSpan); + var parsedValue = ParseValue(valuePart); + return CompareNumeric((ulong)propertyValue!, parsedValue, operatorSpan); } if (propertyType == typeof(int)) { - return TryParseValue(valuePart, out var parsedValue) && - CompareNumeric((int)propertyValue!, parsedValue, operatorSpan); + var parsedValue = ParseValue(valuePart); + return CompareNumeric((int)propertyValue!, parsedValue, operatorSpan); } if (propertyType == typeof(uint)) { - return TryParseValue(valuePart, out var parsedValue) && - CompareNumeric((uint)propertyValue!, parsedValue, operatorSpan); + var parsedValue = ParseValue(valuePart); + return CompareNumeric((uint)propertyValue!, parsedValue, operatorSpan); } if (propertyType == typeof(short)) { - return TryParseValue(valuePart, out var parsedValue) && - CompareNumeric((short)propertyValue!, parsedValue, operatorSpan); + var parsedValue = ParseValue(valuePart); + return CompareNumeric((short)propertyValue!, parsedValue, operatorSpan); } if (propertyType == typeof(ushort)) { - return TryParseValue(valuePart, out var parsedValue) && - CompareNumeric((ushort)propertyValue!, parsedValue, operatorSpan); + var parsedValue = ParseValue(valuePart); + return CompareNumeric((ushort)propertyValue!, parsedValue, operatorSpan); } if (propertyType == typeof(sbyte)) { - return TryParseValue(valuePart, out var parsedValue) && - CompareNumeric((sbyte)propertyValue!, parsedValue, operatorSpan); + var parsedValue = ParseValue(valuePart); + return CompareNumeric((sbyte)propertyValue!, parsedValue, operatorSpan); } if (propertyType == typeof(byte)) { - return TryParseValue(valuePart, out var parsedValue) && - CompareNumeric((byte)propertyValue!, parsedValue, operatorSpan); + var parsedValue = ParseValue(valuePart); + return CompareNumeric((byte)propertyValue!, parsedValue, operatorSpan); } if (propertyType == typeof(float)) { - return TryParseValue(valuePart, out var parsedValue) && - Compare((float)propertyValue!, parsedValue, valuePart, operatorSpan); + var parsedValue = ParseValue(valuePart); + return Compare((float)propertyValue!, parsedValue, valuePart, operatorSpan); } if (propertyType == typeof(double)) { - return TryParseValue(valuePart, out var parsedValue) && - Compare((double)propertyValue!, parsedValue, valuePart, operatorSpan); + var parsedValue = ParseValue(valuePart); + return Compare((double)propertyValue!, parsedValue, valuePart, operatorSpan); } if (propertyType == typeof(string)) { - return TryParseValue(valuePart, out var parsedValue) && - Compare((string)propertyValue!, parsedValue, operatorSpan); + var parsedValue = ParseValue(valuePart); + return Compare((string)propertyValue!, parsedValue, operatorSpan); } if (propertyType == typeof(TimeSpan)) { - return TryParseValue(valuePart, out var parsedValue) && - Compare((TimeSpan)propertyValue!, parsedValue, operatorSpan); + var parsedValue = ParseValue(valuePart); + return Compare((TimeSpan)propertyValue!, parsedValue, operatorSpan); } if (propertyType == typeof(DateTime)) { - return TryParseValue(valuePart, out var parsedValue) && - Compare((DateTime)propertyValue!, parsedValue, operatorSpan); + var parsedValue = ParseValue(valuePart); + return Compare((DateTime)propertyValue!, parsedValue, operatorSpan); } if (propertyType == typeof(bool)) { - return TryParseValue(valuePart, out var parsedValue) && - Compare((bool)propertyValue!, parsedValue, operatorSpan); + var parsedValue = ParseValue(valuePart); + return Compare((bool)propertyValue!, parsedValue, operatorSpan); } if (propertyType.IsEnum) { - if (!Enum.TryParse(propertyType, valuePart.ToString(), true, out var valueEnum) || valueEnum == null) - { - return false; - } + var valueEnum = Enum.Parse(propertyType, valuePart, false); return GetEnumSize(propertyType) switch { @@ -123,17 +121,15 @@ public static class AdvancedSearchUtilities 2 => CompareNumeric((short)propertyValue!, (short)valueEnum, operatorSpan), 4 => CompareNumeric((int)propertyValue!, (int)valueEnum, operatorSpan), 8 => CompareNumeric((long)propertyValue!, (long)valueEnum, operatorSpan), - _ => false }; } - // Anything the hot typed paths above didn't handle — reference types (Poison, Map, entity - // properties resolved by serial), IParsable value types (Guid, decimal, ...), and legacy - // RunUO types with a static Parse(string) (Faction, Town, ...). Delegate to the shared, - // thread-safe Types converter so the target is parsed into the property's real type, then - // compare by value. A string is allocated here, but this is the uncommon path; the common - // types never reach it. Types returns a non-null message when it can't parse -> no match. - return Types.TryParse(propertyType, valuePart.ToString(), out var parsed) == null && - CompareReference(propertyValue!, parsed, operatorSpan); + if (!propertyType.IsValueType) + { + var parsedValue = ParseValue(valuePart); + return CompareReference(propertyValue!, parsedValue, operatorSpan); + } + + return false; } public static bool CompareNumeric(T propertyValue, T parsedValue, ReadOnlySpan operatorSpan) where T : INumber => @@ -240,40 +236,19 @@ public static class AdvancedSearchUtilities _ => false }; - public static bool CompareReference(T propertyValue, T parsedValue, ReadOnlySpan operatorSpan) - { - switch (operatorSpan) + public static bool CompareReference(T propertyValue, T parsedValue, ReadOnlySpan operatorSpan) => + operatorSpan switch { - case "=": - case "==": return Equals(propertyValue, parsedValue); - case "!": - case "!=": return !Equals(propertyValue, parsedValue); - } + "=" or "==" => propertyValue.Equals(parsedValue), + "!" or "!=" => !propertyValue.Equals(parsedValue), + ">" => Comparer.Default.Compare(propertyValue, parsedValue) > 0, + "<" => Comparer.Default.Compare(propertyValue, parsedValue) < 0, + ">=" => Comparer.Default.Compare(propertyValue, parsedValue) >= 0, + "<=" => Comparer.Default.Compare(propertyValue, parsedValue) <= 0, + _ => false + }; - if (propertyValue is IComparable cmp && parsedValue != null) - { - try - { - var c = cmp.CompareTo(parsedValue); - return operatorSpan switch - { - ">" => c > 0, - "<" => c < 0, - ">=" => c >= 0, - "<=" => c <= 0, - _ => false - }; - } - catch - { - return false; - } - } - - return false; - } - - internal static bool TryParseValue(ReadOnlySpan valuePart, out T value) + public static T ParseValue(ReadOnlySpan valuePart) { // Special handling for boolean and hexadecimal values if (typeof(T) == typeof(bool)) @@ -281,149 +256,74 @@ public static class AdvancedSearchUtilities var val = valuePart.ToString().ToLower(); if (val is "true" or "1" or "enabled" or "on") { - value = (T)(object)true; - return true; + return (T)(object)true; } if (val is "false" or "0" or "disabled" or "off") { - value = (T)(object)false; - return true; + return (T)(object)false; } - - value = default; - return false; } if (typeof(T) == typeof(long)) { - return TryParseNumericValue(valuePart, out value); + return ParseNumericValue(valuePart); } if (typeof(T) == typeof(ulong)) { - return TryParseNumericValue(valuePart, out value); + return ParseNumericValue(valuePart); } if (typeof(T) == typeof(int)) { - return TryParseNumericValue(valuePart, out value); + return ParseNumericValue(valuePart); } if (typeof(T) == typeof(uint)) { - return TryParseNumericValue(valuePart, out value); + return ParseNumericValue(valuePart); } if (typeof(T) == typeof(short)) { - return TryParseNumericValue(valuePart, out value); + return ParseNumericValue(valuePart); } if (typeof(T) == typeof(ushort)) { - return TryParseNumericValue(valuePart, out value); + return ParseNumericValue(valuePart); } if (typeof(T) == typeof(sbyte)) { - return TryParseNumericValue(valuePart, out value); + return ParseNumericValue(valuePart); } if (typeof(T) == typeof(byte)) { - return TryParseNumericValue(valuePart, out value); + return ParseNumericValue(valuePart); } if (typeof(T) == typeof(float)) { - return TryParseNumericValue(valuePart, out value); + return ParseNumericValue(valuePart); } if (typeof(T) == typeof(double)) { - return TryParseNumericValue(valuePart, out value); + return ParseNumericValue(valuePart); } - // string needs no parsing — the span itself is the value. - if (typeof(T) == typeof(string)) - { - value = (T)(object)valuePart.ToString(); - return true; - } - - // Remaining supported types (TimeSpan, DateTime) parse straight from the span via - // ISpanParsable — no allocation, no reflection, and unlike Convert.ChangeType it handles - // TimeSpan, which is not IConvertible and previously failed silently. - if (typeof(T) == typeof(TimeSpan)) - { - return TryParseSpanParsable(valuePart, out value); - } - - if (typeof(T) == typeof(DateTime)) - { - return TryParseSpanParsable(valuePart, out value); - } - - value = default; - return false; - } - - // Parses U (a value type exposing ISpanParsable) from the span and reinterprets it as T. The - // two type params mirror TryParseNumericValue: the caller dispatches on typeof(T), so U == T at - // every call site and the (T)(object) cast is always valid. - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private static bool TryParseSpanParsable(ReadOnlySpan valuePart, out T value) where U : ISpanParsable - { - if (U.TryParse(valuePart, null, out var parsed)) - { - value = (T)(object)parsed; - return true; - } - - value = default; - return false; + // Default parsing for other types + return (T)Convert.ChangeType(valuePart.ToString(), typeof(T)); } [MethodImpl(MethodImplOptions.AggressiveInlining)] - private static bool TryParseNumericValue(ReadOnlySpan valuePart, out R value) where T : INumber - { - var ok = valuePart.StartsWith("0x") - ? T.TryParse(valuePart[2..], NumberStyles.HexNumber, null, out var parsed) - : T.TryParse(valuePart, null, out parsed); - - if (ok) - { - value = (R)(object)parsed; - return true; - } - - value = default; - return false; - } - - // Evaluates one trimmed leaf atom against caller-supplied state. A custom delegate is required - // because ReadOnlySpan cannot be a Func<> type argument; passing state avoids a per-call - // capturing closure, so the recursion allocates neither a string nor a closure. - internal delegate bool LeafEvaluator(TState state, ReadOnlySpan leaf); - - // OR ('|') binds looser than AND ('@'); split on the outermost OR first, then AND. - internal static bool EvaluateBoolean(ReadOnlySpan expr, TState state, LeafEvaluator evalLeaf) - { - var orIndex = expr.IndexOf('|'); - if (orIndex != -1) - { - return EvaluateBoolean(expr[..orIndex], state, evalLeaf) || EvaluateBoolean(expr[(orIndex + 1)..], state, evalLeaf); - } - - var andIndex = expr.IndexOf('@'); - if (andIndex != -1) - { - return EvaluateBoolean(expr[..andIndex], state, evalLeaf) && EvaluateBoolean(expr[(andIndex + 1)..], state, evalLeaf); - } - - return evalLeaf(state, expr.Trim()); - } + private static R ParseNumericValue(ReadOnlySpan valuePart) where T : INumber => + valuePart.StartsWith("0x") + ? (R)(object)T.Parse(valuePart[2..], NumberStyles.HexNumber, null) + : (R)(object)T.Parse(valuePart, null); private static int GetEnumSize(Type enumType) => Type.GetTypeCode(Enum.GetUnderlyingType(enumType)) switch 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/Core/Faction.cs b/Projects/UOContent/Engines/Factions/Core/Faction.cs index 6c440584b..8aa8085ca 100644 --- a/Projects/UOContent/Engines/Factions/Core/Faction.cs +++ b/Projects/UOContent/Engines/Factions/Core/Faction.cs @@ -16,7 +16,7 @@ using Server.Targeting; namespace Server.Factions; [CustomEnum(["Minax", "Council of Mages", "True Britannians", "Shadowlords"])] -public abstract class Faction : IComparable, ISpanParsable +public abstract class Faction : IComparable { public const int StabilityFactor = 300; // 300% greater (3 times) than smallest faction public const int StabilityActivation = 200; // Stability code goes into effect when largest faction has > 200 people @@ -242,11 +242,7 @@ public abstract class Faction : IComparable, ISpanParsable public virtual void AddMember(Mobile mob) { - var state = new PlayerState(mob, this, Members); - Members.Insert(ZeroRankOffset, state); - - // Ranked after the insert: the ctor ran while Owner was still short a member. - state.UpdateRank(); + Members.Insert(ZeroRankOffset, new PlayerState(mob, this, Members)); mob.AddToBackpack(FactionItem.Imbue(new Robe(), this, false, Definition.HuePrimary)); mob.SendLocalizedMessage(1010374); // You have been granted a robe which signifies your faction @@ -1317,27 +1313,7 @@ public abstract class Faction : IComparable, ISpanParsable return null; } - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static Faction Parse(string s) => Parse(s, null); - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static Faction Parse(string s, IFormatProvider provider) => Parse(s.AsSpan(), provider); - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static bool TryParse(string s, IFormatProvider provider, out Faction result) => - TryParse(s.AsSpan(), provider, out result); - - public static Faction Parse(ReadOnlySpan s, IFormatProvider provider) - { - if (TryParse(s, provider, out var result)) - { - return result; - } - - throw new FormatException($"The input string '{s}' was not in a correct format."); - } - - public static bool TryParse(ReadOnlySpan s, IFormatProvider provider, out Faction result) + public static Faction Parse(string name) { var factions = Factions; @@ -1345,15 +1321,13 @@ public abstract class Faction : IComparable, ISpanParsable { var faction = factions[i]; - if (s.InsensitiveEquals(faction.Definition.FriendlyName)) + if (faction.Definition.FriendlyName.InsensitiveEquals(name)) { - result = faction; - return true; + return faction; } } - result = null; - return false; + return null; } public static bool InSkillLoss(Mobile mob) => m_SkillLoss.ContainsKey(mob); diff --git a/Projects/UOContent/Engines/Factions/Core/FactionState.cs b/Projects/UOContent/Engines/Factions/Core/FactionState.cs index b8eba5091..0170c718c 100644 --- a/Projects/UOContent/Engines/Factions/Core/FactionState.cs +++ b/Projects/UOContent/Engines/Factions/Core/FactionState.cs @@ -114,13 +114,6 @@ public class FactionState } } - // The loop above only assigns RankIndex to members with kill points, and nothing - // computes rank on read, so rank everyone now that the ordering has settled. - foreach (var player in Members) - { - player.UpdateRank(); - } - FactionItems = []; if (version >= 2) diff --git a/Projects/UOContent/Engines/Factions/Core/PlayerState.cs b/Projects/UOContent/Engines/Factions/Core/PlayerState.cs index e420836ce..792d2b4e3 100644 --- a/Projects/UOContent/Engines/Factions/Core/PlayerState.cs +++ b/Projects/UOContent/Engines/Factions/Core/PlayerState.cs @@ -8,6 +8,7 @@ public class PlayerState : IComparable { private Town m_Finance; + private bool m_InvalidateRank = true; private int m_KillPoints; private MerchantTitle m_MerchantTitle; private RankDefinition m_Rank; @@ -21,10 +22,6 @@ public class PlayerState : IComparable Faction = faction; Owner = owner; - // Owner does not contain this state yet, so the count is short by one; the caller ranks it - // after inserting. - SeedLowestRank(); - Attach(); Invalidate(); } @@ -57,9 +54,6 @@ public class PlayerState : IComparable } } - // Members are still being read; FactionState ranks everyone once the ordering settles. - SeedLowestRank(); - Attach(); } @@ -122,8 +116,6 @@ public class PlayerState : IComparable Owner.Remove(this); Owner.Insert(Faction.ZeroRankOffset, this); - // Direct, not through RankIndex: ZeroRankOffset is mid-update. The - // UpdateRank() at the end of this setter covers it. m_RankIndex = Faction.ZeroRankOffset; Faction.ZeroRankOffset++; } @@ -188,7 +180,6 @@ public class PlayerState : IComparable } m_KillPoints = value; - UpdateRank(); Invalidate(); } } @@ -202,72 +193,49 @@ public class PlayerState : IComparable if (m_RankIndex != value) { m_RankIndex = value; + m_InvalidateRank = true; + } + } + } + + public RankDefinition Rank + { + get + { + if (m_InvalidateRank) + { + var ranks = Faction.Definition.Ranks; + int percent; + + if (Owner.Count == 1) + { + percent = 1000; + } + else if (m_RankIndex == -1) + { + percent = 0; + } + else + { + percent = (Faction.ZeroRankOffset - m_RankIndex) * 1000 / Faction.ZeroRankOffset; + } + + for (var i = 0; i < ranks.Length; i++) + { + var check = ranks[i]; + + if (percent >= check.Required) + { + m_Rank = check; + m_InvalidateRank = false; + break; + } + } - UpdateRank(); Invalidate(); } - } - } - /// - /// Read from PlayerMobile.GetProperties, so it must stay a plain field read -- recomputing or - /// invalidating here re-enters the property list build. Maintained by . - /// - public RankDefinition Rank => m_Rank; - - // Lowest rank (Required 0): correct for an unranked member, and never null, so Rank.Title - // cannot NRE before the first UpdateRank(). - private void SeedLowestRank() - { - var ranks = Faction.Definition.Ranks; - - if (ranks.Length > 0) - { - m_Rank = ranks[^1]; - } - } - - /// - /// Recomputes the cached rank. Call whenever , the faction's - /// ZeroRankOffset, or the member count changes -- and only once they have settled. - /// - public void UpdateRank() - { - var ranks = Faction.Definition.Ranks; - - if (ranks.Length == 0) - { - return; - } - - int percent; - - if (Owner.Count == 1) - { - percent = 1000; - } - else if (m_RankIndex == -1 || Faction.ZeroRankOffset <= 0) - { - percent = 0; - } - else - { - percent = (Faction.ZeroRankOffset - m_RankIndex) * 1000 / Faction.ZeroRankOffset; - } - - // Ranks run Required-descending ending at 0, so anything >= 0 matches below. A negative - // percent (RankIndex out of sync with ZeroRankOffset) would otherwise leave it null. - m_Rank = ranks[^1]; - - for (var i = 0; i < ranks.Length; i++) - { - var check = ranks[i]; - - if (percent >= check.Required) - { - m_Rank = check; - break; - } + return m_Rank; } } diff --git a/Projects/UOContent/Engines/Factions/Core/Town.cs b/Projects/UOContent/Engines/Factions/Core/Town.cs index a30dc23a6..8aa136ff6 100644 --- a/Projects/UOContent/Engines/Factions/Core/Town.cs +++ b/Projects/UOContent/Engines/Factions/Core/Town.cs @@ -1,12 +1,11 @@ using System; using System.Collections.Generic; -using System.Runtime.CompilerServices; using Server.Targeting; namespace Server.Factions; [CustomEnum(["Britain", "Magincia", "Minoc", "Moonglow", "Skara Brae", "Trinsic", "Vesper", "Yew"])] -public abstract class Town : IComparable, ISpanParsable +public abstract class Town : IComparable { public const int SilverCaptureBonus = 10000; @@ -483,27 +482,7 @@ public abstract class Town : IComparable, ISpanParsable return null; } - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static Town Parse(string s) => Parse(s, null); - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static Town Parse(string s, IFormatProvider provider) => Parse(s.AsSpan(), provider); - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static bool TryParse(string s, IFormatProvider provider, out Town result) => - TryParse(s.AsSpan(), provider, out result); - - public static Town Parse(ReadOnlySpan s, IFormatProvider provider) - { - if (TryParse(s, provider, out var result)) - { - return result; - } - - throw new FormatException($"The input string '{s}' was not in a correct format."); - } - - public static bool TryParse(ReadOnlySpan s, IFormatProvider provider, out Town result) + public static Town Parse(string name) { var towns = Towns; @@ -511,15 +490,13 @@ public abstract class Town : IComparable, ISpanParsable { var town = towns[i]; - if (s.InsensitiveEquals(town.Definition.FriendlyName)) + if (town.Definition.FriendlyName.InsensitiveEquals(name)) { - result = town; - return true; + return town; } } - result = null; - return false; + return null; } [Usage("GrantTownSilver ")] 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/FeatureFlags/FeatureFlagManager.cs b/Projects/UOContent/Engines/FeatureFlags/FeatureFlagManager.cs index e599aeeb6..0402ad9a1 100644 --- a/Projects/UOContent/Engines/FeatureFlags/FeatureFlagManager.cs +++ b/Projects/UOContent/Engines/FeatureFlags/FeatureFlagManager.cs @@ -962,21 +962,20 @@ public static class FeatureFlagManager _ = key.ToLowerInvariant() switch { // Server project flags - "player_trading" => ServerFeatureFlags.PlayerTrading = enabled, - "pvp_combat" => ServerFeatureFlags.PvPCombat = enabled, - "bank_access" => ServerFeatureFlags.BankAccess = enabled, - "speedhack_detection" => ServerFeatureFlags.SpeedhackDetection = enabled, - "insurance" => ServerFeatureFlags.InsuranceEnabled = enabled, + "player_trading" => ServerFeatureFlags.PlayerTrading = enabled, + "pvp_combat" => ServerFeatureFlags.PvPCombat = enabled, + "bank_access" => ServerFeatureFlags.BankAccess = enabled, + "speedhack_detection" => ServerFeatureFlags.SpeedhackDetection = enabled, // UOContent flags - "vendor_purchase" => ContentFeatureFlags.VendorPurchase = enabled, - "vendor_sell" => ContentFeatureFlags.VendorSell = enabled, - "player_vendors" => ContentFeatureFlags.PlayerVendors = enabled, - "house_placement" => ContentFeatureFlags.HousePlacement = enabled, - "boat_placement" => ContentFeatureFlags.BoatPlacement = enabled, - "bulk_orders" => ContentFeatureFlags.BulkOrders = enabled, - "passive_detect_hidden" => ContentFeatureFlags.PassiveDetectHidden = enabled, - "young_player_system" => ContentFeatureFlags.YoungPlayerSystem = enabled, + "vendor_purchase" => ContentFeatureFlags.VendorPurchase = enabled, + "vendor_sell" => ContentFeatureFlags.VendorSell = enabled, + "player_vendors" => ContentFeatureFlags.PlayerVendors = enabled, + "house_placement" => ContentFeatureFlags.HousePlacement = enabled, + "boat_placement" => ContentFeatureFlags.BoatPlacement = enabled, + "bulk_orders" => ContentFeatureFlags.BulkOrders = enabled, + "passive_detect_hidden" => ContentFeatureFlags.PassiveDetectHidden = enabled, + "young_player_system" => ContentFeatureFlags.YoungPlayerSystem = enabled, "bitmap_pathfinding_cache" => ContentFeatureFlags.BitmapPathfindingCache = enabled, }; } 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/Insurance/Gumps/CancelRenewInventoryInsuranceGump.cs b/Projects/UOContent/Engines/Insurance/Gumps/CancelRenewInventoryInsuranceGump.cs deleted file mode 100644 index e5cad5e68..000000000 --- a/Projects/UOContent/Engines/Insurance/Gumps/CancelRenewInventoryInsuranceGump.cs +++ /dev/null @@ -1,55 +0,0 @@ -using Server.Mobiles; -using Server.Network; - -namespace Server.Gumps; - -public class CancelRenewInventoryInsuranceGump : StaticGump -{ - private readonly ItemInsuranceMenuGump _insuranceGump; - - public override bool Singleton => true; - - public CancelRenewInventoryInsuranceGump(ItemInsuranceMenuGump insuranceGump) : base(250, 200) => - _insuranceGump = insuranceGump; - - protected override void BuildLayout(ref StaticGumpBuilder builder) - { - builder.AddBackground(0, 0, 240, 142, 0x13BE); - builder.AddImageTiled(6, 6, 228, 100, 0xA40); - builder.AddImageTiled(6, 116, 228, 20, 0xA40); - builder.AddAlphaRegion(6, 6, 228, 142); - - // You are about to disable inventory insurance auto-renewal. - builder.AddHtmlLocalized(8, 8, 228, 100, 1071021, 0x7FFF); - - builder.AddButton(6, 116, 0xFB1, 0xFB2, 0); - builder.AddHtmlLocalized(40, 118, 450, 20, 1060051, 0x7FFF); // CANCEL - - builder.AddButton(114, 116, 0xFA5, 0xFA7, 1); - builder.AddHtmlLocalized(148, 118, 450, 20, 1071022, 0x7FFF); // DISABLE IT! - } - - public override void OnResponse(NetState sender, in RelayInfo info) - { - if (sender.Mobile is not PlayerMobile pm || !pm.CheckAlive()) - { - return; - } - - if (info.ButtonID == 1) - { - // You have cancelled automatically reinsuring all insured items upon death - pm.SendLocalizedMessage(1061075, "", 0x23); - pm.AutoRenewInsurance = false; - } - else - { - pm.SendLocalizedMessage(1042021); // Cancelled. - } - - if (_insuranceGump != null) - { - pm.SendGump(_insuranceGump); - } - } -} diff --git a/Projects/UOContent/Engines/Insurance/Gumps/ItemInsuranceMenuConfirmGump.cs b/Projects/UOContent/Engines/Insurance/Gumps/ItemInsuranceMenuConfirmGump.cs deleted file mode 100644 index 4fd597cf6..000000000 --- a/Projects/UOContent/Engines/Insurance/Gumps/ItemInsuranceMenuConfirmGump.cs +++ /dev/null @@ -1,46 +0,0 @@ -using Server.Mobiles; -using Server.Network; - -namespace Server.Gumps; - -public class ItemInsuranceMenuConfirmGump : StaticGump -{ - private readonly ItemInsuranceMenuGump _insuranceGump; - - public ItemInsuranceMenuConfirmGump(ItemInsuranceMenuGump insuranceGump) : base(250, 200) => - _insuranceGump = insuranceGump; - - protected override void BuildLayout(ref StaticGumpBuilder builder) - { - builder.AddBackground(0, 0, 240, 142, 0x13BE); - builder.AddImageTiled(6, 6, 228, 100, 0xA40); - builder.AddImageTiled(6, 116, 228, 20, 0xA40); - builder.AddAlphaRegion(6, 6, 228, 142); - - builder.AddHtmlLocalized(8, 8, 228, 100, 1114300, 0x7FFF); // Do you wish to insure all newly selected items? - - builder.AddButton(6, 116, 0xFB1, 0xFB2, 0); - builder.AddHtmlLocalized(40, 118, 450, 20, 1060051, 0x7FFF); // CANCEL - - builder.AddButton(114, 116, 0xFA5, 0xFA7, 1); - builder.AddHtmlLocalized(148, 118, 450, 20, 1073996, 0x7FFF); // ACCEPT - } - - public override void OnResponse(NetState sender, in RelayInfo info) - { - if (sender.Mobile is not PlayerMobile pm || !pm.CheckAlive()) - { - return; - } - - if (info.ButtonID == 1) - { - _insuranceGump.ToggleSelected(); - } - else - { - pm.SendLocalizedMessage(1042021); // Cancelled. - pm.SendGump(_insuranceGump); - } - } -} diff --git a/Projects/UOContent/Engines/Insurance/Gumps/ItemInsuranceMenuGump.cs b/Projects/UOContent/Engines/Insurance/Gumps/ItemInsuranceMenuGump.cs deleted file mode 100644 index 2d17f63ab..000000000 --- a/Projects/UOContent/Engines/Insurance/Gumps/ItemInsuranceMenuGump.cs +++ /dev/null @@ -1,207 +0,0 @@ -using Server.Engines.Insurance; -using Server.Mobiles; -using Server.Network; - -namespace Server.Gumps; - -public class ItemInsuranceMenuGump : DynamicGump -{ - private readonly PlayerMobile _from; - private readonly bool[] _insure; - private readonly Item[] _items; - private int _page; - - public override bool Singleton => true; - - public ItemInsuranceMenuGump(PlayerMobile from, Item[] items) : base(25, 50) - { - _from = from; - _items = items; - _insure = new bool[items.Length]; - - for (var i = 0; i < items.Length; ++i) - { - _insure[i] = items[i].Insured; - } - } - - protected override void BuildLayout(ref DynamicGumpBuilder builder) - { - builder.AddPage(); - - builder.AddBackground(0, 0, 520, 510, 0x13BE); - builder.AddImageTiled(10, 10, 500, 30, 0xA40); - builder.AddImageTiled(10, 50, 500, 355, 0xA40); - builder.AddImageTiled(10, 415, 500, 80, 0xA40); - builder.AddAlphaRegion(10, 10, 500, 485); - - builder.AddButton(15, 470, 0xFB1, 0xFB2, 0); - builder.AddHtmlLocalized(50, 472, 80, 20, 1011012, 0x7FFF); // CANCEL - - if (_from.AutoRenewInsurance) - { - builder.AddButton(360, 10, 9723, 9724, 1); - } - else - { - builder.AddButton(360, 10, 9720, 9722, 1); - } - - builder.AddHtmlLocalized(395, 14, 105, 20, 1114122, 0x7FFF); // AUTO REINSURE - - builder.AddButton(395, 470, 0xFA5, 0xFA6, 2); - builder.AddHtmlLocalized(430, 472, 50, 20, 1006044, 0x7FFF); // OK - - builder.AddHtmlLocalized(10, 14, 150, 20, 1114121, 0x7FFF); //
ITEM INSURANCE MENU
- - builder.AddHtmlLocalized(45, 54, 70, 20, 1062214, 0x7FFF); // Item - builder.AddHtmlLocalized(250, 54, 70, 20, 1061038, 0x7FFF); // Cost - builder.AddHtmlLocalized(400, 54, 70, 20, 1114311, 0x7FFF); // Insured - - var balance = Banker.GetBalance(_from); - var cost = 0; - - for (var i = 0; i < _items.Length; ++i) - { - if (_insure[i]) - { - cost += Insurance.GetInsuranceCost(_from, _items[i]); - } - } - - builder.AddHtmlLocalized(15, 420, 300, 20, 1114310, 0x7FFF); // GOLD AVAILABLE: - builder.AddLabel(215, 420, 0x481, $"{balance}"); - builder.AddHtmlLocalized(15, 435, 300, 20, 1114123, 0x7FFF); // TOTAL COST OF INSURANCE: - builder.AddLabel(215, 435, 0x481, $"{cost}"); - - if (cost != 0) - { - builder.AddHtmlLocalized(15, 450, 300, 20, 1114125, 0x7FFF); // NUMBER OF DEATHS PAYABLE: - builder.AddLabel(215, 450, 0x481, $"{balance / cost}"); - } - - for (int i = _page * 4, y = 72; i < (_page + 1) * 4 && i < _items.Length; ++i, y += 75) - { - var item = _items[i]; - var b = ItemBounds.Bounds[item.ItemID]; - - builder.AddImageTiledButton( - 40, - y, - 0x918, - 0x918, - 0, - GumpButtonType.Page, - 0, - item.ItemID, - item.Hue, - 40 - b.Width / 2 - b.X, - 30 - b.Height / 2 - b.Y - ); - builder.AddItemProperty(item.Serial); - - if (_insure[i]) - { - builder.AddButton(400, y, 9723, 9724, 100 + i); - builder.AddLabel(250, y, 0x481, $"{Insurance.GetInsuranceCost(_from, item)}"); - } - else - { - builder.AddButton(400, y, 9720, 9722, 100 + i); - builder.AddLabel(250, y, 0x66C, $"{Insurance.GetInsuranceCost(_from, item)}"); - } - } - - if (_page >= 1) - { - builder.AddButton(15, 380, 0xFAE, 0xFAF, 3); - builder.AddHtmlLocalized(50, 380, 450, 20, 1044044, 0x7FFF); // PREV PAGE - } - - if ((_page + 1) * 4 < _items.Length) - { - builder.AddButton(400, 380, 0xFA5, 0xFA7, 4); - builder.AddHtmlLocalized(435, 380, 70, 20, 1044045, 0x7FFF); // NEXT PAGE - } - } - - public override void OnResponse(NetState sender, in RelayInfo info) - { - if (info.ButtonID == 0 || !_from.CheckAlive()) - { - return; - } - - switch (info.ButtonID) - { - case 1: // Auto Reinsure - { - if (_from.AutoRenewInsurance) - { - _from.SendGump(new CancelRenewInventoryInsuranceGump(this)); - } - else - { - Insurance.AutoRenewInventoryInsurance(_from); - _from.SendGump(this); - } - - break; - } - case 2: // OK - { - _from.SendGump(new ItemInsuranceMenuConfirmGump(this)); - - break; - } - case 3: // Prev - { - if (_page >= 1) - { - _page--; - _from.SendGump(this); - } - - break; - } - case 4: // Next - { - if ((_page + 1) * 4 < _items.Length) - { - _page++; - _from.SendGump(this); - } - - break; - } - default: - { - var idx = info.ButtonID - 100; - - if (idx >= 0 && idx < _items.Length) - { - _insure[idx] = !_insure[idx]; - } - - _from.SendGump(this); - - break; - } - } - } - - public void ToggleSelected() - { - var items = _items; - var insure = _insure; - for (var i = 0; i < items.Length; ++i) - { - var item = items[i]; - - if (item.Insured != insure[i]) - { - Insurance.ToggleItemInsurance(_from, item, false); - } - } - } -} diff --git a/Projects/UOContent/Engines/Insurance/Insurance.cs b/Projects/UOContent/Engines/Insurance/Insurance.cs deleted file mode 100644 index 8ba3985e5..000000000 --- a/Projects/UOContent/Engines/Insurance/Insurance.cs +++ /dev/null @@ -1,323 +0,0 @@ -using System.Collections.Generic; -using Server.Collections; -using Server.Factions; -using Server.Gumps; -using Server.Items; -using Server.Mobiles; -using Server.Targeting; - -namespace Server.Engines.Insurance; - -public static class Insurance -{ - public static bool Enabled => ServerFeatureFlags.InsuranceEnabled; - private static readonly Dictionary _insuranceContexts = []; - - public static void Configure() - { - // Legacy, but not expected to be used anymore in favor of the feature flag system - ServerFeatureFlags.InsuranceEnabled = ServerConfiguration.GetSetting("insurance.enable", Core.AOS); - } - - public static int GetInsuranceCost(Mobile from, Item item) => 600; - - public static void CheckInsuranceBeforeDeath(Mobile from) - { - if (!Enabled) - { - return; - } - - var recentDamager = from.FindMostRecentDamager(false); - if (recentDamager == null) - { - return; - } - - if (recentDamager is BaseCreature creature) - { - recentDamager = creature.GetMaster(); - } - - if (recentDamager != from && recentDamager is PlayerMobile insuranceReward) - { - _insuranceContexts[from] = new InsuranceContext(insuranceReward); - } - } - - public static void CheckInsuranceOnDeath(Mobile from) - { - if (!Enabled || !_insuranceContexts.Remove(from, out var context)) - { - return; - } - - if (context.MissedAutoRenewal) - { - from.SendLocalizedMessage(1061115); // You do not have the gold to automatically reinsure all your items. - } - - if (context.InsuranceReward != null && context.InsuranceBonus > 0 && - Banker.Deposit(context.InsuranceReward, context.InsuranceBonus)) - { - // ~1_AMOUNT~ gold has been deposited into your bank box. - context.InsuranceReward.SendLocalizedMessage(1060397, $"{context.InsuranceBonus}"); - } - } - - public static bool CheckItemInsuranceOnDeath(PlayerMobile from, Item item) - { - if (!Enabled || !item.Insured) - { - return false; - } - - if (from.DuelContext?.Registered == true && from.DuelContext.Started && from.DuelPlayer?.Eliminated != true) - { - return true; - } - - if (!_insuranceContexts.TryGetValue(from, out var context)) - { - _insuranceContexts[from] = context = new InsuranceContext(); - } - - if (from.AutoRenewInsurance) - { - var cost = Insurance.GetInsuranceCost(from, item); - - if (context.InsuranceReward != null) - { - cost /= 2; - } - - if (Banker.Withdraw(from, cost)) - { - item.PaidInsurance = true; - // ~1_AMOUNT~ gold has been withdrawn from your bank box. - from.SendLocalizedMessage(1060398, $"{cost}"); - } - else - { - // TODO: Should this spam? - // from.SendLocalizedMessage(1061079, "", 0x23); // You lack the funds to purchase the insurance - item.PaidInsurance = false; - item.Insured = false; - context.MissedAutoRenewal = true; - } - } - else - { - item.PaidInsurance = false; - item.Insured = false; - } - - context.InsuranceBonus += 300; - return true; - } - - public static bool CanInsure(Mobile from, Item item) - { - if (!Enabled) - { - return false; - } - - if (item is Container && item is not BaseQuiver || item is BagOfSending or KeyRing or PotionKeg or Sigil) - { - return false; - } - - if (item.Stackable) - { - return false; - } - - if (item.LootType == LootType.Cursed) - { - return false; - } - - if (item.ItemID == 0x204E) // death shroud - { - return false; - } - - if (item.Layer == Layer.Mount) - { - return false; - } - - return item.LootType != LootType.Blessed && item.LootType != LootType.Newbied && item.BlessedFor != from; - } - - public static void ToggleItemInsurance(Mobile from) - { - if (!from.CheckAlive()) - { - return; - } - - from.BeginTarget(-1, false, TargetFlags.None, ToggleItemInsurance); - from.SendLocalizedMessage(1060868); // Target the item you wish to toggle insurance status on to cancel - } - - public static void ToggleItemInsurance(Mobile from, object obj) - { - if (!from.CheckAlive()) - { - return; - } - - ToggleItemInsurance(from, obj as Item, true); - } - - public static void ToggleItemInsurance(Mobile from, Item item, bool target) - { - if (item?.IsChildOf(from) != true) - { - if (target) - { - from.BeginTarget(-1, false, TargetFlags.None, ToggleItemInsurance); - } - - // You can only insure items that you have equipped or that are in your backpack - from.SendLocalizedMessage(1060871, "", 0x23); - } - else if (item.Insured) - { - item.Insured = false; - - from.SendLocalizedMessage(1060874, "", 0x35); // You cancel the insurance on the item - - if (target) - { - from.BeginTarget(-1, false, TargetFlags.None, ToggleItemInsurance); - // Target the item you wish to toggle insurance status on to cancel - from.SendLocalizedMessage(1060868, "", 0x23); - } - } - else if (!CanInsure(from, item)) - { - if (target) - { - from.BeginTarget(-1, false, TargetFlags.None, ToggleItemInsurance); - } - - from.SendLocalizedMessage(1060869, "", 0x23); // You cannot insure that - } - else - { - if (!item.PaidInsurance) - { - var cost = GetInsuranceCost(from, item); - - if (Banker.Withdraw(from, cost)) - { - // ~1_AMOUNT~ gold has been withdrawn from your bank box. - from.SendLocalizedMessage(1060398, $"{cost}"); - item.PaidInsurance = true; - } - else - { - from.SendLocalizedMessage(1061079, "", 0x23); // You lack the funds to purchase the insurance - return; - } - } - - item.Insured = true; - - from.SendLocalizedMessage(1060873, "", 0x23); // You have insured the item - - if (target) - { - from.BeginTarget(-1, false, TargetFlags.None, ToggleItemInsurance); - // Target the item you wish to toggle insurance status on to cancel - from.SendLocalizedMessage(1060868, "", 0x23); - } - } - } - - public static void AutoRenewInventoryInsurance(Mobile from) - { - if (!from.CheckAlive()) - { - return; - } - - // You have selected to automatically reinsure all insured items upon death - from.SendLocalizedMessage(1060881, "", 0x23); - (from as PlayerMobile)?.AutoRenewInsurance = true; - } - - public static void CancelRenewInventoryInsurance(Mobile from) - { - if (!from.CheckAlive()) - { - return; - } - - if (Core.SE) - { - from.SendGump(new CancelRenewInventoryInsuranceGump(null)); - } - else - { - // You have cancelled automatically reinsuring all insured items upon death - from.SendLocalizedMessage(1061075, "", 0x23); - (from as PlayerMobile)?.AutoRenewInsurance = false; - } - } - - public static void OpenItemInsuranceMenu(Mobile from) - { - if (!from.CheckAlive() || from.NetState == null) - { - return; - } - - using var queue = PooledRefQueue.Create(128); - - foreach (var item in from.Items) - { - if (DisplayInItemInsuranceGump(from, item)) - { - queue.Enqueue(item); - } - } - - var pack = from.Backpack; - - if (pack != null) - { - foreach (var item in pack.FindItems()) - { - if (DisplayInItemInsuranceGump(from, item)) - { - queue.Enqueue(item); - } - } - } - - if (queue.Count == 0) - { - // None of your current items meet the requirements for insurance. - from.SendLocalizedMessage(1114915, "", 0x35); - } - else if (from is PlayerMobile pm) - { - // TODO: Investigate item sorting - from.SendGump(new ItemInsuranceMenuGump(pm, queue.ToArray())); - } - } - - private static bool DisplayInItemInsuranceGump(Mobile from, Item item) => - (item.Visible || from.AccessLevel >= AccessLevel.GameMaster) && (item.Insured || CanInsure(from, item)); - - private class InsuranceContext(PlayerMobile insuranceReward = null) - { - public PlayerMobile InsuranceReward = insuranceReward; - public int InsuranceBonus { get; set; } - public bool MissedAutoRenewal { get; set; } - } -} 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..a1b368cb0 100644 --- a/Projects/UOContent/Engines/Pathing/Cache/StepCache.cs +++ b/Projects/UOContent/Engines/Pathing/Cache/StepCache.cs @@ -285,8 +285,7 @@ public sealed class StepCache } _lazyReaders[mapId] = reader; - // Debug: opening is the expected case. A rebuild is the interesting one, and BakeMap logs it. - logger.Debug( + logger.Information( "StepCache: opened {Path} ({ChunkCount} chunks indexed) for map {MapId}", path, reader.IndexedChunkCount, mapId ); @@ -727,14 +726,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..b5307ad3d 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; @@ -58,6 +60,8 @@ public sealed class MovementPath public static void Configure() { CommandSystem.Register("Path", AccessLevel.GameMaster, Path_OnCommand); + CacheEvictionTimer.Configure(); + PathCacheCommands.Configure(); } [Usage("Path")] diff --git a/Projects/UOContent/Engines/Pathing/PathCacheCommands.cs b/Projects/UOContent/Engines/Pathing/PathCacheCommands.cs index 7f8fee270..f14886564 100644 --- a/Projects/UOContent/Engines/Pathing/PathCacheCommands.cs +++ b/Projects/UOContent/Engines/Pathing/PathCacheCommands.cs @@ -39,12 +39,15 @@ public static class PathCacheCommands 8192 ); + PathfindRecorder.Configure(); + CommandSystem.Register("PathCacheStats", AccessLevel.Administrator, OnPathCacheStats); CommandSystem.Register("PathCacheClear", AccessLevel.Administrator, OnPathCacheClear); CommandSystem.Register("PathBake", AccessLevel.Administrator, OnPathBake); CommandSystem.Register("PathCacheSave", AccessLevel.Administrator, OnPathCacheSave); CommandSystem.Register("PathCacheLoad", AccessLevel.Administrator, OnPathCacheLoad); CommandSystem.Register("PathRecord", AccessLevel.Administrator, OnPathRecord); + AutoLoadAtStartup(); } /// @@ -77,23 +80,18 @@ public static class PathCacheCommands } /// - /// Opens the existing .swb files, then — when is set — - /// bakes any that are missing or stale. An up-to-date cache makes the bake a no-op, so the cost - /// lands only on a first boot or after a client or map update moves the fingerprint. + /// Bakes any map whose .swb is missing or stale, when is + /// set. Runs in the Initialize phase, once the tile matrix and world are loaded. An up-to-date + /// cache makes it a no-op, so the cost lands only on a first boot or after a client or map + /// update moves the fingerprint. /// - /// Both halves run here rather than in Configure: the fingerprint hashes the map files, so - /// opening a .swb forces the lazy property. In Configure that would - /// build every TileMatrix ahead of TileMatrixLoader, possibly before - /// TileMatrix.Configure() settles Pre6000ClientSupport — both sit at the default - /// call priority and the phase sort is unstable. - /// - /// A reader only opens once its fingerprint validates, so an open reader is proof of a good - /// bake and the map is skipped without fingerprinting it again. + /// A map is judged up-to-date by whether it has an open reader. + /// already ran in the earlier Configure phase and only opens a reader for a .swb whose + /// fingerprint validates, so an open reader is proof of a good bake — no need to fingerprint + /// the map a second time here. /// public static void Initialize() { - AutoLoadAtStartup(); - if (!ServerConfiguration.GetSetting(PrebakeSetting, false)) { return; @@ -121,15 +119,13 @@ public static class PathCacheCommands ); StepCache.Instance.BakeMap(map.MapID, path); StepCache.Instance.ClearResidentChunks(); - - // Just this map: a blanket AutoLoadAtStartup() would reopen every reader already open. - StepCache.Instance.TryOpenLazyReader(path, map.MapID); baked++; } if (baked > 0) { logger.Information("PathBake: pre-bake complete ({Count} map(s) written).", baked); + AutoLoadAtStartup(); // reopen what we just wrote } } 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..6e7c3ac23 100644 --- a/Projects/UOContent/Gumps/AdminGump.cs +++ b/Projects/UOContent/Gumps/AdminGump.cs @@ -5,14 +5,12 @@ 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; using Server.Misc; using Server.Multis; using Server.Network; -using Server.Network.Bans; using Server.Prompts; using Server.Saves; using Server.Text; @@ -227,12 +225,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(); @@ -1748,8 +1743,7 @@ namespace Server.Gumps { for (var i = 0; i < a.LoginIPs.Length; ++i) { - Firewall.Add(new SingleIpFirewallEntry(a.LoginIPs[i])); - BanChannel.Report(a.LoginIPs[i], TimeSpan.Zero, BanReasons.Manual); + AdminFirewall.Add(a.LoginIPs[i]); } notice = "All addresses in the list have been firewalled."; @@ -1773,13 +1767,7 @@ namespace Server.Gumps if (okay) { - var firewallEntry = Firewall.ToFirewallEntry(toFirewall); - Firewall.Add(firewallEntry); - - if (firewallEntry.MinIpAddress == firewallEntry.MaxIpAddress) - { - BanChannel.Report(firewallEntry.MinIpAddress.ToIpAddress(), TimeSpan.Zero, BanReasons.Manual); - } + AdminFirewall.Add(toFirewall); notice = $"{toFirewall} : Added to firewall."; } @@ -2907,7 +2895,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, @@ -3571,7 +3559,7 @@ namespace Server.Gumps IFirewallEntry firewallEntry; try { - firewallEntry = Firewall.ToFirewallEntry(text); + firewallEntry = AdminFirewall.ToFirewallEntry(text); } catch { @@ -3593,17 +3581,7 @@ namespace Server.Gumps $"{from.AccessLevel} {CommandLogging.Format(from)} firewalling {firewallEntry}" ); - Firewall.Add(firewallEntry); - - if (firewallEntry.MinIpAddress == firewallEntry.MaxIpAddress) - { - BanChannel.Report( - firewallEntry.MinIpAddress.ToIpAddress(), - TimeSpan.Zero, - BanReasons.Manual - ); - } - + AdminFirewall.Add(firewallEntry); from.SendGump( new AdminGump( from, @@ -3642,13 +3620,7 @@ namespace Server.Gumps $"{from.AccessLevel} {CommandLogging.Format(from)} removing {m_State} from firewall list" ); - Firewall.Remove(m_State as IFirewallEntry); - - if (m_State is IFirewallEntry fe && fe.MinIpAddress == fe.MaxIpAddress) - { - BanChannel.Retract(fe.MinIpAddress.ToIpAddress()); - } - + AdminFirewall.Remove(m_State); from.SendGump( new AdminGump( 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/ClothingBlessDeed.cs b/Projects/UOContent/Items/Deeds/ClothingBlessDeed.cs index 25860f542..20f8f9fc6 100644 --- a/Projects/UOContent/Items/Deeds/ClothingBlessDeed.cs +++ b/Projects/UOContent/Items/Deeds/ClothingBlessDeed.cs @@ -1,5 +1,4 @@ using ModernUO.Serialization; -using Server.Engines.Insurance; using Server.Targeting; namespace Server.Items; @@ -26,7 +25,7 @@ public class ClothingBlessTarget : Target // Create our targeting class (which w } // Check if its already newbied (blessed) - if (item.LootType == LootType.Blessed || item.BlessedFor == from || Insurance.Enabled && item.Insured) + if (item.LootType == LootType.Blessed || item.BlessedFor == from || Mobile.InsuranceEnabled && item.Insured) { from.SendLocalizedMessage(1045113); // That item is already blessed } 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/AdminFirewall.cs b/Projects/UOContent/Misc/AdminFirewall.cs new file mode 100644 index 000000000..a4275d90c --- /dev/null +++ b/Projects/UOContent/Misc/AdminFirewall.cs @@ -0,0 +1,139 @@ +using System; +using System.Buffers; +using System.IO; +using System.Net; +using System.Runtime.CompilerServices; +using Server.Logging; +using Server.Network; + +namespace Server; + +public static class AdminFirewall +{ + private static readonly ILogger logger = LogFactory.GetLogger(typeof(AdminFirewall)); + + private const string firewallConfigPath = "firewall.cfg"; + + public static void Configure() + { + if (File.Exists(firewallConfigPath)) + { + var searchValues = SearchValues.Create("*Xx?"); + + using var ip = new StreamReader(firewallConfigPath); + + while (ip.ReadLine() is { } line) + { + line = line.Trim(); + + if (line.Length == 0) + { + continue; + } + + if (line.AsSpan().ContainsAny(searchValues)) + { + logger.Warning("Legacy firewall entry \"{Entry}\" ignored", line); + continue; + } + + Add(ToFirewallEntry(line), false); + } + } + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static IFirewallEntry ToFirewallEntry(object entry) + { + return entry switch + { + IFirewallEntry firewallEntry => firewallEntry, + IPAddress address => new SingleIpFirewallEntry(address), + string s => ToFirewallEntry(s), + _ => null + }; + } + + public static IFirewallEntry ToFirewallEntry(string entry) + { + if (entry == null) + { + return null; + } + + try + { + var rangeSeparator = entry.IndexOf('-'); + if (rangeSeparator > -1) + { + return new CidrFirewallEntry( + IPAddress.Parse(entry.AsSpan(0, rangeSeparator)), + IPAddress.Parse(entry.AsSpan(rangeSeparator + 1)) + ); + } + + // CIDR notation + if (entry.IndexOf('/') > -1) + { + return new CidrFirewallEntry(entry); + } + + return new SingleIpFirewallEntry(entry); + } + catch + { + return null; + } + } + + public static bool Remove(object obj, bool save = true) + { + var entry = ToFirewallEntry(obj); + + if (entry == null) + { + return false; + } + + if (!Firewall.Remove(entry)) + { + return false; + } + + if (save) + { + Save(); + } + + return true; + } + + public static void Add(object obj) => Add(ToFirewallEntry(obj)); + + public static bool Add(IFirewallEntry entry, bool save = true) + { + if (!Firewall.Add(entry)) + { + return false; + } + + if (save) + { + Save(); + } + + return true; + } + + public static void Save() + { + Firewall.ReadFirewallSet(firewallSet => + { + using var op = new StreamWriter(firewallConfigPath); + foreach (var entry in firewallSet) + { + op.WriteLine(entry); + } + }); + } +} 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/Abilities/DestroyEquipment.cs b/Projects/UOContent/Mobiles/Abilities/DestroyEquipment.cs index ff7937ded..c74698d7c 100644 --- a/Projects/UOContent/Mobiles/Abilities/DestroyEquipment.cs +++ b/Projects/UOContent/Mobiles/Abilities/DestroyEquipment.cs @@ -1,5 +1,4 @@ using Server.Collections; -using Server.Engines.Insurance; using Server.Items; namespace Server.Mobiles; @@ -24,7 +23,7 @@ public class DestroyEquipment : MonsterAbilitySingleTarget continue; } - if (Insurance.Enabled && item.Insured) + if (Mobile.InsuranceEnabled && item.Insured) { continue; } 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/PlayerMobile.cs b/Projects/UOContent/Mobiles/PlayerMobile.cs index 83c0e4cca..0a81bb846 100644 --- a/Projects/UOContent/Mobiles/PlayerMobile.cs +++ b/Projects/UOContent/Mobiles/PlayerMobile.cs @@ -11,7 +11,6 @@ using Server.Engines.CannedEvil; using Server.Engines.ConPVP; using Server.Engines.Craft; using Server.Engines.Help; -using Server.Engines.Insurance; using Server.Engines.MLQuests; using Server.Engines.MLQuests.Gumps; using Server.Engines.PartySystem; @@ -162,7 +161,7 @@ namespace Server.Mobiles /* * a value of zero means, that the mobile is not executing the spell. Otherwise, * the value should match the BaseMana required - */ + */ private RankDefinition m_GuildRank; @@ -170,6 +169,9 @@ namespace Server.Mobiles public DateTime _honorTime; + private Mobile m_InsuranceAward; + private int m_InsuranceBonus; + private int m_LastGlobalLight = -1, m_LastPersonalLight = -1; private bool m_LastProtectedMessage; @@ -188,6 +190,9 @@ namespace Server.Mobiles private bool m_NoDeltaRecursion; + // number of items that could not be automatically reinsured because gold in bank was not enough + private int m_NonAutoreinsuredItems; + private DateTime m_SavagePaintExpiration; private DateTime[] m_StuckMenuUses; @@ -903,17 +908,17 @@ namespace Server.Mobiles { Direction.North => itemIDs[0], Direction.South => itemIDs[0], - Direction.East => itemIDs[1], - Direction.West => itemIDs[1], - _ => item.ItemID + Direction.East => itemIDs[1], + Direction.West => itemIDs[1], + _ => item.ItemID }, 4 => dir switch { Direction.South => itemIDs[0], - Direction.East => itemIDs[1], + Direction.East => itemIDs[1], Direction.North => itemIDs[2], - Direction.West => itemIDs[3], - _ => item.ItemID + Direction.West => itemIDs[3], + _ => item.ItemID }, _ => item.ItemID }; @@ -984,7 +989,7 @@ namespace Server.Mobiles } if (skillId == 35) - // AnimalTaming.DeferredTarget = true; + // AnimalTaming.DeferredTarget = true; { AnimalTaming.DisableMessage = false; } @@ -1840,7 +1845,7 @@ namespace Server.Mobiles // moving, not teleporting var zDrop = Location.Z - loc.Z; - if (zDrop > 20) // we fell more than one story + if (zDrop > 20) // we fell more than one story { Hits -= zDrop / 20 * 10 - 5; // deal some damage; does not kill, disrupt, etc } @@ -1869,7 +1874,7 @@ namespace Server.Mobiles if (Alive) { - if (Insurance.Enabled) + if (InsuranceEnabled) { if (Core.SA) { @@ -1997,7 +2002,7 @@ namespace Server.Mobiles { var house = BaseHouse.FindHouseAt(this); - if (CheckAlive() && house?.IsOwner(this) == true && house.InternalizedVendors.Count > 0 && NetState != null) + if (CheckAlive() && house?.IsOwner(this) == true && house.InternalizedVendors.Count > 0 && NetState is NetState { } ns) { ReclaimVendorGump.DisplayTo(this, house); } @@ -2305,8 +2310,7 @@ namespace Server.Mobiles pm.DuelPlayer.Eliminated) || base.OnMoveOver(m); public override bool CheckShove(Mobile shoved) => - IgnoreMobiles || shoved.IgnoreMobiles || - TransformationSpellHelper.UnderTransformation(shoved, typeof(WraithFormSpell)) || + IgnoreMobiles || shoved.IgnoreMobiles || TransformationSpellHelper.UnderTransformation(shoved, typeof(WraithFormSpell)) || base.CheckShove(shoved); protected override void OnMapChange(Map oldMap) @@ -2397,8 +2401,7 @@ namespace Server.Mobiles [MethodImpl(MethodImplOptions.AggressiveInlining)] private bool FindItems_Callback(Item item) => - !item.Deleted && Backpack != item.Parent && - (item.LootType == LootType.Blessed || ServerFeatureFlags.InsuranceEnabled && item.Insured); + !item.Deleted && (item.LootType == LootType.Blessed || item.Insured) && Backpack != item.Parent; public override bool OnBeforeDeath() { @@ -2419,8 +2422,30 @@ namespace Server.Mobiles } } - EquipSnapshot = [..Items]; - Insurance.CheckInsuranceBeforeDeath(this); + EquipSnapshot = new List(Items); + + m_NonAutoreinsuredItems = 0; + m_InsuranceAward = FindMostRecentDamager(false); + + if (m_InsuranceAward is BaseCreature creature) + { + var master = creature.GetMaster(); + + if (master != null) + { + m_InsuranceAward = master; + } + } + + if (m_InsuranceAward != null && (!m_InsuranceAward.Player || m_InsuranceAward == this)) + { + m_InsuranceAward = null; + } + + if (m_InsuranceAward is PlayerMobile mobile) + { + mobile.m_InsuranceBonus = 0; + } ReceivedHonorContext?.OnTargetKilled(); SentHonorContext?.OnSourceKilled(); @@ -2428,6 +2453,56 @@ namespace Server.Mobiles return base.OnBeforeDeath(); } + private bool CheckInsuranceOnDeath(Item item) + { + if (!InsuranceEnabled || !item.Insured) + { + return false; + } + + if (DuelContext?.Registered == true && DuelContext.Started && + m_DuelPlayer?.Eliminated != true) + { + return true; + } + + if (AutoRenewInsurance) + { + var cost = GetInsuranceCost(item); + + if (m_InsuranceAward != null) + { + cost /= 2; + } + + if (Banker.Withdraw(this, cost)) + { + item.PaidInsurance = true; + // ~1_AMOUNT~ gold has been withdrawn from your bank box. + SendLocalizedMessage(1060398, cost.ToString()); + } + else + { + SendLocalizedMessage(1061079, "", 0x23); // You lack the funds to purchase the insurance + item.PaidInsurance = false; + item.Insured = false; + m_NonAutoreinsuredItems++; + } + } + else + { + item.PaidInsurance = false; + item.Insured = false; + } + + if (m_InsuranceAward is PlayerMobile insurancePm && Banker.Deposit(m_InsuranceAward, 300)) + { + insurancePm.m_InsuranceBonus += 300; + } + + return true; + } + public override DeathMoveResult GetParentMoveResultFor(Item item) { // It seems all items are unmarked on death, even blessed/insured ones @@ -2436,7 +2511,7 @@ namespace Server.Mobiles item.QuestItem = false; } - if (Insurance.CheckItemInsuranceOnDeath(this, item)) + if (CheckInsuranceOnDeath(item)) { return DeathMoveResult.MoveToBackpack; } @@ -2459,7 +2534,7 @@ namespace Server.Mobiles item.QuestItem = false; } - if (Insurance.CheckItemInsuranceOnDeath(this, item)) + if (CheckInsuranceOnDeath(item)) { return DeathMoveResult.MoveToBackpack; } @@ -2479,6 +2554,11 @@ namespace Server.Mobiles public override void OnDeath(Container c) { + if (m_NonAutoreinsuredItems > 0) + { + SendLocalizedMessage(1061115); + } + base.OnDeath(c); EquipSnapshot = null; @@ -2547,7 +2627,11 @@ namespace Server.Mobiles } } - Insurance.CheckInsuranceOnDeath(this); + if (m_InsuranceAward is PlayerMobile insurancePm && insurancePm.m_InsuranceBonus > 0) + { + // ~1_AMOUNT~ gold has been deposited into your bank box. + insurancePm.SendLocalizedMessage(1060397, insurancePm.m_InsuranceBonus.ToString()); + } var killer = FindMostRecentDamager(true); @@ -2827,8 +2911,7 @@ namespace Server.Mobiles for (var i = 0; i < recipeCount; i++) { var r = reader.ReadInt(); - // Don't add in recipes which we haven't gotten or have been removed - if (version > 33 || reader.ReadBool()) + if (version > 33 || reader.ReadBool()) // Don't add in recipes which we haven't gotten or have been removed { _acquiredRecipes.Add(r); } @@ -2843,7 +2926,6 @@ namespace Server.Mobiles { reader.ReadDeltaTime(); // LastHonorLoss - Not even used } - goto case 23; } case 23: @@ -2939,7 +3021,6 @@ namespace Server.Mobiles { virtues.LastCompassionLoss = reader.ReadDeltaTime(); } - goto case 14; } case 14: @@ -3629,13 +3710,213 @@ namespace Server.Mobiles AutoStabled = null; } - private void ToggleItemInsurance() => Insurance.ToggleItemInsurance(this); + private static int GetInsuranceCost(Item item) => 600; - private void OpenItemInsuranceMenu() => Insurance.OpenItemInsuranceMenu(this); + private void ToggleItemInsurance() + { + if (!CheckAlive()) + { + return; + } - private void CancelRenewInventoryInsurance() => Insurance.CancelRenewInventoryInsurance(this); + BeginTarget(-1, false, TargetFlags.None, ToggleItemInsurance_Callback); + SendLocalizedMessage(1060868); // Target the item you wish to toggle insurance status on to cancel + } - private void AutoRenewInventoryInsurance() => Insurance.AutoRenewInventoryInsurance(this); + private bool CanInsure(Item item) + { + if (item is Container && item is not BaseQuiver || item is BagOfSending or KeyRing or PotionKeg or Sigil) + { + return false; + } + + if (item.Stackable) + { + return false; + } + + if (item.LootType == LootType.Cursed) + { + return false; + } + + if (item.ItemID == 0x204E) // death shroud + { + return false; + } + + if (item.Layer == Layer.Mount) + { + return false; + } + + return item.LootType != LootType.Blessed && item.LootType != LootType.Newbied && item.BlessedFor != this; + } + + private void ToggleItemInsurance_Callback(Mobile from, object obj) + { + if (!CheckAlive()) + { + return; + } + + ToggleItemInsurance_Callback(from, obj as Item, true); + } + + private void ToggleItemInsurance_Callback(Mobile from, Item item, bool target) + { + if (item?.IsChildOf(this) != true) + { + if (target) + { + BeginTarget(-1, false, TargetFlags.None, ToggleItemInsurance_Callback); + } + + SendLocalizedMessage( + 1060871, + "", + 0x23 + ); // You can only insure items that you have equipped or that are in your backpack + } + else if (item.Insured) + { + item.Insured = false; + + SendLocalizedMessage(1060874, "", 0x35); // You cancel the insurance on the item + + if (target) + { + BeginTarget(-1, false, TargetFlags.None, ToggleItemInsurance_Callback); + SendLocalizedMessage( + 1060868, + "", + 0x23 + ); // Target the item you wish to toggle insurance status on to cancel + } + } + else if (!CanInsure(item)) + { + if (target) + { + BeginTarget(-1, false, TargetFlags.None, ToggleItemInsurance_Callback); + } + + SendLocalizedMessage(1060869, "", 0x23); // You cannot insure that + } + else + { + if (!item.PaidInsurance) + { + var cost = GetInsuranceCost(item); + + if (Banker.Withdraw(from, cost)) + { + SendLocalizedMessage( + 1060398, + cost.ToString() + ); // ~1_AMOUNT~ gold has been withdrawn from your bank box. + item.PaidInsurance = true; + } + else + { + SendLocalizedMessage(1061079, "", 0x23); // You lack the funds to purchase the insurance + return; + } + } + + item.Insured = true; + + SendLocalizedMessage(1060873, "", 0x23); // You have insured the item + + if (target) + { + BeginTarget(-1, false, TargetFlags.None, ToggleItemInsurance_Callback); + SendLocalizedMessage( + 1060868, + "", + 0x23 + ); // Target the item you wish to toggle insurance status on to cancel + } + } + } + + private void AutoRenewInventoryInsurance() + { + if (!CheckAlive()) + { + return; + } + + // You have selected to automatically reinsure all insured items upon death + SendLocalizedMessage(1060881, "", 0x23); + AutoRenewInsurance = true; + } + + private void CancelRenewInventoryInsurance() + { + if (!CheckAlive()) + { + return; + } + + if (Core.SE) + { + NetState?.SendGump(new CancelRenewInventoryInsuranceGump(null)); + } + else + { + // You have cancelled automatically reinsuring all insured items upon death + SendLocalizedMessage(1061075, "", 0x23); + AutoRenewInsurance = false; + } + } + + private void OpenItemInsuranceMenu() + { + if (!CheckAlive()) + { + return; + } + + using var queue = PooledRefQueue.Create(128); + + foreach (var item in Items) + { + if (DisplayInItemInsuranceGump(item)) + { + queue.Enqueue(item); + } + } + + var pack = Backpack; + + if (pack != null) + { + foreach (var item in pack.FindItems()) + { + if (DisplayInItemInsuranceGump(item)) + { + queue.Enqueue(item); + } + } + } + + // TODO: Investigate item sorting + if (NetState != null) + { + if (queue.Count == 0) + { + SendLocalizedMessage(1114915, "", 0x35); // None of your current items meet the requirements for insurance. + } + else + { + NetState.SendGump(new ItemInsuranceMenuGump(this, queue.ToArray())); + } + } + } + + private bool DisplayInItemInsuranceGump(Item item) => (item.Visible || AccessLevel >= AccessLevel.GameMaster) && + (item.Insured || CanInsure(item)); private void ToggleQuestItem() { @@ -3741,7 +4022,7 @@ namespace Server.Mobiles if (EvilOmenSpell.EndEffect(this)) { - poison = Poison.IncreaseLevel(poison); + poison = PoisonImpl.IncreaseLevel(poison); } var result = base.ApplyPoison(from, poison); @@ -4192,9 +4473,7 @@ namespace Server.Mobiles var offset = duration.TotalMilliseconds - roundedSeconds * TimeSpan.MillisecondsPerSecond; if (offset > 0) { - Timer.DelayCall( - TimeSpan.FromMilliseconds(offset), - () => + Timer.DelayCall(TimeSpan.FromMilliseconds(offset), () => { // They are still online, we still have the buff icon in the table, and it is the same buff icon if (NetState != null && m_BuffTable?.GetValueOrDefault(buffInfo.ID) == buffInfo) @@ -4319,5 +4598,295 @@ namespace Server.Mobiles m_Callback?.Invoke(); } } + + private class CancelRenewInventoryInsuranceGump : StaticGump + { + private readonly ItemInsuranceMenuGump _insuranceGump; + + public override bool Singleton => true; + + public CancelRenewInventoryInsuranceGump(ItemInsuranceMenuGump insuranceGump) : base(250, 200) => + _insuranceGump = insuranceGump; + + protected override void BuildLayout(ref StaticGumpBuilder builder) + { + builder.AddBackground(0, 0, 240, 142, 0x13BE); + builder.AddImageTiled(6, 6, 228, 100, 0xA40); + builder.AddImageTiled(6, 116, 228, 20, 0xA40); + builder.AddAlphaRegion(6, 6, 228, 142); + + // You are about to disable inventory insurance auto-renewal. + builder.AddHtmlLocalized(8, 8, 228, 100, 1071021, 0x7FFF); + + builder.AddButton(6, 116, 0xFB1, 0xFB2, 0); + builder.AddHtmlLocalized(40, 118, 450, 20, 1060051, 0x7FFF); // CANCEL + + builder.AddButton(114, 116, 0xFA5, 0xFA7, 1); + builder.AddHtmlLocalized(148, 118, 450, 20, 1071022, 0x7FFF); // DISABLE IT! + } + + public override void OnResponse(NetState sender, in RelayInfo info) + { + if (sender.Mobile is not PlayerMobile pm || !pm.CheckAlive()) + { + return; + } + + if (info.ButtonID == 1) + { + // You have cancelled automatically reinsuring all insured items upon death + pm.SendLocalizedMessage(1061075, "", 0x23); + pm.AutoRenewInsurance = false; + } + else + { + pm.SendLocalizedMessage(1042021); // Cancelled. + } + + if (_insuranceGump != null) + { + pm.SendGump(_insuranceGump); + } + } + } + + private class ItemInsuranceMenuGump : DynamicGump + { + private readonly PlayerMobile _from; + private readonly bool[] _insure; + private readonly Item[] _items; + private int _page; + + public override bool Singleton => true; + + public ItemInsuranceMenuGump(PlayerMobile from, Item[] items) : base(25, 50) + { + _from = from; + _items = items; + _insure = new bool[items.Length]; + + for (var i = 0; i < items.Length; ++i) + { + _insure[i] = items[i].Insured; + } + } + + protected override void BuildLayout(ref DynamicGumpBuilder builder) + { + builder.AddPage(); + + builder.AddBackground(0, 0, 520, 510, 0x13BE); + builder.AddImageTiled(10, 10, 500, 30, 0xA40); + builder.AddImageTiled(10, 50, 500, 355, 0xA40); + builder.AddImageTiled(10, 415, 500, 80, 0xA40); + builder.AddAlphaRegion(10, 10, 500, 485); + + builder.AddButton(15, 470, 0xFB1, 0xFB2, 0); + builder.AddHtmlLocalized(50, 472, 80, 20, 1011012, 0x7FFF); // CANCEL + + if (_from.AutoRenewInsurance) + { + builder.AddButton(360, 10, 9723, 9724, 1); + } + else + { + builder.AddButton(360, 10, 9720, 9722, 1); + } + + builder.AddHtmlLocalized(395, 14, 105, 20, 1114122, 0x7FFF); // AUTO REINSURE + + builder.AddButton(395, 470, 0xFA5, 0xFA6, 2); + builder.AddHtmlLocalized(430, 472, 50, 20, 1006044, 0x7FFF); // OK + + builder.AddHtmlLocalized(10, 14, 150, 20, 1114121, 0x7FFF); //
ITEM INSURANCE MENU
+ + builder.AddHtmlLocalized(45, 54, 70, 20, 1062214, 0x7FFF); // Item + builder.AddHtmlLocalized(250, 54, 70, 20, 1061038, 0x7FFF); // Cost + builder.AddHtmlLocalized(400, 54, 70, 20, 1114311, 0x7FFF); // Insured + + var balance = Banker.GetBalance(_from); + var cost = 0; + + for (var i = 0; i < _items.Length; ++i) + { + if (_insure[i]) + { + cost += GetInsuranceCost(_items[i]); + } + } + + builder.AddHtmlLocalized(15, 420, 300, 20, 1114310, 0x7FFF); // GOLD AVAILABLE: + builder.AddLabel(215, 420, 0x481, balance.ToString()); + builder.AddHtmlLocalized(15, 435, 300, 20, 1114123, 0x7FFF); // TOTAL COST OF INSURANCE: + builder.AddLabel(215, 435, 0x481, cost.ToString()); + + if (cost != 0) + { + builder.AddHtmlLocalized(15, 450, 300, 20, 1114125, 0x7FFF); // NUMBER OF DEATHS PAYABLE: + builder.AddLabel(215, 450, 0x481, (balance / cost).ToString()); + } + + for (int i = _page * 4, y = 72; i < (_page + 1) * 4 && i < _items.Length; ++i, y += 75) + { + var item = _items[i]; + var b = ItemBounds.Bounds[item.ItemID]; + + builder.AddImageTiledButton( + 40, + y, + 0x918, + 0x918, + 0, + GumpButtonType.Page, + 0, + item.ItemID, + item.Hue, + 40 - b.Width / 2 - b.X, + 30 - b.Height / 2 - b.Y + ); + builder.AddItemProperty(item.Serial); + + if (_insure[i]) + { + builder.AddButton(400, y, 9723, 9724, 100 + i); + builder.AddLabel(250, y, 0x481, GetInsuranceCost(item).ToString()); + } + else + { + builder.AddButton(400, y, 9720, 9722, 100 + i); + builder.AddLabel(250, y, 0x66C, GetInsuranceCost(item).ToString()); + } + } + + if (_page >= 1) + { + builder.AddButton(15, 380, 0xFAE, 0xFAF, 3); + builder.AddHtmlLocalized(50, 380, 450, 20, 1044044, 0x7FFF); // PREV PAGE + } + + if ((_page + 1) * 4 < _items.Length) + { + builder.AddButton(400, 380, 0xFA5, 0xFA7, 4); + builder.AddHtmlLocalized(435, 380, 70, 20, 1044045, 0x7FFF); // NEXT PAGE + } + } + + public override void OnResponse(NetState sender, in RelayInfo info) + { + if (info.ButtonID == 0 || !_from.CheckAlive()) + { + return; + } + + switch (info.ButtonID) + { + case 1: // Auto Reinsure + { + if (_from.AutoRenewInsurance) + { + _from.SendGump(new CancelRenewInventoryInsuranceGump(this)); + } + else + { + _from.AutoRenewInventoryInsurance(); + _from.SendGump(this); + } + + break; + } + case 2: // OK + { + _from.SendGump(new ItemInsuranceMenuConfirmGump(this)); + + break; + } + case 3: // Prev + { + if (_page >= 1) + { + _page--; + _from.SendGump(this); + } + + break; + } + case 4: // Next + { + if ((_page + 1) * 4 < _items.Length) + { + _page++; + _from.SendGump(this); + } + + break; + } + default: + { + var idx = info.ButtonID - 100; + + if (idx >= 0 && idx < _items.Length) + { + _insure[idx] = !_insure[idx]; + } + + _from.SendGump(this); + + break; + } + } + } + + private class ItemInsuranceMenuConfirmGump : StaticGump + { + private readonly ItemInsuranceMenuGump _parentGump; + + public ItemInsuranceMenuConfirmGump(ItemInsuranceMenuGump parentGump) : base(250, 200) => + _parentGump = parentGump; + + protected override void BuildLayout(ref StaticGumpBuilder builder) + { + builder.AddBackground(0, 0, 240, 142, 0x13BE); + builder.AddImageTiled(6, 6, 228, 100, 0xA40); + builder.AddImageTiled(6, 116, 228, 20, 0xA40); + builder.AddAlphaRegion(6, 6, 228, 142); + + builder.AddHtmlLocalized(8, 8, 228, 100, 1114300, 0x7FFF); // Do you wish to insure all newly selected items? + + builder.AddButton(6, 116, 0xFB1, 0xFB2, 0); + builder.AddHtmlLocalized(40, 118, 450, 20, 1060051, 0x7FFF); // CANCEL + + builder.AddButton(114, 116, 0xFA5, 0xFA7, 1); + builder.AddHtmlLocalized(148, 118, 450, 20, 1073996, 0x7FFF); // ACCEPT + } + + public override void OnResponse(NetState sender, in RelayInfo info) + { + if (sender.Mobile is not PlayerMobile pm || !pm.CheckAlive()) + { + return; + } + + if (info.ButtonID == 1) + { + var items = _parentGump._items; + var insure = _parentGump._insure; + for (var i = 0; i < items.Length; ++i) + { + var item = items[i]; + + if (item.Insured != insure[i]) + { + pm.ToggleItemInsurance_Callback(pm, item, false); + } + } + } + else + { + pm.SendLocalizedMessage(1042021); // Cancelled. + pm.SendGump(_parentGump); + } + } + } + } } } 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 deleted file mode 100644 index 7b3c44ae4..000000000 --- a/Projects/UOContent/Network/AutoDenylist/AutoDenylist.cs +++ /dev/null @@ -1,343 +0,0 @@ -/************************************************************************* - * ModernUO * - * Copyright 2019-2026 - ModernUO Development Team * - * Email: hi@modernuo.com * - * File: AutoDenylist.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.Generic; -using System.Net; -using System.Threading; -using Server.Logging; -using Server.Network.Bans; - -namespace Server.Network; - -/// -/// A short-lived, in-memory denylist of addresses the shard itself just caught misbehaving. -/// -/// -/// 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 -/// 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; - - private static bool _enabled; - private static long _durationMs; - private static int _maxEntries; - private static bool _warnedFull; - - 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(); - var s = AutoDenylistConfiguration.Settings; - - _enabled = s.Enabled && s.Duration > TimeSpan.Zero && s.MaxEntries > 0; - if (!_enabled) - { - return; - } - - _durationMs = (long)s.Duration.TotalMilliseconds; - _maxEntries = s.MaxEntries; - - ConnectionFilters.Register(new AutoDenylistFilter()); - BanChannel.Register(new AutoDenylistReporter()); - } - - /// - /// Holds an address for the configured duration. Ignores non-behavioural verdicts and refuses to grow - /// past the cap: the flood this exists for must not become a memory leak. - /// - public static void Hold(IPAddress address, string reason) => Hold(address, reason, Core.TickCount); - - internal static bool Hold(IPAddress address, string reason, long nowTicks) - { - if (!_enabled || address == null || !BanReasons.IsBehavioral(reason)) - { - 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)) - { - return true; - } - - // Drain already reclaimed everything reclaimable, so being over now means genuinely full. - if (_held.Count > _maxEntries) - { - _held.Remove(key); - - if (!_warnedFull) - { - _warnedFull = true; - logger.Warning( - "Auto-denylist is full at {Max} addresses; further detections are disconnected but not held", - _maxEntries - ); - } - - return false; - } - - Push(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. - /// - internal static bool IsDenied(IPAddress address, long nowTicks) - { - if (!_enabled || address == null) - { - return false; - } - - Drain(nowTicks); - return _held.Contains(address.ToUInt128()); - } - - /// Releases an address early, e.g. when an operator retracts a ban. - public static void Release(IPAddress address) - { - if (!_enabled || address == null) - { - 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); - } - } - - /// - /// 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) - { - _held.Remove(_ringKeys[_ringHead]); - _ringHead = _ringHead + 1 == _ringKeys.Length ? 0 : _ringHead + 1; - _ringCount--; - } - - if (_ringCount != before) - { - _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; - _warnedFull = false; - } -} - -/// Accept-path gate for . -public sealed class AutoDenylistFilter : IConnectionFilter -{ - private Timer _sweepTimer; - - public string Name => "auto-denylist"; - - public void Register() - { - } - - 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) - ); - } - - 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); -} - -/// -/// Feeds from the ban channel. A reporter rather than a direct call, because the -/// detection sites live in the engine and must not reach into content. -/// -public sealed class AutoDenylistReporter : IBanReporter -{ - public string Name => "auto-denylist"; - - public bool CanRetract => true; - - public void Register() - { - } - - public void Start(CancellationToken token) - { - } - - public void Stop() - { - } - - /// - /// The contributed is ignored: how long a bouncer should ban an address is a - /// different question from how long this shard holds it at accept. - /// - public void Report(IPAddress address, TimeSpan ttl, string reason) => AutoDenylist.Hold(address, reason); - - public void Retract(IPAddress address) => AutoDenylist.Release(address); -} diff --git a/Projects/UOContent/Network/AutoDenylist/AutoDenylistConfiguration.cs b/Projects/UOContent/Network/AutoDenylist/AutoDenylistConfiguration.cs deleted file mode 100644 index fd13ef003..000000000 --- a/Projects/UOContent/Network/AutoDenylist/AutoDenylistConfiguration.cs +++ /dev/null @@ -1,88 +0,0 @@ -/************************************************************************* - * ModernUO * - * Copyright 2019-2026 - ModernUO Development Team * - * Email: hi@modernuo.com * - * File: AutoDenylistConfiguration.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; - -/// -/// Loads the from Configuration/auto-denylist.json. Loaded once; -/// a missing file writes a template so operators have something to edit. -/// -public static class AutoDenylistConfiguration -{ - private const string _path = "Configuration/auto-denylist.json"; - - public static AutoDenylistSettings 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 AutoDenylistSettings(); - Save(); - } - } - - private static void Save() - { - JsonConfig.Serialize(Path.Join(Core.BaseDirectory, _path), Settings); - } -} - -/// Bound configuration for . -public record AutoDenylistSettings -{ - /// Whether behavioural detections are held locally. Disabled makes the filter inert. - [JsonPropertyName("enabled")] - public bool Enabled { get; set; } = true; - - /// - /// How long an address is denied at accept after the shard catches it misbehaving. Deliberately - /// independent of the duration reported to external bouncers: this is a local holding pen, not a ban. - /// - /// - /// Short on purpose: it covers the gap before an OS bouncer reacts, and blunts a flood on shards running - /// none. An address still attacking is simply re-detected and re-added, so the list sustains itself while - /// a mistake clears on its own. - /// - [JsonPropertyName("duration")] - public TimeSpan Duration { get; set; } = TimeSpan.FromMinutes(15); - - /// - /// Hard cap on tracked addresses: a distinct-source flood is the case this exists for, so the cap is what - /// 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; -} diff --git a/Projects/UOContent/Network/BanExemptions.cs b/Projects/UOContent/Network/BanExemptions.cs deleted file mode 100644 index c5f6b726a..000000000 --- a/Projects/UOContent/Network/BanExemptions.cs +++ /dev/null @@ -1,61 +0,0 @@ -/************************************************************************* - * ModernUO * - * Copyright 2019-2026 - ModernUO Development Team * - * Email: hi@modernuo.com * - * File: BanExemptions.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.Net; -using Server.Network.Bans; - -namespace Server.Network; - -/// -/// Combines and into the one answer -/// asks for, so neither source has to know about the other. -/// -public static class BanExemptions -{ - public static void Configure() - { - BanChannel.IsExempt = IsExempt; - } - - public static bool IsExempt(IPAddress address, string reason) => - IsExempt(address, reason, LoginAllowlist.IsExemptFromEscalation); - - /// - /// Split for testing. is stateful — calling it spends a strike — so it - /// must not be invoked once the answer is already decided. - /// - internal static bool IsExempt(IPAddress address, string reason, Func loginAllowlist) - { - if (address == null) - { - return false; - } - - // Never suppress an operator's explicit ban. Checked first so it also costs no strike. - if (!BanReasons.IsBehavioral(reason)) - { - return false; - } - - // Deliberate and unconditional, so it wins and must not spend the earned list's strikes. - if (ManualAllowlist.Contains(address)) - { - return true; - } - - return loginAllowlist(address, reason); - } -} diff --git a/Projects/UOContent/Network/Blocklist/BlocklistConfiguration.cs b/Projects/UOContent/Network/Blocklist/BlocklistConfiguration.cs deleted file mode 100644 index 19541636f..000000000 --- a/Projects/UOContent/Network/Blocklist/BlocklistConfiguration.cs +++ /dev/null @@ -1,96 +0,0 @@ -/************************************************************************* - * ModernUO * - * Copyright 2019-2026 - ModernUO Development Team * - * Email: hi@modernuo.com * - * File: BlocklistConfiguration.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/blocklist.json. Loaded once; a -/// missing file writes a template so operators have something to edit. -/// -public static class BlocklistConfiguration -{ - private const string _path = "Configuration/blocklist.json"; - - public static BlocklistSettings 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 BlocklistSettings(); - Save(); - } - } - - private static void Save() - { - JsonConfig.Serialize(Path.Join(Core.BaseDirectory, _path), Settings); - } -} - -/// -/// 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. -/// -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 - /// "" to disable the gate entirely. Produce the file with tools/Export-IpBlocklist.ps1. - /// - [JsonPropertyName("file")] - public string File { get; set; } = "Configuration/ip-blocklist.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); - - /// Whether blocklist hits are contributed to the ban channel (the demand-paging promotion). - [JsonPropertyName("reportHits")] - public bool ReportHits { get; set; } = true; - - /// Duration reported for a blocklist-matched ban. - [JsonPropertyName("banDuration")] - public TimeSpan BanDuration { get; set; } = TimeSpan.FromHours(6); - - /// - /// How long the accept-path guard suppresses re-reporting a promoted address. This only needs to - /// bridge the gap until the OS bouncer picks up the promotion (seconds); after that the kernel drops - /// repeat traffic. Decoupled from so the guard doesn't have to remember - /// hours' worth of distinct addresses. - /// - [JsonPropertyName("promoteSuppression")] - public TimeSpan PromoteSuppression { get; set; } = TimeSpan.FromSeconds(60); -} diff --git a/Projects/UOContent/Network/Blocklist/BlocklistFile.cs b/Projects/UOContent/Network/Blocklist/BlocklistFile.cs deleted file mode 100644 index a593a7035..000000000 --- a/Projects/UOContent/Network/Blocklist/BlocklistFile.cs +++ /dev/null @@ -1,86 +0,0 @@ -/************************************************************************* - * ModernUO * - * Copyright 2019-2026 - ModernUO Development Team * - * Email: hi@modernuo.com * - * File: BlocklistFile.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; - -namespace Server.Network.Bans; - -public readonly record struct BlocklistHeader(string Generated, int Count, bool Present); - -/// Reads the versioned blocklist file: cheap header probe + full snapshot load. -public static class BlocklistFile -{ - public static bool TryReadHeader(string path, out BlocklistHeader header) - { - header = new BlocklistHeader(null, 0, false); - try - { - if (!File.Exists(path)) - { - return false; - } - using var reader = new StreamReader(path); - var first = reader.ReadLine(); - if (first == null) - { - header = new BlocklistHeader(null, 0, true); - return true; - } - string generated = null; - var count = 0; - if (first.StartsWith('#')) - { - var tokens = first.Split(' ', StringSplitOptions.RemoveEmptyEntries); - for (var i = 0; i < tokens.Length; i++) - { - var tok = tokens[i]; - if (tok.StartsWith("generated=", StringComparison.Ordinal)) - { - generated = tok["generated=".Length..]; - } - else if (tok.StartsWith("count=", StringComparison.Ordinal)) - { - int.TryParse(tok["count=".Length..], out count); - } - } - } - header = new BlocklistHeader(generated, count, true); - return true; - } - catch - { - return false; // treat as absent; caller keeps last-good / empty - } - } - - public static BlocklistSnapshot Load(string path, out int parsed, out int skipped) - { - parsed = 0; - skipped = 0; - try - { - if (!File.Exists(path)) - { - return BlocklistSnapshot.Empty; - } - return BlocklistSnapshot.Build(File.ReadAllBytes(path), out parsed, out skipped); - } - catch - { - return BlocklistSnapshot.Empty; - } - } -} diff --git a/Projects/UOContent/Network/Blocklist/BlocklistFilter.cs b/Projects/UOContent/Network/Blocklist/BlocklistFilter.cs deleted file mode 100644 index 030997803..000000000 --- a/Projects/UOContent/Network/Blocklist/BlocklistFilter.cs +++ /dev/null @@ -1,316 +0,0 @@ -/************************************************************************* - * ModernUO * - * Copyright 2019-2026 - ModernUO Development Team * - * Email: hi@modernuo.com * - * File: BlocklistFilter.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.Net; -using System.Threading; -using System.Threading.Tasks; -using Server.Logging; - -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. -/// -/// -/// This is the demand-paging half of the design: an OS firewall cannot hold millions of entries on -/// Windows, so the millions live here and only addresses that actually connect are promoted to CrowdSec -/// (and from there to the OS firewall) through . -/// keeps a flood of repeat connections from re-reporting the same address before the bouncer picks it up. -/// -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. - private volatile BlocklistSnapshot _snapshot = BlocklistSnapshot.Empty; - - private readonly PromotedGuard _guard = new(); - - private bool _enabled; - private string _path; - private TimeSpan _interval; - private bool _reportHits; - private TimeSpan _banDuration; - private long _suppressionMs; - private string _lastGenerated; - private DateTime _lastWriteUtc; - private CancellationTokenSource _cts; - private Timer _sweepTimer; - - public string Name => "blocklist"; - - public int Count => _snapshot.Count; - - public static void Configure() - { - ConnectionFilters.Register(new BlocklistFilter()); - } - - public void Register() - { - BlocklistConfiguration.Load(); - 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; - _suppressionMs = (long)s.PromoteSuppression.TotalMilliseconds; - } - - /// - /// Resolves the configured path once: a relative path is anchored to - /// (never the process working directory, which differs when the shard is launched from elsewhere), an - /// absolute path is taken as-is so several shards can share one generated list. - /// - private static string ResolvePath(string configured) - { - if (string.IsNullOrWhiteSpace(configured)) - { - return null; - } - - return Path.IsPathRooted(configured) ? configured : Path.Join(Core.BaseDirectory, configured); - } - - public void Start(CancellationToken token) - { - if (!_enabled) - { - LogWhyDisabled(); - 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. - if (File.Exists(_path)) - { - Reload(); // synchronous prime; empty on failure (fail-open) - } - else - { - 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); - } - - _ = 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) - { - if (!Evaluate(address, Core.TickCount, out var shouldReport)) - { - return false; - } - - if (shouldReport) - { - // Demand-page this address up to the OS-level bouncer. Enqueue-only; never blocks the loop. - BanChannel.Report(address, _banDuration, BanReasons.Blocklist); - } - - return true; - } - - /// - /// The pure decision, split out so the accept-path policy can be tested without a clock or a ban - /// channel. is true at most once per suppression window. - /// - internal bool Evaluate(IPAddress address, long nowTicks, out bool shouldReport) - { - shouldReport = false; - - if (!_snapshot.IsBanned(address)) - { - return false; - } - - // 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)) - { - return false; - } - - // A feed listing an address a real player logged in from recently is far more often a false positive - // than a compromise. - if (LoginAllowlist.IsAllowed(address)) - { - return false; - } - - if (_reportHits) - { - shouldReport = _guard.TryMark(address.ToUInt128(), nowTicks, _suppressionMs); - } - - return true; - } - - private void SweepGuard() => _guard.Sweep(Core.TickCount); - - // Test hook: inject a snapshot and policy without file I/O. - internal void LoadForTesting(BlocklistSnapshot snapshot, bool reportHits = true, long suppressionMs = 60000) - { - _snapshot = snapshot; - _reportHits = reportHits; - _suppressionMs = suppressionMs; - } - - private async ValueTask PollLoop(CancellationToken token) - { - while (!token.IsCancellationRequested) - { - try - { - await Task.Delay(_interval, token); - } - catch (OperationCanceledException) - { - return; - } - - try - { - if (ChangedSinceLastLoad()) - { - // Parsing millions of lines competes with a save for CPU and page cache, so yield until - // the world is written out. See the threading policy in CLAUDE.md (rules #3 and #10). - while (World.Saving || World.WorldState == WorldState.PendingSave) - { - await Task.Delay(TimeSpan.FromSeconds(1), token); - } - - Reload(); - } - } - catch (OperationCanceledException) - { - return; - } - catch (Exception e) - { - logger.Warning(e, "Blocklist reload check failed; keeping last snapshot ({Count})", Count); - } - } - } - - private bool ChangedSinceLastLoad() - { - try - { - var info = new FileInfo(_path); - if (!info.Exists) - { - return false; - } - - if (info.LastWriteTimeUtc == _lastWriteUtc) - { - return false; // cheapest guard - } - } - catch - { - return false; - } - - return !BlocklistFile.TryReadHeader(_path, out var h) || h.Generated != _lastGenerated; - } - - 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. - var writeUtc = default(DateTime); - try - { - writeUtc = new FileInfo(_path).LastWriteTimeUtc; - } - catch - { - /* keep default */ - } - - BlocklistFile.TryReadHeader(_path, out var h); - - var next = BlocklistFile.Load(_path, out var parsed, out var skipped); - _snapshot = next; // single volatile swap; readers see old or new whole - _lastGenerated = h.Generated; - _lastWriteUtc = writeUtc; - - logger.Information("Blocklist loaded {Parsed} entr(ies) ({Count} ranges, {Skipped} skipped) gen={Gen}", - parsed, next.Count, skipped, h.Generated); - } -} diff --git a/Projects/UOContent/Network/Blocklist/BlocklistSnapshot.cs b/Projects/UOContent/Network/Blocklist/BlocklistSnapshot.cs deleted file mode 100644 index d62df0752..000000000 --- a/Projects/UOContent/Network/Blocklist/BlocklistSnapshot.cs +++ /dev/null @@ -1,210 +0,0 @@ -/************************************************************************* - * ModernUO * - * Copyright 2019-2026 - ModernUO Development Team * - * Email: hi@modernuo.com * - * File: BlocklistSnapshot.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.Buffers.Text; -using System.Net; -using System.Net.Sockets; -using System.Text; -using Server.Collections; - -namespace Server.Network.Bans; - -/// -/// Immutable dual-stack blocklist. Singles and CIDRs are folded into a single sorted, coalesced -/// interval index per family: IPv4 as ranges (lean for the millions-strong common -/// case), IPv6 as ranges (empty unless the feed carries v6). Immutable → lock-free reads. -/// -public sealed class BlocklistSnapshot -{ - public static readonly BlocklistSnapshot Empty = new(SortedRangeIndex.Empty, SortedRangeIndex.Empty); - - private readonly SortedRangeIndex _v4; - private readonly SortedRangeIndex _v6; - - public int Count => _v4.Count + _v6.Count; - - private BlocklistSnapshot(SortedRangeIndex v4, SortedRangeIndex v6) - { - _v4 = v4; - _v6 = v6; - } - - /// - /// 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. - /// - public static BlocklistSnapshot Build(ReadOnlySpan data, out int parsed, out int skipped) - { - parsed = 0; - skipped = 0; - - // Only the two final index arrays (allocated inside SortedRangeIndex.Build) hit the heap; every - // build-time buffer here is a pooled ref list. mt: true is required — this runs off the game loop. - using var v4 = PooledRefList.Range>.Create(mt: true); - using var v6 = PooledRefList.Range>.Create(mt: true); - - var rest = data; - while (!rest.IsEmpty) - { - ReadOnlySpan line; - var nl = rest.IndexOf((byte)'\n'); - if (nl >= 0) - { - line = rest[..nl]; - rest = rest[(nl + 1)..]; - } - else - { - line = rest; - rest = default; - } - - line = line[Ascii.Trim(line)]; - if (line.IsEmpty || line[0] == (byte)'#' || line[0] == (byte)';') - { - continue; - } - - var slash = line.IndexOf((byte)'/'); - var addr = slash >= 0 ? line[..slash] : line; - var bitsToken = slash >= 0 ? line[(slash + 1)..] : default; - - if (addr.IndexOf((byte)':') < 0) - { - // IPv4 single or CIDR — parsed straight from the byte span. - if (slash >= 0) - { - if (IPAddressUtility.TryParseV4(addr, out var ip) && - TryParseBits(bitsToken, out var bits) && bits is >= 0 and <= 32) - { - var size = bits == 0 ? 0xFFFFFFFFu : (1u << (32 - bits)) - 1; - var b = ip & ~size; - v4.Add(new SortedRangeIndex.Range(b, b + size)); - parsed++; - } - else - { - skipped++; - } - } - else if (IPAddressUtility.TryParseV4(addr, out var ip)) - { - v4.Add(new SortedRangeIndex.Range(ip, ip)); - parsed++; - } - else - { - skipped++; - } - } - else if (TryDecodeV6(addr, out var v)) - { - // IPv6 is rare in these feeds; the single token was decoded and framework-parsed above. - if (slash >= 0) - { - if (TryParseBits(bitsToken, out var bits) && bits is >= 0 and <= 128) - { - var mask = bits == 0 ? UInt128.Zero : ~((UInt128.One << (128 - bits)) - 1); - var b = v & mask; - v6.Add(new SortedRangeIndex.Range(b, b | ~mask)); - parsed++; - } - else - { - skipped++; - } - } - else - { - v6.Add(new SortedRangeIndex.Range(v, v)); - parsed++; - } - } - else - { - skipped++; - } - } - - v4.Sort(SortedRangeIndex.ByMin); - v6.Sort(SortedRangeIndex.ByMin); - return new BlocklistSnapshot(SortedRangeIndex.Build(v4.AsSpan()), SortedRangeIndex.Build(v6.AsSpan())); - } - - // Decodes a single IPv6 address token from ASCII bytes and validates it via the framework parser. - private static bool TryDecodeV6(ReadOnlySpan addr, out UInt128 v) - { - v = UInt128.Zero; - if (addr.Length > 45) - { - return false; - } - - Span chars = stackalloc char[addr.Length]; - for (var i = 0; i < addr.Length; i++) - { - chars[i] = (char)addr[i]; - } - - if (!IPAddress.TryParse(chars, out var a) || a.AddressFamily != AddressFamily.InterNetworkV6) - { - return false; - } - - v = a.ToUInt128(); - return true; - } - - private static bool TryParseBits(ReadOnlySpan token, out int bits) - { - if (Utf8Parser.TryParse(token, out bits, out var consumed) && consumed == token.Length) - { - return true; - } - - bits = 0; - return false; - } - - /// - /// 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); - - public bool IsBanned(IPAddress ip) - { - if (ip.IsIPv4MappedToIPv6) - { - // v6-encoded v4 must not dodge the v4 set; extract the embedded v4 uint directly. - return IPAddressUtility.TryMappedV4(ip, out var mv) && _v4.Contains(mv); - } - - if (ip.AddressFamily == AddressFamily.InterNetwork) - { - return IPAddressUtility.TryV4(ip, out var v) && _v4.Contains(v); - } - - if (ip.AddressFamily == AddressFamily.InterNetworkV6) - { - return _v6.Contains(ip.ToUInt128()); - } - - return false; - } -} diff --git a/Projects/UOContent/Network/Blocklist/PromotedGuard.cs b/Projects/UOContent/Network/Blocklist/PromotedGuard.cs deleted file mode 100644 index bba8e3ee5..000000000 --- a/Projects/UOContent/Network/Blocklist/PromotedGuard.cs +++ /dev/null @@ -1,51 +0,0 @@ -/************************************************************************* - * ModernUO * - * Copyright 2019-2026 - ModernUO Development Team * - * Email: hi@modernuo.com * - * File: PromotedGuard.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.Generic; - -namespace Server.Network.Bans; - -/// Suppresses re-reporting the same IP within a TTL. Accept-path thread only. -public sealed class PromotedGuard -{ - private readonly Dictionary _expiry = []; - - public bool TryMark(UInt128 ip, long nowTicks, long ttlMs) - { - if (_expiry.TryGetValue(ip, out var exp) && exp - nowTicks > 0) - { - return false; - } - _expiry[ip] = nowTicks + ttlMs; - return true; - } - - public void Sweep(long nowTicks) - { - if (_expiry.Count == 0) - { - return; - } - - foreach (var (ip, exp) in _expiry) - { - if (exp - nowTicks <= 0) - { - _expiry.Remove(ip); - } - } - } -} diff --git a/Projects/UOContent/Network/CrowdSec/CrowdSecAlert.cs b/Projects/UOContent/Network/CrowdSec/CrowdSecAlert.cs deleted file mode 100644 index 773cf50ec..000000000 --- a/Projects/UOContent/Network/CrowdSec/CrowdSecAlert.cs +++ /dev/null @@ -1,78 +0,0 @@ -/************************************************************************* - * ModernUO * - * Copyright 2019-2026 - ModernUO Development Team * - * Email: hi@modernuo.com * - * File: CrowdSecAlert.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.Text.Json.Serialization; - -namespace Server.Network.Bans.CrowdSec; - -/// One CrowdSec alert (POST /v1/alerts takes an array of these). -public sealed class CrowdSecAlert -{ - [JsonPropertyName("scenario")] public string Scenario { get; set; } - - /// - /// Required by LAPI. It dereferences the field unconditionally when persisting the alert, so - /// omitting it answers 500 rather than a validation error. Empty is the accepted value for a - /// watcher that isn't shipping a hub scenario. - /// - [JsonPropertyName("scenario_hash")] public string ScenarioHash { get; set; } = ""; - - /// Required alongside ; same 500-on-missing behavior. - [JsonPropertyName("scenario_version")] public string ScenarioVersion { get; set; } = "1.0"; - - [JsonPropertyName("message")] public string Message { get; set; } - [JsonPropertyName("events_count")] public int EventsCount { get; set; } = 1; - [JsonPropertyName("start_at")] public string StartAt { get; set; } - [JsonPropertyName("stop_at")] public string StopAt { get; set; } - - /// Bucket capacity. One decision per alert, so 1 — a leaky-bucket capacity of 0 is nonsense. - [JsonPropertyName("capacity")] public int Capacity { get; set; } = 1; - [JsonPropertyName("leakspeed")] public string LeakSpeed { get; set; } = "0s"; - [JsonPropertyName("simulated")] public bool Simulated { get; set; } - [JsonPropertyName("events")] public object[] Events { get; set; } = []; - [JsonPropertyName("remediation")] public bool Remediation { get; set; } = true; - [JsonPropertyName("source")] public CrowdSecSource Source { get; set; } - [JsonPropertyName("decisions")] public CrowdSecDecisionDto[] Decisions { get; set; } -} - -public sealed class CrowdSecSource -{ - [JsonPropertyName("scope")] public string Scope { get; set; } = "Ip"; - [JsonPropertyName("value")] public string Value { get; set; } -} - -public sealed class CrowdSecDecisionDto -{ - [JsonPropertyName("origin")] public string Origin { get; set; } - [JsonPropertyName("type")] public string Type { get; set; } = "ban"; - [JsonPropertyName("scope")] public string Scope { get; set; } = "Ip"; - [JsonPropertyName("value")] public string Value { get; set; } - [JsonPropertyName("duration")] public string Duration { get; set; } - [JsonPropertyName("scenario")] public string Scenario { get; set; } -} - -/// Watcher login request/response for POST /v1/watchers/login. -public sealed class CrowdSecLoginRequest -{ - [JsonPropertyName("machine_id")] public string MachineId { get; set; } - [JsonPropertyName("password")] public string Password { get; set; } -} - -public sealed class CrowdSecLoginResponse -{ - [JsonPropertyName("code")] public int Code { get; set; } - [JsonPropertyName("token")] public string Token { get; set; } - [JsonPropertyName("expire")] public string Expire { get; set; } -} diff --git a/Projects/UOContent/Network/CrowdSec/CrowdSecAlertClient.cs b/Projects/UOContent/Network/CrowdSec/CrowdSecAlertClient.cs deleted file mode 100644 index b91d0ebe5..000000000 --- a/Projects/UOContent/Network/CrowdSec/CrowdSecAlertClient.cs +++ /dev/null @@ -1,152 +0,0 @@ -/************************************************************************* - * ModernUO * - * Copyright 2019-2026 - ModernUO Development Team * - * Email: hi@modernuo.com * - * File: CrowdSecAlertClient.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.Generic; -using System.Globalization; -using System.Net; -using System.Net.Http; -using System.Net.Http.Headers; -using System.Net.Http.Json; -using System.Text.Json; -using System.Threading; -using System.Threading.Tasks; - -namespace Server.Network.Bans.CrowdSec; - -/// Reporter-side LAPI operations, mockable for tests. -public interface ICrowdSecAlertClient : IDisposable -{ - ValueTask PostAlertsAsync(IReadOnlyList alerts, CancellationToken token); - ValueTask DeleteDecisionsAsync(string origin, IPAddress ip, CancellationToken token); -} - -/// -/// CrowdSec LAPI watcher client: authenticates with machine credentials and posts/deletes decisions. -/// Holds a JWT refreshed on expiry or a 401. -/// -public sealed class CrowdSecAlertClient : ICrowdSecAlertClient -{ - private static readonly JsonSerializerOptions _jsonOptions = new() { PropertyNameCaseInsensitive = true }; - - /// - /// The crowdsec/ prefix is load-bearing: LAPI's default watcher profile matches on it and - /// answers 401 for anything else, so this cannot be a plain product string. - /// - internal const string UserAgent = "crowdsec/ModernUO-watcher-1.0"; - - private readonly HttpClient _http; - private readonly string _machineId; - private readonly string _password; - - private string _token; - private DateTime _tokenExpiresUtc = DateTime.MinValue; - - public CrowdSecAlertClient(CrowdSecSettings settings) - { - var baseUri = new Uri(settings.LapiUrl, UriKind.Absolute); // fails loud on malformed url - _http = new HttpClient { BaseAddress = baseUri, Timeout = TimeSpan.FromSeconds(30) }; - _http.DefaultRequestHeaders.Add("User-Agent", UserAgent); - _machineId = settings.MachineId; - _password = settings.Password; - } - - private async Task EnsureAuthAsync(CancellationToken token) - { - if (_token != null && DateTime.UtcNow < _tokenExpiresUtc - TimeSpan.FromMinutes(1)) - { - return; - } - - var request = new CrowdSecLoginRequest { MachineId = _machineId, Password = _password }; - using var response = await _http.PostAsJsonAsync("/v1/watchers/login", request, _jsonOptions, token) - .ConfigureAwait(false); - response.EnsureSuccessStatusCode(); - - var login = await response.Content.ReadFromJsonAsync(_jsonOptions, token) - .ConfigureAwait(false); - _token = login?.Token ?? throw new InvalidOperationException("CrowdSec login returned no token."); - - // LAPI returns an RFC3339 expiry. Parse it invariantly for the same reason we format invariantly: - // the current culture must not decide whether a machine-readable timestamp is understood. - _tokenExpiresUtc = DateTime.TryParse( - login.Expire, - CultureInfo.InvariantCulture, - DateTimeStyles.AdjustToUniversal, - out var exp - ) ? exp : DateTime.UtcNow.AddHours(1); - } - - private void Authorize(HttpRequestMessage message) => - message.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _token); - - public async ValueTask PostAlertsAsync(IReadOnlyList alerts, CancellationToken token) - { - if (alerts.Count == 0) - { - return; - } - - await SendWithRetryAsync(() => - { - var message = new HttpRequestMessage(HttpMethod.Post, "/v1/alerts") - { - Content = JsonContent.Create(alerts, options: _jsonOptions) - }; - Authorize(message); - return message; - }, token).ConfigureAwait(false); - } - - public async ValueTask DeleteDecisionsAsync(string origin, IPAddress ip, CancellationToken token) - { - var query = BuildDeleteQuery(origin, ip); - await SendWithRetryAsync(() => - { - var message = new HttpRequestMessage(HttpMethod.Delete, query); - Authorize(message); - return message; - }, token).ConfigureAwait(false); - } - - /// - /// Builds the decisions-delete query string. is operator-controlled config - /// (crowdsec.json), so it must be escaped like any other untrusted value going into a URL. - /// - internal static string BuildDeleteQuery(string origin, IPAddress ip) => - $"/v1/decisions?origin={Uri.EscapeDataString(origin)}&ip={Uri.EscapeDataString(ip.ToString())}"; - - private async ValueTask SendWithRetryAsync(Func build, CancellationToken token) - { - await EnsureAuthAsync(token).ConfigureAwait(false); - - using var first = build(); - using var response = await _http.SendAsync(first, token).ConfigureAwait(false); - if (response.StatusCode != HttpStatusCode.Unauthorized) - { - response.EnsureSuccessStatusCode(); - return; - } - - // Token rejected mid-flight: force a re-login and retry once. - _token = null; - await EnsureAuthAsync(token).ConfigureAwait(false); - using var retry = build(); - using var retryResponse = await _http.SendAsync(retry, token).ConfigureAwait(false); - retryResponse.EnsureSuccessStatusCode(); - } - - public void Dispose() => _http.Dispose(); -} diff --git a/Projects/UOContent/Network/CrowdSec/CrowdSecConfiguration.cs b/Projects/UOContent/Network/CrowdSec/CrowdSecConfiguration.cs deleted file mode 100644 index 5855633f9..000000000 --- a/Projects/UOContent/Network/CrowdSec/CrowdSecConfiguration.cs +++ /dev/null @@ -1,100 +0,0 @@ -/************************************************************************* - * ModernUO * - * Copyright 2019-2026 - ModernUO Development Team * - * Email: hi@modernuo.com * - * File: CrowdSecConfiguration.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.CrowdSec; - -/// -/// Loads the from Configuration/crowdsec.json. Loaded once; a -/// missing file writes a disabled-by-default template so operators have something to edit. -/// -public static class CrowdSecConfiguration -{ - private const string _path = "Configuration/crowdsec.json"; - - public static CrowdSecSettings 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 CrowdSecSettings - { - LapiUrl = "http://127.0.0.1:8080", - MachineId = "", - Password = "", - Origin = "modernuo", - ManualBanDuration = TimeSpan.FromHours(168), - FlushInterval = TimeSpan.FromSeconds(1), - MaxQueue = 10000 - }; - - Save(); - } - } - - private static void Save() - { - JsonConfig.Serialize(Path.Join(Core.BaseDirectory, _path), Settings); - } -} - -/// -/// Bound configuration for . Read once at Configure(). -/// The reporter is inert unless is true. -/// -public record CrowdSecSettings -{ - /// LAPI endpoint. Default http://127.0.0.1:8080. - [JsonPropertyName("lapiUrl")] - public string LapiUrl { get; set; } = "http://127.0.0.1:8080"; - - /// Watcher machine id from cscli machines add. Empty disables reporting. - [JsonPropertyName("machineId")] - public string MachineId { get; set; } = ""; - - /// Watcher password paired with . - [JsonPropertyName("password")] - public string Password { get; set; } = ""; - - /// Decision origin stamped on our contributions. Default modernuo. - [JsonPropertyName("origin")] - public string Origin { get; set; } = "modernuo"; - - /// Duration for manual admin bans pushed to CrowdSec. Default 168h (renewable). Finite so a missed retract self-heals. - [JsonPropertyName("manualBanDuration")] - public TimeSpan ManualBanDuration { get; set; } = TimeSpan.FromHours(168); - - /// Max time the drain coalesces before flushing a batch. Default 1s. - [JsonPropertyName("flushInterval")] - public TimeSpan FlushInterval { get; set; } = TimeSpan.FromSeconds(1); - - /// Bounded contribution queue capacity; overflow is dropped (counted). Default 10000. - [JsonPropertyName("maxQueue")] - public int MaxQueue { get; set; } = 10000; - - [JsonIgnore] - public bool ReportingEnabled => !string.IsNullOrWhiteSpace(MachineId) && !string.IsNullOrWhiteSpace(Password); -} diff --git a/Projects/UOContent/Network/CrowdSec/CrowdSecReporter.cs b/Projects/UOContent/Network/CrowdSec/CrowdSecReporter.cs deleted file mode 100644 index c900eb90f..000000000 --- a/Projects/UOContent/Network/CrowdSec/CrowdSecReporter.cs +++ /dev/null @@ -1,409 +0,0 @@ -/************************************************************************* - * ModernUO * - * Copyright 2019-2026 - ModernUO Development Team * - * Email: hi@modernuo.com * - * File: CrowdSecReporter.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.Generic; -using System.Globalization; -using System.Net; -using System.Threading; -using System.Threading.Channels; -using System.Threading.Tasks; -using Server.Logging; - -namespace Server.Network.Bans.CrowdSec; - -/// -/// Contributes locally-decided bans to CrowdSec via the LAPI alerts API. Non-blocking on the accept -/// path: enqueues onto a bounded, drop-on-overflow channel drained by a single -/// background task that coalesces by IP and POSTs batched alerts. -/// -public sealed class CrowdSecReporter : IBanReporter -{ - private static readonly ILogger logger = LogFactory.GetLogger(typeof(CrowdSecReporter)); - - internal readonly record struct ReportItem(IPAddress Ip, TimeSpan Ttl, string Reason, bool Retract); - - // Bounded retry for transient LAPI failures during a drain send (network blips, 5xx). Distinct from - // CrowdSecAlertClient.SendWithRetryAsync's single 401-relogin retry, which is an auth concern. - private static readonly TimeSpan[] _retryDelays = [TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(2)]; - - private ICrowdSecAlertClient _client; - private CrowdSecSettings _settings; - private Channel _queue; - private CancellationTokenSource _cts; - private Task _drainTask; - private int _dropped; - private int _sendFailures; - - public CrowdSecReporter() - { - } - - // Test/embedding ctor with an injected client + settings. - internal CrowdSecReporter(ICrowdSecAlertClient client, CrowdSecSettings settings) - { - _client = client; - _settings = settings; - _queue = CreateQueue(settings.MaxQueue); - } - - public string Name => "crowdsec"; - public bool CanRetract => true; - public int DroppedCount => _dropped; - - /// - /// Batches ultimately dropped after the bounded transient-retry in gave up. - /// Distinct from (queue-overflow drops on the accept path): this counts - /// sustained LAPI outages so operators can see contribution loss instead of it being silent. - /// - public int SendFailureCount => _sendFailures; - - /// The drain loop's task, so tests can assert it stays alive until the loop exits. - internal Task DrainTaskForTesting => _drainTask; - - public static void Configure() - { - BanChannel.Register(new CrowdSecReporter()); - } - - public void Register() - { - CrowdSecConfiguration.Load(); - _settings ??= CrowdSecConfiguration.Settings; - } - - public void Start(CancellationToken token) - { - if (!_settings.ReportingEnabled) - { - logger.Information("CrowdSec reporter disabled (machineId/password empty in crowdsec.json)"); - return; - } - - _client ??= new CrowdSecAlertClient(_settings); - _queue ??= CreateQueue(_settings.MaxQueue); - _cts = CancellationTokenSource.CreateLinkedTokenSource(token); - _drainTask = Task.Run(() => DrainLoop(_cts.Token), _cts.Token); - } - - public void Stop() - { - _cts?.Cancel(); - - // The flush below reads a SingleReader channel, so wait for the drain to actually exit first. - var drainExited = true; - try - { - // Wait(timeout) is false only on timeout; a throw means faulted/cancelled, which is still exited. - drainExited = _drainTask == null || _drainTask.Wait(TimeSpan.FromSeconds(2)); - } - catch - { - // Ignored: a faulted wait means the drain has completed and released the channel. - } - - _cts?.Dispose(); - _cts = null; - - if (drainExited) - { - FlushRemainingOnStop(); - } - - _client?.Dispose(); - _drainTask = null; - } - - /// - /// Best-effort bounded flush of whatever is still queued at shutdown. Blocking is correct here — the - /// loop has stopped ticking — but must not happen on the loop thread: runs where - /// SynchronizationContext.Current is the EventLoopContext, and a captured continuation - /// would be posted to a queue nothing pumps any more. keeps the - /// chain on the pool; the bounded wait caps a wedged send at a few seconds of shutdown. - /// - private void FlushRemainingOnStop() - { - if (_queue == null || _client == null) - { - return; - } - - _queue.Writer.TryComplete(); - - List reports = []; - List retracts = []; - while (_queue.Reader.TryRead(out var item)) - { - (item.Retract ? retracts : reports).Add(item); - } - - if (reports.Count == 0 && retracts.Count == 0) - { - return; - } - - try - { - if (!Task.Run(() => FlushRemainingOnStopAsync(reports, retracts)).Wait(TimeSpan.FromSeconds(4))) - { - logger.Warning( - "CrowdSec flush-on-stop timed out; {Count} item(s) not contributed", - reports.Count + retracts.Count - ); - } - } - catch (Exception e) - { - logger.Warning(e, "CrowdSec flush-on-stop failed"); - } - } - - /// - /// Uses a fresh token, not the drain loop's already-cancelled one, which would fail every send - /// immediately. Reports go as one deduped batch; retracts go as individual DELETEs so an admin's - /// unban propagates on a clean shutdown. Leftovers self-heal via - /// . - /// - private async Task FlushRemainingOnStopAsync(List reports, List retracts) - { - using var flushCts = new CancellationTokenSource(TimeSpan.FromSeconds(3)); - - if (reports.Count > 0) - { - var alerts = BuildAlerts(reports, _settings, DateTime.UtcNow); - try - { - await _client.PostAlertsAsync(alerts, flushCts.Token).ConfigureAwait(false); - } - catch (Exception e) - { - logger.Warning(e, "CrowdSec flush-on-stop reports failed"); - RecordSendFailure(alerts.Count); - } - } - - HashSet seen = []; - for (var i = 0; i < retracts.Count; i++) - { - if (flushCts.IsCancellationRequested) - { - break; // out of budget; the rest self-heal via ManualBanDuration - } - - var ip = retracts[i].Ip; - if (!seen.Add(ip.ToString())) - { - continue; - } - - try - { - await _client.DeleteDecisionsAsync(_settings.Origin, ip, flushCts.Token).ConfigureAwait(false); - } - catch (Exception e) - { - logger.Warning(e, "CrowdSec flush-on-stop retract failed for {Address}", ip); - RecordSendFailure(1); - } - } - } - - public void Report(IPAddress address, TimeSpan ttl, string reason) => - Enqueue(new ReportItem(address, ttl, reason, false)); - - public void Retract(IPAddress address) => - Enqueue(new ReportItem(address, TimeSpan.Zero, "retract", true)); - - private void Enqueue(ReportItem item) - { - if (_queue == null || !_queue.Writer.TryWrite(item)) - { - Interlocked.Increment(ref _dropped); - } - } - - // FullMode.Wait (the default) makes TryWrite return false immediately when the channel is full - // instead of blocking the caller — exactly the non-blocking drop-on-overflow behavior the accept - // path requires. DropWrite would silently discard the new item and always report success, which - // would make overflow undetectable. - private static Channel CreateQueue(int capacity) => - Channel.CreateBounded(new BoundedChannelOptions(Math.Max(1, capacity)) - { - FullMode = BoundedChannelFullMode.Wait, - SingleReader = true - }); - - // Must return Task: Start() passes this to Task.Run, which has no Func overload, so a - // ValueTask would bind to Task.Run and yield a Task that completes at the first - // await rather than when the loop exits. - private async Task DrainLoop(CancellationToken token) - { - var reader = _queue.Reader; - - while (!token.IsCancellationRequested) - { - try - { - if (!await reader.WaitToReadAsync(token).ConfigureAwait(false)) - { - return; - } - - // Coalesce a burst before flushing. - await Task.Delay(_settings.FlushInterval, token).ConfigureAwait(false); - - List reports = []; - List retracts = []; - while (reader.TryRead(out var item)) - { - (item.Retract ? retracts : reports).Add(item); - } - - if (reports.Count > 0) - { - var alerts = BuildAlerts(reports, _settings, DateTime.UtcNow); - if (!await SendWithBoundedRetryAsync(() => _client.PostAlertsAsync(alerts, token), token) - .ConfigureAwait(false)) - { - RecordSendFailure(alerts.Count); - } - } - - for (var i = 0; i < retracts.Count; i++) - { - var ip = retracts[i].Ip; - if (!await SendWithBoundedRetryAsync(() => _client.DeleteDecisionsAsync(_settings.Origin, ip, token), token) - .ConfigureAwait(false)) - { - RecordSendFailure(1); - } - } - } - catch (OperationCanceledException) - { - return; - } - catch (Exception e) - { - // Contribution is auxiliary: log and keep draining. Never crash the shard. - logger.Warning(e, "CrowdSec contribution flush failed; dropped this batch"); - } - } - } - - /// - /// 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. - /// - private static async ValueTask SendWithBoundedRetryAsync(Func send, CancellationToken token) - { - for (var attempt = 0; ; attempt++) - { - try - { - await send().ConfigureAwait(false); - return true; - } - catch (OperationCanceledException) - { - throw; - } - catch (Exception e) - { - if (attempt >= _retryDelays.Length) - { - logger.Warning(e, "CrowdSec send failed after {Attempts} attempt(s); giving up", attempt + 1); - return false; - } - - var delay = _retryDelays[attempt]; - logger.Warning(e, "CrowdSec send failed (attempt {Attempt}); retrying in {Delay}", attempt + 1, delay); - await Task.Delay(delay, token).ConfigureAwait(false); - } - } - } - - private void RecordSendFailure(int itemCount) - { - var total = Interlocked.Increment(ref _sendFailures); - logger.Warning( - "CrowdSec contribution batch dropped after retries ({Items} item(s)); total dropped batches: {Total}", - itemCount, - total - ); - } - - /// Coalesces items by IP (last write wins) and builds one alert per unique address. - internal static List BuildAlerts(IEnumerable items, CrowdSecSettings settings, DateTime nowUtc) - { - Dictionary byIp = []; - foreach (var item in items) - { - byIp[item.Ip.ToString()] = item; - } - - var timestamp = FormatTimestamp(nowUtc); - var alerts = new List(byIp.Count); - - foreach (var (value, item) in byIp) - { - var ttl = item.Reason == BanReasons.Manual || item.Ttl <= TimeSpan.Zero ? settings.ManualBanDuration : item.Ttl; - var scenario = $"{settings.Origin}/{item.Reason}"; - - alerts.Add(new CrowdSecAlert - { - Scenario = scenario, - Message = $"ModernUO {item.Reason} ban for {value}", - StartAt = timestamp, - StopAt = timestamp, - Source = new CrowdSecSource { Scope = "Ip", Value = value }, - Decisions = - [ - new CrowdSecDecisionDto - { - Origin = settings.Origin, - Type = "ban", - Scope = "Ip", - Value = value, - Duration = FormatDuration(ttl), - Scenario = scenario - } - ] - }); - } - - return alerts; - } - - /// - /// ISO8601/RFC3339 UTC timestamp for start_at/stop_at. LAPI parses these with Go's - /// time.RFC3339 and answers 500 when the parse fails, so the format must be culture-independent: - /// ':' is the *time separator* specifier in a custom .NET format string, and a shard running under a - /// culture like fi-FI would otherwise emit "T12.34.56.789Z". InvariantCulture also pins the Gregorian - /// calendar, which non-Gregorian cultures (th-TH, ar-SA) would otherwise shift the year for. - /// - internal static string FormatTimestamp(DateTime time) => - (time.Kind == DateTimeKind.Utc ? time : time.ToUniversalTime()) - .ToString("yyyy-MM-ddTHH:mm:ss.fffZ", CultureInfo.InvariantCulture); - - /// CrowdSec accepts Go durations; whole seconds are unambiguous and sufficient. - internal static string FormatDuration(TimeSpan ttl) - { - var seconds = (long)ttl.TotalSeconds; - return $"{Math.Max(1, seconds)}s"; - } -} diff --git a/Projects/UOContent/Network/Firewall/Firewall.cs b/Projects/UOContent/Network/Firewall/Firewall.cs deleted file mode 100644 index 36c11bbf9..000000000 --- a/Projects/UOContent/Network/Firewall/Firewall.cs +++ /dev/null @@ -1,413 +0,0 @@ -/************************************************************************* - * ModernUO * - * Copyright 2019-2026 - ModernUO Development Team * - * Email: hi@modernuo.com * - * File: Firewall.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.Buffers; -using System.Collections.Generic; -using System.IO; -using System.Net; -using System.Runtime.CompilerServices; -using Server.Collections; -using Server.Json; -using Server.Logging; - -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. - private static readonly List _entries = []; - - // Entries with a TTL: entry -> absolute expiry tick (Core.TickCount). Permanent entries are absent. - private static readonly Dictionary _expiring = []; - - private static SortedRangeIndex _index = SortedRangeIndex.Empty; - private static bool _indexDirty; - - private static readonly ILogger logger = LogFactory.GetLogger(typeof(Firewall)); - private const string _path = "Configuration/firewall.json"; - private const string _legacyPath = "firewall.cfg"; - private static bool _dirty; - private static bool _configured; - - public static int FirewallSetCount => _entries.Count; - - public static void ReadFirewallSet(Action> callback) => callback(_entries); - - public static bool IsBlocked(IPAddress address) - { - if (_entries.Count == 0) - { - return false; - } - - EnsureIndex(); - return _index.Contains(address.ToUInt128()); - } - - // Rebuilds the derived lookup index from the authoritative _entries list, but only when entries have - // changed since the last build. Runs on the main game loop, so the pooled build buffer is single-threaded - // (mt: false); only the two final SortedRangeIndex arrays are heap-allocated. - private static void EnsureIndex() - { - if (!_indexDirty) - { - return; - } - - using var ranges = PooledRefList.Range>.Create(_entries.Count, mt: false); - for (var i = 0; i < _entries.Count; i++) - { - var entry = _entries[i]; - ranges.Add(new SortedRangeIndex.Range(entry.MinIpAddress, entry.MaxIpAddress)); - } - - ranges.Sort(SortedRangeIndex.ByMin); - _index = SortedRangeIndex.Build(ranges.AsSpan()); - _indexDirty = false; - } - - public static bool Add(IFirewallEntry firewallEntry) => Add(firewallEntry, TimeSpan.Zero); - - /// - /// Adds an entry. <= means permanent. Returns false - /// if the entry was already present. - /// - // Indexed scan (no closure allocation); firewall lists are small, so O(n) is negligible and this - // stays off the hot path (Add/Remove are admin/boot actions, not the accept path). - private static int IndexOfEntry(IFirewallEntry entry) - { - for (var i = 0; i < _entries.Count; i++) - { - if (_entries[i].CompareTo(entry) == 0) - { - return i; - } - } - - return -1; - } - - public static bool Add(IFirewallEntry firewallEntry, TimeSpan ttl, bool persist = true) - { - if (firewallEntry == null || IndexOfEntry(firewallEntry) >= 0) - { - return false; - } - - _entries.Add(firewallEntry); - - if (ttl > TimeSpan.Zero) - { - _expiring[firewallEntry] = Core.TickCount + (long)ttl.TotalMilliseconds; - } - - _indexDirty = true; - - if (persist) - { - MarkDirty(); - } - - return true; - } - - public static bool Remove(IFirewallEntry entry) - { - if (entry == null) - { - return false; - } - - var index = IndexOfEntry(entry); - if (index < 0) - { - return false; - } - - // Remove the stored instance from _expiring (not the passed reference), so a value-equal - // entry created elsewhere still clears the TTL bookkeeping. - var stored = _entries[index]; - _entries.RemoveAt(index); - _expiring.Remove(stored); - _indexDirty = true; - MarkDirty(); - return true; - } - - /// - /// Removes every entry whose TTL has elapsed. Called from the main-thread maintenance timer. - /// - internal static void ExpireEntries(long nowTicks) - { - if (_expiring.Count == 0) - { - return; - } - - List expired = null; - foreach (var (entry, expiresAt) in _expiring) - { - if (expiresAt - nowTicks <= 0) - { - (expired ??= []).Add(entry); - } - } - - if (expired == null) - { - return; - } - - for (var i = 0; i < expired.Count; i++) - { - var entry = expired[i]; - _entries.Remove(entry); - _expiring.Remove(entry); - } - - _indexDirty = true; - MarkDirty(); - } - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static IFirewallEntry ToFirewallEntry(object entry) => - entry switch - { - IFirewallEntry firewallEntry => firewallEntry, - IPAddress address => new SingleIpFirewallEntry(address), - string s => ToFirewallEntry(s), - _ => null - }; - - public static IFirewallEntry ToFirewallEntry(string entry) - { - if (entry == null) - { - return null; - } - - try - { - var rangeSeparator = entry.IndexOf('-'); - if (rangeSeparator > -1) - { - return new CidrFirewallEntry( - IPAddress.Parse(entry.AsSpan(0, rangeSeparator)), - IPAddress.Parse(entry.AsSpan(rangeSeparator + 1)) - ); - } - - if (entry.IndexOf('/') > -1) - { - return new CidrFirewallEntry(entry); - } - - return new SingleIpFirewallEntry(entry); - } - catch - { - return null; - } - } - - public static void Configure() - { - if (_configured) - { - return; - } - _configured = true; - - var path = Path.Join(Core.BaseDirectory, _path); - - if (File.Exists(path)) - { - LoadFrom(JsonConfig.Deserialize(path)); - } - else - { - var legacyPath = ResolveLegacyCfgPath(); - if (legacyPath != null) - { - MigrateLegacyCfg(legacyPath); - Save(); // materialize firewall.json; the .cfg is no longer read after this - TryMarkLegacyCfgMigrated(legacyPath); - } - } - - // Main-thread maintenance: expire TTLs and flush pending writes. No background thread. - Timer.DelayCall(TimeSpan.FromSeconds(30), TimeSpan.FromSeconds(30), Maintenance); - - // Expose the set to the accept path. Everything else (gump, commands, persistence) keeps using - // the Firewall API directly; only the per-connection question goes through the filter registry. - ConnectionFilters.Register(FirewallConnectionFilter.Instance); - } - - private static void Maintenance() - { - ExpireEntries(Core.TickCount); - - if (_dirty) - { - Save(); - } - } - - private static void MarkDirty() => _dirty = true; - - internal static void LoadFrom(FirewallSettings settings) - { - if (settings?.Entries == null) - { - return; - } - - // Core.Now: this runs on the game loop, via the Configure sweep. - var now = Core.Now; - var records = settings.Entries; - for (var i = 0; i < records.Length; i++) - { - var record = records[i]; - var entry = ToFirewallEntry(record.Value); - if (entry == null) - { - logger.Warning("Ignoring unparseable firewall entry \"{Entry}\"", record.Value); - continue; - } - - var ttl = TimeSpan.Zero; - if (record.Expires is { } expires) - { - ttl = expires - now; - if (ttl <= TimeSpan.Zero) - { - continue; // already expired - } - } - - Add(entry, ttl, persist: false); - } - } - - internal static FirewallSettings ToSettings() - { - // expires is derived below as now + (expiresAtTick - nowTicks), so both operands must come from - // the same instant. Core.Now and Core.TickCount are refreshed together each loop iteration; a - // fresh DateTime.UtcNow here would bake the loop's lag into every persisted expiry. - var now = Core.Now; - var nowTicks = Core.TickCount; - var list = new List(_entries.Count); - - for (var i = 0; i < _entries.Count; i++) - { - var entry = _entries[i]; - DateTime? expires = null; - if (_expiring.TryGetValue(entry, out var expiresAtTick)) - { - expires = now.AddMilliseconds(expiresAtTick - nowTicks); - } - - list.Add(new FirewallEntryRecord { Value = entry.ToString(), Expires = expires }); - } - - return new FirewallSettings { Entries = list.ToArray() }; - } - - public static void Save() - { - _dirty = false; - var path = Path.Join(Core.BaseDirectory, _path); - var tmp = $"{path}.tmp"; - JsonConfig.Serialize(tmp, ToSettings()); - File.Move(tmp, path, overwrite: true); // atomic swap - } - - /// - /// Locates the legacy firewall.cfg to migrate. The modern convention is , - /// checked first; the pre-collapse AdminFirewall used a bare relative path (resolved against the - /// process's current working directory), which may differ from when the - /// shard is launched from elsewhere, so that's checked as a fallback. Returns null if neither exists. - /// - private static string ResolveLegacyCfgPath() - { - var underBaseDirectory = Path.Join(Core.BaseDirectory, _legacyPath); - if (File.Exists(underBaseDirectory)) - { - return underBaseDirectory; - } - - return File.Exists(_legacyPath) ? _legacyPath : null; - } - - private static void MigrateLegacyCfg(string legacyPath) - { - var searchValues = SearchValues.Create("*Xx?"); - - using var reader = new StreamReader(legacyPath); - while (reader.ReadLine() is { } line) - { - line = line.Trim(); - if (line.Length == 0) - { - continue; - } - - if (line.AsSpan().ContainsAny(searchValues)) - { - logger.Warning("Legacy firewall entry \"{Entry}\" ignored during migration", line); - continue; - } - - var entry = ToFirewallEntry(line); - if (entry != null) - { - Add(entry, TimeSpan.Zero, persist: false); - } - } - - logger.Information("Migrated {Count} entr(ies) from legacy firewall.cfg to firewall.json", _entries.Count); - } - - /// - /// Renames the migrated .cfg to firewall.cfg.migrated so it isn't re-scanned on the next - /// boot and operators can see it was already migrated. Best-effort: a locked/read-only file must not - /// fail startup, since the migration itself (firewall.json) already succeeded. - /// - private static void TryMarkLegacyCfgMigrated(string legacyPath) - { - try - { - File.Move(legacyPath, $"{legacyPath}.migrated", overwrite: true); - } - catch (Exception e) - { - logger.Warning(e, "Could not rename migrated legacy firewall file \"{Path}\"", legacyPath); - } - } - - internal static void ResetForTesting() - { - _entries.Clear(); - _expiring.Clear(); - _index = SortedRangeIndex.Empty; - _indexDirty = false; - _configured = false; - } -} diff --git a/Projects/UOContent/Network/Firewall/FirewallConnectionFilter.cs b/Projects/UOContent/Network/Firewall/FirewallConnectionFilter.cs deleted file mode 100644 index 570e33f19..000000000 --- a/Projects/UOContent/Network/Firewall/FirewallConnectionFilter.cs +++ /dev/null @@ -1,52 +0,0 @@ -/************************************************************************* - * ModernUO * - * Copyright 2019-2026 - ModernUO Development Team * - * Email: hi@modernuo.com * - * File: FirewallConnectionFilter.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.Net; -using System.Threading; - -namespace Server.Network; - -/// -/// Exposes the admin-curated set to the accept path as an -/// . The firewall keeps its own API (the admin gump and commands mutate -/// it directly); this is only the accept-path adapter, since a static class cannot implement an -/// interface. It is the cheapest gate to consult — an empty set costs one length compare — so -/// registers it first. -/// -internal sealed class FirewallConnectionFilter : IConnectionFilter -{ - public static readonly FirewallConnectionFilter Instance = new(); - - private FirewallConnectionFilter() - { - } - - public string Name => "firewall"; - - // Firewall.Configure() owns loading and registration. - public void Register() - { - } - - // Nothing to hydrate: the set loads at Configure and is maintained by a main-loop timer. - public void Start(CancellationToken token) - { - } - - /// Flushes pending writes on the way down so a TTL expiry or late admin edit is not lost. - public void Stop() => Firewall.Save(); - - public bool ShouldDeny(IPAddress address) => Firewall.IsBlocked(address); -} diff --git a/Projects/UOContent/Network/Firewall/FirewallSettings.cs b/Projects/UOContent/Network/Firewall/FirewallSettings.cs deleted file mode 100644 index dd0281c92..000000000 --- a/Projects/UOContent/Network/Firewall/FirewallSettings.cs +++ /dev/null @@ -1,42 +0,0 @@ -/************************************************************************* - * ModernUO * - * Copyright 2019-2026 - ModernUO Development Team * - * Email: hi@modernuo.com * - * File: FirewallSettings.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.Serialization; - -namespace Server.Network; - -/// -/// Persisted local firewall entries (manual admin bans). Stored at Configuration/firewall.json. -/// Auto-detected rate-limit trips are never persisted — they are contributed to CrowdSec, not stored here. -/// -public record FirewallSettings -{ - [JsonPropertyName("entries")] - public FirewallEntryRecord[] Entries { get; set; } = []; -} - -/// -/// One persisted entry. is a single IP, a min-max range, or CIDR. -/// is UTC wall-clock; null means permanent. -/// -public record FirewallEntryRecord -{ - [JsonPropertyName("value")] - public string Value { get; set; } - - [JsonPropertyName("expires")] - public DateTime? Expires { get; set; } -} 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 deleted file mode 100644 index 23075f7ff..000000000 --- a/Projects/UOContent/Network/LoginAllowlist/LoginAllowlist.cs +++ /dev/null @@ -1,440 +0,0 @@ -/************************************************************************* - * ModernUO * - * Copyright 2019-2026 - ModernUO Development Team * - * Email: hi@modernuo.com * - * File: LoginAllowlist.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.Generic; -using System.Globalization; -using System.IO; -using System.Net; -using System.Text; -using System.Threading; -using System.Threading.Tasks; -using Server.Logging; -using Server.Network.Bans; - -namespace Server.Network; - -/// -/// An allowlist addresses earn by logging in successfully, so a reputation feed cannot get a known player -/// 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. -/// -public static class LoginAllowlist -{ - private static readonly ILogger logger = LogFactory.GetLogger(typeof(LoginAllowlist)); - - // Address (normalized v6 bits) -> unix seconds of its last successful login. Loop-only. - private static readonly Dictionary _allowed = []; - - // Suppressed contributions in the current window. Only holds allowlisted addresses, so it is bounded by - // _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; - private static int _escalateAfterStrikes; - 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 - { - public int Count; - public long WindowStart; // unix seconds; 0 means "no window open" - } - - public static void Configure() - { - LoginAllowlistConfiguration.Load(); - var s = LoginAllowlistConfiguration.Settings; - - _enabled = s.Enabled && !string.IsNullOrWhiteSpace(s.File) && s.Ttl > TimeSpan.Zero; - if (!_enabled) - { - return; - } - - _path = Path.IsPathRooted(s.File) ? s.File : Path.Join(Core.BaseDirectory, s.File); - _ttlSeconds = (long)s.Ttl.TotalSeconds; - _escalateAfterStrikes = s.EscalateAfterStrikes; - _strikeWindowSeconds = (long)s.StrikeWindow.TotalSeconds; - } - - public static void Initialize() - { - if (!_enabled) - { - logger.Information("Login allowlist disabled"); - return; - } - - Load(); - - var interval = LoginAllowlistConfiguration.Settings.FlushInterval; - if (interval <= TimeSpan.Zero) - { - interval = TimeSpan.FromHours(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; - } - - /// - /// Records a successful authentication. Private addresses are skipped: a LAN or loopback login says - /// nothing about the public internet. - /// - public static void RecordLogin(IPAddress address) => RecordLogin(address, ToUnixSeconds(Core.Now)); - - /// The pure write, split out so policy can be tested without a clock. - internal static void RecordLogin(IPAddress address, long nowUnix) - { - if (!_enabled || address == null || address.IsPrivateNetwork()) - { - return; - } - - var key = address.ToUInt128(); - _allowed[key] = nowUnix; - - // A fresh login clears the tally: someone just proved they hold an account. - _strikes.Remove(key); - _dirty = true; - } - - /// - /// True when this address logged in within the TTL. Expiry is decided on read, so a stale entry left for - /// the next flush can never allow anything. - /// - public static bool IsAllowed(IPAddress address) => IsAllowed(address, ToUnixSeconds(Core.Now)); - - /// The pure decision, split out so the TTL policy can be tested without a clock. - internal static bool IsAllowed(IPAddress address, long nowUnix) - { - if (!_enabled || address == null) - { - return false; - } - - return _allowed.TryGetValue(address.ToUInt128(), out var stamp) && nowUnix - stamp <= _ttlSeconds; - } - - public static bool IsExemptFromEscalation(IPAddress address, string reason) => - IsExemptFromEscalation(address, reason, ToUnixSeconds(Core.Now)); - - /// - /// Whether this contribution should be dropped instead of escalated, counting a strike if so. Not a pure - /// read — calling it is what spends the address's allowance. - /// - internal static bool IsExemptFromEscalation(IPAddress address, string reason, long nowUnix) - { - // An operator's explicit ban, or a reason nobody opted in, escalates untouched. - if (!BanReasons.IsBehavioral(reason) || !IsAllowed(address, nowUnix)) - { - return false; - } - - if (_escalateAfterStrikes <= 0) - { - return true; // revocation disabled: an entry is unconditional - } - - var key = address.ToUInt128(); - _strikes.TryGetValue(key, out var strike); - - if (strike.WindowStart == 0 || nowUnix - strike.WindowStart > _strikeWindowSeconds) - { - strike = new Strike { WindowStart = nowUnix }; - } - - strike.Count++; - - if (strike.Count < _escalateAfterStrikes) - { - _strikes[key] = strike; - return true; - } - - // Allowance spent: drop the entry so this and all after it escalate. Earned back by logging in. - _allowed.Remove(key); - _strikes.Remove(key); - _dirty = true; - - logger.Information( - "{Address} revoked from the login allowlist after {Count} suppressed contribution(s); last was '{Reason}'", - address, - strike.Count, - reason - ); - - return false; - } - - internal static void LoadForTesting(bool enabled, long ttlSeconds, int escalateAfterStrikes = 0, long strikeWindowSeconds = 3600) - { - _allowed.Clear(); - _strikes.Clear(); - _writing = false; - _dirty = false; - _enabled = enabled; - _ttlSeconds = ttlSeconds; - _escalateAfterStrikes = escalateAfterStrikes; - _strikeWindowSeconds = strikeWindowSeconds; - _path = null; - } - - private static long ToUnixSeconds(DateTime utc) => (long)(utc - DateTime.UnixEpoch).TotalSeconds; - - private static void Flush() - { - // 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) - { - 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]; - } - - var count = 0; - dropped = 0; - - foreach (var (address, stamp) in _allowed) - { - if (stamp < cutoff) - { - _allowed.Remove(address); - _strikes.Remove(address); - dropped++; - continue; - } - - _addressBuffer[count] = address; - _stampBuffer[count] = stamp; - count++; - } - - PruneStaleStrikes(nowUnix); - return count; - } - - /// Drops tallies whose window has closed. - private static void PruneStaleStrikes(long nowUnix) - { - if (_strikes.Count == 0) - { - return; - } - - foreach (var (address, strike) in _strikes) - { - if (nowUnix - strike.WindowStart > _strikeWindowSeconds) - { - _strikes.Remove(address); - } - } - } - - /// 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) - { - try - { - var dir = Path.GetDirectoryName(path); - if (!string.IsNullOrEmpty(dir)) - { - Directory.CreateDirectory(dir); - } - - // Sibling + swap, so a reader never sees a half-written list. - var tmp = path + ".tmp"; - - using (var writer = new StreamWriter(tmp, false, new UTF8Encoding(false), 1 << 16)) - { - writer.Write("# modernuo-login-allowlist generated="); - writer.Write(DateTime.UtcNow.ToString("yyyy-MM-ddTHH:mm:ssZ", CultureInfo.InvariantCulture)); - writer.Write(" count="); - writer.Write(count); - writer.Write('\n'); - - for (var i = 0; i < count; i++) - { - writer.Write(addresses[i].ToIpAddress().ToString()); - writer.Write(' '); - writer.Write(stamps[i]); - writer.Write('\n'); - } - } - - File.Move(tmp, path, true); - - if (dropped > 0) - { - 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; - } - } - - private static void Load() - { - if (!File.Exists(_path)) - { - logger.Information("Login allowlist empty: no file at \"{Path}\"", _path); - return; - } - - var cutoff = ToUnixSeconds(Core.Now) - _ttlSeconds; - var loaded = 0; - var skipped = 0; - - try - { - foreach (var line in File.ReadLines(_path)) - { - var span = line.AsSpan().Trim(); - if (span.Length == 0 || span[0] == '#' || span[0] == ';') - { - continue; - } - - var sep = span.IndexOf(' '); - if (sep <= 0 || - !IPAddress.TryParse(span[..sep], out var address) || - !long.TryParse(span[(sep + 1)..].Trim(), NumberStyles.Integer, CultureInfo.InvariantCulture, out var stamp)) - { - skipped++; - continue; - } - - // Expired on disk: do not carry a stranger into memory. - if (stamp < cutoff) - { - skipped++; - _dirty = true; // the file is now out of date; the next flush rewrites it - continue; - } - - _allowed[address.ToUInt128()] = stamp; - loaded++; - } - } - catch (Exception e) - { - // Fail open: an unreadable list allows nobody, which beats refusing to boot. - logger.Warning(e, "Could not read the login allowlist at \"{Path}\"; continuing with {Count}", _path, _allowed.Count); - return; - } - - logger.Information("Login allowlist loaded {Loaded} entr(ies) ({Skipped} expired or malformed)", loaded, skipped); - } -} diff --git a/Projects/UOContent/Network/LoginAllowlist/LoginAllowlistConfiguration.cs b/Projects/UOContent/Network/LoginAllowlist/LoginAllowlistConfiguration.cs deleted file mode 100644 index 5c673eb10..000000000 --- a/Projects/UOContent/Network/LoginAllowlist/LoginAllowlistConfiguration.cs +++ /dev/null @@ -1,106 +0,0 @@ -/************************************************************************* - * ModernUO * - * Copyright 2019-2026 - ModernUO Development Team * - * Email: hi@modernuo.com * - * File: LoginAllowlistConfiguration.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; - -/// -/// Loads the from Configuration/login-allowlist.json. Loaded -/// once; a missing file writes a template so operators have something to edit. -/// -public static class LoginAllowlistConfiguration -{ - private const string _path = "Configuration/login-allowlist.json"; - - public static LoginAllowlistSettings 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 LoginAllowlistSettings(); - Save(); - } - } - - private static void Save() - { - JsonConfig.Serialize(Path.Join(Core.BaseDirectory, _path), Settings); - } -} - -/// -/// Bound configuration for : which addresses have recently proven they carry a -/// real player, how long that proof counts, and how much misbehaviour revokes it. -/// -/// -/// The recency window is the point. Consumer addresses are reassigned constantly, and on CGNAT the same -/// address fronts a different subscriber week to week, so a list without a TTL becomes a list of strangers. -/// -public record LoginAllowlistSettings -{ - /// Whether successful logins are recorded and consulted at all. Disabled makes the list inert. - [JsonPropertyName("enabled")] - public bool Enabled { get; set; } = true; - - /// - /// Where the list is persisted. A relative path resolves against . - /// Plain text, one address unix-seconds pair per line, so it can be read and edited by hand. - /// Set to "" to disable. - /// - [JsonPropertyName("file")] - public string File { get; set; } = "Configuration/login-allowlist.txt"; - - /// - /// How long a successful login allowlists its address; dropped on the next flush after that. 90 days - /// covers a player who takes a season off without carrying a reassigned address indefinitely. - /// - [JsonPropertyName("ttl")] - 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. - /// - [JsonPropertyName("flushInterval")] - public TimeSpan FlushInterval { get; set; } = TimeSpan.FromHours(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 - /// 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. - /// - [JsonPropertyName("escalateAfterStrikes")] - public int EscalateAfterStrikes { get; set; } = 10; - - /// Rolling window the strike count is measured over. A quiet hour clears the tally. - [JsonPropertyName("strikeWindow")] - public TimeSpan StrikeWindow { get; set; } = TimeSpan.FromHours(1); -} diff --git a/Projects/UOContent/Network/ManualAllowlist/ManualAllowlist.cs b/Projects/UOContent/Network/ManualAllowlist/ManualAllowlist.cs deleted file mode 100644 index 20e351457..000000000 --- a/Projects/UOContent/Network/ManualAllowlist/ManualAllowlist.cs +++ /dev/null @@ -1,340 +0,0 @@ -/************************************************************************* - * ModernUO * - * Copyright 2019-2026 - ModernUO Development Team * - * Email: hi@modernuo.com * - * File: ManualAllowlist.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.Generic; -using System.IO; -using System.Net; -using System.Threading; -using System.Threading.Tasks; -using Server.Logging; - -namespace Server.Network.Bans; - -/// -/// The operator's own "leave this address alone" list, read from the same files -/// tools/Export-IpBlocklist.ps1 subtracts at generation time. -/// -/// -/// The generator already subtracts these from the blocklist, but that only covers being BLOCKED. -/// 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 . -/// -public static class ManualAllowlist -{ - private static readonly ILogger logger = LogFactory.GetLogger(typeof(ManualAllowlist)); - - // 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. - private static volatile BlocklistSnapshot _snapshot = BlocklistSnapshot.Empty; - - private static string[] _patterns = []; - private static TimeSpan _interval = TimeSpan.FromSeconds(60); - private static long _lastStamp; - private static CancellationTokenSource _cts; - - public static int Count => _snapshot.Count; - - /// 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; - if (settings == null) - { - return; - } - - _patterns = ResolvePaths(settings.Files); - _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)"); - return; - } - - Enabled = true; - Reload(); - - _cts = CancellationTokenSource.CreateLinkedTokenSource(Core.ClosingTokenSource.Token); - _ = Task.Run(() => PollLoop(_cts.Token), _cts.Token); - } - - public static void Stop() - { - _cts?.Cancel(); - _cts?.Dispose(); - _cts = null; - } - - private static string[] ResolvePaths(string[] configured) - { - if (configured == null) - { - return []; - } - - var resolved = new string[configured.Length]; - var count = 0; - - for (var i = 0; i < configured.Length; i++) - { - var path = configured[i]; - if (string.IsNullOrWhiteSpace(path)) - { - continue; - } - - // Relative resolves against BaseDirectory, never the working directory, which differs when the - // shard is launched from elsewhere. Absolute is as-is, so shards can share a list. - resolved[count++] = Path.IsPathRooted(path) ? path : Path.Join(Core.BaseDirectory, path); - } - - Array.Resize(ref resolved, count); - return resolved; - } - - /// - /// Expands the configured patterns to actual files. Done per poll rather than once, so a carve-out an - /// admin adds is picked up without a restart. - /// - private static string[] ExpandPaths() - { - var files = new List(); - - for (var i = 0; i < _patterns.Length; i++) - { - var pattern = _patterns[i]; - var name = Path.GetFileName(pattern); - - if (name.IndexOf('*') < 0 && name.IndexOf('?') < 0) - { - if (File.Exists(pattern)) - { - files.Add(pattern); - } - - continue; - } - - try - { - var dir = Path.GetDirectoryName(pattern); - if (string.IsNullOrEmpty(dir) || !Directory.Exists(dir)) - { - continue; - } - - var matches = Directory.GetFiles(dir, name); - Array.Sort(matches, StringComparer.Ordinal); - - for (var j = 0; j < matches.Length; j++) - { - // Windows wildcard matching still honours legacy short names, so ".txt" can pull in the - // generator's ".txt.tmp" mid-swap. Check the real extension. - if (matches[j].EndsWith(".txt", StringComparison.OrdinalIgnoreCase)) - { - files.Add(matches[j]); - } - } - } - catch - { - // Unreadable directory; the next poll retries. - } - } - - return files.ToArray(); - } - - private static async ValueTask PollLoop(CancellationToken token) - { - while (!token.IsCancellationRequested) - { - try - { - await Task.Delay(_interval, token); - } - catch (OperationCanceledException) - { - return; - } - - try - { - if (Stamp() != _lastStamp) - { - // A save owns the disk and nothing here is urgent. - // See the threading policy in CLAUDE.md (rules #3 and #10). - while (World.Saving || World.WorldState == WorldState.PendingSave) - { - await Task.Delay(TimeSpan.FromSeconds(1), token); - } - - Reload(); - } - } - catch (OperationCanceledException) - { - return; - } - catch (Exception e) - { - logger.Warning(e, "Manual allowlist reload check failed; keeping last snapshot ({Count})", Count); - } - } - } - - /// - /// Change fingerprint across every configured file. A missing file contributes nothing, so creating or - /// deleting one also registers as a change. - /// - private static long Stamp() - { - var stamp = 0L; - var paths = ExpandPaths(); - - for (var i = 0; i < paths.Length; i++) - { - try - { - var info = new FileInfo(paths[i]); - if (info.Exists) - { - stamp = stamp * 31 + info.LastWriteTimeUtc.Ticks + info.Length; - } - } - catch - { - // Mid-swap by the generator; the next poll picks it up. - } - } - - return stamp; - } - - private static void Reload() - { - // Fingerprint BEFORE parsing, so it describes the version being read. Capturing after could skip a - // version; a stale fingerprint only costs an extra reload. - var stamp = Stamp(); - var combined = ReadAll(out var files); - - // Reuses the blocklist parser and interval index: an address set is direction-agnostic. - var next = combined.Length == 0 - ? BlocklistSnapshot.Empty - : BlocklistSnapshot.Build(combined, out _, out _); - - _snapshot = next; // single volatile swap; readers see old or new whole - _lastStamp = stamp; - - logger.Information( - "Manual allowlist loaded {Count} range(s) from {Files} file(s)", - next.Count, - files - ); - } - - /// - /// Concatenates every configured file into one buffer. The parser is line-based, so a newline join is - /// enough, and membership stays a single lookup. - /// - private static byte[] ReadAll(out int files) - { - files = 0; - - var paths = ExpandPaths(); - var chunks = new byte[paths.Length][]; - var total = 0; - - for (var i = 0; i < paths.Length; i++) - { - try - { - if (!File.Exists(paths[i])) - { - continue; - } - - var bytes = File.ReadAllBytes(paths[i]); - chunks[i] = bytes; - total += bytes.Length + 1; // + newline separator - files++; - } - catch (Exception e) - { - // Fail open per file: losing one entry beats refusing to load the rest. - logger.Warning(e, "Could not read allowlist \"{Path}\"", paths[i]); - } - } - - if (total == 0) - { - return []; - } - - var combined = new byte[total]; - var offset = 0; - - for (var i = 0; i < chunks.Length; i++) - { - var chunk = chunks[i]; - if (chunk == null) - { - continue; - } - - Buffer.BlockCopy(chunk, 0, combined, offset, chunk.Length); - offset += chunk.Length; - combined[offset++] = (byte)'\n'; - } - - return combined; - } - - internal static void LoadForTesting(BlocklistSnapshot snapshot) => _snapshot = snapshot ?? BlocklistSnapshot.Empty; -} 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..810ff0310 100644 --- a/Projects/UOContent/Utilities/Types.cs +++ b/Projects/UOContent/Utilities/Types.cs @@ -1,5 +1,5 @@ using System; -using System.Collections.Concurrent; +using System.Collections.Generic; using System.Globalization; using System.Reflection; using System.Runtime.CompilerServices; @@ -10,8 +10,7 @@ namespace Server { public static readonly Type[] ParseStringParamTypes = { typeof(string), typeof(IFormatProvider) }; public static readonly Type[] ParseStringNumericParamTypes = { typeof(string), typeof(NumberStyles) }; - // Legacy RunUO signature: a static Parse(string) that predates IParsable (e.g. Faction, Town). - public static readonly Type[] ParseStringSingleParamTypes = { typeof(string) }; + private static object[] _parseParams = { null, null }; public static readonly Type OfByte = typeof(byte); public static readonly Type OfSByte = typeof(sbyte); @@ -76,9 +75,7 @@ namespace Server OfULong }; - // Thread-safe: parse metadata is read from parallel callers (e.g. the Advanced Search workers), - // not just the single-threaded command path. - private static readonly ConcurrentDictionary _isParsable = new(); + private static Dictionary _isParsable; [MethodImpl(MethodImplOptions.AggressiveInlining)] public static bool IsType(Type type, Type check) => check.IsAssignableFrom(type); @@ -92,19 +89,25 @@ namespace Server [MethodImpl(MethodImplOptions.AggressiveInlining)] public static bool IsText(Type t) => IsType(t, OfText); - public static bool IsParsable(Type t) => - _isParsable.GetOrAdd(t, static type => + public static bool IsParsable(Type t) + { + _isParsable ??= new(); + if (_isParsable.TryGetValue(t, out var isParsable)) { - foreach (var x in type.GetInterfaces()) - { - if (x.IsGenericType && x.GetGenericTypeDefinition() == typeof(IParsable<>)) - { - return true; - } - } + return isParsable; + } - return false; - }); + foreach (var x in t.GetInterfaces()) + { + if (x.IsGenericType && x.GetGenericTypeDefinition() == typeof(IParsable<>)) + { + isParsable = true; + break; + } + } + + return _isParsable[t] = isParsable; + } [MethodImpl(MethodImplOptions.AggressiveInlining)] public static bool IsDecimal(Type t) => Array.IndexOf(DecimalTypes, t) >= 0; @@ -115,81 +118,18 @@ namespace Server [MethodImpl(MethodImplOptions.AggressiveInlining)] public static bool IsEntity(Type t) => OfEntity.IsAssignableFrom(t); - private static readonly ConcurrentDictionary _parseMethods = new(); - - // A static string Parse method: the modern IParsable Parse(string, IFormatProvider), or a - // legacy RunUO Parse(string). Cached per type; null if the type has neither. (Span-based Parse - // can't be reflection-invoked — a ReadOnlySpan can't be boxed into the args array — so the - // string overloads are what we bind to.) - public static MethodInfo GetParseMethod(Type t) => - _parseMethods.GetOrAdd( - t, - static type => type.GetMethod("Parse", ParseStringParamTypes) - ?? type.GetMethod("Parse", ParseStringSingleParamTypes) - ); + private static Dictionary _parseMethods; public static object Parse(Type t, string value) { - var method = GetParseMethod(t); - if (method == null) + _parseMethods ??= new(); + if (!_parseMethods.TryGetValue(t, out var method)) { - return null; + _parseMethods[t] = method = t.GetMethod("Parse", ParseStringParamTypes); } - // Fresh args array per call — a shared static array would race across concurrent callers. - // Arg shape depends on which overload we bound to (IParsable 2-arg vs legacy 1-arg). - var args = method.GetParameters().Length == 2 ? new object[] { value, null } : new object[] { value }; - return method.Invoke(null, args); - } - - // Parses directly into the concrete numeric type via INumber.TryParse (the Type-dispatched - // equivalent of a generic TryParse). Returns the boxed value; false if the text doesn't fit - // the type's range/format so the caller can fall through. - private static bool TryParseNumeric(Type type, ReadOnlySpan span, NumberStyles style, out object result) - { - if (type == OfInt && int.TryParse(span, style, null, out var i)) - { - result = i; - return true; - } - if (type == OfUInt && uint.TryParse(span, style, null, out var ui)) - { - result = ui; - return true; - } - if (type == OfLong && long.TryParse(span, style, null, out var l)) - { - result = l; - return true; - } - if (type == OfULong && ulong.TryParse(span, style, null, out var ul)) - { - result = ul; - return true; - } - if (type == OfShort && short.TryParse(span, style, null, out var s)) - { - result = s; - return true; - } - if (type == OfUShort && ushort.TryParse(span, style, null, out var us)) - { - result = us; - return true; - } - if (type == OfByte && byte.TryParse(span, style, null, out var b)) - { - result = b; - return true; - } - if (type == OfSByte && sbyte.TryParse(span, style, null, out var sb)) - { - result = sb; - return true; - } - - result = null; - return false; + _parseParams[0] = value; + return method?.Invoke(null, _parseParams); } // Do not use this in "Parse" methods, it may cause a stack overflow @@ -261,38 +201,35 @@ namespace Server if (IsNumeric(type)) { - var span = value.AsSpan(); - var style = NumberStyles.Integer; - if (span.StartsWithOrdinal("0x")) + try { - span = span[2..]; - style = NumberStyles.HexNumber; - } - - if (isEntity || isSerial) - { - // Serial/entity properties were mutated to int above; a Serial is a uint, so parse - // the full 32-bit range as ulong and resolve. - if (ulong.TryParse(span, style, null, out var num)) + var isHex = value.StartsWithOrdinal("0x"); + var index = isHex ? 2 : 0; + if (ulong.TryParse(value.AsSpan(index), isHex ? NumberStyles.HexNumber : NumberStyles.Integer, null, out var num)) { - constructed = isEntity ? World.FindEntity((Serial)num) : (Serial)num; + if (isEntity) + { + constructed = World.FindEntity((Serial)num); + } + else if (isSerial) + { + constructed = (Serial)num; + } + else + { + constructed = Convert.ChangeType(num, type); + } + return null; } } - else if (TryParseNumeric(type, span, style, out constructed)) + catch { - // Parse the string directly into the target type via INumber.TryParse — no - // Convert.ChangeType, and (unlike parse-as-ulong) signed and per-type ranges are honored. - return null; + return "That is not properly formatted."; } - - // On parse failure, fall through to the Parse-method / Convert.ChangeType fallbacks below. } - // IParsable (Parse(string, IFormatProvider)) or a legacy RunUO Parse(string). Gating on - // the discovered method rather than the IParsable interface keeps pre-IParsable types - // (Faction, Town, ...) parseable for backwards compatibility. - if (GetParseMethod(type) != null) + if (IsParsable(type)) { try { 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..39a8d4983 100644 --- a/dev-docs/claude-skills/modernuo-code-audit.md +++ b/dev-docs/claude-skills/modernuo-code-audit.md @@ -191,36 +191,8 @@ mob.SendMessage($"You earned a {rank:L} trophy!"); // "gold" not "Gold" **See**: `dev-docs/string-handling.md` § "Interpolation Anti-Patterns" for the full reference with detailed before/after examples. -### 19. No InvalidateProperties From Inside GetProperties -**Check**: Any property read by a `GetProperties` override — including through helpers — must be a pure read. Flag getters that call `InvalidateProperties()` (or a wrapper like `Invalidate()`) as a side effect. -**Bad**: a `Rank` getter that lazily recomputes and then calls `Invalidate()`; reading it from `GetProperties` re-enters the build. -**Good**: invalidate in the setter that actually changes the value, or defer with `Timer.DelayCall(InvalidateProperties)`. -**Why**: `InvalidateProperties()` rebuilds the list in place (`Reset()` + rebuild). `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. Re-entering mid-build throws `ArgumentNullException` (parameter `"array"`) out of `GetProperties` from a line unrelated to the offending getter, or silently corrupts the tooltip. The engine refuses and logs an error, and `DEBUG` throws, so this shows up as a crash in development. -**Note**: Lazy recomputation inside a getter is fine. It is the notification that must not happen there. - -**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 (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-property-lists.md b/dev-docs/claude-skills/modernuo-property-lists.md index 51e47ff35..4440c63db 100644 --- a/dev-docs/claude-skills/modernuo-property-lists.md +++ b/dev-docs/claude-skills/modernuo-property-lists.md @@ -20,12 +20,6 @@ description: > 3. **String interpolation** works with `IPropertyList` -- use `$"..."` syntax 4. **`[InvalidateProperties]`** on `[SerializableField]` auto-refreshes tooltip on change 5. **Call `InvalidateProperties()`** manually when non-serialized state changes tooltip -6. **Never invalidate from inside `GetProperties`** -- every property a `GetProperties` override - reads must be a pure read. `InvalidateProperties()` rebuilds in place (`Reset()` + rebuild), so a - getter with that side effect tears down the list mid-build: it returns the pooled interpolation - buffer under an in-flight `$"..."` handler (`ArgumentNullException`, parameter `"array"`) and - rewinds the packet cursor. The engine refuses and logs an error; `DEBUG` throws. Defer instead: - `Timer.DelayCall(InvalidateProperties)` ## IPropertyList Interface @@ -241,7 +235,6 @@ block.Add("Cannot be repaired".AsSpan()); // plain span, no string alloc - **Excessive rebuilds**: Don't call `InvalidateProperties()` in tight loops - **Assuming tooltip support**: Check `ObjectPropertyList.Enabled` if needed - **One giant `Add()` for multi-line text**: A property over ~512 chars crashes the legacy 2D client. Use `AddChunked`/`OplTextBlock` for variable-length free text -- **Side-effecting property getters**: A getter reached from `GetProperties` that calls `InvalidateProperties()` (directly or via a helper like `Invalidate()`) re-enters the build and is refused — error logged, `DEBUG` throws. Lazy recomputation in a getter is fine; the *notification* is not. Invalidate where the value changes, or `Timer.DelayCall(InvalidateProperties)` ## Real Examples - Item properties: `Projects/Server/Items/Item.cs` (AddNameProperties, GetProperties) 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 deleted file mode 100644 index 40320efe5..000000000 --- a/dev-docs/ip-bans-and-allowlists.md +++ /dev/null @@ -1,236 +0,0 @@ -# IP Bans, Blocklists and Allowlists - -How a shard decides to refuse a connection, how it contributes bans to an external bouncer, and how an -operator exempts someone who was caught by mistake. - -If you are here because **a player cannot connect**, skip to [Unblocking a player](#unblocking-a-player). - -## The shape of it - -Two independent questions, deliberately separated: - -| Question | Answered by | Effect | -|---|---|---| -| Refuse this connection? | `IConnectionFilter` implementations, at accept | The socket is dropped | -| Tell the outside world about it? | `BanChannel` → `IBanReporter` implementations | CrowdSec, and from there an OS bouncer | - -`BanChannel` **never enforces** and filters **never report on each other's behalf**. Enforcement that -outlives the process belongs to the OS bouncer; the shard only contributes. - -### Refusing a connection - -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 | -| `auto-denylist` | In-memory, 15 min | What this shard just caught misbehaving | - -### Contributing a ban - -`BanChannel.Report(address, ttl, reason)` → `BanExemptions.IsExempt` → if not exempt, fan out to every -reporter (`crowdsec`, `auto-denylist`). - -### Allowlists - -Two, with different authority: - -| List | Source | Revocable? | Covers | -|---|---|---|---| -| `ManualAllowlist` | every `ip-allowlist*.txt` (opt-in) | 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 -attacker is trying to flood — pays nothing for them. The accept gate itself is deliberately allowlist-free: -a whitelist there could only turn a deny into an allow at the cost of a lookup on every accept, the -attacker's included. - -## Unblocking a player - -### 1. Find out what is actually blocking them - -```bash -# In the reputation blocklist? -grep -x "203.0.113.42" Distribution/Configuration/ip-blocklist.txt - -# A live external decision? (this is what survives a restart) -cscli decisions list --ip 203.0.113.42 - -# Admin-curated? -grep 203.0.113.42 Distribution/Configuration/firewall.json -``` - -If none of those match, they may be inside a **CIDR** in the blocklist, or held by the in-memory -`auto-denylist` — that one is not queryable and expires on its own within 15 minutes. - -### 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. - -``` -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. - -### 3. Clear any ban that already exists - -A config change cannot retract a ban that has already left the building: - -```bash -cscli decisions delete -i 203.0.113.42 -``` - -### 4. If it keeps coming back - -The shard is no longer contributing them, so a recurring ban is coming from CrowdSec's own sources (the -community blocklist, another watcher). Allowlist it there too: - -```bash -cscli allowlists create shard-staff -d "Known-good player addresses" -cscli allowlists add shard-staff 203.0.113.42 -``` - -### What will NOT work - -- **Deleting the CrowdSec decision alone.** If the address is still in `ip-blocklist.txt` and not - allowlisted, the next connection re-reports it within `promoteSuppression` (60s). -- **Editing `ip-blocklist.txt` by hand.** The next generator run rewrites the whole file. -- **`cscli allowlists` alone.** That is the enforcement layer. The shard's own accept gate sits upstream of - it and will still refuse the connection. - -## Why entries appear that should not - -Reputation feeds list shared consumer address space constantly. On CGNAT one public address fronts many -subscribers **at the same time**, so a single abusive customer gets the address listed and everyone else -behind it is blocked with them — and where leases rotate, a listing says little about whoever holds the -address now. This is near-universal on mobile carriers, satellite (Starlink) and WISPs, and common on -fixed-line broadband outside North America. - -A **carve-out** exempts a whole network. **None ship with ModernUO** — which providers to exempt depends on -where your players actually are, and a carve-out names a real network, so you build the ones you need: - -```powershell -# Exempt a CGNAT provider whose players keep getting listed -.\Export-IpBlocklist.ps1 -AddCarveout starlink -Asn 14593 - -# Later: bring every carve-out up to date with what those networks currently announce -.\Export-IpBlocklist.ps1 -RefreshCarveouts -``` - -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. - -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. - -**Do you need one?** If players report being blocked and they are on satellite, mobile, or an ISP short on -IPv4, probably yes. Find the ASN by looking up an address the network hands out on any public BGP lookup. - -Prefixes come from **announcements, not ownership records**. Registry data disagrees with what is actually -routed and silently caps result sets: ARIN whois returns at most 256 rows and gives per-customer /24s, and -`206.83.96.0/19` reads as APNIC in RDAP even though `206.83.96/21` is announced by Starlink. - -## Behavioural detection - -Verdicts the shard reaches by watching a connection, rather than by consulting a list: - -| Reason | Trigger | -|---|---| -| `rate-limit` | Too many connection attempts in the limiter's window | -| `silent-connect` | Reaped after `ConnectingSocketIdleLimit` (5s) having sent **zero bytes** | -| `invalid-seed` | Opened with a zero seed, which no real client sends | -| `foreign-protocol` | Positively identified as HTTP, TLS or SSH | - -`BanReasons.IsBehavioral` gates two things: only these may be exempted, and only these enter the -`auto-denylist`. It is an **opt-in list**, not "everything except `manual`" — a reason added later escalates -normally rather than silently inheriting an exemption. `manual` is never exempt and never auto-denied. - -Escalation is **immediate**, on the first detection: a 15-minute local hold plus a `badConnectDuration` -(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 -middlebox can split the opening bytes anywhere regardless of what the client sent. A rule of the form -"these bytes must arrive together" is broken by construction and drops real players on poor links. This has -been tried and reverted before. - -**Do not treat unreadable payloads as hostile.** A legitimate client with encryption enabled when the shard -expects none sends a structurally perfect connection whose payload is noise — `LoginEncryption.ClientDecrypt` -is a byte-for-byte stream XOR, so it preserves length exactly while destroying content. This is why -detection asks "is this positively some *other* protocol?" rather than "is this a good UO client?": however -misconfigured a UO client is, it never sends `GET / HTTP/1.1`. - -**Timeouts are keyed on bytes-received, not elapsed time.** A connection that sent *something* and ran out -of time is far more likely a slow link than an attack. Banning those produces the worst failure mode -available: the player retries, trips the rate limiter, and compounds a bad connection into hours of being -firewalled off. Shortening the 5s handshake window has been tried and broke real players. - -## Known limits - -- **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`. -- **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` | -| `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) | -| `crowdsec.json` | `lapiUrl`, `machineId`, `password`, `origin`, `manualBanDuration`, `flushInterval`, `maxQueue` | -| `firewall.json` | Admin-curated entries | - -A shard fronted by an upstream proxy can disable all of it and register nothing. - -## Key files - -| File | Role | -|---|---| -| `Projects/Server/Network/IConnectionFilter.cs` | Accept-path gate contract | -| `Projects/Server/Network/ConnectionFilters.cs` | Filter registry + lifecycle | -| `Projects/Server/Network/ForeignProtocol.cs` | Positive identification of non-UO traffic | -| `Projects/Server/Network/Bans/BanChannel.cs` | Contribution fan-out + `IsExempt` seam | -| `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/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 | -| `Projects/UOContent/Network/Firewall/Firewall.cs` | Admin-curated firewall set | -| `tools/Export-IpBlocklist.ps1` | Blocklist generator + allowlist subtraction | diff --git a/dev-docs/networking-packets.md b/dev-docs/networking-packets.md index 639963cfd..43d4aec15 100644 --- a/dev-docs/networking-packets.md +++ b/dev-docs/networking-packets.md @@ -473,70 +473,6 @@ ns.SendMovementRej(int sequence, Mobile m); 6. **Big-endian by default** -- only use `WriteLE`/`ReadLE` when the protocol requires it 7. **Function pointers** (`&Handler`) for incoming packet registration (no delegate allocation) -## Connection Filtering (Accept Path) - -Every inbound socket is checked before it becomes a `NetState`. The check runs on the game loop once -per accepted connection -- this is the path that has to survive a DDoS -- so it must be allocation-free -and non-blocking. - -Gates plug in through `IConnectionFilter`, registered with `ConnectionFilters.Register()` during the -Configure sweep: - -```csharp -public sealed class MyFilter : IConnectionFilter -{ - public string Name => "my-filter"; - public void Configure() { /* read config, no I/O */ } - public void Start(CancellationToken token) { /* background hydration */ } - public void Stop() { } - public bool ShouldDeny(IPAddress address) => /* allocation-free membership test */; -} - -// In a static Configure() so the sweep finds it: -ConnectionFilters.Register(new MyFilter()); -``` - -Rules: - -- `ShouldDeny` must be **allocation-free**, O(log n) at worst, no I/O, no blocking. Anything expensive - (parsing, reloading, contributing to an external service) belongs off the loop or behind a bounded, - non-blocking enqueue. -- Side effects a hit implies (reporting to `BanChannel`, promoting to an OS firewall, suppressing - duplicate reports) are the **filter's** business, not the accept path's. -- Filters are consulted in registration order and the first denial short-circuits, so register the - cheapest and most selective first. Order affects only how quickly a denial is reached, never whether - one happens. -- A filter that throws is **unregistered** and the connection fails open. A filter that faults once - faults for every connection, so leaving it registered would mean an exception per accept. - -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. - -The allowlists, ban contribution, behavioural detection and the operator process for exempting a -false-positive address are covered separately in -[`dev-docs/ip-bans-and-allowlists.md`](ip-bans-and-allowlists.md). - -Do **not** route this kind of check through `EventSink.InvokeSocketConnect` -- that fires later and -allocates a `SocketConnectEventArgs` per connection, which is exactly what the accept path avoids for -rejected traffic. - -### IP Address Normalization (`IPAddressUtility`) - -Addresses are normalized to `UInt128` in **IPv6 form** so a single comparison/index works for both -families. An IPv4 address becomes its v4-mapped-v6 value (`::ffff:a.b.c.d`), which is why round-tripping -matters: `IPv4 -> UInt128 -> IPAddress` can come back as `InterNetworkV6` with `IsIPv4MappedToIPv6` -set, even though it is "really" a v4 address. Code that switches on `AddressFamily` alone will mis-handle -those, so the helpers check both. - -> **Known wart / follow-up:** `ToUInt128` guards with `AddressFamily == InterNetwork && !IsIPv4MappedToIPv6`. -> Per BCL semantics `IsIPv4MappedToIPv6` is only ever true for `InterNetworkV6`, so the second clause -> reads as redundant -- it is really defending the round-trip described above. The normalization would be -> clearer as an explicit "to canonical v6 bits" step that never needs the family check at all. Deliberately -> left as-is; to be revisited in a follow-up PR rather than churned mid-feature. - ## Key File References | File | Description | @@ -556,12 +492,3 @@ those, so the helpers check both. | `Projects/Server/Network/Packets/OutgoingAccountPackets.cs` | Account packets | | `Projects/Server/Network/Packets/OutgoingContainerPackets.cs` | Container packets | | `Projects/Server/Network/PacketHandler.cs` | PacketHandler class | -| `Projects/Server/Network/IConnectionFilter.cs` | Accept-path gate contract | -| `Projects/Server/Network/ConnectionFilters.cs` | Filter registry + lifecycle | -| `Projects/UOContent/Network/Firewall/Firewall.cs` | Admin-curated firewall set | -| `Projects/Server/Utilities/IPAddressUtility.cs` | IPAddress <-> UInt128 normalization, CIDR parsing | -| `Projects/UOContent/Network/Blocklist/BlocklistFilter.cs` | File-sourced blocklist filter | -| `Projects/UOContent/Network/LoginAllowlist/LoginAllowlist.cs` | Allowlist earned by a recent successful login | -| `Projects/UOContent/Network/AutoDenylist/AutoDenylist.cs` | Short-lived local hold on behavioural detections | -| `Projects/Server/Network/Bans/BanReasons.cs` | Ban reason slugs + the behavioural opt-in set | -| `Projects/Server/Network/ForeignProtocol.cs` | Positive identification of non-UO traffic (HTTP/TLS/SSH) | diff --git a/dev-docs/pathfinding.md b/dev-docs/pathfinding.md index 861a41d75..dfe3d8e09 100644 --- a/dev-docs/pathfinding.md +++ b/dev-docs/pathfinding.md @@ -139,17 +139,13 @@ several-minutes cost. Wiring: after assemblies load (so content can register prompts) but **before Serilog starts**, so the console prompt is not interleaved with the async console sink. Any class can participate by defining `public static void ConfigurePrompts()` and self-gating on first-boot state. -- Both the reader open and the bake run in `Invoke("Initialize")`, after the tile matrix and world - load. Neither belongs in `Configure`, which runs *before* both: the fingerprint hashes the map - files, so opening a `.swb` there would force the lazy `Map.Tiles` property and build every - `TileMatrix` ahead of `TileMatrixLoader` — possibly before `TileMatrix.Configure()` settles - `Pre6000ClientSupport`, since both sit at the default call priority and the phase sort is - unstable. `PathCacheCommands.Configure` is limited to settings and command registration. +- The bake runs in the later `Invoke("Initialize")` phase (after the tile matrix + world load, + which the bake walks). - Staleness is decided by the `.swb` fingerprint, which `StepCacheFile.OpenForLazy` validates at open time (hash of `tiledata.mul` + the per-map `.mul`/`.uop` files — never the in-memory - `TileData` tables, which the server patches at runtime). `Initialize` opens a reader for every - up-to-date file, then skips any map where `StepCache.HasLazyReader` is already true, so the - fingerprint is computed once per boot. Each newly baked map reopens only itself. + `TileData` tables, which the server patches at runtime). `Configure` opens a reader for every + up-to-date file; the bake in `Initialize` then skips any map where `StepCache.HasLazyReader` is + already true, so the fingerprint is computed once per boot, not twice. ## Configuration levers @@ -158,7 +154,7 @@ several-minutes cost. Wiring: | `pathfinding.enable` | `PathFollower.Configure` | `true` | Master switch for `PathFollower` pathfinding. Off → greedy/auto-turn only, no A* at all. | | `bitmap_pathfinding_cache` feature flag (`ContentFeatureFlags.BitmapPathfindingCache`, `Server.Systems.FeatureFlags`) | `FeatureFlagManager` | `true` | Off → `BitmapAStar` routes straight to the slow path with **no cache probe and no warming memory**. ≈ old FastAStar at ~1×. | | `pathfinding.maxResidentChunks` | `PathCacheCommands.Configure` | 8192 (~40 MB) | LRU cap on resident chunks = the warming-memory ceiling. Lower it (e.g. 512–1024 ≈ 2.5–5 MB) on small shards. | -| `pathfinding.maxSearchNodes` | `BitmapAStarAlgorithm.Configure` → `BitmapAStarAlgorithm.MaxSearchNodes` | 1000 | A* per-Find node-expansion budget. See limits above; ~1000 is the sweet spot. | +| `pathfinding.maxSearchNodes` | `PathCacheCommands.Configure` → `BitmapAStarAlgorithm.MaxSearchNodes` | 1000 | A* per-Find node-expansion budget. See limits above; ~1000 is the sweet spot. | | `pathfinding.prebakeMaps` | `PathCacheCommands` (first-boot prompt + `Initialize`) | `false` | When set, bakes any missing/stale `.swb` for the selected maps at startup (fingerprint-gated, so a fresh cache is a no-op). Set interactively by the first-boot prompt. | | `PathFollower` `RepathDelay` | `PathFollower.cs` (const) | 2 s | Throttle: a moving goal re-`Find`s at most ~once per 2 s; a stationary reachable goal is pathed once and reused until arrival. Not a setting (compile-time). | 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/property-lists.md b/dev-docs/property-lists.md index 929429bcd..98e26efc1 100644 --- a/dev-docs/property-lists.md +++ b/dev-docs/property-lists.md @@ -301,66 +301,6 @@ public void UseCharge() } ``` -### Never Invalidate From Inside `GetProperties` (CRITICAL) - -`InvalidateProperties()` rebuilds the list **in place** — `Reset()`, then `GetProperties()` again on -the same instance. Calling it from a property getter that the build itself reaches is therefore -re-entrant, and `Reset()` does two destructive things to the build in flight: - -1. It returns the pooled interpolation scratch buffer. The compiler rents that buffer in the - interpolated-string handler's constructor and returns it in the closing `Add`, so **every hole is - evaluated while the buffer is live**. Pulling it out mid-append makes the next `Append*` span a - null array — `ArgumentNullException: Value cannot be null. (Parameter 'array')` thrown out of - `GetProperties`, from a line that looks unrelated to the getter that caused it. -2. It rewinds the packet cursor, so properties already written are overwritten by the nested pass. - -This is always a defect in the property getter, so the engine refuses rather than trying to recover: -a nested call logs an error with a stack trace, throws in `DEBUG`, and in `RELEASE` returns without -touching the list — leaving a possibly stale tooltip, but never a crash, a corrupted packet, or a -leaked pool buffer. Retrying the build would only hide the bug. - -```csharp -// BAD -- a getter with a side effect. Reading it from GetProperties re-enters the build. -public RankDefinition Rank -{ - get - { - if (_invalidateRank) - { - _rank = Recompute(); - _invalidateRank = false; - Invalidate(); // -> InvalidateProperties() -> Reset() on the list being built - } - - return _rank; - } -} - -// GOOD -- getters stay side-effect free; invalidate where the value actually changes. -public int RankIndex -{ - get => _rankIndex; - set - { - if (_rankIndex != value) - { - _rankIndex = value; - _invalidateRank = true; - Invalidate(); - } - } -} -``` - -Lazy recomputation inside a getter is fine — it is the *notification* that must not happen there. If -something genuinely must invalidate in response to a read, defer it off the build: - -```csharp -Timer.DelayCall(InvalidateProperties); -``` - -**Check when writing a `GetProperties` override**: every property it reads must be a pure read. - ## ObjectPropertyList Internals Defined in `Projects/Server/PropertyList/ObjectPropertyList.cs`: diff --git a/dev-docs/runuo-migration-docs/01-foundation-changes.md b/dev-docs/runuo-migration-docs/01-foundation-changes.md index 668701bea..0af53c6d7 100644 --- a/dev-docs/runuo-migration-docs/01-foundation-changes.md +++ b/dev-docs/runuo-migration-docs/01-foundation-changes.md @@ -282,65 +282,6 @@ public MyItem(Serial serial) : base(serial) { } // ModernUO — DELETE THIS CONSTRUCTOR. The source generator creates it. ``` -## 15. Static `Parse(string)` → `IParsable` / `ISpanParsable` - -RunUO predates `IParsable`/`ISpanParsable` (C# 11 / .NET 7 static-abstract interface -members), so RunUO types that convert from a string expose a bare `public static T Parse(string value)`. -**ModernUO expects any such type to implement `IParsable` (string) and, where practical, -`ISpanParsable` (span; it extends `IParsable`, so implement span and you get both).** - -This matters because the engine's string→value converter, `Server.Types.TryParse` — used by `[set`, -`[props`, spawner property assignment, the conditional-command compiler (`[where`), and Advanced -Search — binds to the `Parse(string, IFormatProvider)` signature. A type with **only** a legacy -`Parse(string)` is discovered by `Types` through a reflection fallback, but that fallback is a safety -net, not the intended path: a bare `Parse(string)` is easy to miss, doesn't participate in the -span-based fast paths, and (if it returns `null` instead of throwing) makes `[set` silently assign -`null` on bad input. Convert it. - -The `Parse` overloads throw `FormatException` on failure; `TryParse` returns `false`. Delegate the -string overloads to a span core (see `Race`, `Poison`, `Point3D` for the established pattern): - -```csharp -// RunUO -public abstract class Faction : IComparable -{ - public static Faction Parse(string name) // returns null on no-match — wrong contract, not IParsable - { - // ... linear search by name ... - return null; - } -} - -// ModernUO -public abstract class Faction : IComparable, ISpanParsable -{ - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static Faction Parse(string s) => Parse(s, null); - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static Faction Parse(string s, IFormatProvider provider) => Parse(s.AsSpan(), provider); - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static bool TryParse(string s, IFormatProvider provider, out Faction result) => - TryParse(s.AsSpan(), provider, out result); - - public static Faction Parse(ReadOnlySpan s, IFormatProvider provider) => - TryParse(s, provider, out var result) - ? result - : throw new FormatException($"The input string '{s}' was not in a correct format."); - - public static bool TryParse(ReadOnlySpan s, IFormatProvider provider, out Faction result) - { - // ... linear search by name using s.InsensitiveEquals(...) ... - result = null; - return false; - } -} -``` - -To find un-migrated types: search for `public static [A-Za-z0-9_<>]+ Parse\(string ` and check whether -the declaring type lists `IParsable`/`ISpanParsable`. - ## Quick Checklist When migrating any RunUO script, apply these changes in order: @@ -359,7 +300,6 @@ When migrating any RunUO script, apply these changes in order: 12. [ ] Modernize property syntax 13. [ ] Remove `Serial` constructor (handled by serialization generator) 14. [ ] Update usings -15. [ ] Convert bare static `Parse(string)` to `IParsable`/`ISpanParsable` ## See Also 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) diff --git a/tools/Export-IpBlocklist.ps1 b/tools/Export-IpBlocklist.ps1 deleted file mode 100644 index 0e9fd781d..000000000 --- a/tools/Export-IpBlocklist.ps1 +++ /dev/null @@ -1,1082 +0,0 @@ -#requires -Version 7.0 -<# -.SYNOPSIS - Downloads a small, non-overlapping set of public IP threat feeds and writes them to a single - ModernUO blocklist file -- merged, de-duplicated and bogon-filtered. - -.DESCRIPTION - This is the producer half of ModernUO's in-app blocklist gate. It fetches a deliberately THIN feed - set, merges every source into one global set, drops duplicates and reserved/bogon addresses, then - writes the result to a plain text file that the shard reads via `file` in - Configuration/blocklist.json. Nothing is installed and no credentials are needed -- the output is just - a text file, so this can run on any machine that can reach the shard's Distribution folder. - - It writes the file the shard's `BlocklistFilter` demand-pages against. IPs that actually connect are - promoted to CrowdSec / the OS firewall by the shard; the OS firewall never has to hold millions of - entries, which is exactly the scale it cannot handle on Windows. - - Inclusion principle: any category of IP used in OTHER attacks that could plausibly be turned against a - game server should be blocked -- compromised hosts, botnets, scanners, spam / DDoS-as-a-service bots, - open proxies and Tor relays. That whole surface is already covered by the anchor feed `bitwire-it`, - which is itself a 91-source aggregator (it folds in spamhaus, ipsum, firehol-level2, blocklist-de, - dshield, emergingthreats, binarydefense, cins-army, bruteforceblocker, greensnow, vxvault, ThreatFox, - StopForumSpam/sblam, Tor, open-proxy and C2 lists). So the inclusive posture lives in the base layer, - and every one of those standalone feeds is dropped as pure redundancy. Only the feeds bitwire does NOT - already carry are kept on top of it: - - bitwire-it 2h-refreshed 91-source aggregate (compromised hosts, botnets, scanners, spam - bots, Tor/open-proxy abuse relays, ThreatFox C2) -- the broad base layer. - romainmarcoux ~130k fresh attacker IPs bitwire's snapshot lags on (high-churn feed). - sentinel-turris ~800 unique honeypot probers (Turris greylist) not in bitwire. - firehol-level1 hijacked/reputation NETBLOCKS (spamhaus DROP-style) -- bogon-filtered. - - The only category deliberately held back is commercial VPN exit endpoints, which could block a legit - player -- and those are barely present here anyway (bitwire is ~5% of VPN-tunnel lists). If you ever want - to protect VPN/Tor players, pass -ExcludeAnonymizers to subtract Tor/open-proxy/VPN IPs from the output. - - ALLOWLIST - Aggregators inevitably list shared consumer address space. A CGNAT public IP fronts many subscribers, so - one abusive customer gets the address listed and every other subscriber behind it is blocked with them. - Entries in the allowlist file (-AllowlistFile) are SUBTRACTED from the merged set before it is written, so - the exemption costs nothing on the shard's accept path -- which is deliberately allowlist-free, because a - whitelist there could only ever turn a deny into an allow at the price of a lookup on every accept, the - attacker's included. Allowlisting belongs here (at generation) and at the enforcement layer - (`cscli allowlists`), never at the gate. - - Subtraction is range-correct: an allowlisted address that falls inside a blocked CIDR splits that CIDR - around the hole instead of being silently ignored, so an exemption always takes effect no matter which - shape the feed happened to publish. - - Every `ip-allowlist*.txt` beside the output is subtracted, so allowlists are split by owner rather than - kept in one file: `ip-allowlist.txt` holds the operator's own exemptions and is never rewritten, while - network carve-outs live in `ip-allowlist-.txt`. Keeping them apart means a carve-out can be - regenerated, diffed or copied to another shard without disturbing hand-written entries. - - NO CARVE-OUT IS SHIPPED. Which providers to exempt is a policy call that depends on where a shard's - players actually are, and a carve-out names a real network, so this script builds them on request rather - than publishing anyone's. A shard whose players are on CGNAT -- satellite, mobile, or an ISP short on - IPv4 -- will usually want one: - - .\Export-IpBlocklist.ps1 -AddCarveout starlink -Asn 14593 - - That costs roughly 0.1% of the list. Abusive hosts inside a carved-out network are still caught on - BEHAVIOR by the rate limiter and promoted to CrowdSec, which is the gate that actually observes them. - - OUTPUT FORMAT (must stay in sync with UOContent/Network/Blocklist/BlocklistFile.cs): - Line 1 is a header comment carrying the version markers, e.g. - # modernuo-blocklist generated=2026-07-25T18:03:11Z count=3914022 ipv4=3901188 cidr=12834 - The shard polls `reloadInterval` and reloads when the file mtime AND `generated=` change, - so the header is REQUIRED -- without it the shard loads once and never picks up a new file. - Every following line is one entry: a bare IPv4/IPv6 address or a CIDR (`1.2.3.0/24`). Blank lines - and lines starting with `#` or `;` are ignored. Order does not matter; the shard sorts and - coalesces on load. The feeds used here are IPv4-only, but the shard parses IPv6 lines too. - - The file is written to a `.tmp` sibling and swapped into place atomically, so the shard never reads a - half-written list -- it either sees the previous version or the new one, whole. - - Performance: bitwire alone is ~4M lines. Parsing/validating/bogon-filtering that in interpreted - PowerShell is the slow part (minutes), so the hot loop is compiled once via Add-Type (C#) -- it runs in - ~1s. Downloads stream with a live Write-Progress bar; every phase prints its own elapsed time so you can - see exactly where the wall-clock goes. - - Requires PowerShell 7 (pwsh), which runs on Windows, Linux and macOS -- Windows PowerShell 5.1 is - not supported and the script refuses to run there. Schedule it with Task Scheduler, cron, or a - systemd timer. - - Every run rewrites the whole file, so an IP that drops off the feeds stops being blocked on the next - run -- there is no TTL to tune. Calling it is idempotent: if the list on disk is younger than - -MinInterval the script exits without downloading anything, so an over-eager trigger costs nothing - upstream. -Force overrides that. - -.PARAMETER DistributionPath - Path to the shard's Distribution folder. The blocklist is written to the Configuration/ip-blocklist.txt - beneath it, which is the default `file` in blocklist.json. Not needed when the script is run from its - place in the repo (tools/), or when -OutFile is given. - -.PARAMETER OutFile - Explicit output path, overriding -DistributionPath. Use this if you relocated the blocklist and - changed `file` in blocklist.json to match. - -.PARAMETER MinInterval - Refuse to re-run while the existing blocklist is younger than this (default 2h), so a misbehaving - scheduler, a login script or a stuck retry loop cannot hammer the upstream feeds. The age comes from - the `generated=` header of the file already on disk (falling back to its mtime), so it survives across - machines and reboots -- there is no separate state file. Nothing is downloaded when the check trips. - Accepts `90s`, `45m`, `2h`, `2.5h`, `1d`, or a bare number of hours. Use `0` to disable the check. - Match this to how often you actually want fresh data: the anchor feed only refreshes every 2h, so - running more often than that costs bandwidth and gains nothing. - -.PARAMETER Force - Run regardless of how recently the blocklist was generated (bypasses -MinInterval). - -.PARAMETER Feeds - Which feeds to include (by Name). Default: all of them. - -.PARAMETER AllowlistFile - One or more lists of addresses that must NEVER be blocked; every entry is subtracted from the merged set - before the output is written. Same format as the blocklist: one bare IPv4 or CIDR per line, `#`/`;` - comments ignored. - - Defaults to every `ip-allowlist*.txt` beside the output, merged into one allow set: - ip-allowlist.txt operator exemptions -- created on first run, never rewritten - ip-allowlist-.txt a network carve-out -- generated data, safe to regenerate or copy - Discovered rather than configured, so a carve-out you add is picked up with no further edits. Blank a - file (keep the file) to disable its contents; delete it to drop it entirely. - - Passing this parameter replaces the defaults entirely; an explicitly-named file that does not exist is a - warning rather than a silent template write, so a typo cannot look like it worked. Pass `''` to disable - subtraction altogether. - - Editing any allowlist also bypasses -MinInterval on the next run: an exemption you just added would - otherwise sit unapplied for up to the cooldown, which reads exactly like the allowlist not working. - -.PARAMETER AddCarveout - Build a carve-out for a network and start subtracting it. Takes a short name for the file and -Asn for - the network, fetches that ASN's current routing announcements, collapses them, and writes - `ip-allowlist-.txt` beside the output. Implies -Force. - - .\Export-IpBlocklist.ps1 -AddCarveout starlink -Asn 14593 - - No carve-out ships with this script: naming a network to exempt is a policy call for the shard, so they - are built on request rather than published here. Re-running with the same name rebuilds the file. - -.PARAMETER Asn - Autonomous system number for -AddCarveout, e.g. 14593 for Starlink. Look one up by querying an address - the network hands out, or on any public BGP lookup. - -.PARAMETER RefreshCarveouts - Re-fetch every carve-out beside the output and rewrite it from current routing announcements. Implies - -Force. Carve-outs are recognised by the `asn=` marker in their header, so a hand-written allowlist is - left alone, and one whose fetch fails keeps the data it already had. - - Announcements rather than ownership records on purpose: registry data disagrees with what is actually - routed, and registry queries silently cap their result sets. - -.PARAMETER ExcludeAnonymizers - Also download Tor-exit / open-proxy / VPN-tunnel lists and SUBTRACT those IPs from the output. Off by - default -- for a game server, Tor/open-proxy relays are attack infrastructure you want to block. Turn - this on only if you need to keep VPN/Tor players reachable. - -.PARAMETER DryRun - Download + parse + merge + count only. Writes nothing. - -.EXAMPLE - .\Export-IpBlocklist.ps1 -DryRun - -.EXAMPLE - # Safe to call as often as you like -- it no-ops unless the list is older than 2h. - .\Export-IpBlocklist.ps1 -DistributionPath 'C:\Shard\Distribution' - -.EXAMPLE - .\Export-IpBlocklist.ps1 -OutFile 'D:\shared\ip-blocklist.txt' -ExcludeAnonymizers - -.EXAMPLE - # Regenerate right now, ignoring the cooldown. - .\Export-IpBlocklist.ps1 -DistributionPath 'C:\Shard\Distribution' -Force - -.EXAMPLE - # Unblock a player caught by a shared-IP listing: add the address, then regenerate. Editing the - # allowlist bypasses the cooldown, so no -Force is needed. - Add-Content 'C:\Shard\Distribution\Configuration\ip-allowlist.txt' '203.0.113.42' - .\Export-IpBlocklist.ps1 -DistributionPath 'C:\Shard\Distribution' - -.EXAMPLE - # Check what an allowlist would cost before committing to it. - .\Export-IpBlocklist.ps1 -AllowlistFile 'D:\shared\allow.txt' -DryRun - -.EXAMPLE - # Exempt a CGNAT provider whose players keep getting listed, then keep it current. - .\Export-IpBlocklist.ps1 -AddCarveout starlink -Asn 14593 - .\Export-IpBlocklist.ps1 -RefreshCarveouts - -.EXAMPLE - # Linux/macOS, e.g. from cron: - pwsh -File /opt/modernuo/Export-IpBlocklist.ps1 -DistributionPath /opt/modernuo/Distribution - -.NOTES - Feeds are aggressive-but-low-FP for a game server (attacker / botnet / compromised / abuse-relay SOURCE - IPs). Reserved/bogon space (0/8, 10/8, 127/8, RFC1918, multicast, etc.) is always filtered out -- this - matters because firehol-level1 ships bogon netblocks that would otherwise block private/reserved ranges. -#> -[CmdletBinding()] -param( - [string] $DistributionPath, - [string] $OutFile, - [string] $MinInterval = '2h', - [string[]] $AllowlistFile, - [string] $AddCarveout, - [int] $Asn, - [switch] $RefreshCarveouts, - [string[]] $Feeds, - [switch] $ExcludeAnonymizers, - [switch] $Force, - [switch] $DryRun -) - -$ErrorActionPreference = 'Stop' - -# Fail before any download rather than after 60MB of feeds. -if ($AddCarveout -and $Asn -le 0) { - throw "-AddCarveout needs -Asn, e.g. -AddCarveout starlink -Asn 14593" -} -$UA = 'ModernUO-Blocklist-Export' -$totalSw = [System.Diagnostics.Stopwatch]::StartNew() - -# Default location under the Distribution folder. Keep in sync with BlocklistSettings.File. -# Kept as separate segments (never a literal 'a\b') so Join-Path picks the right separator per OS. -$DefaultPathSegments = @('Configuration', 'ip-blocklist.txt') - -# --------------------------------------------------------------------------------------------------------- -# Resolve the output path. Explicit -OutFile wins; then -DistributionPath; then the in-repo layout -# (tools\ sits next to Distribution\) so a checkout works with no arguments at all. The script is meant to -# be copied onto the shard host, and there it needs -DistributionPath (or -OutFile). -# --------------------------------------------------------------------------------------------------------- -if (-not $OutFile) { - if (-not $DistributionPath -and $PSScriptRoot) { - $inRepo = Join-Path (Split-Path -Parent $PSScriptRoot) 'Distribution' - if (Test-Path -LiteralPath $inRepo -PathType Container) { $DistributionPath = $inRepo } - } - if (-not $DistributionPath) { - throw "Could not locate the shard's Distribution folder. Pass -DistributionPath 'C:\path\to\Distribution' (or -OutFile)." - } - if (-not (Test-Path -LiteralPath $DistributionPath -PathType Container)) { - throw "DistributionPath '$DistributionPath' does not exist." - } - $OutFile = Join-Path $DistributionPath @DefaultPathSegments -} - -# --------------------------------------------------------------------------------------------------------- -# Network carve-outs are DATA, not code: this script ships none. Which providers a shard exempts is a policy -# call that depends on where its players actually are, so the carve-outs live in files an admin creates with -# -AddCarveout, and every ip-allowlist*.txt beside the output is subtracted. -# -# A shard whose players are on CGNAT -- satellite, mobile, or an ISP short on IPv4 -- will usually want one: -# .\Export-IpBlocklist.ps1 -AddCarveout starlink -Asn 14593 -# --------------------------------------------------------------------------------------------------------- - -# --------------------------------------------------------------------------------------------------------- -# Resolve the allowlists. They sit beside the output by default so relocating the blocklist keeps the set -# together, and they are split by owner: `ip-allowlist.txt` is the operator's -- hand-edited, never rewritten -# -- while each carve-out file is generated data that can be regenerated, diffed or copied between shards -# without touching anyone's local exemptions. -# -# Carve-outs are discovered rather than listed, so a file an admin drops in is picked up with no config edit -# and no code change. An EXPLICIT -AllowlistFile replaces the whole set and is never templated: if the -# operator names a file, a missing one is a typo worth hearing about. -# --------------------------------------------------------------------------------------------------------- -$AllowGlob = 'ip-allowlist*.txt' -$ConfigDir = Split-Path -Parent $OutFile - -$allowExplicit = $PSBoundParameters.ContainsKey('AllowlistFile') - -if ($allowExplicit) { - $AllowPaths = @($AllowlistFile | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) -} -else { - $AllowPaths = @(Get-ChildItem -Path $ConfigDir -Filter $AllowGlob -File -ErrorAction SilentlyContinue | - Sort-Object Name | ForEach-Object { $_.FullName }) -} - -function Write-AllowlistFile { - param([string]$Path, [string[]]$Lines) - - $dir = Split-Path -Parent $Path - if ($dir -and -not (Test-Path -LiteralPath $dir -PathType Container)) { - New-Item -ItemType Directory -Path $dir -Force | Out-Null - } - - # Same atomic write the blocklist gets: a half-written allowlist would silently under-subtract. - $tmp = $Path + '.tmp' - [IO.File]::WriteAllLines($tmp, $Lines, [System.Text.UTF8Encoding]::new($false)) - [IO.File]::Move($tmp, $Path, $true) -} - -$OperatorTemplate = @' -# ModernUO blocklist allowlist -- every entry here is SUBTRACTED from the generated blocklist. -# -# This file is yours. Export-IpBlocklist.ps1 creates it once and never rewrites it, so anything you add -# survives every regeneration. -# -# One entry per line: a bare IPv4 address (1.2.3.4) or a CIDR (1.2.3.0/24). Lines starting with '#' or ';' -# are comments. Order does not matter. Re-run Export-IpBlocklist.ps1 to apply changes -- editing this file -# bypasses the -MinInterval cooldown, so no -Force is needed. -# -# Removal is range-correct: an address listed here is removed even when a feed published it as part of a -# larger CIDR -- that CIDR is split around the hole rather than dropped wholesale or silently ignored. -# -# Network carve-outs live in their own ip-allowlist-.txt beside this one (see -AddCarveout), so they -# can be regenerated or copied between shards without touching anything you put here. -# -# Put player/staff exemptions below, one per line, e.g.: -# 203.0.113.42 # shard owner, listed via a shared upstream address -'@ - -# Carve-out files carry their own `asn=` marker, so -RefreshCarveouts can rebuild whatever an admin created -# without this script keeping a list of anyone's networks. -function Get-CarveoutHeader { - param([string]$Name, [int]$CarveoutAsn) - - @( - ("# {0} carve-out (asn={1}) -- subtracted from the generated blocklist." -f $Name, $CarveoutAsn) - "#" - "# Reputation feeds list shared consumer address space constantly, so a hit inside a CGNAT network" - "# says little about the player currently behind it. Abusive hosts here are still caught on BEHAVIOR." - "#" - "# GENERATED DATA -- safe to regenerate, diff, or copy to another shard. Blank the file (keep the" - "# file) to reputation-block this network again; delete it to stop carving it out entirely." - "#" - "# Refresh with: .\Export-IpBlocklist.ps1 -RefreshCarveouts" - ) -} - -# Fetches a network's currently ANNOUNCED prefixes and collapses them. Routing data, not a registry: -# ownership records disagree with what is actually announced, and registry queries cap their result sets. -function Get-CarveoutPrefixes { - param([int]$CarveoutAsn) - - $url = "https://stat.ripe.net/data/announced-prefixes/data.json?resource=AS$CarveoutAsn" - $json = Get-Url -Url $url -Label ("AS{0} prefixes" -f $CarveoutAsn) | ConvertFrom-Json - - $v4 = @($json.data.prefixes.prefix | Where-Object { $_ -and $_ -notmatch ':' }) - if (-not $v4) { throw "AS$CarveoutAsn announced no IPv4 prefixes -- refusing to overwrite the carve-out." } - - [BlocklistExporter]::CollapsePrefixes(($v4 -join "`n")) -} - -# Reads the `asn=` marker back out of a carve-out file. Anything without one is a hand-written allowlist and -# is left alone by -RefreshCarveouts. -function Get-CarveoutAsn { - param([string]$Path) - - foreach ($line in (Get-Content -LiteralPath $Path -TotalCount 5 -ErrorAction SilentlyContinue)) { - if ($line -match 'asn=(\d+)') { return [int]$Matches[1] } - } - - return 0 -} - -# The operator's own list is the one file this script will create unprompted; carve-outs are opt-in. -if (-not $allowExplicit) { - $operatorPath = Join-Path $ConfigDir 'ip-allowlist.txt' - if (-not (Test-Path -LiteralPath $operatorPath -PathType Leaf)) { - Write-AllowlistFile -Path $operatorPath -Lines @($OperatorTemplate) - Write-Host ("Created allowlist at {0} (add player/staff exemptions here)." -f $operatorPath) - $AllowPaths = @($operatorPath) + $AllowPaths - } -} -else { - $AllowPaths = @($AllowPaths | ForEach-Object { - if (Test-Path -LiteralPath $_ -PathType Leaf) { return $_ } - Write-Warning ("Allowlist '{0}' does not exist -- nothing will be subtracted from it. Check the path." -f $_) - }) -} - -# --------------------------------------------------------------------------------------------------------- -# Cooldown gate. Runs BEFORE anything is downloaded: the whole point is that a misconfigured scheduler or a -# retry loop cannot spam the upstream feeds. State lives in the output file itself (`generated=` header, -# mtime as fallback), so it is correct across reboots, machines and hand-runs with no sidecar state file. -# --------------------------------------------------------------------------------------------------------- -function ConvertTo-Duration { - param([string]$Text) - if ([string]::IsNullOrWhiteSpace($Text)) { return [TimeSpan]::Zero } - $t = $Text.Trim().ToLowerInvariant() - $unit = $t[$t.Length - 1] - $numText = if ($unit -match '[0-9.]') { $t } else { $t.Substring(0, $t.Length - 1) } - $n = 0.0 - # InvariantCulture is not optional here: under a comma-decimal locale (de-DE, fr-FR, ...) the - # current-culture parse reads '2.5' as 25 -- it treats '.' as a group separator and SUCCEEDS, so - # `-MinInterval 2.5h` would silently become a 25 hour cooldown instead of failing loudly. - if (-not [double]::TryParse($numText, [Globalization.NumberStyles]::Float, - [Globalization.CultureInfo]::InvariantCulture, [ref]$n)) { - throw "Could not parse duration '$Text' (try 90s, 45m, 2h, 2.5h, 1d)." - } - switch ($unit) { - 's' { return [TimeSpan]::FromSeconds($n) } - 'm' { return [TimeSpan]::FromMinutes($n) } - 'h' { return [TimeSpan]::FromHours($n) } - 'd' { return [TimeSpan]::FromDays($n) } - default { return [TimeSpan]::FromHours($n) } # bare number == hours - } -} - -# Age of the list already on disk, or $null when there is nothing usable to age. -function Get-BlocklistAge { - param([string]$Path) - if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { return $null } - - # Prefer the header we wrote: it describes the data, not the file, so copying/restoring the file - # cannot make a stale list look fresh (or a fresh one look stale). - try { - $first = Get-Content -LiteralPath $Path -TotalCount 1 -ErrorAction Stop - if ($first -and $first.StartsWith('#')) { - foreach ($tok in $first.Split(' ', [StringSplitOptions]::RemoveEmptyEntries)) { - if ($tok.StartsWith('generated=', [StringComparison]::Ordinal)) { - $stamp = [DateTime]::MinValue - $styles = [Globalization.DateTimeStyles]::AdjustToUniversal -bor [Globalization.DateTimeStyles]::AssumeUniversal - if ([DateTime]::TryParse($tok.Substring(10), [Globalization.CultureInfo]::InvariantCulture, $styles, [ref]$stamp)) { - return @{ Age = ([DateTime]::UtcNow - $stamp); Stamp = $tok.Substring(10); Source = 'header' } - } - } - } - } - } - catch { } - - # Hand-maintained or truncated file: fall back to the filesystem timestamp. - try { - $w = (Get-Item -LiteralPath $Path -ErrorAction Stop).LastWriteTimeUtc - return @{ Age = ([DateTime]::UtcNow - $w) - Stamp = $w.ToString('yyyy-MM-ddTHH:mm:ssZ', [Globalization.CultureInfo]::InvariantCulture) - Source = 'mtime' } - } - catch { return $null } -} - -$minAge = ConvertTo-Duration $MinInterval -# Asking for carve-out data implies -Force: waiting out the cooldown and leaving the old data in place would -# be the wrong answer. -if (-not $Force -and -not $RefreshCarveouts -and -not $AddCarveout -and $minAge -gt [TimeSpan]::Zero) { - $existing = Get-BlocklistAge -Path $OutFile - if ($existing) { - # A negative age means the stamp is in the future (clock skew, or a file from another host). Treat it - # as fresh: refusing to run is the recoverable failure, hammering the feeds on every tick is not. - if ($existing.Age -lt $minAge) { - # An allowlist edited since the list was built is the one case where waiting out the cooldown is - # the wrong answer: the operator is unblocking someone, and "nothing happened" is indistinguishable - # from the allowlist not working. Cheap to honour -- it can only ever shrink the output. - $changedAllow = $null - $builtAt = [DateTime]::UtcNow - $existing.Age - foreach ($p in $AllowPaths) { - try { - if ((Get-Item -LiteralPath $p -ErrorAction Stop).LastWriteTimeUtc -gt $builtAt) { - $changedAllow = $p - break - } - } - catch { } - } - - if ($changedAllow) { - Write-Host ("Allowlist {0} changed since the blocklist was built; regenerating despite -MinInterval {1}." -f ` - $changedAllow, $MinInterval) - } - else { - $agoText = if ($existing.Age -lt [TimeSpan]::Zero) { 'in the future -- check the clock' } else { ("{0:N1}h ago" -f $existing.Age.TotalHours) } - Write-Host ("Blocklist at {0} was generated {1} ({2}={3}); newer than -MinInterval {4}." -f ` - $OutFile, $agoText, $existing.Source, $existing.Stamp, $MinInterval) - Write-Host "Nothing downloaded. Pass -Force to regenerate now, or lower -MinInterval." - return - } - } - } -} - -# --------------------------------------------------------------------------------------------------------- -# Compiled hot loop. Interpreted PowerShell chokes on bitwire's ~4M lines; this parses + validates + bogon- -# filters + de-dupes in one compiled pass, and writes the final file directly (no 4M-element PS pipelines). -# Deliberately plain C#: no LINQ, no generics beyond HashSet, nothing that would slow the hot loop. -# --------------------------------------------------------------------------------------------------------- -Add-Type -TypeDefinition @' -using System; -using System.Collections.Generic; -using System.IO; -using System.Text; - -public static class BlocklistExporter -{ - static bool TryParseIPv4(string s, int start, int len, out uint val) - { - val = 0; - uint acc = 0; int octet = 0, dots = 0, digits = 0, end = start + len; - for (int i = start; i < end; i++) - { - char c = s[i]; - if (c == '.') - { - if (digits == 0 || octet > 255) return false; - acc = (acc << 8) | (uint)octet; dots++; octet = 0; digits = 0; - } - else if (c >= '0' && c <= '9') - { - octet = octet * 10 + (c - '0'); if (++digits > 3) return false; - } - else return false; - } - if (dots != 3 || digits == 0 || octet > 255) return false; - val = (acc << 8) | (uint)octet; - return true; - } - - static bool IsBogon(uint start, uint end, uint[] bs, uint[] be) - { - for (int i = 0; i < bs.Length; i++) - if (start <= be[i] && end >= bs[i]) return true; - return false; - } - - // Parse one feed's text; add bare IPs to `singles`, CIDRs to `cidrs`. Returns count newly added. - public static int AddContent(string content, HashSet singles, HashSet cidrs, uint[] bs, uint[] be) - { - int added = 0, n = content.Length, i = 0; - while (i < n) - { - int eol = content.IndexOf('\n', i); - int lineEnd = (eol < 0) ? n : eol; - int a = i, b = lineEnd; - while (a < b && (content[a] == ' ' || content[a] == '\t' || content[a] == '\r')) a++; - while (b > a && (content[b - 1] == ' ' || content[b - 1] == '\t' || content[b - 1] == '\r')) b--; - i = (eol < 0) ? n : eol + 1; - if (a >= b) continue; - char first = content[a]; - if (first == '#' || first == ';') continue; - - // Feeds vary: some are bare IPs, some are CSV/whitespace records with the IP first. - int t = a; - while (t < b) - { - char c = content[t]; - if (c == ' ' || c == '\t' || c == ',' || c == ';') break; - t++; - } - int slash = -1; - for (int k = a; k < t; k++) { if (content[k] == '/') { slash = k; break; } } - - if (slash >= 0) - { - uint ip; - if (!TryParseIPv4(content, a, slash - a, out ip)) continue; - int bits = 0, bd = 0; - for (int k = slash + 1; k < t; k++) - { - char c = content[k]; - if (c < '0' || c > '9') { bd = -1; break; } - bits = bits * 10 + (c - '0'); bd++; - } - if (bd <= 0 || bits > 32) continue; - ulong size = (bits == 0) ? 0xFFFFFFFFUL : ((1UL << (32 - bits)) - 1UL); - ulong endAddr = (ulong)ip + size; if (endAddr > 0xFFFFFFFFUL) endAddr = 0xFFFFFFFFUL; - if (IsBogon(ip, (uint)endAddr, bs, be)) continue; - if (cidrs.Add(content.Substring(a, t - a))) added++; - } - else - { - uint ip; - if (!TryParseIPv4(content, a, t - a, out ip)) continue; - if (IsBogon(ip, ip, bs, be)) continue; - if (singles.Add(ip)) added++; - } - } - return added; - } - - // --------------------------------------------------------------------------------------------------- - // Allowlist subtraction. Allow entries become sorted, merged [start,end] ranges once; the blocklist is - // then filtered against them. The interesting case is a blocked CIDR that only PARTIALLY overlaps an - // allow range -- dropping it whole would unblock far more than asked, keeping it whole would ignore the - // exemption, so it is split into the surviving pieces and re-emitted as minimal CIDRs. - // --------------------------------------------------------------------------------------------------- - public sealed class RangeSet - { - public uint[] Start; - public uint[] End; - public int Count; - } - - public sealed class AllowResult - { - public int SinglesRemoved; - public int CidrsDropped; - public int CidrsSplit; - public int EntriesAdded; - } - - // Parses "a.b.c.d/p" to an inclusive range. The base is masked to the prefix, so a sloppy 1.2.3.5/24 - // means the whole 1.2.3.0/24 -- the standard reading, and the safe direction for an exemption. - static bool TryCidrRange(string c, out ulong lo, out ulong hi) - { - lo = 0; hi = 0; - int slash = c.IndexOf('/'); - if (slash <= 0) return false; - uint ip; - if (!TryParseIPv4(c, 0, slash, out ip)) return false; - int bits = 0, bd = 0; - for (int i = slash + 1; i < c.Length; i++) - { - char ch = c[i]; - if (ch < '0' || ch > '9') { bd = -1; break; } - bits = bits * 10 + (ch - '0'); bd++; - } - if (bd <= 0 || bits > 32) return false; - // 1u << 32 is undefined in C# (the shift count is masked to 5 bits), so /0 is special-cased. - uint mask = (bits == 0) ? 0u : ~((uint)((1UL << (32 - bits)) - 1UL)); - ulong size = (bits == 0) ? 0x100000000UL : (1UL << (32 - bits)); - lo = ip & mask; - hi = lo + size - 1UL; - return true; - } - - public static RangeSet BuildRanges(HashSet singles, HashSet cidrs) - { - uint[] s = new uint[singles.Count + cidrs.Count]; - uint[] e = new uint[s.Length]; - int k = 0; - - foreach (uint v in singles) { s[k] = v; e[k] = v; k++; } - foreach (string c in cidrs) - { - ulong lo, hi; - if (!TryCidrRange(c, out lo, out hi)) continue; - s[k] = (uint)lo; - e[k] = (uint)(hi > 0xFFFFFFFFUL ? 0xFFFFFFFFUL : hi); - k++; - } - - Array.Resize(ref s, k); - Array.Resize(ref e, k); - Array.Sort(s, e); - - // Coalesce overlapping AND adjacent ranges so the lookups below can assume disjoint, ordered spans. - int w = 0; - for (int i = 0; i < k; i++) - { - if (w > 0 && (ulong)s[i] <= (ulong)e[w - 1] + 1UL) - { - if (e[i] > e[w - 1]) e[w - 1] = e[i]; - } - else - { - s[w] = s[i]; e[w] = e[i]; w++; - } - } - - return new RangeSet { Start = s, End = e, Count = w }; - } - - // Index of the first range whose End >= v (ranges are disjoint and sorted, so End is sorted too). - static int FirstEndAtLeast(uint[] re, int n, uint v) - { - int lo = 0, hi = n; - while (lo < hi) - { - int mid = (int)(((uint)lo + (uint)hi) >> 1); - if (re[mid] < v) lo = mid + 1; else hi = mid; - } - return lo; - } - - static bool Covered(uint[] rs, uint[] re, int n, uint v) - { - int i = FirstEndAtLeast(re, n, v); - return i < n && rs[i] <= v; - } - - static string FormatCidr(uint ip, int bits) - { - char[] buf = new char[19]; - int p = 0; - p = WriteOctet(buf, p, (ip >> 24) & 255); buf[p++] = '.'; - p = WriteOctet(buf, p, (ip >> 16) & 255); buf[p++] = '.'; - p = WriteOctet(buf, p, (ip >> 8) & 255); buf[p++] = '.'; - p = WriteOctet(buf, p, ip & 255); buf[p++] = '/'; - p = WriteOctet(buf, p, (uint)bits); - return new string(buf, 0, p); - } - - // Writes [lo,hi] as the minimal set of aligned CIDR blocks. A /32 goes back to the singles set so the - // output keeps the file's convention of bare addresses for single hosts. - static void Emit(ulong lo, ulong hi, HashSet singles, HashSet cidrs, AllowResult r) - { - while (lo <= hi) - { - int bits = 32; - while (bits > 0) - { - ulong size = 1UL << (32 - (bits - 1)); - if ((lo % size) != 0UL) break; - if (lo + size - 1UL > hi) break; - bits--; - } - - if (bits == 32) - { - if (singles.Add((uint)lo)) r.EntriesAdded++; - } - else if (cidrs.Add(FormatCidr((uint)lo, bits))) - { - r.EntriesAdded++; - } - - lo += 1UL << (32 - bits); - } - } - - public static AllowResult ApplyAllowlist(HashSet singles, HashSet cidrs, RangeSet allow) - { - var r = new AllowResult(); - if (allow == null || allow.Count == 0) return r; - - uint[] rs = allow.Start, re = allow.End; - int n = allow.Count; - - // Singles first: the CIDR pass below can add new singles, and those are outside the allow ranges by - // construction, so re-testing them would be wasted work. - uint[] sarr = new uint[singles.Count]; - singles.CopyTo(sarr); - for (int i = 0; i < sarr.Length; i++) - { - if (Covered(rs, re, n, sarr[i]) && singles.Remove(sarr[i])) r.SinglesRemoved++; - } - - string[] carr = new string[cidrs.Count]; - cidrs.CopyTo(carr); - cidrs.Clear(); - - for (int i = 0; i < carr.Length; i++) - { - string c = carr[i]; - ulong lo, hi; - - // Unparseable entries are kept verbatim rather than dropped: this pass exists to subtract, and - // silently discarding something it could not read would weaken the list. - if (!TryCidrRange(c, out lo, out hi)) { cidrs.Add(c); continue; } - if (hi > 0xFFFFFFFFUL) hi = 0xFFFFFFFFUL; - - int idx = FirstEndAtLeast(re, n, (uint)lo); - if (idx >= n || (ulong)rs[idx] > hi) { cidrs.Add(c); continue; } // no overlap: the common case - - ulong cursor = lo; - int before = r.EntriesAdded; - for (int j = idx; j < n && (ulong)rs[j] <= hi; j++) - { - if ((ulong)rs[j] > cursor) Emit(cursor, (ulong)rs[j] - 1UL, singles, cidrs, r); - ulong next = (ulong)re[j] + 1UL; - if (next > cursor) cursor = next; - if (cursor > hi) break; - } - if (cursor <= hi) Emit(cursor, hi, singles, cidrs, r); - - if (r.EntriesAdded == before) r.CidrsDropped++; else r.CidrsSplit++; - } - - return r; - } - - static string FormatIp(uint ip) - { - char[] buf = new char[16]; - int p = 0; - p = WriteOctet(buf, p, (ip >> 24) & 255); buf[p++] = '.'; - p = WriteOctet(buf, p, (ip >> 16) & 255); buf[p++] = '.'; - p = WriteOctet(buf, p, (ip >> 8) & 255); buf[p++] = '.'; - p = WriteOctet(buf, p, ip & 255); - return new string(buf, 0, p); - } - - // Collapses a prefix list into the minimal equivalent set, in ascending order. Used by - // -RefreshCarveouts: routing data publishes thousands of overlapping announcements. - public static string[] CollapsePrefixes(string content) - { - uint[] noBogon = new uint[0]; - var singles = new HashSet(); - var cidrs = new HashSet(); - AddContent(content, singles, cidrs, noBogon, noBogon); - - var ranges = BuildRanges(singles, cidrs); - var result = new List(); - - for (int i = 0; i < ranges.Count; i++) - { - ulong lo = ranges.Start[i], hi = ranges.End[i]; - - while (lo <= hi) - { - int bits = 32; - while (bits > 0) - { - ulong size = 1UL << (32 - (bits - 1)); - if ((lo % size) != 0UL) break; - if (lo + size - 1UL > hi) break; - bits--; - } - - result.Add(bits == 32 ? FormatIp((uint)lo) : FormatCidr((uint)lo, bits)); - lo += 1UL << (32 - bits); - } - } - - return result.ToArray(); - } - - static int WriteOctet(char[] buf, int pos, uint v) - { - if (v >= 100) { buf[pos++] = (char)('0' + v / 100); buf[pos++] = (char)('0' + (v / 10) % 10); } - else if (v >= 10) { buf[pos++] = (char)('0' + v / 10); } - buf[pos++] = (char)('0' + v % 10); - return pos; - } - - // Writes the whole blocklist in one streamed pass so we never materialize millions of strings. - // Header first (the shard's reload detector requires it), then singles, then CIDRs. LF line endings. - public static void Write(string path, string header, HashSet singles, HashSet cidrs) - { - using (var w = new StreamWriter(path, false, new UTF8Encoding(false), 1 << 20)) - { - w.Write(header); w.Write('\n'); - - char[] buf = new char[20]; - foreach (uint v in singles) - { - int p = 0; - p = WriteOctet(buf, p, (v >> 24) & 255); buf[p++] = '.'; - p = WriteOctet(buf, p, (v >> 16) & 255); buf[p++] = '.'; - p = WriteOctet(buf, p, (v >> 8) & 255); buf[p++] = '.'; - p = WriteOctet(buf, p, v & 255); buf[p++] = '\n'; - w.Write(buf, 0, p); - } - - foreach (string c in cidrs) { w.Write(c); w.Write('\n'); } - } - } -} -'@ - -# --------------------------------------------------------------------------------------------------------- -# Feed set -- thin, non-overlapping. See .DESCRIPTION for why each is kept and what was dropped as redundant. -# romainmarcoux's "full" set is sharded; only aa..ad carry data today (ae.. are empty placeholders). -# A 404/empty shard is skipped, so extend this list if upstream grows the shard count. -# --------------------------------------------------------------------------------------------------------- -$rmBase = 'https://raw.githubusercontent.com/romainmarcoux/malicious-ip/main/full-300k-' -$rmShards = @('aa','ab','ac','ad') | ForEach-Object { $rmBase + $_ + '.txt' } - -$AllFeeds = @( - [pscustomobject]@{ Name = 'bitwire-it'; Urls = @('https://raw.githubusercontent.com/bitwire-it/ipblocklist/main/inbound.txt') } - [pscustomobject]@{ Name = 'romainmarcoux'; Urls = $rmShards } - [pscustomobject]@{ Name = 'sentinel-turris';Urls = @('https://view.sentinel.turris.cz/greylist-data/greylist-latest.csv') } - [pscustomobject]@{ Name = 'firehol-level1'; Urls = @('https://raw.githubusercontent.com/firehol/blocklist-ipsets/master/firehol_level1.netset') } -) - -# Anonymizer / relay lists subtracted only when -ExcludeAnonymizers is set (Tor exits, open proxies, VPN tunnels). -$AnonFeeds = @( - 'https://raw.githubusercontent.com/borestad/firehol-mirror/refs/heads/main/tor_exits.ipset' - 'https://raw.githubusercontent.com/borestad/firehol-mirror/refs/heads/main/sslproxies_7d.ipset' - 'https://raw.githubusercontent.com/borestad/firehol-mirror/refs/heads/main/socks_proxy_7d.ipset' - 'https://raw.githubusercontent.com/ShadowWhisperer/IPs/master/Lists/Tunnels' -) - -if ($Feeds) { - $AllFeeds = $AllFeeds | Where-Object { $Feeds -contains $_.Name } - if (-not $AllFeeds) { throw "No feeds matched -Feeds." } -} - -# --------------------------------------------------------------------------------------------------------- -# Reserved / bogon ranges -- never valid attacker SOURCE IPs; always filtered. Built once as uint32 arrays. -# --------------------------------------------------------------------------------------------------------- -function ConvertTo-IPv4UInt { - param([string]$s) - $a = $s.Split('.') - if ($a.Length -ne 4) { return $null } - $v = [uint32]0 - foreach ($o in $a) { - $n = 0 - if (-not [int]::TryParse($o, [ref]$n) -or $n -lt 0 -or $n -gt 255) { return $null } - $v = ($v -shl 8) -bor [uint32]$n - } - return $v -} - -$bogonCidrs = '0.0.0.0/8','10.0.0.0/8','100.64.0.0/10','127.0.0.0/8','169.254.0.0/16','172.16.0.0/12', - '192.0.0.0/24','192.0.2.0/24','192.168.0.0/16','198.18.0.0/15','198.51.100.0/24', - '203.0.113.0/24','224.0.0.0/3' # 224/3 covers multicast + reserved + 255.255.255.255 -$bogStart = [System.Collections.Generic.List[uint32]]::new() -$bogEnd = [System.Collections.Generic.List[uint32]]::new() -foreach ($c in $bogonCidrs) { - $p = $c.Split('/'); $base = ConvertTo-IPv4UInt $p[0]; $bits = [int]$p[1] - $size = [uint32]([Math]::Pow(2, 32 - $bits)) - $bogStart.Add($base); $bogEnd.Add([uint32]($base + $size - 1)) -} -$bogStart = $bogStart.ToArray(); $bogEnd = $bogEnd.ToArray() - -# --------------------------------------------------------------------------------------------------------- -# Streaming download with a live progress bar (Write-Progress) so large feeds show real byte progress. -# --------------------------------------------------------------------------------------------------------- -function Get-Url { - param([string]$Url, [string]$Label) - $req = [System.Net.HttpWebRequest]::Create($Url) - $req.UserAgent = $UA; $req.Timeout = 120000; $req.ReadWriteTimeout = 120000 - $resp = $req.GetResponse() - try { - $total = $resp.ContentLength - $stream = $resp.GetResponseStream() - $ms = New-Object System.IO.MemoryStream - $buf = New-Object byte[] (1MB) - $read = 0; $lastReport = 0 - while (($n = $stream.Read($buf, 0, $buf.Length)) -gt 0) { - $ms.Write($buf, 0, $n); $read += $n - if ($read - $lastReport -ge 2MB) { - $lastReport = $read - if ($total -gt 0) { - Write-Progress -Activity ("Downloading {0}" -f $Label) -PercentComplete ([int](100 * $read / $total)) ` - -Status ("{0:N1} / {1:N1} MB" -f ($read / 1MB), ($total / 1MB)) - } else { - Write-Progress -Activity ("Downloading {0}" -f $Label) -Status ("{0:N1} MB" -f ($read / 1MB)) - } - } - } - Write-Progress -Activity ("Downloading {0}" -f $Label) -Completed - return [System.Text.Encoding]::UTF8.GetString($ms.ToArray()) - } - finally { $resp.Close() } -} - -# --------------------------------------------------------------------------------------------------------- -# Refresh carve-out data from routing announcements. Runs here because it needs Get-Url and the compiled -# collapser. A failure leaves the existing file alone rather than truncating a working carve-out. -# --------------------------------------------------------------------------------------------------------- -if ($AddCarveout) { - $path = Join-Path $ConfigDir ("ip-allowlist-{0}.txt" -f $AddCarveout) - $prefixes = Get-CarveoutPrefixes -CarveoutAsn $Asn - - Write-AllowlistFile -Path $path -Lines (@(Get-CarveoutHeader -Name $AddCarveout -CarveoutAsn $Asn) + $prefixes) - Write-Host ("Created {0} carve-out from AS{1}: {2} prefixes -> {3}" -f $AddCarveout, $Asn, @($prefixes).Count, $path) - - if ($AllowPaths -notcontains $path) { $AllowPaths += $path } -} - -if ($RefreshCarveouts) { - foreach ($path in $AllowPaths) { - $carveoutAsn = Get-CarveoutAsn -Path $path - if ($carveoutAsn -le 0) { continue } # hand-written allowlist, not ours to rewrite - - try { - $name = [IO.Path]::GetFileNameWithoutExtension($path) -replace '^ip-allowlist-', '' - $prefixes = Get-CarveoutPrefixes -CarveoutAsn $carveoutAsn - Write-AllowlistFile -Path $path -Lines (@(Get-CarveoutHeader -Name $name -CarveoutAsn $carveoutAsn) + $prefixes) - Write-Host ("Refreshed {0} carve-out from AS{1}: {2} prefixes" -f $name, $carveoutAsn, @($prefixes).Count) - } - catch { - Write-Warning ("AS{0}: refresh failed ({1}) -- keeping the existing carve-out" -f $carveoutAsn, $_.Exception.Message) - } - } -} - -# --------------------------------------------------------------------------------------------------------- -# Collect every kept feed into ONE global set, timing each phase. -# --------------------------------------------------------------------------------------------------------- -$singles = [System.Collections.Generic.HashSet[uint32]]::new() -$cidrs = [System.Collections.Generic.HashSet[string]]::new() -$feedCount = 0 - -foreach ($feed in $AllFeeds) { - $before = $singles.Count + $cidrs.Count - $ok = $false - foreach ($url in $feed.Urls) { - try { - $dlSw = [System.Diagnostics.Stopwatch]::StartNew() - $content = Get-Url -Url $url -Label $feed.Name - $dlSw.Stop() - $mb = [Math]::Round($content.Length / 1MB, 1) - - $pSw = [System.Diagnostics.Stopwatch]::StartNew() - [void][BlocklistExporter]::AddContent($content, $singles, $cidrs, $bogStart, $bogEnd) - $pSw.Stop() - Write-Host (" [dl {0,6:N1}s / parse {1,5:N1}s] {2}" -f $dlSw.Elapsed.TotalSeconds, $pSw.Elapsed.TotalSeconds, ("{0} ({1} MB)" -f $feed.Name, $mb)) - $ok = $true - } - catch { Write-Warning ("{0}: {1} -- skipping shard ({2})" -f $feed.Name, $url, $_.Exception.Message) } - } - if ($ok) { - $feedCount++ - $delta = ($singles.Count + $cidrs.Count) - $before - Write-Host ("{0,-16} +{1,8} new (running total {2} ip / {3} cidr)`n" -f $feed.Name, $delta, $singles.Count, $cidrs.Count) - } - else { Write-Warning ("{0}: all sources failed -- skipping" -f $feed.Name) } -} - -# --------------------------------------------------------------------------------------------------------- -# Subtraction pass: the allowlist file, plus the anonymizer feeds when -ExcludeAnonymizers is set. Both are -# "never block these", so they share one set and one range-correct removal -- which is also the fix for the -# old anonymizer path, where CIDR entries were parsed and then never actually subtracted. -# -# Bogon filtering is deliberately NOT applied here: it exists to keep junk OUT of the blocklist, and running -# it over subtractive input would quietly discard exemptions instead (e.g. a shard exempting its own LAN). -# --------------------------------------------------------------------------------------------------------- -$allowSingles = [System.Collections.Generic.HashSet[uint32]]::new() -$allowCidrs = [System.Collections.Generic.HashSet[string]]::new() -$noBogon = [uint32[]]::new(0) - -foreach ($p in $AllowPaths) { - try { - $allowText = Get-Content -LiteralPath $p -Raw -ErrorAction Stop - if (-not $allowText) { continue } - - $before = $allowSingles.Count + $allowCidrs.Count - [void][BlocklistExporter]::AddContent($allowText, $allowSingles, $allowCidrs, $noBogon, $noBogon) - Write-Host (" allowlist {0,-28} +{1} entr(ies)" -f (Split-Path -Leaf $p), (($allowSingles.Count + $allowCidrs.Count) - $before)) - } - catch { Write-Warning ("Could not read allowlist {0}: {1}" -f $p, $_.Exception.Message) } -} - -if ($ExcludeAnonymizers) { - foreach ($url in $AnonFeeds) { - try { [void][BlocklistExporter]::AddContent((Get-Url -Url $url -Label 'anonymizers'), $allowSingles, $allowCidrs, $noBogon, $noBogon) } - catch { Write-Warning ("anonymizer list {0}: {1}" -f $url, $_.Exception.Message) } - } -} - -$allowCount = $allowSingles.Count + $allowCidrs.Count -if ($allowCount -gt 0) { - $aSw = [System.Diagnostics.Stopwatch]::StartNew() - $ranges = [BlocklistExporter]::BuildRanges($allowSingles, $allowCidrs) - $res = [BlocklistExporter]::ApplyAllowlist($singles, $cidrs, $ranges) - $aSw.Stop() - - Write-Host ("Allowlist: {0} entr(ies) -> {1} ranges; removed {2} IPs, dropped {3} CIDRs, split {4} into {5} ({6:N1}s)" -f ` - $allowCount, $ranges.Count, $res.SinglesRemoved, $res.CidrsDropped, $res.CidrsSplit, $res.EntriesAdded, $aSw.Elapsed.TotalSeconds) -} - -$total = $singles.Count + $cidrs.Count -Write-Host ("Merged {0} feed(s): {1} unique single IPs + {2} unique CIDRs (bogon-filtered) in {3:N1}s." -f ` - $feedCount, $singles.Count, $cidrs.Count, $totalSw.Elapsed.TotalSeconds) - -if ($DryRun) { - Write-Host ("DRY RUN: nothing written (would have written {0} entries to {1})." -f $total, $OutFile) - return -} - -# A partial feed outage must not silently shrink the shard's blocklist to nothing; keep the last good file. -if ($total -eq 0) { throw "No entries parsed -- refusing to overwrite '$OutFile' with an empty list." } - -# --------------------------------------------------------------------------------------------------------- -# Write to a .tmp sibling and swap it into place, so the shard (which reads the whole file on a change) -# never observes a half-written list. One rename does it whether or not a list is already there. -# --------------------------------------------------------------------------------------------------------- -$outDir = Split-Path -Parent $OutFile -if ($outDir -and -not (Test-Path -LiteralPath $outDir -PathType Container)) { - New-Item -ItemType Directory -Path $outDir -Force | Out-Null -} - -# InvariantCulture: ':' is the culture-defined time separator in a custom format string, and the -# header is a machine-read marker the shard compares verbatim. -$generated = [DateTime]::UtcNow.ToString('yyyy-MM-ddTHH:mm:ssZ', [Globalization.CultureInfo]::InvariantCulture) -# The shard's header reader is token-based and ignores tokens it does not know, so `allow=` is additive -- -# it is here so an operator can tell from the file alone whether a carve-out was in effect when it was built. -$header = "# modernuo-blocklist generated=$generated count=$total ipv4=$($singles.Count) cidr=$($cidrs.Count) feeds=$feedCount allow=$allowCount" - -$tmp = $OutFile + '.tmp' -$wSw = [System.Diagnostics.Stopwatch]::StartNew() -try { - [BlocklistExporter]::Write($tmp, $header, $singles, $cidrs) - # One atomic rename over the destination on every platform: MoveFileEx REPLACE_EXISTING on - # Windows, rename(2) on Linux and macOS. - [IO.File]::Move($tmp, $OutFile, $true) -} -finally { - # Never leave a partial .tmp next to a live blocklist for the next run to trip over. - if (Test-Path -LiteralPath $tmp -PathType Leaf) { Remove-Item -LiteralPath $tmp -Force -ErrorAction SilentlyContinue } -} -$wSw.Stop() - -$sizeMb = [Math]::Round((Get-Item -LiteralPath $OutFile).Length / 1MB, 1) -Write-Host ("`nWrote {0} entries ({1} MB) to {2} in {3:N1}s (total {4:N1}s). generated={5}" -f ` - $total, $sizeMb, $OutFile, $wSw.Elapsed.TotalSeconds, $totalSw.Elapsed.TotalSeconds, $generated) -Write-Host "The shard picks this up on its next reloadInterval poll; no restart needed."