Compare commits
28 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
274 changed files with 10146 additions and 2951 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"
|
||||||
]
|
]
|
||||||
|
|
|
||||||
30
.github/workflows/build-test.yml
vendored
30
.github/workflows/build-test.yml
vendored
|
|
@ -46,7 +46,7 @@ jobs:
|
||||||
- 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
|
||||||
|
|
@ -124,12 +124,36 @@ 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
|
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
|
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.
|
||||||
|
|
|
||||||
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -14,6 +14,7 @@
|
||||||
/Distribution/Configuration/blocklist.json
|
/Distribution/Configuration/blocklist.json
|
||||||
/Distribution/Configuration/crowdsec.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
|
||||||
/Distribution/Configuration/ip-allowlist*.txt.tmp
|
/Distribution/Configuration/ip-allowlist*.txt.tmp
|
||||||
/Distribution/Configuration/ip-blocklist.txt
|
/Distribution/Configuration/ip-blocklist.txt
|
||||||
|
|
@ -25,6 +26,7 @@
|
||||||
/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
|
||||||
|
|
|
||||||
22
CLAUDE.md
22
CLAUDE.md
|
|
@ -18,8 +18,8 @@ Apply these when writing or reviewing `.cs` files under `Projects/`.
|
||||||
6. **Cancel timers in `OnDelete()`/`OnAfterDelete()`** — call `_token.Cancel()` or `_timer?.Stop()`
|
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()` for game logic** (tandem with rule #3) — game logic is the single-threaded event loop. Backgrounding is allowed only for work that does not itself touch game state (external service calls, large-file parse). When such work must *feed* game logic: run the heavy/I/O part off-loop and `ConfigureAwait(false)` its awaits so a continuation never resumes on the loop and silently foregrounds heavy work; then hand the result back **explicitly** — publish an immutable snapshot swapped via a `volatile` reference (the loop reads it lock-free), or marshal the apply step with `Core.LoopContext.Post(() => …)`. Never touch game state off-thread; never let the scheduler decide where the heavy work runs → `dev-docs/threading-model.md`
|
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`
|
||||||
|
|
@ -29,6 +29,7 @@ Apply these when writing or reviewing `.cs` files under `Projects/`.
|
||||||
17. **No `System.Text.StringBuilder`** — use `ValueStringBuilder` with `stackalloc` (bounded output) or `ValueStringBuilder.Create()` (unbounded). Supports `$"..."` interpolation directly. Always use `using var` for disposal. Use `Reset()` instead of reassigning → `dev-docs/string-handling.md`
|
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`
|
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
|
||||||
|
|
||||||
|
|
@ -45,7 +46,11 @@ 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` |
|
| IP bans, blocklists & allowlists (incl. unblocking a player) | `dev-docs/ip-bans-and-allowlists.md` |
|
||||||
|
|
@ -94,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",
|
"Debian/Ubuntu: sudo apt-get install -y libdeflate0 libargon2-1 libicuNN tzdata",
|
||||||
"Fedora/RHEL: sudo dnf install -y libicu libdeflate-devel zstd libargon2-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" };
|
|
||||||
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" };
|
|
||||||
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,48 +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"
|
|
||||||
};
|
|
||||||
|
|
||||||
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;
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
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
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -335,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
|
||||||
|
|
@ -373,7 +391,7 @@ public partial class Item : IHued, IComparable<Item>, ISpawnable, IObjectPropert
|
||||||
}
|
}
|
||||||
|
|
||||||
Delta(ItemDelta.Update);
|
Delta(ItemDelta.Update);
|
||||||
UpdateDecayRegistration();
|
RestartDecay();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -389,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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -749,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
|
||||||
{
|
{
|
||||||
|
|
@ -839,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;
|
||||||
|
|
||||||
|
|
@ -949,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)
|
||||||
|
|
@ -979,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))
|
||||||
{
|
{
|
||||||
|
|
@ -1307,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();
|
||||||
|
|
@ -1537,7 +1577,7 @@ public partial class Item : IHued, IComparable<Item>, ISpawnable, IObjectPropert
|
||||||
|
|
||||||
if (oldValue != value)
|
if (oldValue != value)
|
||||||
{
|
{
|
||||||
UpdateDecayRegistration();
|
RestartDecay();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1731,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;
|
||||||
|
|
@ -2315,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()
|
||||||
{
|
{
|
||||||
|
|
@ -2325,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()
|
||||||
|
|
@ -2357,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)
|
||||||
|
|
@ -2657,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:
|
||||||
|
|
@ -2664,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();
|
||||||
}
|
}
|
||||||
|
|
@ -2682,6 +2797,18 @@ public partial class Item : IHued, IComparable<Item>, ISpawnable, IObjectPropert
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (version >= 10 && GetSaveFlag(flags, SaveFlag.DecayReset))
|
||||||
|
{
|
||||||
|
var reset = version >= 11 ? reader.ReadAnchoredTime() : reader.ReadDeltaTime();
|
||||||
|
|
||||||
|
// Pre-v11 LastMoved was stored at whole-minute precision; keep the
|
||||||
|
// stamp only while it still extends the deadline.
|
||||||
|
if (reset > LastMoved)
|
||||||
|
{
|
||||||
|
DecayResetTime = reset;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (GetSaveFlag(flags, SaveFlag.Direction))
|
if (GetSaveFlag(flags, SaveFlag.Direction))
|
||||||
{
|
{
|
||||||
m_Direction = (Direction)reader.ReadByte();
|
m_Direction = (Direction)reader.ReadByte();
|
||||||
|
|
@ -2854,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);
|
||||||
|
|
@ -3325,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;
|
||||||
|
|
@ -3431,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;
|
||||||
}
|
}
|
||||||
|
|
@ -4337,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;
|
||||||
|
|
@ -4374,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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -39,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;
|
||||||
|
|
||||||
|
|
@ -111,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
|
||||||
|
|
@ -235,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)
|
||||||
|
|
@ -424,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
|
||||||
|
|
@ -440,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);
|
||||||
|
|
@ -453,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");
|
||||||
|
|
@ -469,41 +678,71 @@ public static class Core
|
||||||
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -513,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++;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -553,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()
|
||||||
|
|
|
||||||
|
|
@ -2324,11 +2324,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;
|
||||||
|
|
||||||
|
|
@ -5248,8 +5248,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);
|
||||||
|
|
||||||
|
|
@ -6143,6 +6150,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
|
||||||
|
|
@ -6151,9 +6159,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;
|
||||||
}
|
}
|
||||||
|
|
@ -7834,6 +7851,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)
|
||||||
|
|
|
||||||
|
|
@ -71,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>
|
||||||
|
|
|
||||||
|
|
@ -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.9" />
|
<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)
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -1050,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");
|
||||||
|
|
|
||||||
|
|
@ -100,6 +100,8 @@ internal static class TestServerInitializer
|
||||||
}
|
}
|
||||||
|
|
||||||
World.Configure();
|
World.Configure();
|
||||||
|
// Registers the Accounts entity persistence; without it no test can construct an Account.
|
||||||
|
Server.Accounting.Accounts.Configure();
|
||||||
RaceDefinitions.Configure();
|
RaceDefinitions.Configure();
|
||||||
MovementImpl.Configure();
|
MovementImpl.Configure();
|
||||||
PathFollower.Configure();
|
PathFollower.Configure();
|
||||||
|
|
|
||||||
|
|
@ -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"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -20,8 +20,8 @@ using Xunit;
|
||||||
|
|
||||||
namespace Server.Tests.Network.AutoDenylists;
|
namespace Server.Tests.Network.AutoDenylists;
|
||||||
|
|
||||||
// Static store, so every test resets it first. Addresses come from TEST-NET-2 (198.51.100.0/24).
|
// Static store, so every test resets it first and none may run alongside another.
|
||||||
// Sequential: the cap tests reach Sweep, which rents from STArrayPool, which is not thread-safe.
|
// Addresses come from TEST-NET-2 (198.51.100.0/24).
|
||||||
[Collection("Sequential UOContent Tests")]
|
[Collection("Sequential UOContent Tests")]
|
||||||
public class AutoDenylistTests
|
public class AutoDenylistTests
|
||||||
{
|
{
|
||||||
|
|
@ -62,8 +62,11 @@ public class AutoDenylistTests
|
||||||
Assert.Equal(0, AutoDenylist.Count);
|
Assert.Equal(0, AutoDenylist.Count);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Not refreshed on purpose: it is what keeps insertion order equal to expiry order, so retiring lapsed
|
||||||
|
// entries costs the number expiring instead of the number held. A flooder whose hold lapses trips the
|
||||||
|
// rate limiter on its next attempt -- which runs ahead of the connection filters -- and is held again.
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Repeat_detection_extends_the_hold()
|
public void Repeat_detection_does_not_extend_the_hold()
|
||||||
{
|
{
|
||||||
Reset();
|
Reset();
|
||||||
var ip = IPAddress.Parse("198.51.100.13");
|
var ip = IPAddress.Parse("198.51.100.13");
|
||||||
|
|
@ -71,8 +74,72 @@ public class AutoDenylistTests
|
||||||
AutoDenylist.Hold(ip, BanReasons.SilentConnect, Now);
|
AutoDenylist.Hold(ip, BanReasons.SilentConnect, Now);
|
||||||
AutoDenylist.Hold(ip, BanReasons.SilentConnect, Now + DurationMs - 1);
|
AutoDenylist.Hold(ip, BanReasons.SilentConnect, Now + DurationMs - 1);
|
||||||
|
|
||||||
Assert.True(AutoDenylist.IsDenied(ip, Now + DurationMs + 1)); // would have lapsed without the second
|
Assert.Equal(1, AutoDenylist.Count); // no duplicate
|
||||||
Assert.Equal(1, AutoDenylist.Count); // and did not add a duplicate
|
Assert.True(AutoDenylist.IsDenied(ip, Now + DurationMs - 1));
|
||||||
|
Assert.False(AutoDenylist.IsDenied(ip, Now + DurationMs + 1)); // lapses from the FIRST detection
|
||||||
|
}
|
||||||
|
|
||||||
|
// The ring carries the expiry and the set carries membership; if they ever disagree, an address is
|
||||||
|
// either denied forever or retired early.
|
||||||
|
[Fact]
|
||||||
|
public void Ring_and_set_stay_in_step()
|
||||||
|
{
|
||||||
|
Reset(maxEntries: 4);
|
||||||
|
|
||||||
|
for (var i = 0; i < 8; i++)
|
||||||
|
{
|
||||||
|
AutoDenylist.Hold(IPAddress.Parse($"198.51.100.{70 + i}"), BanReasons.InvalidSeed, Now);
|
||||||
|
}
|
||||||
|
|
||||||
|
Assert.Equal(4, AutoDenylist.Count);
|
||||||
|
Assert.Equal(AutoDenylist.Count, AutoDenylist.RingCount);
|
||||||
|
|
||||||
|
AutoDenylist.Release(IPAddress.Parse("198.51.100.71"));
|
||||||
|
Assert.Equal(3, AutoDenylist.Count);
|
||||||
|
Assert.Equal(AutoDenylist.Count, AutoDenylist.RingCount);
|
||||||
|
|
||||||
|
AutoDenylist.Drain(Now + DurationMs + 1);
|
||||||
|
Assert.Equal(0, AutoDenylist.Count);
|
||||||
|
Assert.Equal(0, AutoDenylist.RingCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The ring grows in doublings but is capped at maxEntries, which is not a power of two. Filling exactly
|
||||||
|
// to it must land on the last slot rather than off the end.
|
||||||
|
[Fact]
|
||||||
|
public void Ring_fills_exactly_to_a_non_power_of_two_cap()
|
||||||
|
{
|
||||||
|
Reset(maxEntries: 100);
|
||||||
|
|
||||||
|
for (var i = 0; i < 120; i++)
|
||||||
|
{
|
||||||
|
AutoDenylist.Hold(IPAddress.Parse($"198.51.100.{i}"), BanReasons.InvalidSeed, Now);
|
||||||
|
}
|
||||||
|
|
||||||
|
Assert.Equal(100, AutoDenylist.Count);
|
||||||
|
Assert.Equal(100, AutoDenylist.RingCount);
|
||||||
|
|
||||||
|
// And the whole ring still drains, so no slot was stranded by a wrapped write.
|
||||||
|
AutoDenylist.Drain(Now + DurationMs + 1);
|
||||||
|
Assert.Equal(0, AutoDenylist.Count);
|
||||||
|
Assert.Equal(0, AutoDenylist.RingCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Releasing leaves no ring record behind, so a re-detection is not retired by the old one.
|
||||||
|
[Fact]
|
||||||
|
public void Release_then_re_hold_is_not_retired_by_the_stale_record()
|
||||||
|
{
|
||||||
|
Reset();
|
||||||
|
var ip = IPAddress.Parse("198.51.100.15");
|
||||||
|
|
||||||
|
AutoDenylist.Hold(ip, BanReasons.RateLimit, Now);
|
||||||
|
AutoDenylist.Release(ip);
|
||||||
|
|
||||||
|
var later = Now + DurationMs - 1;
|
||||||
|
AutoDenylist.Hold(ip, BanReasons.RateLimit, later);
|
||||||
|
|
||||||
|
// The first hold's expiry has passed; the second must survive it.
|
||||||
|
Assert.True(AutoDenylist.IsDenied(ip, Now + DurationMs + 1));
|
||||||
|
Assert.Equal(1, AutoDenylist.RingCount);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|
|
||||||
|
|
@ -28,15 +28,15 @@ public class BanExemptionsTests
|
||||||
private static readonly IPAddress _listed = IPAddress.Parse("192.0.2.10");
|
private static readonly IPAddress _listed = IPAddress.Parse("192.0.2.10");
|
||||||
private static readonly IPAddress _unlisted = IPAddress.Parse("192.0.2.11");
|
private static readonly IPAddress _unlisted = IPAddress.Parse("192.0.2.11");
|
||||||
|
|
||||||
private static void WithFileAllowlist(string contents) =>
|
private static void WithManualAllowlist(string contents) =>
|
||||||
FileAllowlist.LoadForTesting(BlocklistSnapshot.Build(Encoding.ASCII.GetBytes(contents), out _, out _));
|
ManualAllowlist.LoadForTesting(BlocklistSnapshot.Build(Encoding.ASCII.GetBytes(contents), out _, out _));
|
||||||
|
|
||||||
private static void WithEmptyFileAllowlist() => FileAllowlist.LoadForTesting(BlocklistSnapshot.Empty);
|
private static void WithEmptyManualAllowlist() => ManualAllowlist.LoadForTesting(BlocklistSnapshot.Empty);
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void File_allowlist_exempts_behavioral_contributions()
|
public void Manual_allowlist_exempts_behavioral_contributions()
|
||||||
{
|
{
|
||||||
WithFileAllowlist("192.0.2.10");
|
WithManualAllowlist("192.0.2.10");
|
||||||
|
|
||||||
// Subtracting from the blocklist does nothing for behavioural detections, which never consult it.
|
// Subtracting from the blocklist does nothing for behavioural detections, which never consult it.
|
||||||
Assert.True(BanExemptions.IsExempt(_listed, BanReasons.ForeignProtocol, NeverCalled));
|
Assert.True(BanExemptions.IsExempt(_listed, BanReasons.ForeignProtocol, NeverCalled));
|
||||||
|
|
@ -46,10 +46,10 @@ public class BanExemptionsTests
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void File_allowlist_covers_cidr_entries()
|
public void Manual_allowlist_covers_cidr_entries()
|
||||||
{
|
{
|
||||||
// Carve-outs are CIDRs, so a shared-CGNAT player is only covered if ranges work here.
|
// Carve-outs are CIDRs, so a shared-CGNAT player is only covered if ranges work here.
|
||||||
WithFileAllowlist("192.0.2.0/24");
|
WithManualAllowlist("192.0.2.0/24");
|
||||||
|
|
||||||
Assert.True(BanExemptions.IsExempt(_listed, BanReasons.RateLimit, NeverCalled));
|
Assert.True(BanExemptions.IsExempt(_listed, BanReasons.RateLimit, NeverCalled));
|
||||||
Assert.True(BanExemptions.IsExempt(IPAddress.Parse("192.0.2.254"), BanReasons.RateLimit, NeverCalled));
|
Assert.True(BanExemptions.IsExempt(IPAddress.Parse("192.0.2.254"), BanReasons.RateLimit, NeverCalled));
|
||||||
|
|
@ -57,9 +57,9 @@ public class BanExemptionsTests
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Manual_bans_are_never_exempt_even_when_file_allowlisted()
|
public void Manual_bans_are_never_exempt_even_when_allowlisted()
|
||||||
{
|
{
|
||||||
WithFileAllowlist("192.0.2.10");
|
WithManualAllowlist("192.0.2.10");
|
||||||
|
|
||||||
// An explicit decision outranks the operator's own carve-out, and must not cost a strike.
|
// An explicit decision outranks the operator's own carve-out, and must not cost a strike.
|
||||||
Assert.False(BanExemptions.IsExempt(_listed, BanReasons.Manual, NeverCalled));
|
Assert.False(BanExemptions.IsExempt(_listed, BanReasons.Manual, NeverCalled));
|
||||||
|
|
@ -68,16 +68,16 @@ public class BanExemptionsTests
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Unopted_reasons_are_never_exempt()
|
public void Unopted_reasons_are_never_exempt()
|
||||||
{
|
{
|
||||||
WithFileAllowlist("192.0.2.10");
|
WithManualAllowlist("192.0.2.10");
|
||||||
|
|
||||||
Assert.False(BanExemptions.IsExempt(_listed, BanReasons.Blocklist, NeverCalled));
|
Assert.False(BanExemptions.IsExempt(_listed, BanReasons.Blocklist, NeverCalled));
|
||||||
Assert.False(BanExemptions.IsExempt(_listed, "some-future-reason", NeverCalled));
|
Assert.False(BanExemptions.IsExempt(_listed, "some-future-reason", NeverCalled));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void File_allowlist_does_not_spend_the_earned_lists_strikes()
|
public void Manual_allowlist_does_not_spend_the_earned_lists_strikes()
|
||||||
{
|
{
|
||||||
WithFileAllowlist("192.0.2.10");
|
WithManualAllowlist("192.0.2.10");
|
||||||
|
|
||||||
// Unconditional, so the revocable list must not be consulted -- that would burn a strike.
|
// Unconditional, so the revocable list must not be consulted -- that would burn a strike.
|
||||||
Assert.True(BanExemptions.IsExempt(_listed, BanReasons.RateLimit, NeverCalled));
|
Assert.True(BanExemptions.IsExempt(_listed, BanReasons.RateLimit, NeverCalled));
|
||||||
|
|
@ -86,7 +86,7 @@ public class BanExemptionsTests
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Falls_through_to_the_login_allowlist_when_not_file_listed()
|
public void Falls_through_to_the_login_allowlist_when_not_file_listed()
|
||||||
{
|
{
|
||||||
WithEmptyFileAllowlist();
|
WithEmptyManualAllowlist();
|
||||||
|
|
||||||
var consulted = 0;
|
var consulted = 0;
|
||||||
|
|
||||||
|
|
@ -107,7 +107,7 @@ public class BanExemptionsTests
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Null_address_is_never_exempt()
|
public void Null_address_is_never_exempt()
|
||||||
{
|
{
|
||||||
WithEmptyFileAllowlist();
|
WithEmptyManualAllowlist();
|
||||||
|
|
||||||
Assert.False(BanExemptions.IsExempt(null, BanReasons.RateLimit, NeverCalled));
|
Assert.False(BanExemptions.IsExempt(null, BanReasons.RateLimit, NeverCalled));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,7 @@ public class BlocklistConfigurationTests
|
||||||
{
|
{
|
||||||
var original = new BlocklistSettings
|
var original = new BlocklistSettings
|
||||||
{
|
{
|
||||||
|
Enabled = true,
|
||||||
File = "D:/shared/ip-blocklist.txt",
|
File = "D:/shared/ip-blocklist.txt",
|
||||||
ReloadInterval = TimeSpan.FromMinutes(5),
|
ReloadInterval = TimeSpan.FromMinutes(5),
|
||||||
ReportHits = false,
|
ReportHits = false,
|
||||||
|
|
@ -39,6 +40,7 @@ public class BlocklistConfigurationTests
|
||||||
|
|
||||||
var json = JsonConfig.Serialize(original);
|
var json = JsonConfig.Serialize(original);
|
||||||
|
|
||||||
|
Assert.Contains("\"enabled\"", json);
|
||||||
Assert.Contains("\"file\"", json);
|
Assert.Contains("\"file\"", json);
|
||||||
Assert.Contains("\"reloadInterval\"", json);
|
Assert.Contains("\"reloadInterval\"", json);
|
||||||
Assert.Contains("\"reportHits\"", json);
|
Assert.Contains("\"reportHits\"", json);
|
||||||
|
|
@ -48,6 +50,7 @@ public class BlocklistConfigurationTests
|
||||||
var restored = JsonSerializer.Deserialize<BlocklistSettings>(json, JsonConfig.DefaultOptions);
|
var restored = JsonSerializer.Deserialize<BlocklistSettings>(json, JsonConfig.DefaultOptions);
|
||||||
|
|
||||||
Assert.NotNull(restored);
|
Assert.NotNull(restored);
|
||||||
|
Assert.Equal(original.Enabled, restored.Enabled);
|
||||||
Assert.Equal(original.File, restored.File);
|
Assert.Equal(original.File, restored.File);
|
||||||
Assert.Equal(original.ReloadInterval, restored.ReloadInterval);
|
Assert.Equal(original.ReloadInterval, restored.ReloadInterval);
|
||||||
Assert.Equal(original.ReportHits, restored.ReportHits);
|
Assert.Equal(original.ReportHits, restored.ReportHits);
|
||||||
|
|
@ -55,6 +58,13 @@ public class BlocklistConfigurationTests
|
||||||
Assert.Equal(original.PromoteSuppression, restored.PromoteSuppression);
|
Assert.Equal(original.PromoteSuppression, restored.PromoteSuppression);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The point of the flag: a shard that never opts in must not start the reload poll.
|
||||||
|
[Fact]
|
||||||
|
public void Blocklist_is_off_by_default()
|
||||||
|
{
|
||||||
|
Assert.False(new BlocklistSettings().Enabled);
|
||||||
|
}
|
||||||
|
|
||||||
// The generator (tools/Export-IpBlocklist.ps1) writes to this path by default; if one side moves
|
// The generator (tools/Export-IpBlocklist.ps1) writes to this path by default; if one side moves
|
||||||
// without the other, a shard silently enforces nothing.
|
// without the other, a shard silently enforces nothing.
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,66 @@
|
||||||
|
/*************************************************************************
|
||||||
|
* ModernUO *
|
||||||
|
* Copyright 2019-2026 - ModernUO Development Team *
|
||||||
|
* Email: hi@modernuo.com *
|
||||||
|
* File: ManualAllowlistConfigurationTests.cs *
|
||||||
|
* *
|
||||||
|
* This program is free software: you can redistribute it and/or modify *
|
||||||
|
* it under the terms of the GNU General Public License as published by *
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or *
|
||||||
|
* (at your option) any later version. *
|
||||||
|
* *
|
||||||
|
* You should have received a copy of the GNU General Public License *
|
||||||
|
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
|
||||||
|
*************************************************************************/
|
||||||
|
|
||||||
|
using System;
|
||||||
|
using System.Text.Json;
|
||||||
|
using Server.Json;
|
||||||
|
using Server.Network.Bans;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Server.Tests.Network.ManualAllowlists;
|
||||||
|
|
||||||
|
public class ManualAllowlistConfigurationTests
|
||||||
|
{
|
||||||
|
// Locks the JsonConfig casing contract: JsonConfig's options are case-SENSITIVE, so every settings
|
||||||
|
// member must carry an explicit [JsonPropertyName("camelCase")] or it silently binds nothing.
|
||||||
|
[Fact]
|
||||||
|
public void ManualAllowlistSettings_RoundTripsThroughJsonConfig()
|
||||||
|
{
|
||||||
|
var original = new ManualAllowlistSettings
|
||||||
|
{
|
||||||
|
Enabled = true,
|
||||||
|
Files = ["D:/shared/ip-allowlist*.txt"],
|
||||||
|
ReloadInterval = TimeSpan.FromMinutes(5)
|
||||||
|
};
|
||||||
|
|
||||||
|
var json = JsonConfig.Serialize(original);
|
||||||
|
|
||||||
|
Assert.Contains("\"enabled\"", json);
|
||||||
|
Assert.Contains("\"files\"", json);
|
||||||
|
Assert.Contains("\"reloadInterval\"", json);
|
||||||
|
|
||||||
|
var restored = JsonSerializer.Deserialize<ManualAllowlistSettings>(json, JsonConfig.DefaultOptions);
|
||||||
|
|
||||||
|
Assert.NotNull(restored);
|
||||||
|
Assert.Equal(original.Enabled, restored.Enabled);
|
||||||
|
Assert.Equal(original.Files, restored.Files);
|
||||||
|
Assert.Equal(original.ReloadInterval, restored.ReloadInterval);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The point of the flag: a shard that never opts in must not start the reload poll.
|
||||||
|
[Fact]
|
||||||
|
public void Manual_allowlist_is_off_by_default()
|
||||||
|
{
|
||||||
|
Assert.False(new ManualAllowlistSettings().Enabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The generator creates ip-allowlist.txt beside the blocklist; the wildcard is what picks up a
|
||||||
|
// carve-out file (-RefreshCarveouts writes ip-allowlist-starlink.txt) with no config edit.
|
||||||
|
[Fact]
|
||||||
|
public void Default_pattern_matches_the_generator_output_path()
|
||||||
|
{
|
||||||
|
Assert.Equal(["Configuration/ip-allowlist*.txt"], new ManualAllowlistSettings().Files);
|
||||||
|
}
|
||||||
|
}
|
||||||
382
Projects/UOContent.Tests/Tests/Network/Packets/AuthIdTests.cs
Normal file
382
Projects/UOContent.Tests/Tests/Network/Packets/AuthIdTests.cs
Normal file
|
|
@ -0,0 +1,382 @@
|
||||||
|
using System;
|
||||||
|
using System.Net;
|
||||||
|
using Server.Accounting;
|
||||||
|
using Server.Accounting.Security;
|
||||||
|
using Server.Network;
|
||||||
|
using Server.Tests.Network;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Server.Tests.Network.Packets;
|
||||||
|
|
||||||
|
[Collection("Sequential UOContent Tests")]
|
||||||
|
public class AuthIdTests : IDisposable
|
||||||
|
{
|
||||||
|
private static readonly IPAddress AddressX = IPAddress.Parse("203.0.113.10");
|
||||||
|
private static readonly IPAddress AddressY = IPAddress.Parse("203.0.113.11");
|
||||||
|
|
||||||
|
private readonly PasswordProtectionAlgorithm _originalAlgorithm = AccountSecurity.CurrentAlgorithm;
|
||||||
|
|
||||||
|
public AuthIdTests()
|
||||||
|
{
|
||||||
|
AccountSecurity.CurrentAlgorithm = PasswordProtectionAlgorithm.Argon2;
|
||||||
|
IncomingAccountPackets.ClearAuthIdWindow();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
IncomingAccountPackets.ClearAuthIdWindow();
|
||||||
|
AccountSecurity.CurrentAlgorithm = _originalAlgorithm;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IAccount CreateAccount(string username) =>
|
||||||
|
Accounts.GetAccount(username) ?? new Account(username, "hunter2");
|
||||||
|
|
||||||
|
private static int Register(IAccount account, IPAddress address) =>
|
||||||
|
IncomingAccountPackets.RegisterAuthId(account, address, new ClientVersion(7, 0, 0, 0));
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void VouchesForTheAccountAndAddressItWasIssuedTo()
|
||||||
|
{
|
||||||
|
var account = CreateAccount("authid-match-user");
|
||||||
|
var authId = Register(account, AddressX);
|
||||||
|
|
||||||
|
var result = IncomingAccountPackets.ConsumeAuthId(authId, account.Username, AddressX, out var entry);
|
||||||
|
|
||||||
|
Assert.Equal(IncomingAccountPackets.AuthIdResult.Vouched, result);
|
||||||
|
Assert.Same(account, entry.Account);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RejectsADifferentAccount()
|
||||||
|
{
|
||||||
|
var issued = CreateAccount("authid-owner-user");
|
||||||
|
var other = CreateAccount("authid-other-user");
|
||||||
|
var authId = Register(issued, AddressX);
|
||||||
|
|
||||||
|
Assert.Equal(
|
||||||
|
IncomingAccountPackets.AuthIdResult.Rejected,
|
||||||
|
IncomingAccountPackets.ConsumeAuthId(authId, other.Username, AddressX, out _)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RejectsADifferentAddress()
|
||||||
|
{
|
||||||
|
var account = CreateAccount("authid-switch-user");
|
||||||
|
var authId = Register(account, AddressX);
|
||||||
|
|
||||||
|
Assert.Equal(
|
||||||
|
IncomingAccountPackets.AuthIdResult.Rejected,
|
||||||
|
IncomingAccountPackets.ConsumeAuthId(authId, account.Username, AddressY, out _)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void MatchesTheUsernameCaseInsensitively()
|
||||||
|
{
|
||||||
|
var account = CreateAccount("AuthId-Case-User");
|
||||||
|
var authId = Register(account, AddressX);
|
||||||
|
|
||||||
|
Assert.Equal(
|
||||||
|
IncomingAccountPackets.AuthIdResult.Vouched,
|
||||||
|
IncomingAccountPackets.ConsumeAuthId(authId, "authid-case-user", AddressX, out _)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void MatchesAnIPv4MappedIPv6Address()
|
||||||
|
{
|
||||||
|
var account = CreateAccount("authid-mapped-user");
|
||||||
|
var authId = Register(account, AddressX);
|
||||||
|
|
||||||
|
Assert.Equal(
|
||||||
|
IncomingAccountPackets.AuthIdResult.Vouched,
|
||||||
|
IncomingAccountPackets.ConsumeAuthId(authId, account.Username, AddressX.MapToIPv6(), out _)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RejectsAnUnknownAuthId()
|
||||||
|
{
|
||||||
|
var account = CreateAccount("authid-unknown-user");
|
||||||
|
var authId = Register(account, AddressX);
|
||||||
|
|
||||||
|
Assert.Equal(
|
||||||
|
IncomingAccountPackets.AuthIdResult.Rejected,
|
||||||
|
IncomingAccountPackets.ConsumeAuthId(authId + 1, account.Username, AddressX, out _)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void IsSingleUseAfterASuccess()
|
||||||
|
{
|
||||||
|
var account = CreateAccount("authid-once-user");
|
||||||
|
var authId = Register(account, AddressX);
|
||||||
|
|
||||||
|
Assert.Equal(
|
||||||
|
IncomingAccountPackets.AuthIdResult.Vouched,
|
||||||
|
IncomingAccountPackets.ConsumeAuthId(authId, account.Username, AddressX, out _)
|
||||||
|
);
|
||||||
|
Assert.Equal(
|
||||||
|
IncomingAccountPackets.AuthIdResult.Rejected,
|
||||||
|
IncomingAccountPackets.ConsumeAuthId(authId, account.Username, AddressX, out _)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// A rejected attempt must not consume the id, or anyone landing on a live one could burn it and
|
||||||
|
// force its owner to log in again.
|
||||||
|
[Fact]
|
||||||
|
public void SurvivesAnAttemptFromTheWrongAddress()
|
||||||
|
{
|
||||||
|
var account = CreateAccount("authid-not-burned-address-user");
|
||||||
|
var authId = Register(account, AddressX);
|
||||||
|
|
||||||
|
Assert.Equal(
|
||||||
|
IncomingAccountPackets.AuthIdResult.Rejected,
|
||||||
|
IncomingAccountPackets.ConsumeAuthId(authId, account.Username, AddressY, out _)
|
||||||
|
);
|
||||||
|
|
||||||
|
Assert.Equal(1, IncomingAccountPackets.AuthIdWindowCount);
|
||||||
|
Assert.Equal(
|
||||||
|
IncomingAccountPackets.AuthIdResult.Vouched,
|
||||||
|
IncomingAccountPackets.ConsumeAuthId(authId, account.Username, AddressX, out _)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SurvivesAnAttemptForTheWrongAccount()
|
||||||
|
{
|
||||||
|
var account = CreateAccount("authid-not-burned-account-user");
|
||||||
|
var authId = Register(account, AddressX);
|
||||||
|
|
||||||
|
Assert.Equal(
|
||||||
|
IncomingAccountPackets.AuthIdResult.Rejected,
|
||||||
|
IncomingAccountPackets.ConsumeAuthId(authId, "not-the-owner", AddressX, out _)
|
||||||
|
);
|
||||||
|
|
||||||
|
Assert.Equal(1, IncomingAccountPackets.AuthIdWindowCount);
|
||||||
|
Assert.Equal(
|
||||||
|
IncomingAccountPackets.AuthIdResult.Vouched,
|
||||||
|
IncomingAccountPackets.ConsumeAuthId(authId, account.Username, AddressX, out _)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ARejectedAttemptYieldsNoEntry()
|
||||||
|
{
|
||||||
|
var account = CreateAccount("authid-no-leak-user");
|
||||||
|
var authId = Register(account, AddressX);
|
||||||
|
|
||||||
|
IncomingAccountPackets.ConsumeAuthId(authId, "not-the-owner", AddressX, out var entry);
|
||||||
|
|
||||||
|
Assert.Null(entry.Account);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AnExpiredIdIsSpentByItsOwner()
|
||||||
|
{
|
||||||
|
var account = CreateAccount("authid-expired-spent-user");
|
||||||
|
var authId = Register(account, AddressX);
|
||||||
|
|
||||||
|
var now = Core._now;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Core._now = now + TimeSpan.FromMinutes(30.0);
|
||||||
|
|
||||||
|
Assert.Equal(
|
||||||
|
IncomingAccountPackets.AuthIdResult.Expired,
|
||||||
|
IncomingAccountPackets.ConsumeAuthId(authId, account.Username, AddressX, out _)
|
||||||
|
);
|
||||||
|
Assert.Equal(0, IncomingAccountPackets.AuthIdWindowCount);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
Core._now = now;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expiry is not a lockout. The game login always verified the password before any of this
|
||||||
|
// existed, so falling back to that verify is the behaviour we started from.
|
||||||
|
[Fact]
|
||||||
|
public void ExpiresIntoAPasswordVerifyRatherThanARejection()
|
||||||
|
{
|
||||||
|
var account = CreateAccount("authid-expired-user");
|
||||||
|
var authId = Register(account, AddressX);
|
||||||
|
|
||||||
|
var now = Core._now;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Core._now = now + TimeSpan.FromMinutes(30.0);
|
||||||
|
|
||||||
|
Assert.Equal(
|
||||||
|
IncomingAccountPackets.AuthIdResult.Expired,
|
||||||
|
IncomingAccountPackets.ConsumeAuthId(authId, account.Username, AddressX, out var entry)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Still carries the client version the game login needs.
|
||||||
|
Assert.Equal(new ClientVersion(7, 0, 0, 0), entry.Version);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
Core._now = now;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AnExpiredIdFromAnotherAddressIsStillRejected()
|
||||||
|
{
|
||||||
|
var account = CreateAccount("authid-expired-elsewhere-user");
|
||||||
|
var authId = Register(account, AddressX);
|
||||||
|
|
||||||
|
var now = Core._now;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Core._now = now + TimeSpan.FromMinutes(30.0);
|
||||||
|
|
||||||
|
Assert.Equal(
|
||||||
|
IncomingAccountPackets.AuthIdResult.Rejected,
|
||||||
|
IncomingAccountPackets.ConsumeAuthId(authId, account.Username, AddressY, out _)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
Core._now = now;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int Ensure(int existingAuthId, IAccount account, IPAddress address) =>
|
||||||
|
IncomingAccountPackets.EnsureAuthId(
|
||||||
|
existingAuthId,
|
||||||
|
account,
|
||||||
|
address,
|
||||||
|
new ClientVersion(7, 0, 0, 0)
|
||||||
|
);
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void IssuesAnIdWhenTheConnectionHasNone()
|
||||||
|
{
|
||||||
|
var account = CreateAccount("authid-first-select-user");
|
||||||
|
|
||||||
|
var authId = Ensure(0, account, AddressX);
|
||||||
|
|
||||||
|
Assert.NotEqual(0, authId);
|
||||||
|
Assert.Equal(1, IncomingAccountPackets.AuthIdWindowCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handing the same id back rather than minting another is what makes an orphan impossible,
|
||||||
|
// instead of something to clean up afterwards.
|
||||||
|
[Fact]
|
||||||
|
public void ReSelectingReturnsTheSameIdAndAddsNothingToTheWindow()
|
||||||
|
{
|
||||||
|
var account = CreateAccount("authid-reselect-user");
|
||||||
|
var first = Ensure(0, account, AddressX);
|
||||||
|
|
||||||
|
for (var i = 0; i < 10; i++)
|
||||||
|
{
|
||||||
|
Assert.Equal(first, Ensure(first, account, AddressX));
|
||||||
|
}
|
||||||
|
|
||||||
|
Assert.Equal(1, IncomingAccountPackets.AuthIdWindowCount);
|
||||||
|
Assert.Equal(
|
||||||
|
IncomingAccountPackets.AuthIdResult.Vouched,
|
||||||
|
IncomingAccountPackets.ConsumeAuthId(first, account.Username, AddressX, out _)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AbandonedIdsAreSweptWhenNewOnesAreIssued()
|
||||||
|
{
|
||||||
|
var abandoned = CreateAccount("authid-abandoned-user");
|
||||||
|
var live = CreateAccount("authid-live-user");
|
||||||
|
|
||||||
|
var now = Core._now;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
for (var i = 0; i < 128; i++)
|
||||||
|
{
|
||||||
|
Register(abandoned, AddressX);
|
||||||
|
}
|
||||||
|
|
||||||
|
Assert.Equal(128, IncomingAccountPackets.AuthIdWindowCount);
|
||||||
|
|
||||||
|
Core._now = now + TimeSpan.FromMinutes(30.0);
|
||||||
|
|
||||||
|
var liveId = Register(live, AddressX);
|
||||||
|
|
||||||
|
Assert.Equal(1, IncomingAccountPackets.AuthIdWindowCount);
|
||||||
|
Assert.Equal(
|
||||||
|
IncomingAccountPackets.AuthIdResult.Vouched,
|
||||||
|
IncomingAccountPackets.ConsumeAuthId(liveId, live.Username, AddressX, out _)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
Core._now = now;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A login rush is not a backlog. Every id belongs to a client on its way to redeem it, so none
|
||||||
|
// may be discarded to hold the window at some arbitrary size.
|
||||||
|
[Fact]
|
||||||
|
public void ALoginRushDoesNotEvictAnyonesAuthId()
|
||||||
|
{
|
||||||
|
var account = CreateAccount("authid-rush-user");
|
||||||
|
var ids = new int[800];
|
||||||
|
|
||||||
|
for (var i = 0; i < ids.Length; i++)
|
||||||
|
{
|
||||||
|
ids[i] = Register(account, AddressX);
|
||||||
|
}
|
||||||
|
|
||||||
|
Assert.Equal(ids.Length, IncomingAccountPackets.AuthIdWindowCount);
|
||||||
|
|
||||||
|
// Every id issued during the rush is still redeemable, including the first one.
|
||||||
|
for (var i = 0; i < ids.Length; i++)
|
||||||
|
{
|
||||||
|
Assert.Equal(
|
||||||
|
IncomingAccountPackets.AuthIdResult.Vouched,
|
||||||
|
IncomingAccountPackets.ConsumeAuthId(ids[i], account.Username, AddressX, out _)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void PreAuthenticatedGameLogin_SkipsThePasswordCheck()
|
||||||
|
{
|
||||||
|
var account = CreateAccount("authid-preauth-user");
|
||||||
|
using var ns = PacketTestUtilities.CreateTestNetState();
|
||||||
|
|
||||||
|
// A wrong password is accepted only because the auth id already vouched for the account.
|
||||||
|
var e = new GameServer.GameLoginEventArgs(ns, account.Username, "wrong-password", true);
|
||||||
|
GameServer.GameServerLoginEvent(e);
|
||||||
|
|
||||||
|
Assert.True(e.Accepted);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GameLoginWithoutPreAuthentication_StillChecksThePassword()
|
||||||
|
{
|
||||||
|
var account = CreateAccount("authid-nopreauth-user");
|
||||||
|
using var ns = PacketTestUtilities.CreateTestNetState();
|
||||||
|
|
||||||
|
var wrong = new GameServer.GameLoginEventArgs(ns, account.Username, "wrong-password", false);
|
||||||
|
GameServer.GameServerLoginEvent(wrong);
|
||||||
|
Assert.False(wrong.Accepted);
|
||||||
|
|
||||||
|
var right = new GameServer.GameLoginEventArgs(ns, account.Username, "hunter2", false);
|
||||||
|
GameServer.GameServerLoginEvent(right);
|
||||||
|
Assert.True(right.Accepted);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GeneratesDistinctAuthIds()
|
||||||
|
{
|
||||||
|
var account = CreateAccount("authid-distinct-user");
|
||||||
|
|
||||||
|
Assert.NotEqual(Register(account, AddressX), Register(account, AddressX));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -4,9 +4,9 @@
|
||||||
<Configurations>Debug;Release;Analyze</Configurations>
|
<Configurations>Debug;Release;Analyze</Configurations>
|
||||||
</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.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>
|
||||||
|
|
@ -15,7 +15,6 @@
|
||||||
<ProjectReference Include="..\UOContent\UOContent.csproj" />
|
<ProjectReference Include="..\UOContent\UOContent.csproj" />
|
||||||
<ProjectReference Include="..\Server.Tests\Server.Tests.csproj" />
|
<ProjectReference Include="..\Server.Tests\Server.Tests.csproj" />
|
||||||
<DataFiles Include="$(SolutionDir)\Distribution\Data\**" />
|
<DataFiles Include="$(SolutionDir)\Distribution\Data\**" />
|
||||||
<PackageReference Update="Serilog" Version="4.4.0" />
|
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
<Target Name="CopyData" AfterTargets="AfterBuild">
|
<Target Name="CopyData" AfterTargets="AfterBuild">
|
||||||
<Copy SourceFiles="@(DataFiles)" DestinationFolder="$(OutDir)\Data\%(RecursiveDir)" />
|
<Copy SourceFiles="@(DataFiles)" DestinationFolder="$(OutDir)\Data\%(RecursiveDir)" />
|
||||||
|
|
|
||||||
|
|
@ -378,28 +378,54 @@ public partial class Account : IAccount, IComparable<Account>
|
||||||
|
|
||||||
public void SetPassword(string plainPassword)
|
public void SetPassword(string plainPassword)
|
||||||
{
|
{
|
||||||
var phrase = _passwordAlgorithm is PasswordProtectionAlgorithm.SHA1 or PasswordProtectionAlgorithm.SHA2
|
|
||||||
? $"{_username}{plainPassword}"
|
|
||||||
: plainPassword;
|
|
||||||
|
|
||||||
Password = AccountSecurity.CurrentPasswordProtection.EncryptPassword(phrase);
|
|
||||||
PasswordAlgorithm = AccountSecurity.CurrentAlgorithm;
|
PasswordAlgorithm = AccountSecurity.CurrentAlgorithm;
|
||||||
|
Password = AccountSecurity.CurrentPasswordProtection.EncryptPassword(
|
||||||
|
AccountSecurity.DerivePhrase(PasswordAlgorithm, _username, plainPassword)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The phrase that verifies against the currently stored hash.</summary>
|
||||||
|
internal string GetVerifyPhrase(string plainPassword) =>
|
||||||
|
AccountSecurity.DerivePhrase(_passwordAlgorithm, _username, plainPassword);
|
||||||
|
|
||||||
|
/// <summary>The phrase a rehash to the configured algorithm would be derived from.</summary>
|
||||||
|
internal string GetRehashPhrase(string plainPassword) =>
|
||||||
|
AccountSecurity.DerivePhrase(AccountSecurity.CurrentAlgorithm, _username, plainPassword);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Whether a successful login should rewrite the stored hash, because the algorithm changed or
|
||||||
|
/// its cost parameters moved.
|
||||||
|
/// </summary>
|
||||||
|
internal bool NeedsPasswordUpgrade() =>
|
||||||
|
_passwordAlgorithm != AccountSecurity.CurrentAlgorithm ||
|
||||||
|
AccountSecurity.CurrentPasswordProtection.NeedsRehash(Password);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Applies a hash derived off the game loop. Distinct from the private <c>UpgradePassword</c>
|
||||||
|
/// below, which adopts a legacy hash when loading pre-binary XML accounts.
|
||||||
|
///
|
||||||
|
/// Unguarded: dispatch is on the loop, one worker drains FIFO, and results return through the
|
||||||
|
/// loop context in that order, so last dispatched is last applied. A second worker would need
|
||||||
|
/// ordering reintroduced here.
|
||||||
|
/// </summary>
|
||||||
|
internal void ApplyPasswordWrite(string newEncrypted, PasswordProtectionAlgorithm algorithm)
|
||||||
|
{
|
||||||
|
PasswordAlgorithm = algorithm;
|
||||||
|
Password = newEncrypted;
|
||||||
}
|
}
|
||||||
|
|
||||||
public bool CheckPassword(string plainPassword)
|
public bool CheckPassword(string plainPassword)
|
||||||
{
|
{
|
||||||
var phrase = _passwordAlgorithm is PasswordProtectionAlgorithm.SHA1 or PasswordProtectionAlgorithm.SHA2
|
var ok = AccountSecurity.GetPasswordProtection(_passwordAlgorithm)
|
||||||
? $"{_username}{plainPassword}"
|
.ValidatePassword(Password, GetVerifyPhrase(plainPassword));
|
||||||
: plainPassword;
|
|
||||||
|
|
||||||
var ok = AccountSecurity.GetPasswordProtection(_passwordAlgorithm).ValidatePassword(Password, phrase);
|
|
||||||
if (!ok)
|
if (!ok)
|
||||||
{
|
{
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Upgrade the password protection in case we change the algorithm
|
// Upgrade the password protection in case we change the algorithm
|
||||||
if (_passwordAlgorithm != AccountSecurity.CurrentAlgorithm)
|
if (NeedsPasswordUpgrade())
|
||||||
{
|
{
|
||||||
SetPassword(plainPassword);
|
SetPassword(plainPassword);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ using System.Net;
|
||||||
using System.Runtime.CompilerServices;
|
using System.Runtime.CompilerServices;
|
||||||
using ModernUO.CodeGeneratedEvents;
|
using ModernUO.CodeGeneratedEvents;
|
||||||
using Server.Accounting;
|
using Server.Accounting;
|
||||||
|
using Server.Accounting.Security;
|
||||||
using Server.Engines.CharacterCreation;
|
using Server.Engines.CharacterCreation;
|
||||||
using Server.Engines.Help;
|
using Server.Engines.Help;
|
||||||
using Server.Logging;
|
using Server.Logging;
|
||||||
|
|
@ -69,6 +70,9 @@ public static class AccountHandler
|
||||||
public static void Initialize()
|
public static void Initialize()
|
||||||
{
|
{
|
||||||
EventSink.AccountLogin += EventSink_AccountLogin;
|
EventSink.AccountLogin += EventSink_AccountLogin;
|
||||||
|
|
||||||
|
EventSink.Shutdown += PasswordWorker.Stop;
|
||||||
|
EventSink.ServerCrashed += PasswordWorker.OnCrashed;
|
||||||
}
|
}
|
||||||
|
|
||||||
[Usage("Password <newPassword> <repeatPassword>")]
|
[Usage("Password <newPassword> <repeatPassword>")]
|
||||||
|
|
@ -139,8 +143,12 @@ public static class AccountHandler
|
||||||
|
|
||||||
if (accessList[0].MatchClassC(ipAddress))
|
if (accessList[0].MatchClassC(ipAddress))
|
||||||
{
|
{
|
||||||
acct.SetPassword(pass);
|
// Confirmed from the callback: off-loop the write has not landed yet here.
|
||||||
from.SendMessage("The password to your account has changed.");
|
PasswordWorker.SetPassword(
|
||||||
|
acct,
|
||||||
|
pass,
|
||||||
|
_ => from.SendMessage("The password to your account has changed.")
|
||||||
|
);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
|
|
@ -307,25 +315,129 @@ public static class AccountHandler
|
||||||
logger.Information("Login: {NetState} Access denied for '{Username}'", e.State, un);
|
logger.Information("Login: {NetState} Access denied for '{Username}'", e.State, un);
|
||||||
e.RejectReason = LockdownLevel > AccessLevel.Player ? ALRReason.BadComm : ALRReason.BadPass;
|
e.RejectReason = LockdownLevel > AccessLevel.Player ? ALRReason.BadComm : ALRReason.BadPass;
|
||||||
}
|
}
|
||||||
else if (!acct.CheckPassword(pw))
|
else
|
||||||
{
|
{
|
||||||
logger.Information("Login: {NetState} Invalid password for '{Username}'", e.State, un);
|
HandlePasswordCheck(e, acct, pw);
|
||||||
e.RejectReason = ALRReason.BadPass;
|
|
||||||
}
|
}
|
||||||
else if (acct.Banned)
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Separate from the caller's else-if chain because two outcomes are not verdicts: the off-loop
|
||||||
|
/// path has none yet, and a full queue must reject rather than fall through and verify.
|
||||||
|
/// </summary>
|
||||||
|
private static void HandlePasswordCheck(AccountLoginEventArgs e, Account acct, string pw)
|
||||||
|
{
|
||||||
|
switch (DispatchPasswordCheck(e, acct, pw))
|
||||||
{
|
{
|
||||||
logger.Information("Login: {NetState} Banned account '{Username}'", e.State, un);
|
case PasswordCheckDispatch.Deferred:
|
||||||
|
{
|
||||||
|
e.Deferred = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
case PasswordCheckDispatch.Saturated:
|
||||||
|
{
|
||||||
|
// Reject rather than verify inline: steering work back onto the loop is what a
|
||||||
|
// flood wants.
|
||||||
|
logger.Warning(
|
||||||
|
"Login: {NetState} Password verification queue full, rejecting '{Username}'",
|
||||||
|
e.State,
|
||||||
|
acct.Username
|
||||||
|
);
|
||||||
|
|
||||||
|
e.RejectReason = ALRReason.BadComm;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!acct.CheckPassword(pw))
|
||||||
|
{
|
||||||
|
logger.Information("Login: {NetState} Invalid password for '{Username}'", e.State, acct.Username);
|
||||||
|
e.RejectReason = ALRReason.BadPass;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ApplyVerifiedLogin(e, acct);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Everything after the password is known good, shared so an off-loop verdict lands
|
||||||
|
/// in the same state as an inline one.</summary>
|
||||||
|
private static void ApplyVerifiedLogin(AccountLoginEventArgs e, Account acct)
|
||||||
|
{
|
||||||
|
if (acct.Banned)
|
||||||
|
{
|
||||||
|
logger.Information("Login: {NetState} Banned account '{Username}'", e.State, acct.Username);
|
||||||
e.RejectReason = ALRReason.Blocked;
|
e.RejectReason = ALRReason.Blocked;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.Information("Login: {NetState} Valid credentials for '{Username}'", e.State, acct.Username);
|
||||||
|
e.State.Account = acct;
|
||||||
|
e.Accepted = true;
|
||||||
|
|
||||||
|
acct.LogAccess(e.State);
|
||||||
|
LoginAllowlist.RecordLogin(e.State?.Address);
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum PasswordCheckDispatch
|
||||||
|
{
|
||||||
|
/// <summary>Verify on the loop.</summary>
|
||||||
|
Inline,
|
||||||
|
|
||||||
|
/// <summary>Handed to the worker; no verdict yet.</summary>
|
||||||
|
Deferred,
|
||||||
|
|
||||||
|
/// <summary>The queue is full.</summary>
|
||||||
|
Saturated
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Hands the password check to the worker, whatever algorithm it uses. Every protection is safe
|
||||||
|
/// off the loop, so there is no carve-out, and a cheap digest does not need one either:
|
||||||
|
/// <c>AccountSecurity.Configure</c> refuses anything below SHA2 as the configured algorithm, so
|
||||||
|
/// MD5 and SHA1 only appear as a stored hash awaiting migration. That makes
|
||||||
|
/// <c>NeedsPasswordUpgrade</c> true, and the upgrade hash dominates the job.
|
||||||
|
/// </summary>
|
||||||
|
private static PasswordCheckDispatch DispatchPasswordCheck(AccountLoginEventArgs e, Account acct, string pw)
|
||||||
|
{
|
||||||
|
if (!PasswordWorker.Enabled)
|
||||||
|
{
|
||||||
|
return PasswordCheckDispatch.Inline;
|
||||||
|
}
|
||||||
|
|
||||||
|
var job = new PasswordJob
|
||||||
|
{
|
||||||
|
Account = acct,
|
||||||
|
State = e.State,
|
||||||
|
StoredHash = acct.Password,
|
||||||
|
StoredAlgorithm = acct.PasswordAlgorithm,
|
||||||
|
VerifyPhrase = acct.GetVerifyPhrase(pw),
|
||||||
|
HashPhrase = acct.NeedsPasswordUpgrade() ? acct.GetRehashPhrase(pw) : null,
|
||||||
|
TargetAlgorithm = AccountSecurity.CurrentAlgorithm,
|
||||||
|
OnComplete = static (j, outcome) =>
|
||||||
|
CompleteDeferredAccountLogin(j.State, j.Account, outcome.Verified)
|
||||||
|
};
|
||||||
|
|
||||||
|
return PasswordWorker.TryEnqueue(job)
|
||||||
|
? PasswordCheckDispatch.Deferred
|
||||||
|
: PasswordCheckDispatch.Saturated;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Resumes a login whose password check ran on the verification thread.</summary>
|
||||||
|
internal static void CompleteDeferredAccountLogin(NetState state, Account acct, bool verified)
|
||||||
|
{
|
||||||
|
var e = new AccountLoginEventArgs(state, acct.Username, null);
|
||||||
|
|
||||||
|
if (verified)
|
||||||
|
{
|
||||||
|
ApplyVerifiedLogin(e, acct);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
logger.Information("Login: {NetState} Valid credentials for '{Username}'", e.State, un);
|
logger.Information("Login: {NetState} Invalid password for '{Username}'", state, acct.Username);
|
||||||
e.State.Account = acct;
|
e.RejectReason = ALRReason.BadPass;
|
||||||
e.Accepted = true;
|
|
||||||
|
|
||||||
acct.LogAccess(e.State);
|
|
||||||
LoginAllowlist.RecordLogin(e.State?.Address);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
IncomingAccountPackets.CompleteAccountLogin(state, e.Accepted, e.RejectReason);
|
||||||
}
|
}
|
||||||
|
|
||||||
[OnEvent(nameof(GameServer.GameServerLoginEvent))]
|
[OnEvent(nameof(GameServer.GameServerLoginEvent))]
|
||||||
|
|
@ -343,7 +455,9 @@ public static class AccountHandler
|
||||||
logger.Information("Login: {NetState} Access denied for '{Username}'", e.State, un);
|
logger.Information("Login: {NetState} Access denied for '{Username}'", e.State, un);
|
||||||
e.Accepted = false;
|
e.Accepted = false;
|
||||||
}
|
}
|
||||||
else if (!acct.CheckPassword(pw))
|
// The auth id was only issued after the account login packet verified this password, so
|
||||||
|
// re-deriving the hash costs a second Argon2 verify to answer the same question.
|
||||||
|
else if (!e.PreAuthenticated && !acct.CheckPassword(pw))
|
||||||
{
|
{
|
||||||
logger.Information("Login: {NetState} Invalid password for '{Username}'", e.State, un);
|
logger.Information("Login: {NetState} Invalid password for '{Username}'", e.State, un);
|
||||||
e.Accepted = false;
|
e.Accepted = false;
|
||||||
|
|
|
||||||
|
|
@ -4,5 +4,12 @@ namespace Server.Accounting
|
||||||
{
|
{
|
||||||
string EncryptPassword(string plainPassword);
|
string EncryptPassword(string plainPassword);
|
||||||
bool ValidatePassword(string encryptedPassword, string plainPassword);
|
bool ValidatePassword(string encryptedPassword, string plainPassword);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// True when <paramref name="encryptedPassword"/> was produced with parameters that differ
|
||||||
|
/// from the ones this protection currently uses, so a successful login should rewrite it.
|
||||||
|
/// Algorithms whose cost is not embedded in the stored value never need this.
|
||||||
|
/// </summary>
|
||||||
|
bool NeedsRehash(string encryptedPassword) => false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -51,6 +51,17 @@ public static class AccountSecurity
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The string actually fed to the KDF. SHA1 and SHA2 salt by username; everything else hashes
|
||||||
|
/// the password alone. Verification must derive with the algorithm the stored hash was made
|
||||||
|
/// with, and a rehash with the one it is moving to -- deriving with the wrong one produces a
|
||||||
|
/// hash that verifies once and never again.
|
||||||
|
/// </summary>
|
||||||
|
public static string DerivePhrase(PasswordProtectionAlgorithm algorithm, string username, string plainPassword)
|
||||||
|
=> algorithm is PasswordProtectionAlgorithm.SHA1 or PasswordProtectionAlgorithm.SHA2
|
||||||
|
? $"{username}{plainPassword}"
|
||||||
|
: plainPassword;
|
||||||
|
|
||||||
public static IPasswordProtection GetPasswordProtection(PasswordProtectionAlgorithm algorithm)
|
public static IPasswordProtection GetPasswordProtection(PasswordProtectionAlgorithm algorithm)
|
||||||
{
|
{
|
||||||
var passwordProtection = algorithm switch
|
var passwordProtection = algorithm switch
|
||||||
|
|
|
||||||
|
|
@ -21,11 +21,37 @@ public class Argon2PasswordProtection : IPasswordProtection
|
||||||
{
|
{
|
||||||
public static IPasswordProtection Instance = new Argon2PasswordProtection();
|
public static IPasswordProtection Instance = new Argon2PasswordProtection();
|
||||||
|
|
||||||
private readonly Argon2PasswordHasher m_PasswordHasher = new(rng: RandomNumberGenerator.Create());
|
// 16 MiB at t=1 is cheaper than 8 MiB at t=3 (8.5 ms vs 10.1 ms) and twice as memory-hard, which
|
||||||
|
// is what resists GPU and ASIC cracking. p=1: native argon2 spawns a thread per lane.
|
||||||
|
private readonly Argon2PasswordHasher _passwordHasher = new(
|
||||||
|
time: 1,
|
||||||
|
memory: 16384,
|
||||||
|
parallel: 1,
|
||||||
|
type: Argon2Type.Argon2id,
|
||||||
|
rng: RandomNumberGenerator.Create()
|
||||||
|
);
|
||||||
|
|
||||||
public string EncryptPassword(string plainPassword) =>
|
public string EncryptPassword(string plainPassword) =>
|
||||||
m_PasswordHasher.Hash(plainPassword);
|
_passwordHasher.Hash(plainPassword);
|
||||||
|
|
||||||
public bool ValidatePassword(string encryptedPassword, string plainPassword) =>
|
public bool ValidatePassword(string encryptedPassword, string plainPassword) =>
|
||||||
m_PasswordHasher.Verify(encryptedPassword, plainPassword);
|
_passwordHasher.Verify(encryptedPassword, plainPassword);
|
||||||
|
|
||||||
|
// Verification uses the parameters embedded in the PHC string, not the configured ones, so
|
||||||
|
// comparing them is what lets a parameter change reach existing accounts.
|
||||||
|
public bool NeedsRehash(string encryptedPassword)
|
||||||
|
{
|
||||||
|
// Unparseable but verified: a format this build does not understand, so rewrite it.
|
||||||
|
if (!Argon2PasswordHasher.TryExtractMetadataValues(encryptedPassword, out var values))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return values.ArgonType != _passwordHasher.ArgonType
|
||||||
|
|| values.MemoryCost != _passwordHasher.MemoryCost
|
||||||
|
|| values.TimeCost != _passwordHasher.TimeCost
|
||||||
|
|| values.Parallelism != _passwordHasher.Parallelism
|
||||||
|
|| values.HashLength != (int)_passwordHasher.HashLength
|
||||||
|
|| values.SaltLength != (int)_passwordHasher.SaltLength;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -19,19 +19,47 @@ using Server.Text;
|
||||||
|
|
||||||
namespace Server.Accounting.Security;
|
namespace Server.Accounting.Security;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The obsolete unsalted digests, kept only so imported accounts can log in once and be upgraded.
|
||||||
|
///
|
||||||
|
/// Hashing goes through the one-shot static APIs rather than a retained <see cref="HashAlgorithm"/>.
|
||||||
|
/// A <see cref="HashAlgorithm"/> instance carries the running digest across HashCore/HashFinal, so
|
||||||
|
/// two threads sharing one corrupt each other's result -- and these are process-wide singletons.
|
||||||
|
/// The static form has no such state, allocates nothing, and produces identical bytes.
|
||||||
|
/// </summary>
|
||||||
public class HashAlgorithmPasswordProtection : IPasswordProtection
|
public class HashAlgorithmPasswordProtection : IPasswordProtection
|
||||||
{
|
{
|
||||||
public static IPasswordProtection MD5Instance = new HashAlgorithmPasswordProtection(MD5.Create());
|
private enum Kind
|
||||||
public static IPasswordProtection SHA1Instance = new HashAlgorithmPasswordProtection(SHA1.Create());
|
{
|
||||||
public static IPasswordProtection SHA2Instance = new HashAlgorithmPasswordProtection(SHA512.Create());
|
MD5,
|
||||||
private readonly HashAlgorithm _hashAlgorithm;
|
SHA1,
|
||||||
|
SHA512
|
||||||
|
}
|
||||||
|
|
||||||
public HashAlgorithmPasswordProtection(HashAlgorithm hashAlgorithm) => _hashAlgorithm = hashAlgorithm;
|
public static readonly IPasswordProtection MD5Instance = new HashAlgorithmPasswordProtection(Kind.MD5);
|
||||||
|
public static readonly IPasswordProtection SHA1Instance = new HashAlgorithmPasswordProtection(Kind.SHA1);
|
||||||
|
public static readonly IPasswordProtection SHA2Instance = new HashAlgorithmPasswordProtection(Kind.SHA512);
|
||||||
|
|
||||||
|
private const int MaxDigestLength = 64; // SHA512, the largest of the three.
|
||||||
|
|
||||||
|
private readonly Kind _kind;
|
||||||
|
|
||||||
|
private HashAlgorithmPasswordProtection(Kind kind) => _kind = kind;
|
||||||
|
|
||||||
public string EncryptPassword(string plainPassword)
|
public string EncryptPassword(string plainPassword)
|
||||||
{
|
{
|
||||||
var bytes = plainPassword.AsSpan(0, Math.Min(256, plainPassword.Length)).GetBytesAscii();
|
var bytes = plainPassword.AsSpan(0, Math.Min(256, plainPassword.Length)).GetBytesAscii();
|
||||||
return _hashAlgorithm.ComputeHash(bytes).ToHexString();
|
|
||||||
|
Span<byte> digest = stackalloc byte[MaxDigestLength];
|
||||||
|
|
||||||
|
var written = _kind switch
|
||||||
|
{
|
||||||
|
Kind.MD5 => MD5.HashData(bytes, digest),
|
||||||
|
Kind.SHA1 => SHA1.HashData(bytes, digest),
|
||||||
|
_ => SHA512.HashData(bytes, digest)
|
||||||
|
};
|
||||||
|
|
||||||
|
return digest[..written].ToHexString();
|
||||||
}
|
}
|
||||||
|
|
||||||
public bool ValidatePassword(string encryptedPassword, string plainPassword) =>
|
public bool ValidatePassword(string encryptedPassword, string plainPassword) =>
|
||||||
|
|
|
||||||
|
|
@ -22,23 +22,24 @@ namespace Server.Accounting.Security;
|
||||||
|
|
||||||
public class PBKDF2PasswordProtection : IPasswordProtection
|
public class PBKDF2PasswordProtection : IPasswordProtection
|
||||||
{
|
{
|
||||||
private const ushort m_MinIterations = 1024;
|
private const ushort MinIterations = 1024;
|
||||||
private const ushort m_MaxIterations = 1536;
|
private const ushort MaxIterations = 1536;
|
||||||
private const int m_SaltSize = 8;
|
private const int SaltSize = 8;
|
||||||
private const int m_HashSize = 32;
|
private const int HashSize = 32;
|
||||||
private const int m_OutputSize = 2 + m_SaltSize + m_HashSize;
|
private const int OutputSize = 2 + SaltSize + HashSize;
|
||||||
public static readonly IPasswordProtection Instance = new PBKDF2PasswordProtection();
|
public static readonly IPasswordProtection Instance = new PBKDF2PasswordProtection();
|
||||||
|
|
||||||
public string EncryptPassword(string plainPassword)
|
public string EncryptPassword(string plainPassword)
|
||||||
{
|
{
|
||||||
Span<byte> output = stackalloc byte[m_OutputSize];
|
Span<byte> output = stackalloc byte[OutputSize];
|
||||||
var iterations = Utility.RandomMinMax(m_MinIterations, m_MaxIterations);
|
|
||||||
|
var iterations = RandomNumberGenerator.GetInt32(MinIterations, MaxIterations + 1);
|
||||||
BinaryPrimitives.WriteUInt16LittleEndian(output[..2], (ushort)iterations);
|
BinaryPrimitives.WriteUInt16LittleEndian(output[..2], (ushort)iterations);
|
||||||
|
|
||||||
var salt = output.Slice(2, m_SaltSize);
|
var salt = output.Slice(2, SaltSize);
|
||||||
RandomNumberGenerator.Fill(salt);
|
RandomNumberGenerator.Fill(salt);
|
||||||
|
|
||||||
var hash = output.Slice(2 + m_SaltSize, m_HashSize);
|
var hash = output.Slice(2 + SaltSize, HashSize);
|
||||||
Rfc2898DeriveBytes.Pbkdf2(plainPassword, salt, hash, iterations, HashAlgorithmName.SHA256);
|
Rfc2898DeriveBytes.Pbkdf2(plainPassword, salt, hash, iterations, HashAlgorithmName.SHA256);
|
||||||
|
|
||||||
return output.ToHexString();
|
return output.ToHexString();
|
||||||
|
|
@ -46,15 +47,15 @@ public class PBKDF2PasswordProtection : IPasswordProtection
|
||||||
|
|
||||||
public bool ValidatePassword(string encryptedPassword, string plainPassword)
|
public bool ValidatePassword(string encryptedPassword, string plainPassword)
|
||||||
{
|
{
|
||||||
Span<byte> encryptedBytes = stackalloc byte[m_OutputSize];
|
Span<byte> encryptedBytes = stackalloc byte[OutputSize];
|
||||||
encryptedPassword.GetBytes(encryptedBytes);
|
encryptedPassword.GetBytes(encryptedBytes);
|
||||||
|
|
||||||
var iterations = BinaryPrimitives.ReadUInt16LittleEndian(encryptedBytes[..2]);
|
var iterations = BinaryPrimitives.ReadUInt16LittleEndian(encryptedBytes[..2]);
|
||||||
var salt = encryptedBytes.Slice(2, m_SaltSize);
|
var salt = encryptedBytes.Slice(2, SaltSize);
|
||||||
|
|
||||||
Span<byte> hash = stackalloc byte[m_HashSize];
|
Span<byte> hash = stackalloc byte[HashSize];
|
||||||
Rfc2898DeriveBytes.Pbkdf2(plainPassword, salt, hash, iterations, HashAlgorithmName.SHA256);
|
Rfc2898DeriveBytes.Pbkdf2(plainPassword, salt, hash, iterations, HashAlgorithmName.SHA256);
|
||||||
|
|
||||||
return hash.SequenceEqual(encryptedBytes[(m_SaltSize + 2)..]);
|
return hash.SequenceEqual(encryptedBytes[(SaltSize + 2)..]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
293
Projects/UOContent/Accounting/Security/PasswordWorker.cs
Normal file
293
Projects/UOContent/Accounting/Security/PasswordWorker.cs
Normal file
|
|
@ -0,0 +1,293 @@
|
||||||
|
/*************************************************************************
|
||||||
|
* ModernUO *
|
||||||
|
* Copyright 2019-2026 - ModernUO Development Team *
|
||||||
|
* Email: hi@modernuo.com *
|
||||||
|
* File: PasswordWorker.cs *
|
||||||
|
* *
|
||||||
|
* This program is free software: you can redistribute it and/or modify *
|
||||||
|
* it under the terms of the GNU General Public License as published by *
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or *
|
||||||
|
* (at your option) any later version. *
|
||||||
|
* *
|
||||||
|
* You should have received a copy of the GNU General Public License *
|
||||||
|
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
|
||||||
|
*************************************************************************/
|
||||||
|
|
||||||
|
using System;
|
||||||
|
using System.Collections.Concurrent;
|
||||||
|
using System.Threading;
|
||||||
|
using Server.Logging;
|
||||||
|
using Server.Network;
|
||||||
|
|
||||||
|
namespace Server.Accounting.Security;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Work handed to the password thread, which reads no game state and writes none.
|
||||||
|
///
|
||||||
|
/// Verify and hash are independently optional: a login verifies and may rehash, an explicit change
|
||||||
|
/// only hashes.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class PasswordJob
|
||||||
|
{
|
||||||
|
public Account Account;
|
||||||
|
|
||||||
|
/// <summary>Ties the job to a connection. Null when the work is not gated on one, such as a
|
||||||
|
/// password change by an admin.</summary>
|
||||||
|
public NetState State;
|
||||||
|
|
||||||
|
/// <summary>Hash to verify against, with <see cref="VerifyPhrase"/>.</summary>
|
||||||
|
public string StoredHash;
|
||||||
|
|
||||||
|
/// <summary>Algorithm <see cref="StoredHash"/> was written with. Both algorithms are resolved on
|
||||||
|
/// the loop; <c>AccountSecurity.CurrentAlgorithm</c> is mutable state the worker must not read.</summary>
|
||||||
|
public PasswordProtectionAlgorithm StoredAlgorithm;
|
||||||
|
|
||||||
|
/// <summary>Phrase to verify, or null to skip verification.</summary>
|
||||||
|
public string VerifyPhrase;
|
||||||
|
|
||||||
|
/// <summary>Phrase to hash, or null when nothing needs writing.</summary>
|
||||||
|
public string HashPhrase;
|
||||||
|
|
||||||
|
public PasswordProtectionAlgorithm TargetAlgorithm;
|
||||||
|
|
||||||
|
/// <summary>Runs on the game loop with the result. Free to touch game state.</summary>
|
||||||
|
public Action<PasswordJob, PasswordOutcome> OnComplete;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal readonly struct PasswordOutcome
|
||||||
|
{
|
||||||
|
/// <summary>True when no verification was asked for, or it succeeded.</summary>
|
||||||
|
public readonly bool Verified;
|
||||||
|
|
||||||
|
/// <summary>The derived hash, or null when nothing was hashed or verification failed.</summary>
|
||||||
|
public readonly string Hash;
|
||||||
|
|
||||||
|
public PasswordOutcome(bool verified, string hash)
|
||||||
|
{
|
||||||
|
Verified = verified;
|
||||||
|
Hash = hash;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Runs password hashing off the game loop. An Argon2 verify costs ~8.9 ms of frozen world per
|
||||||
|
/// login attempt, successful or not.
|
||||||
|
///
|
||||||
|
/// Exactly one worker, and that is load-bearing three times over. It cannot cost the loop more than
|
||||||
|
/// an inline verify under any scheduling regime, because at worst it takes an equal share of one
|
||||||
|
/// core -- which is what lets the measurement hold on hardware we cannot inspect. It caps live
|
||||||
|
/// hashing arenas at one. And writes apply in dispatch order only because a single thread drains
|
||||||
|
/// FIFO, so a second would need ordering reintroduced.
|
||||||
|
///
|
||||||
|
/// ~110 verifies/sec, which is ample: only loop time matters, not login latency.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class PasswordWorker
|
||||||
|
{
|
||||||
|
private static readonly ILogger logger = LogFactory.GetLogger(typeof(PasswordWorker));
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Backstop, not a flood defense. <c>SentFirstPacket</c> holds a connection to one pending
|
||||||
|
/// verify and the engine caps connections at 4096 (<c>NetState.Network.cs</c>), so this matches
|
||||||
|
/// that bound and can only trip if that invariant breaks. A cap low enough to blunt an attack
|
||||||
|
/// would reject real players first; flood defense belongs at the connection layer.
|
||||||
|
/// </summary>
|
||||||
|
private const int MaxPending = 4096;
|
||||||
|
|
||||||
|
// Nothing signals the worker when a save freeze ends, so it re-checks on this interval -- but
|
||||||
|
// only while a save is in progress, never in steady state.
|
||||||
|
private const int SaveGatePollMs = 50;
|
||||||
|
|
||||||
|
private static PasswordWorker _instance;
|
||||||
|
|
||||||
|
// Needs a spare core to move work to, which a 1-2 core host does not have. Off in DEBUG, where
|
||||||
|
// logins are rare and the inline path is easier to follow.
|
||||||
|
internal static readonly bool Enabled =
|
||||||
|
#if DEBUG
|
||||||
|
false;
|
||||||
|
#else
|
||||||
|
Environment.ProcessorCount >= 4;
|
||||||
|
#endif
|
||||||
|
|
||||||
|
private readonly Thread _thread;
|
||||||
|
private readonly AutoResetEvent _work = new(false);
|
||||||
|
private readonly ConcurrentQueue<PasswordJob> _queue = [];
|
||||||
|
|
||||||
|
private int _pending;
|
||||||
|
private volatile bool _exit;
|
||||||
|
|
||||||
|
private PasswordWorker()
|
||||||
|
{
|
||||||
|
_thread = new Thread(Execute)
|
||||||
|
{
|
||||||
|
IsBackground = true,
|
||||||
|
Name = "Password Worker"
|
||||||
|
};
|
||||||
|
|
||||||
|
_thread.Start();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static PasswordWorker Instance => _instance ??= new PasswordWorker();
|
||||||
|
|
||||||
|
/// <summary>Queues a job. False when full, and the caller must then reject without verifying.</summary>
|
||||||
|
internal static bool TryEnqueue(PasswordJob job) => Instance.TryEnqueueCore(job);
|
||||||
|
|
||||||
|
private bool TryEnqueueCore(PasswordJob job)
|
||||||
|
{
|
||||||
|
if (Volatile.Read(ref _pending) >= MaxPending)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
Interlocked.Increment(ref _pending);
|
||||||
|
_queue.Enqueue(job);
|
||||||
|
_work.Set();
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Checked before each job, which bounds a save overlap to whichever hash was already running:
|
||||||
|
/// the freeze holds the loop, so nothing new can be queued during it. PendingSave counts too --
|
||||||
|
/// the serialization threads are already awake and spinning on an empty queue by then.
|
||||||
|
/// </summary>
|
||||||
|
private static bool CanRunNow() => World.WorldState is WorldState.Running or WorldState.WritingSave;
|
||||||
|
|
||||||
|
private void Execute()
|
||||||
|
{
|
||||||
|
while (!_exit)
|
||||||
|
{
|
||||||
|
if (_queue.IsEmpty)
|
||||||
|
{
|
||||||
|
// A kernel block at zero CPU. Set() during a hash leaves the event signalled, so a
|
||||||
|
// wake arriving mid-job is not lost.
|
||||||
|
_work.WaitOne();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!CanRunNow())
|
||||||
|
{
|
||||||
|
_work.WaitOne(SaveGatePollMs);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!_queue.TryDequeue(out var job))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
Interlocked.Decrement(ref _pending);
|
||||||
|
|
||||||
|
// Gone while it waited: skip it rather than hash for a verdict nobody receives. Running
|
||||||
|
// only goes true -> false, so a stale read wastes a hash but never skips a live one. A
|
||||||
|
// null State is a job with no connection to lose, and still runs.
|
||||||
|
if (job.State?.Running == false)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
PasswordOutcome outcome;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
outcome = Compute(job);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
// A verdict must still come back, or the connection never gets a reply.
|
||||||
|
logger.Error(ex, "Password work failed for {Username}", job.Account?.Username);
|
||||||
|
outcome = new PasswordOutcome(false, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
Core.LoopContext.Post(() => Apply(job, outcome));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static PasswordOutcome Compute(PasswordJob job)
|
||||||
|
{
|
||||||
|
if (job.VerifyPhrase != null &&
|
||||||
|
!AccountSecurity.GetPasswordProtection(job.StoredAlgorithm)
|
||||||
|
.ValidatePassword(job.StoredHash, job.VerifyPhrase))
|
||||||
|
{
|
||||||
|
return new PasswordOutcome(false, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new PasswordOutcome(
|
||||||
|
true,
|
||||||
|
job.HashPhrase == null
|
||||||
|
? null : AccountSecurity.GetPasswordProtection(job.TargetAlgorithm).EncryptPassword(job.HashPhrase)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void Apply(PasswordJob job, PasswordOutcome outcome)
|
||||||
|
{
|
||||||
|
// Re-checked: a connection can drop while the result sits in the loop queue.
|
||||||
|
if (job.State?.Running == false)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (outcome.Verified && outcome.Hash != null)
|
||||||
|
{
|
||||||
|
job.Account.ApplyPasswordWrite(outcome.Hash, job.TargetAlgorithm);
|
||||||
|
}
|
||||||
|
|
||||||
|
job.OnComplete?.Invoke(job, outcome);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Sets a password, off the loop where available and inline otherwise, invoking
|
||||||
|
/// <paramref name="onDone"/> on the loop either way.
|
||||||
|
///
|
||||||
|
/// Confirm from <paramref name="onDone"/>, not the call site: off-loop the write has not
|
||||||
|
/// happened when this returns.
|
||||||
|
/// </summary>
|
||||||
|
internal static void SetPassword(Account account, string plainPassword, Action<bool> onDone)
|
||||||
|
{
|
||||||
|
if (!Enabled)
|
||||||
|
{
|
||||||
|
account.SetPassword(plainPassword);
|
||||||
|
onDone?.Invoke(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var job = new PasswordJob
|
||||||
|
{
|
||||||
|
Account = account,
|
||||||
|
HashPhrase = account.GetRehashPhrase(plainPassword),
|
||||||
|
TargetAlgorithm = AccountSecurity.CurrentAlgorithm,
|
||||||
|
OnComplete = (_, outcome) => onDone?.Invoke(outcome.Hash != null)
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!TryEnqueue(job))
|
||||||
|
{
|
||||||
|
// Saturated. Unlike a login, a password change must not be dropped, so it pays the
|
||||||
|
// hash on the loop instead.
|
||||||
|
account.SetPassword(plainPassword);
|
||||||
|
onDone?.Invoke(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Runs a job on the calling thread. The seam the tests drive.</summary>
|
||||||
|
internal static PasswordOutcome ComputeInline(PasswordJob job) => Compute(job);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Stops the worker on shutdown or crash. Pending jobs are dropped rather than finished:
|
||||||
|
/// nothing saves the world after this point, so a write applied here would reach no disk.
|
||||||
|
///
|
||||||
|
/// Draining the loop context is not this type's business either. That belongs in the core
|
||||||
|
/// shutdown path, before subscriber events run -- a subscriber pumping the shared context would
|
||||||
|
/// execute other subscribers' work at an arbitrary point in the event order.
|
||||||
|
/// </summary>
|
||||||
|
internal static void Stop() => _instance?.StopThread();
|
||||||
|
|
||||||
|
// HandleClosed skips InvokeShutdown when the server crashed, so the crash path needs its own
|
||||||
|
// subscription.
|
||||||
|
internal static void OnCrashed(ServerCrashedEventArgs e) => Stop();
|
||||||
|
|
||||||
|
private void StopThread()
|
||||||
|
{
|
||||||
|
_exit = true;
|
||||||
|
_work.Set();
|
||||||
|
_thread.Join(TimeSpan.FromSeconds(5));
|
||||||
|
}
|
||||||
|
}
|
||||||
117
Projects/UOContent/Commands/LoopStats.cs
Normal file
117
Projects/UOContent/Commands/LoopStats.cs
Normal file
|
|
@ -0,0 +1,117 @@
|
||||||
|
#if EVENT_LOOP_PROFILING
|
||||||
|
using System;
|
||||||
|
using System.Globalization;
|
||||||
|
using System.IO;
|
||||||
|
using Server.Logging;
|
||||||
|
|
||||||
|
namespace Server.Commands;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Reports the event-loop time decomposition recorded by <see cref="EventLoopProfiler"/>.
|
||||||
|
/// Only compiled when the server is built with -p:EventLoopProfiling=true.
|
||||||
|
/// See dev-docs/debugging-event-loop.md for how to read the output.
|
||||||
|
/// </summary>
|
||||||
|
public static class LoopStats
|
||||||
|
{
|
||||||
|
private static readonly ILogger logger = LogFactory.GetLogger(typeof(LoopStats));
|
||||||
|
|
||||||
|
public static void Configure()
|
||||||
|
{
|
||||||
|
CommandSystem.Register("LoopStats", AccessLevel.Administrator, LoopStats_OnCommand);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Usage("LoopStats")]
|
||||||
|
[Description("Summarizes the last minute of event-loop time accounting and writes the full history to a CSV.")]
|
||||||
|
private static void LoopStats_OnCommand(CommandEventArgs e)
|
||||||
|
{
|
||||||
|
var history = EventLoopProfiler.History();
|
||||||
|
if (history.Length == 0)
|
||||||
|
{
|
||||||
|
e.Mobile.SendMessage("No samples recorded yet.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var window = Math.Min(60, history.Length);
|
||||||
|
|
||||||
|
double wall = 0, sleep = 0, gc = 0, stolen = 0, stolenMax = 0;
|
||||||
|
long iterations = 0, sleeps = 0, lateWakes = 0, wheelLagMax = 0;
|
||||||
|
Span<double> phases = stackalloc double[EventLoopProfiler.PhaseCount];
|
||||||
|
Span<double> phaseMax = stackalloc double[EventLoopProfiler.PhaseCount];
|
||||||
|
|
||||||
|
for (var i = history.Length - window; i < history.Length; i++)
|
||||||
|
{
|
||||||
|
ref var s = ref history[i];
|
||||||
|
wall += s.WallMs;
|
||||||
|
sleep += s.SleepMs;
|
||||||
|
gc += s.GcPauseMs;
|
||||||
|
stolen += s.StolenMs;
|
||||||
|
iterations += s.Iterations;
|
||||||
|
sleeps += s.Sleeps;
|
||||||
|
lateWakes += s.LateWakes;
|
||||||
|
|
||||||
|
if (s.StolenMs > stolenMax)
|
||||||
|
{
|
||||||
|
stolenMax = s.StolenMs;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (s.WheelLagMaxMs > wheelLagMax)
|
||||||
|
{
|
||||||
|
wheelLagMax = s.WheelLagMaxMs;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (var p = 0; p < EventLoopProfiler.PhaseCount; p++)
|
||||||
|
{
|
||||||
|
phases[p] += s.Phases[p];
|
||||||
|
if (s.Phases[p] > phaseMax[p])
|
||||||
|
{
|
||||||
|
phaseMax[p] = s.Phases[p];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
e.Mobile.SendMessage($"Loop, last {window}s of wall time {wall:F0}ms:");
|
||||||
|
e.Mobile.SendMessage($" sleep {100 * sleep / wall:F1}%, gc {100 * gc / wall:F1}%, stolen {100 * stolen / wall:F1}% (worst {stolenMax:F0}ms/s)");
|
||||||
|
|
||||||
|
for (var p = 0; p < EventLoopProfiler.PhaseCount; p++)
|
||||||
|
{
|
||||||
|
e.Mobile.SendMessage($" {(LoopPhase)p}: {100 * phases[p] / wall:F1}% (worst {phaseMax[p]:F0}ms/s)");
|
||||||
|
}
|
||||||
|
|
||||||
|
e.Mobile.SendMessage($" {iterations} iterations, {sleeps} sleeps, {lateWakes} late wakes, worst wheel lag {wheelLagMax}ms");
|
||||||
|
|
||||||
|
var path = Path.Combine(Core.BaseDirectory, $"loopstats-{Core.Now:yyyyMMdd-HHmmss}.csv");
|
||||||
|
WriteCsv(path, history);
|
||||||
|
e.Mobile.SendMessage($"Full history ({history.Length} samples) written to {path}");
|
||||||
|
logger.Information("Loop stats dumped to {Path}", path);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void WriteCsv(string path, EventLoopProfiler.Sample[] history)
|
||||||
|
{
|
||||||
|
using var writer = new StreamWriter(path);
|
||||||
|
writer.Write("wallStart,wallMs,iterations,sleeps,sleepMs,sleepOvershootMaxMs,lateWakes,wheelLagMaxMs,wakesIssued,wakesElided,gcPauseMs,gen0,gen1,gen2,stolenMs");
|
||||||
|
for (var p = 0; p < EventLoopProfiler.PhaseCount; p++)
|
||||||
|
{
|
||||||
|
writer.Write(',');
|
||||||
|
writer.Write((LoopPhase)p);
|
||||||
|
}
|
||||||
|
|
||||||
|
writer.WriteLine();
|
||||||
|
|
||||||
|
for (var i = 0; i < history.Length; i++)
|
||||||
|
{
|
||||||
|
ref var s = ref history[i];
|
||||||
|
writer.Write(string.Create(
|
||||||
|
CultureInfo.InvariantCulture,
|
||||||
|
$"{s.WallStart},{s.WallMs},{s.Iterations},{s.Sleeps},{s.SleepMs:F2},{s.SleepOvershootMaxMs:F2},{s.LateWakes},{s.WheelLagMaxMs},{s.WakesIssued},{s.WakesElided},{s.GcPauseMs:F2},{s.Gen0},{s.Gen1},{s.Gen2},{s.StolenMs:F2}"
|
||||||
|
));
|
||||||
|
for (var p = 0; p < EventLoopProfiler.PhaseCount; p++)
|
||||||
|
{
|
||||||
|
writer.Write(',');
|
||||||
|
writer.Write(string.Create(CultureInfo.InvariantCulture, $"{s.Phases[p]:F2}"));
|
||||||
|
}
|
||||||
|
|
||||||
|
writer.WriteLine();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
@ -103,9 +103,8 @@ namespace Server.Commands
|
||||||
{
|
{
|
||||||
var list = pm.VisibilityList;
|
var list = pm.VisibilityList;
|
||||||
|
|
||||||
if (list.Contains(targ))
|
if (list.Remove(targ))
|
||||||
{
|
{
|
||||||
list.Remove(targ);
|
|
||||||
pm.SendMessage($"{targ.Name} has been removed from your visibility list.");
|
pm.SendMessage($"{targ.Name} has been removed from your visibility list.");
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
using System;
|
using System;
|
||||||
using System.Buffers;
|
using System.Buffers;
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.Globalization;
|
using System.Globalization;
|
||||||
using System.Numerics;
|
using System.Numerics;
|
||||||
using System.Runtime.CompilerServices;
|
using System.Runtime.CompilerServices;
|
||||||
|
|
|
||||||
|
|
@ -6,27 +6,27 @@ namespace Server.Engines.BulkOrders;
|
||||||
public partial class BOBFilter
|
public partial class BOBFilter
|
||||||
{
|
{
|
||||||
[SerializableField(0)]
|
[SerializableField(0)]
|
||||||
|
[SaveFlag(nameof(ShouldSerializeType))]
|
||||||
private int _type;
|
private int _type;
|
||||||
|
|
||||||
[SerializableFieldSaveFlag(0)]
|
|
||||||
private bool ShouldSerializeType() => _type != 0;
|
private bool ShouldSerializeType() => _type != 0;
|
||||||
|
|
||||||
[SerializableField(1)]
|
[SerializableField(1)]
|
||||||
|
[SaveFlag(nameof(ShouldSerializeQuality))]
|
||||||
private int _quality;
|
private int _quality;
|
||||||
|
|
||||||
[SerializableFieldSaveFlag(1)]
|
|
||||||
private bool ShouldSerializeQuality() => _quality != 0;
|
private bool ShouldSerializeQuality() => _quality != 0;
|
||||||
|
|
||||||
[SerializableField(2)]
|
[SerializableField(2)]
|
||||||
|
[SaveFlag(nameof(ShouldSerializeMaterial))]
|
||||||
private int _material;
|
private int _material;
|
||||||
|
|
||||||
[SerializableFieldSaveFlag(2)]
|
|
||||||
private bool ShouldSerializeMaterial() => _material != 0;
|
private bool ShouldSerializeMaterial() => _material != 0;
|
||||||
|
|
||||||
[SerializableField(3)]
|
[SerializableField(3)]
|
||||||
|
[SaveFlag(nameof(ShouldSerializeQuantity))]
|
||||||
private int _quantity;
|
private int _quantity;
|
||||||
|
|
||||||
[SerializableFieldSaveFlag(3)]
|
|
||||||
private bool ShouldSerializeQuantity() => _quantity != 0;
|
private bool ShouldSerializeQuantity() => _quantity != 0;
|
||||||
|
|
||||||
private void Deserialize(IGenericReader reader, int version)
|
private void Deserialize(IGenericReader reader, int version)
|
||||||
|
|
|
||||||
|
|
@ -33,17 +33,13 @@ public partial class ChampionSkullBrazier : AddonComponent
|
||||||
[SerializedCommandProperty(AccessLevel.GameMaster)]
|
[SerializedCommandProperty(AccessLevel.GameMaster)]
|
||||||
private ChampionSkullPlatform _platform;
|
private ChampionSkullPlatform _platform;
|
||||||
|
|
||||||
[SerializableProperty(2)]
|
[SerializableField(2, fieldChanged: nameof(OnSkullChanged))]
|
||||||
[CommandProperty(AccessLevel.GameMaster)]
|
[SerializedCommandProperty(AccessLevel.GameMaster)]
|
||||||
public Item Skull
|
private Item _skull;
|
||||||
|
|
||||||
|
private void OnSkullChanged(Item oldValue, Item newValue)
|
||||||
{
|
{
|
||||||
get => _skull;
|
_platform?.Validate();
|
||||||
set
|
|
||||||
{
|
|
||||||
_skull = value;
|
|
||||||
this.MarkDirty();
|
|
||||||
_platform?.Validate();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public override int LabelNumber => 1049489 + (int)_type;
|
public override int LabelNumber => 1049489 + (int)_type;
|
||||||
|
|
|
||||||
|
|
@ -27,16 +27,44 @@ using Server.Logging;
|
||||||
|
|
||||||
namespace Server.Engines.CannedEvil;
|
namespace Server.Engines.CannedEvil;
|
||||||
|
|
||||||
[SerializationGenerator(10, false)]
|
[SerializationGenerator(11, false)]
|
||||||
public partial class ChampionSpawn : Item
|
public partial class ChampionSpawn : Item
|
||||||
{
|
{
|
||||||
|
private void MigrateFrom(V10Content content)
|
||||||
|
{
|
||||||
|
_level = content.Level;
|
||||||
|
_activatedByProximity = content.ActivatedByProximity;
|
||||||
|
_nextProximityTime = content.NextProximityTime;
|
||||||
|
_maxLevel = content.MaxLevel;
|
||||||
|
_activatedByValor = content.ActivatedByValor;
|
||||||
|
_damageEntries = content.DamageEntries;
|
||||||
|
_confinedRoaming = content.ConfinedRoaming;
|
||||||
|
_idol = content.Idol;
|
||||||
|
_hasBeenAdvanced = content.HasBeenAdvanced;
|
||||||
|
_spawnArea = content.SpawnArea;
|
||||||
|
_randomizeType = content.RandomizeType;
|
||||||
|
_kills = content.Kills;
|
||||||
|
_active = content.Active;
|
||||||
|
_type = content.Type;
|
||||||
|
_creatures = content.Creatures;
|
||||||
|
_redSkulls = content.RedSkulls;
|
||||||
|
_whiteSkulls = content.WhiteSkulls;
|
||||||
|
_platform = content.Platform;
|
||||||
|
_altar = content.Altar;
|
||||||
|
_expireDelay = content.ExpireDelay;
|
||||||
|
_expireTime = content.ExpireTime;
|
||||||
|
_champion = content.Champion;
|
||||||
|
_restartDelay = content.RestartDelay;
|
||||||
|
_restartTime = content.RestartTime;
|
||||||
|
}
|
||||||
|
|
||||||
private static readonly ILogger logger = LogFactory.GetLogger(typeof(ChampionSpawn));
|
private static readonly ILogger logger = LogFactory.GetLogger(typeof(ChampionSpawn));
|
||||||
|
|
||||||
[SerializableField(1)]
|
[SerializableField(1)]
|
||||||
[SerializedCommandProperty(AccessLevel.GameMaster)]
|
[SerializedCommandProperty(AccessLevel.GameMaster)]
|
||||||
private bool _activatedByProximity;
|
private bool _activatedByProximity;
|
||||||
|
|
||||||
[DeltaDateTime]
|
[AnchoredDateTime]
|
||||||
[SerializableField(2)]
|
[SerializableField(2)]
|
||||||
[SerializedCommandProperty(AccessLevel.GameMaster)]
|
[SerializedCommandProperty(AccessLevel.GameMaster)]
|
||||||
private DateTime _nextProximityTime;
|
private DateTime _nextProximityTime;
|
||||||
|
|
@ -96,7 +124,7 @@ public partial class ChampionSpawn : Item
|
||||||
[SerializedCommandProperty(AccessLevel.GameMaster)]
|
[SerializedCommandProperty(AccessLevel.GameMaster)]
|
||||||
private TimeSpan _expireDelay;
|
private TimeSpan _expireDelay;
|
||||||
|
|
||||||
[DeltaDateTime]
|
[AnchoredDateTime]
|
||||||
[SerializableField(20)]
|
[SerializableField(20)]
|
||||||
[SerializedCommandProperty(AccessLevel.GameMaster)]
|
[SerializedCommandProperty(AccessLevel.GameMaster)]
|
||||||
private DateTime _expireTime;
|
private DateTime _expireTime;
|
||||||
|
|
@ -109,7 +137,7 @@ public partial class ChampionSpawn : Item
|
||||||
[SerializedCommandProperty(AccessLevel.GameMaster)]
|
[SerializedCommandProperty(AccessLevel.GameMaster)]
|
||||||
private TimeSpan _restartDelay;
|
private TimeSpan _restartDelay;
|
||||||
|
|
||||||
[DeltaDateTime]
|
[AnchoredDateTime]
|
||||||
[SerializableField(23, setter: "private")]
|
[SerializableField(23, setter: "private")]
|
||||||
[SerializedCommandProperty(AccessLevel.GameMaster)]
|
[SerializedCommandProperty(AccessLevel.GameMaster)]
|
||||||
private DateTime _restartTime;
|
private DateTime _restartTime;
|
||||||
|
|
@ -203,47 +231,38 @@ public partial class ChampionSpawn : Item
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
[SerializableProperty(3)]
|
[SerializableField(3, allowFieldChange: nameof(AllowMaxLevelChange))]
|
||||||
[CommandProperty(AccessLevel.GameMaster, AccessLevel.Administrator)]
|
[SerializedCommandProperty(AccessLevel.GameMaster, AccessLevel.Administrator)]
|
||||||
public int MaxLevel
|
private int _maxLevel;
|
||||||
|
|
||||||
|
private bool AllowMaxLevelChange(ref int value)
|
||||||
{
|
{
|
||||||
get => _maxLevel;
|
value = Math.Clamp(value, 0, 18);
|
||||||
set => _maxLevel = Math.Clamp(value, 0, 18);
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
[SerializableProperty(9)]
|
[SerializableField(9, fieldChanged: nameof(OnSpawnAreaChanged))]
|
||||||
[CommandProperty(AccessLevel.GameMaster)]
|
[SerializedCommandProperty(AccessLevel.GameMaster)]
|
||||||
public Rectangle2D SpawnArea
|
[InvalidateProperties]
|
||||||
|
private Rectangle2D _spawnArea;
|
||||||
|
|
||||||
|
private void OnSpawnAreaChanged(Rectangle2D oldValue, Rectangle2D newValue)
|
||||||
{
|
{
|
||||||
get => _spawnArea;
|
UpdateRegion();
|
||||||
set
|
|
||||||
{
|
|
||||||
_spawnArea = value;
|
|
||||||
this.MarkDirty();
|
|
||||||
InvalidateProperties();
|
|
||||||
UpdateRegion();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[SerializableProperty(11)]
|
[SerializableField(11, fieldChanged: nameof(OnKillsChanged))]
|
||||||
[CommandProperty(AccessLevel.GameMaster)]
|
[SerializedCommandProperty(AccessLevel.GameMaster)]
|
||||||
public int Kills
|
[InvalidateProperties]
|
||||||
|
private int _kills;
|
||||||
|
|
||||||
|
private void OnKillsChanged(int oldValue, int newValue)
|
||||||
{
|
{
|
||||||
get => _kills;
|
var n = _kills / (double)MaxKills;
|
||||||
set
|
var p = (int)(n * 100);
|
||||||
|
if (p < 90)
|
||||||
{
|
{
|
||||||
_kills = value;
|
SetWhiteSkullCount(p / 20);
|
||||||
this.MarkDirty();
|
|
||||||
|
|
||||||
var n = _kills / (double)MaxKills;
|
|
||||||
var p = (int)(n * 100);
|
|
||||||
|
|
||||||
if (p < 90)
|
|
||||||
{
|
|
||||||
SetWhiteSkullCount(p / 20);
|
|
||||||
}
|
|
||||||
|
|
||||||
InvalidateProperties();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -51,9 +51,9 @@ public partial class ChampionTitleContext
|
||||||
}
|
}
|
||||||
|
|
||||||
[SerializableField(1)]
|
[SerializableField(1)]
|
||||||
|
[SaveFlag(nameof(ShouldSerializeAbyss))]
|
||||||
private ChampionTitle _abyss;
|
private ChampionTitle _abyss;
|
||||||
|
|
||||||
[SerializableFieldSaveFlag(1)]
|
|
||||||
private bool ShouldSerializeAbyss() => _abyss != null;
|
private bool ShouldSerializeAbyss() => _abyss != null;
|
||||||
|
|
||||||
[CommandProperty(AccessLevel.GameMaster)]
|
[CommandProperty(AccessLevel.GameMaster)]
|
||||||
|
|
@ -71,9 +71,9 @@ public partial class ChampionTitleContext
|
||||||
}
|
}
|
||||||
|
|
||||||
[SerializableField(2)]
|
[SerializableField(2)]
|
||||||
|
[SaveFlag(nameof(ShouldSerializeArachnid))]
|
||||||
private ChampionTitle _arachnid;
|
private ChampionTitle _arachnid;
|
||||||
|
|
||||||
[SerializableFieldSaveFlag(2)]
|
|
||||||
private bool ShouldSerializeArachnid() => _arachnid != null;
|
private bool ShouldSerializeArachnid() => _arachnid != null;
|
||||||
|
|
||||||
[CommandProperty(AccessLevel.GameMaster)]
|
[CommandProperty(AccessLevel.GameMaster)]
|
||||||
|
|
@ -91,9 +91,9 @@ public partial class ChampionTitleContext
|
||||||
}
|
}
|
||||||
|
|
||||||
[SerializableField(3)]
|
[SerializableField(3)]
|
||||||
|
[SaveFlag(nameof(ShouldSerializeColdBlood))]
|
||||||
private ChampionTitle _coldBlood;
|
private ChampionTitle _coldBlood;
|
||||||
|
|
||||||
[SerializableFieldSaveFlag(3)]
|
|
||||||
private bool ShouldSerializeColdBlood() => _coldBlood != null;
|
private bool ShouldSerializeColdBlood() => _coldBlood != null;
|
||||||
|
|
||||||
[CommandProperty(AccessLevel.GameMaster)]
|
[CommandProperty(AccessLevel.GameMaster)]
|
||||||
|
|
@ -111,9 +111,9 @@ public partial class ChampionTitleContext
|
||||||
}
|
}
|
||||||
|
|
||||||
[SerializableField(4)]
|
[SerializableField(4)]
|
||||||
|
[SaveFlag(nameof(ShouldSerializeForestLord))]
|
||||||
private ChampionTitle _forestLord;
|
private ChampionTitle _forestLord;
|
||||||
|
|
||||||
[SerializableFieldSaveFlag(4)]
|
|
||||||
private bool ShouldSerializeForestLord() => _forestLord != null;
|
private bool ShouldSerializeForestLord() => _forestLord != null;
|
||||||
|
|
||||||
[CommandProperty(AccessLevel.GameMaster)]
|
[CommandProperty(AccessLevel.GameMaster)]
|
||||||
|
|
@ -131,9 +131,9 @@ public partial class ChampionTitleContext
|
||||||
}
|
}
|
||||||
|
|
||||||
[SerializableField(5)]
|
[SerializableField(5)]
|
||||||
|
[SaveFlag(nameof(ShouldSerializeVerminHorde))]
|
||||||
private ChampionTitle _verminHorde;
|
private ChampionTitle _verminHorde;
|
||||||
|
|
||||||
[SerializableFieldSaveFlag(5)]
|
|
||||||
private bool ShouldSerializeVerminHorde() => _verminHorde != null;
|
private bool ShouldSerializeVerminHorde() => _verminHorde != null;
|
||||||
|
|
||||||
[CommandProperty(AccessLevel.GameMaster)]
|
[CommandProperty(AccessLevel.GameMaster)]
|
||||||
|
|
@ -151,9 +151,9 @@ public partial class ChampionTitleContext
|
||||||
}
|
}
|
||||||
|
|
||||||
[SerializableField(6)]
|
[SerializableField(6)]
|
||||||
|
[SaveFlag(nameof(ShouldSerializeUnholyTerror))]
|
||||||
private ChampionTitle _unholyTerror;
|
private ChampionTitle _unholyTerror;
|
||||||
|
|
||||||
[SerializableFieldSaveFlag(6)]
|
|
||||||
private bool ShouldSerializeUnholyTerror() => _unholyTerror != null;
|
private bool ShouldSerializeUnholyTerror() => _unholyTerror != null;
|
||||||
|
|
||||||
[CommandProperty(AccessLevel.GameMaster)]
|
[CommandProperty(AccessLevel.GameMaster)]
|
||||||
|
|
@ -171,9 +171,9 @@ public partial class ChampionTitleContext
|
||||||
}
|
}
|
||||||
|
|
||||||
[SerializableField(7)]
|
[SerializableField(7)]
|
||||||
|
[SaveFlag(nameof(ShouldSerializeSleepingDragon))]
|
||||||
private ChampionTitle _sleepingDragon;
|
private ChampionTitle _sleepingDragon;
|
||||||
|
|
||||||
[SerializableFieldSaveFlag(7)]
|
|
||||||
private bool ShouldSerializeSleepingDragon() => _sleepingDragon != null;
|
private bool ShouldSerializeSleepingDragon() => _sleepingDragon != null;
|
||||||
|
|
||||||
[CommandProperty(AccessLevel.GameMaster)]
|
[CommandProperty(AccessLevel.GameMaster)]
|
||||||
|
|
@ -191,9 +191,9 @@ public partial class ChampionTitleContext
|
||||||
}
|
}
|
||||||
|
|
||||||
[SerializableField(8)]
|
[SerializableField(8)]
|
||||||
|
[SaveFlag(nameof(ShouldSerializeCorrupt))]
|
||||||
private ChampionTitle _corrupt;
|
private ChampionTitle _corrupt;
|
||||||
|
|
||||||
[SerializableFieldSaveFlag(8)]
|
|
||||||
private bool ShouldSerializeCorrupt() => _corrupt != null;
|
private bool ShouldSerializeCorrupt() => _corrupt != null;
|
||||||
|
|
||||||
[CommandProperty(AccessLevel.GameMaster)]
|
[CommandProperty(AccessLevel.GameMaster)]
|
||||||
|
|
@ -211,9 +211,9 @@ public partial class ChampionTitleContext
|
||||||
}
|
}
|
||||||
|
|
||||||
[SerializableField(9)]
|
[SerializableField(9)]
|
||||||
|
[SaveFlag(nameof(ShouldSerializeGlade))]
|
||||||
private ChampionTitle _glade;
|
private ChampionTitle _glade;
|
||||||
|
|
||||||
[SerializableFieldSaveFlag(9)]
|
|
||||||
private bool ShouldSerializeGlade() => _glade != null;
|
private bool ShouldSerializeGlade() => _glade != null;
|
||||||
|
|
||||||
[CommandProperty(AccessLevel.GameMaster)]
|
[CommandProperty(AccessLevel.GameMaster)]
|
||||||
|
|
|
||||||
|
|
@ -167,20 +167,13 @@ public class ChampionTitleSystem : GenericPersistence
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
using var queue = PooledRefQueue<Mobile>.Create();
|
|
||||||
|
|
||||||
foreach (var context in _championTitleContexts.Values)
|
foreach (var context in _championTitleContexts.Values)
|
||||||
{
|
{
|
||||||
if (!context.CheckAtrophy())
|
if (!context.CheckAtrophy())
|
||||||
{
|
{
|
||||||
queue.Enqueue(context.Player);
|
_championTitleContexts.Remove(context.Player);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
while (queue.Count > 0)
|
|
||||||
{
|
|
||||||
_championTitleContexts.Remove((PlayerMobile)queue.Dequeue());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,15 +3,21 @@ using ModernUO.Serialization;
|
||||||
|
|
||||||
namespace Server.Items;
|
namespace Server.Items;
|
||||||
|
|
||||||
[SerializationGenerator(1, false)]
|
[SerializationGenerator(2, false)]
|
||||||
public partial class StarRoomGate : Moongate
|
public partial class StarRoomGate : Moongate
|
||||||
{
|
{
|
||||||
|
private void MigrateFrom(V1Content content)
|
||||||
|
{
|
||||||
|
_decays = content.Decays;
|
||||||
|
_decayTime = content.DecayTime;
|
||||||
|
}
|
||||||
|
|
||||||
private static TimeSpan GateDuration = TimeSpan.FromMinutes(2.0);
|
private static TimeSpan GateDuration = TimeSpan.FromMinutes(2.0);
|
||||||
|
|
||||||
[SerializableField(0)]
|
[SerializableField(0)]
|
||||||
private bool _decays;
|
private bool _decays;
|
||||||
|
|
||||||
[DeltaDateTime]
|
[AnchoredDateTime]
|
||||||
[SerializableField(1)]
|
[SerializableField(1)]
|
||||||
private DateTime _decayTime;
|
private DateTime _decayTime;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -146,15 +146,8 @@ namespace Server.Engines.Chat
|
||||||
m_Users.Remove(user);
|
m_Users.Remove(user);
|
||||||
user.CurrentChannel = null;
|
user.CurrentChannel = null;
|
||||||
|
|
||||||
if (m_Moderators.Contains(user))
|
m_Moderators.Remove(user);
|
||||||
{
|
m_Voices.Remove(user);
|
||||||
m_Moderators.Remove(user);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (m_Voices.Contains(user))
|
|
||||||
{
|
|
||||||
m_Voices.Remove(user);
|
|
||||||
}
|
|
||||||
|
|
||||||
SendCommand(ChatCommand.RemoveUserFromChannel, user, user.Username);
|
SendCommand(ChatCommand.RemoveUserFromChannel, user, user.Username);
|
||||||
ChatSystem.SendCommandTo(user.Mobile, ChatCommand.LeaveChannel);
|
ChatSystem.SendCommandTo(user.Mobile, ChatCommand.LeaveChannel);
|
||||||
|
|
@ -183,10 +176,7 @@ namespace Server.Engines.Chat
|
||||||
|
|
||||||
public void RemoveBan(ChatUser user)
|
public void RemoveBan(ChatUser user)
|
||||||
{
|
{
|
||||||
if (m_Banned.Contains(user))
|
m_Banned.Remove(user);
|
||||||
{
|
|
||||||
m_Banned.Remove(user);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Kick(ChatUser user, ChatUser moderator = null)
|
public void Kick(ChatUser user, ChatUser moderator = null)
|
||||||
|
|
|
||||||
|
|
@ -716,10 +716,7 @@ public partial class BRBomb : Item
|
||||||
|
|
||||||
m.Target = new BombTarget(this, m);
|
m.Target = new BombTarget(this, m);
|
||||||
|
|
||||||
if (m_Helpers.Contains(m))
|
m_Helpers.Remove(m);
|
||||||
{
|
|
||||||
m_Helpers.Remove(m);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (m_Helpers.Count > 0)
|
if (m_Helpers.Count > 0)
|
||||||
{
|
{
|
||||||
|
|
@ -833,10 +830,9 @@ public partial class BRBomb : Item
|
||||||
[SerializationGenerator(0, false)]
|
[SerializationGenerator(0, false)]
|
||||||
public partial class BRGoal : BaseAddon
|
public partial class BRGoal : BaseAddon
|
||||||
{
|
{
|
||||||
[SerializableField(0)]
|
[SerializableField(0, fieldChanged: nameof(OnNorthChanged))]
|
||||||
private bool _north;
|
private bool _north;
|
||||||
|
|
||||||
[SerializableFieldChanged(0)]
|
|
||||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||||
private void OnNorthChanged(bool oldValue, bool newValue) => Remake();
|
private void OnNorthChanged(bool oldValue, bool newValue) => Remake();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -252,10 +252,9 @@ public partial class HillOfTheKing : Item
|
||||||
public partial class KHBoard : Item
|
public partial class KHBoard : Item
|
||||||
{
|
{
|
||||||
[SerializedCommandProperty(AccessLevel.GameMaster)]
|
[SerializedCommandProperty(AccessLevel.GameMaster)]
|
||||||
[SerializableField(0)]
|
[SerializableField(0, fieldChanged: nameof(OnControllerChanged))]
|
||||||
private KHController _controller;
|
private KHController _controller;
|
||||||
|
|
||||||
[SerializableFieldChanged(0)]
|
|
||||||
private void OnControllerChanged(KHController oldValue, KHController newValue)
|
private void OnControllerChanged(KHController oldValue, KHController newValue)
|
||||||
{
|
{
|
||||||
oldValue?.RemoveBoard(this);
|
oldValue?.RemoveBoard(this);
|
||||||
|
|
|
||||||
|
|
@ -56,12 +56,11 @@ namespace Server.Engines.ConPVP
|
||||||
|
|
||||||
public void RemoveFlavor(Ruleset flavor)
|
public void RemoveFlavor(Ruleset flavor)
|
||||||
{
|
{
|
||||||
if (!Flavors.Contains(flavor))
|
if (!Flavors.Remove(flavor))
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
Flavors.Remove(flavor);
|
|
||||||
Options.And(flavor.Options.Not());
|
Options.And(flavor.Options.Not());
|
||||||
flavor.Options.Not();
|
flavor.Options.Not();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -64,17 +64,13 @@ public partial class Trophy : Item
|
||||||
UpdateStyle();
|
UpdateStyle();
|
||||||
}
|
}
|
||||||
|
|
||||||
[SerializableProperty(1)]
|
[SerializableField(1, fieldChanged: nameof(OnRankChanged))]
|
||||||
[CommandProperty(AccessLevel.GameMaster)]
|
[SerializedCommandProperty(AccessLevel.GameMaster)]
|
||||||
public TrophyRank Rank
|
private TrophyRank _rank;
|
||||||
|
|
||||||
|
private void OnRankChanged(TrophyRank oldValue, TrophyRank newValue)
|
||||||
{
|
{
|
||||||
get => _rank;
|
UpdateStyle();
|
||||||
set
|
|
||||||
{
|
|
||||||
_rank = value;
|
|
||||||
UpdateStyle();
|
|
||||||
this.MarkDirty();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void Deserialize(IGenericReader reader, int version)
|
private void Deserialize(IGenericReader reader, int version)
|
||||||
|
|
|
||||||
|
|
@ -1451,6 +1451,9 @@ namespace Server.Engines.Craft
|
||||||
|
|
||||||
if (item != null)
|
if (item != null)
|
||||||
{
|
{
|
||||||
|
// Stamped here, not in OnCraft: most craftables do not implement ICraftable.
|
||||||
|
item.PlayerConstructed = true;
|
||||||
|
|
||||||
if (item is ICraftable craftable)
|
if (item is ICraftable craftable)
|
||||||
{
|
{
|
||||||
endquality = craftable.OnCraft(quality, makersMark, from, craftSystem, typeRes, tool, this, resHue);
|
endquality = craftable.OnCraft(quality, makersMark, from, craftSystem, typeRes, tool, this, resHue);
|
||||||
|
|
@ -1742,6 +1745,9 @@ namespace Server.Engines.Craft
|
||||||
|
|
||||||
if (item != null)
|
if (item != null)
|
||||||
{
|
{
|
||||||
|
// Stamped here, not in OnCraft: most craftables do not implement ICraftable.
|
||||||
|
item.PlayerConstructed = true;
|
||||||
|
|
||||||
if (item is ICraftable craftable)
|
if (item is ICraftable craftable)
|
||||||
{
|
{
|
||||||
endquality = craftable.OnCraft(quality, makersMark, from, craftSystem, typeRes, tool, this, resHue);
|
endquality = craftable.OnCraft(quality, makersMark, from, craftSystem, typeRes, tool, this, resHue);
|
||||||
|
|
|
||||||
|
|
@ -5,9 +5,20 @@ using Server.Mobiles;
|
||||||
namespace Server.Ethics;
|
namespace Server.Ethics;
|
||||||
|
|
||||||
[PropertyObject]
|
[PropertyObject]
|
||||||
[SerializationGenerator(1)]
|
[SerializationGenerator(2)]
|
||||||
public partial class Player : EthicsEntity
|
public partial class Player : EthicsEntity
|
||||||
{
|
{
|
||||||
|
private void MigrateFrom(V1Content content)
|
||||||
|
{
|
||||||
|
_mobile = content.Mobile;
|
||||||
|
_power = content.Power;
|
||||||
|
_history = content.History;
|
||||||
|
_steed = content.Steed;
|
||||||
|
_familiar = content.Familiar;
|
||||||
|
_shield = content.Shield;
|
||||||
|
_ethic = content.Ethic;
|
||||||
|
}
|
||||||
|
|
||||||
[SerializableField(0, setter: "private")]
|
[SerializableField(0, setter: "private")]
|
||||||
private Mobile _mobile;
|
private Mobile _mobile;
|
||||||
|
|
||||||
|
|
@ -27,7 +38,7 @@ public partial class Player : EthicsEntity
|
||||||
[SerializedCommandProperty(AccessLevel.GameMaster, AccessLevel.Administrator)]
|
[SerializedCommandProperty(AccessLevel.GameMaster, AccessLevel.Administrator)]
|
||||||
private Mobile _familiar;
|
private Mobile _familiar;
|
||||||
|
|
||||||
[DeltaDateTime]
|
[AnchoredDateTime]
|
||||||
[SerializableField(5, setter: "private")]
|
[SerializableField(5, setter: "private")]
|
||||||
private DateTime _shield;
|
private DateTime _shield;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -162,10 +162,15 @@ namespace Server.Items
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
[SerializationGenerator(0)]
|
[SerializationGenerator(1)]
|
||||||
public partial class PuzzleChestSolutionAndTime : PuzzleChestSolution
|
public partial class PuzzleChestSolutionAndTime : PuzzleChestSolution
|
||||||
{
|
{
|
||||||
[DeltaDateTime]
|
private void MigrateFrom(V0Content content)
|
||||||
|
{
|
||||||
|
_when = content.When;
|
||||||
|
}
|
||||||
|
|
||||||
|
[AnchoredDateTime]
|
||||||
[SerializableField(0)]
|
[SerializableField(0)]
|
||||||
private DateTime _when;
|
private DateTime _when;
|
||||||
|
|
||||||
|
|
@ -237,16 +242,12 @@ namespace Server.Items
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
[SerializableProperty(0)]
|
[SerializableField(0, fieldChanged: nameof(OnSolutionChanged))]
|
||||||
public PuzzleChestSolution Solution
|
private PuzzleChestSolution _solution;
|
||||||
|
|
||||||
|
private void OnSolutionChanged(PuzzleChestSolution oldValue, PuzzleChestSolution newValue)
|
||||||
{
|
{
|
||||||
get => _solution;
|
InitHints();
|
||||||
set
|
|
||||||
{
|
|
||||||
_solution = value;
|
|
||||||
InitHints();
|
|
||||||
this.MarkDirty();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public PuzzleChestCylinder FirstHint
|
public PuzzleChestCylinder FirstHint
|
||||||
|
|
@ -550,21 +551,14 @@ namespace Server.Items
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
using var toDelete = PooledRefQueue<Mobile>.Create();
|
|
||||||
|
|
||||||
foreach (var (key, value) in _guesses)
|
foreach (var (key, value) in _guesses)
|
||||||
{
|
{
|
||||||
if (Core.Now - value.When > CleanupTime)
|
if (Core.Now - value.When > CleanupTime)
|
||||||
{
|
{
|
||||||
toDelete.Enqueue(key);
|
_guesses.Remove(key);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
while (toDelete.Count > 0)
|
|
||||||
{
|
|
||||||
_guesses.Remove(toDelete.Dequeue());
|
|
||||||
}
|
|
||||||
|
|
||||||
if (_guesses.Count == 0)
|
if (_guesses.Count == 0)
|
||||||
{
|
{
|
||||||
_guesses = null;
|
_guesses = null;
|
||||||
|
|
|
||||||
|
|
@ -116,10 +116,9 @@ namespace Server.Engines.MLQuests.Gumps
|
||||||
|
|
||||||
private static void CloseCurrent(NetState ns)
|
private static void CloseCurrent(NetState ns)
|
||||||
{
|
{
|
||||||
if (m_Pending.TryGetValue(ns, out var state))
|
if (m_Pending.Remove(ns, out var state))
|
||||||
{
|
{
|
||||||
state._timeoutToken.Cancel();
|
state._timeoutToken.Cancel();
|
||||||
m_Pending.Remove(ns);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
ns.SendCloseRaceChanger();
|
ns.SendCloseRaceChanger();
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@ namespace Server.Engines.MLQuests
|
||||||
{
|
{
|
||||||
base.Serialize(writer);
|
base.Serialize(writer);
|
||||||
|
|
||||||
writer.Write(2); // version
|
writer.Write(3); // version
|
||||||
writer.Write(MLQuestSystem.Contexts.Count);
|
writer.Write(MLQuestSystem.Contexts.Count);
|
||||||
|
|
||||||
foreach (var context in MLQuestSystem.Contexts.Values)
|
foreach (var context in MLQuestSystem.Contexts.Values)
|
||||||
|
|
|
||||||
|
|
@ -119,7 +119,7 @@ namespace Server.Engines.MLQuests.Objectives
|
||||||
if (IsTimed)
|
if (IsTimed)
|
||||||
{
|
{
|
||||||
writer.Write(true);
|
writer.Write(true);
|
||||||
writer.WriteDeltaTime(EndTime);
|
writer.WriteAnchoredTime(EndTime);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
|
|
@ -135,7 +135,7 @@ namespace Server.Engines.MLQuests.Objectives
|
||||||
{
|
{
|
||||||
if (reader.ReadBool())
|
if (reader.ReadBool())
|
||||||
{
|
{
|
||||||
var endTime = reader.ReadDeltaTime();
|
var endTime = version >= 3 ? reader.ReadAnchoredTime() : reader.ReadDeltaTime();
|
||||||
|
|
||||||
if (objInstance != null)
|
if (objInstance != null)
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -727,20 +727,14 @@ public sealed class StepCache
|
||||||
var window = MissPromotionWindowMs;
|
var window = MissPromotionWindowMs;
|
||||||
var beforeCount = _chunkMissTracker.Count;
|
var beforeCount = _chunkMissTracker.Count;
|
||||||
|
|
||||||
using var toRemove = PooledRefQueue<long>.Create();
|
|
||||||
foreach (var kvp in _chunkMissTracker)
|
foreach (var kvp in _chunkMissTracker)
|
||||||
{
|
{
|
||||||
if (now - kvp.Value.LastMissTickStamp > window)
|
if (now - kvp.Value.LastMissTickStamp > window)
|
||||||
{
|
{
|
||||||
toRemove.Enqueue(kvp.Key);
|
_chunkMissTracker.Remove(kvp.Key);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
while (toRemove.Count > 0)
|
|
||||||
{
|
|
||||||
_chunkMissTracker.Remove(toRemove.Dequeue());
|
|
||||||
}
|
|
||||||
|
|
||||||
if (_chunkMissTracker.Count == beforeCount)
|
if (_chunkMissTracker.Count == beforeCount)
|
||||||
{
|
{
|
||||||
_chunkMissTracker.Clear();
|
_chunkMissTracker.Clear();
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,5 @@
|
||||||
using System;
|
using System;
|
||||||
using System.Diagnostics;
|
using System.Diagnostics;
|
||||||
using Server.Engines.Pathing;
|
|
||||||
using Server.Engines.Pathing.Cache;
|
|
||||||
using Server.Items;
|
using Server.Items;
|
||||||
using Server.PathAlgorithms;
|
using Server.PathAlgorithms;
|
||||||
using Server.Spells;
|
using Server.Spells;
|
||||||
|
|
|
||||||
|
|
@ -37,17 +37,17 @@ public partial class PlantItem : Item, ISecurable
|
||||||
|
|
||||||
[SerializedIgnoreDupe]
|
[SerializedIgnoreDupe]
|
||||||
[SerializableField(0)]
|
[SerializableField(0)]
|
||||||
|
[SaveFlag(nameof(ShouldSerializeSecureLevel))]
|
||||||
[SerializedCommandProperty(AccessLevel.GameMaster)]
|
[SerializedCommandProperty(AccessLevel.GameMaster)]
|
||||||
private SecureLevel _level;
|
private SecureLevel _level;
|
||||||
|
|
||||||
[SerializableFieldSaveFlag(0)]
|
|
||||||
private bool ShouldSerializeSecureLevel() => (int)_level != 0;
|
private bool ShouldSerializeSecureLevel() => (int)_level != 0;
|
||||||
|
|
||||||
[SerializedIgnoreDupe]
|
[SerializedIgnoreDupe]
|
||||||
[SerializableField(5, setter: "private")]
|
[SerializableField(5, setter: "private")]
|
||||||
|
[SaveFlag(nameof(ShouldSerializePlantSystem))]
|
||||||
private PlantSystem _plantSystem;
|
private PlantSystem _plantSystem;
|
||||||
|
|
||||||
[SerializableFieldSaveFlag(5)]
|
|
||||||
private bool ShouldSerializePlantSystem() => _plantStatus < PlantStatus.DecorativePlant;
|
private bool ShouldSerializePlantSystem() => _plantStatus < PlantStatus.DecorativePlant;
|
||||||
|
|
||||||
// For clients older than 7.0.12.0
|
// For clients older than 7.0.12.0
|
||||||
|
|
@ -82,6 +82,7 @@ public partial class PlantItem : Item, ISecurable
|
||||||
|
|
||||||
[CommandProperty(AccessLevel.GameMaster)]
|
[CommandProperty(AccessLevel.GameMaster)]
|
||||||
[SerializableProperty(1)]
|
[SerializableProperty(1)]
|
||||||
|
[SaveFlag(nameof(ShouldSerializePlantStatus))]
|
||||||
public PlantStatus PlantStatus
|
public PlantStatus PlantStatus
|
||||||
{
|
{
|
||||||
get => _plantStatus;
|
get => _plantStatus;
|
||||||
|
|
@ -120,53 +121,38 @@ public partial class PlantItem : Item, ISecurable
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
[SerializableFieldSaveFlag(1)]
|
|
||||||
private bool ShouldSerializePlantStatus() => _plantStatus != PlantStatus.BowlOfDirt;
|
private bool ShouldSerializePlantStatus() => _plantStatus != PlantStatus.BowlOfDirt;
|
||||||
|
|
||||||
[SerializableProperty(2)]
|
[SerializableField(2, fieldChanged: nameof(OnPlantTypeChanged))]
|
||||||
[CommandProperty(AccessLevel.GameMaster)]
|
[SaveFlag(nameof(ShouldSerializePlantType))]
|
||||||
public PlantType PlantType
|
[SerializedCommandProperty(AccessLevel.GameMaster)]
|
||||||
|
private PlantType _plantType;
|
||||||
|
|
||||||
|
private void OnPlantTypeChanged(PlantType oldValue, PlantType newValue)
|
||||||
{
|
{
|
||||||
get => _plantType;
|
Update();
|
||||||
set
|
|
||||||
{
|
|
||||||
_plantType = value;
|
|
||||||
Update();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[SerializableFieldSaveFlag(2)]
|
|
||||||
private bool ShouldSerializePlantType() => (int)_plantType != 0;
|
private bool ShouldSerializePlantType() => (int)_plantType != 0;
|
||||||
|
|
||||||
[SerializableProperty(3)]
|
[SerializableField(3, fieldChanged: nameof(OnPlantHueChanged))]
|
||||||
[CommandProperty(AccessLevel.GameMaster)]
|
[SaveFlag(nameof(ShouldSerializePlantHue))]
|
||||||
public PlantHue PlantHue
|
[SerializedCommandProperty(AccessLevel.GameMaster)]
|
||||||
|
private PlantHue _plantHue;
|
||||||
|
|
||||||
|
private void OnPlantHueChanged(PlantHue oldValue, PlantHue newValue)
|
||||||
{
|
{
|
||||||
get => _plantHue;
|
Update();
|
||||||
set
|
|
||||||
{
|
|
||||||
_plantHue = value;
|
|
||||||
Update();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[SerializableFieldSaveFlag(3)]
|
|
||||||
private bool ShouldSerializePlantHue() => _plantHue != PlantHue.None;
|
private bool ShouldSerializePlantHue() => _plantHue != PlantHue.None;
|
||||||
|
|
||||||
[SerializableProperty(4)]
|
[SerializableField(4)]
|
||||||
[CommandProperty(AccessLevel.GameMaster)]
|
[SaveFlag(nameof(ShouldSerializeShowType))]
|
||||||
public bool ShowType
|
[SerializedCommandProperty(AccessLevel.GameMaster)]
|
||||||
{
|
[InvalidateProperties]
|
||||||
get => _showType;
|
private bool _showType;
|
||||||
set
|
|
||||||
{
|
|
||||||
_showType = value;
|
|
||||||
InvalidateProperties();
|
|
||||||
this.MarkDirty();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
[SerializableFieldSaveFlag(4)]
|
|
||||||
private bool ShouldSerializeShowType() => _showType;
|
private bool ShouldSerializeShowType() => _showType;
|
||||||
|
|
||||||
[CommandProperty(AccessLevel.GameMaster)]
|
[CommandProperty(AccessLevel.GameMaster)]
|
||||||
|
|
|
||||||
|
|
@ -33,24 +33,24 @@ namespace Server.Engines.Plants
|
||||||
private PlantItem _plant;
|
private PlantItem _plant;
|
||||||
|
|
||||||
[SerializableField(0)]
|
[SerializableField(0)]
|
||||||
|
[SaveFlag(nameof(ShouldSerializeFertileDirt))]
|
||||||
private bool _fertileDirt;
|
private bool _fertileDirt;
|
||||||
|
|
||||||
[SerializableFieldSaveFlag(0)]
|
|
||||||
private bool ShouldSerializeFertileDirt() => _fertileDirt;
|
private bool ShouldSerializeFertileDirt() => _fertileDirt;
|
||||||
|
|
||||||
[SerializableField(1)]
|
[SerializableField(1)]
|
||||||
private DateTime _nextGrowth;
|
private DateTime _nextGrowth;
|
||||||
|
|
||||||
[SerializableField(2, setter: "private")]
|
[SerializableField(2, setter: "private")]
|
||||||
|
[SaveFlag(nameof(ShouldSerializeGrowthIndicator))]
|
||||||
private PlantGrowthIndicator _growthIndicator;
|
private PlantGrowthIndicator _growthIndicator;
|
||||||
|
|
||||||
[SerializableFieldSaveFlag(2)]
|
|
||||||
private bool ShouldSerializeGrowthIndicator() => _growthIndicator != PlantGrowthIndicator.None;
|
private bool ShouldSerializeGrowthIndicator() => _growthIndicator != PlantGrowthIndicator.None;
|
||||||
|
|
||||||
[SerializableField(13)]
|
[SerializableField(13)]
|
||||||
|
[SaveFlag(nameof(ShouldSerializePollinated))]
|
||||||
private bool _pollinated;
|
private bool _pollinated;
|
||||||
|
|
||||||
[SerializableFieldSaveFlag(13)]
|
|
||||||
private bool ShouldSerializePollinated() => _pollinated;
|
private bool ShouldSerializePollinated() => _pollinated;
|
||||||
|
|
||||||
public PlantSystem(PlantItem plant)
|
public PlantSystem(PlantItem plant)
|
||||||
|
|
@ -97,45 +97,42 @@ namespace Server.Engines.Plants
|
||||||
|
|
||||||
public bool IsFullWater => _water >= 4;
|
public bool IsFullWater => _water >= 4;
|
||||||
|
|
||||||
[SerializableProperty(3)]
|
[SerializableField(3, fieldChanged: nameof(OnWaterChanged), allowFieldChange: nameof(AllowWaterChange))]
|
||||||
public int Water
|
[SaveFlag(nameof(ShouldSerializeWater))]
|
||||||
|
private int _water;
|
||||||
|
|
||||||
|
private bool AllowWaterChange(ref int value)
|
||||||
{
|
{
|
||||||
get => _water;
|
value = Math.Clamp(value, 0, 4);
|
||||||
set
|
return true;
|
||||||
{
|
}
|
||||||
_water = Math.Clamp(value, 0, 4);
|
|
||||||
Plant.InvalidateProperties();
|
private void OnWaterChanged(int oldValue, int newValue)
|
||||||
MarkDirty();
|
{
|
||||||
}
|
Plant.InvalidateProperties();
|
||||||
}
|
}
|
||||||
|
|
||||||
[SerializableFieldSaveFlag(3)]
|
|
||||||
private bool ShouldSerializeWater() => _water != 0;
|
private bool ShouldSerializeWater() => _water != 0;
|
||||||
|
|
||||||
[SerializableProperty(4)]
|
[SerializableField(4, fieldChanged: nameof(OnHitsChanged), allowFieldChange: nameof(AllowHitsChange))]
|
||||||
public int Hits
|
[SaveFlag(nameof(ShouldSerializeHits))]
|
||||||
|
private int _hits;
|
||||||
|
|
||||||
|
private bool AllowHitsChange(ref int value)
|
||||||
{
|
{
|
||||||
get => _hits;
|
value = Math.Clamp(value, 0, MaxHits);
|
||||||
set
|
return true;
|
||||||
{
|
}
|
||||||
if (_hits == value)
|
|
||||||
{
|
private void OnHitsChanged(int oldValue, int newValue)
|
||||||
return;
|
{
|
||||||
}
|
if (_hits == 0)
|
||||||
|
{
|
||||||
_hits = Math.Clamp(value, 0, MaxHits);
|
Plant.Die();
|
||||||
|
}
|
||||||
if (_hits == 0)
|
Plant.InvalidateProperties();
|
||||||
{
|
|
||||||
Plant.Die();
|
|
||||||
}
|
|
||||||
|
|
||||||
Plant.InvalidateProperties();
|
|
||||||
MarkDirty();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[SerializableFieldSaveFlag(4)]
|
|
||||||
private bool ShouldSerializeHits() => _hits != 0;
|
private bool ShouldSerializeHits() => _hits != 0;
|
||||||
|
|
||||||
public int MaxHits => 10 + (int)Plant.PlantStatus * 2;
|
public int MaxHits => 10 + (int)Plant.PlantStatus * 2;
|
||||||
|
|
@ -149,124 +146,108 @@ namespace Server.Engines.Plants
|
||||||
_ => PlantHealth.Vibrant
|
_ => PlantHealth.Vibrant
|
||||||
};
|
};
|
||||||
|
|
||||||
[SerializableProperty(5)]
|
[SerializableField(5, allowFieldChange: nameof(AllowInfestationChange))]
|
||||||
public int Infestation
|
[SaveFlag(nameof(ShouldSerializeInfestation))]
|
||||||
|
private int _infestation;
|
||||||
|
|
||||||
|
private bool AllowInfestationChange(ref int value)
|
||||||
{
|
{
|
||||||
get => _infestation;
|
value = Math.Clamp(value, 0, 2);
|
||||||
set
|
return true;
|
||||||
{
|
|
||||||
_infestation = Math.Clamp(value, 0, 2);
|
|
||||||
MarkDirty();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[SerializableFieldSaveFlag(5)]
|
|
||||||
private bool ShouldSerializeInfestation() => _infestation != 0;
|
private bool ShouldSerializeInfestation() => _infestation != 0;
|
||||||
|
|
||||||
[SerializableProperty(6)]
|
[SerializableField(6, allowFieldChange: nameof(AllowFungusChange))]
|
||||||
public int Fungus
|
[SaveFlag(nameof(ShouldSerializeFungus))]
|
||||||
|
private int _fungus;
|
||||||
|
|
||||||
|
private bool AllowFungusChange(ref int value)
|
||||||
{
|
{
|
||||||
get => _fungus;
|
value = Math.Clamp(value, 0, 2);
|
||||||
set
|
return true;
|
||||||
{
|
|
||||||
_fungus = Math.Clamp(value, 0, 2);
|
|
||||||
MarkDirty();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[SerializableFieldSaveFlag(6)]
|
|
||||||
private bool ShouldSerializeFungus() => _fungus != 0;
|
private bool ShouldSerializeFungus() => _fungus != 0;
|
||||||
|
|
||||||
[SerializableProperty(7)]
|
[SerializableField(7, allowFieldChange: nameof(AllowPoisonChange))]
|
||||||
public int Poison
|
[SaveFlag(nameof(ShouldSerializePoison))]
|
||||||
|
private int _poison;
|
||||||
|
|
||||||
|
private bool AllowPoisonChange(ref int value)
|
||||||
{
|
{
|
||||||
get => _poison;
|
value = Math.Clamp(value, 0, 2);
|
||||||
set
|
return true;
|
||||||
{
|
|
||||||
_poison = Math.Clamp(value, 0, 2);
|
|
||||||
MarkDirty();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[SerializableFieldSaveFlag(7)]
|
|
||||||
private bool ShouldSerializePoison() => _poison != 0;
|
private bool ShouldSerializePoison() => _poison != 0;
|
||||||
|
|
||||||
[SerializableProperty(8)]
|
[SerializableField(8, allowFieldChange: nameof(AllowDiseaseChange))]
|
||||||
public int Disease
|
[SaveFlag(nameof(ShouldSerializeDisease))]
|
||||||
|
private int _disease;
|
||||||
|
|
||||||
|
private bool AllowDiseaseChange(ref int value)
|
||||||
{
|
{
|
||||||
get => _disease;
|
value = Math.Clamp(value, 0, 2);
|
||||||
set
|
return true;
|
||||||
{
|
|
||||||
_disease = Math.Clamp(value, 0, 2);
|
|
||||||
MarkDirty();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[SerializableFieldSaveFlag(8)]
|
|
||||||
private bool ShouldSerializeDisease() => _disease != 0;
|
private bool ShouldSerializeDisease() => _disease != 0;
|
||||||
|
|
||||||
public bool IsFullPoisonPotion => _poisonPotion >= 2;
|
public bool IsFullPoisonPotion => _poisonPotion >= 2;
|
||||||
|
|
||||||
[SerializableProperty(9)]
|
[SerializableField(9, allowFieldChange: nameof(AllowPoisonPotionChange))]
|
||||||
public int PoisonPotion
|
[SaveFlag(nameof(ShouldSerializePoisonPotion))]
|
||||||
|
private int _poisonPotion;
|
||||||
|
|
||||||
|
private bool AllowPoisonPotionChange(ref int value)
|
||||||
{
|
{
|
||||||
get => _poisonPotion;
|
value = Math.Clamp(value, 0, 2);
|
||||||
set
|
return true;
|
||||||
{
|
|
||||||
_poisonPotion = Math.Clamp(value, 0, 2);
|
|
||||||
MarkDirty();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[SerializableFieldSaveFlag(9)]
|
|
||||||
private bool ShouldSerializePoisonPotion() => _poisonPotion != 0;
|
private bool ShouldSerializePoisonPotion() => _poisonPotion != 0;
|
||||||
|
|
||||||
public bool IsFullCurePotion => _curePotion >= 2;
|
public bool IsFullCurePotion => _curePotion >= 2;
|
||||||
|
|
||||||
[SerializableProperty(10)]
|
[SerializableField(10, allowFieldChange: nameof(AllowCurePotionChange))]
|
||||||
public int CurePotion
|
[SaveFlag(nameof(ShouldSerializeCurePotion))]
|
||||||
|
private int _curePotion;
|
||||||
|
|
||||||
|
private bool AllowCurePotionChange(ref int value)
|
||||||
{
|
{
|
||||||
get => _curePotion;
|
value = Math.Clamp(value, 0, 2);
|
||||||
set
|
return true;
|
||||||
{
|
|
||||||
_curePotion = Math.Clamp(value, 0, 2);
|
|
||||||
MarkDirty();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[SerializableFieldSaveFlag(10)]
|
|
||||||
private bool ShouldSerializeCurePotion() => _curePotion != 0;
|
private bool ShouldSerializeCurePotion() => _curePotion != 0;
|
||||||
|
|
||||||
public bool IsFullHealPotion => _healPotion >= 2;
|
public bool IsFullHealPotion => _healPotion >= 2;
|
||||||
|
|
||||||
[SerializableProperty(11)]
|
[SerializableField(11, allowFieldChange: nameof(AllowHealPotionChange))]
|
||||||
public int HealPotion
|
[SaveFlag(nameof(ShouldSerializeHealPotion))]
|
||||||
|
private int _healPotion;
|
||||||
|
|
||||||
|
private bool AllowHealPotionChange(ref int value)
|
||||||
{
|
{
|
||||||
get => _healPotion;
|
value = Math.Clamp(value, 0, 2);
|
||||||
set
|
return true;
|
||||||
{
|
|
||||||
_healPotion = Math.Clamp(value, 0, 2);
|
|
||||||
MarkDirty();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[SerializableFieldSaveFlag(11)]
|
|
||||||
private bool ShouldSerializeHealPotion() => _healPotion != 0;
|
private bool ShouldSerializeHealPotion() => _healPotion != 0;
|
||||||
|
|
||||||
public bool IsFullStrengthPotion => _strengthPotion >= 2;
|
public bool IsFullStrengthPotion => _strengthPotion >= 2;
|
||||||
|
|
||||||
[SerializableProperty(12)]
|
[SerializableField(12, allowFieldChange: nameof(AllowStrengthPotionChange))]
|
||||||
public int StrengthPotion
|
[SaveFlag(nameof(ShouldSerializeStrengthPotion))]
|
||||||
|
private int _strengthPotion;
|
||||||
|
|
||||||
|
private bool AllowStrengthPotionChange(ref int value)
|
||||||
{
|
{
|
||||||
get => _strengthPotion;
|
value = Math.Clamp(value, 0, 2);
|
||||||
set
|
return true;
|
||||||
{
|
|
||||||
_strengthPotion = Math.Clamp(value, 0, 2);
|
|
||||||
MarkDirty();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[SerializableFieldSaveFlag(12)]
|
|
||||||
private bool ShouldSerializeStrengthPotion() => _strengthPotion != 0;
|
private bool ShouldSerializeStrengthPotion() => _strengthPotion != 0;
|
||||||
|
|
||||||
public bool HasMaladies => Infestation > 0 || Fungus > 0 || Poison > 0 || Disease > 0 || Water != 2;
|
public bool HasMaladies => Infestation > 0 || Fungus > 0 || Poison > 0 || Disease > 0 || Water != 2;
|
||||||
|
|
@ -274,6 +255,7 @@ namespace Server.Engines.Plants
|
||||||
public bool PollenProducing => Plant.IsCrossable && Plant.PlantStatus >= PlantStatus.FullGrownPlant;
|
public bool PollenProducing => Plant.IsCrossable && Plant.PlantStatus >= PlantStatus.FullGrownPlant;
|
||||||
|
|
||||||
[SerializableProperty(14)]
|
[SerializableProperty(14)]
|
||||||
|
[SaveFlag(nameof(ShouldSerializeSeedType))]
|
||||||
public PlantType SeedType
|
public PlantType SeedType
|
||||||
{
|
{
|
||||||
get => Pollinated ? _seedType : Plant.PlantType;
|
get => Pollinated ? _seedType : Plant.PlantType;
|
||||||
|
|
@ -284,10 +266,10 @@ namespace Server.Engines.Plants
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
[SerializableFieldSaveFlag(14)]
|
|
||||||
private bool ShouldSerializeSeedType() => _pollinated;
|
private bool ShouldSerializeSeedType() => _pollinated;
|
||||||
|
|
||||||
[SerializableProperty(15)]
|
[SerializableProperty(15)]
|
||||||
|
[SaveFlag(nameof(ShouldSerializeSeedHue))]
|
||||||
public PlantHue SeedHue
|
public PlantHue SeedHue
|
||||||
{
|
{
|
||||||
get => Pollinated ? _seedHue : Plant.PlantHue;
|
get => Pollinated ? _seedHue : Plant.PlantHue;
|
||||||
|
|
@ -298,53 +280,58 @@ namespace Server.Engines.Plants
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
[SerializableFieldSaveFlag(15)]
|
|
||||||
private bool ShouldSerializeSeedHue() => _pollinated;
|
private bool ShouldSerializeSeedHue() => _pollinated;
|
||||||
|
|
||||||
[SerializableProperty(16)]
|
[SerializableField(16, allowFieldChange: nameof(AllowAvailableSeedsChange))]
|
||||||
public int AvailableSeeds
|
[SaveFlag(nameof(ShouldSerializeAvailableSeeds))]
|
||||||
|
private int _availableSeeds;
|
||||||
|
|
||||||
|
private bool AllowAvailableSeedsChange(ref int value)
|
||||||
{
|
{
|
||||||
get => _availableSeeds;
|
value = Math.Max(value, 0);
|
||||||
set => _availableSeeds = Math.Max(value, 0);
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
[SerializableFieldSaveFlag(16)]
|
|
||||||
private bool ShouldSerializeAvailableSeeds() => _availableSeeds != 0;
|
private bool ShouldSerializeAvailableSeeds() => _availableSeeds != 0;
|
||||||
|
|
||||||
[SerializableProperty(17)]
|
[SerializableField(17, allowFieldChange: nameof(AllowLeftSeedsChange))]
|
||||||
public int LeftSeeds
|
[SaveFlag(nameof(ShouldSerializeLeftSeeds), nameof(LeftSeedsDefaultValue))]
|
||||||
|
private int _leftSeeds;
|
||||||
|
|
||||||
|
private bool AllowLeftSeedsChange(ref int value)
|
||||||
{
|
{
|
||||||
get => _leftSeeds;
|
value = Math.Max(value, 0);
|
||||||
set => _leftSeeds = Math.Max(value, 0);
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
[SerializableFieldSaveFlag(17)]
|
|
||||||
private bool ShouldSerializeLeftSeeds() => _leftSeeds != 8;
|
private bool ShouldSerializeLeftSeeds() => _leftSeeds != 8;
|
||||||
|
|
||||||
[SerializableFieldDefault(17)]
|
|
||||||
private int LeftSeedsDefaultValue() => 8;
|
private int LeftSeedsDefaultValue() => 8;
|
||||||
|
|
||||||
[SerializableProperty(18)]
|
[SerializableField(18, allowFieldChange: nameof(AllowAvailableResourcesChange))]
|
||||||
public int AvailableResources
|
[SaveFlag(nameof(ShouldSerializeAvailableResources))]
|
||||||
|
private int _availableResources;
|
||||||
|
|
||||||
|
private bool AllowAvailableResourcesChange(ref int value)
|
||||||
{
|
{
|
||||||
get => _availableResources;
|
value = Math.Max(value, 0);
|
||||||
set => _availableResources = Math.Max(value, 0);
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
[SerializableFieldSaveFlag(18)]
|
|
||||||
private bool ShouldSerializeAvailableResources() => _availableResources != 0;
|
private bool ShouldSerializeAvailableResources() => _availableResources != 0;
|
||||||
|
|
||||||
[SerializableProperty(19)]
|
[SerializableField(19, allowFieldChange: nameof(AllowLeftResourcesChange))]
|
||||||
public int LeftResources
|
[SaveFlag(nameof(ShouldSerializeLeftResources), nameof(LeftResourcesDefaultValue))]
|
||||||
|
private int _leftResources;
|
||||||
|
|
||||||
|
private bool AllowLeftResourcesChange(ref int value)
|
||||||
{
|
{
|
||||||
get => _leftResources;
|
value = Math.Max(value, 0);
|
||||||
set => _leftResources = Math.Max(value, 0);
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
[SerializableFieldSaveFlag(19)]
|
|
||||||
private bool ShouldSerializeLeftResources() => _leftResources != 8;
|
private bool ShouldSerializeLeftResources() => _leftResources != 8;
|
||||||
|
|
||||||
[SerializableFieldDefault(19)]
|
|
||||||
private int LeftResourcesDefaultValue() => 8;
|
private int LeftResourcesDefaultValue() => 8;
|
||||||
|
|
||||||
public void Reset(bool potions)
|
public void Reset(bool potions)
|
||||||
|
|
|
||||||
|
|
@ -35,18 +35,14 @@ public partial class Seed : Item
|
||||||
|
|
||||||
public override double DefaultWeight => 1.0;
|
public override double DefaultWeight => 1.0;
|
||||||
|
|
||||||
[CommandProperty(AccessLevel.GameMaster)]
|
[SerializableField(1, fieldChanged: nameof(OnPlantHueChanged))]
|
||||||
[SerializableProperty(1)]
|
[SerializedCommandProperty(AccessLevel.GameMaster)]
|
||||||
public PlantHue PlantHue
|
[InvalidateProperties]
|
||||||
|
private PlantHue _plantHue;
|
||||||
|
|
||||||
|
private void OnPlantHueChanged(PlantHue oldValue, PlantHue newValue)
|
||||||
{
|
{
|
||||||
get => _plantHue;
|
Hue = PlantHueInfo.GetInfo(newValue).Hue;
|
||||||
set
|
|
||||||
{
|
|
||||||
_plantHue = value;
|
|
||||||
Hue = PlantHueInfo.GetInfo(value).Hue;
|
|
||||||
InvalidateProperties();
|
|
||||||
this.MarkDirty();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public override int LabelNumber => 1060810; // seed
|
public override int LabelNumber => 1060810; // seed
|
||||||
|
|
|
||||||
|
|
@ -16,12 +16,14 @@ public partial class MurderContext
|
||||||
[SerializedCommandProperty(AccessLevel.GameMaster)]
|
[SerializedCommandProperty(AccessLevel.GameMaster)]
|
||||||
private TimeSpan _longTermElapse;
|
private TimeSpan _longTermElapse;
|
||||||
|
|
||||||
[SerializableProperty(2)]
|
[SerializableField(2, allowFieldChange: nameof(AllowShortTermMurdersChange))]
|
||||||
[CommandProperty(AccessLevel.GameMaster)]
|
[SerializedCommandProperty(AccessLevel.GameMaster)]
|
||||||
public int ShortTermMurders
|
private int _shortTermMurders;
|
||||||
|
|
||||||
|
private bool AllowShortTermMurdersChange(ref int value)
|
||||||
{
|
{
|
||||||
get => _shortTermMurders;
|
value = Math.Max(value, 0);
|
||||||
set => _shortTermMurders = Math.Max(value, 0);
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
[SerializableField(3)]
|
[SerializableField(3)]
|
||||||
|
|
|
||||||
|
|
@ -403,27 +403,20 @@ public class PlayerMurderSystem : GenericPersistence
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
using var queue = PooledRefQueue<Mobile>.Create();
|
|
||||||
|
|
||||||
foreach (var context in _contextTerms)
|
foreach (var context in _contextTerms)
|
||||||
{
|
{
|
||||||
context.DecayKills();
|
context.DecayKills();
|
||||||
if (!context.CheckStart())
|
if (!context.CheckStart())
|
||||||
{
|
{
|
||||||
queue.Enqueue(context.Player);
|
var pm = context.Player;
|
||||||
}
|
if (_murderContexts.TryGetValue(pm, out var ctx))
|
||||||
}
|
|
||||||
|
|
||||||
while (queue.Count > 0)
|
|
||||||
{
|
|
||||||
var pm = (PlayerMobile)queue.Dequeue();
|
|
||||||
if (_murderContexts.TryGetValue(pm, out var ctx))
|
|
||||||
{
|
|
||||||
if (ctx.CanRemove())
|
|
||||||
{
|
{
|
||||||
_murderContexts.Remove(pm);
|
if (ctx.CanRemove())
|
||||||
|
{
|
||||||
|
_murderContexts.Remove(pm);
|
||||||
|
}
|
||||||
|
_contextTerms.Remove(ctx);
|
||||||
}
|
}
|
||||||
_contextTerms.Remove(ctx);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,16 +12,12 @@ public partial class SummoningAltar : AbbatoirAddon
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
|
|
||||||
[SerializableProperty(0)]
|
[SerializableField(0, fieldChanged: nameof(OnDaemonChanged))]
|
||||||
public BoneDemon Daemon
|
private BoneDemon _daemon;
|
||||||
|
|
||||||
|
private void OnDaemonChanged(BoneDemon oldValue, BoneDemon newValue)
|
||||||
{
|
{
|
||||||
get => _daemon;
|
CheckDaemon();
|
||||||
set
|
|
||||||
{
|
|
||||||
_daemon = value;
|
|
||||||
CheckDaemon();
|
|
||||||
this.MarkDirty();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public void CheckDaemon()
|
public void CheckDaemon()
|
||||||
|
|
|
||||||
|
|
@ -54,10 +54,10 @@ public abstract partial class BaseSpawner : Item, ISpawner
|
||||||
[SerializedCommandProperty(AccessLevel.Developer)]
|
[SerializedCommandProperty(AccessLevel.Developer)]
|
||||||
private Guid _guid;
|
private Guid _guid;
|
||||||
|
|
||||||
[SerializableFieldSaveFlag(1)]
|
|
||||||
private bool ShouldSerializeReturnOnDeactivate() => _returnOnDeactivate;
|
private bool ShouldSerializeReturnOnDeactivate() => _returnOnDeactivate;
|
||||||
|
|
||||||
[SerializableField(1)]
|
[SerializableField(1)]
|
||||||
|
[SaveFlag(nameof(ShouldSerializeReturnOnDeactivate))]
|
||||||
[SerializedCommandProperty(AccessLevel.Developer)]
|
[SerializedCommandProperty(AccessLevel.Developer)]
|
||||||
private bool _returnOnDeactivate;
|
private bool _returnOnDeactivate;
|
||||||
|
|
||||||
|
|
@ -67,48 +67,46 @@ public abstract partial class BaseSpawner : Item, ISpawner
|
||||||
|
|
||||||
private int _walkingRange = -1;
|
private int _walkingRange = -1;
|
||||||
|
|
||||||
[SerializableFieldSaveFlag(4)]
|
|
||||||
private bool ShouldSerializeWayPoint() => _wayPoint != null;
|
private bool ShouldSerializeWayPoint() => _wayPoint != null;
|
||||||
|
|
||||||
[SerializableField(4)]
|
[SerializableField(4)]
|
||||||
|
[SaveFlag(nameof(ShouldSerializeWayPoint))]
|
||||||
[SerializedCommandProperty(AccessLevel.Developer)]
|
[SerializedCommandProperty(AccessLevel.Developer)]
|
||||||
private WayPoint _wayPoint;
|
private WayPoint _wayPoint;
|
||||||
|
|
||||||
[SerializableFieldSaveFlag(5)]
|
|
||||||
private bool ShouldSerializeGroup() => _group;
|
private bool ShouldSerializeGroup() => _group;
|
||||||
|
|
||||||
[InvalidateProperties]
|
[InvalidateProperties]
|
||||||
[SerializableField(5)]
|
[SerializableField(5)]
|
||||||
|
[SaveFlag(nameof(ShouldSerializeGroup))]
|
||||||
[SerializedCommandProperty(AccessLevel.Developer)]
|
[SerializedCommandProperty(AccessLevel.Developer)]
|
||||||
private bool _group;
|
private bool _group;
|
||||||
|
|
||||||
[SerializableFieldSaveFlag(6)]
|
|
||||||
private bool ShouldSerializeMinDelay() => _minDelay != DefaultMinDelay;
|
private bool ShouldSerializeMinDelay() => _minDelay != DefaultMinDelay;
|
||||||
|
|
||||||
[SerializableFieldDefault(6)]
|
|
||||||
private TimeSpan MinDelayDefault() => DefaultMinDelay;
|
private TimeSpan MinDelayDefault() => DefaultMinDelay;
|
||||||
|
|
||||||
[InvalidateProperties]
|
[InvalidateProperties]
|
||||||
[SerializableField(6)]
|
[SerializableField(6)]
|
||||||
|
[SaveFlag(nameof(ShouldSerializeMinDelay), nameof(MinDelayDefault))]
|
||||||
[SerializedCommandProperty(AccessLevel.Developer)]
|
[SerializedCommandProperty(AccessLevel.Developer)]
|
||||||
private TimeSpan _minDelay;
|
private TimeSpan _minDelay;
|
||||||
|
|
||||||
[SerializableFieldSaveFlag(7)]
|
|
||||||
private bool ShouldSerializeMaxDelay() => _maxDelay != DefaultMaxDelay;
|
private bool ShouldSerializeMaxDelay() => _maxDelay != DefaultMaxDelay;
|
||||||
|
|
||||||
[SerializableFieldDefault(7)]
|
|
||||||
private TimeSpan MaxDelayDefault() => DefaultMaxDelay;
|
private TimeSpan MaxDelayDefault() => DefaultMaxDelay;
|
||||||
|
|
||||||
[InvalidateProperties]
|
[InvalidateProperties]
|
||||||
[SerializableField(7)]
|
[SerializableField(7)]
|
||||||
|
[SaveFlag(nameof(ShouldSerializeMaxDelay), nameof(MaxDelayDefault))]
|
||||||
[SerializedCommandProperty(AccessLevel.Developer)]
|
[SerializedCommandProperty(AccessLevel.Developer)]
|
||||||
private TimeSpan _maxDelay;
|
private TimeSpan _maxDelay;
|
||||||
|
|
||||||
[SerializableFieldSaveFlag(9)]
|
|
||||||
private bool ShouldSerializeTeam() => _team != 0;
|
private bool ShouldSerializeTeam() => _team != 0;
|
||||||
|
|
||||||
[InvalidateProperties]
|
[InvalidateProperties]
|
||||||
[SerializableField(9)]
|
[SerializableField(9)]
|
||||||
|
[SaveFlag(nameof(ShouldSerializeTeam))]
|
||||||
[SerializedCommandProperty(AccessLevel.Developer)]
|
[SerializedCommandProperty(AccessLevel.Developer)]
|
||||||
private int _team;
|
private int _team;
|
||||||
|
|
||||||
|
|
@ -125,29 +123,29 @@ public abstract partial class BaseSpawner : Item, ISpawner
|
||||||
/// If true, the home location of the spawn is the location where it spawned
|
/// If true, the home location of the spawn is the location where it spawned
|
||||||
/// If false, the home location of the spawn is the location of the spawner
|
/// If false, the home location of the spawn is the location of the spawner
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[SerializableFieldSaveFlag(11)]
|
|
||||||
private bool ShouldSerializeSpawnLocationIsHome() => _spawnLocationIsHome;
|
private bool ShouldSerializeSpawnLocationIsHome() => _spawnLocationIsHome;
|
||||||
|
|
||||||
[InvalidateProperties]
|
[InvalidateProperties]
|
||||||
[SerializableField(11)]
|
[SerializableField(11)]
|
||||||
|
[SaveFlag(nameof(ShouldSerializeSpawnLocationIsHome))]
|
||||||
[SerializedCommandProperty(AccessLevel.Developer)]
|
[SerializedCommandProperty(AccessLevel.Developer)]
|
||||||
private bool _spawnLocationIsHome;
|
private bool _spawnLocationIsHome;
|
||||||
|
|
||||||
[SerializableFieldSaveFlag(12)]
|
|
||||||
private bool ShouldSerializeEnd() => _end != default;
|
private bool ShouldSerializeEnd() => _end != default;
|
||||||
|
|
||||||
[SerializableField(12)]
|
[SerializableField(12)]
|
||||||
|
[SaveFlag(nameof(ShouldSerializeEnd))]
|
||||||
[SerializedCommandProperty(AccessLevel.Developer)]
|
[SerializedCommandProperty(AccessLevel.Developer)]
|
||||||
private DateTime _end;
|
private DateTime _end;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Controls how spawn position optimization is handled.
|
/// Controls how spawn position optimization is handled.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[SerializableFieldSaveFlag(13)]
|
|
||||||
private bool ShouldSerializeSpawnPositionMode() =>
|
private bool ShouldSerializeSpawnPositionMode() =>
|
||||||
_spawnPositionMode is not SpawnPositionMode.Automatic and not SpawnPositionMode.Abandoned;
|
_spawnPositionMode is not SpawnPositionMode.Automatic and not SpawnPositionMode.Abandoned;
|
||||||
|
|
||||||
[SerializableField(13)]
|
[SerializableField(13)]
|
||||||
|
[SaveFlag(nameof(ShouldSerializeSpawnPositionMode))]
|
||||||
[SerializedCommandProperty(AccessLevel.Developer)]
|
[SerializedCommandProperty(AccessLevel.Developer)]
|
||||||
private SpawnPositionMode _spawnPositionMode;
|
private SpawnPositionMode _spawnPositionMode;
|
||||||
|
|
||||||
|
|
@ -156,13 +154,12 @@ public abstract partial class BaseSpawner : Item, ISpawner
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Maximum number of random position attempts before engaging optimization.
|
/// Maximum number of random position attempts before engaging optimization.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[SerializableFieldSaveFlag(14)]
|
|
||||||
private bool ShouldSerializeMaxSpawnAttempts() => _maxSpawnAttempts != DefaultMaxSpawnAttempts;
|
private bool ShouldSerializeMaxSpawnAttempts() => _maxSpawnAttempts != DefaultMaxSpawnAttempts;
|
||||||
|
|
||||||
[SerializableFieldDefault(14)]
|
|
||||||
private int MaxSpawnAttemptsDefault() => DefaultMaxSpawnAttempts;
|
private int MaxSpawnAttemptsDefault() => DefaultMaxSpawnAttempts;
|
||||||
|
|
||||||
[SerializableField(14)]
|
[SerializableField(14)]
|
||||||
|
[SaveFlag(nameof(ShouldSerializeMaxSpawnAttempts), nameof(MaxSpawnAttemptsDefault))]
|
||||||
[SerializedCommandProperty(AccessLevel.Developer)]
|
[SerializedCommandProperty(AccessLevel.Developer)]
|
||||||
private int _maxSpawnAttempts;
|
private int _maxSpawnAttempts;
|
||||||
|
|
||||||
|
|
@ -314,26 +311,20 @@ public abstract partial class BaseSpawner : Item, ISpawner
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
[SerializableProperty(8)]
|
[SerializableField(8, fieldChanged: nameof(OnCountChanged))]
|
||||||
[CommandProperty(AccessLevel.Developer)]
|
[SerializedCommandProperty(AccessLevel.Developer)]
|
||||||
public int Count
|
[InvalidateProperties]
|
||||||
|
private int _count;
|
||||||
|
|
||||||
|
private void OnCountChanged(int oldValue, int newValue)
|
||||||
{
|
{
|
||||||
get => _count;
|
if (IsFull)
|
||||||
set
|
|
||||||
{
|
{
|
||||||
_count = value;
|
_timer?.Stop();
|
||||||
|
}
|
||||||
if (IsFull)
|
else if (_timer?.Running != true)
|
||||||
{
|
{
|
||||||
_timer?.Stop();
|
DoTimer();
|
||||||
}
|
|
||||||
else if (_timer?.Running != true)
|
|
||||||
{
|
|
||||||
DoTimer();
|
|
||||||
}
|
|
||||||
|
|
||||||
InvalidateProperties();
|
|
||||||
this.MarkDirty();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,17 +10,17 @@ public partial class Spawner : BaseSpawner
|
||||||
/// When true, enables proactive spiral scanning to find valid spawn positions.
|
/// When true, enables proactive spiral scanning to find valid spawn positions.
|
||||||
/// Only relevant when SpawnPositionMode is Automatic or Enabled.
|
/// Only relevant when SpawnPositionMode is Automatic or Enabled.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[SerializableFieldSaveFlag(0)]
|
|
||||||
private bool ShouldSerializeUseSpiralScan() => _useSpiralScan;
|
private bool ShouldSerializeUseSpiralScan() => _useSpiralScan;
|
||||||
|
|
||||||
[SerializableField(0)]
|
[SerializableField(0)]
|
||||||
|
[SaveFlag(nameof(ShouldSerializeUseSpiralScan))]
|
||||||
[SerializedCommandProperty(AccessLevel.Developer)]
|
[SerializedCommandProperty(AccessLevel.Developer)]
|
||||||
private bool _useSpiralScan;
|
private bool _useSpiralScan;
|
||||||
|
|
||||||
[SerializableFieldSaveFlag(1)]
|
|
||||||
private bool ShouldSerializeSpawnBounds() => _spawnBounds != default;
|
private bool ShouldSerializeSpawnBounds() => _spawnBounds != default;
|
||||||
|
|
||||||
[SerializableProperty(1)]
|
[SerializableProperty(1)]
|
||||||
|
[SaveFlag(nameof(ShouldSerializeSpawnBounds))]
|
||||||
[CommandProperty(AccessLevel.Developer)]
|
[CommandProperty(AccessLevel.Developer)]
|
||||||
public override Rectangle3D SpawnBounds
|
public override Rectangle3D SpawnBounds
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -248,7 +248,7 @@ public class StealableArtifacts : GenericPersistence
|
||||||
|
|
||||||
public override void Serialize(IGenericWriter writer)
|
public override void Serialize(IGenericWriter writer)
|
||||||
{
|
{
|
||||||
writer.WriteEncodedInt(1); // version
|
writer.WriteEncodedInt(2); // version
|
||||||
|
|
||||||
writer.Write(_enabled);
|
writer.Write(_enabled);
|
||||||
|
|
||||||
|
|
@ -261,7 +261,7 @@ public class StealableArtifacts : GenericPersistence
|
||||||
var si = _artifacts[i];
|
var si = _artifacts[i];
|
||||||
|
|
||||||
writer.Write(si.Item);
|
writer.Write(si.Item);
|
||||||
writer.WriteDeltaTime(si.NextRespawn);
|
writer.WriteAnchoredTime(si.NextRespawn);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -282,7 +282,7 @@ public class StealableArtifacts : GenericPersistence
|
||||||
for (var i = 0; i < length; i++)
|
for (var i = 0; i < length; i++)
|
||||||
{
|
{
|
||||||
var item = reader.ReadEntity<Item>();
|
var item = reader.ReadEntity<Item>();
|
||||||
var nextRespawn = reader.ReadDeltaTime();
|
var nextRespawn = version >= 2 ? reader.ReadAnchoredTime() : reader.ReadDeltaTime();
|
||||||
|
|
||||||
if (i < _artifacts.Length)
|
if (i < _artifacts.Length)
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -332,27 +332,22 @@ public partial class PigmentsOfTokuno : BasePigmentsOfTokuno
|
||||||
[Constructible]
|
[Constructible]
|
||||||
public PigmentsOfTokuno(PigmentType type, int uses) : base(uses) => Type = type;
|
public PigmentsOfTokuno(PigmentType type, int uses) : base(uses) => Type = type;
|
||||||
|
|
||||||
[SerializableProperty(0)]
|
[SerializableField(0, fieldChanged: nameof(OnTypeChanged))]
|
||||||
[CommandProperty(AccessLevel.GameMaster)]
|
[SerializedCommandProperty(AccessLevel.GameMaster)]
|
||||||
public PigmentType Type
|
private PigmentType _type;
|
||||||
|
|
||||||
|
private void OnTypeChanged(PigmentType oldValue, PigmentType newValue)
|
||||||
{
|
{
|
||||||
get => _type;
|
var v = (int)_type;
|
||||||
set
|
if (v >= 0 && v < _table.Length)
|
||||||
{
|
{
|
||||||
_type = value;
|
Hue = _table[v][0];
|
||||||
|
Label = _table[v][1];
|
||||||
var v = (int)_type;
|
}
|
||||||
|
else
|
||||||
if (v >= 0 && v < _table.Length)
|
{
|
||||||
{
|
Hue = 0;
|
||||||
Hue = _table[v][0];
|
Label = -1;
|
||||||
Label = _table[v][1];
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
Hue = 0;
|
|
||||||
Label = -1;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -617,27 +617,22 @@ public partial class LesserPigmentsOfTokuno : BasePigmentsOfTokuno
|
||||||
[Constructible]
|
[Constructible]
|
||||||
public LesserPigmentsOfTokuno(LesserPigmentType type) : base(1) => Type = type;
|
public LesserPigmentsOfTokuno(LesserPigmentType type) : base(1) => Type = type;
|
||||||
|
|
||||||
[SerializableProperty(0)]
|
[SerializableField(0, fieldChanged: nameof(OnTypeChanged))]
|
||||||
[CommandProperty(AccessLevel.GameMaster)]
|
[SerializedCommandProperty(AccessLevel.GameMaster)]
|
||||||
public LesserPigmentType Type
|
private LesserPigmentType _type;
|
||||||
|
|
||||||
|
private void OnTypeChanged(LesserPigmentType oldValue, LesserPigmentType newValue)
|
||||||
{
|
{
|
||||||
get => _type;
|
var v = (int)_type;
|
||||||
set
|
if (v >= 0 && v < _table.Length)
|
||||||
{
|
{
|
||||||
_type = value;
|
Hue = _table[v][0];
|
||||||
|
Label = _table[v][1];
|
||||||
var v = (int)_type;
|
}
|
||||||
|
else
|
||||||
if (v >= 0 && v < _table.Length)
|
{
|
||||||
{
|
Hue = 0;
|
||||||
Hue = _table[v][0];
|
Label = -1;
|
||||||
Label = _table[v][1];
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
Hue = 0;
|
|
||||||
Label = -1;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -79,45 +79,33 @@ public partial class CharacterStatue : Mobile, IRewardItem
|
||||||
InvalidateHues();
|
InvalidateHues();
|
||||||
}
|
}
|
||||||
|
|
||||||
[SerializableProperty(0)]
|
[SerializableField(0, fieldChanged: nameof(OnStatueTypeChanged))]
|
||||||
[CommandProperty(AccessLevel.GameMaster)]
|
[SerializedCommandProperty(AccessLevel.GameMaster)]
|
||||||
public StatueType StatueType
|
private StatueType _statueType;
|
||||||
|
|
||||||
|
private void OnStatueTypeChanged(StatueType oldValue, StatueType newValue)
|
||||||
{
|
{
|
||||||
get => _statueType;
|
InvalidateHues();
|
||||||
set
|
InvalidatePose();
|
||||||
{
|
|
||||||
_statueType = value;
|
|
||||||
InvalidateHues();
|
|
||||||
InvalidatePose();
|
|
||||||
this.MarkDirty();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[SerializableProperty(1)]
|
[SerializableField(1, fieldChanged: nameof(OnPoseChanged))]
|
||||||
[CommandProperty(AccessLevel.GameMaster)]
|
[SerializedCommandProperty(AccessLevel.GameMaster)]
|
||||||
public StatuePose Pose
|
private StatuePose _pose;
|
||||||
|
|
||||||
|
private void OnPoseChanged(StatuePose oldValue, StatuePose newValue)
|
||||||
{
|
{
|
||||||
get => _pose;
|
InvalidatePose();
|
||||||
set
|
|
||||||
{
|
|
||||||
_pose = value;
|
|
||||||
InvalidatePose();
|
|
||||||
this.MarkDirty();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[SerializableProperty(2)]
|
[SerializableField(2, fieldChanged: nameof(OnMaterialChanged))]
|
||||||
[CommandProperty(AccessLevel.GameMaster)]
|
[SerializedCommandProperty(AccessLevel.GameMaster)]
|
||||||
public StatueMaterial Material
|
private StatueMaterial _material;
|
||||||
|
|
||||||
|
private void OnMaterialChanged(StatueMaterial oldValue, StatueMaterial newValue)
|
||||||
{
|
{
|
||||||
get => _material;
|
InvalidateHues();
|
||||||
set
|
InvalidatePose();
|
||||||
{
|
|
||||||
_material = value;
|
|
||||||
InvalidateHues();
|
|
||||||
InvalidatePose();
|
|
||||||
this.MarkDirty();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public override void OnDoubleClick(Mobile from)
|
public override void OnDoubleClick(Mobile from)
|
||||||
|
|
|
||||||
|
|
@ -25,17 +25,13 @@ public partial class CharacterStatueMaker : Item, IRewardItem
|
||||||
|
|
||||||
public override int LabelNumber => 1076173; // Character Statue Maker
|
public override int LabelNumber => 1076173; // Character Statue Maker
|
||||||
|
|
||||||
[SerializableProperty(1)]
|
[SerializableField(1, fieldChanged: nameof(OnStatueTypeChanged))]
|
||||||
[CommandProperty(AccessLevel.GameMaster)]
|
[SerializedCommandProperty(AccessLevel.GameMaster)]
|
||||||
public StatueType StatueType
|
private StatueType _statueType;
|
||||||
|
|
||||||
|
private void OnStatueTypeChanged(StatueType oldValue, StatueType newValue)
|
||||||
{
|
{
|
||||||
get => _statueType;
|
InvalidateHue();
|
||||||
set
|
|
||||||
{
|
|
||||||
_statueType = value;
|
|
||||||
InvalidateHue();
|
|
||||||
this.MarkDirty();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public override void OnDoubleClick(Mobile from)
|
public override void OnDoubleClick(Mobile from)
|
||||||
|
|
|
||||||
|
|
@ -5,102 +5,121 @@ using Server.Mobiles;
|
||||||
namespace Server.Engines.Virtues;
|
namespace Server.Engines.Virtues;
|
||||||
|
|
||||||
[PropertyObject]
|
[PropertyObject]
|
||||||
[SerializationGenerator(0)]
|
[SerializationGenerator(1)]
|
||||||
public partial class VirtueContext
|
public partial class VirtueContext
|
||||||
{
|
{
|
||||||
[DeltaDateTime]
|
private void MigrateFrom(V0Content content)
|
||||||
|
{
|
||||||
|
// Save-flagged values arrive as nullables; unset flags fall back to the same
|
||||||
|
// defaults the old deserialize left in place.
|
||||||
|
_lastSacrificeGain = content.LastSacrificeGain ?? default;
|
||||||
|
_lastSacrificeLoss = content.LastSacrificeLoss ?? default;
|
||||||
|
_availableResurrects = content.AvailableResurrects ?? 0;
|
||||||
|
_lastJusticeLoss = content.LastJusticeLoss ?? default;
|
||||||
|
_lastCompassionLoss = content.LastCompassionLoss ?? default;
|
||||||
|
_nextCompassionDay = content.NextCompassionDay ?? default;
|
||||||
|
_compassionGains = content.CompassionGains ?? 0;
|
||||||
|
_lastValorLoss = content.LastValorLoss ?? default;
|
||||||
|
_lastHonorUse = content.LastHonorUse ?? default;
|
||||||
|
_honorActive = content.HonorActive;
|
||||||
|
_justiceProtection = content.JusticeProtection;
|
||||||
|
_justiceStatus = content.JusticeStatus ?? JusticeProtectorStatus.None;
|
||||||
|
_values = content.Values;
|
||||||
|
}
|
||||||
|
|
||||||
|
[AnchoredDateTime]
|
||||||
[SerializableField(0)]
|
[SerializableField(0)]
|
||||||
|
[SaveFlag(nameof(ShouldSerializeLastSacrificeGain))]
|
||||||
[SerializedCommandProperty(AccessLevel.GameMaster, AccessLevel.Administrator)]
|
[SerializedCommandProperty(AccessLevel.GameMaster, AccessLevel.Administrator)]
|
||||||
private DateTime _lastSacrificeGain;
|
private DateTime _lastSacrificeGain;
|
||||||
|
|
||||||
[SerializableFieldSaveFlag(0)]
|
|
||||||
private bool ShouldSerializeLastSacrificeGain() => !SacrificeVirtue.CanGain(this);
|
private bool ShouldSerializeLastSacrificeGain() => !SacrificeVirtue.CanGain(this);
|
||||||
|
|
||||||
[DeltaDateTime]
|
[AnchoredDateTime]
|
||||||
[SerializableField(1)]
|
[SerializableField(1)]
|
||||||
|
[SaveFlag(nameof(ShouldSerializeLastSacrificeLoss))]
|
||||||
[SerializedCommandProperty(AccessLevel.GameMaster, AccessLevel.Administrator)]
|
[SerializedCommandProperty(AccessLevel.GameMaster, AccessLevel.Administrator)]
|
||||||
private DateTime _lastSacrificeLoss;
|
private DateTime _lastSacrificeLoss;
|
||||||
|
|
||||||
[SerializableFieldSaveFlag(1)]
|
|
||||||
private bool ShouldSerializeLastSacrificeLoss() => !SacrificeVirtue.CanAtrophy(this);
|
private bool ShouldSerializeLastSacrificeLoss() => !SacrificeVirtue.CanAtrophy(this);
|
||||||
|
|
||||||
[SerializableField(2)]
|
[SerializableField(2)]
|
||||||
|
[SaveFlag(nameof(ShouldSerializeAvailableResurrects))]
|
||||||
[SerializedCommandProperty(AccessLevel.GameMaster)]
|
[SerializedCommandProperty(AccessLevel.GameMaster)]
|
||||||
private int _availableResurrects;
|
private int _availableResurrects;
|
||||||
|
|
||||||
[SerializableFieldSaveFlag(2)]
|
|
||||||
private bool ShouldSerializeAvailableResurrects() => _availableResurrects > 0;
|
private bool ShouldSerializeAvailableResurrects() => _availableResurrects > 0;
|
||||||
|
|
||||||
[DeltaDateTime]
|
[AnchoredDateTime]
|
||||||
[SerializableField(3)]
|
[SerializableField(3)]
|
||||||
|
[SaveFlag(nameof(ShouldSerializeLastJusticeLoss))]
|
||||||
[SerializedCommandProperty(AccessLevel.GameMaster, AccessLevel.Administrator)]
|
[SerializedCommandProperty(AccessLevel.GameMaster, AccessLevel.Administrator)]
|
||||||
private DateTime _lastJusticeLoss;
|
private DateTime _lastJusticeLoss;
|
||||||
|
|
||||||
[SerializableFieldSaveFlag(3)]
|
|
||||||
private bool ShouldSerializeLastJusticeLoss() => !JusticeVirtue.CanAtrophy(this);
|
private bool ShouldSerializeLastJusticeLoss() => !JusticeVirtue.CanAtrophy(this);
|
||||||
|
|
||||||
[DeltaDateTime]
|
[AnchoredDateTime]
|
||||||
[SerializableField(4)]
|
[SerializableField(4)]
|
||||||
|
[SaveFlag(nameof(ShouldSerializeLastCompassionLoss))]
|
||||||
[SerializedCommandProperty(AccessLevel.GameMaster, AccessLevel.Administrator)]
|
[SerializedCommandProperty(AccessLevel.GameMaster, AccessLevel.Administrator)]
|
||||||
private DateTime _lastCompassionLoss;
|
private DateTime _lastCompassionLoss;
|
||||||
|
|
||||||
[SerializableFieldSaveFlag(4)]
|
|
||||||
private bool ShouldSerializeLastCompassionLoss() => !CompassionVirtue.CanAtrophy(this);
|
private bool ShouldSerializeLastCompassionLoss() => !CompassionVirtue.CanAtrophy(this);
|
||||||
|
|
||||||
[DeltaDateTime]
|
[AnchoredDateTime]
|
||||||
[SerializableField(5)]
|
[SerializableField(5)]
|
||||||
|
[SaveFlag(nameof(ShouldSerializeNextCompassionDay))]
|
||||||
[SerializedCommandProperty(AccessLevel.GameMaster)]
|
[SerializedCommandProperty(AccessLevel.GameMaster)]
|
||||||
private DateTime _nextCompassionDay;
|
private DateTime _nextCompassionDay;
|
||||||
|
|
||||||
[SerializableFieldSaveFlag(5)]
|
|
||||||
private bool ShouldSerializeNextCompassionDay() => _nextCompassionDay > Core.Now;
|
private bool ShouldSerializeNextCompassionDay() => _nextCompassionDay > Core.Now;
|
||||||
|
|
||||||
[SerializableField(6)]
|
[SerializableField(6)]
|
||||||
|
[SaveFlag(nameof(ShouldSerializeCompassionGains))]
|
||||||
[SerializedCommandProperty(AccessLevel.GameMaster)]
|
[SerializedCommandProperty(AccessLevel.GameMaster)]
|
||||||
private int _compassionGains;
|
private int _compassionGains;
|
||||||
|
|
||||||
[SerializableFieldSaveFlag(6)]
|
|
||||||
private bool ShouldSerializeCompassionGains() => _compassionGains > 0;
|
private bool ShouldSerializeCompassionGains() => _compassionGains > 0;
|
||||||
|
|
||||||
[DeltaDateTime]
|
[AnchoredDateTime]
|
||||||
[SerializableField(7)]
|
[SerializableField(7)]
|
||||||
|
[SaveFlag(nameof(ShouldSerializeValorLoss))]
|
||||||
[SerializedCommandProperty(AccessLevel.GameMaster, AccessLevel.Administrator)]
|
[SerializedCommandProperty(AccessLevel.GameMaster, AccessLevel.Administrator)]
|
||||||
private DateTime _lastValorLoss;
|
private DateTime _lastValorLoss;
|
||||||
|
|
||||||
[SerializableFieldSaveFlag(7)]
|
|
||||||
private bool ShouldSerializeValorLoss() => !ValorVirtue.CanAtrophy(this);
|
private bool ShouldSerializeValorLoss() => !ValorVirtue.CanAtrophy(this);
|
||||||
|
|
||||||
[DeltaDateTime]
|
[AnchoredDateTime]
|
||||||
[SerializableField(8)]
|
[SerializableField(8)]
|
||||||
|
[SaveFlag(nameof(ShouldSerializeLastHonorUse))]
|
||||||
[SerializedCommandProperty(AccessLevel.GameMaster)]
|
[SerializedCommandProperty(AccessLevel.GameMaster)]
|
||||||
private DateTime _lastHonorUse;
|
private DateTime _lastHonorUse;
|
||||||
|
|
||||||
[SerializableFieldSaveFlag(8)]
|
|
||||||
private bool ShouldSerializeLastHonorUse() => !HonorVirtue.CanUse(this);
|
private bool ShouldSerializeLastHonorUse() => !HonorVirtue.CanUse(this);
|
||||||
|
|
||||||
[SerializableField(9)]
|
[SerializableField(9)]
|
||||||
|
[SaveFlag(nameof(ShouldSerializeHonorActive))]
|
||||||
[SerializedCommandProperty(AccessLevel.GameMaster, AccessLevel.Administrator)]
|
[SerializedCommandProperty(AccessLevel.GameMaster, AccessLevel.Administrator)]
|
||||||
private bool _honorActive;
|
private bool _honorActive;
|
||||||
|
|
||||||
[SerializableFieldSaveFlag(9)]
|
|
||||||
private bool ShouldSerializeHonorActive() => _honorActive;
|
private bool ShouldSerializeHonorActive() => _honorActive;
|
||||||
|
|
||||||
[SerializableField(10)]
|
[SerializableField(10)]
|
||||||
|
[SaveFlag(nameof(ShouldSerializeJusticeProtection))]
|
||||||
private PlayerMobile _justiceProtection;
|
private PlayerMobile _justiceProtection;
|
||||||
|
|
||||||
[SerializableFieldSaveFlag(10)]
|
|
||||||
private bool ShouldSerializeJusticeProtection() => _justiceProtection != null && _justiceStatus != JusticeProtectorStatus.None;
|
private bool ShouldSerializeJusticeProtection() => _justiceProtection != null && _justiceStatus != JusticeProtectorStatus.None;
|
||||||
|
|
||||||
[SerializableField(11)]
|
[SerializableField(11)]
|
||||||
|
[SaveFlag(nameof(ShouldSerializeJusticeStatus))]
|
||||||
private JusticeProtectorStatus _justiceStatus;
|
private JusticeProtectorStatus _justiceStatus;
|
||||||
|
|
||||||
[SerializableFieldSaveFlag(11)]
|
|
||||||
private bool ShouldSerializeJusticeStatus() => _justiceProtection != null && _justiceStatus != JusticeProtectorStatus.None;
|
private bool ShouldSerializeJusticeStatus() => _justiceProtection != null && _justiceStatus != JusticeProtectorStatus.None;
|
||||||
|
|
||||||
[SerializableField(12, setter: "private")]
|
[SerializableField(12, setter: "private")]
|
||||||
|
[SaveFlag(nameof(ShouldSerializeValues))]
|
||||||
private int[] _values;
|
private int[] _values;
|
||||||
|
|
||||||
[SerializableFieldSaveFlag(12)]
|
|
||||||
private bool ShouldSerializeValues()
|
private bool ShouldSerializeValues()
|
||||||
{
|
{
|
||||||
if (_values == null)
|
if (_values == null)
|
||||||
|
|
|
||||||
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