Compare commits
51 commits
feat/objec
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 25a2aa03c5 | |||
|
|
e52d54b7da | ||
|
|
708a354337 | ||
|
|
d3bf283e2d | ||
|
|
547c2ea0fa | ||
|
|
c9875e7f64 | ||
|
|
e07416902a | ||
|
|
4420872b22 | ||
|
|
38c74a968b | ||
|
|
e7f85d404d | ||
|
|
8e39da2810 | ||
|
|
2935eafe24 | ||
|
|
b992c7b955 | ||
|
|
b042edcf0b | ||
|
|
73f9688083 | ||
|
|
126a10ce53 | ||
|
|
541dbc5ac5 | ||
|
|
971d7b6a77 | ||
|
|
fd27b7a3c9 | ||
|
|
be3a08513f | ||
|
|
2dbaa87377 | ||
|
|
240118340e | ||
|
|
9b35b39d0d | ||
|
|
55ac2c3d98 | ||
|
|
bd79cb7759 | ||
|
|
5ce0f1e92b | ||
|
|
1bc83339bb | ||
|
|
c1442aff3e | ||
|
|
0628902644 | ||
|
|
6d846b11e5 | ||
|
|
a7e65aab01 | ||
|
|
cce035f1c3 | ||
|
|
f33bcd6006 | ||
|
|
64e6fe5da8 | ||
|
|
b2c59191bd | ||
|
|
23dc6649a0 | ||
|
|
246f077778 | ||
|
|
6d81077772 | ||
|
|
86df62fd3e | ||
|
|
aae173a797 | ||
|
|
b8d3fec59a | ||
|
|
967ddf48fa | ||
|
|
294dcd94a0 | ||
|
|
c909ed1f2f | ||
|
|
1a9cec1dbb | ||
|
|
9c11ccdb80 | ||
|
|
c39454137e | ||
|
|
bec4cfa910 | ||
|
|
e4827fc57b | ||
|
|
1e97ed50f6 | ||
|
|
858c1d18bc |
398 changed files with 22159 additions and 5722 deletions
|
|
@ -3,7 +3,7 @@
|
||||||
"isRoot": true,
|
"isRoot": true,
|
||||||
"tools": {
|
"tools": {
|
||||||
"modernuoschemagenerator": {
|
"modernuoschemagenerator": {
|
||||||
"version": "2.14.3",
|
"version": "4.0.0",
|
||||||
"commands": [
|
"commands": [
|
||||||
"ModernUOSchemaGenerator"
|
"ModernUOSchemaGenerator"
|
||||||
]
|
]
|
||||||
|
|
|
||||||
38
.github/workflows/build-test.yml
vendored
38
.github/workflows/build-test.yml
vendored
|
|
@ -40,13 +40,13 @@ jobs:
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0 # avoid shallow clone so nbgv can do its work.
|
fetch-depth: 0 # avoid shallow clone so nbgv can do its work.
|
||||||
- name: Install .NET
|
- name: Install .NET
|
||||||
uses: actions/setup-dotnet@v5
|
uses: actions/setup-dotnet@v6
|
||||||
with:
|
with:
|
||||||
global-json-file: global.json
|
global-json-file: global.json
|
||||||
- name: Install Prerequisites
|
- name: Install Prerequisites
|
||||||
run: |
|
run: |
|
||||||
brew update
|
brew update
|
||||||
brew install icu4c libdeflate zstd argon2
|
brew install icu4c libdeflate argon2
|
||||||
- name: Set Library Path
|
- name: Set Library Path
|
||||||
run: echo "DYLD_LIBRARY_PATH=/opt/homebrew/lib:$DYLD_LIBRARY_PATH" >> $GITHUB_ENV
|
run: echo "DYLD_LIBRARY_PATH=/opt/homebrew/lib:$DYLD_LIBRARY_PATH" >> $GITHUB_ENV
|
||||||
- name: Build
|
- name: Build
|
||||||
|
|
@ -64,7 +64,7 @@ jobs:
|
||||||
fi
|
fi
|
||||||
- name: Upload test results on failure
|
- name: Upload test results on failure
|
||||||
if: failure()
|
if: failure()
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v7
|
||||||
with:
|
with:
|
||||||
name: TestResults-${{ matrix.name }}
|
name: TestResults-${{ matrix.name }}
|
||||||
path: ./TestResults
|
path: ./TestResults
|
||||||
|
|
@ -124,17 +124,41 @@ jobs:
|
||||||
dnf config-manager --set-enabled crb
|
dnf config-manager --set-enabled crb
|
||||||
dnf install -y epel-release
|
dnf install -y epel-release
|
||||||
if: ${{ matrix.epel }}
|
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
|
- name: Install Prerequisites using dnf
|
||||||
run: dnf makecache --refresh && dnf install -y findutils libicu libdeflate-devel zstd libargon2-devel liburing-devel
|
run: dnf makecache --refresh && dnf install -y findutils libicu libdeflate libargon2 tzdata
|
||||||
if: ${{ matrix.packageManager == 'dnf' }}
|
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
|
- name: Install Prerequisites using apt
|
||||||
run: apt-get update -y && apt-get install -y curl libicu-dev libdeflate-dev zstd libargon2-dev tzdata liburing-dev
|
run: apt-get update -y && apt-get install -y curl '^libicu[0-9]+$' libdeflate0 libargon2-1 tzdata
|
||||||
if: ${{ matrix.packageManager == 'apt' }}
|
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
|
- uses: actions/checkout@v7
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0 # avoid shallow clone so nbgv can do its work.
|
fetch-depth: 0 # avoid shallow clone so nbgv can do its work.
|
||||||
- name: Install .NET
|
- name: Install .NET
|
||||||
uses: actions/setup-dotnet@v5
|
uses: actions/setup-dotnet@v6
|
||||||
with:
|
with:
|
||||||
global-json-file: global.json
|
global-json-file: global.json
|
||||||
- name: Build
|
- name: Build
|
||||||
|
|
@ -150,7 +174,7 @@ jobs:
|
||||||
fi
|
fi
|
||||||
- name: Upload test results on failure
|
- name: Upload test results on failure
|
||||||
if: failure()
|
if: failure()
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v7
|
||||||
with:
|
with:
|
||||||
name: TestResults-${{ matrix.name }}
|
name: TestResults-${{ matrix.name }}
|
||||||
path: ./TestResults
|
path: ./TestResults
|
||||||
|
|
|
||||||
2
.github/workflows/build-tool-release.yml
vendored
2
.github/workflows/build-tool-release.yml
vendored
|
|
@ -37,7 +37,7 @@ jobs:
|
||||||
fetch-depth: 0 # Full clone required for Nerdbank.GitVersioning
|
fetch-depth: 0 # Full clone required for Nerdbank.GitVersioning
|
||||||
|
|
||||||
- name: Install .NET
|
- name: Install .NET
|
||||||
uses: actions/setup-dotnet@v5
|
uses: actions/setup-dotnet@v6
|
||||||
with:
|
with:
|
||||||
global-json-file: global.json
|
global-json-file: global.json
|
||||||
|
|
||||||
|
|
|
||||||
2
.github/workflows/create-release.yml
vendored
2
.github/workflows/create-release.yml
vendored
|
|
@ -15,7 +15,7 @@ jobs:
|
||||||
fetch-depth: 0 # avoid shallow clone so nbgv can do its work.
|
fetch-depth: 0 # avoid shallow clone so nbgv can do its work.
|
||||||
token: ${{ secrets.PERSONAL_ACCESS_TOKEN }}
|
token: ${{ secrets.PERSONAL_ACCESS_TOKEN }}
|
||||||
- name: Install .NET
|
- name: Install .NET
|
||||||
uses: actions/setup-dotnet@v5
|
uses: actions/setup-dotnet@v6
|
||||||
with:
|
with:
|
||||||
global-json-file: global.json
|
global-json-file: global.json
|
||||||
- name: Compute version
|
- name: Compute version
|
||||||
|
|
|
||||||
21
.gitignore
vendored
21
.gitignore
vendored
|
|
@ -1,4 +1,5 @@
|
||||||
# Distribution Files
|
# Distribution Files
|
||||||
|
/Distribution/Data/Files
|
||||||
/Distribution/Logger
|
/Distribution/Logger
|
||||||
/Distribution/Logger.*
|
/Distribution/Logger.*
|
||||||
/Distribution/ModernUO
|
/Distribution/ModernUO
|
||||||
|
|
@ -9,16 +10,30 @@
|
||||||
/Distribution/bsdtar
|
/Distribution/bsdtar
|
||||||
/Distribution/Configuration/antimacro.json
|
/Distribution/Configuration/antimacro.json
|
||||||
/Distribution/Configuration/assistants.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/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/modernuo.json
|
||||||
/Distribution/Configuration/email-settings.json
|
/Distribution/Configuration/email-settings.json
|
||||||
/Distribution/Configuration/throttles.json
|
/Distribution/Configuration/throttles.json
|
||||||
/Distribution/Configuration/tot.json
|
/Distribution/Configuration/tot.json
|
||||||
|
/Distribution/Data/Pathfinding
|
||||||
/Distribution/Logs
|
/Distribution/Logs
|
||||||
/Distribution/Archives
|
/Distribution/Archives
|
||||||
/Distribution/Backups
|
/Distribution/Backups
|
||||||
/Distribution/Saves
|
/Distribution/Saves
|
||||||
/Distribution/docs
|
/Distribution/docs
|
||||||
|
/docs/
|
||||||
/Distribution/temp
|
/Distribution/temp
|
||||||
/Distribution/*.dylib
|
/Distribution/*.dylib
|
||||||
/Distribution/*.so
|
/Distribution/*.so
|
||||||
|
|
@ -46,5 +61,7 @@
|
||||||
/packages/*
|
/packages/*
|
||||||
/Distribution/Configuration/server-access.json
|
/Distribution/Configuration/server-access.json
|
||||||
|
|
||||||
# BuildTool native binaries (downloaded from GitHub Releases)
|
# BuildTool native binaries (downloaded from GitHub Releases).
|
||||||
/tools/
|
# Ignore everything under tools/ except the operator scripts checked in below.
|
||||||
|
/tools/*
|
||||||
|
!/tools/Export-IpBlocklist.ps1
|
||||||
|
|
|
||||||
26
CLAUDE.md
26
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`
|
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;`)
|
2. **No `Console.WriteLine`** — use `LogFactory.GetLogger(typeof(MyClass))` → `logger.Information(...)` (requires `using Server.Logging;`)
|
||||||
3. **No concurrency primitives** — no `lock`, `volatile`, `ConcurrentDictionary`, `Mutex`, etc. Server is single-threaded.
|
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`
|
||||||
4. **No `World.Mobiles`/`World.Items` iteration** — use spatial queries: `map.GetMobilesInRange<T>()`, `map.GetItemsInRange<T>()`
|
4. **No `World.Mobiles`/`World.Items` iteration** — use spatial queries: `map.GetMobilesInRange<T>()`, `map.GetItemsInRange<T>()`
|
||||||
5. **Clean up refs in `OnDelete()`/`OnAfterDelete()`** — null out `Item`/`Mobile` references
|
5. **Clean up refs in `OnDelete()`/`OnAfterDelete()`** — null out `Item`/`Mobile` references
|
||||||
6. **Cancel timers in `OnDelete()`/`OnAfterDelete()`** — call `_token.Cancel()` or `_timer?.Stop()`
|
6. **Cancel timers in `OnDelete()`/`OnAfterDelete()`** — call `_token.Cancel()` or `_timer?.Stop()`
|
||||||
7. **`STArrayPool<T>.Shared`** not `ArrayPool<T>.Shared` — single-threaded optimized, no locks
|
7. **`STArrayPool<T>.Shared`** not `ArrayPool<T>.Shared` — single-threaded optimized, no locks
|
||||||
8. **`PooledRefList<T>`** not `new List<T>()` on hot paths — zero GC pressure, stack-allocated ref struct
|
8. **`PooledRefList<T>`** not `new List<T>()` on hot paths — zero GC pressure, stack-allocated ref struct
|
||||||
9. **Serialization** — class must be `partial`, constructor needs `[Constructible]`, `TimerExecutionToken` must NOT have `[SerializableField]`. New classes: use `[SerializationGenerator(version)]` (omit `encoded`). When bumping versions, add `MigrateFrom(VXContent)` (X = previous version). Never modify `Deserialize(reader, version)` for version bumps — that method is only for pre-codegen legacy saves. When migrating from pre-codegen Serialize/Deserialize: pass `false` if old code used `reader.ReadInt()`, bump version +1, and keep old logic as `private void Deserialize(IGenericReader reader, int version)` → `dev-docs/runuo-migration-docs/02-serialization.md`
|
9. **Serialization** — class must be `partial`, constructor needs `[Constructible]`, `TimerExecutionToken` must NOT have `[SerializableField]`. New classes: use `[SerializationGenerator(version)]` (omit `encoded`). Setters that coerce/veto/run side effects: use `[SerializableField]` args `allowFieldChange: nameof(BoolRefMethod)` / `fieldChanged: nameof(OldNewMethod)` — reserve `[SerializableProperty]` for custom getters. Serializable `Timer` members declare `[DeserializeTimer(nameof(Method))]` on the field (anchored by default — downtime preserves remaining delay; `wallClock: true` = absolute; method runs only when a timer was running at save). Conditional writes: `[SaveFlag(nameof(Should), nameof(Default))]` on the field. When bumping versions, add `MigrateFrom(VXContent)` (X = previous version). Never modify `Deserialize(reader, version)` for version bumps — that method is only for pre-codegen legacy saves. When migrating from pre-codegen Serialize/Deserialize: pass `false` if old code used `reader.ReadInt()`, bump version +1, and keep old logic as `private void Deserialize(IGenericReader reader, int version)` → `dev-docs/serialization.md`, `dev-docs/runuo-migration-docs/02-serialization.md`
|
||||||
10. **No `Task.Run`/`new Thread()`** in game code — game logic is single-threaded event loop
|
10. **No `Task.Run`/`new Thread()` for game logic** (tandem with rule #3) — game logic is the single-threaded event loop. Backgrounding is allowed only for work that does not itself touch game state (external service calls, large-file parse). **Prove the need before adding a thread**: measure **on-loop** time, not wall-clock (frozen world is the cost, player latency is not), and gate on `Environment.ProcessorCount` — off-loading creates no CPU and buys nothing on 1–2 cores. New workers go in the vetted table in `dev-docs/threading-model.md` with their measurement. When such work must *feed* game logic: run the heavy/I/O part off-loop and `ConfigureAwait(false)` its awaits so a continuation never resumes on the loop and silently foregrounds heavy work; then hand the result back **explicitly** — publish an immutable snapshot swapped via a `volatile` reference (the loop reads it lock-free), or marshal the apply step with `Core.LoopContext.Post(() => …)`, re-validating in the continuation whatever may have changed while it ran. Never touch game state off-thread; never let the scheduler decide where the heavy work runs → `dev-docs/threading-model.md`
|
||||||
11. **Never assume era** — if code uses `Core.AOS`/`Core.SE`/etc., ask which expansion to target
|
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
|
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`
|
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,6 +28,8 @@ 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`
|
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`
|
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
|
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
|
## Dev-Docs Reference
|
||||||
|
|
||||||
|
|
@ -44,9 +46,14 @@ Apply these when writing or reviewing `.cs` files under `Projects/`.
|
||||||
| Commands & targeting | `dev-docs/commands-targeting.md` |
|
| Commands & targeting | `dev-docs/commands-targeting.md` |
|
||||||
| Event system | `dev-docs/events.md` |
|
| Event system | `dev-docs/events.md` |
|
||||||
| Threading model | `dev-docs/threading-model.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` |
|
| 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` |
|
| Configuration system | `dev-docs/configuration.md` |
|
||||||
| Networking & packets | `dev-docs/networking-packets.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` |
|
| Region system | `dev-docs/regions.md` |
|
||||||
| String handling & ValueStringBuilder | `dev-docs/string-handling.md` |
|
| String handling & ValueStringBuilder | `dev-docs/string-handling.md` |
|
||||||
| RunUO migration (overview) | `dev-docs/runuo-migration-docs/00-overview.md` |
|
| RunUO migration (overview) | `dev-docs/runuo-migration-docs/00-overview.md` |
|
||||||
|
|
@ -92,7 +99,18 @@ Then copy only the relevant skill files based on the task:
|
||||||
| Migrate persistence (WorldSave) | `migrate-from-runuo/migrate-persistence` |
|
| Migrate persistence (WorldSave) | `migrate-from-runuo/migrate-persistence` |
|
||||||
| Migrate multi-file system | `migrate-from-runuo/migrate-systems` |
|
| Migrate multi-file system | `migrate-from-runuo/migrate-systems` |
|
||||||
|
|
||||||
To enable a skill: `cp dev-docs/claude-skills/<name>.md .claude/skills/`
|
To enable a skill — Claude Code loads `.claude/skills/<name>/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/<name> && cp dev-docs/claude-skills/<name>.md .claude/skills/<name>/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/<name>" is the source path, not the name):
|
||||||
|
mkdir -p .claude/skills/<name> && cp dev-docs/claude-skills/migrate-from-runuo/<name>.md .claude/skills/<name>/SKILL.md
|
||||||
|
```
|
||||||
|
|
||||||
Migration skills reference the deep docs in `dev-docs/runuo-migration-docs/` and point to existing ModernUO skills for best practices.
|
Migration skills reference the deep docs in `dev-docs/runuo-migration-docs/` and point to existing ModernUO skills for best practices.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -63,8 +63,15 @@
|
||||||
<CodeAnalysisRuleSet>..\..\Rules.ruleset</CodeAnalysisRuleSet>
|
<CodeAnalysisRuleSet>..\..\Rules.ruleset</CodeAnalysisRuleSet>
|
||||||
<AnalysisLevel>latest</AnalysisLevel>
|
<AnalysisLevel>latest</AnalysisLevel>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
<!-- Event-loop time accounting, compiled out unless requested:
|
||||||
|
dotnet build -p:EventLoopProfiling=true
|
||||||
|
See dev-docs/debugging-event-loop.md. Placed last so it appends to whatever the
|
||||||
|
configuration groups above set DefineConstants to. -->
|
||||||
|
<PropertyGroup Condition="'$(EventLoopProfiling)'=='true'">
|
||||||
|
<DefineConstants>$(DefineConstants);EVENT_LOOP_PROFILING</DefineConstants>
|
||||||
|
</PropertyGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Serilog" Version="4.3.1" />
|
<PackageReference Include="Serilog" Version="4.4.0" />
|
||||||
<PackageReference Include="Serilog.Sinks.Async" Version="2.1.0" />
|
<PackageReference Include="Serilog.Sinks.Async" Version="2.1.0" />
|
||||||
<PackageReference Include="Serilog.Sinks.Console" Version="6.1.1" />
|
<PackageReference Include="Serilog.Sinks.Console" Version="6.1.1" />
|
||||||
<PackageReference Include="Nerdbank.GitVersioning" Condition="!Exists('packages.config')">
|
<PackageReference Include="Nerdbank.GitVersioning" Condition="!Exists('packages.config')">
|
||||||
|
|
|
||||||
|
|
@ -3,12 +3,16 @@
|
||||||
"level": "VerySlow",
|
"level": "VerySlow",
|
||||||
"active": 0.4,
|
"active": 0.4,
|
||||||
"passive": 0.8,
|
"passive": 0.8,
|
||||||
|
"activeMove": 0.9,
|
||||||
|
"passiveMove": 1.5,
|
||||||
"types": []
|
"types": []
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"level": "Slow",
|
"level": "Slow",
|
||||||
"active": 0.3,
|
"active": 0.3,
|
||||||
"passive": 0.6,
|
"passive": 0.6,
|
||||||
|
"activeMove": 0.6,
|
||||||
|
"passiveMove": 1.2,
|
||||||
"types": [
|
"types": [
|
||||||
"AntLion", "ArcticOgreLord", "BogThing",
|
"AntLion", "ArcticOgreLord", "BogThing",
|
||||||
"Bogle", "BoneKnight", "EarthElemental",
|
"Bogle", "BoneKnight", "EarthElemental",
|
||||||
|
|
@ -28,6 +32,8 @@
|
||||||
"level": "Medium",
|
"level": "Medium",
|
||||||
"active": 0.25,
|
"active": 0.25,
|
||||||
"passive": 0.5,
|
"passive": 0.5,
|
||||||
|
"activeMove": 0.45,
|
||||||
|
"passiveMove": 1.05,
|
||||||
"types": [
|
"types": [
|
||||||
"AcidElemental", "AgapiteElemental", "Alligator",
|
"AcidElemental", "AgapiteElemental", "Alligator",
|
||||||
"AncientLich", "Betrayer", "Bird",
|
"AncientLich", "Betrayer", "Bird",
|
||||||
|
|
@ -108,6 +114,8 @@
|
||||||
"level": "Fast",
|
"level": "Fast",
|
||||||
"active": 0.2,
|
"active": 0.2,
|
||||||
"passive": 0.4,
|
"passive": 0.4,
|
||||||
|
"activeMove": 0.3,
|
||||||
|
"passiveMove": 0.9,
|
||||||
"types": [
|
"types": [
|
||||||
"LordOaks", "Silvani", "AirElemental",
|
"LordOaks", "Silvani", "AirElemental",
|
||||||
"AncientWyrm", "Balron", "BladeSpirits",
|
"AncientWyrm", "Balron", "BladeSpirits",
|
||||||
|
|
@ -139,6 +147,8 @@
|
||||||
"level": "VeryFast",
|
"level": "VeryFast",
|
||||||
"active": 0.125,
|
"active": 0.125,
|
||||||
"passive": 0.30,
|
"passive": 0.30,
|
||||||
|
"activeMove": 0.125,
|
||||||
|
"passiveMove": 0.6,
|
||||||
"types": [
|
"types": [
|
||||||
"Barracoon", "Mephitis", "Neira",
|
"Barracoon", "Mephitis", "Neira",
|
||||||
"Rikktor", "Semidar", "EnergyVortex",
|
"Rikktor", "Semidar", "EnergyVortex",
|
||||||
|
|
|
||||||
|
|
@ -14,4 +14,11 @@ public sealed class BuildOptions
|
||||||
public string? Arch { get; set; }
|
public string? Arch { get; set; }
|
||||||
public bool SkipPrereqs { get; set; }
|
public bool SkipPrereqs { get; set; }
|
||||||
public bool Interactive { get; set; }
|
public bool Interactive { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
public bool CheckPrereqsOnly { get; set; }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,5 @@
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Spectre.Console" Version="0.57.2" />
|
<PackageReference Include="Spectre.Console" Version="0.57.2" />
|
||||||
<PackageReference Update="Serilog" Version="4.4.0" />
|
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
</Project>
|
</Project>
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
using BuildTool.Platform;
|
using BuildTool.Platform;
|
||||||
using BuildTool.Publishing;
|
using BuildTool.Publishing;
|
||||||
|
|
||||||
|
|
@ -31,8 +32,10 @@ public static class NativeLibraryChecker
|
||||||
"Linux",
|
"Linux",
|
||||||
[
|
[
|
||||||
".NET 10 Runtime — https://dotnet.microsoft.com/download/dotnet/10.0",
|
".NET 10 Runtime — https://dotnet.microsoft.com/download/dotnet/10.0",
|
||||||
"Debian/Ubuntu: sudo apt-get install -y libicu-dev libdeflate-dev zstd libargon2-dev liburing-dev",
|
"Debian/Ubuntu: sudo apt-get install -y libdeflate0 libargon2-1 libicuNN tzdata",
|
||||||
"Fedora/RHEL: sudo dnf install -y libicu libdeflate-devel zstd libargon2-devel liburing-devel",
|
" (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",
|
||||||
"CentOS: Also requires epel-release and CRB enabled"
|
"CentOS: Also requires epel-release and CRB enabled"
|
||||||
]
|
]
|
||||||
),
|
),
|
||||||
|
|
@ -176,82 +179,59 @@ public static class NativeLibraryChecker
|
||||||
return results;
|
return results;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
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<PrerequisiteResult> CheckLinux(PlatformInfo platform)
|
private static List<PrerequisiteResult> CheckLinux(PlatformInfo platform)
|
||||||
{
|
|
||||||
return platform.PackageManager switch
|
|
||||||
{
|
|
||||||
PackageManager.Apt => CheckLinuxApt(),
|
|
||||||
PackageManager.Dnf => CheckLinuxDnf(platform),
|
|
||||||
_ => CheckLinuxGeneric(platform)
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private static List<PrerequisiteResult> CheckLinuxApt()
|
|
||||||
{
|
{
|
||||||
var results = new List<PrerequisiteResult>();
|
var results = new List<PrerequisiteResult>();
|
||||||
var packages = new[] { "libicu-dev", "libdeflate-dev", "zstd", "libargon2-dev", "liburing-dev" };
|
|
||||||
var missing = new List<string>();
|
var missing = new List<string>();
|
||||||
|
|
||||||
foreach (var package in packages)
|
foreach (var (name, minSoVersion, maxSoVersion) in _linuxLibraries)
|
||||||
{
|
{
|
||||||
var result = ProcessRunner.RunCaptured("dpkg", $"-l {package}");
|
var found = CanLoad(name, minSoVersion, maxSoVersion);
|
||||||
var installed = result.Success && result.StandardOutput.Contains("ii");
|
|
||||||
|
|
||||||
if (!installed)
|
if (!found)
|
||||||
{
|
{
|
||||||
missing.Add(package);
|
missing.Add(name);
|
||||||
}
|
}
|
||||||
|
|
||||||
results.Add(new PrerequisiteResult
|
results.Add(new PrerequisiteResult
|
||||||
{
|
{
|
||||||
Name = package,
|
Name = name,
|
||||||
Passed = installed,
|
Passed = found,
|
||||||
Details = installed ? "Installed" : "Not installed"
|
Details = found ? "Found" : "Not found"
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (missing.Count > 0)
|
var hasTimeZoneData = HasTimeZoneData();
|
||||||
|
if (!hasTimeZoneData)
|
||||||
{
|
{
|
||||||
results.Add(new PrerequisiteResult
|
missing.Add("tzdata");
|
||||||
{
|
|
||||||
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)}"
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return results;
|
results.Add(new PrerequisiteResult
|
||||||
}
|
|
||||||
|
|
||||||
private static List<PrerequisiteResult> CheckLinuxDnf(PlatformInfo platform)
|
|
||||||
{
|
|
||||||
var results = new List<PrerequisiteResult>();
|
|
||||||
var packages = new[] { "libicu", "libdeflate-devel", "zstd", "libargon2-devel", "liburing-devel" };
|
|
||||||
var missing = new List<string>();
|
|
||||||
|
|
||||||
foreach (var package in packages)
|
|
||||||
{
|
{
|
||||||
var result = ProcessRunner.RunCaptured("rpm", $"-q {package}");
|
Name = "tzdata",
|
||||||
var installed = result.Success;
|
Passed = hasTimeZoneData,
|
||||||
|
Details = hasTimeZoneData ? "Found" : "Not found — every zone except UTC will throw"
|
||||||
|
});
|
||||||
|
|
||||||
if (!installed)
|
if (missing.Count == 0)
|
||||||
{
|
{
|
||||||
missing.Add(package);
|
return results;
|
||||||
}
|
|
||||||
|
|
||||||
results.Add(new PrerequisiteResult
|
|
||||||
{
|
|
||||||
Name = package,
|
|
||||||
Passed = installed,
|
|
||||||
Details = installed ? "Installed" : "Not installed"
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if this is CentOS (needs EPEL)
|
if (platform.DistroId?.Equals("centos", StringComparison.OrdinalIgnoreCase) == true)
|
||||||
var isCentOs = platform.DistroId?.Equals("centos", StringComparison.OrdinalIgnoreCase) == true;
|
|
||||||
if (isCentOs && missing.Count > 0)
|
|
||||||
{
|
{
|
||||||
results.Add(new PrerequisiteResult
|
results.Add(new PrerequisiteResult
|
||||||
{
|
{
|
||||||
|
|
@ -263,49 +243,131 @@ public static class NativeLibraryChecker
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (missing.Count > 0)
|
results.Add(new PrerequisiteResult
|
||||||
{
|
{
|
||||||
results.Add(new PrerequisiteResult
|
Name = "Install all missing",
|
||||||
{
|
Passed = false,
|
||||||
Name = "Install all missing",
|
IsWarning = true,
|
||||||
Passed = false,
|
Details = "Install the missing dependencies. The -dev/-devel packages are not required:",
|
||||||
IsWarning = true,
|
InstallCommand = BuildInstallCommand(platform, missing)
|
||||||
Details = "Run the following command to install all missing dependencies:",
|
});
|
||||||
InstallCommand = $"sudo dnf install -y {string.Join(' ', missing)}"
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return results;
|
return results;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static List<PrerequisiteResult> CheckLinuxGeneric(PlatformInfo platform)
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
private static bool HasTimeZoneData()
|
||||||
{
|
{
|
||||||
var results = new List<PrerequisiteResult>();
|
try
|
||||||
|
|
||||||
// Use ldconfig to check for shared libraries
|
|
||||||
var ldResult = ProcessRunner.RunCaptured("ldconfig", "-p");
|
|
||||||
var ldOutput = ldResult.Success ? ldResult.StandardOutput : "";
|
|
||||||
|
|
||||||
var libraries = new Dictionary<string, string>
|
|
||||||
{
|
{
|
||||||
["libicu"] = "libicuuc",
|
return TimeZoneInfo.GetSystemTimeZones().Count > 1;
|
||||||
["libdeflate"] = "libdeflate",
|
}
|
||||||
["zstd"] = "libzstd",
|
catch
|
||||||
["libargon2"] = "libargon2",
|
|
||||||
["liburing"] = "liburing"
|
|
||||||
};
|
|
||||||
|
|
||||||
foreach (var (name, soName) in libraries)
|
|
||||||
{
|
{
|
||||||
var found = ldOutput.Contains(soName, StringComparison.OrdinalIgnoreCase);
|
return false;
|
||||||
results.Add(new PrerequisiteResult
|
}
|
||||||
{
|
}
|
||||||
Name = name,
|
|
||||||
Passed = found,
|
/// <summary>
|
||||||
Details = found ? "Found" : "Not found — install using your package manager"
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
private static bool CanLoad(string library, int minSoVersion, int maxSoVersion)
|
||||||
|
{
|
||||||
|
if (TryLoadAndFree($"{library}.so"))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
return results;
|
for (var soVersion = maxSoVersion; soVersion >= minSoVersion; soVersion--)
|
||||||
|
{
|
||||||
|
if (TryLoadAndFree($"{library}.so.{soVersion}"))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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<string> 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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
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";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ using BuildTool.Interactive;
|
||||||
using BuildTool.Platform;
|
using BuildTool.Platform;
|
||||||
using BuildTool.Prerequisites;
|
using BuildTool.Prerequisites;
|
||||||
using BuildTool.Publishing;
|
using BuildTool.Publishing;
|
||||||
|
using Spectre.Console;
|
||||||
|
|
||||||
Console.OutputEncoding = Encoding.UTF8;
|
Console.OutputEncoding = Encoding.UTF8;
|
||||||
|
|
||||||
|
|
@ -36,6 +37,22 @@ options.Os ??= detectedPlatform.OsRid;
|
||||||
options.Arch ??= detectedPlatform.ArchRid;
|
options.Arch ??= detectedPlatform.ArchRid;
|
||||||
var rid = $"{options.Os}-{options.Arch}";
|
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
|
// Run prerequisite checks unless skipped
|
||||||
if (!options.SkipPrereqs)
|
if (!options.SkipPrereqs)
|
||||||
{
|
{
|
||||||
|
|
@ -108,6 +125,12 @@ static BuildOptions ParseArguments(string[] args)
|
||||||
hasNamedArgs = true;
|
hasNamedArgs = true;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
case "--check-prereqs":
|
||||||
|
{
|
||||||
|
options.CheckPrereqsOnly = true;
|
||||||
|
hasNamedArgs = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
case "--interactive":
|
case "--interactive":
|
||||||
{
|
{
|
||||||
options.Interactive = true;
|
options.Interactive = true;
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
using System;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Reflection;
|
using System.Reflection;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
|
|
@ -78,6 +79,15 @@ internal static class TestServerInitializer
|
||||||
Core.LoopContext = new EventLoopContext();
|
Core.LoopContext = new EventLoopContext();
|
||||||
Core.Expansion = Expansion.EJ;
|
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)
|
// Configure networking (initializes RingSocketManager for tests)
|
||||||
Server.Network.NetState.Configure();
|
Server.Network.NetState.Configure();
|
||||||
|
|
||||||
|
|
@ -87,8 +97,6 @@ internal static class TestServerInitializer
|
||||||
// Configure the world
|
// Configure the world
|
||||||
World.Configure();
|
World.Configure();
|
||||||
|
|
||||||
Timer.Init(0);
|
|
||||||
|
|
||||||
// Load the world
|
// Load the world
|
||||||
World.Load();
|
World.Load();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,17 +5,16 @@
|
||||||
<RootNamespace>Server.Tests</RootNamespace>
|
<RootNamespace>Server.Tests</RootNamespace>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.8.1" />
|
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.9.0" />
|
||||||
<PackageReference Include="xunit" Version="2.9.3" />
|
<PackageReference Include="xunit" Version="2.9.3" />
|
||||||
<PackageReference Include="xunit.SkippableFact" Version="1.5.61" />
|
<PackageReference Include="xunit.SkippableFact" Version="1.5.61" />
|
||||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.5">
|
<PackageReference Include="xunit.runner.visualstudio" Version="4.0.0">
|
||||||
<PrivateAssets>all</PrivateAssets>
|
<PrivateAssets>all</PrivateAssets>
|
||||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
</PackageReference>
|
</PackageReference>
|
||||||
<ProjectReference Include="..\Application\Application.csproj" />
|
<ProjectReference Include="..\Application\Application.csproj" />
|
||||||
<DataFiles Include="$(SolutionDir)\Distribution\Data\**" />
|
<DataFiles Include="$(SolutionDir)\Distribution\Data\**" />
|
||||||
<ProjectReference Include="..\UOContent\UOContent.csproj" />
|
<ProjectReference Include="..\UOContent\UOContent.csproj" />
|
||||||
<PackageReference Update="Serilog" Version="4.4.0" />
|
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
<!-- Copy native ioring.dll for tests -->
|
<!-- Copy native ioring.dll for tests -->
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ using Xunit;
|
||||||
|
|
||||||
namespace Server.Tests.Buffers;
|
namespace Server.Tests.Buffers;
|
||||||
|
|
||||||
|
[Collection("Sequential Server Tests")]
|
||||||
public class RawInterpolatedStringHandlerTests
|
public class RawInterpolatedStringHandlerTests
|
||||||
{
|
{
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|
|
||||||
|
|
@ -208,6 +208,272 @@ public class DecayRegistrationTests
|
||||||
item.Delete();
|
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.
|
// Dropping into a container must untrack; taking it back out to the ground must re-track.
|
||||||
[Fact]
|
[Fact]
|
||||||
public void ItemMovedIntoContainerThenBackToGround_IsRegisteredForDecay()
|
public void ItemMovedIntoContainerThenBackToGround_IsRegisteredForDecay()
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,95 @@
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Server.Tests;
|
||||||
|
|
||||||
|
[Collection("Sequential Server Tests")]
|
||||||
|
public class PlayerConstructedStackingTests
|
||||||
|
{
|
||||||
|
// PlayerConstructed is per-instance provenance, and stack operations were written when no
|
||||||
|
// item carried any. Merging keeps the receiver's copy of a field and splitting rebuilds one
|
||||||
|
// half from a fixed list of fields, so a flag that is not accounted for in both places is
|
||||||
|
// one that ordinary stacking can launder or erase.
|
||||||
|
|
||||||
|
// Stands in for a real stackable type. LiftItemDupe builds the remainder through the
|
||||||
|
// parameterless constructor and copies only a fixed list of fields onto it -- Stackable is
|
||||||
|
// not on that list -- so the remainder is only stackable if the type restores it the way
|
||||||
|
// every genuine stackable does.
|
||||||
|
private class StackableItem : Item
|
||||||
|
{
|
||||||
|
public StackableItem() => Stackable = true;
|
||||||
|
|
||||||
|
public StackableItem(Serial serial) : base(serial) => Stackable = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static StackableItem MakeStack(Serial serial, int amount, bool playerConstructed) =>
|
||||||
|
new(serial) { Amount = amount, PlayerConstructed = playerConstructed };
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(false)]
|
||||||
|
[InlineData(true)]
|
||||||
|
public void CanStackWith_IsTrueWhenProvenanceMatches(bool playerConstructed)
|
||||||
|
{
|
||||||
|
var first = MakeStack((Serial)0x1, 5, playerConstructed);
|
||||||
|
var second = MakeStack((Serial)0x2, 7, playerConstructed);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Assert.True(first.CanStackWith(second));
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
first.Delete();
|
||||||
|
second.Delete();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(false)]
|
||||||
|
[InlineData(true)]
|
||||||
|
public void LiftItemDupe_CopiesPlayerConstructedToRemainder(bool playerConstructed)
|
||||||
|
{
|
||||||
|
var stack = MakeStack((Serial)0x1, 10, playerConstructed);
|
||||||
|
Item remainder = null;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
remainder = Mobile.LiftItemDupe(stack, 4);
|
||||||
|
|
||||||
|
Assert.NotNull(remainder);
|
||||||
|
Assert.NotSame(stack, remainder);
|
||||||
|
Assert.Equal(4, stack.Amount);
|
||||||
|
Assert.Equal(6, remainder.Amount);
|
||||||
|
Assert.Equal(playerConstructed, remainder.PlayerConstructed);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
stack.Delete();
|
||||||
|
remainder?.Delete();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SplitHalvesRemainStackableWithEachOther()
|
||||||
|
{
|
||||||
|
// The two halves of a split must still be one pile's worth: if the split dropped the
|
||||||
|
// flag, the remainder would no longer stack back onto what it came from.
|
||||||
|
var stack = MakeStack((Serial)0x1, 10, true);
|
||||||
|
Item remainder = null;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
remainder = Mobile.LiftItemDupe(stack, 4);
|
||||||
|
Assert.NotNull(remainder);
|
||||||
|
|
||||||
|
Assert.True(stack.CanStackWith(remainder));
|
||||||
|
Assert.True(stack.StackWith(null, remainder, false));
|
||||||
|
Assert.Equal(10, stack.Amount);
|
||||||
|
Assert.True(stack.PlayerConstructed);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
stack.Delete();
|
||||||
|
remainder?.Delete();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
311
Projects/Server.Tests/Tests/Mobiles/DamageEntryTests.cs
Normal file
311
Projects/Server.Tests/Tests/Mobiles/DamageEntryTests.cs
Normal file
|
|
@ -0,0 +1,311 @@
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using Server.Collections;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Server.Tests;
|
||||||
|
|
||||||
|
[Collection("Sequential Server Tests")]
|
||||||
|
public class DamageEntryTests
|
||||||
|
{
|
||||||
|
private class TestMobile : Mobile
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
private class PetMobile : Mobile
|
||||||
|
{
|
||||||
|
public Mobile Master { get; set; }
|
||||||
|
|
||||||
|
public override Mobile GetDamageMaster(Mobile damagee) => Master;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<Mobile> Damagers(Mobile victim)
|
||||||
|
{
|
||||||
|
var result = new List<Mobile>();
|
||||||
|
foreach (var de in victim.DamageEntries)
|
||||||
|
{
|
||||||
|
result.Add(de.Damager);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void FreshMobile_HasNoEntries()
|
||||||
|
{
|
||||||
|
var m = new TestMobile();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Assert.Equal(0, m.DamageEntries.Count);
|
||||||
|
Assert.Null(m.FindMostRecentDamageEntry(true));
|
||||||
|
Assert.Null(m.FindLeastRecentDamageEntry(true));
|
||||||
|
Assert.Null(m.FindMostTotalDamageEntry(true));
|
||||||
|
Assert.Null(m.FindLeastTotalDamageEntry(true));
|
||||||
|
Assert.Null(m.FindDamageEntryFor(m));
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
m.Delete();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RegisterDamage_OrdersLeastRecentToMostRecent()
|
||||||
|
{
|
||||||
|
var victim = new TestMobile();
|
||||||
|
var a = new TestMobile();
|
||||||
|
var b = new TestMobile();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
victim.RegisterDamage(10, a);
|
||||||
|
victim.RegisterDamage(20, b);
|
||||||
|
victim.RegisterDamage(5, a); // a becomes most recent again
|
||||||
|
|
||||||
|
Assert.Equal(2, victim.DamageEntries.Count);
|
||||||
|
Assert.Equal(new[] { b, a }, Damagers(victim));
|
||||||
|
Assert.Equal(15, victim.FindDamageEntryFor(a).DamageGiven);
|
||||||
|
Assert.Same(a, victim.FindMostRecentDamager(true));
|
||||||
|
Assert.Same(b, victim.FindLeastRecentDamager(true));
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
victim.Delete();
|
||||||
|
a.Delete();
|
||||||
|
b.Delete();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void FindRecent_HonorsAllowSelf()
|
||||||
|
{
|
||||||
|
var victim = new TestMobile();
|
||||||
|
var a = new TestMobile();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
victim.RegisterDamage(10, a);
|
||||||
|
victim.RegisterDamage(10, victim); // self is most recent
|
||||||
|
|
||||||
|
Assert.Same(victim, victim.FindMostRecentDamager(true));
|
||||||
|
Assert.Same(a, victim.FindMostRecentDamager(false));
|
||||||
|
Assert.Same(a, victim.FindLeastRecentDamager(false));
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
victim.Delete();
|
||||||
|
a.Delete();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void FindLeastRecent_HonorsAllowSelf()
|
||||||
|
{
|
||||||
|
var victim = new TestMobile();
|
||||||
|
var a = new TestMobile();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
victim.RegisterDamage(10, victim); // self is least recent, so the head is the one to skip
|
||||||
|
victim.RegisterDamage(10, a);
|
||||||
|
|
||||||
|
Assert.Same(victim, victim.FindLeastRecentDamager(true));
|
||||||
|
Assert.Same(a, victim.FindLeastRecentDamager(false));
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
victim.Delete();
|
||||||
|
a.Delete();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void FindTotal_PicksByDamage_MostRecentWinsTies()
|
||||||
|
{
|
||||||
|
var victim = new TestMobile();
|
||||||
|
var a = new TestMobile();
|
||||||
|
var b = new TestMobile();
|
||||||
|
var c = new TestMobile();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
victim.RegisterDamage(30, a);
|
||||||
|
victim.RegisterDamage(30, b); // ties a; b is more recent
|
||||||
|
victim.RegisterDamage(1, c);
|
||||||
|
|
||||||
|
Assert.Same(b, victim.FindMostTotalDamager(true));
|
||||||
|
Assert.Same(c, victim.FindLeastTotalDamager(true));
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
victim.Delete();
|
||||||
|
a.Delete();
|
||||||
|
b.Delete();
|
||||||
|
c.Delete();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void FindLeastTotal_MostRecentWinsTies()
|
||||||
|
{
|
||||||
|
var victim = new TestMobile();
|
||||||
|
var a = new TestMobile();
|
||||||
|
var b = new TestMobile();
|
||||||
|
var c = new TestMobile();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
victim.RegisterDamage(30, a);
|
||||||
|
victim.RegisterDamage(5, b);
|
||||||
|
victim.RegisterDamage(5, c); // ties b for the minimum; c is more recent
|
||||||
|
|
||||||
|
Assert.Same(a, victim.FindMostTotalDamager(true));
|
||||||
|
Assert.Same(c, victim.FindLeastTotalDamager(true));
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
victim.Delete();
|
||||||
|
a.Delete();
|
||||||
|
b.Delete();
|
||||||
|
c.Delete();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Prune_RemovesExpiredPrefix_KeepsOrder()
|
||||||
|
{
|
||||||
|
var start = Core._now;
|
||||||
|
var victim = new TestMobile();
|
||||||
|
var a = new TestMobile();
|
||||||
|
var b = new TestMobile();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
victim.RegisterDamage(10, a);
|
||||||
|
|
||||||
|
Core._now = start + DamageEntry.ExpireDelay + TimeSpan.FromSeconds(1);
|
||||||
|
victim.RegisterDamage(10, b); // a is now expired, b is live
|
||||||
|
|
||||||
|
Assert.Equal(new[] { b }, Damagers(victim));
|
||||||
|
Assert.Null(victim.FindDamageEntryFor(a));
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
Core._now = start;
|
||||||
|
victim.Delete();
|
||||||
|
a.Delete();
|
||||||
|
b.Delete();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Prune_AllExpired_EmptiesList()
|
||||||
|
{
|
||||||
|
var start = Core._now;
|
||||||
|
var victim = new TestMobile();
|
||||||
|
var a = new TestMobile();
|
||||||
|
var b = new TestMobile();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
victim.RegisterDamage(10, a);
|
||||||
|
victim.RegisterDamage(10, b);
|
||||||
|
|
||||||
|
Core._now = start + DamageEntry.ExpireDelay + TimeSpan.FromSeconds(1);
|
||||||
|
|
||||||
|
Assert.Equal(0, victim.DamageEntries.Count);
|
||||||
|
Assert.Null(victim.FindMostRecentDamageEntry(true));
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
Core._now = start;
|
||||||
|
victim.Delete();
|
||||||
|
a.Delete();
|
||||||
|
b.Delete();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ClearDamageEntries_UnlinksEveryNode()
|
||||||
|
{
|
||||||
|
var victim = new TestMobile();
|
||||||
|
var a = new TestMobile();
|
||||||
|
var b = new TestMobile();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var ea = victim.RegisterDamage(10, a);
|
||||||
|
var eb = victim.RegisterDamage(10, b);
|
||||||
|
|
||||||
|
victim.ClearDamageEntries();
|
||||||
|
|
||||||
|
Assert.Equal(0, victim.DamageEntries.Count);
|
||||||
|
Assert.False(ea.OnLinkList);
|
||||||
|
Assert.False(eb.OnLinkList);
|
||||||
|
Assert.Null(ea.Next);
|
||||||
|
Assert.Null(ea.Previous);
|
||||||
|
Assert.Null(eb.Next);
|
||||||
|
Assert.Null(eb.Previous);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
victim.Delete();
|
||||||
|
a.Delete();
|
||||||
|
b.Delete();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void FullHitPoints_ClearsEntries()
|
||||||
|
{
|
||||||
|
var victim = new TestMobile();
|
||||||
|
var a = new TestMobile();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
victim.RawStr = 50; // HitsMax follows Str for a base Mobile
|
||||||
|
victim.Hits = 10;
|
||||||
|
victim.RegisterDamage(10, a);
|
||||||
|
Assert.Equal(1, victim.DamageEntries.Count);
|
||||||
|
|
||||||
|
// Also stops the HitsTimer the Hits = 10 write started, so the test leaves no timer behind.
|
||||||
|
victim.Hits = victim.HitsMax;
|
||||||
|
|
||||||
|
Assert.Equal(0, victim.DamageEntries.Count);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
victim.Delete();
|
||||||
|
a.Delete();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RegisterDamage_AccumulatesResponsibleMaster()
|
||||||
|
{
|
||||||
|
var victim = new TestMobile();
|
||||||
|
var master = new TestMobile();
|
||||||
|
var pet = new PetMobile { Master = master };
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
victim.RegisterDamage(10, pet);
|
||||||
|
var entry = victim.RegisterDamage(5, pet);
|
||||||
|
|
||||||
|
Assert.Same(pet, entry.Damager);
|
||||||
|
Assert.Equal(15, entry.DamageGiven);
|
||||||
|
Assert.NotNull(entry.Responsible);
|
||||||
|
Assert.Single(entry.Responsible);
|
||||||
|
Assert.Same(master, entry.Responsible[0].Damager);
|
||||||
|
Assert.Equal(15, entry.Responsible[0].DamageGiven);
|
||||||
|
Assert.False(entry.Responsible[0].OnLinkList); // sub-entries never join the main list
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
victim.Delete();
|
||||||
|
master.Delete();
|
||||||
|
pet.Delete();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
96
Projects/Server.Tests/Tests/Network/Bans/BanChannelTests.cs
Normal file
96
Projects/Server.Tests/Tests/Network/Bans/BanChannelTests.cs
Normal file
|
|
@ -0,0 +1,96 @@
|
||||||
|
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<IPAddress> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,44 @@
|
||||||
|
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<BanSettings>(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);
|
||||||
|
}
|
||||||
|
}
|
||||||
133
Projects/Server.Tests/Tests/Network/ConnectionFiltersTests.cs
Normal file
133
Projects/Server.Tests/Tests/Network/ConnectionFiltersTests.cs
Normal file
|
|
@ -0,0 +1,133 @@
|
||||||
|
/*************************************************************************
|
||||||
|
* 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 <http://www.gnu.org/licenses/>. *
|
||||||
|
*************************************************************************/
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
69
Projects/Server.Tests/Tests/Network/EventLoopIdleTests.cs
Normal file
69
Projects/Server.Tests/Tests/Network/EventLoopIdleTests.cs
Normal file
|
|
@ -0,0 +1,69 @@
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Server.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
[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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,137 +0,0 @@
|
||||||
|
|
||||||
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));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
116
Projects/Server.Tests/Tests/Network/ForeignProtocolTests.cs
Normal file
116
Projects/Server.Tests/Tests/Network/ForeignProtocolTests.cs
Normal file
|
|
@ -0,0 +1,116 @@
|
||||||
|
/*************************************************************************
|
||||||
|
* 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 <http://www.gnu.org/licenses/>. *
|
||||||
|
*************************************************************************/
|
||||||
|
|
||||||
|
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 _));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,86 @@
|
||||||
|
using System;
|
||||||
|
using Server.Network;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Server.Tests.Network;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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 <c>output.Length - 4</c>, which
|
||||||
|
/// underflows for those sizes and stops bounding the writes at all.
|
||||||
|
/// </summary>
|
||||||
|
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]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,145 @@
|
||||||
|
using System;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Server.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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".
|
||||||
|
/// </summary>
|
||||||
|
// 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
[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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
[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<InvalidOperationException>(() => _ = m.PropertyList);
|
||||||
|
#else
|
||||||
|
Assert.Null(Record.Exception(() => _ = m.PropertyList));
|
||||||
|
#endif
|
||||||
|
|
||||||
|
// Refused, not retried.
|
||||||
|
Assert.Equal(1, m.Builds);
|
||||||
|
m.Delete();
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
ObjectPropertyList.Enabled = wasEnabled;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -62,6 +62,21 @@ public class ObjectPropertyListSpanAddTests
|
||||||
Assert.Equal((1070722, "Custom"), entries[0]);
|
Assert.Equal((1070722, "Custom"), entries[0]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void HashFormat_OnlyMarksIntegers()
|
||||||
|
{
|
||||||
|
// Integer {value:#} emits the cliloc marker "#<value>".
|
||||||
|
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]
|
[Fact]
|
||||||
public void Add_TruncatesArgumentOverMaxLength()
|
public void Add_TruncatesArgumentOverMaxLength()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,76 @@
|
||||||
|
using System;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Server.Tests;
|
||||||
|
|
||||||
|
[Collection("Sequential Server Tests")]
|
||||||
|
public class AnchoredItemSerializationTests
|
||||||
|
{
|
||||||
|
private static byte[] SerializeItem(Item item)
|
||||||
|
{
|
||||||
|
var writer = new BufferWriter(new byte[256], true);
|
||||||
|
item.Serialize(writer);
|
||||||
|
return writer.Buffer[..(int)writer.Position];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Item v11 stores LastMoved and DecayResetTime as anchored time: the serialized bytes
|
||||||
|
/// are a function of item state only, not of when the save runs. Pre-v11 stored
|
||||||
|
/// minutes-since-moved and delta time, which rewrote the bytes on every save.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void ItemBytes_AreStable_AcrossSavesAtDifferentTimes()
|
||||||
|
{
|
||||||
|
var start = Core._now;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var item = new Item(0x1F13);
|
||||||
|
item.MoveToWorld(new Point3D(120, 100, 0), Map.Felucca);
|
||||||
|
item.RestartDecay();
|
||||||
|
|
||||||
|
var first = SerializeItem(item);
|
||||||
|
|
||||||
|
// A save hours later, with no state change, must produce identical bytes.
|
||||||
|
Core._now = start + TimeSpan.FromHours(5);
|
||||||
|
var second = SerializeItem(item);
|
||||||
|
|
||||||
|
Assert.Equal(first, second);
|
||||||
|
|
||||||
|
item.Delete();
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
Core._now = start;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Pre-v11 LastMoved was stored at whole-minute precision relative to the save time and
|
||||||
|
/// could never round-trip exactly. Anchored storage is absolute and exact.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void LastMovedAndDecayReset_RoundTripExactly()
|
||||||
|
{
|
||||||
|
var item = new Item(0x1F13);
|
||||||
|
item.MoveToWorld(new Point3D(121, 100, 0), Map.Felucca);
|
||||||
|
|
||||||
|
// Sub-minute precision that the old minutes encoding would have destroyed.
|
||||||
|
var moved = Core.Now - TimeSpan.FromSeconds(90.5) - TimeSpan.FromMilliseconds(123);
|
||||||
|
item.LastMoved = moved;
|
||||||
|
|
||||||
|
item.RestartDecay();
|
||||||
|
var decayReset = item.DecayResetTime;
|
||||||
|
Assert.NotEqual(default(DateTime), decayReset);
|
||||||
|
|
||||||
|
var bytes = SerializeItem(item);
|
||||||
|
|
||||||
|
var restored = new Item((Serial)0x7ffff123u);
|
||||||
|
restored.Deserialize(new BufferReader(bytes));
|
||||||
|
|
||||||
|
Assert.Equal(moved, restored.LastMoved);
|
||||||
|
Assert.Equal(decayReset, restored.DecayResetTime);
|
||||||
|
|
||||||
|
item.Delete();
|
||||||
|
}
|
||||||
|
}
|
||||||
189
Projects/Server.Tests/Tests/Serialization/AnchoredTimeTests.cs
Normal file
189
Projects/Server.Tests/Tests/Serialization/AnchoredTimeTests.cs
Normal file
|
|
@ -0,0 +1,189 @@
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.IO;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Server.Tests;
|
||||||
|
|
||||||
|
public class AnchoredTimeTests
|
||||||
|
{
|
||||||
|
private static (BufferWriter Writer, Func<TimeSpan, IGenericReader> Read) CreateRoundTrip()
|
||||||
|
{
|
||||||
|
var writer = new BufferWriter(new byte[64], true);
|
||||||
|
return (writer, shift => new BufferReader(writer.Buffer) { AnchoredTimeShift = shift });
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AnchoredTime_RoundTripsExactly_WithZeroShift()
|
||||||
|
{
|
||||||
|
var (writer, read) = CreateRoundTrip();
|
||||||
|
var value = new DateTime(2026, 8, 22, 12, 30, 0, DateTimeKind.Utc);
|
||||||
|
|
||||||
|
writer.WriteAnchoredTime(value);
|
||||||
|
|
||||||
|
Assert.Equal(value, read(TimeSpan.Zero).ReadAnchoredTime());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AnchoredTime_AppliesShiftOnRead()
|
||||||
|
{
|
||||||
|
var (writer, read) = CreateRoundTrip();
|
||||||
|
var value = new DateTime(2026, 8, 22, 12, 30, 0, DateTimeKind.Utc);
|
||||||
|
var shift = TimeSpan.FromHours(3);
|
||||||
|
|
||||||
|
writer.WriteAnchoredTime(value);
|
||||||
|
|
||||||
|
Assert.Equal(value + shift, read(shift).ReadAnchoredTime());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AnchoredTime_SentinelsPassThroughUnshifted()
|
||||||
|
{
|
||||||
|
var (writer, read) = CreateRoundTrip();
|
||||||
|
|
||||||
|
writer.WriteAnchoredTime(DateTime.MinValue);
|
||||||
|
writer.WriteAnchoredTime(DateTime.MaxValue);
|
||||||
|
|
||||||
|
var reader = read(TimeSpan.FromDays(2));
|
||||||
|
Assert.Equal(DateTime.MinValue, reader.ReadAnchoredTime());
|
||||||
|
Assert.Equal(DateTime.MaxValue, reader.ReadAnchoredTime());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AnchoredTime_SaturatesInsteadOfOverflowing()
|
||||||
|
{
|
||||||
|
var (writer, read) = CreateRoundTrip();
|
||||||
|
|
||||||
|
writer.WriteAnchoredTime(DateTime.MaxValue - TimeSpan.FromMinutes(1));
|
||||||
|
|
||||||
|
Assert.Equal(DateTime.MaxValue, read(TimeSpan.FromDays(1)).ReadAnchoredTime());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AnchoredTime_NormalizesLocalKindOnWrite()
|
||||||
|
{
|
||||||
|
var (writer, read) = CreateRoundTrip();
|
||||||
|
var local = new DateTime(2026, 8, 22, 12, 30, 0, DateTimeKind.Local);
|
||||||
|
|
||||||
|
writer.WriteAnchoredTime(local);
|
||||||
|
|
||||||
|
Assert.Equal(local.ToUniversalTime(), read(TimeSpan.Zero).ReadAnchoredTime());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal class AnchoredEntity : ISerializable
|
||||||
|
{
|
||||||
|
public AnchoredEntity(Serial serial) => Serial = serial;
|
||||||
|
|
||||||
|
public Serial Serial { get; }
|
||||||
|
public DateTime Created { get; set; } = DateTime.UtcNow;
|
||||||
|
public bool Deleted => false;
|
||||||
|
|
||||||
|
public DateTime LastRested { get; set; }
|
||||||
|
|
||||||
|
public void Delete()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Serialize(IGenericWriter writer) => writer.WriteAnchoredTime(LastRested);
|
||||||
|
|
||||||
|
public void Deserialize(IGenericReader reader) => LastRested = reader.ReadAnchoredTime();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Collection("Sequential Server Tests")]
|
||||||
|
public class AnchoredTimePersistenceTests
|
||||||
|
{
|
||||||
|
private class AnchoredPersistence : GenericEntityPersistence<AnchoredEntity>
|
||||||
|
{
|
||||||
|
public AnchoredPersistence(int priority) : base("AnchoredTrip", priority, 1, 0x7FFFFFFF)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The idx v5 header carries the save-start anchor; loading re-bases anchored timestamps
|
||||||
|
/// by the elapsed time since the save started, so downtime does not age them.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void SaveStartAnchor_RebasesAnchoredTimestampsAtLoad()
|
||||||
|
{
|
||||||
|
var previousAssemblies = AssemblyHandler.Assemblies;
|
||||||
|
AssemblyHandler.Assemblies = [.. previousAssemblies ?? [], typeof(AnchoredEntity).Assembly];
|
||||||
|
|
||||||
|
var source = new SerializationChunkSource();
|
||||||
|
var workers = new SerializationThreadWorker[2];
|
||||||
|
for (var i = 0; i < workers.Length; i++)
|
||||||
|
{
|
||||||
|
workers[i] = new SerializationThreadWorker(i, source);
|
||||||
|
workers[i].AllocateHeap();
|
||||||
|
}
|
||||||
|
|
||||||
|
var previousWorkers = World._threadWorkers;
|
||||||
|
World._threadWorkers = workers;
|
||||||
|
|
||||||
|
var previousSaveStart = World.SaveStartTime;
|
||||||
|
|
||||||
|
var persistence = new AnchoredPersistence(2100);
|
||||||
|
AnchoredPersistence loaded = null;
|
||||||
|
|
||||||
|
var dir = Path.Combine(Path.GetTempPath(), $"muo-anchored-{Guid.NewGuid():N}");
|
||||||
|
Directory.CreateDirectory(dir);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var lastRested = Core.Now - TimeSpan.FromMinutes(10);
|
||||||
|
var serial = (Serial)1u;
|
||||||
|
persistence.EntitiesBySerial[serial] = new AnchoredEntity(serial) { LastRested = lastRested };
|
||||||
|
persistence.RegisterType(typeof(AnchoredEntity));
|
||||||
|
|
||||||
|
// Pretend the save started two hours ago, as if the server had been down since.
|
||||||
|
var downtime = TimeSpan.FromHours(2);
|
||||||
|
World.SaveStartTime = Core.Now - downtime;
|
||||||
|
|
||||||
|
foreach (var worker in workers)
|
||||||
|
{
|
||||||
|
worker.Wake();
|
||||||
|
}
|
||||||
|
|
||||||
|
source.SetOwner(persistence);
|
||||||
|
Assert.True(persistence.TrySnapshotEntries(out var slotCount));
|
||||||
|
source.PushSlotRanges(persistence, slotCount);
|
||||||
|
|
||||||
|
source.Flush();
|
||||||
|
foreach (var worker in workers)
|
||||||
|
{
|
||||||
|
worker.Sleep();
|
||||||
|
}
|
||||||
|
|
||||||
|
persistence.WriteSnapshot(dir);
|
||||||
|
persistence.PostWorldSave();
|
||||||
|
|
||||||
|
loaded = new AnchoredPersistence(2101);
|
||||||
|
loaded.DeserializeIndexes(dir, null);
|
||||||
|
loaded.Deserialize(dir, null);
|
||||||
|
|
||||||
|
var entity = loaded.EntitiesBySerial[serial];
|
||||||
|
var expected = lastRested + downtime;
|
||||||
|
|
||||||
|
Assert.True(
|
||||||
|
(entity.LastRested - expected).Duration() <= TimeSpan.FromSeconds(30),
|
||||||
|
$"Anchored timestamp must re-base by the downtime; expected ~{expected}, got {entity.LastRested}."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
World.SaveStartTime = previousSaveStart;
|
||||||
|
persistence.Unregister();
|
||||||
|
loaded?.Unregister();
|
||||||
|
|
||||||
|
foreach (var worker in workers)
|
||||||
|
{
|
||||||
|
worker.Exit();
|
||||||
|
}
|
||||||
|
|
||||||
|
World._threadWorkers = previousWorkers;
|
||||||
|
AssemblyHandler.Assemblies = previousAssemblies;
|
||||||
|
Directory.Delete(dir, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
134
Projects/Server/Collections/SortedRangeIndex.cs
Normal file
134
Projects/Server/Collections/SortedRangeIndex.cs
Normal file
|
|
@ -0,0 +1,134 @@
|
||||||
|
/*************************************************************************
|
||||||
|
* 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 <http://www.gnu.org/licenses/>. *
|
||||||
|
*************************************************************************/
|
||||||
|
|
||||||
|
using System;
|
||||||
|
using System.Numerics;
|
||||||
|
|
||||||
|
namespace Server.Collections;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Immutable, allocation-lean membership index over a set of inclusive integer ranges. Ranges are
|
||||||
|
/// stored as two parallel arrays (<c>_mins</c>/<c>_maxs</c>) sorted by minimum and coalesced into
|
||||||
|
/// disjoint runs, so a single binary search decides membership. Coalescing is required for
|
||||||
|
/// correctness: <see cref="Contains"/> only inspects the rightmost run whose minimum is <= the
|
||||||
|
/// value, which is only sound when the runs never overlap or nest.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class SortedRangeIndex<T> where T : IBinaryInteger<T>
|
||||||
|
{
|
||||||
|
public static readonly SortedRangeIndex<T> Empty = new([], []);
|
||||||
|
|
||||||
|
/// <summary>An inclusive <c>[Min, Max]</c> range. Singles are represented as <c>Min == Max</c>.</summary>
|
||||||
|
public readonly record struct Range(T Min, T Max);
|
||||||
|
|
||||||
|
/// <summary>Orders ranges ascending by minimum; the coalescing pass in <see cref="Build"/> requires this.</summary>
|
||||||
|
public static readonly Comparison<Range> 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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// True when <paramref name="value"/> falls in any range. Binary-searches for the rightmost run
|
||||||
|
/// whose minimum is <= the value, then tests that value against that run's maximum.
|
||||||
|
/// </summary>
|
||||||
|
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];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Builds an index from ranges that are already sorted ascending by <see cref="Range.Min"/> (sort
|
||||||
|
/// the source with <see cref="ByMin"/> 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.
|
||||||
|
/// </summary>
|
||||||
|
public static SortedRangeIndex<T> Build(ReadOnlySpan<Range> 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<T>(mins, maxs);
|
||||||
|
}
|
||||||
|
}
|
||||||
234
Projects/Server/Diagnostics/EventLoopProfiler.cs
Normal file
234
Projects/Server/Diagnostics/EventLoopProfiler.cs
Normal file
|
|
@ -0,0 +1,234 @@
|
||||||
|
/*************************************************************************
|
||||||
|
* 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 <http://www.gnu.org/licenses/>. *
|
||||||
|
*************************************************************************/
|
||||||
|
|
||||||
|
using System;
|
||||||
|
using System.Diagnostics;
|
||||||
|
using System.Runtime.CompilerServices;
|
||||||
|
|
||||||
|
namespace Server;
|
||||||
|
|
||||||
|
public enum LoopPhase
|
||||||
|
{
|
||||||
|
MobileDeltas,
|
||||||
|
ItemDeltas,
|
||||||
|
TimerSlice,
|
||||||
|
NetworkSlice,
|
||||||
|
LoopTasks,
|
||||||
|
WorldSnapshot,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Event-loop time accounting, compiled out of normal builds. Build with
|
||||||
|
/// <c>-p:EventLoopProfiling=true</c> to enable; every hook is
|
||||||
|
/// <c>[Conditional("EVENT_LOOP_PROFILING")]</c>, 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.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Each one-second sample decomposes wall time into work (per <see cref="LoopPhase"/>), 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.
|
||||||
|
/// </remarks>
|
||||||
|
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;
|
||||||
|
|
||||||
|
/// <summary>Number of samples recorded so far (capped at the ring size).</summary>
|
||||||
|
public static int SampleCount => _ringCount;
|
||||||
|
|
||||||
|
/// <summary>The sample currently being accumulated (not yet in the ring).</summary>
|
||||||
|
public static Sample Current => _current;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Copies the newest <paramref name="count"/> completed samples, oldest first.
|
||||||
|
/// </summary>
|
||||||
|
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++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -42,10 +42,47 @@ public sealed class EventLoopContext : SynchronizationContext
|
||||||
|
|
||||||
public override SynchronizationContext CreateCopy() => new EventLoopContext();
|
public override SynchronizationContext CreateCopy() => new EventLoopContext();
|
||||||
|
|
||||||
public void Post(Action d, Priority priority = Priority.Normal) =>
|
/// <summary>
|
||||||
(priority == Priority.High ? _priorityQueue : _queue).Enqueue(d);
|
/// True when no callbacks are waiting to run.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <see cref="ExecuteTasks"/> drains at most <c>_maxPerFrame</c> callbacks, so work can
|
||||||
|
/// legitimately be left over. The event loop checks this before sleeping so a backlog keeps
|
||||||
|
/// it running instead.
|
||||||
|
/// </remarks>
|
||||||
|
public bool IsEmpty => _queue.IsEmpty && _priorityQueue.IsEmpty;
|
||||||
|
|
||||||
public override void Post(SendOrPostCallback d, object state) => _queue.Enqueue(() => d(state));
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Nudges the game loop in case it is asleep: the loop blocks on network I/O, which a queue
|
||||||
|
/// push alone does not signal.
|
||||||
|
/// </summary>
|
||||||
|
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 Send(SendOrPostCallback d, object state)
|
public override void Send(SendOrPostCallback d, object state)
|
||||||
{
|
{
|
||||||
|
|
@ -63,6 +100,8 @@ public sealed class EventLoopContext : SynchronizationContext
|
||||||
evt.Set();
|
evt.Set();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
WakeEventLoop();
|
||||||
|
|
||||||
evt.WaitOne();
|
evt.WaitOne();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,13 @@ public class AccountLoginEventArgs
|
||||||
public bool Accepted { get; set; }
|
public bool Accepted { get; set; }
|
||||||
|
|
||||||
public ALRReason RejectReason { get; set; }
|
public ALRReason RejectReason { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
public bool Deferred { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public static partial class EventSink
|
public static partial class EventSink
|
||||||
|
|
|
||||||
|
|
@ -10,4 +10,5 @@ public static class ServerFeatureFlags
|
||||||
public static bool PvPCombat { get; set; } = true;
|
public static bool PvPCombat { get; set; } = true;
|
||||||
public static bool BankAccess { get; set; } = true;
|
public static bool BankAccess { get; set; } = true;
|
||||||
public static bool SpeedhackDetection { get; set; }
|
public static bool SpeedhackDetection { get; set; }
|
||||||
|
public static bool InsuranceEnabled { get; set; }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -44,10 +44,10 @@ public partial class Container : Item
|
||||||
internal int _version;
|
internal int _version;
|
||||||
|
|
||||||
[SerializableField(3)]
|
[SerializableField(3)]
|
||||||
|
[SaveFlag(nameof(ShouldSerializeLiftOverride))]
|
||||||
[SerializedCommandProperty(AccessLevel.GameMaster)]
|
[SerializedCommandProperty(AccessLevel.GameMaster)]
|
||||||
private bool _liftOverride;
|
private bool _liftOverride;
|
||||||
|
|
||||||
[SerializableFieldSaveFlag(3)]
|
|
||||||
private bool ShouldSerializeLiftOverride() => _liftOverride;
|
private bool ShouldSerializeLiftOverride() => _liftOverride;
|
||||||
|
|
||||||
public Container(int itemID) : base(itemID)
|
public Container(int itemID) : base(itemID)
|
||||||
|
|
@ -84,6 +84,7 @@ public partial class Container : Item
|
||||||
|
|
||||||
[EncodedInt]
|
[EncodedInt]
|
||||||
[SerializableProperty(0)]
|
[SerializableProperty(0)]
|
||||||
|
[SaveFlag(nameof(ShouldSerializeMaxItems), nameof(MaxItemsDefaultValue))]
|
||||||
[CommandProperty(AccessLevel.GameMaster)]
|
[CommandProperty(AccessLevel.GameMaster)]
|
||||||
public int MaxItems
|
public int MaxItems
|
||||||
{
|
{
|
||||||
|
|
@ -96,14 +97,13 @@ public partial class Container : Item
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
[SerializableFieldSaveFlag(0)]
|
|
||||||
private bool ShouldSerializeMaxItems() => _maxItems != -1;
|
private bool ShouldSerializeMaxItems() => _maxItems != -1;
|
||||||
|
|
||||||
[SerializableFieldDefault(0)]
|
|
||||||
private int MaxItemsDefaultValue() => -1;
|
private int MaxItemsDefaultValue() => -1;
|
||||||
|
|
||||||
[EncodedInt]
|
[EncodedInt]
|
||||||
[SerializableProperty(1)]
|
[SerializableProperty(1)]
|
||||||
|
[SaveFlag(nameof(ShouldSerializeGumpId), nameof(GumpIDDefaultValue))]
|
||||||
[CommandProperty(AccessLevel.GameMaster)]
|
[CommandProperty(AccessLevel.GameMaster)]
|
||||||
public int GumpID
|
public int GumpID
|
||||||
{
|
{
|
||||||
|
|
@ -115,14 +115,13 @@ public partial class Container : Item
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
[SerializableFieldSaveFlag(1)]
|
|
||||||
private bool ShouldSerializeGumpId() => _gumpID != -1;
|
private bool ShouldSerializeGumpId() => _gumpID != -1;
|
||||||
|
|
||||||
[SerializableFieldDefault(1)]
|
|
||||||
private int GumpIDDefaultValue() => -1;
|
private int GumpIDDefaultValue() => -1;
|
||||||
|
|
||||||
[EncodedInt]
|
[EncodedInt]
|
||||||
[SerializableProperty(2)]
|
[SerializableProperty(2)]
|
||||||
|
[SaveFlag(nameof(ShouldSerializeDropSound), nameof(DropSoundDefaultValue))]
|
||||||
[CommandProperty(AccessLevel.GameMaster)]
|
[CommandProperty(AccessLevel.GameMaster)]
|
||||||
public int DropSound
|
public int DropSound
|
||||||
{
|
{
|
||||||
|
|
@ -134,10 +133,8 @@ public partial class Container : Item
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
[SerializableFieldSaveFlag(2)]
|
|
||||||
private bool ShouldSerializeDropSound() => _dropSound != -1;
|
private bool ShouldSerializeDropSound() => _dropSound != -1;
|
||||||
|
|
||||||
[SerializableFieldDefault(2)]
|
|
||||||
private int DropSoundDefaultValue() => -1;
|
private int DropSoundDefaultValue() => -1;
|
||||||
|
|
||||||
[CommandProperty(AccessLevel.GameMaster)]
|
[CommandProperty(AccessLevel.GameMaster)]
|
||||||
|
|
|
||||||
|
|
@ -311,7 +311,7 @@ public class DecayScheduler : Timer
|
||||||
|
|
||||||
if (timeUntilDecay > _bucketInterval)
|
if (timeUntilDecay > _bucketInterval)
|
||||||
{
|
{
|
||||||
// Item was moved (SetLastMoved called) - re-bucket or move to overflow
|
// Deadline was pushed out (SetLastMoved/RestartDecay) - re-bucket or move to overflow
|
||||||
if (timeUntilDecay > _totalBucketSpan)
|
if (timeUntilDecay > _totalBucketSpan)
|
||||||
{
|
{
|
||||||
// Extended beyond total span - move to overflow
|
// 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
|
// 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.
|
// already left the queue; re-registering as-is would spin on a due time in the past.
|
||||||
item.SetLastMoved();
|
item.RestartDecay();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@
|
||||||
*************************************************************************/
|
*************************************************************************/
|
||||||
|
|
||||||
using System;
|
using System;
|
||||||
|
using System.Diagnostics;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Reflection;
|
using System.Reflection;
|
||||||
using System.Runtime.CompilerServices;
|
using System.Runtime.CompilerServices;
|
||||||
|
|
@ -334,7 +335,25 @@ public partial class Item : IHued, IComparable<Item>, ISpawnable, IObjectPropert
|
||||||
[CommandProperty(AccessLevel.GameMaster)]
|
[CommandProperty(AccessLevel.GameMaster)]
|
||||||
public virtual bool Decays => Movable && Visible && Spawner == null;
|
public virtual bool Decays => Movable && Visible && Spawner == null;
|
||||||
|
|
||||||
public DateTime LastMoved { get; set; }
|
private DateTime _lastMoved;
|
||||||
|
|
||||||
|
public DateTime LastMoved
|
||||||
|
{
|
||||||
|
get => _lastMoved;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
_lastMoved = value;
|
||||||
|
|
||||||
|
// A move at or past the reset stamp supersedes it; drop it so the CompactInfo can collapse.
|
||||||
|
var info = LookupCompactInfo();
|
||||||
|
|
||||||
|
if (info != null && info.m_DecayReset != default && info.m_DecayReset <= value)
|
||||||
|
{
|
||||||
|
info.m_DecayReset = default;
|
||||||
|
VerifyCompactInfo();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
[CommandProperty(AccessLevel.GameMaster)]
|
[CommandProperty(AccessLevel.GameMaster)]
|
||||||
public bool Stackable
|
public bool Stackable
|
||||||
|
|
@ -372,7 +391,7 @@ public partial class Item : IHued, IComparable<Item>, ISpawnable, IObjectPropert
|
||||||
}
|
}
|
||||||
|
|
||||||
Delta(ItemDelta.Update);
|
Delta(ItemDelta.Update);
|
||||||
UpdateDecayRegistration();
|
RestartDecay();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -388,7 +407,7 @@ public partial class Item : IHued, IComparable<Item>, ISpawnable, IObjectPropert
|
||||||
SetFlag(ImplFlag.Movable, value);
|
SetFlag(ImplFlag.Movable, value);
|
||||||
|
|
||||||
Delta(ItemDelta.Update);
|
Delta(ItemDelta.Update);
|
||||||
UpdateDecayRegistration();
|
RestartDecay();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -748,6 +767,12 @@ public partial class Item : IHued, IComparable<Item>, ISpawnable, IObjectPropert
|
||||||
|
|
||||||
public static bool ScissorCopyLootType { get; set; }
|
public static bool ScissorCopyLootType { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// True when the item was produced by the crafting system rather than bought or looted.
|
||||||
|
/// </summary>
|
||||||
|
[CommandProperty(AccessLevel.GameMaster)]
|
||||||
|
public bool PlayerConstructed { get; set; }
|
||||||
|
|
||||||
[CommandProperty(AccessLevel.GameMaster)]
|
[CommandProperty(AccessLevel.GameMaster)]
|
||||||
public bool QuestItem
|
public bool QuestItem
|
||||||
{
|
{
|
||||||
|
|
@ -798,7 +823,22 @@ public partial class Item : IHued, IComparable<Item>, ISpawnable, IObjectPropert
|
||||||
|
|
||||||
public virtual int HuedItemID => m_ItemID;
|
public virtual int HuedItemID => m_ItemID;
|
||||||
|
|
||||||
public ObjectPropertyList PropertyList => m_PropertyList ??= InitializePropertyList(new ObjectPropertyList(this));
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Overridable. Fills an <see cref="ObjectPropertyList" /> with everything applicable. By default, this invokes
|
/// Overridable. Fills an <see cref="ObjectPropertyList" /> with everything applicable. By default, this invokes
|
||||||
|
|
@ -823,7 +863,7 @@ public partial class Item : IHued, IComparable<Item>, ISpawnable, IObjectPropert
|
||||||
|
|
||||||
public virtual void Serialize(IGenericWriter writer)
|
public virtual void Serialize(IGenericWriter writer)
|
||||||
{
|
{
|
||||||
writer.Write(9); // version
|
writer.Write(11); // version
|
||||||
|
|
||||||
var flags = SaveFlag.None;
|
var flags = SaveFlag.None;
|
||||||
|
|
||||||
|
|
@ -933,6 +973,11 @@ public partial class Item : IHued, IComparable<Item>, ISpawnable, IObjectPropert
|
||||||
{
|
{
|
||||||
flags |= SaveFlag.SavedFlags;
|
flags |= SaveFlag.SavedFlags;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (info.m_DecayReset > LastMoved)
|
||||||
|
{
|
||||||
|
flags |= SaveFlag.DecayReset;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (info == null || info.m_Weight < 0)
|
if (info == null || info.m_Weight < 0)
|
||||||
|
|
@ -963,16 +1008,21 @@ public partial class Item : IHued, IComparable<Item>, ISpawnable, IObjectPropert
|
||||||
flags |= SaveFlag.ImplFlags;
|
flags |= SaveFlag.ImplFlags;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (PlayerConstructed)
|
||||||
|
{
|
||||||
|
flags |= SaveFlag.PlayerConstructed;
|
||||||
|
}
|
||||||
|
|
||||||
writer.Write((int)flags);
|
writer.Write((int)flags);
|
||||||
|
|
||||||
/* begin last moved time optimization */
|
// Anchored: shifted by downtime at load, so time-since-moved is preserved and the
|
||||||
var ticks = LastMoved.Ticks;
|
// bytes are stable across saves while the item does not move.
|
||||||
var now = Core.Now.Ticks;
|
writer.WriteAnchoredTime(LastMoved);
|
||||||
|
|
||||||
var minutes = new TimeSpan(now - ticks).TotalMinutes;
|
if (GetSaveFlag(flags, SaveFlag.DecayReset))
|
||||||
|
{
|
||||||
writer.WriteEncodedInt((int)Math.Clamp(minutes, int.MinValue, int.MaxValue));
|
writer.WriteAnchoredTime(info.m_DecayReset);
|
||||||
/* end */
|
}
|
||||||
|
|
||||||
if (GetSaveFlag(flags, SaveFlag.Direction))
|
if (GetSaveFlag(flags, SaveFlag.Direction))
|
||||||
{
|
{
|
||||||
|
|
@ -1291,6 +1341,12 @@ public partial class Item : IHued, IComparable<Item>, ISpawnable, IObjectPropert
|
||||||
|
|
||||||
OnMapChange();
|
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)
|
if (old == null || old == Map.Internal)
|
||||||
{
|
{
|
||||||
InvalidateProperties();
|
InvalidateProperties();
|
||||||
|
|
@ -1521,7 +1577,7 @@ public partial class Item : IHued, IComparable<Item>, ISpawnable, IObjectPropert
|
||||||
|
|
||||||
if (oldValue != value)
|
if (oldValue != value)
|
||||||
{
|
{
|
||||||
UpdateDecayRegistration();
|
RestartDecay();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1715,6 +1771,7 @@ public partial class Item : IHued, IComparable<Item>, ISpawnable, IObjectPropert
|
||||||
|| info.m_HeldBy != null
|
|| info.m_HeldBy != null
|
||||||
|| info.m_BlessedFor != null
|
|| info.m_BlessedFor != null
|
||||||
|| info.m_Spawner != null
|
|| info.m_Spawner != null
|
||||||
|
|| info.m_DecayReset != default
|
||||||
|| info.m_TempFlags != 0
|
|| info.m_TempFlags != 0
|
||||||
|| info.m_SavedFlags != 0
|
|| info.m_SavedFlags != 0
|
||||||
|| info.m_Weight >= 0;
|
|| info.m_Weight >= 0;
|
||||||
|
|
@ -1884,7 +1941,7 @@ public partial class Item : IHued, IComparable<Item>, ISpawnable, IObjectPropert
|
||||||
{
|
{
|
||||||
list.Add(1049643); // cursed
|
list.Add(1049643); // cursed
|
||||||
}
|
}
|
||||||
else if (Insured)
|
else if (ServerFeatureFlags.InsuranceEnabled && Insured)
|
||||||
{
|
{
|
||||||
list.Add(1061682); // <b>insured</b>
|
list.Add(1061682); // <b>insured</b>
|
||||||
}
|
}
|
||||||
|
|
@ -2299,7 +2356,64 @@ public partial class Item : IHued, IComparable<Item>, ISpawnable, IObjectPropert
|
||||||
public virtual bool OnDecay() =>
|
public virtual bool OnDecay() =>
|
||||||
CanDecay() && Region.Find(Location, Map).OnDecay(this);
|
CanDecay() && Region.Find(Location, Map).OnDecay(this);
|
||||||
|
|
||||||
public DateTime ScheduledDecayTime => LastMoved + DecayTime;
|
public DateTime ScheduledDecayTime
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
var reset = DecayResetTime;
|
||||||
|
var lastMoved = LastMoved;
|
||||||
|
|
||||||
|
return (reset > lastMoved ? reset : lastMoved) + DecayTime;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// When decay eligibility was last restored without the item moving, e.g. a GM unfreezing it.
|
||||||
|
/// The decay countdown runs from the later of this and <see cref="LastMoved" />.
|
||||||
|
/// </summary>
|
||||||
|
public DateTime DecayResetTime
|
||||||
|
{
|
||||||
|
get => LookupCompactInfo()?.m_DecayReset ?? default;
|
||||||
|
private set
|
||||||
|
{
|
||||||
|
if (value == default)
|
||||||
|
{
|
||||||
|
var info = LookupCompactInfo();
|
||||||
|
|
||||||
|
if (info != null && info.m_DecayReset != default)
|
||||||
|
{
|
||||||
|
info.m_DecayReset = default;
|
||||||
|
VerifyCompactInfo();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
AcquireCompactInfo().m_DecayReset = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Restarts the decay countdown without touching <see cref="LastMoved" />: call when decay
|
||||||
|
/// eligibility changes state (Movable/Visible/Spawner) or a region refuses a decay, where a
|
||||||
|
/// stale <see cref="LastMoved" /> would otherwise decay the item on the next tick.
|
||||||
|
/// Stamps <see cref="DecayResetTime" /> only when that extends the current deadline, then
|
||||||
|
/// updates the scheduler registration.
|
||||||
|
/// </summary>
|
||||||
|
public void RestartDecay()
|
||||||
|
{
|
||||||
|
if (CanDecay())
|
||||||
|
{
|
||||||
|
var now = Core.Now;
|
||||||
|
|
||||||
|
if (ScheduledDecayTime < now + DecayTime)
|
||||||
|
{
|
||||||
|
DecayResetTime = now;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
UpdateDecayRegistration();
|
||||||
|
}
|
||||||
|
|
||||||
public void UpdateDecayRegistration()
|
public void UpdateDecayRegistration()
|
||||||
{
|
{
|
||||||
|
|
@ -2309,6 +2423,12 @@ public partial class Item : IHued, IComparable<Item>, ISpawnable, IObjectPropert
|
||||||
{
|
{
|
||||||
DecayScheduler.Register(this);
|
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()
|
public void SetLastMoved()
|
||||||
|
|
@ -2341,6 +2461,11 @@ public partial class Item : IHued, IComparable<Item>, ISpawnable, IObjectPropert
|
||||||
}
|
}
|
||||||
|
|
||||||
Amount += dropped.Amount;
|
Amount += dropped.Amount;
|
||||||
|
if (PlayerConstructed != dropped.PlayerConstructed)
|
||||||
|
{
|
||||||
|
PlayerConstructed = false;
|
||||||
|
}
|
||||||
|
|
||||||
dropped.Delete();
|
dropped.Delete();
|
||||||
|
|
||||||
if (playSound && from != null)
|
if (playSound && from != null)
|
||||||
|
|
@ -2429,9 +2554,19 @@ public partial class Item : IHued, IComparable<Item>, ISpawnable, IObjectPropert
|
||||||
|
|
||||||
private ObjectPropertyList InitializePropertyList(ObjectPropertyList list)
|
private ObjectPropertyList InitializePropertyList(ObjectPropertyList list)
|
||||||
{
|
{
|
||||||
GetProperties(list);
|
list.IsBuilding = true;
|
||||||
AppendChildProperties(list);
|
|
||||||
list.Terminate();
|
try
|
||||||
|
{
|
||||||
|
GetProperties(list);
|
||||||
|
AppendChildProperties(list);
|
||||||
|
list.Terminate();
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
list.IsBuilding = false;
|
||||||
|
}
|
||||||
|
|
||||||
return list;
|
return list;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -2448,6 +2583,26 @@ public partial class Item : IHued, IComparable<Item>, ISpawnable, IObjectPropert
|
||||||
return;
|
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)
|
if (m_Map != null && m_Map != Map.Internal && !World.Loading)
|
||||||
{
|
{
|
||||||
int? oldHash;
|
int? oldHash;
|
||||||
|
|
@ -2611,6 +2766,8 @@ public partial class Item : IHued, IComparable<Item>, ISpawnable, IObjectPropert
|
||||||
|
|
||||||
switch (version)
|
switch (version)
|
||||||
{
|
{
|
||||||
|
case 11:
|
||||||
|
case 10:
|
||||||
case 9:
|
case 9:
|
||||||
case 8:
|
case 8:
|
||||||
case 7:
|
case 7:
|
||||||
|
|
@ -2618,7 +2775,11 @@ public partial class Item : IHued, IComparable<Item>, ISpawnable, IObjectPropert
|
||||||
{
|
{
|
||||||
var flags = (SaveFlag)reader.ReadInt();
|
var flags = (SaveFlag)reader.ReadInt();
|
||||||
|
|
||||||
if (version < 7)
|
if (version >= 11)
|
||||||
|
{
|
||||||
|
LastMoved = reader.ReadAnchoredTime();
|
||||||
|
}
|
||||||
|
else if (version < 7)
|
||||||
{
|
{
|
||||||
LastMoved = reader.ReadDeltaTime();
|
LastMoved = reader.ReadDeltaTime();
|
||||||
}
|
}
|
||||||
|
|
@ -2636,6 +2797,18 @@ public partial class Item : IHued, IComparable<Item>, ISpawnable, IObjectPropert
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (version >= 10 && GetSaveFlag(flags, SaveFlag.DecayReset))
|
||||||
|
{
|
||||||
|
var reset = version >= 11 ? reader.ReadAnchoredTime() : reader.ReadDeltaTime();
|
||||||
|
|
||||||
|
// Pre-v11 LastMoved was stored at whole-minute precision; keep the
|
||||||
|
// stamp only while it still extends the deadline.
|
||||||
|
if (reset > LastMoved)
|
||||||
|
{
|
||||||
|
DecayResetTime = reset;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (GetSaveFlag(flags, SaveFlag.Direction))
|
if (GetSaveFlag(flags, SaveFlag.Direction))
|
||||||
{
|
{
|
||||||
m_Direction = (Direction)reader.ReadByte();
|
m_Direction = (Direction)reader.ReadByte();
|
||||||
|
|
@ -2808,6 +2981,8 @@ public partial class Item : IHued, IComparable<Item>, ISpawnable, IObjectPropert
|
||||||
AcquireCompactInfo().m_SavedFlags = reader.ReadEncodedInt();
|
AcquireCompactInfo().m_SavedFlags = reader.ReadEncodedInt();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
PlayerConstructed = GetSaveFlag(flags, SaveFlag.PlayerConstructed);
|
||||||
|
|
||||||
if (m_Map != null && m_Parent == null)
|
if (m_Map != null && m_Parent == null)
|
||||||
{
|
{
|
||||||
m_Map.OnEnter(this);
|
m_Map.OnEnter(this);
|
||||||
|
|
@ -3279,6 +3454,12 @@ public partial class Item : IHued, IComparable<Item>, ISpawnable, IObjectPropert
|
||||||
m_DeltaFlags &= ~flags;
|
m_DeltaFlags &= ~flags;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// True when deltas remain queued after a <see cref="ProcessDeltaQueue"/> pass, which is
|
||||||
|
/// bounded by the count it saw on entry. The event loop consults this before sleeping.
|
||||||
|
/// </summary>
|
||||||
|
public static bool HasQueuedDeltas => m_DeltaQueue.Count > 0;
|
||||||
|
|
||||||
public static void ProcessDeltaQueue()
|
public static void ProcessDeltaQueue()
|
||||||
{
|
{
|
||||||
var limit = m_DeltaQueue.Count;
|
var limit = m_DeltaQueue.Count;
|
||||||
|
|
@ -3385,7 +3566,7 @@ public partial class Item : IHued, IComparable<Item>, ISpawnable, IObjectPropert
|
||||||
for (var i = 0; i < props.Length; i++)
|
for (var i = 0; i < props.Length; i++)
|
||||||
{
|
{
|
||||||
var p = props[i];
|
var p = props[i];
|
||||||
if (p.GetCustomAttribute(typeof(IgnoreDupeAttribute), true) != null || !p.CanRead || !p.CanWrite)
|
if (p.GetCustomAttribute<IgnoreDupeAttribute>(true) != null || !p.CanRead || !p.CanWrite)
|
||||||
{
|
{
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
@ -4216,12 +4397,12 @@ public partial class Item : IHued, IComparable<Item>, ISpawnable, IObjectPropert
|
||||||
}
|
}
|
||||||
|
|
||||||
public virtual bool CheckBlessed(Mobile m) =>
|
public virtual bool CheckBlessed(Mobile m) =>
|
||||||
m_LootType == LootType.Blessed || Mobile.InsuranceEnabled && Insured || m != null && m == BlessedFor;
|
m_LootType == LootType.Blessed || ServerFeatureFlags.InsuranceEnabled && Insured || m != null && m == BlessedFor;
|
||||||
|
|
||||||
public virtual bool CheckNewbied() => m_LootType == LootType.Newbied;
|
public virtual bool CheckNewbied() => m_LootType == LootType.Newbied;
|
||||||
|
|
||||||
public virtual bool IsStandardLoot() =>
|
public virtual bool IsStandardLoot() =>
|
||||||
(!Mobile.InsuranceEnabled || !Insured) && BlessedFor == null && m_LootType == LootType.Regular;
|
(!ServerFeatureFlags.InsuranceEnabled || !Insured) && BlessedFor == null && m_LootType == LootType.Regular;
|
||||||
|
|
||||||
public override string ToString() => $"{Serial} \"{GetType().Name}\"";
|
public override string ToString() => $"{Serial} \"{GetType().Name}\"";
|
||||||
|
|
||||||
|
|
@ -4291,6 +4472,8 @@ public partial class Item : IHued, IComparable<Item>, ISpawnable, IObjectPropert
|
||||||
|
|
||||||
public ISpawner m_Spawner;
|
public ISpawner m_Spawner;
|
||||||
|
|
||||||
|
public DateTime m_DecayReset;
|
||||||
|
|
||||||
public int m_TempFlags;
|
public int m_TempFlags;
|
||||||
|
|
||||||
public double m_Weight = -1;
|
public double m_Weight = -1;
|
||||||
|
|
@ -4328,6 +4511,8 @@ public partial class Item : IHued, IComparable<Item>, ISpawnable, IObjectPropert
|
||||||
HeldBy = 0x00800000,
|
HeldBy = 0x00800000,
|
||||||
IntWeight = 0x01000000,
|
IntWeight = 0x01000000,
|
||||||
SavedFlags = 0x02000000,
|
SavedFlags = 0x02000000,
|
||||||
NullWeight = 0x04000000
|
NullWeight = 0x04000000,
|
||||||
|
PlayerConstructed = 0x08000000,
|
||||||
|
DecayReset = 0x10000000
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
/*************************************************************************
|
/*************************************************************************
|
||||||
* ModernUO *
|
* ModernUO *
|
||||||
* Copyright 2019-2026 - ModernUO Development Team *
|
* Copyright 2019-2026 - ModernUO Development Team *
|
||||||
* Email: hi@modernuo.com *
|
* Email: hi@modernuo.com *
|
||||||
|
|
@ -30,6 +30,7 @@ using Server.Compression;
|
||||||
using Server.Json;
|
using Server.Json;
|
||||||
using Server.Logging;
|
using Server.Logging;
|
||||||
using Server.Network;
|
using Server.Network;
|
||||||
|
using Server.Network.Bans;
|
||||||
using Server.Text;
|
using Server.Text;
|
||||||
|
|
||||||
namespace Server;
|
namespace Server;
|
||||||
|
|
@ -38,10 +39,181 @@ public static class Core
|
||||||
{
|
{
|
||||||
private static readonly ILogger logger = LogFactory.GetLogger(typeof(Core));
|
private static readonly ILogger logger = LogFactory.GetLogger(typeof(Core));
|
||||||
|
|
||||||
private static bool _performProcessKill;
|
// Written off-loop (Kill, RequestSnapshot); volatile because the loop blocks between reads.
|
||||||
|
private static volatile bool _performProcessKill;
|
||||||
private static bool _restartOnKill;
|
private static bool _restartOnKill;
|
||||||
private static bool _performSnapshot;
|
private static volatile bool _performSnapshot;
|
||||||
private static string _snapshotPath;
|
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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Longest the loop will block while idle, in milliseconds. 0 spins instead; the backoff
|
||||||
|
/// does the same temporarily when the host keeps returning waits late.
|
||||||
|
/// </summary>
|
||||||
|
public static int EventLoopIdleWaitMs => _eventLoopIdleWaitMs;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// True when idle sleeping was disabled at startup because the host cannot honor short
|
||||||
|
/// waits, overriding whatever <c>server.eventLoopIdleWaitMs</c> was configured to.
|
||||||
|
/// </summary>
|
||||||
|
public static bool IdleSleepUnsupported { get; private set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Whether idle sleeping is currently suspended because the host returned waits late.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Compared by subtraction, never directly: tick counts can start enormous and wrap.
|
||||||
|
/// See dev-docs/tick-counts.md.
|
||||||
|
/// </remarks>
|
||||||
|
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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Once a second, suspends idle sleeping (with escalating duration) if the host keeps
|
||||||
|
/// returning idle waits a full tick or more late.
|
||||||
|
/// </summary>
|
||||||
|
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 bool _crashed;
|
||||||
private static string _baseDirectory;
|
private static string _baseDirectory;
|
||||||
|
|
||||||
|
|
@ -110,14 +282,6 @@ public static class Core
|
||||||
|
|
||||||
public static long Uptime => TickCount - _firstTick;
|
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
|
public static string BaseDirectory
|
||||||
{
|
{
|
||||||
get
|
get
|
||||||
|
|
@ -234,6 +398,10 @@ public static class Core
|
||||||
{
|
{
|
||||||
_restartOnKill = restart;
|
_restartOnKill = restart;
|
||||||
_performProcessKill = true;
|
_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)
|
public static void CurrentDomain_UnhandledException(object sender, UnhandledExceptionEventArgs e)
|
||||||
|
|
@ -260,7 +428,7 @@ public static class Core
|
||||||
// ignored
|
// ignored
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!close && !Core.Headless)
|
if (!close && !Headless)
|
||||||
{
|
{
|
||||||
Console.WriteLine("This exception is fatal, press return to exit");
|
Console.WriteLine("This exception is fatal, press return to exit");
|
||||||
ConsoleInputHandler.ReadLine();
|
ConsoleInputHandler.ReadLine();
|
||||||
|
|
@ -342,6 +510,8 @@ public static class Core
|
||||||
World.ExitSerializationThreads();
|
World.ExitSerializationThreads();
|
||||||
PingServer.Shutdown();
|
PingServer.Shutdown();
|
||||||
NetState.Shutdown();
|
NetState.Shutdown();
|
||||||
|
BanChannel.Stop();
|
||||||
|
ConnectionFilters.Stop();
|
||||||
|
|
||||||
if (!_crashed)
|
if (!_crashed)
|
||||||
{
|
{
|
||||||
|
|
@ -421,6 +591,45 @@ public static class Core
|
||||||
|
|
||||||
ServerConfiguration.Load();
|
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);
|
var assemblyPath = Path.Join(BaseDirectory, AssembliesConfiguration);
|
||||||
|
|
||||||
// Load UOContent.dll
|
// Load UOContent.dll
|
||||||
|
|
@ -437,10 +646,8 @@ public static class Core
|
||||||
|
|
||||||
AssemblyHandler.LoadAssemblies(assemblyFiles);
|
AssemblyHandler.LoadAssemblies(assemblyFiles);
|
||||||
|
|
||||||
// First-boot interactive setup. Runs after assemblies are loaded (so content can
|
// First-boot interactive setup. After assemblies load so content can register prompts,
|
||||||
// register prompts) but before any Serilog output, so console prompts are not
|
// before any Serilog output so prompts are not interleaved with the async console sink.
|
||||||
// interleaved with the async console sink. Handlers self-gate on first-boot state
|
|
||||||
// (e.g. "is my setting already present?").
|
|
||||||
AssemblyHandler.Invoke("ConfigurePrompts");
|
AssemblyHandler.Invoke("ConfigurePrompts");
|
||||||
|
|
||||||
logger.Information("Running on {Framework}", RuntimeInformation.FrameworkDescription);
|
logger.Information("Running on {Framework}", RuntimeInformation.FrameworkDescription);
|
||||||
|
|
@ -450,6 +657,11 @@ public static class Core
|
||||||
_now = DateTime.UtcNow;
|
_now = DateTime.UtcNow;
|
||||||
_firstTick = _tickCount = GetTimestamp();
|
_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);
|
Timer.Init(_tickCount);
|
||||||
|
|
||||||
AssemblyHandler.Invoke("Configure");
|
AssemblyHandler.Invoke("Configure");
|
||||||
|
|
@ -461,44 +673,76 @@ public static class Core
|
||||||
|
|
||||||
AssemblyHandler.Invoke("Initialize");
|
AssemblyHandler.Invoke("Initialize");
|
||||||
|
|
||||||
|
BanChannel.Start(ClosingTokenSource.Token);
|
||||||
|
ConnectionFilters.Start(ClosingTokenSource.Token);
|
||||||
NetState.Start();
|
NetState.Start();
|
||||||
PingServer.Start();
|
PingServer.Start();
|
||||||
EventSink.InvokeServerStarted();
|
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();
|
RunEventLoop();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
private static bool IsIdle() =>
|
||||||
|
!Mobile.HasQueuedDeltas && !Item.HasQueuedDeltas && LoopContext.IsEmpty && NetState.IsIdle;
|
||||||
|
|
||||||
public static void RunEventLoop()
|
public static void RunEventLoop()
|
||||||
{
|
{
|
||||||
try
|
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)
|
while (!Closing)
|
||||||
{
|
{
|
||||||
_tickCount = GetTimestamp();
|
_tickCount = GetTimestamp();
|
||||||
_now = DateTime.UtcNow;
|
_now = DateTime.UtcNow;
|
||||||
|
|
||||||
|
EventLoopProfiler.IterationStart(_tickCount);
|
||||||
|
|
||||||
|
EventLoopProfiler.PhaseStart(LoopPhase.MobileDeltas);
|
||||||
Mobile.ProcessDeltaQueue();
|
Mobile.ProcessDeltaQueue();
|
||||||
|
EventLoopProfiler.PhaseEnd(LoopPhase.MobileDeltas);
|
||||||
|
|
||||||
|
EventLoopProfiler.PhaseStart(LoopPhase.ItemDeltas);
|
||||||
Item.ProcessDeltaQueue();
|
Item.ProcessDeltaQueue();
|
||||||
|
EventLoopProfiler.PhaseEnd(LoopPhase.ItemDeltas);
|
||||||
|
|
||||||
|
EventLoopProfiler.PhaseStart(LoopPhase.TimerSlice);
|
||||||
Timer.Slice(_tickCount);
|
Timer.Slice(_tickCount);
|
||||||
|
EventLoopProfiler.PhaseEnd(LoopPhase.TimerSlice);
|
||||||
|
|
||||||
// Handle networking
|
// Handle networking
|
||||||
|
EventLoopProfiler.PhaseStart(LoopPhase.NetworkSlice);
|
||||||
NetState.Slice();
|
NetState.Slice();
|
||||||
|
EventLoopProfiler.PhaseEnd(LoopPhase.NetworkSlice);
|
||||||
|
|
||||||
// Execute captured post-await methods (like Timer.Pause)
|
// Execute captured post-await methods (like Timer.Pause)
|
||||||
|
EventLoopProfiler.PhaseStart(LoopPhase.LoopTasks);
|
||||||
LoopContext.ExecuteTasks();
|
LoopContext.ExecuteTasks();
|
||||||
|
EventLoopProfiler.PhaseEnd(LoopPhase.LoopTasks);
|
||||||
|
|
||||||
Timer.CheckTimerPool(); // Check for pool depletion so we can async refill it.
|
Timer.CheckTimerPool(); // Check for pool depletion so we can async refill it.
|
||||||
|
|
||||||
if (_performSnapshot)
|
if (_performSnapshot)
|
||||||
{
|
{
|
||||||
|
EventLoopProfiler.PhaseStart(LoopPhase.WorldSnapshot);
|
||||||
// Return value is the offset that can be used to fix timers that should drift
|
// Return value is the offset that can be used to fix timers that should drift
|
||||||
World.Snapshot(_snapshotPath);
|
World.Snapshot(_snapshotPath);
|
||||||
|
EventLoopProfiler.PhaseEnd(LoopPhase.WorldSnapshot);
|
||||||
_performSnapshot = false;
|
_performSnapshot = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -508,29 +752,35 @@ public static class Core
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (sample++ == interval)
|
CheckSchedulerHealth();
|
||||||
|
|
||||||
|
if (_eventLoopIdleWaitMs > 0 && _tickCount - _idleSleepSuspendedUntil >= 0 && IsIdle())
|
||||||
{
|
{
|
||||||
sample = 0;
|
// Re-read the clock: a stale timestamp overstates the time to the next tick
|
||||||
var nowRaw = Stopwatch.GetTimestamp();
|
// and sleeps straight past it.
|
||||||
|
var start = GetTimestamp();
|
||||||
_currentCPS = frequency / (nowRaw - lastRaw);
|
var due = Timer.MillisecondsUntilNextTick(start);
|
||||||
|
if (due > 0)
|
||||||
if (!_cpsInitialized)
|
|
||||||
{
|
{
|
||||||
_averageCPS = _currentCPS;
|
var requested = (int)Math.Min(due, _eventLoopIdleWaitMs);
|
||||||
_cpsInitialized = true;
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
_averageCPS += alpha * (_currentCPS - _averageCPS);
|
|
||||||
}
|
|
||||||
|
|
||||||
lastRaw = nowRaw;
|
// 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);
|
||||||
|
|
||||||
var sleepMs = (int)Timer.MillisecondsUntilNextTick(_tickCount);
|
NetState.WaitForCompletion(requested);
|
||||||
if (sleepMs >= 2)
|
|
||||||
{
|
var elapsed = GetTimestamp() - start;
|
||||||
NetState.WaitForCompletion(sleepMs - 1);
|
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++;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -548,6 +798,9 @@ public static class Core
|
||||||
{
|
{
|
||||||
_snapshotPath = snapshotPath;
|
_snapshotPath = snapshotPath;
|
||||||
_performSnapshot = true;
|
_performSnapshot = true;
|
||||||
|
|
||||||
|
// Save requests arrive off-loop; wake so the snapshot starts now.
|
||||||
|
NetState.Wake();
|
||||||
}
|
}
|
||||||
|
|
||||||
public static void VerifySerialization()
|
public static void VerifySerialization()
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,7 @@ using Server.Network;
|
||||||
using Server.Prompts;
|
using Server.Prompts;
|
||||||
using Server.Targeting;
|
using Server.Targeting;
|
||||||
using System;
|
using System;
|
||||||
|
using System.Diagnostics;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Runtime.CompilerServices;
|
using System.Runtime.CompilerServices;
|
||||||
using Server.Buffers;
|
using Server.Buffers;
|
||||||
|
|
@ -41,7 +42,7 @@ public delegate void PromptCallback(Mobile from, string text);
|
||||||
|
|
||||||
public delegate void PromptStateCallback<in T>(Mobile from, string text, T state);
|
public delegate void PromptStateCallback<in T>(Mobile from, string text, T state);
|
||||||
|
|
||||||
public class DamageEntry
|
public class DamageEntry : IValueLinkListNode<DamageEntry>
|
||||||
{
|
{
|
||||||
public DamageEntry(Mobile damager) => Damager = damager;
|
public DamageEntry(Mobile damager) => Damager = damager;
|
||||||
|
|
||||||
|
|
@ -56,6 +57,11 @@ public class DamageEntry
|
||||||
public List<DamageEntry> Responsible { get; set; }
|
public List<DamageEntry> Responsible { get; set; }
|
||||||
|
|
||||||
public static TimeSpan ExpireDelay { get; set; } = TimeSpan.FromMinutes(2.0);
|
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]
|
[Flags]
|
||||||
|
|
@ -376,7 +382,6 @@ public partial class Mobile : IHued, IComparable<Mobile>, ISpawnable, IObjectPro
|
||||||
Aggressors = new List<AggressorInfo>();
|
Aggressors = new List<AggressorInfo>();
|
||||||
Aggressed = new List<AggressorInfo>();
|
Aggressed = new List<AggressorInfo>();
|
||||||
NextSkillTime = Core.TickCount;
|
NextSkillTime = Core.TickCount;
|
||||||
DamageEntries = new List<DamageEntry>();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sectors
|
// Sectors
|
||||||
|
|
@ -953,13 +958,27 @@ public partial class Mobile : IHued, IComparable<Mobile>, ISpawnable, IObjectPro
|
||||||
|
|
||||||
public static TimeSpan AutoManifestTimeout { get; set; } = TimeSpan.FromSeconds(5.0);
|
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 int ActionDelay { get; set; } = 500;
|
||||||
|
|
||||||
public static VisibleDamageType VisibleDamageType { get; set; }
|
public static VisibleDamageType VisibleDamageType { get; set; }
|
||||||
|
|
||||||
public List<DamageEntry> DamageEntries { get; private set; }
|
private ValueLinkList<DamageEntry> _damageEntries;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Damage entries ordered least recent (head) to most recent (tail). Expired entries are
|
||||||
|
/// pruned on access. Enumerate with <c>foreach</c> (ascending) or <c>.ByDescending()</c>.
|
||||||
|
/// Mutate only through <see cref="RegisterDamage"/> and <see cref="ClearDamageEntries"/>.
|
||||||
|
/// Calling a ValueLinkList mutator on this reference compiles, but operates on a defensive copy
|
||||||
|
/// while still unlinking the real nodes — it silently corrupts the list.
|
||||||
|
/// </summary>
|
||||||
|
public ref readonly ValueLinkList<DamageEntry> DamageEntries
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
PruneExpiredDamageEntries();
|
||||||
|
return ref _damageEntries;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
[CommandProperty(AccessLevel.GameMaster)]
|
[CommandProperty(AccessLevel.GameMaster)]
|
||||||
public Mobile LastKiller { get; set; }
|
public Mobile LastKiller { get; set; }
|
||||||
|
|
@ -1628,7 +1647,7 @@ public partial class Mobile : IHued, IComparable<Mobile>, ISpawnable, IObjectPro
|
||||||
|
|
||||||
public virtual bool KeepsItemsOnDeath => m_AccessLevel > AccessLevel.Player;
|
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; }
|
public bool NoMoveHS { get; set; }
|
||||||
|
|
||||||
|
|
@ -2021,10 +2040,7 @@ public partial class Mobile : IHued, IComparable<Mobile>, ISpawnable, IObjectPro
|
||||||
Aggressors[i].CanReportMurder = false;
|
Aggressors[i].CanReportMurder = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (DamageEntries.Count > 0)
|
ClearDamageEntries(); // reset damage entries on full HP
|
||||||
{
|
|
||||||
DamageEntries.Clear(); // reset damage entries on full HP
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
else if (CanRegenHits)
|
else if (CanRegenHits)
|
||||||
{
|
{
|
||||||
|
|
@ -2293,7 +2309,22 @@ public partial class Mobile : IHued, IComparable<Mobile>, ISpawnable, IObjectPro
|
||||||
public int CompareTo(Mobile other) => other == null ? -1 : Serial.CompareTo(other.Serial);
|
public int CompareTo(Mobile other) => other == null ? -1 : Serial.CompareTo(other.Serial);
|
||||||
|
|
||||||
public virtual int HuedItemID => m_Female ? 0x2107 : 0x2106;
|
public virtual int HuedItemID => m_Female ? 0x2107 : 0x2106;
|
||||||
public ObjectPropertyList PropertyList => m_PropertyList ??= InitializePropertyList(new ObjectPropertyList(this));
|
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 virtual void GetProperties(IPropertyList list)
|
public virtual void GetProperties(IPropertyList list)
|
||||||
{
|
{
|
||||||
|
|
@ -2310,11 +2341,11 @@ public partial class Mobile : IHued, IComparable<Mobile>, ISpawnable, IObjectPro
|
||||||
|
|
||||||
public virtual void Serialize(IGenericWriter writer)
|
public virtual void Serialize(IGenericWriter writer)
|
||||||
{
|
{
|
||||||
writer.Write(37); // version
|
writer.Write(38); // version
|
||||||
|
|
||||||
writer.WriteDeltaTime(LastStrGain);
|
writer.WriteAnchoredTime(LastStrGain);
|
||||||
writer.WriteDeltaTime(LastIntGain);
|
writer.WriteAnchoredTime(LastIntGain);
|
||||||
writer.WriteDeltaTime(LastDexGain);
|
writer.WriteAnchoredTime(LastDexGain);
|
||||||
|
|
||||||
byte hairflag = 0x00;
|
byte hairflag = 0x00;
|
||||||
|
|
||||||
|
|
@ -5234,8 +5265,15 @@ public partial class Mobile : IHued, IComparable<Mobile>, ISpawnable, IObjectPro
|
||||||
item.Name = oldItem.Name;
|
item.Name = oldItem.Name;
|
||||||
item.Weight = oldItem.Weight;
|
item.Weight = oldItem.Weight;
|
||||||
|
|
||||||
|
item.PlayerConstructed = oldItem.PlayerConstructed;
|
||||||
item.Amount = oldAmount - amount;
|
item.Amount = oldAmount - amount;
|
||||||
item.Map = oldItem.Map;
|
|
||||||
|
// A parented remainder gets its map from AddItem (parent first, then map), keeping the
|
||||||
|
// split off the decay scheduler; a ground remainder is placed and enrolled here.
|
||||||
|
if (oldItem.Parent == null)
|
||||||
|
{
|
||||||
|
item.Map = oldItem.Map;
|
||||||
|
}
|
||||||
|
|
||||||
oldItem.OnAfterDuped(item);
|
oldItem.OnAfterDuped(item);
|
||||||
|
|
||||||
|
|
@ -5724,24 +5762,54 @@ public partial class Mobile : IHued, IComparable<Mobile>, ISpawnable, IObjectPro
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Entries are kept in LastDamage order, so expired entries are always a head prefix.
|
||||||
|
private void PruneExpiredDamageEntries()
|
||||||
|
{
|
||||||
|
#if DEBUG
|
||||||
|
for (var node = _damageEntries._first; node != null; node = node.Next)
|
||||||
|
{
|
||||||
|
Debug.Assert(
|
||||||
|
node.Next == null || node.Next.LastDamage >= node.LastDamage,
|
||||||
|
"Damage entries must be ordered by LastDamage ascending."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
var first = _damageEntries._first;
|
||||||
|
|
||||||
|
if (first?.HasExpired != true)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var firstLive = first.Next;
|
||||||
|
|
||||||
|
while (firstLive?.HasExpired == true)
|
||||||
|
{
|
||||||
|
firstLive = firstLive.Next;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (firstLive == null)
|
||||||
|
{
|
||||||
|
_damageEntries.RemoveAll();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_damageEntries.RemoveAllBefore(firstLive);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void ClearDamageEntries() => _damageEntries.RemoveAll();
|
||||||
|
|
||||||
public Mobile FindMostRecentDamager(bool allowSelf) => FindMostRecentDamageEntry(allowSelf)?.Damager;
|
public Mobile FindMostRecentDamager(bool allowSelf) => FindMostRecentDamageEntry(allowSelf)?.Damager;
|
||||||
|
|
||||||
public DamageEntry FindMostRecentDamageEntry(bool allowSelf)
|
public DamageEntry FindMostRecentDamageEntry(bool allowSelf)
|
||||||
{
|
{
|
||||||
for (var i = DamageEntries.Count - 1; i >= 0; --i)
|
PruneExpiredDamageEntries();
|
||||||
|
|
||||||
|
for (var de = _damageEntries._last; de != null; de = de.Previous)
|
||||||
{
|
{
|
||||||
if (i >= DamageEntries.Count)
|
if (allowSelf || de.Damager != this)
|
||||||
{
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
var de = DamageEntries[i];
|
|
||||||
|
|
||||||
if (de.HasExpired)
|
|
||||||
{
|
|
||||||
DamageEntries.RemoveAt(i);
|
|
||||||
}
|
|
||||||
else if (allowSelf || de.Damager != this)
|
|
||||||
{
|
{
|
||||||
return de;
|
return de;
|
||||||
}
|
}
|
||||||
|
|
@ -5754,21 +5822,11 @@ public partial class Mobile : IHued, IComparable<Mobile>, ISpawnable, IObjectPro
|
||||||
|
|
||||||
public DamageEntry FindLeastRecentDamageEntry(bool allowSelf)
|
public DamageEntry FindLeastRecentDamageEntry(bool allowSelf)
|
||||||
{
|
{
|
||||||
for (var i = 0; i < DamageEntries.Count; ++i)
|
PruneExpiredDamageEntries();
|
||||||
|
|
||||||
|
for (var de = _damageEntries._first; de != null; de = de.Next)
|
||||||
{
|
{
|
||||||
if (i < 0)
|
if (allowSelf || de.Damager != this)
|
||||||
{
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
var de = DamageEntries[i];
|
|
||||||
|
|
||||||
if (de.HasExpired)
|
|
||||||
{
|
|
||||||
DamageEntries.RemoveAt(i);
|
|
||||||
--i;
|
|
||||||
}
|
|
||||||
else if (allowSelf || de.Damager != this)
|
|
||||||
{
|
{
|
||||||
return de;
|
return de;
|
||||||
}
|
}
|
||||||
|
|
@ -5779,24 +5837,17 @@ public partial class Mobile : IHued, IComparable<Mobile>, ISpawnable, IObjectPro
|
||||||
|
|
||||||
public Mobile FindMostTotalDamager(bool allowSelf) => FindMostTotalDamageEntry(allowSelf)?.Damager;
|
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)
|
public DamageEntry FindMostTotalDamageEntry(bool allowSelf)
|
||||||
{
|
{
|
||||||
|
PruneExpiredDamageEntries();
|
||||||
|
|
||||||
DamageEntry mostTotal = null;
|
DamageEntry mostTotal = null;
|
||||||
|
|
||||||
for (var i = DamageEntries.Count - 1; i >= 0; --i)
|
for (var de = _damageEntries._last; de != null; de = de.Previous)
|
||||||
{
|
{
|
||||||
if (i >= DamageEntries.Count)
|
if ((allowSelf || de.Damager != this) && (mostTotal == null || de.DamageGiven > mostTotal.DamageGiven))
|
||||||
{
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
var de = DamageEntries[i];
|
|
||||||
|
|
||||||
if (de.HasExpired)
|
|
||||||
{
|
|
||||||
DamageEntries.RemoveAt(i);
|
|
||||||
}
|
|
||||||
else if ((allowSelf || de.Damager != this) && (mostTotal == null || de.DamageGiven > mostTotal.DamageGiven))
|
|
||||||
{
|
{
|
||||||
mostTotal = de;
|
mostTotal = de;
|
||||||
}
|
}
|
||||||
|
|
@ -5809,46 +5860,28 @@ public partial class Mobile : IHued, IComparable<Mobile>, ISpawnable, IObjectPro
|
||||||
|
|
||||||
public DamageEntry FindLeastTotalDamageEntry(bool allowSelf)
|
public DamageEntry FindLeastTotalDamageEntry(bool allowSelf)
|
||||||
{
|
{
|
||||||
DamageEntry mostTotal = null;
|
PruneExpiredDamageEntries();
|
||||||
|
|
||||||
for (var i = DamageEntries.Count - 1; i >= 0; --i)
|
DamageEntry leastTotal = null;
|
||||||
|
|
||||||
|
for (var de = _damageEntries._last; de != null; de = de.Previous)
|
||||||
{
|
{
|
||||||
if (i >= DamageEntries.Count)
|
if ((allowSelf || de.Damager != this) && (leastTotal == null || de.DamageGiven < leastTotal.DamageGiven))
|
||||||
{
|
{
|
||||||
continue;
|
leastTotal = de;
|
||||||
}
|
|
||||||
|
|
||||||
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 mostTotal;
|
return leastTotal;
|
||||||
}
|
}
|
||||||
|
|
||||||
public DamageEntry FindDamageEntryFor(Mobile m)
|
public DamageEntry FindDamageEntryFor(Mobile m)
|
||||||
{
|
{
|
||||||
for (var i = DamageEntries.Count - 1; i >= 0; --i)
|
PruneExpiredDamageEntries();
|
||||||
|
|
||||||
|
for (var de = _damageEntries._last; de != null; de = de.Previous)
|
||||||
{
|
{
|
||||||
if (i >= DamageEntries.Count)
|
if (de.Damager == m)
|
||||||
{
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
var de = DamageEntries[i];
|
|
||||||
|
|
||||||
if (de.HasExpired)
|
|
||||||
{
|
|
||||||
DamageEntries.RemoveAt(i);
|
|
||||||
}
|
|
||||||
else if (de.Damager == m)
|
|
||||||
{
|
{
|
||||||
return de;
|
return de;
|
||||||
}
|
}
|
||||||
|
|
@ -5866,8 +5899,13 @@ public partial class Mobile : IHued, IComparable<Mobile>, ISpawnable, IObjectPro
|
||||||
de.DamageGiven += amount;
|
de.DamageGiven += amount;
|
||||||
de.LastDamage = Core.Now;
|
de.LastDamage = Core.Now;
|
||||||
|
|
||||||
DamageEntries.Remove(de);
|
// Move to the tail so the list stays in LastDamage order.
|
||||||
DamageEntries.Add(de);
|
if (de.OnLinkList)
|
||||||
|
{
|
||||||
|
_damageEntries.Remove(de);
|
||||||
|
}
|
||||||
|
|
||||||
|
_damageEntries.AddLast(de);
|
||||||
|
|
||||||
var master = from.GetDamageMaster(this);
|
var master = from.GetDamageMaster(this);
|
||||||
|
|
||||||
|
|
@ -6129,6 +6167,7 @@ public partial class Mobile : IHued, IComparable<Mobile>, ISpawnable, IObjectPro
|
||||||
|
|
||||||
switch (version)
|
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 37: // Decomposed hair into inline item id/hue (dropped the VirtualHairInfo object)
|
||||||
case 36: // Moved virtues to VirtueSystem
|
case 36: // Moved virtues to VirtueSystem
|
||||||
case 35: // Moved short term murders to PlayerMurderSystem
|
case 35: // Moved short term murders to PlayerMurderSystem
|
||||||
|
|
@ -6137,9 +6176,18 @@ public partial class Mobile : IHued, IComparable<Mobile>, ISpawnable, IObjectPro
|
||||||
case 32: // Removed StuckMenu
|
case 32: // Removed StuckMenu
|
||||||
case 31:
|
case 31:
|
||||||
{
|
{
|
||||||
LastStrGain = reader.ReadDeltaTime();
|
if (version >= 38)
|
||||||
LastIntGain = reader.ReadDeltaTime();
|
{
|
||||||
LastDexGain = reader.ReadDeltaTime();
|
LastStrGain = reader.ReadAnchoredTime();
|
||||||
|
LastIntGain = reader.ReadAnchoredTime();
|
||||||
|
LastDexGain = reader.ReadAnchoredTime();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
LastStrGain = reader.ReadDeltaTime();
|
||||||
|
LastIntGain = reader.ReadDeltaTime();
|
||||||
|
LastDexGain = reader.ReadDeltaTime();
|
||||||
|
}
|
||||||
|
|
||||||
goto case 30;
|
goto case 30;
|
||||||
}
|
}
|
||||||
|
|
@ -6447,9 +6495,6 @@ public partial class Mobile : IHued, IComparable<Mobile>, ISpawnable, IObjectPro
|
||||||
m_DexLock = (StatLockType)reader.ReadByte();
|
m_DexLock = (StatLockType)reader.ReadByte();
|
||||||
m_IntLock = (StatLockType)reader.ReadByte();
|
m_IntLock = (StatLockType)reader.ReadByte();
|
||||||
|
|
||||||
_statMods = new List<StatMod>();
|
|
||||||
_skillMods = new List<SkillMod>();
|
|
||||||
|
|
||||||
if (version < 32)
|
if (version < 32)
|
||||||
{
|
{
|
||||||
if (reader.ReadBool())
|
if (reader.ReadBool())
|
||||||
|
|
@ -7227,8 +7272,18 @@ public partial class Mobile : IHued, IComparable<Mobile>, ISpawnable, IObjectPro
|
||||||
|
|
||||||
private ObjectPropertyList InitializePropertyList(ObjectPropertyList list)
|
private ObjectPropertyList InitializePropertyList(ObjectPropertyList list)
|
||||||
{
|
{
|
||||||
GetProperties(list);
|
list.IsBuilding = true;
|
||||||
list.Terminate();
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
GetProperties(list);
|
||||||
|
list.Terminate();
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
list.IsBuilding = false;
|
||||||
|
}
|
||||||
|
|
||||||
return list;
|
return list;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -7245,6 +7300,26 @@ public partial class Mobile : IHued, IComparable<Mobile>, ISpawnable, IObjectPro
|
||||||
return;
|
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)
|
if (m_Map != null && m_Map != Map.Internal && !World.Loading)
|
||||||
{
|
{
|
||||||
int? oldHash;
|
int? oldHash;
|
||||||
|
|
@ -7752,13 +7827,10 @@ public partial class Mobile : IHued, IComparable<Mobile>, ISpawnable, IObjectPro
|
||||||
m_FollowersMax = 5;
|
m_FollowersMax = 5;
|
||||||
Skills = new Skills(this);
|
Skills = new Skills(this);
|
||||||
Items = new List<Item>();
|
Items = new List<Item>();
|
||||||
_statMods = new List<StatMod>();
|
|
||||||
_skillMods = new List<SkillMod>();
|
|
||||||
Map = Map.Internal;
|
Map = Map.Internal;
|
||||||
AutoPageNotify = true;
|
AutoPageNotify = true;
|
||||||
Aggressors = new List<AggressorInfo>();
|
Aggressors = new List<AggressorInfo>();
|
||||||
Aggressed = new List<AggressorInfo>();
|
Aggressed = new List<AggressorInfo>();
|
||||||
DamageEntries = new List<DamageEntry>();
|
|
||||||
|
|
||||||
NextSkillTime = Core.TickCount;
|
NextSkillTime = Core.TickCount;
|
||||||
}
|
}
|
||||||
|
|
@ -7790,6 +7862,12 @@ public partial class Mobile : IHued, IComparable<Mobile>, ISpawnable, IObjectPro
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// True when deltas remain queued after a <see cref="ProcessDeltaQueue"/> pass, which is
|
||||||
|
/// bounded by the count it saw on entry. The event loop consults this before sleeping.
|
||||||
|
/// </summary>
|
||||||
|
public static bool HasQueuedDeltas => m_DeltaQueue.Count > 0;
|
||||||
|
|
||||||
public static void ProcessDeltaQueue()
|
public static void ProcessDeltaQueue()
|
||||||
{
|
{
|
||||||
var limit = m_DeltaQueue.Count;
|
var limit = m_DeltaQueue.Count;
|
||||||
|
|
|
||||||
|
|
@ -21,17 +21,15 @@ namespace Server;
|
||||||
[SerializationGenerator(0)]
|
[SerializationGenerator(0)]
|
||||||
public partial class ResistanceMod : MobileMod
|
public partial class ResistanceMod : MobileMod
|
||||||
{
|
{
|
||||||
[SerializableField(0)]
|
[SerializableField(0, fieldChanged: nameof(OnTypeChanged))]
|
||||||
private ResistanceType _type;
|
private ResistanceType _type;
|
||||||
|
|
||||||
[SerializableFieldChanged(0)]
|
|
||||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||||
private void OnTypeChanged(ResistanceType oldValue, ResistanceType newValue) => Owner?.UpdateResistances();
|
private void OnTypeChanged(ResistanceType oldValue, ResistanceType newValue) => Owner?.UpdateResistances();
|
||||||
|
|
||||||
[SerializableField(1)]
|
[SerializableField(1, fieldChanged: nameof(OnOffsetChanged))]
|
||||||
private int _offset;
|
private int _offset;
|
||||||
|
|
||||||
[SerializableFieldChanged(1)]
|
|
||||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||||
private void OnOffsetChanged(int oldValue, int newValue) => Owner?.UpdateResistances();
|
private void OnOffsetChanged(int oldValue, int newValue) => Owner?.UpdateResistances();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -21,33 +21,29 @@ namespace Server;
|
||||||
[SerializationGenerator(0)]
|
[SerializationGenerator(0)]
|
||||||
public abstract partial class SkillMod : MobileMod
|
public abstract partial class SkillMod : MobileMod
|
||||||
{
|
{
|
||||||
[SerializableField(0)]
|
[SerializableField(0, fieldChanged: nameof(OnObeyCapChanged))]
|
||||||
private bool _obeyCap;
|
private bool _obeyCap;
|
||||||
|
|
||||||
[SerializableFieldChanged(0)]
|
|
||||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||||
private void OnObeCapChanged(bool oldValue, bool newValue) => Owner?.Skills[_skill]?.Update();
|
private void OnObeyCapChanged(bool oldValue, bool newValue) => Owner?.Skills[_skill]?.Update();
|
||||||
|
|
||||||
[SerializableField(1)]
|
[SerializableField(1, fieldChanged: nameof(OnSkillChanged))]
|
||||||
private SkillName _skill;
|
private SkillName _skill;
|
||||||
|
|
||||||
[SerializableFieldChanged(1)]
|
|
||||||
private void OnSkillChanged(SkillName oldValue, SkillName newValue)
|
private void OnSkillChanged(SkillName oldValue, SkillName newValue)
|
||||||
{
|
{
|
||||||
Owner?.Skills[newValue]?.Update();
|
Owner?.Skills[newValue]?.Update();
|
||||||
Owner?.Skills[oldValue]?.Update();
|
Owner?.Skills[oldValue]?.Update();
|
||||||
}
|
}
|
||||||
|
|
||||||
[SerializableField(2)]
|
[SerializableField(2, fieldChanged: nameof(OnRelativeChanged))]
|
||||||
private bool _relative;
|
private bool _relative;
|
||||||
|
|
||||||
[SerializableFieldChanged(2)]
|
|
||||||
private void OnRelativeChanged(bool oldValue, bool newValue) => Owner?.Skills[_skill]?.Update();
|
private void OnRelativeChanged(bool oldValue, bool newValue) => Owner?.Skills[_skill]?.Update();
|
||||||
|
|
||||||
[SerializableField(3)]
|
[SerializableField(3, fieldChanged: nameof(OnValueChanged))]
|
||||||
private double _value;
|
private double _value;
|
||||||
|
|
||||||
[SerializableFieldChanged(3)]
|
|
||||||
private void OnValueChanged(double oldValue, double newValue) => Owner?.Skills[_skill]?.Update();
|
private void OnValueChanged(double oldValue, double newValue) => Owner?.Skills[_skill]?.Update();
|
||||||
|
|
||||||
public SkillMod(Mobile owner) : base(owner)
|
public SkillMod(Mobile owner) : base(owner)
|
||||||
|
|
|
||||||
160
Projects/Server/Network/Bans/BanChannel.cs
Normal file
160
Projects/Server/Network/Bans/BanChannel.cs
Normal file
|
|
@ -0,0 +1,160 @@
|
||||||
|
/*************************************************************************
|
||||||
|
* 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 <http://www.gnu.org/licenses/>. *
|
||||||
|
*************************************************************************/
|
||||||
|
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Net;
|
||||||
|
using System.Threading;
|
||||||
|
using Server.Logging;
|
||||||
|
|
||||||
|
namespace Server.Network.Bans;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Coordinates the configured <see cref="IBanReporter"/> contribution sinks. Enforcement is NOT here —
|
||||||
|
/// the accept path asks <see cref="ConnectionFilters"/>. This channel only fans locally-decided bans out
|
||||||
|
/// to external systems (CrowdSec), which distribute them to OS-level bouncers.
|
||||||
|
/// </summary>
|
||||||
|
public static class BanChannel
|
||||||
|
{
|
||||||
|
private static readonly ILogger logger = LogFactory.GetLogger(typeof(BanChannel));
|
||||||
|
|
||||||
|
private static IBanReporter[] _reporters = [];
|
||||||
|
|
||||||
|
public static IReadOnlyList<IBanReporter> Reporters => _reporters;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Registers a contribution sink from content (inversion of control). Idempotent by
|
||||||
|
/// <see cref="IBanReporter.Name"/>: a second registration of the same name is ignored. Configures the
|
||||||
|
/// reporter immediately so it is ready before <see cref="Start"/>.
|
||||||
|
/// </summary>
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Fans a locally-decided ban out to every reporter. Non-blocking; never throws.</summary>
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// An implementation that ignores <c>reason</c> would silently swallow manual bans. See
|
||||||
|
/// <see cref="BanReasons.IsBehavioral"/>.
|
||||||
|
/// </remarks>
|
||||||
|
public static Func<IPAddress, string, bool> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Fans a retraction (manual unban) out to every retract-capable reporter.</summary>
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
98
Projects/Server/Network/Bans/BanConfiguration.cs
Normal file
98
Projects/Server/Network/Bans/BanConfiguration.cs
Normal file
|
|
@ -0,0 +1,98 @@
|
||||||
|
/*************************************************************************
|
||||||
|
* 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 <http://www.gnu.org/licenses/>. *
|
||||||
|
*************************************************************************/
|
||||||
|
|
||||||
|
using System;
|
||||||
|
using System.IO;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
using Server.Json;
|
||||||
|
|
||||||
|
namespace Server.Network.Bans;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Loads the <see cref="BanSettings"/> from <c>Configuration/bans.json</c> (matching the per-feature
|
||||||
|
/// JSON config pattern used by <c>AssistantConfiguration</c>). Loaded once; a missing file writes a
|
||||||
|
/// local-only, fail-open template so operators have something to edit.
|
||||||
|
/// </summary>
|
||||||
|
public static class BanConfiguration
|
||||||
|
{
|
||||||
|
private const string _path = "Configuration/bans.json";
|
||||||
|
|
||||||
|
private static bool _loaded;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Never null: the accept and reap paths read this per connection, including before <see cref="Configure"/>
|
||||||
|
/// has run (a harness driving <c>NetState.Slice</c> directly), so it starts at the record's defaults.
|
||||||
|
/// </summary>
|
||||||
|
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<BanSettings>(path);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Settings = new BanSettings
|
||||||
|
{
|
||||||
|
ReportRateLimitTrips = true,
|
||||||
|
AutoBanDuration = TimeSpan.FromHours(4)
|
||||||
|
};
|
||||||
|
|
||||||
|
Save();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void Save()
|
||||||
|
{
|
||||||
|
JsonConfig.Serialize(Path.Join(Core.BaseDirectory, _path), Settings);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Ban-channel policy: which reporters receive contributions, and how auto-detections are handled.</summary>
|
||||||
|
public record BanSettings
|
||||||
|
{
|
||||||
|
/// <summary>Whether IP rate-limiter trips are contributed to reporters. They never enter the local firewall set.</summary>
|
||||||
|
[JsonPropertyName("reportRateLimitTrips")]
|
||||||
|
public bool ReportRateLimitTrips { get; set; } = true;
|
||||||
|
|
||||||
|
/// <summary>Duration reported for an auto-detected (rate-limit) ban.</summary>
|
||||||
|
[JsonPropertyName("autoBanDuration")]
|
||||||
|
public TimeSpan AutoBanDuration { get; set; } = TimeSpan.FromHours(4);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Whether behavioural detections are contributed to reporters. Those connections are disconnected either
|
||||||
|
/// way; this only controls escalation.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// 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 <c>dev-docs/ip-bans-and-allowlists.md</c>.
|
||||||
|
/// </remarks>
|
||||||
|
[JsonPropertyName("reportBadConnects")]
|
||||||
|
public bool ReportBadConnects { get; set; } = true;
|
||||||
|
|
||||||
|
[JsonPropertyName("badConnectDuration")]
|
||||||
|
public TimeSpan BadConnectDuration { get; set; } = TimeSpan.FromHours(4);
|
||||||
|
}
|
||||||
48
Projects/Server/Network/Bans/BanReasons.cs
Normal file
48
Projects/Server/Network/Bans/BanReasons.cs
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
/*************************************************************************
|
||||||
|
* 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 <http://www.gnu.org/licenses/>. *
|
||||||
|
*************************************************************************/
|
||||||
|
|
||||||
|
namespace Server.Network.Bans;
|
||||||
|
|
||||||
|
/// <summary>The <c>reason</c> slugs contributed through <see cref="BanChannel"/>. Policy keys off these.</summary>
|
||||||
|
public static class BanReasons
|
||||||
|
{
|
||||||
|
/// <summary>An operator banned this address explicitly. Never exempt, never auto-denied.</summary>
|
||||||
|
public const string Manual = "manual";
|
||||||
|
|
||||||
|
public const string RateLimit = "rate-limit";
|
||||||
|
|
||||||
|
/// <summary>Matched the reputation blocklist. Enforced by its own filter, not by behaviour.</summary>
|
||||||
|
public const string Blocklist = "blocklist";
|
||||||
|
|
||||||
|
/// <summary>Reaped without ever sending a byte.</summary>
|
||||||
|
public const string SilentConnect = "silent-connect";
|
||||||
|
|
||||||
|
/// <summary>Opened with a zero seed, which no real client sends.</summary>
|
||||||
|
public const string InvalidSeed = "invalid-seed";
|
||||||
|
|
||||||
|
/// <summary>Positively identified as another protocol entirely. See <see cref="ForeignProtocol"/>.</summary>
|
||||||
|
public const string ForeignProtocol = "foreign-protocol";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Verdicts the shard reached by watching the connection. Only these may be exempted, and only these feed
|
||||||
|
/// the local denylist.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Opt-in rather than "everything except <see cref="Manual"/>", so a reason added later escalates normally
|
||||||
|
/// instead of silently inheriting an exemption.
|
||||||
|
/// </remarks>
|
||||||
|
public static bool IsBehavioral(string reason) =>
|
||||||
|
reason is RateLimit or SilentConnect or InvalidSeed or ForeignProtocol;
|
||||||
|
}
|
||||||
55
Projects/Server/Network/Bans/IBanReporter.cs
Normal file
55
Projects/Server/Network/Bans/IBanReporter.cs
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
/*************************************************************************
|
||||||
|
* 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 <http://www.gnu.org/licenses/>. *
|
||||||
|
*************************************************************************/
|
||||||
|
|
||||||
|
using System;
|
||||||
|
using System.Net;
|
||||||
|
using System.Threading;
|
||||||
|
|
||||||
|
namespace Server.Network.Bans;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A contribution sink behind <see cref="BanChannel"/>. 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 <see cref="IConnectionFilter"/>'s job.
|
||||||
|
/// </summary>
|
||||||
|
public interface IBanReporter
|
||||||
|
{
|
||||||
|
/// <summary>Stable id for logging/config (e.g. <c>crowdsec</c>).</summary>
|
||||||
|
string Name { get; }
|
||||||
|
|
||||||
|
/// <summary>Reads configuration. No network or file I/O here.</summary>
|
||||||
|
void Register();
|
||||||
|
|
||||||
|
/// <summary>Starts background delivery. The token is cancelled on shutdown.</summary>
|
||||||
|
void Start(CancellationToken token);
|
||||||
|
|
||||||
|
/// <summary>Flushes and tears down background delivery.</summary>
|
||||||
|
void Stop();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="ttl"><see cref="TimeSpan.Zero"/> or negative = use the reporter's default duration.</param>
|
||||||
|
/// <param name="reason">Short slug (<c>manual</c>, <c>rate-limit</c>) used as the scenario suffix.</param>
|
||||||
|
void Report(IPAddress address, TimeSpan ttl, string reason);
|
||||||
|
|
||||||
|
/// <summary>True if this reporter can retract a previously-reported ban.</summary>
|
||||||
|
bool CanRetract { get; }
|
||||||
|
|
||||||
|
/// <summary>Enqueues a retraction (e.g. a manual unban). No-op if unsupported.</summary>
|
||||||
|
void Retract(IPAddress address);
|
||||||
|
}
|
||||||
155
Projects/Server/Network/ConnectionFilters.cs
Normal file
155
Projects/Server/Network/ConnectionFilters.cs
Normal file
|
|
@ -0,0 +1,155 @@
|
||||||
|
/*************************************************************************
|
||||||
|
* 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 <http://www.gnu.org/licenses/>. *
|
||||||
|
*************************************************************************/
|
||||||
|
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Net;
|
||||||
|
using System.Threading;
|
||||||
|
using Server.Logging;
|
||||||
|
|
||||||
|
namespace Server.Network;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Registry of the <see cref="IConnectionFilter"/> gates the accept path consults, and their lifecycle.
|
||||||
|
/// Filters are registered during the Configure sweep and the backing store is a plain array, so
|
||||||
|
/// <see cref="ShouldDeny"/> 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.
|
||||||
|
/// </summary>
|
||||||
|
public static class ConnectionFilters
|
||||||
|
{
|
||||||
|
private static readonly ILogger logger = LogFactory.GetLogger(typeof(ConnectionFilters));
|
||||||
|
|
||||||
|
private static IConnectionFilter[] _filters = [];
|
||||||
|
|
||||||
|
public static IReadOnlyList<IConnectionFilter> Filters => _filters;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Registers a gate (inversion of control, mirroring <c>BanChannel.Register</c>). Idempotent by
|
||||||
|
/// <see cref="IConnectionFilter.Name"/>. Filters are consulted in registration order, so register
|
||||||
|
/// the cheapest and most selective first — core registers the firewall before content is swept.
|
||||||
|
/// </summary>
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// True when any filter denies the connection. Short-circuits on the first denial;
|
||||||
|
/// <paramref name="deniedBy"/> names it for logging.
|
||||||
|
/// </summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
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<IConnectionFilter>(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 = [];
|
||||||
|
}
|
||||||
|
|
@ -1,168 +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 <http://www.gnu.org/licenses/>. *
|
|
||||||
*************************************************************************/
|
|
||||||
|
|
||||||
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<IPAddress, int> _isBlockedCache = [];
|
|
||||||
private static readonly ReaderWriterLockSlim _firewallLock = new(LockRecursionPolicy.NoRecursion);
|
|
||||||
|
|
||||||
private static int _firewallVersion;
|
|
||||||
private static readonly SortedSet<IFirewallEntry> _firewallSet = [];
|
|
||||||
|
|
||||||
public static int FirewallSetCount => _firewallSet.Count;
|
|
||||||
|
|
||||||
public static void ReadFirewallSet(Action<IReadOnlySet<IFirewallEntry>> 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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
146
Projects/Server/Network/ForeignProtocol.cs
Normal file
146
Projects/Server/Network/ForeignProtocol.cs
Normal file
|
|
@ -0,0 +1,146 @@
|
||||||
|
/*************************************************************************
|
||||||
|
* 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 <http://www.gnu.org/licenses/>. *
|
||||||
|
*************************************************************************/
|
||||||
|
|
||||||
|
using System;
|
||||||
|
|
||||||
|
namespace Server.Network;
|
||||||
|
|
||||||
|
public enum ForeignProtocolKind
|
||||||
|
{
|
||||||
|
None,
|
||||||
|
Http,
|
||||||
|
Tls,
|
||||||
|
Ssh
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum ForeignProtocolMatch
|
||||||
|
{
|
||||||
|
None,
|
||||||
|
|
||||||
|
/// <summary>A prefix matched, but more bytes are needed to be sure.</summary>
|
||||||
|
Incomplete,
|
||||||
|
|
||||||
|
Confirmed
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Identifies traffic that is positively some OTHER protocol (HTTP, TLS, SSH), rather than deciding whether
|
||||||
|
/// a connection is a good Ultima Online client.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The direction matters. A legitimate client with encryption enabled when the shard expects none sends a
|
||||||
|
/// structurally correct connection whose payload is noise, because <c>LoginEncryption.ClientDecrypt</c> 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
|
||||||
|
/// <c>dev-docs/ip-bans-and-allowlists.md</c>.
|
||||||
|
/// </remarks>
|
||||||
|
public static class ForeignProtocol
|
||||||
|
{
|
||||||
|
private const int RequiredBytes = 8;
|
||||||
|
private const int MaxTlsRecordLength = 16384;
|
||||||
|
|
||||||
|
public static ForeignProtocolMatch Identify(ReadOnlySpan<byte> 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<byte> 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<byte> 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<byte> buffer, string ascii)
|
||||||
|
{
|
||||||
|
for (var i = 0; i < ascii.Length; i++)
|
||||||
|
{
|
||||||
|
if (buffer[i] != (byte)ascii[i])
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
60
Projects/Server/Network/IConnectionFilter.cs
Normal file
60
Projects/Server/Network/IConnectionFilter.cs
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
/*************************************************************************
|
||||||
|
* 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 <http://www.gnu.org/licenses/>. *
|
||||||
|
*************************************************************************/
|
||||||
|
|
||||||
|
using System.Net;
|
||||||
|
using System.Threading;
|
||||||
|
|
||||||
|
namespace Server.Network;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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 <c>Firewall</c> and <c>BlocklistFilter</c> in UOContent).
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// <see cref="ShouldDeny"/> 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.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Side effects that a hit implies (contributing to <c>BanChannel</c>, promoting to an OS firewall,
|
||||||
|
/// suppressing duplicate reports) are the filter's own business, not the accept path's. This is why
|
||||||
|
/// <see cref="ShouldDeny"/> returns a bare bool: the accept path asks one question and does one thing.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public interface IConnectionFilter
|
||||||
|
{
|
||||||
|
/// <summary>Stable id for logging/config (e.g. <c>firewall</c>, <c>blocklist</c>).</summary>
|
||||||
|
string Name { get; }
|
||||||
|
|
||||||
|
/// <summary>Reads configuration. Called by <see cref="ConnectionFilters.Register"/>. No I/O beyond config.</summary>
|
||||||
|
void Register();
|
||||||
|
|
||||||
|
/// <summary>Starts any background hydration. The token is cancelled on shutdown.</summary>
|
||||||
|
void Start(CancellationToken token);
|
||||||
|
|
||||||
|
/// <summary>Flushes and tears down. Called during shutdown.</summary>
|
||||||
|
void Stop();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// True to deny the connection. Must be allocation-free and non-blocking; see the remarks on
|
||||||
|
/// <see cref="IConnectionFilter"/>.
|
||||||
|
/// </summary>
|
||||||
|
bool ShouldDeny(IPAddress address);
|
||||||
|
}
|
||||||
|
|
@ -50,9 +50,6 @@ public static class MovementThrottle
|
||||||
private const int ClientMaxUnackedMovements = 5;
|
private const int ClientMaxUnackedMovements = 5;
|
||||||
private const int MaxQueueWithUnmodifiedClient = ClientMaxUnackedMovements - 1; // 4
|
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
|
// Track NetStates with queued movements for efficient processing
|
||||||
private static readonly HashSet<NetState> _netStatesWithQueuedMovements = new(256);
|
private static readonly HashSet<NetState> _netStatesWithQueuedMovements = new(256);
|
||||||
|
|
||||||
|
|
@ -83,15 +80,9 @@ public static class MovementThrottle
|
||||||
|
|
||||||
public static void Configure()
|
public static void Configure()
|
||||||
{
|
{
|
||||||
_maxCredit = ServerConfiguration.GetOrUpdateSetting(
|
_maxCredit = ServerConfiguration.GetOrUpdateSetting("movementThrottle.maxCredit", _maxCredit);
|
||||||
"movementThrottle.maxCredit",
|
_maxRttBonus = ServerConfiguration.GetOrUpdateSetting("movementThrottle.maxRttBonus", _maxRttBonus);
|
||||||
_maxCredit
|
_hardQueueLimit = ServerConfiguration.GetOrUpdateSetting("movementThrottle.hardQueueLimit", _hardQueueLimit);
|
||||||
);
|
|
||||||
|
|
||||||
_hardQueueLimit = ServerConfiguration.GetOrUpdateSetting(
|
|
||||||
"movementThrottle.hardQueueLimit",
|
|
||||||
_hardQueueLimit
|
|
||||||
);
|
|
||||||
|
|
||||||
_movementHistorySize = ServerConfiguration.GetOrUpdateSetting(
|
_movementHistorySize = ServerConfiguration.GetOrUpdateSetting(
|
||||||
"movementThrottle.movementHistorySize",
|
"movementThrottle.movementHistorySize",
|
||||||
|
|
@ -103,6 +94,13 @@ public static class MovementThrottle
|
||||||
_minSamplesForRate
|
_minSamplesForRate
|
||||||
);
|
);
|
||||||
|
|
||||||
|
_maxChainGap = ServerConfiguration.GetOrUpdateSetting("movementThrottle.maxChainGap", _maxChainGap);
|
||||||
|
|
||||||
|
_speedHackNotificationCooldown = ServerConfiguration.GetOrUpdateSetting(
|
||||||
|
"movementThrottle.speedHackNotificationCooldown",
|
||||||
|
_speedHackNotificationCooldown
|
||||||
|
);
|
||||||
|
|
||||||
_suspiciousRateThreshold = (float)ServerConfiguration.GetOrUpdateSetting(
|
_suspiciousRateThreshold = (float)ServerConfiguration.GetOrUpdateSetting(
|
||||||
"movementThrottle.suspiciousRateThreshold",
|
"movementThrottle.suspiciousRateThreshold",
|
||||||
_suspiciousRateThreshold
|
_suspiciousRateThreshold
|
||||||
|
|
@ -112,11 +110,6 @@ public static class MovementThrottle
|
||||||
"movementThrottle.definiteRateThreshold",
|
"movementThrottle.definiteRateThreshold",
|
||||||
_definiteRateThreshold
|
_definiteRateThreshold
|
||||||
);
|
);
|
||||||
|
|
||||||
_debugLogging = ServerConfiguration.GetOrUpdateSetting(
|
|
||||||
"movementThrottle.debugLogging",
|
|
||||||
_debugLogging
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
@ -191,15 +184,16 @@ public static class MovementThrottle
|
||||||
// Credit can go negative up to -dynamicCredit (debt limit)
|
// Credit can go negative up to -dynamicCredit (debt limit)
|
||||||
if (ns._movementCredit - earlyAmount >= -dynamicCredit)
|
if (ns._movementCredit - earlyAmount >= -dynamicCredit)
|
||||||
{
|
{
|
||||||
var prevCredit = ns._movementCredit;
|
|
||||||
// Use credit to cover early arrival
|
// Use credit to cover early arrival
|
||||||
ns._movementCredit -= earlyAmount;
|
ns._movementCredit -= earlyAmount;
|
||||||
|
|
||||||
if (_debugLogging && ns._movementLogging)
|
if (ns._movementLogging)
|
||||||
{
|
{
|
||||||
|
var prevCredit = ns._movementCredit + earlyAmount;
|
||||||
|
|
||||||
logger.Debug(
|
logger.Debug(
|
||||||
"[Credit] {Name}: delta={Delta}ms early={Early}ms credit={PrevCredit}->{Credit}/{MaxCredit} action=execute",
|
"[Credit] {Name}: delta={Delta}ms early={Early}ms credit={PrevCredit}->{Credit}/{MaxCredit} action=execute",
|
||||||
mobile.RawName, delta, earlyAmount, prevCredit, ns._movementCredit, dynamicCredit
|
mobile, delta, earlyAmount, prevCredit, ns._movementCredit, dynamicCredit
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -208,11 +202,11 @@ public static class MovementThrottle
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (_debugLogging && ns._movementLogging)
|
if (ns._movementLogging)
|
||||||
{
|
{
|
||||||
logger.Debug(
|
logger.Debug(
|
||||||
"[Credit] {Name}: delta={Delta}ms early={Early}ms credit={Credit}/{MaxCredit} EXHAUSTED -> queue",
|
"[Credit] {Name}: delta={Delta}ms early={Early}ms credit={Credit}/{MaxCredit} EXHAUSTED -> queue",
|
||||||
mobile.RawName, delta, earlyAmount, ns._movementCredit, dynamicCredit
|
mobile, delta, earlyAmount, ns._movementCredit, dynamicCredit
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -227,11 +221,11 @@ public static class MovementThrottle
|
||||||
var prevCredit = ns._movementCredit;
|
var prevCredit = ns._movementCredit;
|
||||||
ns._movementCredit = Math.Min(ns._movementCredit + delta, dynamicCredit);
|
ns._movementCredit = Math.Min(ns._movementCredit + delta, dynamicCredit);
|
||||||
|
|
||||||
if (_debugLogging && ns._movementLogging && ns._movementCredit != prevCredit)
|
if (ns._movementLogging && ns._movementCredit != prevCredit)
|
||||||
{
|
{
|
||||||
logger.Debug(
|
logger.Debug(
|
||||||
"[Credit] {Name}: delta=+{Delta}ms credit={PrevCredit}->{Credit}/{MaxCredit} action=execute",
|
"[Credit] {Name}: delta=+{Delta}ms credit={PrevCredit}->{Credit}/{MaxCredit} action=execute",
|
||||||
mobile.RawName, delta, prevCredit, ns._movementCredit, dynamicCredit
|
mobile, delta, prevCredit, ns._movementCredit, dynamicCredit
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -247,12 +241,9 @@ public static class MovementThrottle
|
||||||
{
|
{
|
||||||
if (!mobile.Move(dir))
|
if (!mobile.Move(dir))
|
||||||
{
|
{
|
||||||
if (_debugLogging && ns._movementLogging)
|
if (ns._movementLogging)
|
||||||
{
|
{
|
||||||
logger.Debug(
|
logger.Debug("[Execute] {Name}: Move FAILED dir={Dir} seq={Seq} -> reject+reset", mobile, dir, seq);
|
||||||
"[Execute] {Name}: Move FAILED dir={Dir} seq={Seq} -> reject+reset",
|
|
||||||
mobile.RawName, dir, seq
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Movement failed (blocked, paralyzed, frozen, etc.)
|
// Movement failed (blocked, paralyzed, frozen, etc.)
|
||||||
|
|
@ -260,11 +251,11 @@ public static class MovementThrottle
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (_debugLogging && ns._movementLogging)
|
if (ns._movementLogging)
|
||||||
{
|
{
|
||||||
logger.Debug(
|
logger.Debug(
|
||||||
"[Execute] {Name}: Move OK dir={Dir} seq={Seq} nextMove={NextMove}ms",
|
"[Execute] {Name}: Move OK dir={Dir} seq={Seq} nextMove={NextMove}ms",
|
||||||
mobile.RawName, dir, seq, ns._nextMovementTime - Core.TickCount
|
mobile, dir, seq, ns._nextMovementTime - Core.TickCount
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -304,11 +295,11 @@ public static class MovementThrottle
|
||||||
ns._hasQueuedMovements = true;
|
ns._hasQueuedMovements = true;
|
||||||
_netStatesWithQueuedMovements.Add(ns);
|
_netStatesWithQueuedMovements.Add(ns);
|
||||||
|
|
||||||
if (_debugLogging && ns._movementLogging)
|
if (ns._movementLogging)
|
||||||
{
|
{
|
||||||
logger.Debug(
|
logger.Debug(
|
||||||
"[Queue] {Name}: enqueued dir={Dir} seq={Seq} (depth={Depth})",
|
"[Queue] {Name}: enqueued dir={Dir} seq={Seq} (depth={Depth})",
|
||||||
ns.Mobile?.RawName, dir, seq, ns._movementQueue.Count
|
ns.Mobile, dir, seq, ns._movementQueue.Count
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -320,7 +311,6 @@ public static class MovementThrottle
|
||||||
{
|
{
|
||||||
ns.SendMovementRej(seq, mobile);
|
ns.SendMovementRej(seq, mobile);
|
||||||
ns.ResetMovementState();
|
ns.ResetMovementState();
|
||||||
_netStatesWithQueuedMovements.Remove(ns);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
@ -333,20 +323,18 @@ public static class MovementThrottle
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Process each NetState with queued movements
|
foreach (var ns in _netStatesWithQueuedMovements)
|
||||||
// Use a snapshot to avoid modification during iteration
|
|
||||||
var toProcess = new List<NetState>(_netStatesWithQueuedMovements);
|
|
||||||
|
|
||||||
for (var i = 0; i < toProcess.Count; i++)
|
|
||||||
{
|
{
|
||||||
var ns = toProcess[i];
|
if (ns.Running)
|
||||||
if (!ns.Running)
|
|
||||||
{
|
{
|
||||||
_netStatesWithQueuedMovements.Remove(ns);
|
ProcessMovementQueue(ns);
|
||||||
continue;
|
if (ns._hasQueuedMovements)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
ProcessMovementQueue(ns);
|
_netStatesWithQueuedMovements.Remove(ns);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -356,6 +344,7 @@ public static class MovementThrottle
|
||||||
public static void ProcessMovementQueue(NetState ns)
|
public static void ProcessMovementQueue(NetState ns)
|
||||||
{
|
{
|
||||||
var mobile = ns.Mobile;
|
var mobile = ns.Mobile;
|
||||||
|
|
||||||
if (mobile?.Deleted != false)
|
if (mobile?.Deleted != false)
|
||||||
{
|
{
|
||||||
ClearQueue(ns);
|
ClearQueue(ns);
|
||||||
|
|
@ -374,7 +363,7 @@ public static class MovementThrottle
|
||||||
while (ns._movementQueue?.Count > 0)
|
while (ns._movementQueue?.Count > 0)
|
||||||
{
|
{
|
||||||
// Check if it's time to execute
|
// Check if it's time to execute
|
||||||
if (now < ns._nextMovementTime)
|
if (now - ns._nextMovementTime < 0)
|
||||||
{
|
{
|
||||||
// Not yet - leave remaining items in queue for next Slice
|
// Not yet - leave remaining items in queue for next Slice
|
||||||
break;
|
break;
|
||||||
|
|
@ -394,11 +383,11 @@ public static class MovementThrottle
|
||||||
// Execute the move
|
// Execute the move
|
||||||
if (!mobile.Move(movement.Direction))
|
if (!mobile.Move(movement.Direction))
|
||||||
{
|
{
|
||||||
if (_debugLogging && ns._movementLogging)
|
if (ns._movementLogging)
|
||||||
{
|
{
|
||||||
logger.Debug(
|
logger.Debug(
|
||||||
"[Queue] {Name}: dequeued FAILED dir={Dir} (remaining={Remaining})",
|
"[Queue] {Name}: dequeued FAILED dir={Dir} (remaining={Remaining})",
|
||||||
mobile.RawName, movement.Direction, remaining
|
mobile, movement.Direction, remaining
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -407,12 +396,12 @@ public static class MovementThrottle
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (_debugLogging && ns._movementLogging)
|
if (ns._movementLogging)
|
||||||
{
|
{
|
||||||
var waited = now - ns._nextMovementTime;
|
var waited = now - ns._nextMovementTime;
|
||||||
logger.Debug(
|
logger.Debug(
|
||||||
"[Queue] {Name}: dequeued OK dir={Dir} (remaining={Remaining}, waited={Waited}ms)",
|
"[Queue] {Name}: dequeued OK dir={Dir} (remaining={Remaining}, waited={Waited}ms)",
|
||||||
mobile.RawName, movement.Direction, remaining, waited >= 0 ? waited : 0
|
mobile, movement.Direction, remaining, waited >= 0 ? waited : 0
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -430,10 +419,6 @@ public static class MovementThrottle
|
||||||
|
|
||||||
// Update tracking
|
// Update tracking
|
||||||
ns._hasQueuedMovements = ns._movementQueue?.Count > 0;
|
ns._hasQueuedMovements = ns._movementQueue?.Count > 0;
|
||||||
if (!ns._hasQueuedMovements)
|
|
||||||
{
|
|
||||||
_netStatesWithQueuedMovements.Remove(ns);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
@ -469,7 +454,6 @@ public static class MovementThrottle
|
||||||
{
|
{
|
||||||
ns._movementQueue?.Clear();
|
ns._movementQueue?.Clear();
|
||||||
ns._hasQueuedMovements = false;
|
ns._hasQueuedMovements = false;
|
||||||
_netStatesWithQueuedMovements.Remove(ns);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Maximum expected packets per second (mounted running = 100ms = 10/sec, plus tolerance)
|
// Maximum expected packets per second (mounted running = 100ms = 10/sec, plus tolerance)
|
||||||
|
|
@ -484,7 +468,7 @@ public static class MovementThrottle
|
||||||
logger.Information(
|
logger.Information(
|
||||||
"Movement queue overflow: {Character} ({Account}) | " +
|
"Movement queue overflow: {Character} ({Account}) | " +
|
||||||
"Queue reached hard limit: {Limit} | IP: {IP}",
|
"Queue reached hard limit: {Limit} | IP: {IP}",
|
||||||
mobile?.RawName ?? "Unknown",
|
mobile,
|
||||||
ns.Account?.Username ?? "Unknown",
|
ns.Account?.Username ?? "Unknown",
|
||||||
_hardQueueLimit,
|
_hardQueueLimit,
|
||||||
ns.Address
|
ns.Address
|
||||||
|
|
@ -516,7 +500,7 @@ public static class MovementThrottle
|
||||||
private static void RecordMovement(NetState ns, long now, int cost, Direction dir, Mobile mobile)
|
private static void RecordMovement(NetState ns, long now, int cost, Direction dir, Mobile mobile)
|
||||||
{
|
{
|
||||||
// Calculate interval since last movement
|
// Calculate interval since last movement
|
||||||
var interval = ns._lastMovementRecordTime > 0
|
var interval = ns._hasMovementRecord
|
||||||
? (int)(now - ns._lastMovementRecordTime)
|
? (int)(now - ns._lastMovementRecordTime)
|
||||||
: -1; // -1 indicates first movement (no previous time)
|
: -1; // -1 indicates first movement (no previous time)
|
||||||
|
|
||||||
|
|
@ -525,6 +509,7 @@ public static class MovementThrottle
|
||||||
if (interval <= 0 || interval > _maxChainGap)
|
if (interval <= 0 || interval > _maxChainGap)
|
||||||
{
|
{
|
||||||
ns._lastMovementRecordTime = now;
|
ns._lastMovementRecordTime = now;
|
||||||
|
ns._hasMovementRecord = true;
|
||||||
|
|
||||||
// Use RTT to distinguish "stopped moving" vs "lagged"
|
// Use RTT to distinguish "stopped moving" vs "lagged"
|
||||||
// - Stable low-latency connection with gap >> RTT → player stopped, reset history
|
// - Stable low-latency connection with gap >> RTT → player stopped, reset history
|
||||||
|
|
@ -544,19 +529,19 @@ public static class MovementThrottle
|
||||||
// A large gap followed by a burst of packets = likely lag recovery, not speed hack
|
// A large gap followed by a burst of packets = likely lag recovery, not speed hack
|
||||||
ns._lastGapDuration = interval;
|
ns._lastGapDuration = interval;
|
||||||
|
|
||||||
if (_debugLogging && mobile?.RawName != null)
|
if (ns._movementLogging)
|
||||||
{
|
{
|
||||||
var action = shouldReset ? "history reset" : "history preserved (possible lag)";
|
var action = shouldReset ? "history reset" : "history preserved (possible lag)";
|
||||||
logger.Debug(
|
logger.Debug(
|
||||||
"[Movement] {Name}: SKIP recording (gap {Gap}ms > {MaxGap}ms, " +
|
"[Movement] {Name}: SKIP recording (gap {Gap}ms > {MaxGap}ms, " +
|
||||||
"RTT={RTT}ms stable={Stable} → {Action})",
|
"RTT={RTT}ms stable={Stable} → {Action})",
|
||||||
mobile.RawName, interval, _maxChainGap, avgRtt, ns.HasStableConnection, action
|
mobile, interval, _maxChainGap, avgRtt, ns.HasStableConnection, action
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else if (_debugLogging && mobile?.RawName != null)
|
else if (ns._movementLogging)
|
||||||
{
|
{
|
||||||
logger.Debug("[Movement] {Name}: SKIP recording (first in chain)", mobile.RawName);
|
logger.Debug("[Movement] {Name}: SKIP recording (first in chain)", mobile);
|
||||||
}
|
}
|
||||||
|
|
||||||
return;
|
return;
|
||||||
|
|
@ -572,12 +557,9 @@ public static class MovementThrottle
|
||||||
// the next real move's interval artificially short, inflating rate.
|
// the next real move's interval artificially short, inflating rate.
|
||||||
if (cost == 0)
|
if (cost == 0)
|
||||||
{
|
{
|
||||||
if (_debugLogging && mobile?.RawName != null)
|
if (ns._movementLogging)
|
||||||
{
|
{
|
||||||
logger.Debug(
|
logger.Debug("[Movement] {Name}: SKIP direction-only change (preserves interval measurement)", mobile);
|
||||||
"[Movement] {Name}: SKIP direction-only change (preserves interval measurement)",
|
|
||||||
mobile.RawName
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -613,15 +595,16 @@ public static class MovementThrottle
|
||||||
}
|
}
|
||||||
|
|
||||||
ns._lastMovementRecordTime = now;
|
ns._lastMovementRecordTime = now;
|
||||||
|
ns._hasMovementRecord = true;
|
||||||
|
|
||||||
// Debug logging
|
// Debug logging
|
||||||
if (_debugLogging && mobile?.RawName != null)
|
if (ns._movementLogging)
|
||||||
{
|
{
|
||||||
var historyCount = ns._movementHistoryFull ? _movementHistorySize : ns._movementHistoryIndex;
|
var historyCount = ns._movementHistoryFull ? _movementHistorySize : ns._movementHistoryIndex;
|
||||||
logger.Debug(
|
logger.Debug(
|
||||||
"[Movement] {Name}: interval={Interval}ms target={Target}ms queue={Queue} " +
|
"[Movement] {Name}: interval={Interval}ms target={Target}ms queue={Queue} " +
|
||||||
"flags={Flags} history={History}/{MaxHistory} RTT={RTT}ms",
|
"flags={Flags} history={History}/{MaxHistory} RTT={RTT}ms",
|
||||||
mobile.RawName, interval, cost, record.QueueDepth,
|
mobile, interval, cost, record.QueueDepth,
|
||||||
flags, historyCount, _movementHistorySize, ns.AverageRtt
|
flags, historyCount, _movementHistorySize, ns.AverageRtt
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -814,7 +797,7 @@ public static class MovementThrottle
|
||||||
var averageRtt = ns.AverageRtt;
|
var averageRtt = ns.AverageRtt;
|
||||||
|
|
||||||
// Detailed rate breakdown for debugging
|
// Detailed rate breakdown for debugging
|
||||||
if (_debugLogging)
|
if (ns._movementLogging)
|
||||||
{
|
{
|
||||||
logger.Debug("[MovementAnalysis] Rate={Rate:F3}, Samples={Samples}, RTT={RTT}ms",
|
logger.Debug("[MovementAnalysis] Rate={Rate:F3}, Samples={Samples}, RTT={RTT}ms",
|
||||||
rate, sampleCount, averageRtt);
|
rate, sampleCount, averageRtt);
|
||||||
|
|
@ -977,19 +960,19 @@ public static class MovementThrottle
|
||||||
var verdict = AnalyzeMovement(ns, out var rate, out var sampleCount, out var confidence);
|
var verdict = AnalyzeMovement(ns, out var rate, out var sampleCount, out var confidence);
|
||||||
|
|
||||||
// Debug logging
|
// Debug logging
|
||||||
if (_debugLogging && ns.Mobile?.RawName != null)
|
if (ns._movementLogging)
|
||||||
{
|
{
|
||||||
var (burstSize, _) = DetectRecentBurst(ns);
|
var (burstSize, _) = DetectRecentBurst(ns);
|
||||||
var probeStatus = ns._rttProbeTime > 0 ? "pending" : "idle";
|
var probeStatus = ns._rttProbePending ? "pending" : "idle";
|
||||||
var queueDepth = ns._movementQueue?.Count ?? 0;
|
var queueDepth = ns._movementQueue?.Count ?? 0;
|
||||||
logger.Debug(
|
logger.Debug(
|
||||||
"[RateCheck] {Name}: rate={Rate:F3} samples={Samples} verdict={Verdict} " +
|
"[RateCheck] {Name}: rate={Rate:F3} samples={Samples} verdict={Verdict} " +
|
||||||
"confidence={Confidence:P0} queue={Queue} burst={Burst} sustained={Sustained}s",
|
"confidence={Confidence:P0} queue={Queue} burst={Burst} sustained={Sustained}s",
|
||||||
ns.Mobile.RawName, rate, sampleCount, verdict, confidence, queueDepth, burstSize, ns._consecutiveHighRateSeconds
|
ns.Mobile, rate, sampleCount, verdict, confidence, queueDepth, burstSize, ns._consecutiveHighRateSeconds
|
||||||
);
|
);
|
||||||
logger.Debug(
|
logger.Debug(
|
||||||
" RTT: avg={Avg}ms last={Last}ms var={Var} samples={RttSamples} stable={Stable} probe={Probe}",
|
" RTT: avg={Avg}ms last={Last}ms var={Var} samples={RttSamples} stable={Stable} probe={Probe}",
|
||||||
ns.AverageRtt, ns._lastRtt, ns._rttVariance, ns._rttSampleCount, ns.HasStableConnection, probeStatus
|
ns.AverageRtt, ns.LastRtt, ns._rttVariance, ns._rttSampleCount, ns.HasStableConnection, probeStatus
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1025,11 +1008,11 @@ public static class MovementThrottle
|
||||||
|
|
||||||
if (shouldNotify)
|
if (shouldNotify)
|
||||||
{
|
{
|
||||||
if (_debugLogging)
|
if (ns._movementLogging)
|
||||||
{
|
{
|
||||||
logger.Debug(
|
logger.Debug(
|
||||||
"[ALERT] {Urgency} - {Name}: rate={Rate:F3} verdict={Verdict} confidence={Confidence:P0}",
|
"[ALERT] {Urgency} - {Name}: rate={Rate:F3} verdict={Verdict} confidence={Confidence:P0}",
|
||||||
urgency, ns.Mobile?.RawName, rate, verdict, confidence
|
urgency, ns.Mobile, rate, verdict, confidence
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
NotifyStaff(ns, rate, sampleCount, confidence, verdict, urgency);
|
NotifyStaff(ns, rate, sampleCount, confidence, verdict, urgency);
|
||||||
|
|
@ -1054,11 +1037,12 @@ public static class MovementThrottle
|
||||||
var now = Core.TickCount;
|
var now = Core.TickCount;
|
||||||
|
|
||||||
// Rate-limit notifications per player
|
// Rate-limit notifications per player
|
||||||
if (now - ns._lastSpeedHackNotification < _speedHackNotificationCooldown)
|
if (ns._speedHackNotified && now - ns._lastSpeedHackNotification < _speedHackNotificationCooldown)
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ns._speedHackNotified = true;
|
||||||
ns._lastSpeedHackNotification = now;
|
ns._lastSpeedHackNotification = now;
|
||||||
|
|
||||||
var mobile = ns.Mobile;
|
var mobile = ns.Mobile;
|
||||||
|
|
@ -1070,7 +1054,7 @@ public static class MovementThrottle
|
||||||
"PacketRate: {PacketRate}/s (peak: {PeakRate}/s) | RTT: {Rtt}ms (stable: {Stable}) | " +
|
"PacketRate: {PacketRate}/s (peak: {PeakRate}/s) | RTT: {Rtt}ms (stable: {Stable}) | " +
|
||||||
"Sustained: {Sustained}s | Queue: {Queue} | Location: {Location} Map: {Map} | IP: {IP}",
|
"Sustained: {Sustained}s | Queue: {Queue} | Location: {Location} Map: {Map} | IP: {IP}",
|
||||||
urgency,
|
urgency,
|
||||||
mobile?.RawName ?? "Unknown",
|
mobile,
|
||||||
ns.Account?.Username ?? "Unknown",
|
ns.Account?.Username ?? "Unknown",
|
||||||
rate,
|
rate,
|
||||||
sampleCount,
|
sampleCount,
|
||||||
|
|
@ -1138,7 +1122,7 @@ public static class MovementThrottle
|
||||||
Verdict = verdict,
|
Verdict = verdict,
|
||||||
Confidence = confidence,
|
Confidence = confidence,
|
||||||
AverageRtt = ns.AverageRtt,
|
AverageRtt = ns.AverageRtt,
|
||||||
LastRtt = ns._lastRtt,
|
LastRtt = ns.LastRtt,
|
||||||
RttVariance = ns._rttVariance,
|
RttVariance = ns._rttVariance,
|
||||||
StableConnection = ns.HasStableConnection,
|
StableConnection = ns.HasStableConnection,
|
||||||
RttSampleCount = ns._rttSampleCount,
|
RttSampleCount = ns._rttSampleCount,
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,6 @@
|
||||||
|
|
||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Diagnostics;
|
|
||||||
using System.Runtime.InteropServices;
|
using System.Runtime.InteropServices;
|
||||||
using Server.Logging;
|
using Server.Logging;
|
||||||
|
|
||||||
|
|
@ -70,23 +69,24 @@ public partial class NetState
|
||||||
internal Queue<QueuedMovement> _movementQueue; // Lazy initialized
|
internal Queue<QueuedMovement> _movementQueue; // Lazy initialized
|
||||||
internal long _movementCredit; // Credit buffer for timing jitter
|
internal long _movementCredit; // Credit buffer for timing jitter
|
||||||
internal long _nextMovementTime = Core.TickCount; // When next movement is allowed
|
internal long _nextMovementTime = Core.TickCount; // When next movement is allowed
|
||||||
internal int _sustainedQueueDepth; // Tracks sustained high queue depth
|
internal long _lastQueueDepthCheck = Core.TickCount; // Throttle depth check frequency
|
||||||
internal long _lastQueueDepthCheck; // Throttle depth check frequency
|
|
||||||
internal bool _hasQueuedMovements; // Fast check for Slice()
|
internal bool _hasQueuedMovements; // Fast check for Slice()
|
||||||
|
|
||||||
// Movement history for rate-based speed hack detection (lazy initialized)
|
// Movement history for rate-based speed hack detection (lazy initialized)
|
||||||
internal MovementRecord[] _movementHistory; // Circular buffer
|
internal MovementRecord[] _movementHistory; // Circular buffer
|
||||||
internal int _movementHistoryIndex; // Next write position (also serves as count until full)
|
internal int _movementHistoryIndex; // Next write position (also serves as count until full)
|
||||||
internal bool _movementHistoryFull; // True once buffer has wrapped
|
internal bool _movementHistoryFull; // True once buffer has wrapped
|
||||||
internal long _lastMovementRecordTime; // For calculating intervals
|
internal long _lastMovementRecordTime; // For calculating intervals (valid only when _hasMovementRecord)
|
||||||
|
internal bool _hasMovementRecord; // False until the first movement in a chain is seen
|
||||||
|
|
||||||
// Detection state
|
// Detection state
|
||||||
internal int _consecutiveHighRateSeconds; // Sustained detection counter
|
internal int _consecutiveHighRateSeconds; // Sustained detection counter
|
||||||
internal long _lastSpeedHackNotification; // Rate-limit notifications
|
internal long _lastSpeedHackNotification; // Rate-limit notifications (valid only when _speedHackNotified)
|
||||||
|
internal bool _speedHackNotified; // False until the first notification is sent
|
||||||
internal int _lastGapDuration; // Duration of last gap > maxChainGap (for burst forgiveness)
|
internal int _lastGapDuration; // Duration of last gap > maxChainGap (for burst forgiveness)
|
||||||
|
|
||||||
// Movement packet rate tracking (for speed hack detection)
|
// Movement packet rate tracking (for speed hack detection)
|
||||||
internal long _movementWindowStart; // Start of current 1-second window
|
internal long _movementWindowStart = Core.TickCount; // Start of current 1-second window
|
||||||
internal int _movementsInWindow; // Count in current window
|
internal int _movementsInWindow; // Count in current window
|
||||||
internal int _peakMovementRate; // Highest rate seen (packets/sec)
|
internal int _peakMovementRate; // Highest rate seen (packets/sec)
|
||||||
|
|
||||||
|
|
@ -100,10 +100,9 @@ public partial class NetState
|
||||||
_nextMovementTime = Core.TickCount;
|
_nextMovementTime = Core.TickCount;
|
||||||
_movementCredit = 0;
|
_movementCredit = 0;
|
||||||
_hasQueuedMovements = false;
|
_hasQueuedMovements = false;
|
||||||
_sustainedQueueDepth = 0;
|
|
||||||
|
|
||||||
// Reset movement history - next movement starts a new chain
|
// Reset movement history - next movement starts a new chain
|
||||||
_lastMovementRecordTime = 0;
|
_hasMovementRecord = false;
|
||||||
_movementHistoryIndex = 0;
|
_movementHistoryIndex = 0;
|
||||||
_movementHistoryFull = false;
|
_movementHistoryFull = false;
|
||||||
|
|
||||||
|
|
@ -113,7 +112,7 @@ public partial class NetState
|
||||||
_rttProbeInterval = RttProbeIntervalNormal;
|
_rttProbeInterval = RttProbeIntervalNormal;
|
||||||
|
|
||||||
// Reset packet rate window
|
// Reset packet rate window
|
||||||
_movementWindowStart = 0;
|
_movementWindowStart = Core.TickCount;
|
||||||
_movementsInWindow = 0;
|
_movementsInWindow = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -165,17 +164,19 @@ public partial class NetState
|
||||||
private const long MaxStableLatency = 200; // Max RTT (ms) for "stable" connection
|
private const long MaxStableLatency = 200; // Max RTT (ms) for "stable" connection
|
||||||
|
|
||||||
// RTT state
|
// RTT state
|
||||||
internal long _rttProbeTime; // When we sent the probe (0 = not waiting)
|
internal bool _rttProbePending; // True while waiting for a probe response
|
||||||
internal long _lastRtt; // Most recent RTT measurement
|
internal long _rttProbeTime; // When we sent the probe (valid only when _rttProbePending)
|
||||||
internal long[] _rttHistory; // Rolling history (lazy init)
|
internal long[] _rttHistory; // Rolling history (lazy init)
|
||||||
internal int _rttHistoryIndex; // Current position in history
|
internal int _rttHistoryIndex; // Current position in history
|
||||||
internal int _rttSampleCount; // Number of samples collected (saturates at RttHistorySize)
|
internal int _rttSampleCount; // Number of samples collected (saturates at RttHistorySize)
|
||||||
internal long _rttVariance; // Calculated variance for stability
|
internal long _rttVariance; // Calculated variance for stability
|
||||||
internal long _nextRttProbe; // When to send next probe
|
internal long _nextRttProbe = Core.TickCount; // When to send next probe
|
||||||
internal int _rttProbeInterval = RttProbeIntervalNormal; // Current probe interval
|
internal int _rttProbeInterval = RttProbeIntervalNormal; // Current probe interval
|
||||||
|
|
||||||
// High-resolution timestamp for RTT measurement (Stopwatch ticks, not game loop ticks)
|
/// <summary>
|
||||||
private long _rttProbeTimestampHiRes;
|
/// Gets the most recent RTT measurement, or 0 if none has been recorded.
|
||||||
|
/// </summary>
|
||||||
|
public long LastRtt => _rttSampleCount > 0 ? _rttHistory[(_rttHistoryIndex - 1) & (RttHistorySize - 1)] : 0;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Sets the RTT probe interval based on suspicion level.
|
/// Sets the RTT probe interval based on suspicion level.
|
||||||
|
|
@ -206,23 +207,22 @@ public partial class NetState
|
||||||
var now = Core.TickCount;
|
var now = Core.TickCount;
|
||||||
|
|
||||||
// Don't send if we're still waiting for a response
|
// Don't send if we're still waiting for a response
|
||||||
if (_rttProbeTime > 0)
|
if (_rttProbePending)
|
||||||
{
|
{
|
||||||
// Timeout after 10 seconds - connection is probably dead or very laggy
|
// Timeout after 10 seconds - connection is probably dead or very laggy
|
||||||
if (now - _rttProbeTime > 10000)
|
if (now - _rttProbeTime > 10000)
|
||||||
{
|
{
|
||||||
_rttProbeTime = 0;
|
_rttProbePending = false;
|
||||||
_rttProbeTimestampHiRes = 0;
|
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// First probe: send immediately when player starts moving
|
// First probe: send immediately when player starts moving
|
||||||
// Subsequent probes: send when interval has passed
|
// Subsequent probes: send when interval has passed
|
||||||
if (_nextRttProbe == 0 || now >= _nextRttProbe)
|
if (now - _nextRttProbe >= 0)
|
||||||
{
|
{
|
||||||
|
_rttProbePending = true;
|
||||||
_rttProbeTime = now;
|
_rttProbeTime = now;
|
||||||
_rttProbeTimestampHiRes = Stopwatch.GetTimestamp();
|
|
||||||
_nextRttProbe = now + _rttProbeInterval + Utility.Random(RttProbeJitter);
|
_nextRttProbe = now + _rttProbeInterval + Utility.Random(RttProbeJitter);
|
||||||
|
|
||||||
if (_movementLogging)
|
if (_movementLogging)
|
||||||
|
|
@ -242,10 +242,9 @@ public partial class NetState
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public void RecordRttMeasurement()
|
public void RecordRttMeasurement()
|
||||||
{
|
{
|
||||||
var nowHiRes = Stopwatch.GetTimestamp();
|
|
||||||
var now = Core.TickCount;
|
var now = Core.TickCount;
|
||||||
|
|
||||||
if (_rttProbeTime <= 0)
|
if (!_rttProbePending)
|
||||||
{
|
{
|
||||||
// Not expecting a response (client-initiated version send) - ignore silently
|
// Not expecting a response (client-initiated version send) - ignore silently
|
||||||
return;
|
return;
|
||||||
|
|
@ -253,19 +252,15 @@ public partial class NetState
|
||||||
|
|
||||||
var rtt = now - _rttProbeTime;
|
var rtt = now - _rttProbeTime;
|
||||||
|
|
||||||
// High-resolution RTT in microseconds
|
|
||||||
var rttHiResUs = (nowHiRes - _rttProbeTimestampHiRes) * 1_000_000 / Stopwatch.Frequency;
|
|
||||||
|
|
||||||
if (_movementLogging)
|
if (_movementLogging)
|
||||||
{
|
{
|
||||||
movementLogger.Debug(
|
movementLogger.Debug(
|
||||||
"[RTT-Response] {Account}: {Rtt}ms (HiRes: {RttHiRes:F2}ms)",
|
"[RTT-Response] {Account}: {Rtt}ms",
|
||||||
Account?.Username ?? _toString, rtt, rttHiResUs / 1000.0
|
Account?.Username ?? _toString, rtt
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
_rttProbeTime = 0;
|
_rttProbePending = false;
|
||||||
_rttProbeTimestampHiRes = 0;
|
|
||||||
|
|
||||||
// Sanity check - RTT should be positive and reasonable
|
// Sanity check - RTT should be positive and reasonable
|
||||||
if (rtt is <= 0 or > 10000)
|
if (rtt is <= 0 or > 10000)
|
||||||
|
|
@ -285,7 +280,6 @@ public partial class NetState
|
||||||
|
|
||||||
// Update history
|
// Update history
|
||||||
_rttHistory[_rttHistoryIndex++ & (RttHistorySize - 1)] = rtt;
|
_rttHistory[_rttHistoryIndex++ & (RttHistorySize - 1)] = rtt;
|
||||||
_lastRtt = rtt;
|
|
||||||
|
|
||||||
// Track sample count (saturates at buffer size)
|
// Track sample count (saturates at buffer size)
|
||||||
if (_rttSampleCount < RttHistorySize)
|
if (_rttSampleCount < RttHistorySize)
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ using System.Linq;
|
||||||
using System.Net;
|
using System.Net;
|
||||||
using System.Net.NetworkInformation;
|
using System.Net.NetworkInformation;
|
||||||
using System.Network;
|
using System.Network;
|
||||||
|
using System.Numerics;
|
||||||
|
|
||||||
namespace Server.Network;
|
namespace Server.Network;
|
||||||
|
|
||||||
|
|
@ -28,9 +29,10 @@ namespace Server.Network;
|
||||||
public partial class NetState
|
public partial class NetState
|
||||||
{
|
{
|
||||||
// Buffer sizes
|
// Buffer sizes
|
||||||
private const int RecvBufferSize = 1024 * 64; // 64KB recv buffers
|
private const int RecvBufferSize = 1024 * 64; // 64KB recv buffers
|
||||||
private const int SendBufferSize = 1024 * 256; // 256KB send buffers
|
private const int DefaultSendBufferSize = 1024 * 256; // 256KB send buffers
|
||||||
private const int MaxConnections = 4096; // Max concurrent connections
|
private const int MinSendBufferSize = 1024 * 64; // Platform allocation granularity
|
||||||
|
private const int MaxConnections = 4096; // Max concurrent connections
|
||||||
|
|
||||||
private static readonly Queue<NetState> _disposed = [];
|
private static readonly Queue<NetState> _disposed = [];
|
||||||
private static readonly TimeSpan ConnectingSocketIdleLimit = TimeSpan.FromMilliseconds(5000); // 5 seconds
|
private static readonly TimeSpan ConnectingSocketIdleLimit = TimeSpan.FromMilliseconds(5000); // 5 seconds
|
||||||
|
|
@ -41,7 +43,9 @@ public partial class NetState
|
||||||
// NetState storage indexed by RingSocket.Id
|
// NetState storage indexed by RingSocket.Id
|
||||||
private static readonly NetState[] _netStates = new NetState[MaxConnections];
|
private static readonly NetState[] _netStates = new NetState[MaxConnections];
|
||||||
|
|
||||||
// Events buffer for ProcessCompletions
|
// 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.
|
||||||
private static readonly RingSocketEvent[] _events = new RingSocketEvent[MaxConnections * 2];
|
private static readonly RingSocketEvent[] _events = new RingSocketEvent[MaxConnections * 2];
|
||||||
|
|
||||||
// Listener management
|
// Listener management
|
||||||
|
|
@ -67,6 +71,24 @@ public partial class NetState
|
||||||
_socketManager?.WaitForCompletion(timeoutMs);
|
_socketManager?.WaitForCompletion(timeoutMs);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Wakes the game loop if it is blocked in <see cref="WaitForCompletion"/>. 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.
|
||||||
|
/// </summary>
|
||||||
|
public static void Wake()
|
||||||
|
{
|
||||||
|
_socketManager?.Ring?.Wake();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// True when no queued network work remains for the loop to drain. <see cref="Slice"/> defers
|
||||||
|
/// work in several places, so an empty completion queue alone is not enough.
|
||||||
|
/// </summary>
|
||||||
|
internal static bool IsIdle =>
|
||||||
|
_throttled.Count == 0 && _throttledPending.Count == 0 &&
|
||||||
|
_flushPending.Count == 0 && _pendingDisconnects.Count == 0 && _disposed.Count == 0;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets the listening addresses that the server is bound to.
|
/// Gets the listening addresses that the server is bound to.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|
@ -88,20 +110,61 @@ public partial class NetState
|
||||||
// Initialize IP rate limiter
|
// Initialize IP rate limiter
|
||||||
_ipRateLimiter = new IPRateLimiter(10, 10000, 1000, 2.0, 3_600_000, Core.ClosingTokenSource.Token);
|
_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
|
// Initialize IORingGroup
|
||||||
var ring = IORingGroup.Create(queueSize: MaxConnections * 2, maxConnections: MaxConnections);
|
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();
|
||||||
|
|
||||||
// Create socket manager which handles buffer pools and socket lifecycle
|
// Create socket manager which handles buffer pools and socket lifecycle
|
||||||
_socketManager = new RingSocketManager(
|
_socketManager = new RingSocketManager(
|
||||||
ring,
|
ring,
|
||||||
maxSockets: MaxConnections,
|
maxSockets: MaxConnections,
|
||||||
recvBufferSize: RecvBufferSize,
|
recvBufferSize: RecvBufferSize,
|
||||||
sendBufferSize: SendBufferSize,
|
sendBufferSize: sendBufferSize,
|
||||||
initialBufferSlabs: 8,
|
initialBufferSlabs: 8,
|
||||||
maxBufferSlabs: 32
|
maxBufferSlabs: 32
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Starts the network server on configured listening addresses.
|
/// Starts the network server on configured listening addresses.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|
@ -224,10 +287,19 @@ public partial class NetState
|
||||||
if (_ipRateLimiter != null && !_ipRateLimiter.Verify(remoteIP, out var totalAttempts))
|
if (_ipRateLimiter != null && !_ipRateLimiter.Verify(remoteIP, out var totalAttempts))
|
||||||
{
|
{
|
||||||
logger.Debug("{Address} Past IP limit threshold ({TotalAttempts})", remoteIP, 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 (Firewall.IsBlocked(remoteIP))
|
else if (ConnectionFilters.ShouldDeny(remoteIP, out var deniedBy))
|
||||||
{
|
{
|
||||||
logger.Debug("{Address} Firewalled", 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);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
|
|
@ -308,6 +380,18 @@ public partial class NetState
|
||||||
// Socket must have finished the entire authentication process or be forcibly disconnected
|
// Socket must have finished the entire authentication process or be forcibly disconnected
|
||||||
if (!ns.SentFirstPacket || !ns.Seeded)
|
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);
|
ns.Disconnect(null);
|
||||||
|
|
||||||
// Force immediate cleanup - these are unauthenticated connections
|
// Force immediate cleanup - these are unauthenticated connections
|
||||||
|
|
@ -484,6 +568,11 @@ public partial class NetState
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (bytesReceived > 0)
|
||||||
|
{
|
||||||
|
ns._receivedData = true;
|
||||||
|
}
|
||||||
|
|
||||||
// Data is already committed to buffer by RingSocketManager
|
// Data is already committed to buffer by RingSocketManager
|
||||||
// Decode if encryption is enabled
|
// Decode if encryption is enabled
|
||||||
ns.DecryptRecvBuffer(bytesReceived);
|
ns.DecryptRecvBuffer(bytesReceived);
|
||||||
|
|
|
||||||
|
|
@ -44,7 +44,7 @@ public partial class NetState : IComparable<NetState>, IValueLinkListNode<NetSta
|
||||||
|
|
||||||
private static readonly Queue<NetState> _connectingQueue = new(2048);
|
private static readonly Queue<NetState> _connectingQueue = new(2048);
|
||||||
private static readonly HashSet<NetState> _instances = new(2048);
|
private static readonly HashSet<NetState> _instances = new(2048);
|
||||||
public static IReadOnlySet<NetState> Instances => _instances;
|
public static HashSet<NetState> Instances => _instances;
|
||||||
|
|
||||||
private readonly string _toString;
|
private readonly string _toString;
|
||||||
private ClientVersion _version;
|
private ClientVersion _version;
|
||||||
|
|
@ -60,6 +60,10 @@ public partial class NetState : IComparable<NetState>, IValueLinkListNode<NetSta
|
||||||
internal ProtocolState _protocolState = ProtocolState.AwaitingSeed;
|
internal ProtocolState _protocolState = ProtocolState.AwaitingSeed;
|
||||||
private bool _packetLogging;
|
private bool _packetLogging;
|
||||||
|
|
||||||
|
// Whether ANY inbound bytes have arrived: what separates a slow client from a socket held open on
|
||||||
|
// purpose. See BanSettings.ReportBadConnects.
|
||||||
|
internal bool _receivedData;
|
||||||
|
|
||||||
// Managed socket with buffers (handles lifecycle automatically)
|
// Managed socket with buffers (handles lifecycle automatically)
|
||||||
internal RingSocket _socket;
|
internal RingSocket _socket;
|
||||||
|
|
||||||
|
|
@ -105,9 +109,6 @@ public partial class NetState : IComparable<NetState>, IValueLinkListNode<NetSta
|
||||||
Address = address;
|
Address = address;
|
||||||
|
|
||||||
Seeded = false;
|
Seeded = false;
|
||||||
HuePickers = [];
|
|
||||||
Menus = [];
|
|
||||||
Trades = [];
|
|
||||||
NextActivityCheck = Core.TickCount + 30000;
|
NextActivityCheck = Core.TickCount + 30000;
|
||||||
ConnectedOn = Core.Now;
|
ConnectedOn = Core.Now;
|
||||||
_toString = address?.ToString() ?? "(error)";
|
_toString = address?.ToString() ?? "(error)";
|
||||||
|
|
@ -162,7 +163,7 @@ public partial class NetState : IComparable<NetState>, IValueLinkListNode<NetSta
|
||||||
|
|
||||||
public bool BlockAllPackets { get; set; }
|
public bool BlockAllPackets { get; set; }
|
||||||
|
|
||||||
public List<SecureTrade> Trades { get; }
|
public List<SecureTrade> Trades { get; private set; }
|
||||||
|
|
||||||
public bool Seeded { get; set; }
|
public bool Seeded { get; set; }
|
||||||
|
|
||||||
|
|
@ -256,8 +257,18 @@ public partial class NetState : IComparable<NetState>, IValueLinkListNode<NetSta
|
||||||
|
|
||||||
public void ValidateAllTrades()
|
public void ValidateAllTrades()
|
||||||
{
|
{
|
||||||
|
if (Trades == null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
for (var i = Trades.Count - 1; i >= 0; --i)
|
for (var i = Trades.Count - 1; i >= 0; --i)
|
||||||
{
|
{
|
||||||
|
if (Trades == null)
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
if (i >= Trades.Count)
|
if (i >= Trades.Count)
|
||||||
{
|
{
|
||||||
continue;
|
continue;
|
||||||
|
|
@ -276,8 +287,18 @@ public partial class NetState : IComparable<NetState>, IValueLinkListNode<NetSta
|
||||||
|
|
||||||
public void CancelAllTrades()
|
public void CancelAllTrades()
|
||||||
{
|
{
|
||||||
|
if (Trades == null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
for (var i = Trades.Count - 1; i >= 0; --i)
|
for (var i = Trades.Count - 1; i >= 0; --i)
|
||||||
{
|
{
|
||||||
|
if (Trades != null)
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
if (i < Trades.Count)
|
if (i < Trades.Count)
|
||||||
{
|
{
|
||||||
Trades[i].Cancel();
|
Trades[i].Cancel();
|
||||||
|
|
@ -287,11 +308,21 @@ public partial class NetState : IComparable<NetState>, IValueLinkListNode<NetSta
|
||||||
|
|
||||||
public void RemoveTrade(SecureTrade trade)
|
public void RemoveTrade(SecureTrade trade)
|
||||||
{
|
{
|
||||||
Trades.Remove(trade);
|
Trades?.Remove(trade);
|
||||||
|
|
||||||
|
if (Trades?.Count == 0)
|
||||||
|
{
|
||||||
|
Trades = null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public SecureTrade FindTrade(Mobile m)
|
public SecureTrade FindTrade(Mobile m)
|
||||||
{
|
{
|
||||||
|
if (Trades == null)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
for (var i = 0; i < Trades.Count; ++i)
|
for (var i = 0; i < Trades.Count; ++i)
|
||||||
{
|
{
|
||||||
var trade = Trades[i];
|
var trade = Trades[i];
|
||||||
|
|
@ -307,6 +338,11 @@ public partial class NetState : IComparable<NetState>, IValueLinkListNode<NetSta
|
||||||
|
|
||||||
public SecureTradeContainer FindTradeContainer(Mobile m)
|
public SecureTradeContainer FindTradeContainer(Mobile m)
|
||||||
{
|
{
|
||||||
|
if (Trades == null)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
for (var i = 0; i < Trades.Count; ++i)
|
for (var i = 0; i < Trades.Count; ++i)
|
||||||
{
|
{
|
||||||
var trade = Trades[i];
|
var trade = Trades[i];
|
||||||
|
|
@ -332,7 +368,11 @@ public partial class NetState : IComparable<NetState>, IValueLinkListNode<NetSta
|
||||||
{
|
{
|
||||||
var newTrade = new SecureTrade(Mobile, state.Mobile);
|
var newTrade = new SecureTrade(Mobile, state.Mobile);
|
||||||
|
|
||||||
|
Trades ??= [];
|
||||||
|
|
||||||
Trades.Add(newTrade);
|
Trades.Add(newTrade);
|
||||||
|
|
||||||
|
state.Trades ??= [];
|
||||||
state.Trades.Add(newTrade);
|
state.Trades.Add(newTrade);
|
||||||
|
|
||||||
return newTrade.From.Container;
|
return newTrade.From.Container;
|
||||||
|
|
@ -444,17 +484,36 @@ public partial class NetState : IComparable<NetState>, IValueLinkListNode<NetSta
|
||||||
}
|
}
|
||||||
|
|
||||||
var length = span.Length;
|
var length = span.Length;
|
||||||
if (length <= 0 || !GetSendBuffer(out var buffer))
|
if (length <= 0)
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Never drop silently: the client would stay connected while missing game state.
|
||||||
|
if (!GetSendBuffer(out var buffer))
|
||||||
|
{
|
||||||
|
SendBufferExhausted(length, 0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
// Apply encoding first (e.g., compression from UOContent)
|
// Apply encoding first (e.g., compression from UOContent)
|
||||||
if (CompressionEnabled)
|
if (CompressionEnabled)
|
||||||
{
|
{
|
||||||
length = NetworkCompression.Compress(span, buffer);
|
length = NetworkCompression.Compress(span, buffer);
|
||||||
|
|
||||||
|
// 0 means nothing was written, whether it did not fit or the input was too large.
|
||||||
|
if (length <= 0)
|
||||||
|
{
|
||||||
|
SendBufferExhausted(span.Length, buffer.Length);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (span.Length > buffer.Length)
|
||||||
|
{
|
||||||
|
SendBufferExhausted(span.Length, buffer.Length);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
|
|
@ -484,6 +543,31 @@ public partial class NetState : IComparable<NetState>, IValueLinkListNode<NetSta
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Handles a packet that cannot be placed in the send buffer.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// 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.
|
||||||
|
/// </remarks>
|
||||||
|
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()
|
private void StartPacketLog()
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
|
|
@ -577,6 +661,37 @@ public partial class NetState : IComparable<NetState>, IValueLinkListNode<NetSta
|
||||||
{
|
{
|
||||||
case ProtocolState.AwaitingSeed:
|
case ProtocolState.AwaitingSeed:
|
||||||
{
|
{
|
||||||
|
// Traffic that is positively another protocol. Unlike "does not look like a
|
||||||
|
// good client", this cannot misfire on a misconfigured one.
|
||||||
|
var foreign = ForeignProtocol.Identify(buffer, out var foreignKind);
|
||||||
|
|
||||||
|
if (foreign == ForeignProtocolMatch.Incomplete)
|
||||||
|
{
|
||||||
|
_parserState = ParserState.AwaitingPartialPacket;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (foreign == ForeignProtocolMatch.Confirmed)
|
||||||
|
{
|
||||||
|
logger.Debug(
|
||||||
|
"{Address} spoke {Protocol} on the game port; disconnecting",
|
||||||
|
Address,
|
||||||
|
foreignKind
|
||||||
|
);
|
||||||
|
|
||||||
|
if (Bans.BanConfiguration.Settings.ReportBadConnects)
|
||||||
|
{
|
||||||
|
Bans.BanChannel.Report(
|
||||||
|
Address,
|
||||||
|
Bans.BanConfiguration.Settings.BadConnectDuration,
|
||||||
|
Bans.BanReasons.ForeignProtocol
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Disconnect(string.Empty);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (packetId == 0xEF)
|
if (packetId == 0xEF)
|
||||||
{
|
{
|
||||||
_parserState = ParserState.ProcessingPacket;
|
_parserState = ParserState.ProcessingPacket;
|
||||||
|
|
@ -592,6 +707,18 @@ public partial class NetState : IComparable<NetState>, IValueLinkListNode<NetSta
|
||||||
|
|
||||||
if (newSeed == 0)
|
if (newSeed == 0)
|
||||||
{
|
{
|
||||||
|
// No real client sends a zero seed, so this is deliberate garbage
|
||||||
|
// rather than a damaged connection — unlike the short-read branch
|
||||||
|
// below, which a fragmented first segment can reach honestly.
|
||||||
|
if (Bans.BanConfiguration.Settings.ReportBadConnects)
|
||||||
|
{
|
||||||
|
Bans.BanChannel.Report(
|
||||||
|
Address,
|
||||||
|
Bans.BanConfiguration.Settings.BadConnectDuration,
|
||||||
|
Bans.BanReasons.InvalidSeed
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Disconnect(string.Empty);
|
Disconnect(string.Empty);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -603,8 +730,11 @@ public partial class NetState : IComparable<NetState>, IValueLinkListNode<NetSta
|
||||||
_parserState = ParserState.AwaitingNextPacket;
|
_parserState = ParserState.AwaitingNextPacket;
|
||||||
_protocolState = ProtocolState.GameServer_AwaitingGameServerLogin;
|
_protocolState = ProtocolState.GameServer_AwaitingGameServerLogin;
|
||||||
}
|
}
|
||||||
else // Don't allow partial packets on initial connection, just disconnect them.
|
else
|
||||||
{
|
{
|
||||||
|
// Disconnect rather than wait. Waiting would hold a connection slot for
|
||||||
|
// the full ConnectingSocketIdleLimit per one- or two-byte client, which
|
||||||
|
// is what a flood sends. Only pre-0xEF clients reach here.
|
||||||
Disconnect(string.Empty);
|
Disconnect(string.Empty);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
@ -1082,8 +1212,16 @@ public partial class NetState : IComparable<NetState>, IValueLinkListNode<NetSta
|
||||||
|
|
||||||
var a = Account;
|
var a = Account;
|
||||||
|
|
||||||
Menus.Clear();
|
Menus?.Clear();
|
||||||
HuePickers.Clear();
|
Menus = null;
|
||||||
|
|
||||||
|
HuePickers?.Clear();
|
||||||
|
HuePickers = null;
|
||||||
|
|
||||||
|
// Just in case, but should already be nulled when Mobile.NetState is set to null and CancelAllTrades is called.
|
||||||
|
Trades?.Clear();
|
||||||
|
Trades = null;
|
||||||
|
|
||||||
Account = null;
|
Account = null;
|
||||||
ServerInfo = null;
|
ServerInfo = null;
|
||||||
CityInfo = null;
|
CityInfo = null;
|
||||||
|
|
|
||||||
|
|
@ -73,7 +73,9 @@ public static class NetworkCompression
|
||||||
|
|
||||||
public static int Compress(ReadOnlySpan<byte> input, Span<byte> output)
|
public static int Compress(ReadOnlySpan<byte> input, Span<byte> output)
|
||||||
{
|
{
|
||||||
if (input.Length > DefiniteOverflow)
|
// 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)
|
||||||
{
|
{
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -55,6 +55,12 @@ public sealed class ObjectPropertyList : IPropertyList, IDisposable
|
||||||
private int _pos;
|
private int _pos;
|
||||||
private char[]? _arrayToReturnToPool;
|
private char[]? _arrayToReturnToPool;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
internal bool IsBuilding { get; set; }
|
||||||
|
|
||||||
public ObjectPropertyList(IEntity? e)
|
public ObjectPropertyList(IEntity? e)
|
||||||
{
|
{
|
||||||
Entity = e;
|
Entity = e;
|
||||||
|
|
@ -319,8 +325,23 @@ public sealed class ObjectPropertyList : IPropertyList, IDisposable
|
||||||
private static int GetDefaultLength(int literalLength, int formattedCount) =>
|
private static int GetDefaultLength(int literalLength, int formattedCount) =>
|
||||||
Math.Max(256, literalLength + formattedCount * 11);
|
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<char>.Shared.Rent(256);
|
||||||
|
_pos = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public void AppendLiteral(string value)
|
public void AppendLiteral(string value)
|
||||||
{
|
{
|
||||||
|
EnsureInterpolationBuffer();
|
||||||
|
|
||||||
if (value.Length == 1)
|
if (value.Length == 1)
|
||||||
{
|
{
|
||||||
var chars = _arrayToReturnToPool.AsSpan();
|
var chars = _arrayToReturnToPool.AsSpan();
|
||||||
|
|
@ -354,6 +375,8 @@ public sealed class ObjectPropertyList : IPropertyList, IDisposable
|
||||||
|
|
||||||
public void AppendFormatted<T>(T value)
|
public void AppendFormatted<T>(T value)
|
||||||
{
|
{
|
||||||
|
EnsureInterpolationBuffer();
|
||||||
|
|
||||||
string? s;
|
string? s;
|
||||||
if (value is IFormattable)
|
if (value is IFormattable)
|
||||||
{
|
{
|
||||||
|
|
@ -384,9 +407,11 @@ public sealed class ObjectPropertyList : IPropertyList, IDisposable
|
||||||
|
|
||||||
public void AppendFormatted<T>(T value, string? format)
|
public void AppendFormatted<T>(T value, string? format)
|
||||||
{
|
{
|
||||||
// We support localization '#' cliloc formatter for custom property lists
|
EnsureInterpolationBuffer();
|
||||||
// This allows someone to build an IPropertyList that creates HTML using the same syntax as LocalizationInterpolationHandler
|
|
||||||
if (format == "#")
|
// '#' marks an integer argument as a cliloc ("#<value>"). 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)
|
||||||
{
|
{
|
||||||
AppendLiteral("#");
|
AppendLiteral("#");
|
||||||
format = null;
|
format = null;
|
||||||
|
|
@ -442,6 +467,8 @@ public sealed class ObjectPropertyList : IPropertyList, IDisposable
|
||||||
|
|
||||||
public void AppendFormatted(ReadOnlySpan<char> value)
|
public void AppendFormatted(ReadOnlySpan<char> value)
|
||||||
{
|
{
|
||||||
|
EnsureInterpolationBuffer();
|
||||||
|
|
||||||
if (value.TryCopyTo(_arrayToReturnToPool.AsSpan(_pos..)))
|
if (value.TryCopyTo(_arrayToReturnToPool.AsSpan(_pos..)))
|
||||||
{
|
{
|
||||||
_pos += value.Length;
|
_pos += value.Length;
|
||||||
|
|
@ -454,6 +481,8 @@ public sealed class ObjectPropertyList : IPropertyList, IDisposable
|
||||||
|
|
||||||
public void AppendFormatted(ReadOnlySpan<char> value, int alignment = 0, string? format = null)
|
public void AppendFormatted(ReadOnlySpan<char> value, int alignment = 0, string? format = null)
|
||||||
{
|
{
|
||||||
|
EnsureInterpolationBuffer();
|
||||||
|
|
||||||
var leftAlign = false;
|
var leftAlign = false;
|
||||||
if (alignment < 0)
|
if (alignment < 0)
|
||||||
{
|
{
|
||||||
|
|
@ -488,6 +517,8 @@ public sealed class ObjectPropertyList : IPropertyList, IDisposable
|
||||||
|
|
||||||
public void AppendFormatted(string? value)
|
public void AppendFormatted(string? value)
|
||||||
{
|
{
|
||||||
|
EnsureInterpolationBuffer();
|
||||||
|
|
||||||
if (value?.TryCopyTo(_arrayToReturnToPool.AsSpan(_pos..)) == true)
|
if (value?.TryCopyTo(_arrayToReturnToPool.AsSpan(_pos..)) == true)
|
||||||
{
|
{
|
||||||
_pos += value.Length;
|
_pos += value.Length;
|
||||||
|
|
|
||||||
|
|
@ -74,6 +74,12 @@ public sealed unsafe class BinaryFileReader : IDisposable, IGenericReader
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public long Position => _reader.Position;
|
public long Position => _reader.Position;
|
||||||
|
|
||||||
|
public TimeSpan AnchoredTimeShift
|
||||||
|
{
|
||||||
|
get => _reader.AnchoredTimeShift;
|
||||||
|
set => _reader.AnchoredTimeShift = value;
|
||||||
|
}
|
||||||
|
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
{
|
{
|
||||||
_accessor?.SafeMemoryMappedViewHandle.ReleasePointer();
|
_accessor?.SafeMemoryMappedViewHandle.ReleasePointer();
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,8 @@ public class BufferReader : IGenericReader
|
||||||
public long Position => _position;
|
public long Position => _position;
|
||||||
public long BufferSize => _buffer.Length;
|
public long BufferSize => _buffer.Length;
|
||||||
|
|
||||||
|
public TimeSpan AnchoredTimeShift { get; set; }
|
||||||
|
|
||||||
public BufferReader(byte[] buffer, Dictionary<ulong, string> typesDb = null, Encoding encoding = null)
|
public BufferReader(byte[] buffer, Dictionary<ulong, string> typesDb = null, Encoding encoding = null)
|
||||||
{
|
{
|
||||||
_buffer = buffer;
|
_buffer = buffer;
|
||||||
|
|
|
||||||
|
|
@ -384,6 +384,7 @@ public class BufferWriter : IGenericWriter
|
||||||
}
|
}
|
||||||
|
|
||||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
[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)
|
public void WriteDeltaTime(DateTime value)
|
||||||
{
|
{
|
||||||
if (value == DateTime.MinValue)
|
if (value == DateTime.MinValue)
|
||||||
|
|
@ -407,6 +408,21 @@ public class BufferWriter : IGenericWriter
|
||||||
Write(value.Ticks - DateTime.UtcNow.Ticks);
|
Write(value.Ticks - DateTime.UtcNow.Ticks);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Writes the absolute value; <see cref="IGenericReader.ReadAnchoredTime" /> re-bases it
|
||||||
|
/// by the elapsed time since the save started, so downtime does not age it and an
|
||||||
|
/// unchanged value serializes to identical bytes.
|
||||||
|
/// </summary>
|
||||||
|
public void WriteAnchoredTime(DateTime value)
|
||||||
|
{
|
||||||
|
if (value.Kind == DateTimeKind.Local)
|
||||||
|
{
|
||||||
|
value = value.ToUniversalTime();
|
||||||
|
}
|
||||||
|
|
||||||
|
Write(value.Ticks);
|
||||||
|
}
|
||||||
|
|
||||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||||
public void Write(IPAddress value)
|
public void Write(IPAddress value)
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -114,9 +114,10 @@ public class GenericEntityPersistence<T> : GenericPersistence, IGenericEntityPer
|
||||||
using var binFs = new FileStream(
|
using var binFs = new FileStream(
|
||||||
Path.Combine(dir, $"{Name}.bin"), FileMode.Create, FileAccess.Write, FileShare.None, 1024 * 1024
|
Path.Combine(dir, $"{Name}.bin"), FileMode.Create, FileAccess.Write, FileShare.None, 1024 * 1024
|
||||||
);
|
);
|
||||||
// v4 records are fixed-width 26 bytes; the header carries the type table
|
// v4 records are fixed-width 26 bytes; the v5 header carries the save-start anchor
|
||||||
// (name lengths vary — 64 bytes per entry is a staging hint, not a contract).
|
// and the type table (name lengths vary — 64 bytes per entry is a staging hint, not
|
||||||
var expectedIdxSize = 12 + 26L * EntitiesBySerial.Count + 64L * _typeTable.Count;
|
// a contract).
|
||||||
|
var expectedIdxSize = 20 + 26L * EntitiesBySerial.Count + 64L * _typeTable.Count;
|
||||||
using var idx = new FileBufferWriter(Path.Combine(dir, $"{Name}.idx"), expectedIdxSize);
|
using var idx = new FileBufferWriter(Path.Combine(dir, $"{Name}.idx"), expectedIdxSize);
|
||||||
|
|
||||||
var binPosition = 0L;
|
var binPosition = 0L;
|
||||||
|
|
@ -142,7 +143,10 @@ public class GenericEntityPersistence<T> : GenericPersistence, IGenericEntityPer
|
||||||
binPosition += _selfLength;
|
binPosition += _selfLength;
|
||||||
}
|
}
|
||||||
|
|
||||||
idx.Write(4); // Version
|
idx.Write(5); // Version
|
||||||
|
|
||||||
|
// One anchor for the whole save: the world is frozen from the moment it is stamped.
|
||||||
|
idx.Write(World.SaveStartTime.Ticks);
|
||||||
|
|
||||||
// The type table is fully known at freeze (AddEntity diverts to the pending
|
// 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
|
// queues while saving) and is written before the records so the loader can
|
||||||
|
|
@ -494,6 +498,18 @@ public class GenericEntityPersistence<T> : GenericPersistence, IGenericEntityPer
|
||||||
|
|
||||||
var version = dataReader.ReadInt();
|
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)
|
if (version >= 4)
|
||||||
{
|
{
|
||||||
DeserializeIndexesV4(dataReader, entities);
|
DeserializeIndexesV4(dataReader, entities);
|
||||||
|
|
@ -660,6 +676,9 @@ public class GenericEntityPersistence<T> : GenericPersistence, IGenericEntityPer
|
||||||
|
|
||||||
private static List<T> _toDelete;
|
private static List<T> _toDelete;
|
||||||
|
|
||||||
|
// From the loaded idx (v5+); zero when the save predates the anchor.
|
||||||
|
private TimeSpan _anchoredTimeShift;
|
||||||
|
|
||||||
private unsafe void InternalDeserialize(string filePath, int index, Dictionary<ulong, string> typesDb)
|
private unsafe void InternalDeserialize(string filePath, int index, Dictionary<ulong, string> typesDb)
|
||||||
{
|
{
|
||||||
using var mmf = MemoryMappedFile.CreateFromFile(filePath, FileMode.Open);
|
using var mmf = MemoryMappedFile.CreateFromFile(filePath, FileMode.Open);
|
||||||
|
|
@ -667,7 +686,10 @@ public class GenericEntityPersistence<T> : GenericPersistence, IGenericEntityPer
|
||||||
|
|
||||||
byte* ptr = null;
|
byte* ptr = null;
|
||||||
accessor.SafeMemoryMappedViewHandle.AcquirePointer(ref ptr);
|
accessor.SafeMemoryMappedViewHandle.AcquirePointer(ref ptr);
|
||||||
var dataReader = new UnmanagedDataReader(ptr, accessor.Length, typesDb);
|
var dataReader = new UnmanagedDataReader(ptr, accessor.Length, typesDb)
|
||||||
|
{
|
||||||
|
AnchoredTimeShift = _anchoredTimeShift
|
||||||
|
};
|
||||||
|
|
||||||
Deserialize(dataReader);
|
Deserialize(dataReader);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -98,7 +98,13 @@ public abstract class GenericPersistence : Persistence, IGenericSerializable
|
||||||
|
|
||||||
byte* ptr = null;
|
byte* ptr = null;
|
||||||
accessor.SafeMemoryMappedViewHandle.AcquirePointer(ref ptr);
|
accessor.SafeMemoryMappedViewHandle.AcquirePointer(ref ptr);
|
||||||
var dataReader = new UnmanagedDataReader(ptr, accessor.Length, typesDb);
|
var dataReader = new UnmanagedDataReader(ptr, accessor.Length, typesDb)
|
||||||
|
{
|
||||||
|
// These payloads carry no anchor of their own; they inherit the save-wide
|
||||||
|
// shift stamped while the entity indexes were read (indexes always load
|
||||||
|
// before persistence payloads — see Persistence.Load).
|
||||||
|
AnchoredTimeShift = World.LoadTimeShift
|
||||||
|
};
|
||||||
Deserialize(dataReader);
|
Deserialize(dataReader);
|
||||||
|
|
||||||
error = dataReader.Position != fileLength
|
error = dataReader.Position != fileLength
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,12 @@ public interface IGenericReader
|
||||||
DateTime ReadDateTime() => new(ReadLong(), DateTimeKind.Utc);
|
DateTime ReadDateTime() => new(ReadLong(), DateTimeKind.Utc);
|
||||||
TimeSpan ReadTimeSpan() => new(ReadLong());
|
TimeSpan ReadTimeSpan() => new(ReadLong());
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Decodes a legacy delta-time value. Only for reading old-version payloads (version
|
||||||
|
/// fallbacks and migration replays) — current formats store anchored time and read it
|
||||||
|
/// with <see cref="ReadAnchoredTime" />. <see cref="IGenericWriter.WriteDeltaTime" /> is
|
||||||
|
/// obsolete: no current-version format may write delta time.
|
||||||
|
/// </summary>
|
||||||
DateTime ReadDeltaTime()
|
DateTime ReadDeltaTime()
|
||||||
{
|
{
|
||||||
return ReadLong() switch
|
return ReadLong() switch
|
||||||
|
|
@ -52,6 +58,37 @@ public interface IGenericReader
|
||||||
var delta => new DateTime(delta + DateTime.UtcNow.Ticks, DateTimeKind.Utc)
|
var delta => new DateTime(delta + DateTime.UtcNow.Ticks, DateTimeKind.Utc)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Elapsed time between the loaded save starting and this load, applied by
|
||||||
|
/// <see cref="ReadAnchoredTime" />. Zero when the source carries no anchor.
|
||||||
|
/// </summary>
|
||||||
|
TimeSpan AnchoredTimeShift => TimeSpan.Zero;
|
||||||
|
|
||||||
|
DateTime ReadAnchoredTime()
|
||||||
|
{
|
||||||
|
var value = ReadDateTime();
|
||||||
|
|
||||||
|
if (value == DateTime.MinValue || value == DateTime.MaxValue)
|
||||||
|
{
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
var shift = AnchoredTimeShift;
|
||||||
|
if (shift == TimeSpan.Zero)
|
||||||
|
{
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
var ticks = value.Ticks + shift.Ticks;
|
||||||
|
|
||||||
|
if (ticks >= DateTime.MaxValue.Ticks)
|
||||||
|
{
|
||||||
|
return DateTime.MaxValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
return ticks <= 0 ? DateTime.MinValue : new DateTime(ticks, DateTimeKind.Utc);
|
||||||
|
}
|
||||||
decimal ReadDecimal() => new([ReadInt(), ReadInt(), ReadInt(), ReadInt()]);
|
decimal ReadDecimal() => new([ReadInt(), ReadInt(), ReadInt(), ReadInt()]);
|
||||||
int ReadEncodedInt()
|
int ReadEncodedInt()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -40,7 +40,11 @@ public interface IGenericWriter
|
||||||
void Write(decimal value);
|
void Write(decimal value);
|
||||||
void WriteEncodedInt(int value);
|
void WriteEncodedInt(int value);
|
||||||
void Write(DateTime 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 WriteDeltaTime(DateTime value);
|
||||||
|
|
||||||
|
void WriteAnchoredTime(DateTime value);
|
||||||
void Write(IPAddress value);
|
void Write(IPAddress value);
|
||||||
void Write(TimeSpan value);
|
void Write(TimeSpan value);
|
||||||
void Write(Point3D value);
|
void Write(Point3D value);
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,8 @@ public unsafe class UnmanagedDataReader : IGenericReader
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public long Position { get; private set; }
|
public long Position { get; private set; }
|
||||||
|
|
||||||
|
public TimeSpan AnchoredTimeShift { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Read bits of data raw from a serialized file using Little-endian.
|
/// Read bits of data raw from a serialized file using Little-endian.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|
|
||||||
|
|
@ -34,14 +34,13 @@
|
||||||
</Target>
|
</Target>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\Logger\Logger.csproj" />
|
<ProjectReference Include="..\Logger\Logger.csproj" />
|
||||||
<PackageReference Include="IORingGroup" Version="1.0.7" />
|
<PackageReference Include="IORingGroup" Version="1.0.10" />
|
||||||
<PackageReference Include="CommunityToolkit.HighPerformance" Version="8.4.2" />
|
<PackageReference Include="CommunityToolkit.HighPerformance" Version="8.4.2" />
|
||||||
<PackageReference Include="LibDeflate.Bindings" Version="1.0.3" />
|
<PackageReference Include="LibDeflate.Bindings" Version="1.0.4" />
|
||||||
<PackageReference Include="System.IO.Hashing" Version="10.0.10" />
|
<PackageReference Include="System.IO.Hashing" Version="10.0.11" />
|
||||||
|
|
||||||
<PackageReference Include="ModernUO.Serialization.Annotations" Version="2.14.2" />
|
<PackageReference Include="ModernUO.Serialization.Annotations" Version="4.0.0" />
|
||||||
<PackageReference Include="ModernUO.Serialization.Generator" Version="2.14.3" />
|
<PackageReference Include="ModernUO.Serialization.Generator" Version="4.0.0" PrivateAssets="all" />
|
||||||
<PackageReference Update="Serilog" Version="4.4.0" />
|
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<AdditionalFiles Include="Migrations/*.v*.json" />
|
<AdditionalFiles Include="Migrations/*.v*.json" />
|
||||||
|
|
|
||||||
|
|
@ -51,8 +51,15 @@ public partial class Timer
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Milliseconds of simulated time one wheel turn advances.
|
||||||
|
/// </summary>
|
||||||
|
public static int TickRate => _tickRate;
|
||||||
|
|
||||||
public static void Slice(long tickCount)
|
public static void Slice(long tickCount)
|
||||||
{
|
{
|
||||||
|
EventLoopProfiler.WheelSlice(tickCount - _lastTickTurned);
|
||||||
|
|
||||||
var deltaSinceTurn = tickCount - _lastTickTurned;
|
var deltaSinceTurn = tickCount - _lastTickTurned;
|
||||||
while (deltaSinceTurn >= _tickRate)
|
while (deltaSinceTurn >= _tickRate)
|
||||||
{
|
{
|
||||||
|
|
|
||||||
240
Projects/Server/Utilities/IPAddressUtility.cs
Normal file
240
Projects/Server/Utilities/IPAddressUtility.cs
Normal file
|
|
@ -0,0 +1,240 @@
|
||||||
|
/*************************************************************************
|
||||||
|
* 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 <http://www.gnu.org/licenses/>. *
|
||||||
|
*************************************************************************/
|
||||||
|
|
||||||
|
using System;
|
||||||
|
using System.Buffers.Binary;
|
||||||
|
using System.Net;
|
||||||
|
using System.Net.Sockets;
|
||||||
|
using System.Numerics;
|
||||||
|
|
||||||
|
namespace Server;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
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<byte> integer = stackalloc byte[4];
|
||||||
|
return !ip.TryWriteBytes(integer, out _)
|
||||||
|
? (UInt128)0
|
||||||
|
: new UInt128(0, 0xFFFF00000000UL | BinaryPrimitives.ReadUInt32BigEndian(integer));
|
||||||
|
}
|
||||||
|
|
||||||
|
Span<byte> 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<byte> bytes = stackalloc byte[16]; // 128 bits for IPv6 address
|
||||||
|
((IBinaryInteger<UInt128>)value).WriteBigEndian(bytes);
|
||||||
|
|
||||||
|
return new IPAddress(bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Parses <c>a.b.c.d/n</c>, <c>::/n</c>, or a bare address (treated as a single-host range) into an
|
||||||
|
/// inclusive <see cref="UInt128"/> 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.
|
||||||
|
/// </summary>
|
||||||
|
public static bool TryParseCidrRange(ReadOnlySpan<char> 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<byte> bytes = stackalloc byte[16];
|
||||||
|
ip.WriteMappedIPv6To(bytes);
|
||||||
|
|
||||||
|
min = Utility.CreateCidrAddress(bytes, prefixLength, false);
|
||||||
|
max = Utility.CreateCidrAddress(bytes, prefixLength, true);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Extracts the big-endian uint of an <see cref="AddressFamily.InterNetwork"/> address.</summary>
|
||||||
|
public static bool TryV4(IPAddress ip, out uint v)
|
||||||
|
{
|
||||||
|
Span<byte> 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Extracts the embedded v4 uint from a v4-mapped-v6 address directly from the mapped bytes,
|
||||||
|
/// avoiding the allocation of <see cref="IPAddress.MapToIPv4"/>.
|
||||||
|
/// </summary>
|
||||||
|
public static bool TryMappedV4(IPAddress ip, out uint v)
|
||||||
|
{
|
||||||
|
Span<byte> 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Parses a dotted-quad IPv4 literal into a big-endian uint. Allocation-free, strict.</summary>
|
||||||
|
public static bool TryParseV4(ReadOnlySpan<char> 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// UTF-8/ASCII byte overload of <see cref="TryParseV4(ReadOnlySpan{char}, out uint)"/>, mirroring its
|
||||||
|
/// validation exactly so the blocklist can parse dotted-quads straight from file bytes with no
|
||||||
|
/// per-line string allocation.
|
||||||
|
/// </summary>
|
||||||
|
public static bool TryParseV4(ReadOnlySpan<byte> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
|
using System;
|
||||||
using System.Net;
|
using System.Net;
|
||||||
using System.Net.Sockets;
|
using System.Net.Sockets;
|
||||||
using Server.Network;
|
using Server.Collections;
|
||||||
|
|
||||||
namespace Server;
|
namespace Server;
|
||||||
|
|
||||||
|
|
@ -14,36 +15,42 @@ public static class NetworkUtilities
|
||||||
_ => false
|
_ => false
|
||||||
};
|
};
|
||||||
|
|
||||||
private static readonly IFirewallEntry[] _privateNetworkV4 =
|
// 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
|
||||||
new CidrFirewallEntry("127.0.0.1/8"),
|
// firewall entry types was a convenience that made core depend on the firewall for something that has
|
||||||
new CidrFirewallEntry("192.168.0.0/16"),
|
// nothing to do with banning.
|
||||||
new CidrFirewallEntry("10.0.0.0/8"),
|
private static readonly SortedRangeIndex<UInt128> _privateNetworkV4 = BuildIndex(
|
||||||
new CidrFirewallEntry("172.16.0.0/12"),
|
"127.0.0.1/8",
|
||||||
new CidrFirewallEntry("169.254.0.0/16"),
|
"192.168.0.0/16",
|
||||||
new CidrFirewallEntry("100.64.0.0/10")
|
"10.0.0.0/8",
|
||||||
];
|
"172.16.0.0/12",
|
||||||
|
"169.254.0.0/16",
|
||||||
|
"100.64.0.0/10"
|
||||||
|
);
|
||||||
|
|
||||||
private static readonly IFirewallEntry[] _privateNetworkV6 =
|
private static readonly SortedRangeIndex<UInt128> _privateNetworkV6 = BuildIndex(
|
||||||
[
|
"fc00::/7",
|
||||||
new CidrFirewallEntry("fc00::/7"),
|
"fe80::/10"
|
||||||
new CidrFirewallEntry("fe80::/10")
|
);
|
||||||
];
|
|
||||||
|
|
||||||
public static bool IsPrivateNetworkV4(this IPAddress ip)
|
private static SortedRangeIndex<UInt128> BuildIndex(params ReadOnlySpan<string> cidrs)
|
||||||
{
|
{
|
||||||
for (var i = 0; i < _privateNetworkV4.Length; i++)
|
var ranges = new SortedRangeIndex<UInt128>.Range[cidrs.Length];
|
||||||
|
for (var i = 0; i < cidrs.Length; i++)
|
||||||
{
|
{
|
||||||
if (_privateNetworkV4[i].IsBlocked(ip))
|
if (!IPAddressUtility.TryParseCidrRange(cidrs[i], out var min, out var max))
|
||||||
{
|
{
|
||||||
return true;
|
throw new ArgumentException($"Invalid reserved-network CIDR \"{cidrs[i]}\"");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ranges[i] = new SortedRangeIndex<UInt128>.Range(min, max);
|
||||||
}
|
}
|
||||||
|
|
||||||
return false;
|
Array.Sort(ranges, SortedRangeIndex<UInt128>.ByMin);
|
||||||
|
return SortedRangeIndex<UInt128>.Build(ranges);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static bool IsPrivateNetworkV6(this IPAddress ip) =>
|
public static bool IsPrivateNetworkV4(this IPAddress ip) => _privateNetworkV4.Contains(ip.ToUInt128());
|
||||||
_privateNetworkV6[0].IsBlocked(ip) ||
|
|
||||||
_privateNetworkV6[1].IsBlocked(ip);
|
public static bool IsPrivateNetworkV6(this IPAddress ip) => _privateNetworkV6.Contains(ip.ToUInt128());
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -108,45 +108,6 @@ 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<byte> integer = stackalloc byte[4];
|
|
||||||
return !ip.TryWriteBytes(integer, out _)
|
|
||||||
? (UInt128)0
|
|
||||||
: new UInt128(0, 0xFFFF00000000UL | BinaryPrimitives.ReadUInt32BigEndian(integer));
|
|
||||||
}
|
|
||||||
|
|
||||||
Span<byte> 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<byte> bytes = stackalloc byte[16]; // 128 bits for IPv6 address
|
|
||||||
((IBinaryInteger<UInt128>)value).WriteBigEndian(bytes);
|
|
||||||
|
|
||||||
return new IPAddress(bytes);
|
|
||||||
}
|
|
||||||
|
|
||||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||||
public static UInt128 CreateCidrAddress(ReadOnlySpan<byte> bytes, int prefixLength, bool isMax)
|
public static UInt128 CreateCidrAddress(ReadOnlySpan<byte> bytes, int prefixLength, bool isMax)
|
||||||
{
|
{
|
||||||
|
|
@ -1089,28 +1050,16 @@ public static partial class Utility
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
using var queue = PooledRefQueue<K>.Create();
|
|
||||||
foreach (var (key, value) in dictionary)
|
foreach (var (key, value) in dictionary)
|
||||||
{
|
{
|
||||||
if (serializableKey)
|
var deleted = serializableKey
|
||||||
{
|
? ((ISerializable)key).Deleted
|
||||||
if (key == null || ((ISerializable)key).Deleted)
|
: value == null || ((ISerializable)value).Deleted;
|
||||||
{
|
|
||||||
queue.Enqueue(key);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
if (value == null || ((ISerializable)value).Deleted)
|
|
||||||
{
|
|
||||||
queue.Enqueue(key);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
while (queue.Count > 0)
|
if (deleted)
|
||||||
{
|
{
|
||||||
dictionary.Remove(queue.Dequeue());
|
dictionary.Remove(key);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
dictionary.TrimExcess();
|
dictionary.TrimExcess();
|
||||||
|
|
|
||||||
|
|
@ -93,6 +93,21 @@ public static class World
|
||||||
public static string SavePath { get; private set; }
|
public static string SavePath { get; private set; }
|
||||||
public static WorldState WorldState { get; private set; }
|
public static WorldState WorldState { get; private set; }
|
||||||
public static bool Saving => WorldState == WorldState.Saving;
|
public static bool Saving => WorldState == WorldState.Saving;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// UTC time the current or most recent world save started. Written into save indexes so
|
||||||
|
/// anchored timestamps can be re-based by the downtime at load.
|
||||||
|
/// </summary>
|
||||||
|
public static DateTime SaveStartTime { get; internal set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The anchored-time shift for the save currently being loaded: the downtime between the
|
||||||
|
/// save's start and this load. Stamped while entity indexes are read (they all carry the
|
||||||
|
/// same anchor, since the whole save shares one <see cref="SaveStartTime" />) and applied
|
||||||
|
/// to every reader of that save's files — including <see cref="GenericPersistence" />
|
||||||
|
/// payloads, which carry no anchor of their own. Zero for saves that predate the anchor.
|
||||||
|
/// </summary>
|
||||||
|
public static TimeSpan LoadTimeShift { get; internal set; }
|
||||||
public static bool Running => WorldState is not WorldState.Loading and not WorldState.Initial;
|
public static bool Running => WorldState is not WorldState.Loading and not WorldState.Initial;
|
||||||
public static bool Loading => WorldState == WorldState.Loading;
|
public static bool Loading => WorldState == WorldState.Loading;
|
||||||
|
|
||||||
|
|
@ -287,6 +302,10 @@ public static class World
|
||||||
|
|
||||||
WorldState = WorldState.Saving;
|
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.");
|
Broadcast(0x35, true, "The world is saving, please wait.");
|
||||||
|
|
||||||
logger.Information("Saving world");
|
logger.Information("Saving world");
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
using System;
|
using System;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Reflection;
|
using System.Reflection;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
|
|
@ -61,6 +61,15 @@ internal static class TestServerInitializer
|
||||||
AssemblyHandler.LoadAssemblies(["Server.dll", "UOContent.dll"]);
|
AssemblyHandler.LoadAssemblies(["Server.dll", "UOContent.dll"]);
|
||||||
|
|
||||||
SkillsInfo.Configure();
|
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();
|
Server.Network.NetState.Configure();
|
||||||
TestMapDefinitions.ConfigureTestMapDefinitions();
|
TestMapDefinitions.ConfigureTestMapDefinitions();
|
||||||
|
|
||||||
|
|
@ -91,8 +100,10 @@ internal static class TestServerInitializer
|
||||||
}
|
}
|
||||||
|
|
||||||
World.Configure();
|
World.Configure();
|
||||||
Timer.Init(0);
|
// Registers the Accounts entity persistence; without it no test can construct an Account.
|
||||||
|
Server.Accounting.Accounts.Configure();
|
||||||
RaceDefinitions.Configure();
|
RaceDefinitions.Configure();
|
||||||
|
Server.Movement.Movement.Configure();
|
||||||
MovementImpl.Configure();
|
MovementImpl.Configure();
|
||||||
PathFollower.Configure();
|
PathFollower.Configure();
|
||||||
World.Load();
|
World.Load();
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,74 @@
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
229
Projects/UOContent.Tests/Tests/Accounting/PasswordWorkerTests.cs
Normal file
229
Projects/UOContent.Tests/Tests/Accounting/PasswordWorkerTests.cs
Normal file
|
|
@ -0,0 +1,229 @@
|
||||||
|
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);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Enqueues work, then pumps the loop context until <paramref name="complete"/> or the deadline.
|
||||||
|
///
|
||||||
|
/// The context pins itself to the thread that constructed it and refuses <c>ExecuteTasks</c>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
private static void PumpUntil(Action enqueue, Func<bool> 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
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Drives the real queue rather than <c>ComputeInline</c>. 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.
|
||||||
|
/// </summary>
|
||||||
|
[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));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
[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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -74,4 +74,89 @@ public class PasswordProtectionTest
|
||||||
|
|
||||||
Assert.False(passwordProtection.ValidatePassword(encryptedPassword, "Not the same password"));
|
Assert.False(passwordProtection.ValidatePassword(encryptedPassword, "Not the same password"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Literal digests of <see cref="plainPassword"/>, 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.
|
||||||
|
/// </summary>
|
||||||
|
[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"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,80 +0,0 @@
|
||||||
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<CAGCategory>(FindChild(root, "Items"));
|
|
||||||
var weapons = Assert.IsType<CAGCategory>(FindChild(items, "Weapons"));
|
|
||||||
var swords = Assert.IsType<CAGCategory>(FindChild(weapons, "Swords"));
|
|
||||||
|
|
||||||
var leaf = Assert.IsType<CAGObject>(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<CAGObject>().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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,44 +0,0 @@
|
||||||
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<CAGJson> { Cat("Items.Weapons.Swords", typeof(Katana)) };
|
|
||||||
var discovered = new List<System.Type> { 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<CAGJson> { Cat("Items.Weapons.Swords", typeof(Katana)) };
|
|
||||||
var discovered = new List<System.Type> { typeof(Runebook) };
|
|
||||||
|
|
||||||
var (_, report) = CategorizationSync.Reconcile(categorization, discovered);
|
|
||||||
|
|
||||||
Assert.Contains("Katana", report.Orphaned);
|
|
||||||
Assert.Contains("Runebook", report.Appended);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,35 +0,0 @@
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,32 +0,0 @@
|
||||||
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<Type> { typeof(Runebook), typeof(Katana) };
|
|
||||||
var categorization = new List<CAGJson>(); // 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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,19 +0,0 @@
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,52 +0,0 @@
|
||||||
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<ObjectIndexFile>(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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,23 +0,0 @@
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,31 +0,0 @@
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,17 +0,0 @@
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,22 +0,0 @@
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,29 +0,0 @@
|
||||||
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));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -0,0 +1,17 @@
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,27 @@
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,119 @@
|
||||||
|
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<TimeSpan> 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", "="));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,95 @@
|
||||||
|
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<AdvancedSearchResult>();
|
||||||
|
var ignore = new ConcurrentQueue<IEntity>();
|
||||||
|
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<AdvancedSearchResult>();
|
||||||
|
var ignore = new ConcurrentQueue<IEntity>();
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,100 @@
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using Server;
|
||||||
|
using Server.Factions;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace UOContent.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
[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<PlayerState> 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<PlayerState>();
|
||||||
|
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<PlayerState>();
|
||||||
|
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<PlayerState>();
|
||||||
|
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<PlayerState>();
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -6,6 +6,8 @@ public class DynamicTestGump : DynamicGump
|
||||||
{
|
{
|
||||||
private readonly string _petName;
|
private readonly string _petName;
|
||||||
|
|
||||||
|
public bool HasVisualElementsForTest => HasVisualElements;
|
||||||
|
|
||||||
public DynamicTestGump(string petName) : base(50, 50)
|
public DynamicTestGump(string petName) : base(50, 50)
|
||||||
{
|
{
|
||||||
_petName = petName;
|
_petName = petName;
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,40 @@
|
||||||
|
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<EmptyStaticTestGump>
|
||||||
|
{
|
||||||
|
public bool HasVisualElementsForTest => HasVisualElements;
|
||||||
|
|
||||||
|
public EmptyStaticTestGump() : base(0, 0)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void BuildLayout(ref StaticGumpBuilder builder)
|
||||||
|
{
|
||||||
|
builder.SetNoClose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -4,6 +4,8 @@ namespace Server.Tests.Gumps;
|
||||||
|
|
||||||
public sealed class LegacyTestGump : Gump
|
public sealed class LegacyTestGump : Gump
|
||||||
{
|
{
|
||||||
|
public bool HasVisualElementsForTest => HasVisualElements;
|
||||||
|
|
||||||
public LegacyTestGump(string petName) : base(50, 50)
|
public LegacyTestGump(string petName) : base(50, 50)
|
||||||
{
|
{
|
||||||
Serial = (Serial)0x123;
|
Serial = (Serial)0x123;
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,8 @@ namespace Server.Tests.Gumps;
|
||||||
|
|
||||||
public class StaticTestGump : StaticGump<StaticTestGump>
|
public class StaticTestGump : StaticGump<StaticTestGump>
|
||||||
{
|
{
|
||||||
|
public bool HasVisualElementsForTest => HasVisualElements;
|
||||||
|
|
||||||
public StaticTestGump() : base(50, 50)
|
public StaticTestGump() : base(50, 50)
|
||||||
{
|
{
|
||||||
Serial = (Serial)0x123;
|
Serial = (Serial)0x123;
|
||||||
|
|
|
||||||
|
|
@ -73,6 +73,32 @@ public class TestLayoutGumps
|
||||||
AssertThat.Equal(writer.Span, packet);
|
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>(T gump) where T : BaseGump
|
||||||
|
{
|
||||||
|
var buffer = GC.AllocateUninitializedArray<byte>(512);
|
||||||
|
var writer = new SpanWriter(buffer);
|
||||||
|
gump.Compile(ref writer);
|
||||||
|
return gump;
|
||||||
|
}
|
||||||
|
|
||||||
private static void InternalTestStaticGump<T>(ReadOnlySpan<byte> expectedLayout, StaticGump<T> staticGump, string[] strings)
|
private static void InternalTestStaticGump<T>(ReadOnlySpan<byte> expectedLayout, StaticGump<T> staticGump, string[] strings)
|
||||||
where T : StaticGump<T>
|
where T : StaticGump<T>
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,111 @@
|
||||||
|
using Server;
|
||||||
|
using Server.Items;
|
||||||
|
using Server.Mobiles;
|
||||||
|
using Server.Tests;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace UOContent.Tests;
|
||||||
|
|
||||||
|
[Collection("Sequential UOContent Tests")]
|
||||||
|
public class TreasureMapChestLiftTests
|
||||||
|
{
|
||||||
|
// Coordinates chosen to avoid overlap with Tracking (1000-4000, 1000-4000) and
|
||||||
|
// DetectHidden (1000-2400, 500) test areas.
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void PartialLift_MarksSplitRemainderAsLifted()
|
||||||
|
{
|
||||||
|
using var rng = new PredictableRandom(10); // RandomDouble() = 0.5, no spawn roll fires
|
||||||
|
var map = Map.Felucca;
|
||||||
|
var location = new Point3D(5000, 600, 0);
|
||||||
|
var player = CreatePlayerMobile(map, location);
|
||||||
|
var chest = new TreasureMapChest(1);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
chest.MoveToWorld(location, map);
|
||||||
|
chest.Locked = false;
|
||||||
|
|
||||||
|
var gold = FindGold(chest, null);
|
||||||
|
Assert.NotNull(gold);
|
||||||
|
|
||||||
|
player.Lift(gold, 1, out var rejected, out _);
|
||||||
|
Assert.False(rejected);
|
||||||
|
|
||||||
|
// The stack split re-adds the remainder as a brand-new item. It must count as
|
||||||
|
// already lifted, otherwise every 1-coin pull grants a fresh guardian spawn roll.
|
||||||
|
var remainder = FindGold(chest, gold);
|
||||||
|
Assert.NotNull(remainder);
|
||||||
|
Assert.Contains(remainder, chest.Lifted);
|
||||||
|
Assert.Contains(gold, chest.Lifted);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
player.Holding?.Delete();
|
||||||
|
player.Delete();
|
||||||
|
chest.Delete();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ItemAddedAfterFill_IsMarkedLifted()
|
||||||
|
{
|
||||||
|
using var rng = new PredictableRandom(10);
|
||||||
|
var chest = new TreasureMapChest(1);
|
||||||
|
var packed = new Gold(500);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Anything entering the chest after the initial fill (packed-back gold, split
|
||||||
|
// remainders, GM drops) was never part of the original loot and must not
|
||||||
|
// grant spawn rolls when lifted back out.
|
||||||
|
chest.DropItem(packed);
|
||||||
|
|
||||||
|
Assert.Contains(packed, chest.Lifted);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
chest.Delete();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void OriginalFillLoot_IsNotMarkedLifted()
|
||||||
|
{
|
||||||
|
using var rng = new PredictableRandom(10);
|
||||||
|
var chest = new TreasureMapChest(1);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// The original loot must stay roll-eligible for its first lift.
|
||||||
|
Assert.True(chest.Lifted == null || chest.Lifted.Count == 0);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
chest.Delete();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Gold FindGold(TreasureMapChest chest, Gold except)
|
||||||
|
{
|
||||||
|
var items = chest.Items;
|
||||||
|
|
||||||
|
for (var i = 0; i < items.Count; i++)
|
||||||
|
{
|
||||||
|
if (items[i] is Gold gold && gold != except)
|
||||||
|
{
|
||||||
|
return gold;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static PlayerMobile CreatePlayerMobile(Map map, Point3D location)
|
||||||
|
{
|
||||||
|
var mobile = new PlayerMobile(World.NewMobile);
|
||||||
|
mobile.DefaultMobileInit();
|
||||||
|
mobile.MoveToWorld(location, map);
|
||||||
|
return mobile;
|
||||||
|
}
|
||||||
|
}
|
||||||
211
Projects/UOContent.Tests/Tests/Mobiles/AI/AcquisitionTests.cs
Normal file
211
Projects/UOContent.Tests/Tests/Mobiles/AI/AcquisitionTests.cs
Normal file
|
|
@ -0,0 +1,211 @@
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using Server;
|
||||||
|
using Server.Mobiles;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace UOContent.Tests.Mobiles.AI;
|
||||||
|
|
||||||
|
// Pins the reacquire gate and the AcquireOnApproachDelay gradient: every scan re-arms the
|
||||||
|
// full ReacquireDelay; enemy movement clamps the deadline to the approach delay (Zero =
|
||||||
|
// prodded scan); an illegal deadline self-heals.
|
||||||
|
[Collection("Sequential Pathfinding Tests")]
|
||||||
|
public class AcquisitionTests : IDisposable
|
||||||
|
{
|
||||||
|
private readonly List<Mobile> _created = new();
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
foreach (var m in _created)
|
||||||
|
{
|
||||||
|
m?.Delete();
|
||||||
|
}
|
||||||
|
|
||||||
|
_created.Clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class WildStub : BaseCreature
|
||||||
|
{
|
||||||
|
public WildStub() : base(AIType.AI_Melee, FightMode.Closest, 16, 1) => Body = 0xC9;
|
||||||
|
|
||||||
|
public override void GetSpeeds(out double activeSpeed, out double passiveSpeed)
|
||||||
|
{
|
||||||
|
activeSpeed = 0.3;
|
||||||
|
passiveSpeed = 0.6;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class TargetStub : Mobile
|
||||||
|
{
|
||||||
|
public TargetStub() => Body = 0x190;
|
||||||
|
}
|
||||||
|
|
||||||
|
private WildStub Spawn(Map map, Point3D loc)
|
||||||
|
{
|
||||||
|
var bc = new WildStub();
|
||||||
|
bc.MoveToWorld(loc, map);
|
||||||
|
bc.AIObject.AITimer?.Stop();
|
||||||
|
_created.Add(bc);
|
||||||
|
return bc;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void EmptyScan_HonorsReacquireDelay()
|
||||||
|
{
|
||||||
|
var map = Map.Maps[1];
|
||||||
|
Assert.NotNull(map);
|
||||||
|
map.GetAverageZ(1500, 1600, out _, out var z, out _);
|
||||||
|
|
||||||
|
var bc = Spawn(map, new Point3D(1500, 1600, (sbyte)z));
|
||||||
|
bc.NextReacquireTime = Core.TickCount;
|
||||||
|
|
||||||
|
Assert.False(bc.AIObject.AcquireFocusMob(bc.RangePerception, FightMode.Closest, false, false, true));
|
||||||
|
Assert.InRange(bc.NextReacquireTime - Core.TickCount, 5000, 10000);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void WedgedGate_SelfHeals()
|
||||||
|
{
|
||||||
|
var map = Map.Maps[1];
|
||||||
|
Assert.NotNull(map);
|
||||||
|
map.GetAverageZ(1500, 1600, out _, out var z, out _);
|
||||||
|
|
||||||
|
var bc = Spawn(map, new Point3D(1500, 1600, (sbyte)z));
|
||||||
|
|
||||||
|
var target = new TargetStub();
|
||||||
|
target.DefaultMobileInit();
|
||||||
|
target.MoveToWorld(new Point3D(1497, 1600, (sbyte)z), map);
|
||||||
|
_created.Add(target);
|
||||||
|
|
||||||
|
// Illegal deadline (beyond ReacquireDelay): must read as open, not block forever.
|
||||||
|
bc.NextReacquireTime = Core.TickCount + 60000;
|
||||||
|
|
||||||
|
Assert.True(bc.AIObject.AcquireFocusMob(bc.RangePerception, FightMode.Closest, false, false, true));
|
||||||
|
Assert.Equal(target, bc.FocusMob);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(false, 5, true)] // an enemy moving inside approach range (10) clamps the deadline
|
||||||
|
[InlineData(true, 5, false)] // a same-team wild creature is not an enemy — ignored
|
||||||
|
[InlineData(false, 12, false)] // inside RangePerception but outside approach range — poll only
|
||||||
|
[InlineData(false, 20, false)] // outside approach range (10) is ignored
|
||||||
|
public void MovementClampsScanDeadlineOnlyForEnemiesInRange(bool wildMover, int distance, bool notices)
|
||||||
|
{
|
||||||
|
var map = Map.Maps[1];
|
||||||
|
Assert.NotNull(map);
|
||||||
|
map.GetAverageZ(1500, 1600, out _, out var z, out _);
|
||||||
|
|
||||||
|
var bc = Spawn(map, new Point3D(1500, 1600, (sbyte)z));
|
||||||
|
bc.NextReacquireTime = Core.TickCount + 8000;
|
||||||
|
|
||||||
|
Mobile mover;
|
||||||
|
if (wildMover)
|
||||||
|
{
|
||||||
|
mover = Spawn(map, new Point3D(1500 - distance, 1600, (sbyte)z));
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
mover = new TargetStub { Player = true };
|
||||||
|
mover.DefaultMobileInit();
|
||||||
|
mover.MoveToWorld(new Point3D(1500 - distance, 1600, (sbyte)z), map);
|
||||||
|
_created.Add(mover);
|
||||||
|
}
|
||||||
|
|
||||||
|
bc.OnMovement(mover, new Point3D(1400, 1600, (sbyte)z));
|
||||||
|
|
||||||
|
var remaining = bc.NextReacquireTime - Core.TickCount;
|
||||||
|
|
||||||
|
if (notices)
|
||||||
|
{
|
||||||
|
// Clamped to the approach delay (2s), never opened outright.
|
||||||
|
Assert.InRange(remaining, 1, (long)bc.AcquireOnApproachDelay.TotalMilliseconds);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Assert.True(remaining > 5000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class InstantStub : BaseCreature
|
||||||
|
{
|
||||||
|
public InstantStub() : base(AIType.AI_Melee, FightMode.Closest, 16, 1) => Body = 0xC9;
|
||||||
|
|
||||||
|
public override TimeSpan AcquireOnApproachDelay => TimeSpan.Zero;
|
||||||
|
|
||||||
|
public override void GetSpeeds(out double activeSpeed, out double passiveSpeed)
|
||||||
|
{
|
||||||
|
activeSpeed = 0.3;
|
||||||
|
passiveSpeed = 0.6;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ZeroApproachDelay_OpensGateImmediately()
|
||||||
|
{
|
||||||
|
var map = Map.Maps[1];
|
||||||
|
Assert.NotNull(map);
|
||||||
|
map.GetAverageZ(1500, 1600, out _, out var z, out _);
|
||||||
|
|
||||||
|
var bc = new InstantStub();
|
||||||
|
bc.MoveToWorld(new Point3D(1500, 1600, (sbyte)z), map);
|
||||||
|
bc.AIObject.AITimer?.Stop();
|
||||||
|
_created.Add(bc);
|
||||||
|
bc.NextReacquireTime = Core.TickCount + 8000;
|
||||||
|
|
||||||
|
var mover = new TargetStub { Player = true };
|
||||||
|
mover.DefaultMobileInit();
|
||||||
|
mover.MoveToWorld(new Point3D(1495, 1600, (sbyte)z), map);
|
||||||
|
_created.Add(mover);
|
||||||
|
|
||||||
|
bc.OnMovement(mover, new Point3D(1400, 1600, (sbyte)z));
|
||||||
|
|
||||||
|
// Zero = the gate opens and the AI is prodded to think now; no direct engage.
|
||||||
|
Assert.True(Core.TickCount - bc.NextReacquireTime >= 0);
|
||||||
|
Assert.Null(bc.Combatant);
|
||||||
|
Assert.True(bc.AIObject.AITimer.Running);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RepeatedMovement_DoesNotShortenBelowApproachDelay()
|
||||||
|
{
|
||||||
|
var map = Map.Maps[1];
|
||||||
|
Assert.NotNull(map);
|
||||||
|
map.GetAverageZ(1500, 1600, out _, out var z, out _);
|
||||||
|
|
||||||
|
var bc = Spawn(map, new Point3D(1500, 1600, (sbyte)z));
|
||||||
|
bc.NextReacquireTime = Core.TickCount + 8000;
|
||||||
|
|
||||||
|
var mover = new TargetStub { Player = true };
|
||||||
|
mover.DefaultMobileInit();
|
||||||
|
mover.MoveToWorld(new Point3D(1495, 1600, (sbyte)z), map);
|
||||||
|
_created.Add(mover);
|
||||||
|
|
||||||
|
bc.OnMovement(mover, new Point3D(1400, 1600, (sbyte)z));
|
||||||
|
var afterFirst = bc.NextReacquireTime;
|
||||||
|
|
||||||
|
bc.OnMovement(mover, new Point3D(1496, 1600, (sbyte)z));
|
||||||
|
|
||||||
|
Assert.Equal(afterFirst, bc.NextReacquireTime);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SuccessfulAcquire_HoldsFullDelay()
|
||||||
|
{
|
||||||
|
var map = Map.Maps[1];
|
||||||
|
Assert.NotNull(map);
|
||||||
|
map.GetAverageZ(1500, 1600, out _, out var z, out _);
|
||||||
|
|
||||||
|
var bc = Spawn(map, new Point3D(1500, 1600, (sbyte)z));
|
||||||
|
|
||||||
|
var target = new TargetStub();
|
||||||
|
target.DefaultMobileInit();
|
||||||
|
target.MoveToWorld(new Point3D(1497, 1600, (sbyte)z), map);
|
||||||
|
_created.Add(target);
|
||||||
|
|
||||||
|
bc.NextReacquireTime = Core.TickCount;
|
||||||
|
|
||||||
|
Assert.True(bc.AIObject.AcquireFocusMob(bc.RangePerception, FightMode.Closest, false, false, true));
|
||||||
|
Assert.Equal(target, bc.FocusMob);
|
||||||
|
Assert.True(bc.NextReacquireTime - Core.TickCount > 5000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -40,7 +40,7 @@ public class ApproachTargetTests
|
||||||
for (var i = 0; i < maxTicks; i++)
|
for (var i = 0; i < maxTicks; i++)
|
||||||
{
|
{
|
||||||
ai.NextMove = 0;
|
ai.NextMove = 0;
|
||||||
ai.WalkMobileRange(target, 1, false, 1, 2);
|
ai.WalkMobileRange(target, 1, 1, 2);
|
||||||
if (bc.InRange(target, arriveDist))
|
if (bc.InRange(target, arriveDist))
|
||||||
{
|
{
|
||||||
return true;
|
return true;
|
||||||
|
|
@ -123,7 +123,7 @@ public class ApproachTargetTests
|
||||||
for (var i = 0; i < 200; i++)
|
for (var i = 0; i < 200; i++)
|
||||||
{
|
{
|
||||||
ai.NextMove = 0;
|
ai.NextMove = 0;
|
||||||
ai.MoveTo(target, false, 1);
|
ai.MoveTo(target, 1);
|
||||||
if (bc.InRange(target, 1))
|
if (bc.InRange(target, 1))
|
||||||
{
|
{
|
||||||
arrived = true;
|
arrived = true;
|
||||||
|
|
@ -154,7 +154,7 @@ public class ApproachTargetTests
|
||||||
for (var i = 0; i < 60; i++)
|
for (var i = 0; i < 60; i++)
|
||||||
{
|
{
|
||||||
ai.NextMove = 0;
|
ai.NextMove = 0;
|
||||||
ai.MoveTo(target, true, 1);
|
ai.MoveTo(target, 1);
|
||||||
|
|
||||||
// Target walks west every other tick for its first several steps, then stops,
|
// Target walks west every other tick for its first several steps, then stops,
|
||||||
// so a same-speed chaser eventually closes the gap.
|
// so a same-speed chaser eventually closes the gap.
|
||||||
|
|
@ -214,7 +214,7 @@ public class ApproachTargetTests
|
||||||
for (var i = 0; i < 120; i++)
|
for (var i = 0; i < 120; i++)
|
||||||
{
|
{
|
||||||
ai.NextMove = 0;
|
ai.NextMove = 0;
|
||||||
ai.MoveTo(target, false, 1);
|
ai.MoveTo(target, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
// After giving up, the creature must idle (not oscillate) while the goal is still.
|
// 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++)
|
for (var i = 0; i < 20; i++)
|
||||||
{
|
{
|
||||||
ai.NextMove = 0;
|
ai.NextMove = 0;
|
||||||
ai.MoveTo(target, false, 1);
|
ai.MoveTo(target, 1);
|
||||||
if (bc.Location != idleStart)
|
if (bc.Location != idleStart)
|
||||||
{
|
{
|
||||||
stayedIdle = false;
|
stayedIdle = false;
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,96 @@
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using Server;
|
||||||
|
using Server.Mobiles;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace UOContent.Tests.Mobiles.AI;
|
||||||
|
|
||||||
|
// Guard-following may pathfind, so this shares the pathfinding collection.
|
||||||
|
[Collection("Sequential Pathfinding Tests")]
|
||||||
|
public class GuardFollowTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void GuardFollow_StepsTowardMaster_AndRegistersMoveIntent()
|
||||||
|
{
|
||||||
|
var map = Map.Maps[1];
|
||||||
|
Assert.NotNull(map);
|
||||||
|
map.GetAverageZ(1500, 1600, out _, out var z, out _);
|
||||||
|
|
||||||
|
var master = new PlayerMobile(World.NewMobile);
|
||||||
|
master.DefaultMobileInit();
|
||||||
|
master.MoveToWorld(new Point3D(1494, 1600, (sbyte)z), map);
|
||||||
|
|
||||||
|
var pet = new PetTestStub();
|
||||||
|
pet.MoveToWorld(new Point3D(1500, 1600, (sbyte)z), map); // 6 tiles east, open terrain
|
||||||
|
pet.SetControlMaster(master);
|
||||||
|
|
||||||
|
var ai = pet.AIObject;
|
||||||
|
ai.AITimer?.Stop(); // drive manually
|
||||||
|
pet.ControlOrder = OrderType.Guard;
|
||||||
|
ai.AITimer?.Stop(); // the order change may restart the timer
|
||||||
|
|
||||||
|
var start = pet.Location;
|
||||||
|
ai.NextMove = 0;
|
||||||
|
ai.Obey();
|
||||||
|
|
||||||
|
var moved = pet.Location != start;
|
||||||
|
var hasIntent = ai.TryGetMoveWake(out _);
|
||||||
|
var currentSpeed = pet.CurrentSpeed;
|
||||||
|
var currentMoveSpeed = pet.CurrentMoveSpeed;
|
||||||
|
|
||||||
|
pet.Delete();
|
||||||
|
master.Delete();
|
||||||
|
|
||||||
|
Assert.True(moved, "a guarding pet beyond guard range must step toward its master");
|
||||||
|
// Without a move intent, guard-following only steps on the think grid.
|
||||||
|
Assert.True(hasIntent, "guard-following must register a move intent");
|
||||||
|
|
||||||
|
// AOS return sprint on both clocks; the per-step speed flip must not undo it.
|
||||||
|
Assert.Equal(0.1, currentSpeed);
|
||||||
|
Assert.Equal(0.1, currentMoveSpeed);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GuardReturn_PreAOS_RunsActive()
|
||||||
|
{
|
||||||
|
var previous = Core.Expansion;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Core.Expansion = Expansion.UOR;
|
||||||
|
|
||||||
|
var map = Map.Maps[1];
|
||||||
|
Assert.NotNull(map);
|
||||||
|
map.GetAverageZ(1500, 1600, out _, out var z, out _);
|
||||||
|
|
||||||
|
var master = new PlayerMobile(World.NewMobile);
|
||||||
|
master.DefaultMobileInit();
|
||||||
|
master.MoveToWorld(new Point3D(1494, 1600, (sbyte)z), map);
|
||||||
|
|
||||||
|
var pet = new PetTestStub();
|
||||||
|
pet.MoveToWorld(new Point3D(1500, 1600, (sbyte)z), map);
|
||||||
|
pet.SetControlMaster(master);
|
||||||
|
|
||||||
|
var ai = pet.AIObject;
|
||||||
|
ai.AITimer?.Stop();
|
||||||
|
pet.ControlOrder = OrderType.Guard;
|
||||||
|
ai.AITimer?.Stop();
|
||||||
|
pet.SetCurrentSpeedToPassive(); // a stale passive state must not persist
|
||||||
|
|
||||||
|
ai.NextMove = 0;
|
||||||
|
ai.Obey();
|
||||||
|
|
||||||
|
var currentSpeed = pet.CurrentSpeed;
|
||||||
|
|
||||||
|
pet.Delete();
|
||||||
|
master.Delete();
|
||||||
|
|
||||||
|
// No sprint pre-AOS: the return runs active.
|
||||||
|
Assert.Equal(0.2, currentSpeed);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
Core.Expansion = previous;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
137
Projects/UOContent.Tests/Tests/Mobiles/AI/GuardOrderTests.cs
Normal file
137
Projects/UOContent.Tests/Tests/Mobiles/AI/GuardOrderTests.cs
Normal file
|
|
@ -0,0 +1,137 @@
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using Server;
|
||||||
|
using Server.Mobiles;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace UOContent.Tests.Mobiles.AI;
|
||||||
|
|
||||||
|
// A guarding pet fights without leaving the Guard order, retargets toward the master's
|
||||||
|
// closest aggressor, and stands down when nothing threatens. Scene: the open
|
||||||
|
// (1495..1500, 1600) Trammel segment; targets are adjacent so no pathfinding runs.
|
||||||
|
[Collection("Sequential UOContent Tests")]
|
||||||
|
public class GuardOrderTests : IDisposable
|
||||||
|
{
|
||||||
|
private readonly List<Mobile> _created = new();
|
||||||
|
|
||||||
|
private sealed class AggressorStub : Mobile
|
||||||
|
{
|
||||||
|
public AggressorStub() => Body = 0xC9;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
foreach (var m in _created)
|
||||||
|
{
|
||||||
|
m?.Delete();
|
||||||
|
}
|
||||||
|
|
||||||
|
_created.Clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
private (PlayerMobile master, PetTestStub pet) SpawnGuardingPet(out Map map, out int z)
|
||||||
|
{
|
||||||
|
map = Map.Maps[1];
|
||||||
|
Assert.NotNull(map);
|
||||||
|
map.GetAverageZ(1500, 1600, out _, out z, out _);
|
||||||
|
|
||||||
|
var master = new PlayerMobile(World.NewMobile);
|
||||||
|
master.DefaultMobileInit();
|
||||||
|
master.MoveToWorld(new Point3D(1500, 1600, (sbyte)z), map);
|
||||||
|
_created.Add(master);
|
||||||
|
|
||||||
|
var pet = new PetTestStub();
|
||||||
|
pet.MoveToWorld(new Point3D(1499, 1600, (sbyte)z), map);
|
||||||
|
pet.SetControlMaster(master);
|
||||||
|
_created.Add(pet);
|
||||||
|
|
||||||
|
pet.AIObject.AITimer?.Stop(); // drive manually
|
||||||
|
pet.ControlOrder = OrderType.Guard;
|
||||||
|
pet.AIObject.AITimer?.Stop(); // the order change restarts the timer
|
||||||
|
|
||||||
|
return (master, pet);
|
||||||
|
}
|
||||||
|
|
||||||
|
private AggressorStub SpawnAggressor(PetTestStub pet, Point3D loc, Mobile attacking)
|
||||||
|
{
|
||||||
|
var aggr = new AggressorStub();
|
||||||
|
aggr.MoveToWorld(loc, pet.Map);
|
||||||
|
_created.Add(aggr);
|
||||||
|
|
||||||
|
// Setup guard: the scene must stay LOS-clear and the combatant must not be vetoed.
|
||||||
|
Assert.True(pet.InLOS(aggr), $"no LOS from pet to aggressor at {loc}");
|
||||||
|
|
||||||
|
if (attacking != null)
|
||||||
|
{
|
||||||
|
aggr.Combatant = attacking;
|
||||||
|
Assert.Same(attacking, aggr.Combatant);
|
||||||
|
}
|
||||||
|
|
||||||
|
return aggr;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GuardEngage_KeepsGuardOrder()
|
||||||
|
{
|
||||||
|
var (master, pet) = SpawnGuardingPet(out _, out var z);
|
||||||
|
var aggr = SpawnAggressor(pet, new Point3D(1498, 1600, (sbyte)z), master);
|
||||||
|
|
||||||
|
pet.AIObject.Obey();
|
||||||
|
|
||||||
|
Assert.Same(aggr, pet.Combatant);
|
||||||
|
Assert.Equal(OrderType.Guard, pet.ControlOrder);
|
||||||
|
Assert.Equal(OrderType.Guard, pet.AIObject.PersistentOrder);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Guard_RetargetsToAggressorClosestToMaster()
|
||||||
|
{
|
||||||
|
var (master, pet) = SpawnGuardingPet(out _, out var z);
|
||||||
|
var far = SpawnAggressor(pet, new Point3D(1495, 1600, (sbyte)z), master);
|
||||||
|
var near = SpawnAggressor(pet, new Point3D(1498, 1600, (sbyte)z), master);
|
||||||
|
|
||||||
|
pet.Combatant = far; // already fighting the far aggressor
|
||||||
|
|
||||||
|
pet.AIObject.Obey();
|
||||||
|
|
||||||
|
Assert.Same(near, pet.Combatant); // defends the master, not the current fight
|
||||||
|
Assert.Equal(OrderType.Guard, pet.ControlOrder);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ExplicitAttack_ResumesGuard_WithoutChainingIntoAttack()
|
||||||
|
{
|
||||||
|
var (master, pet) = SpawnGuardingPet(out _, out var z);
|
||||||
|
|
||||||
|
// Explicit kill order on a target that then becomes invalid.
|
||||||
|
var victim = SpawnAggressor(pet, new Point3D(1498, 1600, (sbyte)z), null);
|
||||||
|
pet.ControlTarget = victim;
|
||||||
|
pet.ControlOrder = OrderType.Attack;
|
||||||
|
victim.Hidden = true;
|
||||||
|
|
||||||
|
// A second aggressor is still after the master; FightMode.Closest would chain it.
|
||||||
|
var aggr2 = SpawnAggressor(pet, new Point3D(1497, 1600, (sbyte)z), master);
|
||||||
|
|
||||||
|
pet.AIObject.Obey(); // attack completes -> resume the persistent Guard
|
||||||
|
|
||||||
|
Assert.Equal(OrderType.Guard, pet.ControlOrder);
|
||||||
|
|
||||||
|
pet.AIObject.Obey(); // the guard scan engages the remaining aggressor in-order
|
||||||
|
|
||||||
|
Assert.Same(aggr2, pet.Combatant);
|
||||||
|
Assert.Equal(OrderType.Guard, pet.ControlOrder);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void PeacefulGuard_StandsDown()
|
||||||
|
{
|
||||||
|
var (_, pet) = SpawnGuardingPet(out _, out _);
|
||||||
|
Assert.True(pet.Warmode); // the guard order opens in war stance
|
||||||
|
|
||||||
|
pet.AIObject.Obey(); // nothing to guard against
|
||||||
|
|
||||||
|
Assert.False(pet.Warmode);
|
||||||
|
Assert.Null(pet.Combatant);
|
||||||
|
Assert.Null(pet.FocusMob);
|
||||||
|
}
|
||||||
|
}
|
||||||
219
Projects/UOContent.Tests/Tests/Mobiles/AI/MoveSpeedTests.cs
Normal file
219
Projects/UOContent.Tests/Tests/Mobiles/AI/MoveSpeedTests.cs
Normal file
|
|
@ -0,0 +1,219 @@
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using Server;
|
||||||
|
using Server.Mobiles;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace UOContent.Tests.Mobiles.AI;
|
||||||
|
|
||||||
|
// Pins the CurrentMoveSpeed classification (verbatim active/passive maps to the matching
|
||||||
|
// move value; bespoke stays fused), SetSpeed's one-clock guarantee, and the v22 tail.
|
||||||
|
[Collection("Sequential UOContent Tests")]
|
||||||
|
public class MoveSpeedTests : IDisposable
|
||||||
|
{
|
||||||
|
// Delete spawned stubs so they don't linger in the shared static World.
|
||||||
|
private readonly List<Mobile> _created = new();
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
for (var i = 0; i < _created.Count; i++)
|
||||||
|
{
|
||||||
|
_created[i].Delete();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class SpeedStub : BaseCreature
|
||||||
|
{
|
||||||
|
// Stands in for the npc-speeds table (unconfigured in the test fixture).
|
||||||
|
public double TableActiveMove;
|
||||||
|
public double TablePassiveMove;
|
||||||
|
|
||||||
|
public SpeedStub() : base(AIType.AI_Animal) => Body = 0xC9;
|
||||||
|
|
||||||
|
public SpeedStub(Serial serial) : base(serial) => Body = 0xC9;
|
||||||
|
|
||||||
|
public override void GetSpeeds(out double activeSpeed, out double passiveSpeed)
|
||||||
|
{
|
||||||
|
activeSpeed = 0.3;
|
||||||
|
passiveSpeed = 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override void GetMoveSpeeds(out double activeMoveSpeed, out double passiveMoveSpeed)
|
||||||
|
{
|
||||||
|
activeMoveSpeed = TableActiveMove;
|
||||||
|
passiveMoveSpeed = TablePassiveMove;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private SpeedStub NewCreature()
|
||||||
|
{
|
||||||
|
var bc = new SpeedStub();
|
||||||
|
_created.Add(bc);
|
||||||
|
return bc;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void MoveSpeeds_InheritThinkValues_ByDefault()
|
||||||
|
{
|
||||||
|
var bc = NewCreature();
|
||||||
|
|
||||||
|
Assert.Equal(0.3, bc.ActiveMoveSpeed);
|
||||||
|
Assert.Equal(0.6, bc.PassiveMoveSpeed);
|
||||||
|
Assert.Equal(bc.CurrentSpeed, bc.CurrentMoveSpeed);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CurrentMoveSpeed_ResolvesPerMode_WhenOverridden()
|
||||||
|
{
|
||||||
|
var bc = NewCreature();
|
||||||
|
bc.SetMoveSpeed(0.45, 0.9);
|
||||||
|
|
||||||
|
// SetSpeed left the creature passive; the think clock is untouched.
|
||||||
|
Assert.Equal(0.6, bc.CurrentSpeed);
|
||||||
|
Assert.Equal(0.9, bc.CurrentMoveSpeed);
|
||||||
|
|
||||||
|
bc.SetCurrentSpeedToActive();
|
||||||
|
Assert.Equal(0.3, bc.CurrentSpeed);
|
||||||
|
Assert.Equal(0.45, bc.CurrentMoveSpeed);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CurrentMoveSpeed_BespokePace_StaysFused()
|
||||||
|
{
|
||||||
|
var bc = NewCreature();
|
||||||
|
bc.SetMoveSpeed(0.45, 0.9);
|
||||||
|
|
||||||
|
// Neither think value verbatim, so both clocks run it.
|
||||||
|
bc.CurrentSpeed = 0.11;
|
||||||
|
Assert.Equal(0.11, bc.CurrentMoveSpeed);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SetSpeed_ClearsMoveOverrides()
|
||||||
|
{
|
||||||
|
var bc = NewCreature();
|
||||||
|
bc.SetMoveSpeed(0.45, 0.9);
|
||||||
|
|
||||||
|
bc.SetSpeed(0.2, 0.4);
|
||||||
|
|
||||||
|
Assert.Equal(0.2, bc.ActiveMoveSpeed);
|
||||||
|
Assert.Equal(0.4, bc.PassiveMoveSpeed);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void NonPositiveMoveSpeed_ClearsThatOverride()
|
||||||
|
{
|
||||||
|
var bc = NewCreature();
|
||||||
|
bc.SetMoveSpeed(0.45, 0.9);
|
||||||
|
|
||||||
|
bc.ActiveMoveSpeed = 0;
|
||||||
|
|
||||||
|
Assert.Equal(0.3, bc.ActiveMoveSpeed); // inheriting again
|
||||||
|
Assert.Equal(0.9, bc.PassiveMoveSpeed); // other override untouched
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ScaleMoveSpeed_ScalesOverrides_LeavesInheritAlone()
|
||||||
|
{
|
||||||
|
var bc = NewCreature();
|
||||||
|
bc.ActiveMoveSpeed = 0.6; // passive left inheriting
|
||||||
|
|
||||||
|
bc.ScaleMoveSpeed(1.0 / 1.2);
|
||||||
|
|
||||||
|
Assert.Equal(0.5, bc.ActiveMoveSpeed);
|
||||||
|
Assert.Equal(bc.PassiveSpeed, bc.PassiveMoveSpeed); // still inheriting, not 0 * scalar
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Herding_DrivesMoveClock_ThinkUntouched()
|
||||||
|
{
|
||||||
|
var bc = NewCreature(); // think 0.3/0.6, passive
|
||||||
|
bc.SetMoveSpeed(0.45, 1.05);
|
||||||
|
|
||||||
|
bc.TargetLocation = new Point2D(10, 10);
|
||||||
|
|
||||||
|
Assert.Equal(0.6, bc.CurrentSpeed); // think clock unaffected by herding
|
||||||
|
Assert.Equal(0.3, bc.CurrentMoveSpeed); // fixed herding pace, not 1.05
|
||||||
|
|
||||||
|
bc.TargetLocation = null;
|
||||||
|
Assert.Equal(1.05, bc.CurrentMoveSpeed);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SnapSpeedsToTable_UndoesScalingDrift_KeepsTunedValues()
|
||||||
|
{
|
||||||
|
var bc = NewCreature();
|
||||||
|
bc.TableActiveMove = 0.45;
|
||||||
|
bc.TablePassiveMove = 0.9;
|
||||||
|
bc.SetMoveSpeed(0.45, 0.9);
|
||||||
|
|
||||||
|
// 0.45 and 0.9 do not survive /1.2 then *1.2 bit-exactly.
|
||||||
|
bc.ScaleMoveSpeed(1.0 / 1.2);
|
||||||
|
bc.ScaleMoveSpeed(1.2);
|
||||||
|
Assert.NotEqual(0.45, bc.ActiveMoveSpeed);
|
||||||
|
|
||||||
|
bc.SnapSpeedsToTable();
|
||||||
|
Assert.Equal(0.45, bc.ActiveMoveSpeed);
|
||||||
|
Assert.Equal(0.9, bc.PassiveMoveSpeed);
|
||||||
|
|
||||||
|
// A hand-tuned value is nowhere near the epsilon and must keep.
|
||||||
|
bc.SetMoveSpeed(0.7, 0.9);
|
||||||
|
bc.SnapSpeedsToTable();
|
||||||
|
Assert.Equal(0.7, bc.ActiveMoveSpeed);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Migration_MatchingThinkSpeeds_AdoptTableMoveValues()
|
||||||
|
{
|
||||||
|
var bc = NewCreature(); // think 0.3/0.6, matching its table entry
|
||||||
|
bc.TableActiveMove = 0.45;
|
||||||
|
bc.TablePassiveMove = 0.9;
|
||||||
|
|
||||||
|
bc.MigrateMoveSpeeds();
|
||||||
|
|
||||||
|
Assert.Equal(0.45, bc.ActiveMoveSpeed);
|
||||||
|
Assert.Equal(0.9, bc.PassiveMoveSpeed);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Migration_TunedThinkSpeeds_KeepInheriting()
|
||||||
|
{
|
||||||
|
var bc = NewCreature();
|
||||||
|
bc.SetSpeed(0.35, 0.6); // hand-tuned: no longer matches the table entry
|
||||||
|
bc.TableActiveMove = 0.45;
|
||||||
|
bc.TablePassiveMove = 0.9;
|
||||||
|
|
||||||
|
bc.MigrateMoveSpeeds();
|
||||||
|
|
||||||
|
Assert.Equal(0.35, bc.ActiveMoveSpeed);
|
||||||
|
Assert.Equal(0.6, bc.PassiveMoveSpeed);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(true)]
|
||||||
|
[InlineData(false)]
|
||||||
|
public void MoveSpeedOverrides_SurviveSerialization(bool overridden)
|
||||||
|
{
|
||||||
|
var bc = NewCreature();
|
||||||
|
if (overridden)
|
||||||
|
{
|
||||||
|
bc.SetMoveSpeed(0.45, 0.9);
|
||||||
|
}
|
||||||
|
|
||||||
|
var writer = new BufferWriter(true);
|
||||||
|
bc.Serialize(writer);
|
||||||
|
|
||||||
|
var buffer = new byte[writer.Position];
|
||||||
|
writer.Buffer.AsSpan(0, (int)writer.Position).CopyTo(buffer);
|
||||||
|
|
||||||
|
var copy = new SpeedStub(World.NewMobile);
|
||||||
|
_created.Add(copy);
|
||||||
|
var reader = new BufferReader(buffer);
|
||||||
|
copy.Deserialize(reader);
|
||||||
|
|
||||||
|
// The v22 tail is the last block; exact consumption catches any offset mistake.
|
||||||
|
Assert.Equal(buffer.Length, reader.Position);
|
||||||
|
Assert.Equal(overridden ? 0.45 : 0.3, copy.ActiveMoveSpeed);
|
||||||
|
Assert.Equal(overridden ? 0.9 : 0.6, copy.PassiveMoveSpeed);
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue