Compare commits

...

16 commits

Author SHA1 Message Date
25a2aa03c5 #W# Update: added Distribution/Data/Files to gitignore.
Some checks are pending
Build / Build (MacOS 15) (push) Waiting to run
Build / Build (MacOS 26) (push) Waiting to run
Build / Build (AlmaLinux 10) (push) Waiting to run
Build / Build (Debian 12) (push) Waiting to run
Build / Build (Debian 13) (push) Waiting to run
Build / Build (Fedora 44) (push) Waiting to run
Build / Build (CentOS 10 Stream) (push) Waiting to run
Build / Build (CentOS 9 Stream) (push) Waiting to run
Build / Build (Ubuntu 26) (push) Waiting to run
Build / Build (Ubuntu 22) (push) Waiting to run
Build / Build (Ubuntu 24) (push) Waiting to run
2026-09-02 10:16:24 -04:00
Kamron Batman
e52d54b7da
perf: keep damage entries in an inline intrusive list (#2605)
## Summary

`Mobile.DamageEntries` was a `List<DamageEntry>` allocated for every mobile, including the ~99% that never take damage. It is now an inline `ValueLinkList<DamageEntry>` (24 bytes in the `Mobile` object, no separate allocation) ordered least recent → most recent.

- `DamageEntry` implements `IValueLinkListNode<DamageEntry>`.
- `RegisterDamage` moves the entry to the tail in O(1) instead of `Remove` + `Add` on a list.
- Expired entries are always a head prefix, so pruning walks from the head and stops at the first live entry. The `DamageEntries` getter prunes on access.
- `DamageEntries` is exposed as `ref readonly`; enumerate with `foreach` or `.ByDescending()`. Mutation goes through `RegisterDamage` / `ClearDamageEntries`.
- `BaseCreature.GetLootingRights` and `BaseCreature.ComputeBonusDamage` take `in ValueLinkList<DamageEntry>`; all callers compile unchanged. Files that `foreach` over `DamageEntries` need `using Server.Collections;` for the enumerator extension.
- RunUO migration docs (`dev-docs/runuo-migration-docs/09`, `11`) and the `migrate-items-mobiles` skill document the change.

Saves one object and 16 bytes per mobile (~8 MB and 500k gen2 objects on a 500k world). Second of three PRs from the lazy per-mobile collections design (first: #2604). Branched from `main`; the two diffs touch disjoint hunks of `Mobile.cs`.

## Breaking change

- `Mobile.DamageEntries` is no longer a `List<DamageEntry>`. Indexing, `.Clear()`, `.Add()`, `.Remove()` no longer compile; use `foreach`, `.ByDescending()`, `.Count`, `ClearDamageEntries()`, and `RegisterDamage`. Calling a `ValueLinkList` mutator on the `ref readonly` property compiles but operates on a copy while still unlinking the real nodes; do not.
- `BaseCreature.GetLootingRights` and `BaseCreature.ComputeBonusDamage` signatures changed to `(in ValueLinkList<DamageEntry>, …)`.

Save format is untouched: damage entries are not serialized.

## Behavior

Recency order, `allowSelf`, tie-breaking in `FindMostTotal`/`FindLeastTotal` (most recent wins), `Responsible` accounting, and loot-rights ordering are unchanged and covered by the new `DamageEntryTests` and `LootingRightsTests`.

## Testing

- `dotnet build -c Release` clean.
- New `DamageEntryTests` and `LootingRightsTests` plus full `Server.Tests` and `UOContent.Tests`.
2026-09-01 23:25:14 -07:00
Kamron Batman
708a354337
perf: stop allocating stat/skill mod lists for every mobile (#2604)
## Summary

`_statMods` and `_skillMods` are created lazily by `AddStatMod` / `AddSkillMod` and nulled when they empty, and every reader already null-checks. The eager `new List<T>()` in `DefaultMobileInit` and `Deserialize` therefore allocated two dead 32-byte objects for every mobile. On a ~500k-mobile world that is ~32 MB and 1M gen2 objects that hold nothing.

- Removes the four eager allocations.
- Removes the `StatMods` accessor (no references).
- Documents `SkillMods` as `null` when no mods are active (its one caller in `Skills.cs` already checks).

First of three PRs from the lazy per-mobile collections design; `DamageEntries` and `Aggressors`/`Aggressed` follow separately.

## Breaking change

- `Mobile.SkillMods` may now be `null` (it was never null after construction before). External callers that enumerate it or read `.Count` must null-check.
- `Mobile.StatMods` is removed. Use `GetStatMod(name)` / `AddStatMod` / `RemoveStatMod`.

Save format is untouched: neither list is serialized.

## Testing

- `dotnet build -c Release` clean.
- New `MobileLazyModListTests` plus full `Server.Tests` (840) and `UOContent.Tests` (756).
2026-09-01 23:23:32 -07:00
Kamron Batman
d3bf283e2d
feat: event-driven target acquisition with a reaction-time gradient (#2601)
Fixes walk-up aggro latency (up to a full 10 s of obliviousness) and hardens the reacquire gate so no state can silence acquisition, while turning `AcquireOnApproach` into the reaction-time knob for future per-creature intelligence tuning.

### Why

`AcquireFocusMob` re-armed the 10 s `ReacquireDelay` **before** scanning, success or failure. A creature that scanned an empty room was blind for 10 s to a player walking up — walk-up aggro latency was uniform in 0..10 s. Waking from sector sleep stacked the AI timer's 0–3 s construction stagger on top. And `NextReacquireTime` is not serialized: on hosts whose tick counter starts negative (GCP pass-through), the 0 default blocked **all** acquisition shard-wide after a restart until the counter crossed zero.

### What

**Event-driven reaction — `AcquireOnApproachDelay` (the intelligence gradient)**
- The paragon `AcquireOnApproach` bool becomes a `TimeSpan` on every creature: an enemy moving inside `AcquireOnApproachRange` (10 for all creatures — on-screen reactive aggro; the periodic scan keeps the wide `RangePerception` sweep) *clamps* the next scan to at most the delay. Repeated steps cannot shorten it further — one scan per delay period, not per step or think.
- `Zero` (paragons) also prods the AI timer: the ranked scan engages within a wheel turn — the old snap, minus the special-cased engage path. The target now comes from the normal FightMode ranking instead of whichever mobile happened to move, and the `Combatant == null` guard stops re-engage spam.
- The 2 s default reads as "took a beat to notice you"; larger values are dumber; `ReacquireDelay` alone is the oblivious floor. Mover checks are the approach logic's `IsEnemy` + `CanBeHarmful` (so pets count and hidden movers are excluded via `CanSee`), with `IsEnemy` first to cheaply reject same-team wild creatures wandering past. The check rides the `OnMovement` callback every step already pays for — no polling added.

**Gate correctness**
- Every scan re-arms the full `ReacquireDelay`, success or failure (classic semantics; reaction time is the approach path, not the poll).
- Self-healing by construction: a deadline further out than `ReacquireDelay` is an illegal state and reads as open — no wedged or wrapped value can silence acquisition beyond one delay period.
- `NextReacquireTime` is seeded from a live tick on deserialize (the GCP negative-tick blackout).

**AI timer wake**
- Activation (sector wake, spawn, resurrection) starts within a 0–256 ms spread instead of the 0–3 s construction stagger, which read as lag.
- The stagger's real job — keeping same-speed cohorts out of lock-step (the RunUO town artifact) — is now a zero-mean ±period/8 jitter on each **idle** think, so phases random-walk apart within seconds and can never re-lock. Instrumentation showed why a one-shot spread can't do this job: the timer wheel fires within ±1 ms, so with 10 creatures on a 500 ms period some pair collides on nearly the same phase ~75% of the time (birthday paradox) and then steps in the same loop iteration *forever*. Jitter is scoped to passive speed: engaged cadence stays exact, since pursuit timing anchors to real step times.

**Debug**
- The `AcquireFocusMob` scan message no longer re-arms the shared 5 s debug cooldown, which swallowed every AI's "I have detected X" transition line.

**API change** for custom scripts: `AcquireOnApproach` (bool) → `AcquireOnApproachDelay` (TimeSpan). Documented in `content-patterns.md` § Target Acquisition, `runuo-migration-docs/09` + `11`, and the migration skill checklist.

### Tests

`AcquisitionTests`: both scan outcomes honor `ReacquireDelay`; a 60 s-wedged gate still acquires; enemy movement clamps the deadline (same-team wild movers and out-of-range movers ignored); repeated movement cannot shorten below the delay; `Zero` opens the gate and prods without a direct engage. Full suite: 755 UOContent green.
2026-09-01 20:42:15 -07:00
Kamron Batman
547c2ea0fa
fix: Fixes tick count wrap-around in movement throttle, and eliminates more allocations in NetState (#2603)
## Summary

Removes the per-tick allocation in the movement throttle, fixes tick-count wrap-around bugs in the throttle and RTT probe state, and trims per-connection allocations and dead fields in `NetState`.

## Movement throttle

- **No more per-tick `List<NetState>` snapshot.** `ProcessAllQueues()` iterates the `HashSet` directly and removes drained or disconnected states in place. `HashSet<T>.Remove` does not invalidate enumerators on .NET Core 3.0+ (verified on 10.0.11); only inserting a *new* member does, and the only `Add` is in the packet handler, which never nests with `Slice()`. The eager `Remove` calls in `RejectAndReset`, `ClearQueue`, and `ProcessMovementQueue` are gone; membership is reconciled once per tick from `_hasQueuedMovements`.
- **Debug logging** is now gated solely by the per-connection `NetState.MovementLogging` flag. The global `movementThrottle.debugLogging` setting is removed.
- **New settings**: `movementThrottle.maxRttBonus`, `movementThrottle.maxChainGap`, and `movementThrottle.speedHackNotificationCooldown` were fields with no config binding.

## Tick-count wrap-around

All comparisons are now in subtraction form and no tick field uses zero as a sentinel:

- `now < _nextMovementTime` in the queue drain loop → `now - _nextMovementTime < 0`.
- `_lastMovementRecordTime > 0`, `_lastSpeedHackNotification`, `_rttProbeTime > 0`, and `_nextRttProbe == 0` sentinels replaced with `_hasMovementRecord`, `_speedHackNotified`, `_rttProbePending`, and a seeded `_nextRttProbe`.
- `_lastQueueDepthCheck` and `_movementWindowStart` are seeded from `Core.TickCount` at construction and on reset instead of zero.

User-visible effects of the old code: on hosts with pass-through counters (GCP) movement history never recorded and speed hack detection was silently off; on every host, staff speed hack notifications were suppressed until `Core.TickCount` exceeded the five-minute cooldown.

## NetState

- `Instances` returns `HashSet<NetState>` again so engine-internal `foreach` uses the struct enumerator instead of boxing through `IReadOnlySet<T>`.
- Removed `_sustainedQueueDepth` (declared and zeroed since #2266, never read), `_lastRtt` (now derived as `LastRtt` from the newest history slot), and `_rttProbeTimestampHiRes` (only fed one debug log line). 20 bytes per connection.
- `HuePickers`, `Menus`, and `Trades` are lazily created instead of allocating three lists per connection, including every login-server connection that dies on shard select. `Trades` is released when it empties. All helpers and the `HuePickerResponse` / `MenuResponse` handlers are null-tolerant; the trade cancel loops keep their `i < Count` guards because `SecureTrade.Cancel()` runs virtual item hooks that can re-enter the same list.

## Testing

- `dotnet build -c Release` clean.
- All MovementThrottle tests pass (27), plus the Trade / Menu / HuePicker / NetState tests (32).
2026-09-01 20:25:20 -07:00
Sergi Rosell
c9875e7f64
fix: delete the bonus item, not the primary yield, when the bonus cannot be placed (#2602) 2026-08-31 08:46:20 -07:00
Kamron Batman
e07416902a
feat: derive the Running bit from the step pace and fix step-pacing bursts (#2599)
Stacked on #2594. Fixes jerky creature movement (lich / Fast-bucket melee chases) by choosing the client animation flag from the actual step pace instead of a caller-supplied `run` argument, and fixes three step-pacing defects in the move budget found while verifying it with paired server/client traces.

### Why

The `Direction.Running` bit does nothing for creatures server-side (`Mobile.OnMove` reads it only for the player throttle and stealth reveal). Its whole effect is on the client, which animates each step over a fixed time selected by that bit: walk 400 ms / run 200 ms on foot, 200 / 100 ms mounted. ClassicUO queues up to 5 steps and *drops* the sixth, so a creature stepping every 300 ms while flagged as walking backs the queue up until it snaps forward — the observed jerk.

The `run` argument never carried the one fact that matters (the step interval). RunUO passed `true` in combat / `false` for pets and gated it on `dist > 5`; #2271 flipped every combat site to `false`; pets passed `currentDistance > 2`. None of that is a coherent signal.

### What

**Pace-derived run flag**
- `BaseAI.ShouldRun()`: run iff the effective step delay (move clock + badly-hurt inflation) is shorter than `Movement.WalkFootDelay` / `WalkMountDelay` (mounted or flying) — with a continuity rule: an *isolated* step (taken after standing at least a walk interval) goes out as a walk, because the client renders each step alone and a lone run-flagged step is a 200 ms dart. Only a continuing cadence flags run; a true sprinter (pace under the run interpolation) always runs, since a walk-rendered first step would flood the client's 5-step queue. This reproduces RunUO's close-in feel (its `dist > 5` gate) from first principles.
- `DoMoveImpl` stamps the bit; it is the single place the flag is set.
- `run` removed from `MoveTo`, `WalkMobileRange`, `ApproachTarget`, `MoveToPoint`, `MoveToWithGroup`, `MoveToWithCollisionAvoidance`, the move intent, and `PathFollower.Follow`. All 35 call sites updated. **API change** for custom scripts — documented in the RunUO migration docs (`09-items-mobiles-creatures.md`, `11-api-reference.md`) and `content-patterns.md` § Creature Speeds.

**Move-budget pacing fixes** (each confirmed by UTC-aligned server/client step traces)
- A stall no longer banks catch-up steps: the budget's snap-to-now released up to three steps in ~300 ms when a creature resumed chasing after standing beside its target — rendered as a teleport.
- Debt accrual removed entirely: a step landing sub-period late (think-grid vs budget misalignment during reactive mirroring) kept the remainder and fired a follow-up ~100 ms later — a dart pair. `ConsumeMoveBudget` now paces every step from when it was actually taken; in continuous pursuit the move-wake lands within wheel resolution of the deadline, so the cost is single-digit-ms drift.
- Net effect: a creature can never step faster than its pace, verified across a full chase session (zero sub-pace steps; metronomic 350 ms cadence for a 0.3 s lich).

- Test fixture now runs `Movement.Configure()` (the walk delays were 0 in tests).

### Accepted trade-off

Animal (LOW group) bodies without a run animation slide on their stand frames when flagged as running. Most are slow enough to stay flagged as walking; the client-side fallback is in ClassicUO/ClassicUO#1930.

### Tests

`RunFlagTests`: foot thresholds (0.3 / 0.125 run; 0.4 / 0.45 / 1.05 walk), flying uses the mount threshold, badly-hurt inflation flips a 0.35 s creature back to walk, a real `DoMove` stamps the bit, isolated steps drop to walk (sprinters keep running), a stall restarts the cadence with no banked steps, and a late step earns no quicker follow-up. Full suite: 837 Server + 747 UOContent green.
2026-08-30 16:48:52 -07:00
Kamron Batman
4420872b22
fix: pet obedience pacing, stale AI wake rescheduling, and Guard order persistence through combat (#2594)
Closes #2593. Closes #2595.

Two related pet-AI fixes: the post-#2591 pacing/wake regression (#2593), and the guard order silently converting to Attack during combat (#2595). Root-cause analyses are in the issues.

## #2593 — pets follow slowly; stale AITimer wakes

**Why pets slowed:**
- The per-step budget grew from **half a think interval** (`CurrentSpeed * 500`) to the full RunUO-parity move table (`CurrentMoveSpeed * 1000`). Medium-bucket pets (Horse, Dog, most tamables): passiveMove **1.05s/step**.
- Pet order speed depended on stale `Warmode`: `HandleGuardOrder` set it once, but `OnCombatantChange` clears it whenever the combatant drops, so obedience ran active or passive **by combat history** — usually passive. Net: Guard/Come at ~1.05s/step (~2.1x slower than pre-#2591), vs a player running at 0.1–0.2s/step.
- The AITimer never rescheduled its pending wheel entry: the wheel reads `Interval` only after the next fire, so a speed-up or a fresh order (`Activate()` no-ops while running) waited out the stale wake — up to a full passive think, stacked on the residual move budget on Guard → Follow.

**What changed:**
- **Order handlers own obedience speed** (RunUO `OnCurrentOrderChanged`/`DoOrder*` parity, re-derived continuously): issuing a movement order (Come/Follow/Guard/Attack) sets the **active** think clock, resting orders (Stay/None/Transfer) set passive, and the guard/follow peaceful branches write **RunUO's AOS `CurrentSpeed = 0.1` sprint** — RunUO's guard else-branch had the identical write as follow. The bespoke 0.1 fuses to both clocks through #2591's existing classification, so `CurrentMoveSpeed` stays **pure herding + classification** with no obedience special case, and `DoMoveImpl`'s per-step flip skips obeying pets (their handler owns the pace) and loses its old follow-only 0.1 write. Combat still re-derives organically via warmode/combatant.
- **`AITimer`**: tracks the pending wake and reschedules (`Stop`, `Delay` = remaining, `Start`) when a speed-up or fresh order moves the earliest deadline up; changes inside a tick still flow through `ScheduleNext`. New `Prod()` wakes the AI immediately on player commands — including from a stopped timer, so stable claims no longer wait out the random construction stagger. Sector/spawn wakes keep the stagger. Spam-safe: a prodded think grants reaction, never action — steps/swings/casts/abilities are gated by their own budgets and timers.

The residual move budget is deliberately **not** cleared on order change — that would let order-spam macros grant free steps. Deadline changes reschedule the timer; rate changes take effect at the next deadline computation.

## #2595 — Guard order converts to Attack during combat

**Why:** `FindCombatant()` set `ControlOrder = OrderType.Attack` when engaging, so a guarding pet left the Guard order for the whole fight: OPL tags wiped (pet `1080078` + master `501129`), no retargeting (`DoOrderAttack` locks its target), `TeleportPets` left the pet behind on recall/gate, and every engage→kill→resume cycle replayed the guard flourish.

**What changed:**
- **`FindGuardTarget()`** (was `FindCombatant`): a pure selector — prefers the aggressor **closest to the master** (RunUO guard parity, dynamic retargeting to protect the owner), keeps the current combatant unless a strictly closer one exists, and never mutates order state. `DoOrderGuard` engages through it while **staying in Guard** the whole fight.
- **Persistent-order semantics** (the ModernUO improvement over RunUO): an explicit `all attack` completes → `ResumePersistentOrder()` returns to Guard → the guard scan engages remaining threats in-order. The Attack-chaining fallback (`FightMode.Closest/Aggressor`) now applies only to non-guard persistent orders. Resuming Guard no longer replays the sound/"is now guarding you" message.
- **Peaceful guard stands down deterministically** (`Warmode`/`Combatant`/`FocusMob` cleared) and returns to the master at the RunUO sprint (see above); at the master's side it stays organically active.
- **`WalkMobileRange` honors the caller's run flag** (the internal hardcoded `dist > 5` gate silently overrode it). Run is animation-only server-side; the only callers passing anything but `false` — follow, guard, clone — gate on their own thresholds.

## Resulting behavior (Medium-bucket pet)

| Scenario | Broken | This PR |
|---|---|---|
| Guard trailing master (AOS) | ~1.05s/step, think-grid quantized | 0.1s/step sprint (RunUO parity), smooth move wakes |
| Guard during combat | order flips to Attack; tags lost; no retarget; left behind on recall | stays Guard; retargets to master's closest aggressor; teleports with master |
| `all attack` while guarding | resume spams guard flourish per kill; chains into Attack | resumes Guard silently; guard scan takes over |
| Come / friend-follow | 1.05s/step | activeMove 0.45s/step (≈ pre-#2591 feel) |
| Guard → Follow reaction | up to ~1.5s dead time | think within one wheel turn |
| Follow master (AOS sprint) | 0.1s/step | 0.1s/step (unchanged) |
| Wild creature chase | RunUO-parity move table | unchanged |

Also documents two contracts this work leaned on: the `ControlOrder` setter deliberately fires on every assignment (a reissued order is a command — retarget/break-off/re-anchor), and `OnThink`/`MonsterAbility` must be excess-call tolerant (`dev-docs/content-patterns.md` § OnThink: the excess-call contract).

## Testing

- Full suite passes (1570: 837 Server + 733 UOContent).
- `PetPacingTests`: order-issue think-clock parity, follow-master sprint via Obey, guard organically active at the master's side, combat-chase and herding boundaries, plus two deterministic timer-wheel tests (8ms-lockstep slicing) proving a fresh order and a mid-wait speed-up wake the AI promptly.
- `GuardOrderTests`: engage keeps the Guard order; retargets to the aggressor closest to the master; explicit attack resumes Guard without chaining into Attack; peaceful guard stands down. Setup self-validates LOS/terrain.
- `GuardFollowTests`: guard-following registers a move intent, steps toward the master, sprints at 0.1 under AOS (per-step flip must not undo it), and runs active pre-AOS.
- All behavioral tests were written first and failed for the documented reasons.
2026-08-30 16:39:29 -07:00
Tald0r
38c74a968b
fix(regions): correct end Z coordinate assignment in InitRectangles (#2597)
The `ez` variable was incorrectly assigned `rect.End.X` instead of `rect.End.Z`, causing incorrect rectangle processing in region initialization.
2026-08-27 06:51:42 -07:00
Kamron Batman
e7f85d404d
feat: Adds independent think/move clocks for creature AI to fix speed (#2591)
Splits creature speed into two clocks so movement pace can be tuned without touching reaction time:

- **Think clock** — `ActiveSpeed`/`PassiveSpeed`/`CurrentSpeed`: seconds per AI decision. Unchanged in meaning, storage, and cadence.
- **Move clock** — `ActiveMoveSpeed`/`PassiveMoveSpeed` (+ resolved `CurrentMoveSpeed`): seconds per step. `0` = inherit the matching think value.

### How

- Move speeds come from optional `activeMove`/`passiveMove` in `npc-speeds.json`, are `[props`-tunable per instance (set `0` to re-inherit), and serialize (BaseCreature v22).
- `SetSpeed()` keeps its legacy one-clock semantics — sets the think clock **and clears move overrides** — so existing callers cannot half-configure a creature. `SetMoveSpeed()`/`ClearMoveSpeed()` configure movement explicitly; `ScaleMoveSpeed()` scales overrides for buffs.
- `CurrentMoveSpeed` is derived by classifying `CurrentSpeed`: a verbatim active/passive think value maps to the matching move value; a bespoke pace written directly (mount boosts, follow sprint) stays fused to both clocks. External `CurrentSpeed` writers need no changes.
- `AITimer` schedules the earlier of the two deadlines. Decisions run at the think cadence exactly as before; while a pursuit/investigation is live, the timer also wakes when the movement budget elapses and advances one step with no decisions. Steps no longer snap to the think grid, so any step delay paces smoothly on the 8ms wheel. A blocked creature schedules no move wakes.
- The movement budget is RunUO's `m_NextMove` accumulate-and-clamp at a full step, so long-run pacing averages `CurrentMoveSpeed` exactly.

### Behavior changes

- **`npc-speeds.json` buckets get RunUO `TransformMoveDelay`-parity move values**: creatures step at RunUO pace while thinking/reacting at current speed. The situational +0.1/+0.2 offsets are deliberately omitted.
- **Existing saves migrate on load**: a pre-v22 creature whose think speeds still match its npc-speeds entry (never hand-tuned) adopts the table's move values — worlds and pets pick up the new pacing without a respawn. Tuned creatures keep movement inheriting their think clock.
- **Paragons scale movement by `SpeedBuff` (1.2x)**: RunUO had no deliberate policy here — dividing by 1.2 knocked most speeds off `TransformMoveDelay`'s exact-equality table (raw pass-through, 2x+ faster), while 0.3/0.6 creatures landed back on it for ~1.33x. This applies the uniform 1.2x the buff always claimed. UnConvert snaps speeds back to exact table values within 1e-4 — /1.2 then ×1.2 drifts 0.45 and 0.9 by an ulp, which would read as hand-tuned (and defeat a future skip-table-conformant-values serialization pass); tuned speeds keep.
- **Herding paces the movement clock**: the old `CurrentSpeed` getter hack is gone. A herded creature walks at a fixed 0.3s/step — RunUO's forced pace, without its `TransformMoveDelay` inflation to 0.6 — so herding is never penalized by a slow creature. Thinking is untouched, and `CheckHerding` walks through `MoveToPoint`, so herded creatures path around obstacles.
- **Badly-hurt slowdown now inflates the step delay only** (RunUO parity), computed from the base each step. Previously it wrote `CurrentSpeed = CurrentSpeed + 0.05..0.15` back on every successful step — compounding unboundedly while hurt and slowing decisions too.
- Removes the vestigial `MoveSpeedMod` (never read, written, or serialized).
- With no bucket or per-instance move values, both clocks carry identical values and creatures pace as before.

### Testing

- Full suite passes (1557, including 12 new `MoveSpeedTests`: resolution classes, `SetSpeed` clearing, `0`-re-inherit, v22 round-trip with exact-consumption check, save migration adopt/skip, buff scale/snap, herding).
- In-game verified via local diagnostics build (per-step budget tracing): steady 700ms step cadence on a 0.3s think grid with one-step catch-up after idle, think grid unperturbed by move wakes.
2026-08-23 10:19:59 -07:00
Kamron Batman
8e39da2810
fix: creatures track and chase targets reliably around corners (#2590)
### Summary

Fixes the long-standing reports of monsters losing track of players who run around a corner ("Is monster AI not using pathfinding? It seems to be LOS blocked by statics"). Root-cause investigation compared current behavior against RunUO line-by-line and traced the regressions through the AI overhaul era (#2232, #2246, #2379, #2401, #2461).

### Root causes and fixes

1. **Movement contract** — `MoveTo`/`ApproachTarget` returned false on every healthy mid-chase tick (true only on arrival), so MeleeAI's RunUO-inherited *"move failed and beyond RangePerception+1 → Guard"* clause — which RunUO only evaluated on genuine blockage — fired **every tick of every chase**. A mounted player trivially opens 17 tiles at a corner, the monster guards, Guard nulls the combatant, and re-acquisition is LOS-gated — unrecoverable through a wall. Movement now reports failure only on genuine failure (no step taken with no working path, or approach give-up). ArcherAI's equivalent clause moves to the hard leash.

2. **Last-known-position pursuit** — while a combatant is in LOS its position is recorded each think tick. When the target vanishes (corner, hiding, recall), the creature walks to the last-seen spot, stands guard there ~10s (restoring RunUO's guard grace, which had decayed to a single tick since #2246), and **re-engages instantly** if the same target re-enters view — bypassing the 10s reacquire throttle.

3. **`ChaseLeashRange`** — new virtual on BaseCreature (default `RangePerception * 2` = 32 tiles) replaces the inline `RangePerception * 3` (48) in Melee/Mage/Archer AI. Per-creature tunable via `[props`.

4. **Group movement demoted to a crowding refinement** — previously any uncontrolled creature with one ally within 8 tiles on the same target used greedy ring-stepping for the *entire* chase, with wall-slides counted as success, never invoking the pathfinder — the "aggroed but won't come around the corner" symptom for spawn groups. It now engages only near the target when allies actually contest the ring, and blocked/wall-slid steps escalate to the pathfinding approach primitive.

5. **Mages close distance on broken LOS** — a mage within casting range but LOS-blocked by geometry stood at the wall holding a spell target until the 60s combatant expiry (ProcessTarget short-circuits Think and its RunTo stands off at RangeFight). Geometry-blocked mages now close in until LOS returns, both pre-cast and while holding a target. Hidden targets (CanSee) and poison-cure priority unchanged. The new movement contract also stops the constant spurious `OnFailedMove` teleport rolls mid-chase.

6. **Move budget: one actual step per AI tick** — nothing advanced `NextMove` on a normal step (RunUO's `m_NextMove` budget was lost), so code paths attempting several moves in one think tick could cross multiple tiles at once — visible as "warping" when crowded creatures jockey for position. A successful step now consumes a half-step budget (floor 50ms): blocks intra-tick double moves, stays safely below the timer interval so legitimate next-tick moves are never jitter-throttled, and does not reintroduce `TransformMoveDelay` inflation. Blocked attempts consume nothing, so retry ladders (repath-and-step, the collision fan) are unaffected. `CanMoveNow` is also wraparound-safe now.

### Reference behavior

RunUO requires LOS to *acquire* a target and to *land* a hit or spell — never to *continue* a chase (its MeleeAI LOS bail-out is literally commented out in stock code). Chases drop only on: target hidden, target dead/off-map, beyond `RangePerception * 3`, 60s without combat interaction, or blocked movement while far away. This PR restores those semantics while adding the last-known-position investigation on top. NPC run flags are untouched — pace is AI-timer-driven and most NPC art has no run animation.
2026-08-23 01:13:00 -07:00
Kamron Batman
2935eafe24
feat: convert all delta-time serialization to anchored time (#2589)
## Summary

Phase 3 of the anchored-time work: **every actively-written delta-time value in the engine now stores an anchored timestamp** — absolute on the wire, shifted forward by the downtime at load. Remaining time survives restarts (as delta did), and unlike delta, the bytes do not change on every save, so an idle world serializes identically save after save.

The answer to "is it possible everywhere": **yes** — including the one case that looked impossible.

## The GenericPersistence problem, solved

`GenericPersistence` bins (`Virtues.bin`, `StealableArtifacts.bin`, …) are raw payloads with no idx header, so they have no anchor of their own — anchored reads there would silently apply zero shift. But the anchor is a property of the **save**, not the file: every file in one save shares one `World.SaveStartTime`, and `Persistence.Load` reads **all** entity indexes (phase 1) before **any** persistence payload (phase 2). So the idx v5 header stamps a save-wide `World.LoadTimeShift`, and generic persistence readers inherit it. No file-format change, no per-bin header, old bins unaffected.

## Converted

- **Item v10 → v11**: `LastMoved` — previously whole-minute delta, rewritten every save for every item, the single largest source of idle-save churn — and `DecayResetTime` (retiring the TODO from #2583). **Mobile v37 → v38**: the three stat-gain stamps. **BaseCreature v20 → v21**: `SummonEnd`.
- **17 code-generated classes** (`[DeltaDateTime]` → `[AnchoredDateTime]`, version bump + `MigrateFrom` each): the five field spells, TransientItem, VirtueContext (×7 fields), PuzzleChestSolutionAndTime, BaseCamp, BaseBoat, RentedVendor, PlayerVendor, Ethics Player, Sheep, StarRoomGate, ChampionSpawn (×3), Corpse (`TimeOfDeath`, v19). The `MigrateFrom` bodies were generated from each class's current migration schema and are compiler-verified; VirtueContext's save-flagged nullables fall back to the same defaults the old deserialize left in place. Corpse's six migrations moved to a new `Corpse.Migrations.cs`.
- **Hand-written sites**: StealableArtifacts (v2), VendorInventory (v1), ML quest objectives (persistence v3) — each gated on its own version.

**Not converted, deliberately**: the ~25 read-only `ReadDeltaTime` sites in legacy version fallbacks and migration replays — they decode existing old bytes and must never change. `[DeltaDateTime]`/`WriteDeltaTime` remain available for them.

## Verification

- Build 0 errors / 0 warnings; **837 + 708 tests green**.
- Schema regeneration produced exactly the 17 expected new `vN.json` files (all `AnchoredTime` rule args), nothing else touched.
- **New acceptance tests** pin the point of the whole effort: serializing the same item at two save times **5 hours apart produces byte-identical output**, and `LastMoved`/`DecayResetTime` round-trip **exactly** at sub-minute precision (the old minutes encoding destroyed both properties).

## Notes for review

- `LastMoved` grows from a 1–3 byte encoded minutes value to 8-byte ticks per item — the price of byte-stability; it repays itself in incremental-save behavior since unchanged items now produce unchanged bytes.
- BaseEscortable-style semantics are unchanged: anchored shift preserves *remaining* time exactly, the same contract delta provided, so no gameplay-visible behavior changes — deadlines simply stop being consumed by downtime that delta already protected against, now with stable bytes.

## Enforcement

`WriteDeltaTime` is now `[Obsolete]` (interface + implementation). With the repo's warnings-as-errors, any new delta-time write — hand-written or emitted by a still-unconverted `[DeltaDateTime]` field — fails the build, with the migration instructions in the message. That the full solution still builds with **zero warnings** is itself the proof no active delta writer survived the conversion. `ReadDeltaTime` deliberately stays un-attributed: its remaining callers decode existing old bytes and are correct forever; its XML docs now state the legacy-decode-only contract.
2026-08-22 19:34:43 -07:00
Kamron Batman
b992c7b955
docs: update serialization docs and skills for generator v4 (#2588)
## Summary

Brings every serialization-related doc, skill, and the CLAUDE.md rule in line with generator **v4** (adopted in #2586/#2587). No code changes.

**Updated surface, everywhere it was referenced:**
- `[SerializableFieldSaveFlag(order)]` / `[SerializableFieldDefault(order)]` → `[SaveFlag(nameof(Should), nameof(Default))]` on the field (second method optional).
- `[TimerDrift]` + `[DeserializeTimerField(order)]` → `[DeserializeTimer(nameof(Method), wallClock)]` on the field, with the anchored-time semantics spelled out: drifting by default (downtime preserves the remaining delay, idle saves byte-stable), `wallClock: true` for absolute deadlines, restart method invoked **only when a timer was running** (no sentinel), and the timer `MigrateFrom` pattern (`XxxNext`/`XxxDelay`) for wire-format changes.
- New `[SerializableField]` documentation: the real signature (the documented `saveIf` parameter never existed) plus the setter hooks — `allowFieldChange` (`bool Method(ref T value)`: coerce/veto before assignment) and `fieldChanged` (`void Method(T oldValue, T newValue)` after) — with the generated pipeline and the SG3015/SG3018 guardrails.
- `[SerializableProperty]` guidance narrowed to its remaining purpose: custom getters and setter semantics the hooks cannot express.
- `[AnchoredDateTime]` documented alongside `[DeltaDateTime]` (now marked legacy, with the version-bump warning for converting between them).

**Files:** `dev-docs/serialization.md`, `dev-docs/timers.md`, `dev-docs/claude-skills/modernuo-serialization.md`, `dev-docs/claude-skills/modernuo-timers.md`, `dev-docs/runuo-migration-docs/02-serialization.md`, `dev-docs/runuo-migration-docs/03-timers.md`, and a condensed v4 addition to CLAUDE.md rule 9.

**Example refresh:** the skill's `BagOfSending` "custom properties" example was itself converted in #2587 — it is now quoted in its real post-conversion form as the canonical hooks example; the real-examples list points at `BaseWeapon.cs` for custom getters and `BaseLight.cs` for the drifting-timer + `MigrateFrom` pattern.

Verified by grep: zero references to the removed v3 attribute names remain anywhere in `dev-docs/` or `CLAUDE.md`.
2026-08-22 18:29:27 -07:00
Kamron Batman
b042edcf0b
refactor: fold hand-written serializable property setters into field hooks (#2587)
## Summary

Folds **113** hand-written `[SerializableProperty]` members into plain `[SerializableField]` declarations using the v4 setter hooks — value coercion/vetoes via `allowFieldChange`, post-change side effects via `fieldChanged` (whose `oldValue` parameter covers the old-house/old-sender unsubscribe patterns). Net **-450 lines** of setter boilerplate.

```cs
// before
[SerializableProperty(1)]
[CommandProperty(AccessLevel.GameMaster)]
public int Charges
{
    get => _charges;
    set
    {
        _charges = Math.Clamp(value, 0, MaxCharges);
        InvalidateProperties();
        this.MarkDirty();
    }
}

// after
[SerializableField(1, allowFieldChange: nameof(AllowChargesChange))]
[SerializedCommandProperty(AccessLevel.GameMaster)]
[InvalidateProperties]
private int _charges;

private bool AllowChargesChange(ref int value)
{
    value = Math.Clamp(value, 0, MaxCharges);
    return true;
}
```

## How sites were selected

A classifier parsed all 204 `[SerializableProperty]` sites and converted only those matching strict shapes: getter is exactly `get => _field;`, the assignment comes first (after at most an equality guard), and relocated side effects contain no `return`, no `value` mutation, and no field re-assignment. Everything else was left alone deliberately:

- **~34 custom getters** (fallback defaults like `_x == -1 ? Default : _x`, self-healing refs) — no setter hook can express these.
- **~35 pre-assignment logic** (durability Unscale/Scale sandwiches, old-state captures like PotionKeg's pile weight).
- **virtual/override members, name-mismatched backing fields (`m_`), exotic semantics** (guards' `Focus` does work on *equal* assignment; `ChampionSpawn.Active` never assigns its field).

Five sites the classifier refused were converted by hand where the hooks fit cleanly: `ReceiverCrystal.Sender`, `PlayerVendor.House`, `PlayerBarkeeper.House` (old-value unsubscribe via `oldValue`), `BaseSuit.AccessLevel` (its existing virtual `OnAccessLevelChanged` already had the exact callback shape), and `DyeTub.DyedHue` (a true veto: `AllowDyedHueChange(ref int value) => _redyable`).

## Verification

- Build: **0 errors, 0 warnings**.
- **Schema regeneration produces zero Migrations changes** — the conversion is wire- and schema-neutral by construction (same orders, types, and property names), and CI's schema diff check enforces it.
- **835 + 708 tests green.**

## Behavioral notes (all strict improvements, called out for review)

- Generated setters skip everything when the incoming value equals the current one; a few converted setters previously re-ran side effects on equal assignment (redundant `Update()`-style refreshes).
- Generated setters always `MarkDirty()` on change; several converted setters never did (e.g. `DyeTub.DyedHue`, `MorphItem` ranges) — their changes only persisted if something else dirtied the entity. Those latent persistence bugs are fixed by construction.
2026-08-22 18:20:39 -07:00
Kamron Batman
73f9688083
feat: adopt serialization generator v4 (field-side linkage, anchored timers) (#2586)
## Summary

Adopts ModernUO.Serialization 4.0.0 across the engine. Three commits, reviewable independently:

1. **Package + tool bump to 4.0.0** (`Server.csproj`, `UOContent.csproj`, `dotnet-tools.json`).
2. **Timers → `[DeserializeTimer]`** — the 8 drifting timers (BaseLight, TreasureMapChest, MarkContainer, FillableContainer, DeathRobe, DecayedCorpse, Corpse, BaseEscortable) now store their next tick as **anchored time**: server downtime no longer consumes the remaining delay, and idle-world saves are byte-stable. This changes their wire format, so each class bumps its serialization version with a `MigrateFrom` that replays the old delta-time read through the migration schema (the new `vN.json` files carry `@AnchoredTimer`; the old ones keep `@TimerDrift`, which the generator reads forever). The 2 wall-clock timers (Aquarium, FountainOfLife) keep their exact format via `wallClock: true` — no bump. Restart methods drop their `TimeSpan.MinValue` sentinel checks: v4 invokes them **only when a timer was actually running at save**.
3. **Linkage → field-side declarations** — 175 conversions across 25 files: `[SerializableFieldSaveFlag(order)]`/`[SerializableFieldDefault(order)]` become `[SaveFlag(nameof(...), nameof(...))]` on the field, and `[SerializableFieldChanged(order)]` becomes the `fieldChanged:` argument of `[SerializableField]`. **Wire-neutral: zero migration schemas changed.**

## Verification

- Solution builds with **0 errors, 0 warnings**; all three 4.0.0 packages verified indexed on nuget.org (no local feed needed).
- **835 + 708 tests green.**
- Generated output inspected: old-version content structs replay `ReadDeltaTime` (e.g. `V3Content.DecayTimerNext = reader.ReadDeltaTime()`), current versions write/read anchored time with the gated restart, and the wall-clock classes emit byte-identical `Write`/`ReadDateTime` framing.
- Schema tool run is committed (CI's `git diff --exit-code` schema check passes): exactly the 8 expected new `vN.json` files, nothing else touched.
- The conversion was scripted with a class-scoped resolver (order → same-class `[SerializableField(order)]`/`[SerializableProperty(order)]`); it planned 175/175 with zero ambiguities before applying.

## Notes

- New `MigrateFrom`s use the content structs' provided `XxxDelay` property, matching the pre-existing idiom in Corpse's and TreasureMapChest's older migrations.
- Follow-up candidate (separate PR, wire-neutral, any time): fold the ~150 eligible hand-written `[SerializableProperty]` setters (clamps, post-change side effects) down to `[SerializableField]` with `allowFieldChange`/`fieldChanged` hooks.
2026-08-22 17:54:02 -07:00
Kamron Batman
126a10ce53
feat: anchored-time infrastructure with a save-start anchor in idx v5 (#2585)
## Summary

The save-stability infrastructure consumed by generator v3's `[AnchoredDateTime]`: anchored timestamps are written as **absolute values** and re-based once at load by the elapsed time since the save started — so downtime doesn't age them, and an unchanged entity serializes to identical bytes (the prerequisite for replacing delta-time encodings, which rewrite every entity on every save).

## Design

- **`WriteAnchoredTime` / `ReadAnchoredTime`** on `IGenericWriter`/`IGenericReader`. The read side applies the reader's `AnchoredTimeShift`; `Min/MaxValue` sentinels pass through unshifted, and shifts saturate instead of overflowing.
- **`World.SaveStartTime`** is stamped the moment the world freezes for a snapshot — one anchor for the entire save, no per-persistence skew.
- **idx v5**: the anchor ticks sit in the header right after the version. The anchor travels with the file it re-anchors, so a single idx+bin pair restored from a backup is self-describing, and anchor presence is guaranteed by the same version gate as the record format — there is no separate anchor file to lose.
- **The shift rides the reader instance** (`BufferReader`, `UnmanagedDataReader`, `BinaryFileReader` delegating), not a static — parallel per-persistence loads and ad-hoc restores each see their own file's anchor. idx v4 and older read with a zero shift.

## Scope

Behavior-neutral: nothing serializes anchored values yet (`Item.DecayResetTime` and the `[DeltaDateTime]` field migrations come separately, with their own version bumps). Saves written from this branch are idx v5; loading v4/v3 saves is unchanged and remains pinned by the existing hand-written-header tests.

## Testing

- Unit round-trips: exact with zero shift, shifted read, sentinel passthrough, saturation, Local→UTC normalization.
- End-to-end through the real worker/segment-log pipeline: an anchored timestamp re-bases across a simulated two-hour downtime via the idx v5 header.
- Full suites green: Server.Tests 835/835, UOContent.Tests 708/708 (including the existing v4/v3 idx loading tests).
2026-08-22 15:59:39 -07:00
203 changed files with 6400 additions and 2340 deletions

View file

@ -3,7 +3,7 @@
"isRoot": true,
"tools": {
"modernuoschemagenerator": {
"version": "3.0.0",
"version": "4.0.0",
"commands": [
"ModernUOSchemaGenerator"
]

1
.gitignore vendored
View file

@ -1,4 +1,5 @@
# Distribution Files
/Distribution/Data/Files
/Distribution/Logger
/Distribution/Logger.*
/Distribution/ModernUO

View file

@ -18,7 +18,7 @@ Apply these when writing or reviewing `.cs` files under `Projects/`.
6. **Cancel timers in `OnDelete()`/`OnAfterDelete()`** — call `_token.Cancel()` or `_timer?.Stop()`
7. **`STArrayPool<T>.Shared`** not `ArrayPool<T>.Shared` — single-threaded optimized, no locks
8. **`PooledRefList<T>`** not `new List<T>()` on hot paths — zero GC pressure, stack-allocated ref struct
9. **Serialization** — class must be `partial`, constructor needs `[Constructible]`, `TimerExecutionToken` must NOT have `[SerializableField]`. New classes: use `[SerializationGenerator(version)]` (omit `encoded`). When bumping versions, add `MigrateFrom(VXContent)` (X = previous version). Never modify `Deserialize(reader, version)` for version bumps — that method is only for pre-codegen legacy saves. When migrating from pre-codegen Serialize/Deserialize: pass `false` if old code used `reader.ReadInt()`, bump version +1, and keep old logic as `private void Deserialize(IGenericReader reader, int version)``dev-docs/runuo-migration-docs/02-serialization.md`
9. **Serialization** — class must be `partial`, constructor needs `[Constructible]`, `TimerExecutionToken` must NOT have `[SerializableField]`. New classes: use `[SerializationGenerator(version)]` (omit `encoded`). Setters that coerce/veto/run side effects: use `[SerializableField]` args `allowFieldChange: nameof(BoolRefMethod)` / `fieldChanged: nameof(OldNewMethod)` — reserve `[SerializableProperty]` for custom getters. Serializable `Timer` members declare `[DeserializeTimer(nameof(Method))]` on the field (anchored by default — downtime preserves remaining delay; `wallClock: true` = absolute; method runs only when a timer was running at save). Conditional writes: `[SaveFlag(nameof(Should), nameof(Default))]` on the field. When bumping versions, add `MigrateFrom(VXContent)` (X = previous version). Never modify `Deserialize(reader, version)` for version bumps — that method is only for pre-codegen legacy saves. When migrating from pre-codegen Serialize/Deserialize: pass `false` if old code used `reader.ReadInt()`, bump version +1, and keep old logic as `private void Deserialize(IGenericReader reader, int version)` `dev-docs/serialization.md`, `dev-docs/runuo-migration-docs/02-serialization.md`
10. **No `Task.Run`/`new Thread()` for game logic** (tandem with rule #3) — game logic is the single-threaded event loop. Backgrounding is allowed only for work that does not itself touch game state (external service calls, large-file parse). **Prove the need before adding a thread**: measure **on-loop** time, not wall-clock (frozen world is the cost, player latency is not), and gate on `Environment.ProcessorCount` — off-loading creates no CPU and buys nothing on 12 cores. New workers go in the vetted table in `dev-docs/threading-model.md` with their measurement. When such work must *feed* game logic: run the heavy/I/O part off-loop and `ConfigureAwait(false)` its awaits so a continuation never resumes on the loop and silently foregrounds heavy work; then hand the result back **explicitly** — publish an immutable snapshot swapped via a `volatile` reference (the loop reads it lock-free), or marshal the apply step with `Core.LoopContext.Post(() => …)`, re-validating in the continuation whatever may have changed while it ran. Never touch game state off-thread; never let the scheduler decide where the heavy work runs → `dev-docs/threading-model.md`
11. **Never assume era** — if code uses `Core.AOS`/`Core.SE`/etc., ask which expansion to target
12. **Naming**`_camelCase` private fields, `PascalCase` properties/methods/classes; don't flag legacy `m_` but use `_` for new code

View file

@ -3,12 +3,16 @@
"level": "VerySlow",
"active": 0.4,
"passive": 0.8,
"activeMove": 0.9,
"passiveMove": 1.5,
"types": []
},
{
"level": "Slow",
"active": 0.3,
"passive": 0.6,
"activeMove": 0.6,
"passiveMove": 1.2,
"types": [
"AntLion", "ArcticOgreLord", "BogThing",
"Bogle", "BoneKnight", "EarthElemental",
@ -28,6 +32,8 @@
"level": "Medium",
"active": 0.25,
"passive": 0.5,
"activeMove": 0.45,
"passiveMove": 1.05,
"types": [
"AcidElemental", "AgapiteElemental", "Alligator",
"AncientLich", "Betrayer", "Bird",
@ -108,6 +114,8 @@
"level": "Fast",
"active": 0.2,
"passive": 0.4,
"activeMove": 0.3,
"passiveMove": 0.9,
"types": [
"LordOaks", "Silvani", "AirElemental",
"AncientWyrm", "Balron", "BladeSpirits",
@ -139,6 +147,8 @@
"level": "VeryFast",
"active": 0.125,
"passive": 0.30,
"activeMove": 0.125,
"passiveMove": 0.6,
"types": [
"Barracoon", "Mephitis", "Neira",
"Rikktor", "Semidar", "EnergyVortex",

View file

@ -0,0 +1,311 @@
using System;
using System.Collections.Generic;
using Server.Collections;
using Xunit;
namespace Server.Tests;
[Collection("Sequential Server Tests")]
public class DamageEntryTests
{
private class TestMobile : Mobile
{
}
private class PetMobile : Mobile
{
public Mobile Master { get; set; }
public override Mobile GetDamageMaster(Mobile damagee) => Master;
}
private static List<Mobile> Damagers(Mobile victim)
{
var result = new List<Mobile>();
foreach (var de in victim.DamageEntries)
{
result.Add(de.Damager);
}
return result;
}
[Fact]
public void FreshMobile_HasNoEntries()
{
var m = new TestMobile();
try
{
Assert.Equal(0, m.DamageEntries.Count);
Assert.Null(m.FindMostRecentDamageEntry(true));
Assert.Null(m.FindLeastRecentDamageEntry(true));
Assert.Null(m.FindMostTotalDamageEntry(true));
Assert.Null(m.FindLeastTotalDamageEntry(true));
Assert.Null(m.FindDamageEntryFor(m));
}
finally
{
m.Delete();
}
}
[Fact]
public void RegisterDamage_OrdersLeastRecentToMostRecent()
{
var victim = new TestMobile();
var a = new TestMobile();
var b = new TestMobile();
try
{
victim.RegisterDamage(10, a);
victim.RegisterDamage(20, b);
victim.RegisterDamage(5, a); // a becomes most recent again
Assert.Equal(2, victim.DamageEntries.Count);
Assert.Equal(new[] { b, a }, Damagers(victim));
Assert.Equal(15, victim.FindDamageEntryFor(a).DamageGiven);
Assert.Same(a, victim.FindMostRecentDamager(true));
Assert.Same(b, victim.FindLeastRecentDamager(true));
}
finally
{
victim.Delete();
a.Delete();
b.Delete();
}
}
[Fact]
public void FindRecent_HonorsAllowSelf()
{
var victim = new TestMobile();
var a = new TestMobile();
try
{
victim.RegisterDamage(10, a);
victim.RegisterDamage(10, victim); // self is most recent
Assert.Same(victim, victim.FindMostRecentDamager(true));
Assert.Same(a, victim.FindMostRecentDamager(false));
Assert.Same(a, victim.FindLeastRecentDamager(false));
}
finally
{
victim.Delete();
a.Delete();
}
}
[Fact]
public void FindLeastRecent_HonorsAllowSelf()
{
var victim = new TestMobile();
var a = new TestMobile();
try
{
victim.RegisterDamage(10, victim); // self is least recent, so the head is the one to skip
victim.RegisterDamage(10, a);
Assert.Same(victim, victim.FindLeastRecentDamager(true));
Assert.Same(a, victim.FindLeastRecentDamager(false));
}
finally
{
victim.Delete();
a.Delete();
}
}
[Fact]
public void FindTotal_PicksByDamage_MostRecentWinsTies()
{
var victim = new TestMobile();
var a = new TestMobile();
var b = new TestMobile();
var c = new TestMobile();
try
{
victim.RegisterDamage(30, a);
victim.RegisterDamage(30, b); // ties a; b is more recent
victim.RegisterDamage(1, c);
Assert.Same(b, victim.FindMostTotalDamager(true));
Assert.Same(c, victim.FindLeastTotalDamager(true));
}
finally
{
victim.Delete();
a.Delete();
b.Delete();
c.Delete();
}
}
[Fact]
public void FindLeastTotal_MostRecentWinsTies()
{
var victim = new TestMobile();
var a = new TestMobile();
var b = new TestMobile();
var c = new TestMobile();
try
{
victim.RegisterDamage(30, a);
victim.RegisterDamage(5, b);
victim.RegisterDamage(5, c); // ties b for the minimum; c is more recent
Assert.Same(a, victim.FindMostTotalDamager(true));
Assert.Same(c, victim.FindLeastTotalDamager(true));
}
finally
{
victim.Delete();
a.Delete();
b.Delete();
c.Delete();
}
}
[Fact]
public void Prune_RemovesExpiredPrefix_KeepsOrder()
{
var start = Core._now;
var victim = new TestMobile();
var a = new TestMobile();
var b = new TestMobile();
try
{
victim.RegisterDamage(10, a);
Core._now = start + DamageEntry.ExpireDelay + TimeSpan.FromSeconds(1);
victim.RegisterDamage(10, b); // a is now expired, b is live
Assert.Equal(new[] { b }, Damagers(victim));
Assert.Null(victim.FindDamageEntryFor(a));
}
finally
{
Core._now = start;
victim.Delete();
a.Delete();
b.Delete();
}
}
[Fact]
public void Prune_AllExpired_EmptiesList()
{
var start = Core._now;
var victim = new TestMobile();
var a = new TestMobile();
var b = new TestMobile();
try
{
victim.RegisterDamage(10, a);
victim.RegisterDamage(10, b);
Core._now = start + DamageEntry.ExpireDelay + TimeSpan.FromSeconds(1);
Assert.Equal(0, victim.DamageEntries.Count);
Assert.Null(victim.FindMostRecentDamageEntry(true));
}
finally
{
Core._now = start;
victim.Delete();
a.Delete();
b.Delete();
}
}
[Fact]
public void ClearDamageEntries_UnlinksEveryNode()
{
var victim = new TestMobile();
var a = new TestMobile();
var b = new TestMobile();
try
{
var ea = victim.RegisterDamage(10, a);
var eb = victim.RegisterDamage(10, b);
victim.ClearDamageEntries();
Assert.Equal(0, victim.DamageEntries.Count);
Assert.False(ea.OnLinkList);
Assert.False(eb.OnLinkList);
Assert.Null(ea.Next);
Assert.Null(ea.Previous);
Assert.Null(eb.Next);
Assert.Null(eb.Previous);
}
finally
{
victim.Delete();
a.Delete();
b.Delete();
}
}
[Fact]
public void FullHitPoints_ClearsEntries()
{
var victim = new TestMobile();
var a = new TestMobile();
try
{
victim.RawStr = 50; // HitsMax follows Str for a base Mobile
victim.Hits = 10;
victim.RegisterDamage(10, a);
Assert.Equal(1, victim.DamageEntries.Count);
// Also stops the HitsTimer the Hits = 10 write started, so the test leaves no timer behind.
victim.Hits = victim.HitsMax;
Assert.Equal(0, victim.DamageEntries.Count);
}
finally
{
victim.Delete();
a.Delete();
}
}
[Fact]
public void RegisterDamage_AccumulatesResponsibleMaster()
{
var victim = new TestMobile();
var master = new TestMobile();
var pet = new PetMobile { Master = master };
try
{
victim.RegisterDamage(10, pet);
var entry = victim.RegisterDamage(5, pet);
Assert.Same(pet, entry.Damager);
Assert.Equal(15, entry.DamageGiven);
Assert.NotNull(entry.Responsible);
Assert.Single(entry.Responsible);
Assert.Same(master, entry.Responsible[0].Damager);
Assert.Equal(15, entry.Responsible[0].DamageGiven);
Assert.False(entry.Responsible[0].OnLinkList); // sub-entries never join the main list
}
finally
{
victim.Delete();
master.Delete();
pet.Delete();
}
}
}

View file

@ -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();
}
}

View 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);
}
}
}

View file

@ -44,10 +44,10 @@ public partial class Container : Item
internal int _version;
[SerializableField(3)]
[SaveFlag(nameof(ShouldSerializeLiftOverride))]
[SerializedCommandProperty(AccessLevel.GameMaster)]
private bool _liftOverride;
[SerializableFieldSaveFlag(3)]
private bool ShouldSerializeLiftOverride() => _liftOverride;
public Container(int itemID) : base(itemID)
@ -84,6 +84,7 @@ public partial class Container : Item
[EncodedInt]
[SerializableProperty(0)]
[SaveFlag(nameof(ShouldSerializeMaxItems), nameof(MaxItemsDefaultValue))]
[CommandProperty(AccessLevel.GameMaster)]
public int MaxItems
{
@ -96,14 +97,13 @@ public partial class Container : Item
}
}
[SerializableFieldSaveFlag(0)]
private bool ShouldSerializeMaxItems() => _maxItems != -1;
[SerializableFieldDefault(0)]
private int MaxItemsDefaultValue() => -1;
[EncodedInt]
[SerializableProperty(1)]
[SaveFlag(nameof(ShouldSerializeGumpId), nameof(GumpIDDefaultValue))]
[CommandProperty(AccessLevel.GameMaster)]
public int GumpID
{
@ -115,14 +115,13 @@ public partial class Container : Item
}
}
[SerializableFieldSaveFlag(1)]
private bool ShouldSerializeGumpId() => _gumpID != -1;
[SerializableFieldDefault(1)]
private int GumpIDDefaultValue() => -1;
[EncodedInt]
[SerializableProperty(2)]
[SaveFlag(nameof(ShouldSerializeDropSound), nameof(DropSoundDefaultValue))]
[CommandProperty(AccessLevel.GameMaster)]
public int DropSound
{
@ -134,10 +133,8 @@ public partial class Container : Item
}
}
[SerializableFieldSaveFlag(2)]
private bool ShouldSerializeDropSound() => _dropSound != -1;
[SerializableFieldDefault(2)]
private int DropSoundDefaultValue() => -1;
[CommandProperty(AccessLevel.GameMaster)]

View file

@ -863,7 +863,7 @@ public partial class Item : IHued, IComparable<Item>, ISpawnable, IObjectPropert
public virtual void Serialize(IGenericWriter writer)
{
writer.Write(10); // version
writer.Write(11); // version
var flags = SaveFlag.None;
@ -1015,19 +1015,13 @@ public partial class Item : IHued, IComparable<Item>, ISpawnable, IObjectPropert
writer.Write((int)flags);
/* begin last moved time optimization */
var ticks = LastMoved.Ticks;
var now = Core.Now.Ticks;
var minutes = new TimeSpan(now - ticks).TotalMinutes;
writer.WriteEncodedInt((int)Math.Clamp(minutes, int.MinValue, int.MaxValue));
/* end */
// Anchored: shifted by downtime at load, so time-since-moved is preserved and the
// bytes are stable across saves while the item does not move.
writer.WriteAnchoredTime(LastMoved);
if (GetSaveFlag(flags, SaveFlag.DecayReset))
{
//TODO Use WriteAnchoredTime once the save-time anchor is ported
writer.WriteDeltaTime(info.m_DecayReset);
writer.WriteAnchoredTime(info.m_DecayReset);
}
if (GetSaveFlag(flags, SaveFlag.Direction))
@ -2772,6 +2766,7 @@ public partial class Item : IHued, IComparable<Item>, ISpawnable, IObjectPropert
switch (version)
{
case 11:
case 10:
case 9:
case 8:
@ -2780,7 +2775,11 @@ public partial class Item : IHued, IComparable<Item>, ISpawnable, IObjectPropert
{
var flags = (SaveFlag)reader.ReadInt();
if (version < 7)
if (version >= 11)
{
LastMoved = reader.ReadAnchoredTime();
}
else if (version < 7)
{
LastMoved = reader.ReadDeltaTime();
}
@ -2800,10 +2799,10 @@ public partial class Item : IHued, IComparable<Item>, ISpawnable, IObjectPropert
if (version >= 10 && GetSaveFlag(flags, SaveFlag.DecayReset))
{
var reset = reader.ReadDeltaTime();
var reset = version >= 11 ? reader.ReadAnchoredTime() : reader.ReadDeltaTime();
// LastMoved is stored at whole-minute precision; keep the stamp only
// while it still extends the deadline.
// Pre-v11 LastMoved was stored at whole-minute precision; keep the
// stamp only while it still extends the deadline.
if (reset > LastMoved)
{
DecayResetTime = reset;

View file

@ -42,7 +42,7 @@ public delegate void PromptCallback(Mobile from, string text);
public delegate void PromptStateCallback<in T>(Mobile from, string text, T state);
public class DamageEntry
public class DamageEntry : IValueLinkListNode<DamageEntry>
{
public DamageEntry(Mobile damager) => Damager = damager;
@ -57,6 +57,11 @@ public class DamageEntry
public List<DamageEntry> Responsible { get; set; }
public static TimeSpan ExpireDelay { get; set; } = TimeSpan.FromMinutes(2.0);
// Intrusive links for Mobile._damageEntries. Sub-entries in Responsible never join a list.
public DamageEntry Next { get; set; }
public DamageEntry Previous { get; set; }
public bool OnLinkList { get; set; }
}
[Flags]
@ -377,7 +382,6 @@ public partial class Mobile : IHued, IComparable<Mobile>, ISpawnable, IObjectPro
Aggressors = new List<AggressorInfo>();
Aggressed = new List<AggressorInfo>();
NextSkillTime = Core.TickCount;
DamageEntries = new List<DamageEntry>();
}
// Sectors
@ -958,7 +962,23 @@ public partial class Mobile : IHued, IComparable<Mobile>, ISpawnable, IObjectPro
public static VisibleDamageType VisibleDamageType { get; set; }
public List<DamageEntry> DamageEntries { get; private set; }
private ValueLinkList<DamageEntry> _damageEntries;
/// <summary>
/// Damage entries ordered least recent (head) to most recent (tail). Expired entries are
/// pruned on access. Enumerate with <c>foreach</c> (ascending) or <c>.ByDescending()</c>.
/// Mutate only through <see cref="RegisterDamage"/> and <see cref="ClearDamageEntries"/>.
/// Calling a ValueLinkList mutator on this reference compiles, but operates on a defensive copy
/// while still unlinking the real nodes — it silently corrupts the list.
/// </summary>
public ref readonly ValueLinkList<DamageEntry> DamageEntries
{
get
{
PruneExpiredDamageEntries();
return ref _damageEntries;
}
}
[CommandProperty(AccessLevel.GameMaster)]
public Mobile LastKiller { get; set; }
@ -1627,7 +1647,7 @@ public partial class Mobile : IHued, IComparable<Mobile>, ISpawnable, IObjectPro
public virtual bool KeepsItemsOnDeath => m_AccessLevel > AccessLevel.Player;
public bool HasTrade => m_NetState?.Trades.Count > 0;
public bool HasTrade => m_NetState?.Trades?.Count > 0;
public bool NoMoveHS { get; set; }
@ -2020,10 +2040,7 @@ public partial class Mobile : IHued, IComparable<Mobile>, ISpawnable, IObjectPro
Aggressors[i].CanReportMurder = false;
}
if (DamageEntries.Count > 0)
{
DamageEntries.Clear(); // reset damage entries on full HP
}
ClearDamageEntries(); // reset damage entries on full HP
}
else if (CanRegenHits)
{
@ -2324,11 +2341,11 @@ public partial class Mobile : IHued, IComparable<Mobile>, ISpawnable, IObjectPro
public virtual void Serialize(IGenericWriter writer)
{
writer.Write(37); // version
writer.Write(38); // version
writer.WriteDeltaTime(LastStrGain);
writer.WriteDeltaTime(LastIntGain);
writer.WriteDeltaTime(LastDexGain);
writer.WriteAnchoredTime(LastStrGain);
writer.WriteAnchoredTime(LastIntGain);
writer.WriteAnchoredTime(LastDexGain);
byte hairflag = 0x00;
@ -5745,24 +5762,54 @@ public partial class Mobile : IHued, IComparable<Mobile>, ISpawnable, IObjectPro
}
}
// Entries are kept in LastDamage order, so expired entries are always a head prefix.
private void PruneExpiredDamageEntries()
{
#if DEBUG
for (var node = _damageEntries._first; node != null; node = node.Next)
{
Debug.Assert(
node.Next == null || node.Next.LastDamage >= node.LastDamage,
"Damage entries must be ordered by LastDamage ascending."
);
}
#endif
var first = _damageEntries._first;
if (first?.HasExpired != true)
{
return;
}
var firstLive = first.Next;
while (firstLive?.HasExpired == true)
{
firstLive = firstLive.Next;
}
if (firstLive == null)
{
_damageEntries.RemoveAll();
}
else
{
_damageEntries.RemoveAllBefore(firstLive);
}
}
public void ClearDamageEntries() => _damageEntries.RemoveAll();
public Mobile FindMostRecentDamager(bool allowSelf) => FindMostRecentDamageEntry(allowSelf)?.Damager;
public DamageEntry FindMostRecentDamageEntry(bool allowSelf)
{
for (var i = DamageEntries.Count - 1; i >= 0; --i)
PruneExpiredDamageEntries();
for (var de = _damageEntries._last; de != null; de = de.Previous)
{
if (i >= DamageEntries.Count)
{
continue;
}
var de = DamageEntries[i];
if (de.HasExpired)
{
DamageEntries.RemoveAt(i);
}
else if (allowSelf || de.Damager != this)
if (allowSelf || de.Damager != this)
{
return de;
}
@ -5775,21 +5822,11 @@ public partial class Mobile : IHued, IComparable<Mobile>, ISpawnable, IObjectPro
public DamageEntry FindLeastRecentDamageEntry(bool allowSelf)
{
for (var i = 0; i < DamageEntries.Count; ++i)
PruneExpiredDamageEntries();
for (var de = _damageEntries._first; de != null; de = de.Next)
{
if (i < 0)
{
continue;
}
var de = DamageEntries[i];
if (de.HasExpired)
{
DamageEntries.RemoveAt(i);
--i;
}
else if (allowSelf || de.Damager != this)
if (allowSelf || de.Damager != this)
{
return de;
}
@ -5800,24 +5837,17 @@ public partial class Mobile : IHued, IComparable<Mobile>, ISpawnable, IObjectPro
public Mobile FindMostTotalDamager(bool allowSelf) => FindMostTotalDamageEntry(allowSelf)?.Damager;
// Walks most recent first with a strict comparison so the most recent entry wins ties,
// matching the previous reverse-indexed loop.
public DamageEntry FindMostTotalDamageEntry(bool allowSelf)
{
PruneExpiredDamageEntries();
DamageEntry mostTotal = null;
for (var i = DamageEntries.Count - 1; i >= 0; --i)
for (var de = _damageEntries._last; de != null; de = de.Previous)
{
if (i >= DamageEntries.Count)
{
continue;
}
var de = DamageEntries[i];
if (de.HasExpired)
{
DamageEntries.RemoveAt(i);
}
else if ((allowSelf || de.Damager != this) && (mostTotal == null || de.DamageGiven > mostTotal.DamageGiven))
if ((allowSelf || de.Damager != this) && (mostTotal == null || de.DamageGiven > mostTotal.DamageGiven))
{
mostTotal = de;
}
@ -5830,46 +5860,28 @@ public partial class Mobile : IHued, IComparable<Mobile>, ISpawnable, IObjectPro
public DamageEntry FindLeastTotalDamageEntry(bool allowSelf)
{
DamageEntry mostTotal = null;
PruneExpiredDamageEntries();
for (var i = DamageEntries.Count - 1; i >= 0; --i)
DamageEntry leastTotal = null;
for (var de = _damageEntries._last; de != null; de = de.Previous)
{
if (i >= DamageEntries.Count)
if ((allowSelf || de.Damager != this) && (leastTotal == null || de.DamageGiven < leastTotal.DamageGiven))
{
continue;
}
var de = DamageEntries[i];
if (de.HasExpired)
{
DamageEntries.RemoveAt(i);
}
else if ((allowSelf || de.Damager != this) && (mostTotal == null || de.DamageGiven < mostTotal.DamageGiven))
{
mostTotal = de;
leastTotal = de;
}
}
return mostTotal;
return leastTotal;
}
public DamageEntry FindDamageEntryFor(Mobile m)
{
for (var i = DamageEntries.Count - 1; i >= 0; --i)
PruneExpiredDamageEntries();
for (var de = _damageEntries._last; de != null; de = de.Previous)
{
if (i >= DamageEntries.Count)
{
continue;
}
var de = DamageEntries[i];
if (de.HasExpired)
{
DamageEntries.RemoveAt(i);
}
else if (de.Damager == m)
if (de.Damager == m)
{
return de;
}
@ -5887,8 +5899,13 @@ public partial class Mobile : IHued, IComparable<Mobile>, ISpawnable, IObjectPro
de.DamageGiven += amount;
de.LastDamage = Core.Now;
DamageEntries.Remove(de);
DamageEntries.Add(de);
// Move to the tail so the list stays in LastDamage order.
if (de.OnLinkList)
{
_damageEntries.Remove(de);
}
_damageEntries.AddLast(de);
var master = from.GetDamageMaster(this);
@ -6150,6 +6167,7 @@ public partial class Mobile : IHued, IComparable<Mobile>, ISpawnable, IObjectPro
switch (version)
{
case 38: // Stat-gain stamps moved from delta time to anchored time
case 37: // Decomposed hair into inline item id/hue (dropped the VirtualHairInfo object)
case 36: // Moved virtues to VirtueSystem
case 35: // Moved short term murders to PlayerMurderSystem
@ -6158,9 +6176,18 @@ public partial class Mobile : IHued, IComparable<Mobile>, ISpawnable, IObjectPro
case 32: // Removed StuckMenu
case 31:
{
LastStrGain = reader.ReadDeltaTime();
LastIntGain = reader.ReadDeltaTime();
LastDexGain = reader.ReadDeltaTime();
if (version >= 38)
{
LastStrGain = reader.ReadAnchoredTime();
LastIntGain = reader.ReadAnchoredTime();
LastDexGain = reader.ReadAnchoredTime();
}
else
{
LastStrGain = reader.ReadDeltaTime();
LastIntGain = reader.ReadDeltaTime();
LastDexGain = reader.ReadDeltaTime();
}
goto case 30;
}
@ -6468,9 +6495,6 @@ public partial class Mobile : IHued, IComparable<Mobile>, ISpawnable, IObjectPro
m_DexLock = (StatLockType)reader.ReadByte();
m_IntLock = (StatLockType)reader.ReadByte();
_statMods = new List<StatMod>();
_skillMods = new List<SkillMod>();
if (version < 32)
{
if (reader.ReadBool())
@ -7803,13 +7827,10 @@ public partial class Mobile : IHued, IComparable<Mobile>, ISpawnable, IObjectPro
m_FollowersMax = 5;
Skills = new Skills(this);
Items = new List<Item>();
_statMods = new List<StatMod>();
_skillMods = new List<SkillMod>();
Map = Map.Internal;
AutoPageNotify = true;
Aggressors = new List<AggressorInfo>();
Aggressed = new List<AggressorInfo>();
DamageEntries = new List<DamageEntry>();
NextSkillTime = Core.TickCount;
}

View file

@ -21,17 +21,15 @@ namespace Server;
[SerializationGenerator(0)]
public partial class ResistanceMod : MobileMod
{
[SerializableField(0)]
[SerializableField(0, fieldChanged: nameof(OnTypeChanged))]
private ResistanceType _type;
[SerializableFieldChanged(0)]
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private void OnTypeChanged(ResistanceType oldValue, ResistanceType newValue) => Owner?.UpdateResistances();
[SerializableField(1)]
[SerializableField(1, fieldChanged: nameof(OnOffsetChanged))]
private int _offset;
[SerializableFieldChanged(1)]
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private void OnOffsetChanged(int oldValue, int newValue) => Owner?.UpdateResistances();

View file

@ -21,33 +21,29 @@ namespace Server;
[SerializationGenerator(0)]
public abstract partial class SkillMod : MobileMod
{
[SerializableField(0)]
[SerializableField(0, fieldChanged: nameof(OnObeyCapChanged))]
private bool _obeyCap;
[SerializableFieldChanged(0)]
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private void OnObeCapChanged(bool oldValue, bool newValue) => Owner?.Skills[_skill]?.Update();
private void OnObeyCapChanged(bool oldValue, bool newValue) => Owner?.Skills[_skill]?.Update();
[SerializableField(1)]
[SerializableField(1, fieldChanged: nameof(OnSkillChanged))]
private SkillName _skill;
[SerializableFieldChanged(1)]
private void OnSkillChanged(SkillName oldValue, SkillName newValue)
{
Owner?.Skills[newValue]?.Update();
Owner?.Skills[oldValue]?.Update();
}
[SerializableField(2)]
[SerializableField(2, fieldChanged: nameof(OnRelativeChanged))]
private bool _relative;
[SerializableFieldChanged(2)]
private void OnRelativeChanged(bool oldValue, bool newValue) => Owner?.Skills[_skill]?.Update();
[SerializableField(3)]
[SerializableField(3, fieldChanged: nameof(OnValueChanged))]
private double _value;
[SerializableFieldChanged(3)]
private void OnValueChanged(double oldValue, double newValue) => Owner?.Skills[_skill]?.Update();
public SkillMod(Mobile owner) : base(owner)

View file

@ -50,9 +50,6 @@ public static class MovementThrottle
private const int ClientMaxUnackedMovements = 5;
private const int MaxQueueWithUnmodifiedClient = ClientMaxUnackedMovements - 1; // 4
// Debug logging - enable for testing speed hack detection
private static bool _debugLogging = false;
// Track NetStates with queued movements for efficient processing
private static readonly HashSet<NetState> _netStatesWithQueuedMovements = new(256);
@ -83,15 +80,9 @@ public static class MovementThrottle
public static void Configure()
{
_maxCredit = ServerConfiguration.GetOrUpdateSetting(
"movementThrottle.maxCredit",
_maxCredit
);
_hardQueueLimit = ServerConfiguration.GetOrUpdateSetting(
"movementThrottle.hardQueueLimit",
_hardQueueLimit
);
_maxCredit = ServerConfiguration.GetOrUpdateSetting("movementThrottle.maxCredit", _maxCredit);
_maxRttBonus = ServerConfiguration.GetOrUpdateSetting("movementThrottle.maxRttBonus", _maxRttBonus);
_hardQueueLimit = ServerConfiguration.GetOrUpdateSetting("movementThrottle.hardQueueLimit", _hardQueueLimit);
_movementHistorySize = ServerConfiguration.GetOrUpdateSetting(
"movementThrottle.movementHistorySize",
@ -103,6 +94,13 @@ public static class MovementThrottle
_minSamplesForRate
);
_maxChainGap = ServerConfiguration.GetOrUpdateSetting("movementThrottle.maxChainGap", _maxChainGap);
_speedHackNotificationCooldown = ServerConfiguration.GetOrUpdateSetting(
"movementThrottle.speedHackNotificationCooldown",
_speedHackNotificationCooldown
);
_suspiciousRateThreshold = (float)ServerConfiguration.GetOrUpdateSetting(
"movementThrottle.suspiciousRateThreshold",
_suspiciousRateThreshold
@ -112,11 +110,6 @@ public static class MovementThrottle
"movementThrottle.definiteRateThreshold",
_definiteRateThreshold
);
_debugLogging = ServerConfiguration.GetOrUpdateSetting(
"movementThrottle.debugLogging",
_debugLogging
);
}
/// <summary>
@ -191,15 +184,16 @@ public static class MovementThrottle
// Credit can go negative up to -dynamicCredit (debt limit)
if (ns._movementCredit - earlyAmount >= -dynamicCredit)
{
var prevCredit = ns._movementCredit;
// Use credit to cover early arrival
ns._movementCredit -= earlyAmount;
if (_debugLogging && ns._movementLogging)
if (ns._movementLogging)
{
var prevCredit = ns._movementCredit + earlyAmount;
logger.Debug(
"[Credit] {Name}: delta={Delta}ms early={Early}ms credit={PrevCredit}->{Credit}/{MaxCredit} action=execute",
mobile.RawName, delta, earlyAmount, prevCredit, ns._movementCredit, dynamicCredit
mobile, delta, earlyAmount, prevCredit, ns._movementCredit, dynamicCredit
);
}
@ -208,11 +202,11 @@ public static class MovementThrottle
return;
}
if (_debugLogging && ns._movementLogging)
if (ns._movementLogging)
{
logger.Debug(
"[Credit] {Name}: delta={Delta}ms early={Early}ms credit={Credit}/{MaxCredit} EXHAUSTED -> queue",
mobile.RawName, delta, earlyAmount, ns._movementCredit, dynamicCredit
mobile, delta, earlyAmount, ns._movementCredit, dynamicCredit
);
}
@ -227,11 +221,11 @@ public static class MovementThrottle
var prevCredit = ns._movementCredit;
ns._movementCredit = Math.Min(ns._movementCredit + delta, dynamicCredit);
if (_debugLogging && ns._movementLogging && ns._movementCredit != prevCredit)
if (ns._movementLogging && ns._movementCredit != prevCredit)
{
logger.Debug(
"[Credit] {Name}: delta=+{Delta}ms credit={PrevCredit}->{Credit}/{MaxCredit} action=execute",
mobile.RawName, delta, prevCredit, ns._movementCredit, dynamicCredit
mobile, delta, prevCredit, ns._movementCredit, dynamicCredit
);
}
}
@ -247,12 +241,9 @@ public static class MovementThrottle
{
if (!mobile.Move(dir))
{
if (_debugLogging && ns._movementLogging)
if (ns._movementLogging)
{
logger.Debug(
"[Execute] {Name}: Move FAILED dir={Dir} seq={Seq} -> reject+reset",
mobile.RawName, dir, seq
);
logger.Debug("[Execute] {Name}: Move FAILED dir={Dir} seq={Seq} -> reject+reset", mobile, dir, seq);
}
// Movement failed (blocked, paralyzed, frozen, etc.)
@ -260,11 +251,11 @@ public static class MovementThrottle
return;
}
if (_debugLogging && ns._movementLogging)
if (ns._movementLogging)
{
logger.Debug(
"[Execute] {Name}: Move OK dir={Dir} seq={Seq} nextMove={NextMove}ms",
mobile.RawName, dir, seq, ns._nextMovementTime - Core.TickCount
mobile, dir, seq, ns._nextMovementTime - Core.TickCount
);
}
@ -304,11 +295,11 @@ public static class MovementThrottle
ns._hasQueuedMovements = true;
_netStatesWithQueuedMovements.Add(ns);
if (_debugLogging && ns._movementLogging)
if (ns._movementLogging)
{
logger.Debug(
"[Queue] {Name}: enqueued dir={Dir} seq={Seq} (depth={Depth})",
ns.Mobile?.RawName, dir, seq, ns._movementQueue.Count
ns.Mobile, dir, seq, ns._movementQueue.Count
);
}
}
@ -320,7 +311,6 @@ public static class MovementThrottle
{
ns.SendMovementRej(seq, mobile);
ns.ResetMovementState();
_netStatesWithQueuedMovements.Remove(ns);
}
/// <summary>
@ -333,20 +323,18 @@ public static class MovementThrottle
return;
}
// Process each NetState with queued movements
// Use a snapshot to avoid modification during iteration
var toProcess = new List<NetState>(_netStatesWithQueuedMovements);
for (var i = 0; i < toProcess.Count; i++)
foreach (var ns in _netStatesWithQueuedMovements)
{
var ns = toProcess[i];
if (!ns.Running)
if (ns.Running)
{
_netStatesWithQueuedMovements.Remove(ns);
continue;
ProcessMovementQueue(ns);
if (ns._hasQueuedMovements)
{
continue;
}
}
ProcessMovementQueue(ns);
_netStatesWithQueuedMovements.Remove(ns);
}
}
@ -356,6 +344,7 @@ public static class MovementThrottle
public static void ProcessMovementQueue(NetState ns)
{
var mobile = ns.Mobile;
if (mobile?.Deleted != false)
{
ClearQueue(ns);
@ -374,7 +363,7 @@ public static class MovementThrottle
while (ns._movementQueue?.Count > 0)
{
// Check if it's time to execute
if (now < ns._nextMovementTime)
if (now - ns._nextMovementTime < 0)
{
// Not yet - leave remaining items in queue for next Slice
break;
@ -394,11 +383,11 @@ public static class MovementThrottle
// Execute the move
if (!mobile.Move(movement.Direction))
{
if (_debugLogging && ns._movementLogging)
if (ns._movementLogging)
{
logger.Debug(
"[Queue] {Name}: dequeued FAILED dir={Dir} (remaining={Remaining})",
mobile.RawName, movement.Direction, remaining
mobile, movement.Direction, remaining
);
}
@ -407,12 +396,12 @@ public static class MovementThrottle
return;
}
if (_debugLogging && ns._movementLogging)
if (ns._movementLogging)
{
var waited = now - ns._nextMovementTime;
logger.Debug(
"[Queue] {Name}: dequeued OK dir={Dir} (remaining={Remaining}, waited={Waited}ms)",
mobile.RawName, movement.Direction, remaining, waited >= 0 ? waited : 0
mobile, movement.Direction, remaining, waited >= 0 ? waited : 0
);
}
@ -430,10 +419,6 @@ public static class MovementThrottle
// Update tracking
ns._hasQueuedMovements = ns._movementQueue?.Count > 0;
if (!ns._hasQueuedMovements)
{
_netStatesWithQueuedMovements.Remove(ns);
}
}
/// <summary>
@ -469,7 +454,6 @@ public static class MovementThrottle
{
ns._movementQueue?.Clear();
ns._hasQueuedMovements = false;
_netStatesWithQueuedMovements.Remove(ns);
}
// Maximum expected packets per second (mounted running = 100ms = 10/sec, plus tolerance)
@ -484,7 +468,7 @@ public static class MovementThrottle
logger.Information(
"Movement queue overflow: {Character} ({Account}) | " +
"Queue reached hard limit: {Limit} | IP: {IP}",
mobile?.RawName ?? "Unknown",
mobile,
ns.Account?.Username ?? "Unknown",
_hardQueueLimit,
ns.Address
@ -516,7 +500,7 @@ public static class MovementThrottle
private static void RecordMovement(NetState ns, long now, int cost, Direction dir, Mobile mobile)
{
// Calculate interval since last movement
var interval = ns._lastMovementRecordTime > 0
var interval = ns._hasMovementRecord
? (int)(now - ns._lastMovementRecordTime)
: -1; // -1 indicates first movement (no previous time)
@ -525,6 +509,7 @@ public static class MovementThrottle
if (interval <= 0 || interval > _maxChainGap)
{
ns._lastMovementRecordTime = now;
ns._hasMovementRecord = true;
// Use RTT to distinguish "stopped moving" vs "lagged"
// - Stable low-latency connection with gap >> RTT → player stopped, reset history
@ -544,19 +529,19 @@ public static class MovementThrottle
// A large gap followed by a burst of packets = likely lag recovery, not speed hack
ns._lastGapDuration = interval;
if (_debugLogging && mobile?.RawName != null)
if (ns._movementLogging)
{
var action = shouldReset ? "history reset" : "history preserved (possible lag)";
logger.Debug(
"[Movement] {Name}: SKIP recording (gap {Gap}ms > {MaxGap}ms, " +
"RTT={RTT}ms stable={Stable} → {Action})",
mobile.RawName, interval, _maxChainGap, avgRtt, ns.HasStableConnection, action
mobile, interval, _maxChainGap, avgRtt, ns.HasStableConnection, action
);
}
}
else if (_debugLogging && mobile?.RawName != null)
else if (ns._movementLogging)
{
logger.Debug("[Movement] {Name}: SKIP recording (first in chain)", mobile.RawName);
logger.Debug("[Movement] {Name}: SKIP recording (first in chain)", mobile);
}
return;
@ -572,12 +557,9 @@ public static class MovementThrottle
// the next real move's interval artificially short, inflating rate.
if (cost == 0)
{
if (_debugLogging && mobile?.RawName != null)
if (ns._movementLogging)
{
logger.Debug(
"[Movement] {Name}: SKIP direction-only change (preserves interval measurement)",
mobile.RawName
);
logger.Debug("[Movement] {Name}: SKIP direction-only change (preserves interval measurement)", mobile);
}
return;
}
@ -613,15 +595,16 @@ public static class MovementThrottle
}
ns._lastMovementRecordTime = now;
ns._hasMovementRecord = true;
// Debug logging
if (_debugLogging && mobile?.RawName != null)
if (ns._movementLogging)
{
var historyCount = ns._movementHistoryFull ? _movementHistorySize : ns._movementHistoryIndex;
logger.Debug(
"[Movement] {Name}: interval={Interval}ms target={Target}ms queue={Queue} " +
"flags={Flags} history={History}/{MaxHistory} RTT={RTT}ms",
mobile.RawName, interval, cost, record.QueueDepth,
mobile, interval, cost, record.QueueDepth,
flags, historyCount, _movementHistorySize, ns.AverageRtt
);
}
@ -814,7 +797,7 @@ public static class MovementThrottle
var averageRtt = ns.AverageRtt;
// Detailed rate breakdown for debugging
if (_debugLogging)
if (ns._movementLogging)
{
logger.Debug("[MovementAnalysis] Rate={Rate:F3}, Samples={Samples}, RTT={RTT}ms",
rate, sampleCount, averageRtt);
@ -977,19 +960,19 @@ public static class MovementThrottle
var verdict = AnalyzeMovement(ns, out var rate, out var sampleCount, out var confidence);
// Debug logging
if (_debugLogging && ns.Mobile?.RawName != null)
if (ns._movementLogging)
{
var (burstSize, _) = DetectRecentBurst(ns);
var probeStatus = ns._rttProbeTime > 0 ? "pending" : "idle";
var probeStatus = ns._rttProbePending ? "pending" : "idle";
var queueDepth = ns._movementQueue?.Count ?? 0;
logger.Debug(
"[RateCheck] {Name}: rate={Rate:F3} samples={Samples} verdict={Verdict} " +
"confidence={Confidence:P0} queue={Queue} burst={Burst} sustained={Sustained}s",
ns.Mobile.RawName, rate, sampleCount, verdict, confidence, queueDepth, burstSize, ns._consecutiveHighRateSeconds
ns.Mobile, rate, sampleCount, verdict, confidence, queueDepth, burstSize, ns._consecutiveHighRateSeconds
);
logger.Debug(
" RTT: avg={Avg}ms last={Last}ms var={Var} samples={RttSamples} stable={Stable} probe={Probe}",
ns.AverageRtt, ns._lastRtt, ns._rttVariance, ns._rttSampleCount, ns.HasStableConnection, probeStatus
ns.AverageRtt, ns.LastRtt, ns._rttVariance, ns._rttSampleCount, ns.HasStableConnection, probeStatus
);
}
@ -1025,11 +1008,11 @@ public static class MovementThrottle
if (shouldNotify)
{
if (_debugLogging)
if (ns._movementLogging)
{
logger.Debug(
"[ALERT] {Urgency} - {Name}: rate={Rate:F3} verdict={Verdict} confidence={Confidence:P0}",
urgency, ns.Mobile?.RawName, rate, verdict, confidence
urgency, ns.Mobile, rate, verdict, confidence
);
}
NotifyStaff(ns, rate, sampleCount, confidence, verdict, urgency);
@ -1054,11 +1037,12 @@ public static class MovementThrottle
var now = Core.TickCount;
// Rate-limit notifications per player
if (now - ns._lastSpeedHackNotification < _speedHackNotificationCooldown)
if (ns._speedHackNotified && now - ns._lastSpeedHackNotification < _speedHackNotificationCooldown)
{
return;
}
ns._speedHackNotified = true;
ns._lastSpeedHackNotification = now;
var mobile = ns.Mobile;
@ -1070,7 +1054,7 @@ public static class MovementThrottle
"PacketRate: {PacketRate}/s (peak: {PeakRate}/s) | RTT: {Rtt}ms (stable: {Stable}) | " +
"Sustained: {Sustained}s | Queue: {Queue} | Location: {Location} Map: {Map} | IP: {IP}",
urgency,
mobile?.RawName ?? "Unknown",
mobile,
ns.Account?.Username ?? "Unknown",
rate,
sampleCount,
@ -1138,7 +1122,7 @@ public static class MovementThrottle
Verdict = verdict,
Confidence = confidence,
AverageRtt = ns.AverageRtt,
LastRtt = ns._lastRtt,
LastRtt = ns.LastRtt,
RttVariance = ns._rttVariance,
StableConnection = ns.HasStableConnection,
RttSampleCount = ns._rttSampleCount,

View file

@ -15,7 +15,6 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Runtime.InteropServices;
using Server.Logging;
@ -70,23 +69,24 @@ public partial class NetState
internal Queue<QueuedMovement> _movementQueue; // Lazy initialized
internal long _movementCredit; // Credit buffer for timing jitter
internal long _nextMovementTime = Core.TickCount; // When next movement is allowed
internal int _sustainedQueueDepth; // Tracks sustained high queue depth
internal long _lastQueueDepthCheck; // Throttle depth check frequency
internal long _lastQueueDepthCheck = Core.TickCount; // Throttle depth check frequency
internal bool _hasQueuedMovements; // Fast check for Slice()
// Movement history for rate-based speed hack detection (lazy initialized)
internal MovementRecord[] _movementHistory; // Circular buffer
internal int _movementHistoryIndex; // Next write position (also serves as count until full)
internal bool _movementHistoryFull; // True once buffer has wrapped
internal long _lastMovementRecordTime; // For calculating intervals
internal long _lastMovementRecordTime; // For calculating intervals (valid only when _hasMovementRecord)
internal bool _hasMovementRecord; // False until the first movement in a chain is seen
// Detection state
internal int _consecutiveHighRateSeconds; // Sustained detection counter
internal long _lastSpeedHackNotification; // Rate-limit notifications
internal long _lastSpeedHackNotification; // Rate-limit notifications (valid only when _speedHackNotified)
internal bool _speedHackNotified; // False until the first notification is sent
internal int _lastGapDuration; // Duration of last gap > maxChainGap (for burst forgiveness)
// Movement packet rate tracking (for speed hack detection)
internal long _movementWindowStart; // Start of current 1-second window
internal long _movementWindowStart = Core.TickCount; // Start of current 1-second window
internal int _movementsInWindow; // Count in current window
internal int _peakMovementRate; // Highest rate seen (packets/sec)
@ -100,10 +100,9 @@ public partial class NetState
_nextMovementTime = Core.TickCount;
_movementCredit = 0;
_hasQueuedMovements = false;
_sustainedQueueDepth = 0;
// Reset movement history - next movement starts a new chain
_lastMovementRecordTime = 0;
_hasMovementRecord = false;
_movementHistoryIndex = 0;
_movementHistoryFull = false;
@ -113,7 +112,7 @@ public partial class NetState
_rttProbeInterval = RttProbeIntervalNormal;
// Reset packet rate window
_movementWindowStart = 0;
_movementWindowStart = Core.TickCount;
_movementsInWindow = 0;
}
@ -165,17 +164,19 @@ public partial class NetState
private const long MaxStableLatency = 200; // Max RTT (ms) for "stable" connection
// RTT state
internal long _rttProbeTime; // When we sent the probe (0 = not waiting)
internal long _lastRtt; // Most recent RTT measurement
internal bool _rttProbePending; // True while waiting for a probe response
internal long _rttProbeTime; // When we sent the probe (valid only when _rttProbePending)
internal long[] _rttHistory; // Rolling history (lazy init)
internal int _rttHistoryIndex; // Current position in history
internal int _rttSampleCount; // Number of samples collected (saturates at RttHistorySize)
internal long _rttVariance; // Calculated variance for stability
internal long _nextRttProbe; // When to send next probe
internal long _nextRttProbe = Core.TickCount; // When to send next probe
internal int _rttProbeInterval = RttProbeIntervalNormal; // Current probe interval
// High-resolution timestamp for RTT measurement (Stopwatch ticks, not game loop ticks)
private long _rttProbeTimestampHiRes;
/// <summary>
/// Gets the most recent RTT measurement, or 0 if none has been recorded.
/// </summary>
public long LastRtt => _rttSampleCount > 0 ? _rttHistory[(_rttHistoryIndex - 1) & (RttHistorySize - 1)] : 0;
/// <summary>
/// Sets the RTT probe interval based on suspicion level.
@ -206,23 +207,22 @@ public partial class NetState
var now = Core.TickCount;
// Don't send if we're still waiting for a response
if (_rttProbeTime > 0)
if (_rttProbePending)
{
// Timeout after 10 seconds - connection is probably dead or very laggy
if (now - _rttProbeTime > 10000)
{
_rttProbeTime = 0;
_rttProbeTimestampHiRes = 0;
_rttProbePending = false;
}
return;
}
// First probe: send immediately when player starts moving
// Subsequent probes: send when interval has passed
if (_nextRttProbe == 0 || now >= _nextRttProbe)
if (now - _nextRttProbe >= 0)
{
_rttProbePending = true;
_rttProbeTime = now;
_rttProbeTimestampHiRes = Stopwatch.GetTimestamp();
_nextRttProbe = now + _rttProbeInterval + Utility.Random(RttProbeJitter);
if (_movementLogging)
@ -242,10 +242,9 @@ public partial class NetState
/// </summary>
public void RecordRttMeasurement()
{
var nowHiRes = Stopwatch.GetTimestamp();
var now = Core.TickCount;
if (_rttProbeTime <= 0)
if (!_rttProbePending)
{
// Not expecting a response (client-initiated version send) - ignore silently
return;
@ -253,19 +252,15 @@ public partial class NetState
var rtt = now - _rttProbeTime;
// High-resolution RTT in microseconds
var rttHiResUs = (nowHiRes - _rttProbeTimestampHiRes) * 1_000_000 / Stopwatch.Frequency;
if (_movementLogging)
{
movementLogger.Debug(
"[RTT-Response] {Account}: {Rtt}ms (HiRes: {RttHiRes:F2}ms)",
Account?.Username ?? _toString, rtt, rttHiResUs / 1000.0
"[RTT-Response] {Account}: {Rtt}ms",
Account?.Username ?? _toString, rtt
);
}
_rttProbeTime = 0;
_rttProbeTimestampHiRes = 0;
_rttProbePending = false;
// Sanity check - RTT should be positive and reasonable
if (rtt is <= 0 or > 10000)
@ -285,7 +280,6 @@ public partial class NetState
// Update history
_rttHistory[_rttHistoryIndex++ & (RttHistorySize - 1)] = rtt;
_lastRtt = rtt;
// Track sample count (saturates at buffer size)
if (_rttSampleCount < RttHistorySize)

View file

@ -44,7 +44,7 @@ public partial class NetState : IComparable<NetState>, IValueLinkListNode<NetSta
private static readonly Queue<NetState> _connectingQueue = new(2048);
private static readonly HashSet<NetState> _instances = new(2048);
public static IReadOnlySet<NetState> Instances => _instances;
public static HashSet<NetState> Instances => _instances;
private readonly string _toString;
private ClientVersion _version;
@ -109,9 +109,6 @@ public partial class NetState : IComparable<NetState>, IValueLinkListNode<NetSta
Address = address;
Seeded = false;
HuePickers = [];
Menus = [];
Trades = [];
NextActivityCheck = Core.TickCount + 30000;
ConnectedOn = Core.Now;
_toString = address?.ToString() ?? "(error)";
@ -166,7 +163,7 @@ public partial class NetState : IComparable<NetState>, IValueLinkListNode<NetSta
public bool BlockAllPackets { get; set; }
public List<SecureTrade> Trades { get; }
public List<SecureTrade> Trades { get; private set; }
public bool Seeded { get; set; }
@ -260,8 +257,18 @@ public partial class NetState : IComparable<NetState>, IValueLinkListNode<NetSta
public void ValidateAllTrades()
{
if (Trades == null)
{
return;
}
for (var i = Trades.Count - 1; i >= 0; --i)
{
if (Trades == null)
{
break;
}
if (i >= Trades.Count)
{
continue;
@ -280,8 +287,18 @@ public partial class NetState : IComparable<NetState>, IValueLinkListNode<NetSta
public void CancelAllTrades()
{
if (Trades == null)
{
return;
}
for (var i = Trades.Count - 1; i >= 0; --i)
{
if (Trades != null)
{
break;
}
if (i < Trades.Count)
{
Trades[i].Cancel();
@ -291,11 +308,21 @@ public partial class NetState : IComparable<NetState>, IValueLinkListNode<NetSta
public void RemoveTrade(SecureTrade trade)
{
Trades.Remove(trade);
Trades?.Remove(trade);
if (Trades?.Count == 0)
{
Trades = null;
}
}
public SecureTrade FindTrade(Mobile m)
{
if (Trades == null)
{
return null;
}
for (var i = 0; i < Trades.Count; ++i)
{
var trade = Trades[i];
@ -311,6 +338,11 @@ public partial class NetState : IComparable<NetState>, IValueLinkListNode<NetSta
public SecureTradeContainer FindTradeContainer(Mobile m)
{
if (Trades == null)
{
return null;
}
for (var i = 0; i < Trades.Count; ++i)
{
var trade = Trades[i];
@ -336,7 +368,11 @@ public partial class NetState : IComparable<NetState>, IValueLinkListNode<NetSta
{
var newTrade = new SecureTrade(Mobile, state.Mobile);
Trades ??= [];
Trades.Add(newTrade);
state.Trades ??= [];
state.Trades.Add(newTrade);
return newTrade.From.Container;
@ -1176,8 +1212,16 @@ public partial class NetState : IComparable<NetState>, IValueLinkListNode<NetSta
var a = Account;
Menus.Clear();
HuePickers.Clear();
Menus?.Clear();
Menus = null;
HuePickers?.Clear();
HuePickers = null;
// Just in case, but should already be nulled when Mobile.NetState is set to null and CancelAllTrades is called.
Trades?.Clear();
Trades = null;
Account = null;
ServerInfo = null;
CityInfo = null;

View file

@ -74,6 +74,12 @@ public sealed unsafe class BinaryFileReader : IDisposable, IGenericReader
/// </summary>
public long Position => _reader.Position;
public TimeSpan AnchoredTimeShift
{
get => _reader.AnchoredTimeShift;
set => _reader.AnchoredTimeShift = value;
}
public void Dispose()
{
_accessor?.SafeMemoryMappedViewHandle.ReleasePointer();

View file

@ -37,6 +37,8 @@ public class BufferReader : IGenericReader
public long Position => _position;
public long BufferSize => _buffer.Length;
public TimeSpan AnchoredTimeShift { get; set; }
public BufferReader(byte[] buffer, Dictionary<ulong, string> typesDb = null, Encoding encoding = null)
{
_buffer = buffer;

View file

@ -384,6 +384,7 @@ public class BufferWriter : IGenericWriter
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
[Obsolete("Delta time rewrites its bytes on every save. Write anchored time instead (WriteAnchoredTime, or [AnchoredDateTime] on generated fields); bump the containing type's version, as the wire format changes. Existing delta payloads remain readable through ReadDeltaTime in old-version fallbacks.")]
public void WriteDeltaTime(DateTime value)
{
if (value == DateTime.MinValue)
@ -407,6 +408,21 @@ public class BufferWriter : IGenericWriter
Write(value.Ticks - DateTime.UtcNow.Ticks);
}
/// <summary>
/// Writes the absolute value; <see cref="IGenericReader.ReadAnchoredTime" /> re-bases it
/// by the elapsed time since the save started, so downtime does not age it and an
/// unchanged value serializes to identical bytes.
/// </summary>
public void WriteAnchoredTime(DateTime value)
{
if (value.Kind == DateTimeKind.Local)
{
value = value.ToUniversalTime();
}
Write(value.Ticks);
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public void Write(IPAddress value)
{

View file

@ -114,9 +114,10 @@ public class GenericEntityPersistence<T> : GenericPersistence, IGenericEntityPer
using var binFs = new FileStream(
Path.Combine(dir, $"{Name}.bin"), FileMode.Create, FileAccess.Write, FileShare.None, 1024 * 1024
);
// v4 records are fixed-width 26 bytes; the header carries the type table
// (name lengths vary — 64 bytes per entry is a staging hint, not a contract).
var expectedIdxSize = 12 + 26L * EntitiesBySerial.Count + 64L * _typeTable.Count;
// v4 records are fixed-width 26 bytes; the v5 header carries the save-start anchor
// and the type table (name lengths vary — 64 bytes per entry is a staging hint, not
// a contract).
var expectedIdxSize = 20 + 26L * EntitiesBySerial.Count + 64L * _typeTable.Count;
using var idx = new FileBufferWriter(Path.Combine(dir, $"{Name}.idx"), expectedIdxSize);
var binPosition = 0L;
@ -142,7 +143,10 @@ public class GenericEntityPersistence<T> : GenericPersistence, IGenericEntityPer
binPosition += _selfLength;
}
idx.Write(4); // Version
idx.Write(5); // Version
// One anchor for the whole save: the world is frozen from the moment it is stamped.
idx.Write(World.SaveStartTime.Ticks);
// The type table is fully known at freeze (AddEntity diverts to the pending
// queues while saving) and is written before the records so the loader can
@ -494,6 +498,18 @@ public class GenericEntityPersistence<T> : GenericPersistence, IGenericEntityPer
var version = dataReader.ReadInt();
if (version >= 5)
{
// Re-base anchored timestamps by the elapsed time since the save started.
var anchor = new DateTime(dataReader.ReadLong(), DateTimeKind.Utc);
var shift = Core.Now - anchor;
_anchoredTimeShift = anchor.Ticks > 0 && shift > TimeSpan.Zero ? shift : TimeSpan.Zero;
// The whole save shares one anchor. Publish it so payloads without their own
// (GenericPersistence bins) can shift too; indexes load before any of them.
World.LoadTimeShift = _anchoredTimeShift;
}
if (version >= 4)
{
DeserializeIndexesV4(dataReader, entities);
@ -660,6 +676,9 @@ public class GenericEntityPersistence<T> : GenericPersistence, IGenericEntityPer
private static List<T> _toDelete;
// From the loaded idx (v5+); zero when the save predates the anchor.
private TimeSpan _anchoredTimeShift;
private unsafe void InternalDeserialize(string filePath, int index, Dictionary<ulong, string> typesDb)
{
using var mmf = MemoryMappedFile.CreateFromFile(filePath, FileMode.Open);
@ -667,7 +686,10 @@ public class GenericEntityPersistence<T> : GenericPersistence, IGenericEntityPer
byte* ptr = null;
accessor.SafeMemoryMappedViewHandle.AcquirePointer(ref ptr);
var dataReader = new UnmanagedDataReader(ptr, accessor.Length, typesDb);
var dataReader = new UnmanagedDataReader(ptr, accessor.Length, typesDb)
{
AnchoredTimeShift = _anchoredTimeShift
};
Deserialize(dataReader);

View file

@ -98,7 +98,13 @@ public abstract class GenericPersistence : Persistence, IGenericSerializable
byte* ptr = null;
accessor.SafeMemoryMappedViewHandle.AcquirePointer(ref ptr);
var dataReader = new UnmanagedDataReader(ptr, accessor.Length, typesDb);
var dataReader = new UnmanagedDataReader(ptr, accessor.Length, typesDb)
{
// These payloads carry no anchor of their own; they inherit the save-wide
// shift stamped while the entity indexes were read (indexes always load
// before persistence payloads — see Persistence.Load).
AnchoredTimeShift = World.LoadTimeShift
};
Deserialize(dataReader);
error = dataReader.Position != fileLength

View file

@ -43,6 +43,12 @@ public interface IGenericReader
DateTime ReadDateTime() => new(ReadLong(), DateTimeKind.Utc);
TimeSpan ReadTimeSpan() => new(ReadLong());
/// <summary>
/// Decodes a legacy delta-time value. Only for reading old-version payloads (version
/// fallbacks and migration replays) — current formats store anchored time and read it
/// with <see cref="ReadAnchoredTime" />. <see cref="IGenericWriter.WriteDeltaTime" /> is
/// obsolete: no current-version format may write delta time.
/// </summary>
DateTime ReadDeltaTime()
{
return ReadLong() switch
@ -52,6 +58,37 @@ public interface IGenericReader
var delta => new DateTime(delta + DateTime.UtcNow.Ticks, DateTimeKind.Utc)
};
}
/// <summary>
/// Elapsed time between the loaded save starting and this load, applied by
/// <see cref="ReadAnchoredTime" />. Zero when the source carries no anchor.
/// </summary>
TimeSpan AnchoredTimeShift => TimeSpan.Zero;
DateTime ReadAnchoredTime()
{
var value = ReadDateTime();
if (value == DateTime.MinValue || value == DateTime.MaxValue)
{
return value;
}
var shift = AnchoredTimeShift;
if (shift == TimeSpan.Zero)
{
return value;
}
var ticks = value.Ticks + shift.Ticks;
if (ticks >= DateTime.MaxValue.Ticks)
{
return DateTime.MaxValue;
}
return ticks <= 0 ? DateTime.MinValue : new DateTime(ticks, DateTimeKind.Utc);
}
decimal ReadDecimal() => new([ReadInt(), ReadInt(), ReadInt(), ReadInt()]);
int ReadEncodedInt()
{

View file

@ -40,7 +40,11 @@ public interface IGenericWriter
void Write(decimal value);
void WriteEncodedInt(int value);
void Write(DateTime value);
[Obsolete("Delta time rewrites its bytes on every save. Write anchored time instead (WriteAnchoredTime, or [AnchoredDateTime] on generated fields); bump the containing type's version, as the wire format changes. Existing delta payloads remain readable through ReadDeltaTime in old-version fallbacks.")]
void WriteDeltaTime(DateTime value);
void WriteAnchoredTime(DateTime value);
void Write(IPAddress value);
void Write(TimeSpan value);
void Write(Point3D value);

View file

@ -43,6 +43,8 @@ public unsafe class UnmanagedDataReader : IGenericReader
/// </summary>
public long Position { get; private set; }
public TimeSpan AnchoredTimeShift { get; set; }
/// <summary>
/// Read bits of data raw from a serialized file using Little-endian.
/// </summary>

View file

@ -39,8 +39,8 @@
<PackageReference Include="LibDeflate.Bindings" Version="1.0.4" />
<PackageReference Include="System.IO.Hashing" Version="10.0.11" />
<PackageReference Include="ModernUO.Serialization.Annotations" Version="3.0.0" />
<PackageReference Include="ModernUO.Serialization.Generator" Version="3.0.0" PrivateAssets="all" />
<PackageReference Include="ModernUO.Serialization.Annotations" Version="4.0.0" />
<PackageReference Include="ModernUO.Serialization.Generator" Version="4.0.0" PrivateAssets="all" />
</ItemGroup>
<ItemGroup>
<AdditionalFiles Include="Migrations/*.v*.json" />

View file

@ -93,6 +93,21 @@ public static class World
public static string SavePath { get; private set; }
public static WorldState WorldState { get; private set; }
public static bool Saving => WorldState == WorldState.Saving;
/// <summary>
/// UTC time the current or most recent world save started. Written into save indexes so
/// anchored timestamps can be re-based by the downtime at load.
/// </summary>
public static DateTime SaveStartTime { get; internal set; }
/// <summary>
/// The anchored-time shift for the save currently being loaded: the downtime between the
/// save's start and this load. Stamped while entity indexes are read (they all carry the
/// same anchor, since the whole save shares one <see cref="SaveStartTime" />) and applied
/// to every reader of that save's files — including <see cref="GenericPersistence" />
/// payloads, which carry no anchor of their own. Zero for saves that predate the anchor.
/// </summary>
public static TimeSpan LoadTimeShift { get; internal set; }
public static bool Running => WorldState is not WorldState.Loading and not WorldState.Initial;
public static bool Loading => WorldState == WorldState.Loading;
@ -287,6 +302,10 @@ public static class World
WorldState = WorldState.Saving;
// The world is frozen from here: one anchor for the whole save. Written into save
// indexes so anchored timestamps can be re-based by the downtime at load.
SaveStartTime = Core.Now;
Broadcast(0x35, true, "The world is saving, please wait.");
logger.Information("Saving world");

View file

@ -103,6 +103,7 @@ internal static class TestServerInitializer
// Registers the Accounts entity persistence; without it no test can construct an Account.
Server.Accounting.Accounts.Configure();
RaceDefinitions.Configure();
Server.Movement.Movement.Configure();
MovementImpl.Configure();
PathFollower.Configure();
World.Load();

View file

@ -0,0 +1,211 @@
using System;
using System.Collections.Generic;
using Server;
using Server.Mobiles;
using Xunit;
namespace UOContent.Tests.Mobiles.AI;
// Pins the reacquire gate and the AcquireOnApproachDelay gradient: every scan re-arms the
// full ReacquireDelay; enemy movement clamps the deadline to the approach delay (Zero =
// prodded scan); an illegal deadline self-heals.
[Collection("Sequential Pathfinding Tests")]
public class AcquisitionTests : IDisposable
{
private readonly List<Mobile> _created = new();
public void Dispose()
{
foreach (var m in _created)
{
m?.Delete();
}
_created.Clear();
}
private sealed class WildStub : BaseCreature
{
public WildStub() : base(AIType.AI_Melee, FightMode.Closest, 16, 1) => Body = 0xC9;
public override void GetSpeeds(out double activeSpeed, out double passiveSpeed)
{
activeSpeed = 0.3;
passiveSpeed = 0.6;
}
}
private sealed class TargetStub : Mobile
{
public TargetStub() => Body = 0x190;
}
private WildStub Spawn(Map map, Point3D loc)
{
var bc = new WildStub();
bc.MoveToWorld(loc, map);
bc.AIObject.AITimer?.Stop();
_created.Add(bc);
return bc;
}
[Fact]
public void EmptyScan_HonorsReacquireDelay()
{
var map = Map.Maps[1];
Assert.NotNull(map);
map.GetAverageZ(1500, 1600, out _, out var z, out _);
var bc = Spawn(map, new Point3D(1500, 1600, (sbyte)z));
bc.NextReacquireTime = Core.TickCount;
Assert.False(bc.AIObject.AcquireFocusMob(bc.RangePerception, FightMode.Closest, false, false, true));
Assert.InRange(bc.NextReacquireTime - Core.TickCount, 5000, 10000);
}
[Fact]
public void WedgedGate_SelfHeals()
{
var map = Map.Maps[1];
Assert.NotNull(map);
map.GetAverageZ(1500, 1600, out _, out var z, out _);
var bc = Spawn(map, new Point3D(1500, 1600, (sbyte)z));
var target = new TargetStub();
target.DefaultMobileInit();
target.MoveToWorld(new Point3D(1497, 1600, (sbyte)z), map);
_created.Add(target);
// Illegal deadline (beyond ReacquireDelay): must read as open, not block forever.
bc.NextReacquireTime = Core.TickCount + 60000;
Assert.True(bc.AIObject.AcquireFocusMob(bc.RangePerception, FightMode.Closest, false, false, true));
Assert.Equal(target, bc.FocusMob);
}
[Theory]
[InlineData(false, 5, true)] // an enemy moving inside approach range (10) clamps the deadline
[InlineData(true, 5, false)] // a same-team wild creature is not an enemy — ignored
[InlineData(false, 12, false)] // inside RangePerception but outside approach range — poll only
[InlineData(false, 20, false)] // outside approach range (10) is ignored
public void MovementClampsScanDeadlineOnlyForEnemiesInRange(bool wildMover, int distance, bool notices)
{
var map = Map.Maps[1];
Assert.NotNull(map);
map.GetAverageZ(1500, 1600, out _, out var z, out _);
var bc = Spawn(map, new Point3D(1500, 1600, (sbyte)z));
bc.NextReacquireTime = Core.TickCount + 8000;
Mobile mover;
if (wildMover)
{
mover = Spawn(map, new Point3D(1500 - distance, 1600, (sbyte)z));
}
else
{
mover = new TargetStub { Player = true };
mover.DefaultMobileInit();
mover.MoveToWorld(new Point3D(1500 - distance, 1600, (sbyte)z), map);
_created.Add(mover);
}
bc.OnMovement(mover, new Point3D(1400, 1600, (sbyte)z));
var remaining = bc.NextReacquireTime - Core.TickCount;
if (notices)
{
// Clamped to the approach delay (2s), never opened outright.
Assert.InRange(remaining, 1, (long)bc.AcquireOnApproachDelay.TotalMilliseconds);
}
else
{
Assert.True(remaining > 5000);
}
}
private sealed class InstantStub : BaseCreature
{
public InstantStub() : base(AIType.AI_Melee, FightMode.Closest, 16, 1) => Body = 0xC9;
public override TimeSpan AcquireOnApproachDelay => TimeSpan.Zero;
public override void GetSpeeds(out double activeSpeed, out double passiveSpeed)
{
activeSpeed = 0.3;
passiveSpeed = 0.6;
}
}
[Fact]
public void ZeroApproachDelay_OpensGateImmediately()
{
var map = Map.Maps[1];
Assert.NotNull(map);
map.GetAverageZ(1500, 1600, out _, out var z, out _);
var bc = new InstantStub();
bc.MoveToWorld(new Point3D(1500, 1600, (sbyte)z), map);
bc.AIObject.AITimer?.Stop();
_created.Add(bc);
bc.NextReacquireTime = Core.TickCount + 8000;
var mover = new TargetStub { Player = true };
mover.DefaultMobileInit();
mover.MoveToWorld(new Point3D(1495, 1600, (sbyte)z), map);
_created.Add(mover);
bc.OnMovement(mover, new Point3D(1400, 1600, (sbyte)z));
// Zero = the gate opens and the AI is prodded to think now; no direct engage.
Assert.True(Core.TickCount - bc.NextReacquireTime >= 0);
Assert.Null(bc.Combatant);
Assert.True(bc.AIObject.AITimer.Running);
}
[Fact]
public void RepeatedMovement_DoesNotShortenBelowApproachDelay()
{
var map = Map.Maps[1];
Assert.NotNull(map);
map.GetAverageZ(1500, 1600, out _, out var z, out _);
var bc = Spawn(map, new Point3D(1500, 1600, (sbyte)z));
bc.NextReacquireTime = Core.TickCount + 8000;
var mover = new TargetStub { Player = true };
mover.DefaultMobileInit();
mover.MoveToWorld(new Point3D(1495, 1600, (sbyte)z), map);
_created.Add(mover);
bc.OnMovement(mover, new Point3D(1400, 1600, (sbyte)z));
var afterFirst = bc.NextReacquireTime;
bc.OnMovement(mover, new Point3D(1496, 1600, (sbyte)z));
Assert.Equal(afterFirst, bc.NextReacquireTime);
}
[Fact]
public void SuccessfulAcquire_HoldsFullDelay()
{
var map = Map.Maps[1];
Assert.NotNull(map);
map.GetAverageZ(1500, 1600, out _, out var z, out _);
var bc = Spawn(map, new Point3D(1500, 1600, (sbyte)z));
var target = new TargetStub();
target.DefaultMobileInit();
target.MoveToWorld(new Point3D(1497, 1600, (sbyte)z), map);
_created.Add(target);
bc.NextReacquireTime = Core.TickCount;
Assert.True(bc.AIObject.AcquireFocusMob(bc.RangePerception, FightMode.Closest, false, false, true));
Assert.Equal(target, bc.FocusMob);
Assert.True(bc.NextReacquireTime - Core.TickCount > 5000);
}
}

View file

@ -40,7 +40,7 @@ public class ApproachTargetTests
for (var i = 0; i < maxTicks; i++)
{
ai.NextMove = 0;
ai.WalkMobileRange(target, 1, false, 1, 2);
ai.WalkMobileRange(target, 1, 1, 2);
if (bc.InRange(target, arriveDist))
{
return true;
@ -123,7 +123,7 @@ public class ApproachTargetTests
for (var i = 0; i < 200; i++)
{
ai.NextMove = 0;
ai.MoveTo(target, false, 1);
ai.MoveTo(target, 1);
if (bc.InRange(target, 1))
{
arrived = true;
@ -154,7 +154,7 @@ public class ApproachTargetTests
for (var i = 0; i < 60; i++)
{
ai.NextMove = 0;
ai.MoveTo(target, true, 1);
ai.MoveTo(target, 1);
// Target walks west every other tick for its first several steps, then stops,
// so a same-speed chaser eventually closes the gap.
@ -214,7 +214,7 @@ public class ApproachTargetTests
for (var i = 0; i < 120; i++)
{
ai.NextMove = 0;
ai.MoveTo(target, false, 1);
ai.MoveTo(target, 1);
}
// After giving up, the creature must idle (not oscillate) while the goal is still.
@ -223,7 +223,7 @@ public class ApproachTargetTests
for (var i = 0; i < 20; i++)
{
ai.NextMove = 0;
ai.MoveTo(target, false, 1);
ai.MoveTo(target, 1);
if (bc.Location != idleStart)
{
stayedIdle = false;

View file

@ -0,0 +1,96 @@
using System.Collections.Generic;
using Server;
using Server.Mobiles;
using Xunit;
namespace UOContent.Tests.Mobiles.AI;
// Guard-following may pathfind, so this shares the pathfinding collection.
[Collection("Sequential Pathfinding Tests")]
public class GuardFollowTests
{
[Fact]
public void GuardFollow_StepsTowardMaster_AndRegistersMoveIntent()
{
var map = Map.Maps[1];
Assert.NotNull(map);
map.GetAverageZ(1500, 1600, out _, out var z, out _);
var master = new PlayerMobile(World.NewMobile);
master.DefaultMobileInit();
master.MoveToWorld(new Point3D(1494, 1600, (sbyte)z), map);
var pet = new PetTestStub();
pet.MoveToWorld(new Point3D(1500, 1600, (sbyte)z), map); // 6 tiles east, open terrain
pet.SetControlMaster(master);
var ai = pet.AIObject;
ai.AITimer?.Stop(); // drive manually
pet.ControlOrder = OrderType.Guard;
ai.AITimer?.Stop(); // the order change may restart the timer
var start = pet.Location;
ai.NextMove = 0;
ai.Obey();
var moved = pet.Location != start;
var hasIntent = ai.TryGetMoveWake(out _);
var currentSpeed = pet.CurrentSpeed;
var currentMoveSpeed = pet.CurrentMoveSpeed;
pet.Delete();
master.Delete();
Assert.True(moved, "a guarding pet beyond guard range must step toward its master");
// Without a move intent, guard-following only steps on the think grid.
Assert.True(hasIntent, "guard-following must register a move intent");
// AOS return sprint on both clocks; the per-step speed flip must not undo it.
Assert.Equal(0.1, currentSpeed);
Assert.Equal(0.1, currentMoveSpeed);
}
[Fact]
public void GuardReturn_PreAOS_RunsActive()
{
var previous = Core.Expansion;
try
{
Core.Expansion = Expansion.UOR;
var map = Map.Maps[1];
Assert.NotNull(map);
map.GetAverageZ(1500, 1600, out _, out var z, out _);
var master = new PlayerMobile(World.NewMobile);
master.DefaultMobileInit();
master.MoveToWorld(new Point3D(1494, 1600, (sbyte)z), map);
var pet = new PetTestStub();
pet.MoveToWorld(new Point3D(1500, 1600, (sbyte)z), map);
pet.SetControlMaster(master);
var ai = pet.AIObject;
ai.AITimer?.Stop();
pet.ControlOrder = OrderType.Guard;
ai.AITimer?.Stop();
pet.SetCurrentSpeedToPassive(); // a stale passive state must not persist
ai.NextMove = 0;
ai.Obey();
var currentSpeed = pet.CurrentSpeed;
pet.Delete();
master.Delete();
// No sprint pre-AOS: the return runs active.
Assert.Equal(0.2, currentSpeed);
}
finally
{
Core.Expansion = previous;
}
}
}

View file

@ -0,0 +1,137 @@
using System;
using System.Collections.Generic;
using Server;
using Server.Mobiles;
using Xunit;
namespace UOContent.Tests.Mobiles.AI;
// A guarding pet fights without leaving the Guard order, retargets toward the master's
// closest aggressor, and stands down when nothing threatens. Scene: the open
// (1495..1500, 1600) Trammel segment; targets are adjacent so no pathfinding runs.
[Collection("Sequential UOContent Tests")]
public class GuardOrderTests : IDisposable
{
private readonly List<Mobile> _created = new();
private sealed class AggressorStub : Mobile
{
public AggressorStub() => Body = 0xC9;
}
public void Dispose()
{
foreach (var m in _created)
{
m?.Delete();
}
_created.Clear();
}
private (PlayerMobile master, PetTestStub pet) SpawnGuardingPet(out Map map, out int z)
{
map = Map.Maps[1];
Assert.NotNull(map);
map.GetAverageZ(1500, 1600, out _, out z, out _);
var master = new PlayerMobile(World.NewMobile);
master.DefaultMobileInit();
master.MoveToWorld(new Point3D(1500, 1600, (sbyte)z), map);
_created.Add(master);
var pet = new PetTestStub();
pet.MoveToWorld(new Point3D(1499, 1600, (sbyte)z), map);
pet.SetControlMaster(master);
_created.Add(pet);
pet.AIObject.AITimer?.Stop(); // drive manually
pet.ControlOrder = OrderType.Guard;
pet.AIObject.AITimer?.Stop(); // the order change restarts the timer
return (master, pet);
}
private AggressorStub SpawnAggressor(PetTestStub pet, Point3D loc, Mobile attacking)
{
var aggr = new AggressorStub();
aggr.MoveToWorld(loc, pet.Map);
_created.Add(aggr);
// Setup guard: the scene must stay LOS-clear and the combatant must not be vetoed.
Assert.True(pet.InLOS(aggr), $"no LOS from pet to aggressor at {loc}");
if (attacking != null)
{
aggr.Combatant = attacking;
Assert.Same(attacking, aggr.Combatant);
}
return aggr;
}
[Fact]
public void GuardEngage_KeepsGuardOrder()
{
var (master, pet) = SpawnGuardingPet(out _, out var z);
var aggr = SpawnAggressor(pet, new Point3D(1498, 1600, (sbyte)z), master);
pet.AIObject.Obey();
Assert.Same(aggr, pet.Combatant);
Assert.Equal(OrderType.Guard, pet.ControlOrder);
Assert.Equal(OrderType.Guard, pet.AIObject.PersistentOrder);
}
[Fact]
public void Guard_RetargetsToAggressorClosestToMaster()
{
var (master, pet) = SpawnGuardingPet(out _, out var z);
var far = SpawnAggressor(pet, new Point3D(1495, 1600, (sbyte)z), master);
var near = SpawnAggressor(pet, new Point3D(1498, 1600, (sbyte)z), master);
pet.Combatant = far; // already fighting the far aggressor
pet.AIObject.Obey();
Assert.Same(near, pet.Combatant); // defends the master, not the current fight
Assert.Equal(OrderType.Guard, pet.ControlOrder);
}
[Fact]
public void ExplicitAttack_ResumesGuard_WithoutChainingIntoAttack()
{
var (master, pet) = SpawnGuardingPet(out _, out var z);
// Explicit kill order on a target that then becomes invalid.
var victim = SpawnAggressor(pet, new Point3D(1498, 1600, (sbyte)z), null);
pet.ControlTarget = victim;
pet.ControlOrder = OrderType.Attack;
victim.Hidden = true;
// A second aggressor is still after the master; FightMode.Closest would chain it.
var aggr2 = SpawnAggressor(pet, new Point3D(1497, 1600, (sbyte)z), master);
pet.AIObject.Obey(); // attack completes -> resume the persistent Guard
Assert.Equal(OrderType.Guard, pet.ControlOrder);
pet.AIObject.Obey(); // the guard scan engages the remaining aggressor in-order
Assert.Same(aggr2, pet.Combatant);
Assert.Equal(OrderType.Guard, pet.ControlOrder);
}
[Fact]
public void PeacefulGuard_StandsDown()
{
var (_, pet) = SpawnGuardingPet(out _, out _);
Assert.True(pet.Warmode); // the guard order opens in war stance
pet.AIObject.Obey(); // nothing to guard against
Assert.False(pet.Warmode);
Assert.Null(pet.Combatant);
Assert.Null(pet.FocusMob);
}
}

View 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);
}
}

View file

@ -0,0 +1,222 @@
using System;
using System.Collections.Generic;
using Server;
using Server.Mobiles;
using Xunit;
namespace UOContent.Tests.Mobiles.AI;
// Pet order handlers own the speed clocks; combat chases and herding keep their own pacing.
[Collection("Sequential UOContent Tests")]
public class PetPacingTests : IDisposable
{
private readonly List<Mobile> _created = new();
private (PlayerMobile master, PetTestStub pet) Spawn(Point3D masterLoc, Point3D petLoc)
{
var pair = PetTestSetup.SpawnControlledPet(masterLoc, petLoc);
_created.Add(pair.master);
_created.Add(pair.pet);
return pair;
}
public void Dispose()
{
foreach (var m in _created)
{
m?.Delete();
}
_created.Clear();
}
// Movement orders run active, resting orders run passive; the move clock follows.
[Fact]
public void OrderIssue_SetsThinkClock()
{
var (master, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1001, 1000, 0));
pet.SetMoveSpeed(0.3, 0.9);
pet.SetCurrentSpeedToPassive();
pet.ControlOrder = OrderType.Come;
Assert.Equal(0.2, pet.CurrentSpeed);
Assert.Equal(0.3, pet.CurrentMoveSpeed); // verbatim active -> activeMove
pet.ControlOrder = OrderType.Stay;
Assert.Equal(0.4, pet.CurrentSpeed);
Assert.Equal(0.9, pet.CurrentMoveSpeed);
pet.ControlTarget = master;
pet.ControlOrder = OrderType.Follow;
Assert.Equal(0.2, pet.CurrentSpeed);
pet.ControlOrder = OrderType.Guard;
Assert.Equal(0.2, pet.CurrentSpeed);
}
// AOS: following the master sprints at a bespoke 0.1 on both clocks.
[Fact]
public void FollowMaster_ObeySprints()
{
var (master, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1001, 1000, 0));
pet.SetMoveSpeed(0.3, 0.9);
pet.AIObject.AITimer?.Stop();
pet.ControlTarget = master;
pet.ControlOrder = OrderType.Follow; // fixture era is EJ
pet.AIObject.Obey();
Assert.Equal(0.1, pet.CurrentSpeed);
Assert.Equal(0.1, pet.CurrentMoveSpeed);
}
// At the master's side a guarding pet stays active: no stale-warmode passive, no sprint.
[Fact]
public void GuardAtMastersSide_IsActive()
{
var (_, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1001, 1000, 0));
pet.SetMoveSpeed(0.3, 0.9);
pet.AIObject.AITimer?.Stop();
pet.SetCurrentSpeedToPassive();
pet.ControlOrder = OrderType.Guard;
pet.AIObject.Obey(); // nothing to guard against, master adjacent
Assert.Equal(0.2, pet.CurrentSpeed);
Assert.Equal(0.3, pet.CurrentMoveSpeed);
}
// A pet chasing a combatant keeps the move table.
[Fact]
public void CombatChasingPet_KeepsMoveTable()
{
var (_, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1001, 1000, 0));
var target = new PetTestStub();
target.MoveToWorld(new Point3D(1003, 1000, 0), Map.Felucca);
_created.Add(target);
pet.SetMoveSpeed(0.3, 0.9);
pet.ControlOrder = OrderType.Guard;
pet.Combatant = target;
pet.SetCurrentSpeedToActive();
Assert.Equal(0.3, pet.CurrentMoveSpeed);
}
// Herding overrides order pacing.
[Fact]
public void HerdedObeyingPet_KeepsHerdingPace()
{
var (_, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1001, 1000, 0));
pet.SetMoveSpeed(0.45, 0.9);
pet.SetCurrentSpeedToPassive();
pet.TargetLocation = new Point2D(1010, 1010);
Assert.Equal(0.3, pet.CurrentMoveSpeed); // fixed herding pace
}
private sealed class ThinkProbe : PetTestStub
{
public int Thinks;
public override void OnThink()
{
Thinks++;
base.OnThink();
}
}
private (PlayerMobile master, ThinkProbe pet) SpawnProbe()
{
var master = new PlayerMobile(World.NewMobile);
master.DefaultMobileInit();
master.MoveToWorld(new Point3D(1000, 1000, 0), Map.Felucca);
_created.Add(master);
var pet = new ThinkProbe();
pet.MoveToWorld(new Point3D(1001, 1000, 0), Map.Felucca);
pet.SetControlMaster(master);
_created.Add(pet);
return (master, pet);
}
// Advances time in 8ms lockstep so the wheel and Core.TickCount stay in sync.
private static void RunFor(long ms)
{
var deadline = Core._tickCount + ms;
while (Core._tickCount < deadline)
{
Core._tickCount += 8;
Timer.Slice(Core._tickCount);
}
}
private static bool RunUntil(Func<bool> condition, long maxMs)
{
var deadline = Core._tickCount + maxMs;
while (Core._tickCount < deadline)
{
if (condition())
{
return true;
}
Core._tickCount += 8;
Timer.Slice(Core._tickCount);
}
return condition();
}
// Runs past the spawn stagger; returns right after a think with the next 0.4s away.
private ThinkProbe SettledProbe(out PlayerMobile master)
{
Core._tickCount = 0;
Timer.Init(0);
var (m, pet) = SpawnProbe();
master = m;
pet.ForceIdle = true; // no wandering; pure cadence
pet.ControlOrder = OrderType.Stay;
var settled = RunUntil(() => pet.Thinks >= 2, 8000);
Assert.True(settled, "the AI must reach a steady think cadence");
return pet;
}
[Fact]
public void OrderChange_WakesStaleThinkTimer()
{
var pet = SettledProbe(out var master);
var thinksBefore = pet.Thinks;
RunFor(200); // mid-wait, next think ~200ms out
Assert.Equal(thinksBefore, pet.Thinks);
pet.ControlTarget = master;
pet.ControlOrder = OrderType.Follow;
RunFor(80);
Assert.True(pet.Thinks > thinksBefore, "a fresh order must wake the AI promptly");
}
[Fact]
public void SpeedUp_ReschedulesPendingWake()
{
var pet = SettledProbe(out _);
var thinksBefore = pet.Thinks;
RunFor(200); // mid-wait, next think ~200ms out
Assert.Equal(thinksBefore, pet.Thinks);
pet.CurrentSpeed = 0.1;
RunFor(120);
Assert.True(pet.Thinks > thinksBefore, "a speed-up must reschedule the pending wake");
}
}

View file

@ -0,0 +1,162 @@
using System.Collections.Generic;
using Server;
using Server.Mobiles;
using Xunit;
namespace UOContent.Tests.Mobiles.AI;
// The Running bit is derived from the step pace: a step shorter than the client's walk
// interpolation (400ms on foot, 200ms mounted/flying) is flagged as a run.
[Collection("Sequential Pathfinding Tests")]
public class RunFlagTests : System.IDisposable
{
private readonly List<Mobile> _created = new();
private PetTestStub Spawn(double activeMove)
{
var pet = new PetTestStub();
pet.MoveToWorld(new Point3D(1000, 1000, 0), Map.Felucca);
pet.AIObject.AITimer?.Stop();
pet.SetMoveSpeed(activeMove, activeMove * 3);
pet.SetCurrentSpeedToActive();
pet.LastMoveTime = Core.TickCount; // mid-cadence unless a test says otherwise
_created.Add(pet);
return pet;
}
public void Dispose()
{
foreach (var m in _created)
{
m?.Delete();
}
_created.Clear();
}
[Theory]
[InlineData(0.3, true)]
[InlineData(0.125, true)]
[InlineData(0.4, false)]
[InlineData(0.45, false)]
[InlineData(1.05, false)]
public void FootCreature_RunsOnlyWhenFasterThanWalk(double activeMove, bool expected)
{
var pet = Spawn(activeMove);
Assert.Equal(activeMove, pet.CurrentMoveSpeed);
Assert.Equal(expected, pet.AIObject.ShouldRun());
}
[Theory]
[InlineData(0.3, false)]
[InlineData(0.15, true)]
public void FlyingCreature_UsesMountThresholds(double activeMove, bool expected)
{
var pet = Spawn(activeMove);
pet.Flying = true;
Assert.Equal(expected, pet.AIObject.ShouldRun());
}
[Fact]
public void BadlyHurt_SlowsBelowWalk_DropsToWalk()
{
var pet = Spawn(0.35);
Assert.True(pet.AIObject.ShouldRun());
// The hurt inflation is on the observed step pace, so the flag follows it.
pet.SetHits(100);
pet.Hits = 5;
pet.SetStam(100);
pet.Stam = 5;
Assert.False(pet.AIObject.ShouldRun());
}
[Theory]
[InlineData(0.3, true)]
[InlineData(0.45, false)]
public void DoMove_StampsRunningBit(double activeMove, bool expected)
{
var map = Map.Maps[1];
Assert.NotNull(map);
map.GetAverageZ(1500, 1600, out _, out var z, out _);
var pet = Spawn(activeMove);
pet.MoveToWorld(new Point3D(1500, 1600, (sbyte)z), map);
var ai = pet.AIObject;
ai.NextMove = 0;
var start = pet.Location;
Assert.True(ai.DoMove(Direction.West));
Assert.NotEqual(start, pet.Location);
Assert.Equal(expected, (pet.Direction & Direction.Running) != 0);
}
// An isolated step (after standing at least a walk interval) renders alone and darts
// if run-flagged, so it walks; continuing cadences and true sprinters keep the flag.
[Fact]
public void IsolatedStep_DropsToWalk()
{
var pet = Spawn(0.3);
pet.LastMoveTime = Core.TickCount - 1000;
Assert.False(pet.AIObject.ShouldRun());
}
[Fact]
public void IsolatedStep_SprinterStillRuns()
{
var pet = Spawn(0.125);
pet.LastMoveTime = Core.TickCount - 1000;
Assert.True(pet.AIObject.ShouldRun());
}
[Fact]
public void StallDoesNotBankCatchUpSteps()
{
var map = Map.Maps[1];
Assert.NotNull(map);
map.GetAverageZ(1500, 1600, out _, out var z, out _);
var pet = Spawn(0.3);
pet.MoveToWorld(new Point3D(1500, 1600, (sbyte)z), map);
var ai = pet.AIObject;
ai.NextMove = Core.TickCount - 1000;
Assert.True(ai.DoMove(Direction.West));
// A stall must restart the cadence at full pace: banked catch-up steps
// release as a burst the client renders as a sprint/teleport.
Assert.False(ai.CanMoveNow(out _));
Assert.True(ai.NextMove - Core.TickCount > 250);
}
[Fact]
public void LateStepDoesNotEarnAQuickerFollowUp()
{
var map = Map.Maps[1];
Assert.NotNull(map);
map.GetAverageZ(1500, 1600, out _, out var z, out _);
var pet = Spawn(0.3);
pet.MoveToWorld(new Point3D(1500, 1600, (sbyte)z), map);
pet.Warmode = true; // keep the active move clock through the step
var ai = pet.AIObject;
// The step lands 200ms past the budget — under one period, the reactive
// mirroring case (think grid vs budget deadline misalignment).
ai.NextMove = Core.TickCount - 200;
Assert.True(ai.DoMove(Direction.West));
// The debt must not be repaid: a sub-period catch-up step follows ~100ms
// behind and renders as a dart pair beside the player.
Assert.True(ai.NextMove - Core.TickCount > 250);
}
}

View file

@ -0,0 +1,146 @@
using System.Collections.Generic;
using Server.Mobiles;
using Xunit;
namespace Server.Tests;
/// <summary>
/// Pins the looting-rights rules that the inline damage entry list has to keep producing: the
/// returned stores are sorted by damage descending, the first (least recent) damager takes the
/// 1.25x bonus, the hitsMax band decides who clears the threshold, and a pet's damage is credited
/// to its damage master rather than to the pet.
/// </summary>
[Collection("Sequential UOContent Tests")]
public class LootingRightsTests
{
private class TestMobile : Mobile
{
}
private class PetMobile : Mobile
{
public Mobile Master { get; set; }
public override Mobile GetDamageMaster(Mobile damagee) => Master;
}
// GetLootingRights only ever credits mobiles flagged as players.
private static TestMobile NewPlayer() => new() { Player = true };
private static DamageStore FindStore(List<DamageStore> rights, Mobile m)
{
for (var i = 0; i < rights.Count; i++)
{
if (rights[i].m_Mobile == m)
{
return rights[i];
}
}
return null;
}
[Fact]
public void TwoPlayerDamagers_SortDescending_AndTheFirstDamagerTakesTheBonus()
{
var victim = new TestMobile();
var first = NewPlayer();
var second = NewPlayer();
try
{
victim.RegisterDamage(100, first);
victim.RegisterDamage(40, second); // second is the most recent, first is the "first damager"
// hitsMax < 200 puts the bar at topDamage / 2.
var rights = BaseCreature.GetLootingRights(victim.DamageEntries, 100);
Assert.Equal(2, rights.Count);
// Sorted by damage descending.
Assert.True(rights[0].m_Damage >= rights[1].m_Damage);
Assert.Same(first, rights[0].m_Mobile);
Assert.Same(second, rights[1].m_Mobile);
// The first damager - the least recent entry - gets the 1.25x bonus; nobody else does.
Assert.Equal(125, rights[0].m_Damage);
Assert.Equal(40, rights[1].m_Damage);
// topDamage 125 / 2 = 62, so 40 is below the bar.
Assert.True(rights[0].m_HasRight);
Assert.False(rights[1].m_HasRight);
}
finally
{
victim.Delete();
first.Delete();
second.Delete();
}
}
[Fact]
public void HitsMaxBand_MovesTheRightsThreshold()
{
var victim = new TestMobile();
var first = NewPlayer();
var second = NewPlayer();
try
{
victim.RegisterDamage(100, first);
victim.RegisterDamage(40, second);
// hitsMax >= 200 drops the bar to topDamage / 4 = 31, which 40 clears.
var rights = BaseCreature.GetLootingRights(victim.DamageEntries, 200);
Assert.Equal(2, rights.Count);
Assert.True(rights[0].m_HasRight);
Assert.True(rights[1].m_HasRight);
Assert.Same(second, rights[1].m_Mobile);
}
finally
{
victim.Delete();
first.Delete();
second.Delete();
}
}
[Fact]
public void PetDamage_CreditsTheMaster_NotThePet()
{
var victim = new TestMobile();
var master = NewPlayer();
var pet = new PetMobile { Master = master };
var wild = new TestMobile(); // no damage master, and not a player
try
{
victim.RegisterDamage(50, pet);
victim.RegisterDamage(20, wild);
var rights = BaseCreature.GetLootingRights(victim.DamageEntries, 100);
// The master is credited through the entry's Responsible sub-entry, and is the only one.
Assert.Single(rights);
var masterStore = FindStore(rights, master);
Assert.NotNull(masterStore);
Assert.Equal(62, masterStore.m_Damage); // 50, then the first-damager 1.25x bonus
Assert.True(masterStore.m_HasRight);
// The pet's own damage was fully handed to the master, so it earns no store.
Assert.Null(FindStore(rights, pet));
// A non-player damager earns nothing even when its damage was never reassigned.
Assert.Null(FindStore(rights, wild));
}
finally
{
victim.Delete();
master.Delete();
pet.Delete();
wild.Delete();
}
}
}

View file

@ -6,27 +6,27 @@ namespace Server.Engines.BulkOrders;
public partial class BOBFilter
{
[SerializableField(0)]
[SaveFlag(nameof(ShouldSerializeType))]
private int _type;
[SerializableFieldSaveFlag(0)]
private bool ShouldSerializeType() => _type != 0;
[SerializableField(1)]
[SaveFlag(nameof(ShouldSerializeQuality))]
private int _quality;
[SerializableFieldSaveFlag(1)]
private bool ShouldSerializeQuality() => _quality != 0;
[SerializableField(2)]
[SaveFlag(nameof(ShouldSerializeMaterial))]
private int _material;
[SerializableFieldSaveFlag(2)]
private bool ShouldSerializeMaterial() => _material != 0;
[SerializableField(3)]
[SaveFlag(nameof(ShouldSerializeQuantity))]
private int _quantity;
[SerializableFieldSaveFlag(3)]
private bool ShouldSerializeQuantity() => _quantity != 0;
private void Deserialize(IGenericReader reader, int version)

View file

@ -33,17 +33,13 @@ public partial class ChampionSkullBrazier : AddonComponent
[SerializedCommandProperty(AccessLevel.GameMaster)]
private ChampionSkullPlatform _platform;
[SerializableProperty(2)]
[CommandProperty(AccessLevel.GameMaster)]
public Item Skull
[SerializableField(2, fieldChanged: nameof(OnSkullChanged))]
[SerializedCommandProperty(AccessLevel.GameMaster)]
private Item _skull;
private void OnSkullChanged(Item oldValue, Item newValue)
{
get => _skull;
set
{
_skull = value;
this.MarkDirty();
_platform?.Validate();
}
_platform?.Validate();
}
public override int LabelNumber => 1049489 + (int)_type;

View file

@ -18,6 +18,7 @@ using System.Net;
using System.Collections.Generic;
using System.Runtime.InteropServices;
using ModernUO.Serialization;
using Server.Collections;
using Server.Engines.Virtues;
using Server.Gumps;
using Server.Items;
@ -27,16 +28,44 @@ using Server.Logging;
namespace Server.Engines.CannedEvil;
[SerializationGenerator(10, false)]
[SerializationGenerator(11, false)]
public partial class ChampionSpawn : Item
{
private void MigrateFrom(V10Content content)
{
_level = content.Level;
_activatedByProximity = content.ActivatedByProximity;
_nextProximityTime = content.NextProximityTime;
_maxLevel = content.MaxLevel;
_activatedByValor = content.ActivatedByValor;
_damageEntries = content.DamageEntries;
_confinedRoaming = content.ConfinedRoaming;
_idol = content.Idol;
_hasBeenAdvanced = content.HasBeenAdvanced;
_spawnArea = content.SpawnArea;
_randomizeType = content.RandomizeType;
_kills = content.Kills;
_active = content.Active;
_type = content.Type;
_creatures = content.Creatures;
_redSkulls = content.RedSkulls;
_whiteSkulls = content.WhiteSkulls;
_platform = content.Platform;
_altar = content.Altar;
_expireDelay = content.ExpireDelay;
_expireTime = content.ExpireTime;
_champion = content.Champion;
_restartDelay = content.RestartDelay;
_restartTime = content.RestartTime;
}
private static readonly ILogger logger = LogFactory.GetLogger(typeof(ChampionSpawn));
[SerializableField(1)]
[SerializedCommandProperty(AccessLevel.GameMaster)]
private bool _activatedByProximity;
[DeltaDateTime]
[AnchoredDateTime]
[SerializableField(2)]
[SerializedCommandProperty(AccessLevel.GameMaster)]
private DateTime _nextProximityTime;
@ -96,7 +125,7 @@ public partial class ChampionSpawn : Item
[SerializedCommandProperty(AccessLevel.GameMaster)]
private TimeSpan _expireDelay;
[DeltaDateTime]
[AnchoredDateTime]
[SerializableField(20)]
[SerializedCommandProperty(AccessLevel.GameMaster)]
private DateTime _expireTime;
@ -109,7 +138,7 @@ public partial class ChampionSpawn : Item
[SerializedCommandProperty(AccessLevel.GameMaster)]
private TimeSpan _restartDelay;
[DeltaDateTime]
[AnchoredDateTime]
[SerializableField(23, setter: "private")]
[SerializedCommandProperty(AccessLevel.GameMaster)]
private DateTime _restartTime;
@ -203,47 +232,38 @@ public partial class ChampionSpawn : Item
}
}
[SerializableProperty(3)]
[CommandProperty(AccessLevel.GameMaster, AccessLevel.Administrator)]
public int MaxLevel
[SerializableField(3, allowFieldChange: nameof(AllowMaxLevelChange))]
[SerializedCommandProperty(AccessLevel.GameMaster, AccessLevel.Administrator)]
private int _maxLevel;
private bool AllowMaxLevelChange(ref int value)
{
get => _maxLevel;
set => _maxLevel = Math.Clamp(value, 0, 18);
value = Math.Clamp(value, 0, 18);
return true;
}
[SerializableProperty(9)]
[CommandProperty(AccessLevel.GameMaster)]
public Rectangle2D SpawnArea
[SerializableField(9, fieldChanged: nameof(OnSpawnAreaChanged))]
[SerializedCommandProperty(AccessLevel.GameMaster)]
[InvalidateProperties]
private Rectangle2D _spawnArea;
private void OnSpawnAreaChanged(Rectangle2D oldValue, Rectangle2D newValue)
{
get => _spawnArea;
set
{
_spawnArea = value;
this.MarkDirty();
InvalidateProperties();
UpdateRegion();
}
UpdateRegion();
}
[SerializableProperty(11)]
[CommandProperty(AccessLevel.GameMaster)]
public int Kills
[SerializableField(11, fieldChanged: nameof(OnKillsChanged))]
[SerializedCommandProperty(AccessLevel.GameMaster)]
[InvalidateProperties]
private int _kills;
private void OnKillsChanged(int oldValue, int newValue)
{
get => _kills;
set
var n = _kills / (double)MaxKills;
var p = (int)(n * 100);
if (p < 90)
{
_kills = value;
this.MarkDirty();
var n = _kills / (double)MaxKills;
var p = (int)(n * 100);
if (p < 90)
{
SetWhiteSkullCount(p / 20);
}
InvalidateProperties();
SetWhiteSkullCount(p / 20);
}
}
@ -1162,11 +1182,6 @@ public partial class ChampionSpawn : Item
foreach (var de in m.DamageEntries)
{
if (de.HasExpired)
{
continue;
}
var damager = de.Damager;
var master = damager.GetDamageMaster(m);

View file

@ -51,9 +51,9 @@ public partial class ChampionTitleContext
}
[SerializableField(1)]
[SaveFlag(nameof(ShouldSerializeAbyss))]
private ChampionTitle _abyss;
[SerializableFieldSaveFlag(1)]
private bool ShouldSerializeAbyss() => _abyss != null;
[CommandProperty(AccessLevel.GameMaster)]
@ -71,9 +71,9 @@ public partial class ChampionTitleContext
}
[SerializableField(2)]
[SaveFlag(nameof(ShouldSerializeArachnid))]
private ChampionTitle _arachnid;
[SerializableFieldSaveFlag(2)]
private bool ShouldSerializeArachnid() => _arachnid != null;
[CommandProperty(AccessLevel.GameMaster)]
@ -91,9 +91,9 @@ public partial class ChampionTitleContext
}
[SerializableField(3)]
[SaveFlag(nameof(ShouldSerializeColdBlood))]
private ChampionTitle _coldBlood;
[SerializableFieldSaveFlag(3)]
private bool ShouldSerializeColdBlood() => _coldBlood != null;
[CommandProperty(AccessLevel.GameMaster)]
@ -111,9 +111,9 @@ public partial class ChampionTitleContext
}
[SerializableField(4)]
[SaveFlag(nameof(ShouldSerializeForestLord))]
private ChampionTitle _forestLord;
[SerializableFieldSaveFlag(4)]
private bool ShouldSerializeForestLord() => _forestLord != null;
[CommandProperty(AccessLevel.GameMaster)]
@ -131,9 +131,9 @@ public partial class ChampionTitleContext
}
[SerializableField(5)]
[SaveFlag(nameof(ShouldSerializeVerminHorde))]
private ChampionTitle _verminHorde;
[SerializableFieldSaveFlag(5)]
private bool ShouldSerializeVerminHorde() => _verminHorde != null;
[CommandProperty(AccessLevel.GameMaster)]
@ -151,9 +151,9 @@ public partial class ChampionTitleContext
}
[SerializableField(6)]
[SaveFlag(nameof(ShouldSerializeUnholyTerror))]
private ChampionTitle _unholyTerror;
[SerializableFieldSaveFlag(6)]
private bool ShouldSerializeUnholyTerror() => _unholyTerror != null;
[CommandProperty(AccessLevel.GameMaster)]
@ -171,9 +171,9 @@ public partial class ChampionTitleContext
}
[SerializableField(7)]
[SaveFlag(nameof(ShouldSerializeSleepingDragon))]
private ChampionTitle _sleepingDragon;
[SerializableFieldSaveFlag(7)]
private bool ShouldSerializeSleepingDragon() => _sleepingDragon != null;
[CommandProperty(AccessLevel.GameMaster)]
@ -191,9 +191,9 @@ public partial class ChampionTitleContext
}
[SerializableField(8)]
[SaveFlag(nameof(ShouldSerializeCorrupt))]
private ChampionTitle _corrupt;
[SerializableFieldSaveFlag(8)]
private bool ShouldSerializeCorrupt() => _corrupt != null;
[CommandProperty(AccessLevel.GameMaster)]
@ -211,9 +211,9 @@ public partial class ChampionTitleContext
}
[SerializableField(9)]
[SaveFlag(nameof(ShouldSerializeGlade))]
private ChampionTitle _glade;
[SerializableFieldSaveFlag(9)]
private bool ShouldSerializeGlade() => _glade != null;
[CommandProperty(AccessLevel.GameMaster)]

View file

@ -3,15 +3,21 @@ using ModernUO.Serialization;
namespace Server.Items;
[SerializationGenerator(1, false)]
[SerializationGenerator(2, false)]
public partial class StarRoomGate : Moongate
{
private void MigrateFrom(V1Content content)
{
_decays = content.Decays;
_decayTime = content.DecayTime;
}
private static TimeSpan GateDuration = TimeSpan.FromMinutes(2.0);
[SerializableField(0)]
private bool _decays;
[DeltaDateTime]
[AnchoredDateTime]
[SerializableField(1)]
private DateTime _decayTime;

View file

@ -830,10 +830,9 @@ public partial class BRBomb : Item
[SerializationGenerator(0, false)]
public partial class BRGoal : BaseAddon
{
[SerializableField(0)]
[SerializableField(0, fieldChanged: nameof(OnNorthChanged))]
private bool _north;
[SerializableFieldChanged(0)]
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private void OnNorthChanged(bool oldValue, bool newValue) => Remake();

View file

@ -252,10 +252,9 @@ public partial class HillOfTheKing : Item
public partial class KHBoard : Item
{
[SerializedCommandProperty(AccessLevel.GameMaster)]
[SerializableField(0)]
[SerializableField(0, fieldChanged: nameof(OnControllerChanged))]
private KHController _controller;
[SerializableFieldChanged(0)]
private void OnControllerChanged(KHController oldValue, KHController newValue)
{
oldValue?.RemoveBoard(this);

View file

@ -64,17 +64,13 @@ public partial class Trophy : Item
UpdateStyle();
}
[SerializableProperty(1)]
[CommandProperty(AccessLevel.GameMaster)]
public TrophyRank Rank
[SerializableField(1, fieldChanged: nameof(OnRankChanged))]
[SerializedCommandProperty(AccessLevel.GameMaster)]
private TrophyRank _rank;
private void OnRankChanged(TrophyRank oldValue, TrophyRank newValue)
{
get => _rank;
set
{
_rank = value;
UpdateStyle();
this.MarkDirty();
}
UpdateStyle();
}
private void Deserialize(IGenericReader reader, int version)

View file

@ -5,9 +5,20 @@ using Server.Mobiles;
namespace Server.Ethics;
[PropertyObject]
[SerializationGenerator(1)]
[SerializationGenerator(2)]
public partial class Player : EthicsEntity
{
private void MigrateFrom(V1Content content)
{
_mobile = content.Mobile;
_power = content.Power;
_history = content.History;
_steed = content.Steed;
_familiar = content.Familiar;
_shield = content.Shield;
_ethic = content.Ethic;
}
[SerializableField(0, setter: "private")]
private Mobile _mobile;
@ -27,7 +38,7 @@ public partial class Player : EthicsEntity
[SerializedCommandProperty(AccessLevel.GameMaster, AccessLevel.Administrator)]
private Mobile _familiar;
[DeltaDateTime]
[AnchoredDateTime]
[SerializableField(5, setter: "private")]
private DateTime _shield;

View file

@ -359,14 +359,14 @@ namespace Server.Factions
{
if (m_Mobile.InRange( m, 1 ))
RunFrom( m );
else if (!m_Mobile.InRange( m, m_Mobile.RangeFight > 2 ? m_Mobile.RangeFight : 2 ) && !MoveTo( m, true, 1 ))
else if (!m_Mobile.InRange( m, m_Mobile.RangeFight > 2 ? m_Mobile.RangeFight : 2 ) && !MoveTo(m, 1))
OnFailedMove();
}
else
{*/
if (!Mobile.InRange(m, Mobile.RangeFight))
{
if (!MoveTo(m, true, 1))
if (!MoveTo(m, 1))
{
OnFailedMove();
}

View file

@ -219,7 +219,7 @@ namespace Server.Engines.Harvest
}
else
{
item.Delete();
bonusItem?.Delete();
}
}

View file

@ -162,10 +162,15 @@ namespace Server.Items
}
}
[SerializationGenerator(0)]
[SerializationGenerator(1)]
public partial class PuzzleChestSolutionAndTime : PuzzleChestSolution
{
[DeltaDateTime]
private void MigrateFrom(V0Content content)
{
_when = content.When;
}
[AnchoredDateTime]
[SerializableField(0)]
private DateTime _when;
@ -237,16 +242,12 @@ namespace Server.Items
}
}
[SerializableProperty(0)]
public PuzzleChestSolution Solution
[SerializableField(0, fieldChanged: nameof(OnSolutionChanged))]
private PuzzleChestSolution _solution;
private void OnSolutionChanged(PuzzleChestSolution oldValue, PuzzleChestSolution newValue)
{
get => _solution;
set
{
_solution = value;
InitHints();
this.MarkDirty();
}
InitHints();
}
public PuzzleChestCylinder FirstHint

View file

@ -19,7 +19,7 @@ namespace Server.Engines.MLQuests
{
base.Serialize(writer);
writer.Write(2); // version
writer.Write(3); // version
writer.Write(MLQuestSystem.Contexts.Count);
foreach (var context in MLQuestSystem.Contexts.Values)

View file

@ -119,7 +119,7 @@ namespace Server.Engines.MLQuests.Objectives
if (IsTimed)
{
writer.Write(true);
writer.WriteDeltaTime(EndTime);
writer.WriteAnchoredTime(EndTime);
}
else
{
@ -135,7 +135,7 @@ namespace Server.Engines.MLQuests.Objectives
{
if (reader.ReadBool())
{
var endTime = reader.ReadDeltaTime();
var endTime = version >= 3 ? reader.ReadAnchoredTime() : reader.ReadDeltaTime();
if (objInstance != null)
{

View file

@ -83,7 +83,7 @@ public class PathFollower
public static bool Check(Point3D loc, Point3D goal, int range) =>
Utility.InRange(loc, goal, range) && (range > 1 || (loc.Z - goal.Z).Abs() < 16);
public bool Follow(bool run, int range)
public bool Follow(int range)
{
var goal = GetGoalLocation();
Direction d;
@ -97,13 +97,13 @@ public class PathFollower
if (!(Enabled && m_Path.Success))
{
d = m_From.GetDirectionTo(goal, run);
d = m_From.GetDirectionTo(goal);
m_From.SetDirection(d);
return Move(d) is MoveResult.Success or MoveResult.SuccessAutoTurn && Check(m_From.Location, goal, range);
}
d = m_From.GetDirectionTo(m_Next, run);
d = m_From.GetDirectionTo(m_Next);
m_From.SetDirection(d);
var res = Move(d);

View file

@ -37,17 +37,17 @@ public partial class PlantItem : Item, ISecurable
[SerializedIgnoreDupe]
[SerializableField(0)]
[SaveFlag(nameof(ShouldSerializeSecureLevel))]
[SerializedCommandProperty(AccessLevel.GameMaster)]
private SecureLevel _level;
[SerializableFieldSaveFlag(0)]
private bool ShouldSerializeSecureLevel() => (int)_level != 0;
[SerializedIgnoreDupe]
[SerializableField(5, setter: "private")]
[SaveFlag(nameof(ShouldSerializePlantSystem))]
private PlantSystem _plantSystem;
[SerializableFieldSaveFlag(5)]
private bool ShouldSerializePlantSystem() => _plantStatus < PlantStatus.DecorativePlant;
// For clients older than 7.0.12.0
@ -82,6 +82,7 @@ public partial class PlantItem : Item, ISecurable
[CommandProperty(AccessLevel.GameMaster)]
[SerializableProperty(1)]
[SaveFlag(nameof(ShouldSerializePlantStatus))]
public PlantStatus PlantStatus
{
get => _plantStatus;
@ -120,53 +121,38 @@ public partial class PlantItem : Item, ISecurable
}
}
[SerializableFieldSaveFlag(1)]
private bool ShouldSerializePlantStatus() => _plantStatus != PlantStatus.BowlOfDirt;
[SerializableProperty(2)]
[CommandProperty(AccessLevel.GameMaster)]
public PlantType PlantType
[SerializableField(2, fieldChanged: nameof(OnPlantTypeChanged))]
[SaveFlag(nameof(ShouldSerializePlantType))]
[SerializedCommandProperty(AccessLevel.GameMaster)]
private PlantType _plantType;
private void OnPlantTypeChanged(PlantType oldValue, PlantType newValue)
{
get => _plantType;
set
{
_plantType = value;
Update();
}
Update();
}
[SerializableFieldSaveFlag(2)]
private bool ShouldSerializePlantType() => (int)_plantType != 0;
[SerializableProperty(3)]
[CommandProperty(AccessLevel.GameMaster)]
public PlantHue PlantHue
[SerializableField(3, fieldChanged: nameof(OnPlantHueChanged))]
[SaveFlag(nameof(ShouldSerializePlantHue))]
[SerializedCommandProperty(AccessLevel.GameMaster)]
private PlantHue _plantHue;
private void OnPlantHueChanged(PlantHue oldValue, PlantHue newValue)
{
get => _plantHue;
set
{
_plantHue = value;
Update();
}
Update();
}
[SerializableFieldSaveFlag(3)]
private bool ShouldSerializePlantHue() => _plantHue != PlantHue.None;
[SerializableProperty(4)]
[CommandProperty(AccessLevel.GameMaster)]
public bool ShowType
{
get => _showType;
set
{
_showType = value;
InvalidateProperties();
this.MarkDirty();
}
}
[SerializableField(4)]
[SaveFlag(nameof(ShouldSerializeShowType))]
[SerializedCommandProperty(AccessLevel.GameMaster)]
[InvalidateProperties]
private bool _showType;
[SerializableFieldSaveFlag(4)]
private bool ShouldSerializeShowType() => _showType;
[CommandProperty(AccessLevel.GameMaster)]

View file

@ -33,24 +33,24 @@ namespace Server.Engines.Plants
private PlantItem _plant;
[SerializableField(0)]
[SaveFlag(nameof(ShouldSerializeFertileDirt))]
private bool _fertileDirt;
[SerializableFieldSaveFlag(0)]
private bool ShouldSerializeFertileDirt() => _fertileDirt;
[SerializableField(1)]
private DateTime _nextGrowth;
[SerializableField(2, setter: "private")]
[SaveFlag(nameof(ShouldSerializeGrowthIndicator))]
private PlantGrowthIndicator _growthIndicator;
[SerializableFieldSaveFlag(2)]
private bool ShouldSerializeGrowthIndicator() => _growthIndicator != PlantGrowthIndicator.None;
[SerializableField(13)]
[SaveFlag(nameof(ShouldSerializePollinated))]
private bool _pollinated;
[SerializableFieldSaveFlag(13)]
private bool ShouldSerializePollinated() => _pollinated;
public PlantSystem(PlantItem plant)
@ -97,45 +97,42 @@ namespace Server.Engines.Plants
public bool IsFullWater => _water >= 4;
[SerializableProperty(3)]
public int Water
[SerializableField(3, fieldChanged: nameof(OnWaterChanged), allowFieldChange: nameof(AllowWaterChange))]
[SaveFlag(nameof(ShouldSerializeWater))]
private int _water;
private bool AllowWaterChange(ref int value)
{
get => _water;
set
{
_water = Math.Clamp(value, 0, 4);
Plant.InvalidateProperties();
MarkDirty();
}
value = Math.Clamp(value, 0, 4);
return true;
}
private void OnWaterChanged(int oldValue, int newValue)
{
Plant.InvalidateProperties();
}
[SerializableFieldSaveFlag(3)]
private bool ShouldSerializeWater() => _water != 0;
[SerializableProperty(4)]
public int Hits
[SerializableField(4, fieldChanged: nameof(OnHitsChanged), allowFieldChange: nameof(AllowHitsChange))]
[SaveFlag(nameof(ShouldSerializeHits))]
private int _hits;
private bool AllowHitsChange(ref int value)
{
get => _hits;
set
{
if (_hits == value)
{
return;
}
_hits = Math.Clamp(value, 0, MaxHits);
if (_hits == 0)
{
Plant.Die();
}
Plant.InvalidateProperties();
MarkDirty();
}
value = Math.Clamp(value, 0, MaxHits);
return true;
}
private void OnHitsChanged(int oldValue, int newValue)
{
if (_hits == 0)
{
Plant.Die();
}
Plant.InvalidateProperties();
}
[SerializableFieldSaveFlag(4)]
private bool ShouldSerializeHits() => _hits != 0;
public int MaxHits => 10 + (int)Plant.PlantStatus * 2;
@ -149,124 +146,108 @@ namespace Server.Engines.Plants
_ => PlantHealth.Vibrant
};
[SerializableProperty(5)]
public int Infestation
[SerializableField(5, allowFieldChange: nameof(AllowInfestationChange))]
[SaveFlag(nameof(ShouldSerializeInfestation))]
private int _infestation;
private bool AllowInfestationChange(ref int value)
{
get => _infestation;
set
{
_infestation = Math.Clamp(value, 0, 2);
MarkDirty();
}
value = Math.Clamp(value, 0, 2);
return true;
}
[SerializableFieldSaveFlag(5)]
private bool ShouldSerializeInfestation() => _infestation != 0;
[SerializableProperty(6)]
public int Fungus
[SerializableField(6, allowFieldChange: nameof(AllowFungusChange))]
[SaveFlag(nameof(ShouldSerializeFungus))]
private int _fungus;
private bool AllowFungusChange(ref int value)
{
get => _fungus;
set
{
_fungus = Math.Clamp(value, 0, 2);
MarkDirty();
}
value = Math.Clamp(value, 0, 2);
return true;
}
[SerializableFieldSaveFlag(6)]
private bool ShouldSerializeFungus() => _fungus != 0;
[SerializableProperty(7)]
public int Poison
[SerializableField(7, allowFieldChange: nameof(AllowPoisonChange))]
[SaveFlag(nameof(ShouldSerializePoison))]
private int _poison;
private bool AllowPoisonChange(ref int value)
{
get => _poison;
set
{
_poison = Math.Clamp(value, 0, 2);
MarkDirty();
}
value = Math.Clamp(value, 0, 2);
return true;
}
[SerializableFieldSaveFlag(7)]
private bool ShouldSerializePoison() => _poison != 0;
[SerializableProperty(8)]
public int Disease
[SerializableField(8, allowFieldChange: nameof(AllowDiseaseChange))]
[SaveFlag(nameof(ShouldSerializeDisease))]
private int _disease;
private bool AllowDiseaseChange(ref int value)
{
get => _disease;
set
{
_disease = Math.Clamp(value, 0, 2);
MarkDirty();
}
value = Math.Clamp(value, 0, 2);
return true;
}
[SerializableFieldSaveFlag(8)]
private bool ShouldSerializeDisease() => _disease != 0;
public bool IsFullPoisonPotion => _poisonPotion >= 2;
[SerializableProperty(9)]
public int PoisonPotion
[SerializableField(9, allowFieldChange: nameof(AllowPoisonPotionChange))]
[SaveFlag(nameof(ShouldSerializePoisonPotion))]
private int _poisonPotion;
private bool AllowPoisonPotionChange(ref int value)
{
get => _poisonPotion;
set
{
_poisonPotion = Math.Clamp(value, 0, 2);
MarkDirty();
}
value = Math.Clamp(value, 0, 2);
return true;
}
[SerializableFieldSaveFlag(9)]
private bool ShouldSerializePoisonPotion() => _poisonPotion != 0;
public bool IsFullCurePotion => _curePotion >= 2;
[SerializableProperty(10)]
public int CurePotion
[SerializableField(10, allowFieldChange: nameof(AllowCurePotionChange))]
[SaveFlag(nameof(ShouldSerializeCurePotion))]
private int _curePotion;
private bool AllowCurePotionChange(ref int value)
{
get => _curePotion;
set
{
_curePotion = Math.Clamp(value, 0, 2);
MarkDirty();
}
value = Math.Clamp(value, 0, 2);
return true;
}
[SerializableFieldSaveFlag(10)]
private bool ShouldSerializeCurePotion() => _curePotion != 0;
public bool IsFullHealPotion => _healPotion >= 2;
[SerializableProperty(11)]
public int HealPotion
[SerializableField(11, allowFieldChange: nameof(AllowHealPotionChange))]
[SaveFlag(nameof(ShouldSerializeHealPotion))]
private int _healPotion;
private bool AllowHealPotionChange(ref int value)
{
get => _healPotion;
set
{
_healPotion = Math.Clamp(value, 0, 2);
MarkDirty();
}
value = Math.Clamp(value, 0, 2);
return true;
}
[SerializableFieldSaveFlag(11)]
private bool ShouldSerializeHealPotion() => _healPotion != 0;
public bool IsFullStrengthPotion => _strengthPotion >= 2;
[SerializableProperty(12)]
public int StrengthPotion
[SerializableField(12, allowFieldChange: nameof(AllowStrengthPotionChange))]
[SaveFlag(nameof(ShouldSerializeStrengthPotion))]
private int _strengthPotion;
private bool AllowStrengthPotionChange(ref int value)
{
get => _strengthPotion;
set
{
_strengthPotion = Math.Clamp(value, 0, 2);
MarkDirty();
}
value = Math.Clamp(value, 0, 2);
return true;
}
[SerializableFieldSaveFlag(12)]
private bool ShouldSerializeStrengthPotion() => _strengthPotion != 0;
public bool HasMaladies => Infestation > 0 || Fungus > 0 || Poison > 0 || Disease > 0 || Water != 2;
@ -274,6 +255,7 @@ namespace Server.Engines.Plants
public bool PollenProducing => Plant.IsCrossable && Plant.PlantStatus >= PlantStatus.FullGrownPlant;
[SerializableProperty(14)]
[SaveFlag(nameof(ShouldSerializeSeedType))]
public PlantType SeedType
{
get => Pollinated ? _seedType : Plant.PlantType;
@ -284,10 +266,10 @@ namespace Server.Engines.Plants
}
}
[SerializableFieldSaveFlag(14)]
private bool ShouldSerializeSeedType() => _pollinated;
[SerializableProperty(15)]
[SaveFlag(nameof(ShouldSerializeSeedHue))]
public PlantHue SeedHue
{
get => Pollinated ? _seedHue : Plant.PlantHue;
@ -298,53 +280,58 @@ namespace Server.Engines.Plants
}
}
[SerializableFieldSaveFlag(15)]
private bool ShouldSerializeSeedHue() => _pollinated;
[SerializableProperty(16)]
public int AvailableSeeds
[SerializableField(16, allowFieldChange: nameof(AllowAvailableSeedsChange))]
[SaveFlag(nameof(ShouldSerializeAvailableSeeds))]
private int _availableSeeds;
private bool AllowAvailableSeedsChange(ref int value)
{
get => _availableSeeds;
set => _availableSeeds = Math.Max(value, 0);
value = Math.Max(value, 0);
return true;
}
[SerializableFieldSaveFlag(16)]
private bool ShouldSerializeAvailableSeeds() => _availableSeeds != 0;
[SerializableProperty(17)]
public int LeftSeeds
[SerializableField(17, allowFieldChange: nameof(AllowLeftSeedsChange))]
[SaveFlag(nameof(ShouldSerializeLeftSeeds), nameof(LeftSeedsDefaultValue))]
private int _leftSeeds;
private bool AllowLeftSeedsChange(ref int value)
{
get => _leftSeeds;
set => _leftSeeds = Math.Max(value, 0);
value = Math.Max(value, 0);
return true;
}
[SerializableFieldSaveFlag(17)]
private bool ShouldSerializeLeftSeeds() => _leftSeeds != 8;
[SerializableFieldDefault(17)]
private int LeftSeedsDefaultValue() => 8;
[SerializableProperty(18)]
public int AvailableResources
[SerializableField(18, allowFieldChange: nameof(AllowAvailableResourcesChange))]
[SaveFlag(nameof(ShouldSerializeAvailableResources))]
private int _availableResources;
private bool AllowAvailableResourcesChange(ref int value)
{
get => _availableResources;
set => _availableResources = Math.Max(value, 0);
value = Math.Max(value, 0);
return true;
}
[SerializableFieldSaveFlag(18)]
private bool ShouldSerializeAvailableResources() => _availableResources != 0;
[SerializableProperty(19)]
public int LeftResources
[SerializableField(19, allowFieldChange: nameof(AllowLeftResourcesChange))]
[SaveFlag(nameof(ShouldSerializeLeftResources), nameof(LeftResourcesDefaultValue))]
private int _leftResources;
private bool AllowLeftResourcesChange(ref int value)
{
get => _leftResources;
set => _leftResources = Math.Max(value, 0);
value = Math.Max(value, 0);
return true;
}
[SerializableFieldSaveFlag(19)]
private bool ShouldSerializeLeftResources() => _leftResources != 8;
[SerializableFieldDefault(19)]
private int LeftResourcesDefaultValue() => 8;
public void Reset(bool potions)

View file

@ -35,18 +35,14 @@ public partial class Seed : Item
public override double DefaultWeight => 1.0;
[CommandProperty(AccessLevel.GameMaster)]
[SerializableProperty(1)]
public PlantHue PlantHue
[SerializableField(1, fieldChanged: nameof(OnPlantHueChanged))]
[SerializedCommandProperty(AccessLevel.GameMaster)]
[InvalidateProperties]
private PlantHue _plantHue;
private void OnPlantHueChanged(PlantHue oldValue, PlantHue newValue)
{
get => _plantHue;
set
{
_plantHue = value;
Hue = PlantHueInfo.GetInfo(value).Hue;
InvalidateProperties();
this.MarkDirty();
}
Hue = PlantHueInfo.GetInfo(newValue).Hue;
}
public override int LabelNumber => 1060810; // seed

View file

@ -16,12 +16,14 @@ public partial class MurderContext
[SerializedCommandProperty(AccessLevel.GameMaster)]
private TimeSpan _longTermElapse;
[SerializableProperty(2)]
[CommandProperty(AccessLevel.GameMaster)]
public int ShortTermMurders
[SerializableField(2, allowFieldChange: nameof(AllowShortTermMurdersChange))]
[SerializedCommandProperty(AccessLevel.GameMaster)]
private int _shortTermMurders;
private bool AllowShortTermMurdersChange(ref int value)
{
get => _shortTermMurders;
set => _shortTermMurders = Math.Max(value, 0);
value = Math.Max(value, 0);
return true;
}
[SerializableField(3)]

View file

@ -12,16 +12,12 @@ public partial class SummoningAltar : AbbatoirAddon
{
}
[SerializableProperty(0)]
public BoneDemon Daemon
[SerializableField(0, fieldChanged: nameof(OnDaemonChanged))]
private BoneDemon _daemon;
private void OnDaemonChanged(BoneDemon oldValue, BoneDemon newValue)
{
get => _daemon;
set
{
_daemon = value;
CheckDaemon();
this.MarkDirty();
}
CheckDaemon();
}
public void CheckDaemon()

View file

@ -54,10 +54,10 @@ public abstract partial class BaseSpawner : Item, ISpawner
[SerializedCommandProperty(AccessLevel.Developer)]
private Guid _guid;
[SerializableFieldSaveFlag(1)]
private bool ShouldSerializeReturnOnDeactivate() => _returnOnDeactivate;
[SerializableField(1)]
[SaveFlag(nameof(ShouldSerializeReturnOnDeactivate))]
[SerializedCommandProperty(AccessLevel.Developer)]
private bool _returnOnDeactivate;
@ -67,48 +67,46 @@ public abstract partial class BaseSpawner : Item, ISpawner
private int _walkingRange = -1;
[SerializableFieldSaveFlag(4)]
private bool ShouldSerializeWayPoint() => _wayPoint != null;
[SerializableField(4)]
[SaveFlag(nameof(ShouldSerializeWayPoint))]
[SerializedCommandProperty(AccessLevel.Developer)]
private WayPoint _wayPoint;
[SerializableFieldSaveFlag(5)]
private bool ShouldSerializeGroup() => _group;
[InvalidateProperties]
[SerializableField(5)]
[SaveFlag(nameof(ShouldSerializeGroup))]
[SerializedCommandProperty(AccessLevel.Developer)]
private bool _group;
[SerializableFieldSaveFlag(6)]
private bool ShouldSerializeMinDelay() => _minDelay != DefaultMinDelay;
[SerializableFieldDefault(6)]
private TimeSpan MinDelayDefault() => DefaultMinDelay;
[InvalidateProperties]
[SerializableField(6)]
[SaveFlag(nameof(ShouldSerializeMinDelay), nameof(MinDelayDefault))]
[SerializedCommandProperty(AccessLevel.Developer)]
private TimeSpan _minDelay;
[SerializableFieldSaveFlag(7)]
private bool ShouldSerializeMaxDelay() => _maxDelay != DefaultMaxDelay;
[SerializableFieldDefault(7)]
private TimeSpan MaxDelayDefault() => DefaultMaxDelay;
[InvalidateProperties]
[SerializableField(7)]
[SaveFlag(nameof(ShouldSerializeMaxDelay), nameof(MaxDelayDefault))]
[SerializedCommandProperty(AccessLevel.Developer)]
private TimeSpan _maxDelay;
[SerializableFieldSaveFlag(9)]
private bool ShouldSerializeTeam() => _team != 0;
[InvalidateProperties]
[SerializableField(9)]
[SaveFlag(nameof(ShouldSerializeTeam))]
[SerializedCommandProperty(AccessLevel.Developer)]
private int _team;
@ -125,29 +123,29 @@ public abstract partial class BaseSpawner : Item, ISpawner
/// If true, the home location of the spawn is the location where it spawned
/// If false, the home location of the spawn is the location of the spawner
/// </summary>
[SerializableFieldSaveFlag(11)]
private bool ShouldSerializeSpawnLocationIsHome() => _spawnLocationIsHome;
[InvalidateProperties]
[SerializableField(11)]
[SaveFlag(nameof(ShouldSerializeSpawnLocationIsHome))]
[SerializedCommandProperty(AccessLevel.Developer)]
private bool _spawnLocationIsHome;
[SerializableFieldSaveFlag(12)]
private bool ShouldSerializeEnd() => _end != default;
[SerializableField(12)]
[SaveFlag(nameof(ShouldSerializeEnd))]
[SerializedCommandProperty(AccessLevel.Developer)]
private DateTime _end;
/// <summary>
/// Controls how spawn position optimization is handled.
/// </summary>
[SerializableFieldSaveFlag(13)]
private bool ShouldSerializeSpawnPositionMode() =>
_spawnPositionMode is not SpawnPositionMode.Automatic and not SpawnPositionMode.Abandoned;
[SerializableField(13)]
[SaveFlag(nameof(ShouldSerializeSpawnPositionMode))]
[SerializedCommandProperty(AccessLevel.Developer)]
private SpawnPositionMode _spawnPositionMode;
@ -156,13 +154,12 @@ public abstract partial class BaseSpawner : Item, ISpawner
/// <summary>
/// Maximum number of random position attempts before engaging optimization.
/// </summary>
[SerializableFieldSaveFlag(14)]
private bool ShouldSerializeMaxSpawnAttempts() => _maxSpawnAttempts != DefaultMaxSpawnAttempts;
[SerializableFieldDefault(14)]
private int MaxSpawnAttemptsDefault() => DefaultMaxSpawnAttempts;
[SerializableField(14)]
[SaveFlag(nameof(ShouldSerializeMaxSpawnAttempts), nameof(MaxSpawnAttemptsDefault))]
[SerializedCommandProperty(AccessLevel.Developer)]
private int _maxSpawnAttempts;
@ -314,26 +311,20 @@ public abstract partial class BaseSpawner : Item, ISpawner
}
}
[SerializableProperty(8)]
[CommandProperty(AccessLevel.Developer)]
public int Count
[SerializableField(8, fieldChanged: nameof(OnCountChanged))]
[SerializedCommandProperty(AccessLevel.Developer)]
[InvalidateProperties]
private int _count;
private void OnCountChanged(int oldValue, int newValue)
{
get => _count;
set
if (IsFull)
{
_count = value;
if (IsFull)
{
_timer?.Stop();
}
else if (_timer?.Running != true)
{
DoTimer();
}
InvalidateProperties();
this.MarkDirty();
_timer?.Stop();
}
else if (_timer?.Running != true)
{
DoTimer();
}
}

View file

@ -10,17 +10,17 @@ public partial class Spawner : BaseSpawner
/// When true, enables proactive spiral scanning to find valid spawn positions.
/// Only relevant when SpawnPositionMode is Automatic or Enabled.
/// </summary>
[SerializableFieldSaveFlag(0)]
private bool ShouldSerializeUseSpiralScan() => _useSpiralScan;
[SerializableField(0)]
[SaveFlag(nameof(ShouldSerializeUseSpiralScan))]
[SerializedCommandProperty(AccessLevel.Developer)]
private bool _useSpiralScan;
[SerializableFieldSaveFlag(1)]
private bool ShouldSerializeSpawnBounds() => _spawnBounds != default;
[SerializableProperty(1)]
[SaveFlag(nameof(ShouldSerializeSpawnBounds))]
[CommandProperty(AccessLevel.Developer)]
public override Rectangle3D SpawnBounds
{

View file

@ -248,7 +248,7 @@ public class StealableArtifacts : GenericPersistence
public override void Serialize(IGenericWriter writer)
{
writer.WriteEncodedInt(1); // version
writer.WriteEncodedInt(2); // version
writer.Write(_enabled);
@ -261,7 +261,7 @@ public class StealableArtifacts : GenericPersistence
var si = _artifacts[i];
writer.Write(si.Item);
writer.WriteDeltaTime(si.NextRespawn);
writer.WriteAnchoredTime(si.NextRespawn);
}
}
}
@ -282,7 +282,7 @@ public class StealableArtifacts : GenericPersistence
for (var i = 0; i < length; i++)
{
var item = reader.ReadEntity<Item>();
var nextRespawn = reader.ReadDeltaTime();
var nextRespawn = version >= 2 ? reader.ReadAnchoredTime() : reader.ReadDeltaTime();
if (i < _artifacts.Length)
{

View file

@ -332,27 +332,22 @@ public partial class PigmentsOfTokuno : BasePigmentsOfTokuno
[Constructible]
public PigmentsOfTokuno(PigmentType type, int uses) : base(uses) => Type = type;
[SerializableProperty(0)]
[CommandProperty(AccessLevel.GameMaster)]
public PigmentType Type
[SerializableField(0, fieldChanged: nameof(OnTypeChanged))]
[SerializedCommandProperty(AccessLevel.GameMaster)]
private PigmentType _type;
private void OnTypeChanged(PigmentType oldValue, PigmentType newValue)
{
get => _type;
set
var v = (int)_type;
if (v >= 0 && v < _table.Length)
{
_type = value;
var v = (int)_type;
if (v >= 0 && v < _table.Length)
{
Hue = _table[v][0];
Label = _table[v][1];
}
else
{
Hue = 0;
Label = -1;
}
Hue = _table[v][0];
Label = _table[v][1];
}
else
{
Hue = 0;
Label = -1;
}
}

View file

@ -617,27 +617,22 @@ public partial class LesserPigmentsOfTokuno : BasePigmentsOfTokuno
[Constructible]
public LesserPigmentsOfTokuno(LesserPigmentType type) : base(1) => Type = type;
[SerializableProperty(0)]
[CommandProperty(AccessLevel.GameMaster)]
public LesserPigmentType Type
[SerializableField(0, fieldChanged: nameof(OnTypeChanged))]
[SerializedCommandProperty(AccessLevel.GameMaster)]
private LesserPigmentType _type;
private void OnTypeChanged(LesserPigmentType oldValue, LesserPigmentType newValue)
{
get => _type;
set
var v = (int)_type;
if (v >= 0 && v < _table.Length)
{
_type = value;
var v = (int)_type;
if (v >= 0 && v < _table.Length)
{
Hue = _table[v][0];
Label = _table[v][1];
}
else
{
Hue = 0;
Label = -1;
}
Hue = _table[v][0];
Label = _table[v][1];
}
else
{
Hue = 0;
Label = -1;
}
}

View file

@ -79,45 +79,33 @@ public partial class CharacterStatue : Mobile, IRewardItem
InvalidateHues();
}
[SerializableProperty(0)]
[CommandProperty(AccessLevel.GameMaster)]
public StatueType StatueType
[SerializableField(0, fieldChanged: nameof(OnStatueTypeChanged))]
[SerializedCommandProperty(AccessLevel.GameMaster)]
private StatueType _statueType;
private void OnStatueTypeChanged(StatueType oldValue, StatueType newValue)
{
get => _statueType;
set
{
_statueType = value;
InvalidateHues();
InvalidatePose();
this.MarkDirty();
}
InvalidateHues();
InvalidatePose();
}
[SerializableProperty(1)]
[CommandProperty(AccessLevel.GameMaster)]
public StatuePose Pose
[SerializableField(1, fieldChanged: nameof(OnPoseChanged))]
[SerializedCommandProperty(AccessLevel.GameMaster)]
private StatuePose _pose;
private void OnPoseChanged(StatuePose oldValue, StatuePose newValue)
{
get => _pose;
set
{
_pose = value;
InvalidatePose();
this.MarkDirty();
}
InvalidatePose();
}
[SerializableProperty(2)]
[CommandProperty(AccessLevel.GameMaster)]
public StatueMaterial Material
[SerializableField(2, fieldChanged: nameof(OnMaterialChanged))]
[SerializedCommandProperty(AccessLevel.GameMaster)]
private StatueMaterial _material;
private void OnMaterialChanged(StatueMaterial oldValue, StatueMaterial newValue)
{
get => _material;
set
{
_material = value;
InvalidateHues();
InvalidatePose();
this.MarkDirty();
}
InvalidateHues();
InvalidatePose();
}
public override void OnDoubleClick(Mobile from)

View file

@ -25,17 +25,13 @@ public partial class CharacterStatueMaker : Item, IRewardItem
public override int LabelNumber => 1076173; // Character Statue Maker
[SerializableProperty(1)]
[CommandProperty(AccessLevel.GameMaster)]
public StatueType StatueType
[SerializableField(1, fieldChanged: nameof(OnStatueTypeChanged))]
[SerializedCommandProperty(AccessLevel.GameMaster)]
private StatueType _statueType;
private void OnStatueTypeChanged(StatueType oldValue, StatueType newValue)
{
get => _statueType;
set
{
_statueType = value;
InvalidateHue();
this.MarkDirty();
}
InvalidateHue();
}
public override void OnDoubleClick(Mobile from)

View file

@ -5,102 +5,121 @@ using Server.Mobiles;
namespace Server.Engines.Virtues;
[PropertyObject]
[SerializationGenerator(0)]
[SerializationGenerator(1)]
public partial class VirtueContext
{
[DeltaDateTime]
private void MigrateFrom(V0Content content)
{
// Save-flagged values arrive as nullables; unset flags fall back to the same
// defaults the old deserialize left in place.
_lastSacrificeGain = content.LastSacrificeGain ?? default;
_lastSacrificeLoss = content.LastSacrificeLoss ?? default;
_availableResurrects = content.AvailableResurrects ?? 0;
_lastJusticeLoss = content.LastJusticeLoss ?? default;
_lastCompassionLoss = content.LastCompassionLoss ?? default;
_nextCompassionDay = content.NextCompassionDay ?? default;
_compassionGains = content.CompassionGains ?? 0;
_lastValorLoss = content.LastValorLoss ?? default;
_lastHonorUse = content.LastHonorUse ?? default;
_honorActive = content.HonorActive;
_justiceProtection = content.JusticeProtection;
_justiceStatus = content.JusticeStatus ?? JusticeProtectorStatus.None;
_values = content.Values;
}
[AnchoredDateTime]
[SerializableField(0)]
[SaveFlag(nameof(ShouldSerializeLastSacrificeGain))]
[SerializedCommandProperty(AccessLevel.GameMaster, AccessLevel.Administrator)]
private DateTime _lastSacrificeGain;
[SerializableFieldSaveFlag(0)]
private bool ShouldSerializeLastSacrificeGain() => !SacrificeVirtue.CanGain(this);
[DeltaDateTime]
[AnchoredDateTime]
[SerializableField(1)]
[SaveFlag(nameof(ShouldSerializeLastSacrificeLoss))]
[SerializedCommandProperty(AccessLevel.GameMaster, AccessLevel.Administrator)]
private DateTime _lastSacrificeLoss;
[SerializableFieldSaveFlag(1)]
private bool ShouldSerializeLastSacrificeLoss() => !SacrificeVirtue.CanAtrophy(this);
[SerializableField(2)]
[SaveFlag(nameof(ShouldSerializeAvailableResurrects))]
[SerializedCommandProperty(AccessLevel.GameMaster)]
private int _availableResurrects;
[SerializableFieldSaveFlag(2)]
private bool ShouldSerializeAvailableResurrects() => _availableResurrects > 0;
[DeltaDateTime]
[AnchoredDateTime]
[SerializableField(3)]
[SaveFlag(nameof(ShouldSerializeLastJusticeLoss))]
[SerializedCommandProperty(AccessLevel.GameMaster, AccessLevel.Administrator)]
private DateTime _lastJusticeLoss;
[SerializableFieldSaveFlag(3)]
private bool ShouldSerializeLastJusticeLoss() => !JusticeVirtue.CanAtrophy(this);
[DeltaDateTime]
[AnchoredDateTime]
[SerializableField(4)]
[SaveFlag(nameof(ShouldSerializeLastCompassionLoss))]
[SerializedCommandProperty(AccessLevel.GameMaster, AccessLevel.Administrator)]
private DateTime _lastCompassionLoss;
[SerializableFieldSaveFlag(4)]
private bool ShouldSerializeLastCompassionLoss() => !CompassionVirtue.CanAtrophy(this);
[DeltaDateTime]
[AnchoredDateTime]
[SerializableField(5)]
[SaveFlag(nameof(ShouldSerializeNextCompassionDay))]
[SerializedCommandProperty(AccessLevel.GameMaster)]
private DateTime _nextCompassionDay;
[SerializableFieldSaveFlag(5)]
private bool ShouldSerializeNextCompassionDay() => _nextCompassionDay > Core.Now;
[SerializableField(6)]
[SaveFlag(nameof(ShouldSerializeCompassionGains))]
[SerializedCommandProperty(AccessLevel.GameMaster)]
private int _compassionGains;
[SerializableFieldSaveFlag(6)]
private bool ShouldSerializeCompassionGains() => _compassionGains > 0;
[DeltaDateTime]
[AnchoredDateTime]
[SerializableField(7)]
[SaveFlag(nameof(ShouldSerializeValorLoss))]
[SerializedCommandProperty(AccessLevel.GameMaster, AccessLevel.Administrator)]
private DateTime _lastValorLoss;
[SerializableFieldSaveFlag(7)]
private bool ShouldSerializeValorLoss() => !ValorVirtue.CanAtrophy(this);
[DeltaDateTime]
[AnchoredDateTime]
[SerializableField(8)]
[SaveFlag(nameof(ShouldSerializeLastHonorUse))]
[SerializedCommandProperty(AccessLevel.GameMaster)]
private DateTime _lastHonorUse;
[SerializableFieldSaveFlag(8)]
private bool ShouldSerializeLastHonorUse() => !HonorVirtue.CanUse(this);
[SerializableField(9)]
[SaveFlag(nameof(ShouldSerializeHonorActive))]
[SerializedCommandProperty(AccessLevel.GameMaster, AccessLevel.Administrator)]
private bool _honorActive;
[SerializableFieldSaveFlag(9)]
private bool ShouldSerializeHonorActive() => _honorActive;
[SerializableField(10)]
[SaveFlag(nameof(ShouldSerializeJusticeProtection))]
private PlayerMobile _justiceProtection;
[SerializableFieldSaveFlag(10)]
private bool ShouldSerializeJusticeProtection() => _justiceProtection != null && _justiceStatus != JusticeProtectorStatus.None;
[SerializableField(11)]
[SaveFlag(nameof(ShouldSerializeJusticeStatus))]
private JusticeProtectorStatus _justiceStatus;
[SerializableFieldSaveFlag(11)]
private bool ShouldSerializeJusticeStatus() => _justiceProtection != null && _justiceStatus != JusticeProtectorStatus.None;
[SerializableField(12, setter: "private")]
[SaveFlag(nameof(ShouldSerializeValues))]
private int[] _values;
[SerializableFieldSaveFlag(12)]
private bool ShouldSerializeValues()
{
if (_values == null)

View file

@ -63,22 +63,14 @@ namespace Server.Items
}
}
[SerializableProperty(1)]
[CommandProperty(AccessLevel.GameMaster)]
public CraftResource Resource
{
get => _resource;
set
{
if (_resource != value)
{
_resource = value;
Hue = CraftResources.GetHue(_resource);
[SerializableField(1, fieldChanged: nameof(OnResourceChanged))]
[SerializedCommandProperty(AccessLevel.GameMaster)]
[InvalidateProperties]
private CraftResource _resource;
InvalidateProperties();
this.MarkDirty();
}
}
private void OnResourceChanged(CraftResource oldValue, CraftResource newValue)
{
Hue = CraftResources.GetHue(_resource);
}
Item IAddon.Deed => Deed;

View file

@ -41,22 +41,14 @@ namespace Server.Items
}
}
[SerializableProperty(1)]
[CommandProperty(AccessLevel.GameMaster)]
public CraftResource Resource
{
get => _resource;
set
{
if (_resource != value)
{
_resource = value;
Hue = CraftResources.GetHue(_resource);
[SerializableField(1, fieldChanged: nameof(OnResourceChanged))]
[SerializedCommandProperty(AccessLevel.GameMaster)]
[InvalidateProperties]
private CraftResource _resource;
InvalidateProperties();
this.MarkDirty();
}
}
private void OnResourceChanged(CraftResource oldValue, CraftResource newValue)
{
Hue = CraftResources.GetHue(_resource);
}
public virtual bool RetainDeedHue => false;

View file

@ -23,22 +23,14 @@ public abstract partial class BaseAddonContainerDeed : Item, ICraftable
public abstract BaseAddonContainer Addon { get; }
[SerializableProperty(0)]
[CommandProperty(AccessLevel.GameMaster)]
public CraftResource Resource
{
get => _resource;
set
{
if (_resource != value)
{
_resource = value;
Hue = CraftResources.GetHue(_resource);
[SerializableField(0, fieldChanged: nameof(OnResourceChanged))]
[SerializedCommandProperty(AccessLevel.GameMaster)]
[InvalidateProperties]
private CraftResource _resource;
InvalidateProperties();
this.MarkDirty();
}
}
private void OnResourceChanged(CraftResource oldValue, CraftResource newValue)
{
Hue = CraftResources.GetHue(_resource);
}
public virtual int OnCraft(

View file

@ -48,17 +48,19 @@ namespace Server.Items
[CommandProperty(AccessLevel.GameMaster)]
public int MaxFlour => 2;
[SerializableProperty(0)]
[CommandProperty(AccessLevel.GameMaster)]
public int CurFlour
[SerializableField(0, fieldChanged: nameof(OnCurFlourChanged), allowFieldChange: nameof(AllowCurFlourChange))]
[SerializedCommandProperty(AccessLevel.GameMaster)]
private int _curFlour;
private bool AllowCurFlourChange(ref int value)
{
get => _curFlour;
set
{
_curFlour = Math.Clamp(value, 0, MaxFlour);
UpdateStage();
this.MarkDirty();
}
value = Math.Clamp(value, 0, MaxFlour);
return true;
}
private void OnCurFlourChanged(int oldValue, int newValue)
{
UpdateStage();
}
public void StartWorking(Mobile from)

View file

@ -35,16 +35,19 @@ namespace Server.Items
[CommandProperty(AccessLevel.GameMaster)]
public int MaxFlour => 2;
[SerializableProperty(0)]
[CommandProperty(AccessLevel.GameMaster)]
public int CurFlour
[SerializableField(0, fieldChanged: nameof(OnCurFlourChanged), allowFieldChange: nameof(AllowCurFlourChange))]
[SerializedCommandProperty(AccessLevel.GameMaster)]
private int _curFlour;
private bool AllowCurFlourChange(ref int value)
{
get => _curFlour;
set
{
_curFlour = Math.Max(0, Math.Min(value, MaxFlour));
UpdateStage();
}
value = Math.Max(0, Math.Min(value, MaxFlour));
return true;
}
private void OnCurFlourChanged(int oldValue, int newValue)
{
UpdateStage();
}
public void StartWorking(Mobile from)

View file

@ -25,35 +25,27 @@ namespace Server.Items
_teleOffset = offset;
}
[SerializableProperty(0)]
[CommandProperty(AccessLevel.GameMaster)]
public bool Active
{
get => _active;
set
{
_active = value;
[SerializableField(0, fieldChanged: nameof(OnActiveChanged))]
[SerializedCommandProperty(AccessLevel.GameMaster)]
private bool _active;
if (Addon is SHTeleporter sourceAddon)
{
sourceAddon.ChangeActive(value);
}
private void OnActiveChanged(bool oldValue, bool newValue)
{
if (Addon is SHTeleporter sourceAddon)
{
sourceAddon.ChangeActive(newValue);
}
}
[SerializableProperty(1)]
[CommandProperty(AccessLevel.GameMaster)]
public SHTeleComponent TeleDest
{
get => _teleDest;
set
{
_teleDest = value;
[SerializableField(1, fieldChanged: nameof(OnTeleDestChanged))]
[SerializedCommandProperty(AccessLevel.GameMaster)]
private SHTeleComponent _teleDest;
if (Addon is SHTeleporter sourceAddon)
{
sourceAddon.ChangeDest(value);
}
private void OnTeleDestChanged(SHTeleComponent oldValue, SHTeleComponent newValue)
{
if (Addon is SHTeleporter sourceAddon)
{
sourceAddon.ChangeDest(newValue);
}
}

View file

@ -31,9 +31,9 @@ namespace Server.Items
private bool m_EvaluateDay;
[SerializableField(0, setter: "private")]
[DeserializeTimer(nameof(DeserializeEvaluateTimer), wallClock: true)]
private Timer _evaluateTimer;
[DeserializeTimerField(0)]
private void DeserializeEvaluateTimer(TimeSpan delay)
{
_evaluateTimer = Timer.DelayCall(delay, EvaluationInterval, Evaluate);

View file

@ -30,19 +30,14 @@ namespace Server.Items
public AquariumState(Aquarium parent) => _aquarium = parent;
[SerializableProperty(0)]
[CommandProperty(AccessLevel.GameMaster)]
public int State
[SerializableField(0, allowFieldChange: nameof(AllowStateChange))]
[SerializedCommandProperty(AccessLevel.GameMaster)]
private int _state;
private bool AllowStateChange(ref int value)
{
get => _state;
set
{
if (_state != value)
{
_state = Math.Clamp(value, 0, 4);
MarkDirty();
}
}
value = Math.Clamp(value, 0, 4);
return true;
}
[SerializableField(1)]

View file

@ -18,95 +18,92 @@ namespace Server.Items
{
[SerializedIgnoreDupe]
[SerializableField(0, setter: "private")]
[SaveFlag(nameof(ShouldSerializeAosAttributes), nameof(AttributesDefaultValue))]
[SerializedCommandProperty(AccessLevel.GameMaster, canModify: true)]
private AosAttributes _attributes;
[SerializableFieldSaveFlag(0)]
private bool ShouldSerializeAosAttributes() => !_attributes.IsEmpty;
[SerializableFieldDefault(0)]
private AosAttributes AttributesDefaultValue() => new(this);
[SerializedIgnoreDupe]
[SerializableField(1, setter: "private")]
[SaveFlag(nameof(ShouldSerializeArmorAttributes), nameof(ArmorAttributesDefaultValue))]
[SerializedCommandProperty(AccessLevel.GameMaster, canModify: true)]
private AosArmorAttributes _armorAttributes;
[SerializableFieldSaveFlag(1)]
private bool ShouldSerializeArmorAttributes() => !_armorAttributes.IsEmpty;
[SerializableFieldDefault(1)]
private AosArmorAttributes ArmorAttributesDefaultValue() => new(this);
[EncodedInt]
[InvalidateProperties]
[SerializableField(2)]
[SaveFlag(nameof(ShouldSerializePhysicalBonus))]
[SerializedCommandProperty(AccessLevel.GameMaster)]
private int _physicalBonus;
[SerializableFieldSaveFlag(2)]
private bool ShouldSerializePhysicalBonus() => _physicalBonus != 0;
[EncodedInt]
[InvalidateProperties]
[SerializableField(3)]
[SaveFlag(nameof(ShouldSerializeFireBonus))]
[SerializedCommandProperty(AccessLevel.GameMaster)]
private int _fireBonus;
[SerializableFieldSaveFlag(3)]
private bool ShouldSerializeFireBonus() => _fireBonus != 0;
[EncodedInt]
[InvalidateProperties]
[SerializableField(4)]
[SaveFlag(nameof(ShouldSerializeColdBonus))]
[SerializedCommandProperty(AccessLevel.GameMaster)]
private int _coldBonus;
[SerializableFieldSaveFlag(4)]
private bool ShouldSerializeColdBonus() => _coldBonus != 0;
[EncodedInt]
[InvalidateProperties]
[SerializableField(5)]
[SaveFlag(nameof(ShouldSerializePoisonBonus))]
[SerializedCommandProperty(AccessLevel.GameMaster)]
private int _poisonBonus;
[SerializableFieldSaveFlag(5)]
private bool ShouldSerializePoisonBonus() => _poisonBonus != 0;
[EncodedInt]
[InvalidateProperties]
[SerializableField(6)]
[SaveFlag(nameof(ShouldSerializeEnergyBonus))]
[SerializedCommandProperty(AccessLevel.GameMaster)]
private int _energyBonus;
[SerializableFieldSaveFlag(6)]
private bool ShouldSerializeEnergyBonus() => _energyBonus != 0;
[SerializableField(7)]
[SaveFlag(nameof(ShouldSerializeIdentified))]
[SerializedCommandProperty(AccessLevel.GameMaster)]
private bool _identified;
[SerializableFieldSaveFlag(7)]
private bool ShouldSerializeIdentified() => _identified;
[EncodedInt]
[SerializableField(8)]
[SaveFlag(nameof(ShouldSerializeMaxHitPoints))]
[SerializedCommandProperty(AccessLevel.GameMaster)]
private int _maxHitPoints;
[SerializableFieldSaveFlag(8)]
private bool ShouldSerializeMaxHitPoints() => _maxHitPoints != 0;
[InvalidateProperties]
[SerializableField(10)]
[SaveFlag(nameof(ShouldSerializeCrafter))]
[SerializedCommandProperty(AccessLevel.GameMaster)]
private string _crafter;
[SerializableFieldSaveFlag(10)]
private bool ShouldSerializeCrafter() => !string.IsNullOrEmpty(_crafter);
[SerializableFieldSaveFlag(14)]
private bool ShouldSerializeResource() => _resource != DefaultResource;
// Field 15
@ -135,13 +132,12 @@ namespace Server.Items
[SerializedIgnoreDupe]
[SerializableField(23, setter: "private")]
[SaveFlag(nameof(ShouldSerializeSkillBonuses), nameof(SkillBonusesDefaultValue))]
[SerializedCommandProperty(AccessLevel.GameMaster, canModify: true)]
public AosSkillBonuses _skillBonuses;
[SerializableFieldSaveFlag(23)]
private bool ShouldSerializeSkillBonuses() => !_skillBonuses.IsEmpty;
[SerializableFieldDefault(23)]
private AosSkillBonuses SkillBonusesDefaultValue() => new(this);
private FactionItem m_FactionState;
@ -190,6 +186,7 @@ namespace Server.Items
public virtual int OldIntReq => 0;
[SerializableProperty(11)]
[SaveFlag(nameof(ShouldSerializeArmorQuality), nameof(QualityDefaultValue))]
[CommandProperty(AccessLevel.GameMaster)]
public ArmorQuality Quality
{
@ -202,13 +199,12 @@ namespace Server.Items
}
}
[SerializableFieldSaveFlag(11)]
private bool ShouldSerializeArmorQuality() => _quality != ArmorQuality.Regular;
[SerializableFieldDefault(11)]
private ArmorQuality QualityDefaultValue() => ArmorQuality.Regular;
[SerializableProperty(12)]
[SaveFlag(nameof(ShouldSerializeDurability))]
[CommandProperty(AccessLevel.GameMaster)]
public ArmorDurabilityLevel Durability
{
@ -221,33 +217,24 @@ namespace Server.Items
}
}
[SerializableFieldSaveFlag(12)]
private bool ShouldSerializeDurability() => _durability != ArmorDurabilityLevel.Regular;
[SerializableProperty(13)]
[CommandProperty(AccessLevel.GameMaster)]
public ArmorProtectionLevel ProtectionLevel
[SerializableField(13, fieldChanged: nameof(OnProtectionLevelChanged))]
[SaveFlag(nameof(ShouldSerializeProtectionLevel))]
[SerializedCommandProperty(AccessLevel.GameMaster)]
[InvalidateProperties]
private ArmorProtectionLevel _protectionLevel;
private void OnProtectionLevelChanged(ArmorProtectionLevel oldValue, ArmorProtectionLevel newValue)
{
get => _protectionLevel;
set
{
if (_protectionLevel != value)
{
_protectionLevel = value;
Invalidate();
InvalidateProperties();
(Parent as Mobile)?.UpdateResistances();
this.MarkDirty();
}
}
Invalidate();
(Parent as Mobile)?.UpdateResistances();
}
[SerializableFieldSaveFlag(13)]
private bool ShouldSerializeProtectionLevel() => _protectionLevel != ArmorProtectionLevel.Regular;
[SerializableProperty(14)]
[SaveFlag(nameof(ShouldSerializeResource), nameof(ResourceDefaultValue))]
[CommandProperty(AccessLevel.GameMaster)]
public CraftResource Resource
{
@ -273,11 +260,11 @@ namespace Server.Items
}
}
[SerializableFieldDefault(14)]
private CraftResource ResourceDefaultValue() => DefaultResource;
[EncodedInt]
[SerializableProperty(15, useField: nameof(_armorBase))]
[SaveFlag(nameof(ShouldSerializeArmorBase), nameof(ArmorBaseDefaultValue))]
[CommandProperty(AccessLevel.GameMaster)]
public int BaseArmorRating
{
@ -290,10 +277,8 @@ namespace Server.Items
}
}
[SerializableFieldSaveFlag(15)]
private bool ShouldSerializeArmorBase() => _armorBase != -1;
[SerializableFieldDefault(15)]
private int ArmorBaseDefaultValue() => -1;
public double BaseArmorRatingScaled => BaseArmorRating * ArmorScalar;
@ -343,6 +328,7 @@ namespace Server.Items
[EncodedInt]
[SerializableProperty(16, useField: nameof(_strBonus))]
[SaveFlag(nameof(ShouldSerializeStrBonus), nameof(StrBonusDefaultValue))]
[CommandProperty(AccessLevel.GameMaster)]
public int StrBonus
{
@ -355,14 +341,13 @@ namespace Server.Items
}
}
[SerializableFieldSaveFlag(16)]
private bool ShouldSerializeStrBonus() => _strBonus != -1;
[SerializableFieldDefault(16)]
private int StrBonusDefaultValue() => -1;
[EncodedInt]
[SerializableProperty(17, useField: nameof(_dexBonus))]
[SaveFlag(nameof(ShouldSerializeDexBonus), nameof(DexBonusDefaultValue))]
[CommandProperty(AccessLevel.GameMaster)]
public int DexBonus
{
@ -375,14 +360,13 @@ namespace Server.Items
}
}
[SerializableFieldSaveFlag(17)]
private bool ShouldSerializeDexBonus() => _dexBonus != -1;
[SerializableFieldDefault(17)]
private int DexBonusDefaultValue() => -1;
[EncodedInt]
[SerializableProperty(18, useField: nameof(_intBonus))]
[SaveFlag(nameof(ShouldSerializeIntBonus), nameof(IntBonusDefaultValue))]
[CommandProperty(AccessLevel.GameMaster)]
public int IntBonus
{
@ -395,14 +379,13 @@ namespace Server.Items
}
}
[SerializableFieldSaveFlag(18)]
private bool ShouldSerializeIntBonus() => _intBonus != -1;
[SerializableFieldDefault(18)]
private int IntBonusDefaultValue() => -1;
[EncodedInt]
[SerializableProperty(19, useField: nameof(_strReq))]
[SaveFlag(nameof(ShouldSerializeStrReq), nameof(StrReqDefaultValue))]
[CommandProperty(AccessLevel.GameMaster)]
public int StrRequirement
{
@ -415,14 +398,13 @@ namespace Server.Items
}
}
[SerializableFieldSaveFlag(19)]
private bool ShouldSerializeStrReq() => _strReq != -1;
[SerializableFieldDefault(19)]
private int StrReqDefaultValue() => -1;
[EncodedInt]
[SerializableProperty(20, useField: nameof(_dexReq))]
[SaveFlag(nameof(ShouldSerializeDexReq), nameof(DexReqDefaultValue))]
[CommandProperty(AccessLevel.GameMaster)]
public int DexRequirement
{
@ -435,14 +417,13 @@ namespace Server.Items
}
}
[SerializableFieldSaveFlag(20)]
private bool ShouldSerializeDexReq() => _dexReq != -1;
[SerializableFieldDefault(20)]
private int DexReqDefaultValue() => -1;
[EncodedInt]
[SerializableProperty(21, useField: nameof(_intReq))]
[SaveFlag(nameof(ShouldSerializeIntReq), nameof(IntReqDefaultValue))]
[CommandProperty(AccessLevel.GameMaster)]
public int IntRequirement
{
@ -455,13 +436,12 @@ namespace Server.Items
}
}
[SerializableFieldSaveFlag(21)]
private bool ShouldSerializeIntReq() => _intReq != -1;
[SerializableFieldDefault(21)]
private int IntReqDefaultValue() => -1;
[SerializableProperty(22, useField: nameof(_meditate))]
[SaveFlag(nameof(ShouldSerializeMeditationAllowance))]
[CommandProperty(AccessLevel.GameMaster)]
public AMA MeditationAllowance
{
@ -473,7 +453,6 @@ namespace Server.Items
}
}
[SerializableFieldSaveFlag(22)]
private bool ShouldSerializeMeditationAllowance() => _meditate >= AMA.All;
public virtual double ArmorScalar
@ -689,6 +668,7 @@ namespace Server.Items
[EncodedInt]
[SerializableProperty(9)]
[SaveFlag(nameof(ShouldSerializeHitPoints))]
[CommandProperty(AccessLevel.GameMaster)]
public int HitPoints
{
@ -716,7 +696,6 @@ namespace Server.Items
}
}
[SerializableFieldSaveFlag(9)]
private bool ShouldSerializeHitPoints() => _hitPoints != 0;
public virtual int InitMinHits => 0;

View file

@ -31,13 +31,12 @@ namespace Server.Items
public override ArmorMeditationAllowance DefMedAllowance => ArmorMeditationAllowance.All;
[SerializableField(0, setter: "private")]
[SaveFlag(nameof(ShouldSerializeWeaponAttributes), nameof(WeaponAttributesDefaultValue))]
[SerializedCommandProperty(AccessLevel.GameMaster, canModify: true)]
public AosWeaponAttributes _weaponAttributes;
[SerializableFieldSaveFlag(0)]
private bool ShouldSerializeWeaponAttributes() => !_weaponAttributes.IsEmpty;
[SerializableFieldDefault(0)]
private AosWeaponAttributes WeaponAttributesDefaultValue() => new(this);
public override void AppendChildNameProperties(IPropertyList list)

View file

@ -33,32 +33,26 @@ namespace Server.Items
public override ArmorMeditationAllowance DefMedAllowance => ArmorMeditationAllowance.All;
[SerializableField(0, fieldChanged: nameof(OnCurArcaneChargesChanged))]
[EncodedInt]
[SerializableProperty(0)]
[CommandProperty(AccessLevel.GameMaster)]
public int CurArcaneCharges
[SerializedCommandProperty(AccessLevel.GameMaster)]
[InvalidateProperties]
private int _curArcaneCharges;
private void OnCurArcaneChargesChanged(int oldValue, int newValue)
{
get => _curArcaneCharges;
set
{
_curArcaneCharges = value;
InvalidateProperties();
Update();
}
Update();
}
[SerializableField(1, fieldChanged: nameof(OnMaxArcaneChargesChanged))]
[EncodedInt]
[SerializableProperty(1)]
[CommandProperty(AccessLevel.GameMaster)]
public int MaxArcaneCharges
[SerializedCommandProperty(AccessLevel.GameMaster)]
[InvalidateProperties]
private int _maxArcaneCharges;
private void OnMaxArcaneChargesChanged(int oldValue, int newValue)
{
get => _maxArcaneCharges;
set
{
_maxArcaneCharges = value;
InvalidateProperties();
Update();
}
Update();
}
[CommandProperty(AccessLevel.GameMaster)]

View file

@ -32,32 +32,26 @@ namespace Server.Items
public override ArmorMeditationAllowance DefMedAllowance => ArmorMeditationAllowance.All;
[SerializableField(0, fieldChanged: nameof(OnCurArcaneChargesChanged))]
[EncodedInt]
[SerializableProperty(0)]
[CommandProperty(AccessLevel.GameMaster)]
public int CurArcaneCharges
[SerializedCommandProperty(AccessLevel.GameMaster)]
[InvalidateProperties]
private int _curArcaneCharges;
private void OnCurArcaneChargesChanged(int oldValue, int newValue)
{
get => _curArcaneCharges;
set
{
_curArcaneCharges = value;
InvalidateProperties();
Update();
}
Update();
}
[SerializableField(1, fieldChanged: nameof(OnMaxArcaneChargesChanged))]
[EncodedInt]
[SerializableProperty(1)]
[CommandProperty(AccessLevel.GameMaster)]
public int MaxArcaneCharges
[SerializedCommandProperty(AccessLevel.GameMaster)]
[InvalidateProperties]
private int _maxArcaneCharges;
private void OnMaxArcaneChargesChanged(int oldValue, int newValue)
{
get => _maxArcaneCharges;
set
{
_maxArcaneCharges = value;
InvalidateProperties();
Update();
}
Update();
}
[CommandProperty(AccessLevel.GameMaster)]

View file

@ -19,41 +19,38 @@ namespace Server.Items
[InternString]
[InvalidateProperties]
[SerializableField(1)]
[SaveFlag(nameof(ShouldSerializeTitle), nameof(TitleDefaultValue))]
[SerializedCommandProperty(AccessLevel.GameMaster)]
private string _title;
[SerializableFieldSaveFlag(1)]
private bool ShouldSerializeTitle() => _title != DefaultContent?.Title;
[SerializableFieldDefault(1)]
private string TitleDefaultValue() => DefaultContent?.Title;
[InvalidateProperties]
[SerializableField(2)]
[SaveFlag(nameof(ShouldSerializeAuthor), nameof(AuthorDefaultValue))]
[SerializedCommandProperty(AccessLevel.GameMaster)]
private string _author;
[SerializableFieldSaveFlag(2)]
private bool ShouldSerializeAuthor() => _author != DefaultContent?.Author;
[SerializableFieldDefault(2)]
private string AuthorDefaultValue() => DefaultContent?.Author;
[SerializableField(3)]
[SaveFlag(nameof(ShouldSerializeWritable))]
[SerializedCommandProperty(AccessLevel.GameMaster)]
private bool _writable;
[SerializableFieldSaveFlag(3)]
private bool ShouldSerializeWritable() => _writable;
[SerializedIgnoreDupe]
[SerializableField(4, setter: "protected")]
[SaveFlag(nameof(ShouldSerializePages), nameof(PagesDefaultValue))]
private BookPageInfo[] _pages;
[SerializableFieldSaveFlag(4)]
private bool ShouldSerializePages() => DefaultContent?.IsMatch(_pages) != true;
[SerializableFieldDefault(4)]
private BookPageInfo[] PagesDefaultValue() => DefaultContent?.Copy() ?? Array.Empty<BookPageInfo>();
[Constructible]

View file

@ -26,76 +26,71 @@ namespace Server.Items
public abstract partial class BaseClothing
: Item, IDyable, IScissorable, IFactionItem, ICraftable, IWearableDurability, IAosItem
{
[SerializableFieldSaveFlag(0)]
private bool ShouldSerializeResource() => _resource != DefaultResource;
[SerializedIgnoreDupe]
[SerializableField(1, setter: "private")]
[SaveFlag(nameof(ShouldSerializeAttributes), nameof(AttributesDefaultValue))]
[SerializedCommandProperty(AccessLevel.GameMaster, canModify: true)]
private AosAttributes _attributes;
[SerializableFieldSaveFlag(1)]
private bool ShouldSerializeAttributes() => !_attributes.IsEmpty;
[SerializableFieldDefault(1)]
private AosAttributes AttributesDefaultValue() => new(this);
[SerializedIgnoreDupe]
[SerializableField(2, setter: "private")]
[SaveFlag(nameof(ShouldSerializeClothingAttributes), nameof(ClothingAttributesDefaultValue))]
[SerializedCommandProperty(AccessLevel.GameMaster, canModify: true)]
private AosArmorAttributes _clothingAttributes;
[SerializableFieldSaveFlag(2)]
private bool ShouldSerializeClothingAttributes() => !_clothingAttributes.IsEmpty;
[SerializableFieldDefault(2)]
private AosArmorAttributes ClothingAttributesDefaultValue() => new(this);
[SerializedIgnoreDupe]
[SerializableField(3, setter: "private")]
[SaveFlag(nameof(ShouldSerializeSkillBonuses), nameof(SkillBonusesDefaultValue))]
[SerializedCommandProperty(AccessLevel.GameMaster, canModify: true)]
private AosSkillBonuses _skillBonuses;
[SerializableFieldSaveFlag(3)]
private bool ShouldSerializeSkillBonuses() => !_skillBonuses.IsEmpty;
[SerializableFieldDefault(3)]
private AosSkillBonuses SkillBonusesDefaultValue() => new(this);
[SerializedIgnoreDupe]
[SerializableField(4, setter: "private")]
[SaveFlag(nameof(ShouldSerializeResistances), nameof(ResistancesDefaultValue))]
[SerializedCommandProperty(AccessLevel.GameMaster, canModify: true)]
private AosElementAttributes _resistances;
[SerializableFieldSaveFlag(4)]
private bool ShouldSerializeResistances() => !_resistances.IsEmpty;
[SerializableFieldDefault(4)]
private AosElementAttributes ResistancesDefaultValue() => new(this);
[EncodedInt]
[InvalidateProperties]
[SerializableField(5)]
[SaveFlag(nameof(ShouldSerializeMaxHitPoints))]
[SerializedCommandProperty(AccessLevel.GameMaster)]
private int _maxHitPoints;
[SerializableFieldSaveFlag(5)]
private bool ShouldSerializeMaxHitPoints() => _maxHitPoints != 0;
[InvalidateProperties]
[SerializableField(7)]
[SaveFlag(nameof(ShouldSerializeCrafter))]
[SerializedCommandProperty(AccessLevel.GameMaster)]
private string _crafter;
[SerializableFieldSaveFlag(7)]
private bool ShouldSerializeCrafter() => !string.IsNullOrEmpty(_crafter);
[InvalidateProperties]
[SerializableField(8)]
[SaveFlag(nameof(ShouldSerializeQuality))]
[SerializedCommandProperty(AccessLevel.GameMaster)]
private ClothingQuality _quality = ClothingQuality.Regular;
[SerializableFieldSaveFlag(8)]
private bool ShouldSerializeQuality() => _quality != ClothingQuality.Regular;
// Field 9
@ -118,21 +113,19 @@ namespace Server.Items
Resistances = new AosElementAttributes(this);
}
[SerializableProperty(0)]
[CommandProperty(AccessLevel.GameMaster)]
public CraftResource Resource
[SerializableField(0, fieldChanged: nameof(OnResourceChanged))]
[SaveFlag(nameof(ShouldSerializeResource))]
[SerializedCommandProperty(AccessLevel.GameMaster)]
[InvalidateProperties]
private CraftResource _resource;
private void OnResourceChanged(CraftResource oldValue, CraftResource newValue)
{
get => _resource;
set
{
_resource = value;
Hue = CraftResources.GetHue(_resource);
InvalidateProperties();
this.MarkDirty();
}
Hue = CraftResources.GetHue(_resource);
}
[SerializableProperty(9, useField: nameof(_strReq))]
[SaveFlag(nameof(ShouldSerializeStrReq))]
[CommandProperty(AccessLevel.GameMaster)]
public int StrRequirement
{
@ -145,7 +138,6 @@ namespace Server.Items
}
}
[SerializableFieldSaveFlag(9)]
private bool ShouldSerializeStrReq() => _strReq != -1;
public virtual CraftResource DefaultResource => CraftResource.None;
@ -299,6 +291,7 @@ namespace Server.Items
[EncodedInt]
[SerializableProperty(6)]
[SaveFlag(nameof(ShouldSerializeHitPoints))]
[CommandProperty(AccessLevel.GameMaster)]
public int HitPoints
{
@ -324,7 +317,6 @@ namespace Server.Items
}
}
[SerializableFieldSaveFlag(6)]
private bool ShouldSerializeHitPoints() => _hitPoints != 0;
public virtual int InitMinHits => 0;

View file

@ -22,34 +22,26 @@ namespace Server.Items
public override double DefaultWeight => 5.0;
[SerializableField(0, fieldChanged: nameof(OnCurArcaneChargesChanged))]
[EncodedInt]
[SerializableProperty(0)]
[CommandProperty(AccessLevel.GameMaster)]
public int CurArcaneCharges
[SerializedCommandProperty(AccessLevel.GameMaster)]
[InvalidateProperties]
private int _curArcaneCharges;
private void OnCurArcaneChargesChanged(int oldValue, int newValue)
{
get => _curArcaneCharges;
set
{
_curArcaneCharges = value;
this.MarkDirty();
InvalidateProperties();
Update();
}
Update();
}
[SerializableField(1, fieldChanged: nameof(OnMaxArcaneChargesChanged))]
[EncodedInt]
[SerializableProperty(1)]
[CommandProperty(AccessLevel.GameMaster)]
public int MaxArcaneCharges
[SerializedCommandProperty(AccessLevel.GameMaster)]
[InvalidateProperties]
private int _maxArcaneCharges;
private void OnMaxArcaneChargesChanged(int oldValue, int newValue)
{
get => _maxArcaneCharges;
set
{
_maxArcaneCharges = value;
this.MarkDirty();
InvalidateProperties();
Update();
}
Update();
}
[CommandProperty(AccessLevel.GameMaster)]

View file

@ -37,21 +37,22 @@ namespace Server.Items
public override double DefaultWeight => 3.0;
}
[SerializationGenerator(3, false)]
[SerializationGenerator(4, false)]
public partial class DeathRobe : Robe
{
private static readonly TimeSpan m_DefaultDecayTime = TimeSpan.FromMinutes(1.0);
[TimerDrift]
[SerializableField(0)]
[DeserializeTimer(nameof(DeserializeDecayTimer))]
private Timer _decayTimer;
[DeserializeTimerField(0)]
private void DeserializeDecayTimer(TimeSpan delay)
private void DeserializeDecayTimer(TimeSpan delay) => BeginDecay(delay);
private void MigrateFrom(V3Content content)
{
if (delay != TimeSpan.MinValue)
if (content.DecayTimerDelay != TimeSpan.MinValue)
{
BeginDecay(delay);
DeserializeDecayTimer(content.DecayTimerDelay);
}
}
@ -324,34 +325,26 @@ namespace Server.Items
public override double DefaultWeight => 3.0;
[SerializableField(0, fieldChanged: nameof(OnCurArcaneChargesChanged))]
[EncodedInt]
[SerializableProperty(0)]
[CommandProperty(AccessLevel.GameMaster)]
public int CurArcaneCharges
[SerializedCommandProperty(AccessLevel.GameMaster)]
[InvalidateProperties]
private int _curArcaneCharges;
private void OnCurArcaneChargesChanged(int oldValue, int newValue)
{
get => _curArcaneCharges;
set
{
_curArcaneCharges = value;
InvalidateProperties();
Update();
this.MarkDirty();
}
Update();
}
[SerializableField(1, fieldChanged: nameof(OnMaxArcaneChargesChanged))]
[EncodedInt]
[SerializableProperty(1)]
[CommandProperty(AccessLevel.GameMaster)]
public int MaxArcaneCharges
[SerializedCommandProperty(AccessLevel.GameMaster)]
[InvalidateProperties]
private int _maxArcaneCharges;
private void OnMaxArcaneChargesChanged(int oldValue, int newValue)
{
get => _maxArcaneCharges;
set
{
_maxArcaneCharges = value;
InvalidateProperties();
Update();
this.MarkDirty();
}
Update();
}
[CommandProperty(AccessLevel.GameMaster)]

View file

@ -54,11 +54,10 @@ namespace Server.Items
{
[EncodedInt]
[InvalidateProperties]
[SerializableField(0)]
[SerializableField(0, fieldChanged: nameof(OnCurArcaneChargesChanged))]
[SerializedCommandProperty(AccessLevel.GameMaster)]
private int _curArcaneCharges;
[SerializableFieldChanged(0)]
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private void OnCurArcaneChargesChanged(int oldValue, int newValue) => Update();
@ -71,19 +70,15 @@ namespace Server.Items
public override CraftResource DefaultResource => CraftResource.RegularLeather;
[SerializableField(1, fieldChanged: nameof(OnMaxArcaneChargesChanged))]
[EncodedInt]
[SerializableProperty(1)]
[CommandProperty(AccessLevel.GameMaster)]
public int MaxArcaneCharges
[SerializedCommandProperty(AccessLevel.GameMaster)]
[InvalidateProperties]
private int _maxArcaneCharges;
private void OnMaxArcaneChargesChanged(int oldValue, int newValue)
{
get => _maxArcaneCharges;
set
{
_maxArcaneCharges = value;
InvalidateProperties();
Update();
this.MarkDirty();
}
Update();
}
[CommandProperty(AccessLevel.GameMaster)]

View file

@ -74,43 +74,31 @@ public abstract partial class BaseDoor : Item, ILockable, ITelekinesisable
Movable = false;
}
[SerializableProperty(1)]
[CommandProperty(AccessLevel.GameMaster)]
public bool Open
[SerializableField(1, fieldChanged: nameof(OnOpenChanged))]
[SerializedCommandProperty(AccessLevel.GameMaster)]
private bool _open;
private void OnOpenChanged(bool oldValue, bool newValue)
{
get => _open;
set
ItemID = _open ? _openedId : _closedId;
if (_open)
{
if (_open != value)
{
_open = value;
ItemID = _open ? _openedId : _closedId;
if (_open)
{
Location = new Point3D(X + _offset.X, Y + _offset.Y, Z + _offset.Z);
}
else
{
Location = new Point3D(X - _offset.X, Y - _offset.Y, Z - _offset.Z);
}
Effects.PlaySound(this, _open ? OpenedSound : ClosedSound);
if (_open)
{
_timer ??= new InternalTimer(this);
_timer.Start();
}
else
{
_timer.Stop();
_timer = null;
}
this.MarkDirty();
}
Location = new Point3D(X + _offset.X, Y + _offset.Y, Z + _offset.Z);
}
else
{
Location = new Point3D(X - _offset.X, Y - _offset.Y, Z - _offset.Z);
}
Effects.PlaySound(this, _open ? OpenedSound : ClosedSound);
if (_open)
{
_timer ??= new InternalTimer(this);
_timer.Start();
}
else
{
_timer.Stop();
_timer = null;
}
}

View file

@ -3,19 +3,22 @@ using ModernUO.Serialization;
namespace Server.Items;
[SerializationGenerator(2, false)]
[SerializationGenerator(3, false)]
public abstract partial class FillableContainer : LockableContainer
{
[TimerDrift]
[SerializableField(1)]
[DeserializeTimer(nameof(DeserializeRespawnTimer))]
private Timer _respawnTimer;
[DeserializeTimerField(1)]
private void DeserializeRespawnTimer(TimeSpan delay)
private void DeserializeRespawnTimer(TimeSpan delay) => _respawnTimer = Timer.DelayCall(delay, Respawn);
private void MigrateFrom(V2Content content)
{
if (delay > TimeSpan.MinValue)
_contentType = content.ContentType;
if (content.RespawnTimerDelay != TimeSpan.MinValue)
{
_respawnTimer = Timer.DelayCall(delay, Respawn);
DeserializeRespawnTimer(content.RespawnTimerDelay);
}
}

View file

@ -3,14 +3,13 @@ using ModernUO.Serialization;
namespace Server.Items;
[SerializationGenerator(0, false)]
[SerializationGenerator(1, false)]
public partial class MarkContainer : LockableContainer
{
[TimerDrift]
[SerializableField(1, getter: "private", setter: "private")]
[DeserializeTimer(nameof(DeserializeRelockTimer))]
private InternalTimer _relockTimer;
[DeserializeTimerField(1)]
private void DeserializeRelockTimer(TimeSpan delay)
{
if (!Locked && _autoLock)
@ -19,6 +18,19 @@ public partial class MarkContainer : LockableContainer
}
}
private void MigrateFrom(V0Content content)
{
_autoLock = content.AutoLock;
_targetMap = content.TargetMap;
_target = content.Target;
_description = content.Description;
if (content.RelockTimerDelay != TimeSpan.MinValue)
{
DeserializeRelockTimer(content.RelockTimerDelay);
}
}
[SerializableField(2)]
[SerializedCommandProperty(AccessLevel.GameMaster)]
private Map _targetMap;
@ -50,23 +62,19 @@ public partial class MarkContainer : LockableContainer
}
}
[SerializableProperty(0)]
[CommandProperty(AccessLevel.GameMaster)]
public bool AutoLock
{
get => _autoLock;
set
{
_autoLock = value;
[SerializableField(0, fieldChanged: nameof(OnAutoLockChanged))]
[SerializedCommandProperty(AccessLevel.GameMaster)]
private bool _autoLock;
if (!_autoLock)
{
StopTimer();
}
else if (!Locked)
{
_relockTimer ??= new InternalTimer(this);
}
private void OnAutoLockChanged(bool oldValue, bool newValue)
{
if (!_autoLock)
{
StopTimer();
}
else if (!Locked)
{
_relockTimer ??= new InternalTimer(this);
}
}

View file

@ -9,7 +9,7 @@ using Server.Network;
namespace Server.Items;
[SerializationGenerator(3, false)]
[SerializationGenerator(4, false)]
public partial class TreasureMapChest : LockableContainer
{
[Tidy]
@ -29,12 +29,11 @@ public partial class TreasureMapChest : LockableContainer
[SerializedCommandProperty(AccessLevel.GameMaster)]
private int _level;
[TimerDrift]
[SerializableField(4)]
[SerializedCommandProperty(AccessLevel.GameMaster)]
[DeserializeTimer(nameof(DeserializeExpireTimer))]
private Timer _expireTimer;
[DeserializeTimerField(4)]
private void DeserializeExpireTimer(TimeSpan delay)
{
if (!_temporary)
@ -43,6 +42,20 @@ public partial class TreasureMapChest : LockableContainer
}
}
private void MigrateFrom(V3Content content)
{
_guardians = content.Guardians;
_temporary = content.Temporary;
_owner = content.Owner;
_level = content.Level;
_lifted = content.Lifted;
if (content.ExpireTimerDelay != TimeSpan.MinValue)
{
DeserializeExpireTimer(content.ExpireTimerDelay);
}
}
[Tidy]
[CanBeNull]
[SerializableField(5, setter: "private")]

View file

@ -28,17 +28,14 @@ public partial class DragonBardingDeed : Item, ICraftable
public override int LabelNumber => _exceptional ? 1053181 : 1053012; // dragon barding deed
[SerializableProperty(2)]
[CommandProperty(AccessLevel.GameMaster)]
public CraftResource Resource
[SerializableField(2, fieldChanged: nameof(OnResourceChanged))]
[SerializedCommandProperty(AccessLevel.GameMaster)]
[InvalidateProperties]
private CraftResource _resource;
private void OnResourceChanged(CraftResource oldValue, CraftResource newValue)
{
get => _resource;
set
{
_resource = value;
Hue = CraftResources.GetHue(value);
InvalidateProperties();
}
Hue = CraftResources.GetHue(newValue);
}
public int OnCraft(

View file

@ -324,27 +324,21 @@ public abstract partial class BaseBeverage : Item, IHasQuantity
[CommandProperty(AccessLevel.GameMaster)]
public bool IsFull => _quantity >= MaxQuantity;
[SerializableProperty(2)]
[CommandProperty(AccessLevel.GameMaster)]
public BeverageType Content
[SerializableField(2, fieldChanged: nameof(OnContentChanged))]
[SerializedCommandProperty(AccessLevel.GameMaster)]
[InvalidateProperties]
private BeverageType _content;
private void OnContentChanged(BeverageType oldValue, BeverageType newValue)
{
get => _content;
set
var itemID = ComputeItemID();
if (itemID > 0)
{
_content = value;
InvalidateProperties();
var itemID = ComputeItemID();
if (itemID > 0)
{
ItemID = itemID;
}
else
{
Delete();
}
ItemID = itemID;
}
else
{
Delete();
}
}

View file

@ -76,25 +76,25 @@ public partial class SackFlour : Item, IHasQuantity
public override double DefaultWeight => 5.0;
[SerializableProperty(0)]
[CommandProperty(AccessLevel.GameMaster)]
public int Quantity
[SerializableField(0, fieldChanged: nameof(OnQuantityChanged), allowFieldChange: nameof(AllowQuantityChange))]
[SerializedCommandProperty(AccessLevel.GameMaster)]
private int _quantity;
private bool AllowQuantityChange(ref int value)
{
get => _quantity;
set
value = Math.Min(20, Math.Max(0, value));
return true;
}
private void OnQuantityChanged(int oldValue, int newValue)
{
if (_quantity == 0)
{
_quantity = Math.Min(20, Math.Max(0, value));
if (_quantity == 0)
{
Delete();
}
else if (_quantity < 20 && ItemID is 0x1039 or 0x1045)
{
++ItemID;
}
this.MarkDirty();
Delete();
}
else if (_quantity < 20 && ItemID is 0x1039 or 0x1045)
{
++ItemID;
}
}

View file

@ -55,55 +55,33 @@ public partial class MahjongGame : Item, ISecurable
public override double DefaultWeight => 5.0;
[CommandProperty(AccessLevel.GameMaster)]
[SerializableProperty(6)]
public bool ShowScores
[SerializableField(6, fieldChanged: nameof(OnShowScoresChanged))]
[SerializedCommandProperty(AccessLevel.GameMaster)]
private bool _showScores;
private void OnShowScoresChanged(bool oldValue, bool newValue)
{
get => _showScores;
set
if (newValue)
{
if (_showScores == value)
{
return;
}
_showScores = value;
if (value)
{
_players.SendPlayersPacket(true, true);
}
_players.SendGeneralPacket(true, true);
_players.SendLocalizedMessage(value ? 1062777 : 1062778); // The dealer has enabled/disabled score display.
this.MarkDirty();
_players.SendPlayersPacket(true, true);
}
_players.SendGeneralPacket(true, true);
_players.SendLocalizedMessage(newValue ? 1062777 : 1062778); // The dealer has enabled/disabled score display.
}
[CommandProperty(AccessLevel.GameMaster)]
[SerializableProperty(7)]
public bool SpectatorVision
[SerializableField(7, fieldChanged: nameof(OnSpectatorVisionChanged))]
[SerializedCommandProperty(AccessLevel.GameMaster)]
[InvalidateProperties]
private bool _spectatorVision;
private void OnSpectatorVisionChanged(bool oldValue, bool newValue)
{
get => _spectatorVision;
set
if (_players.IsInGamePlayer(_players.DealerPosition))
{
if (_spectatorVision == value)
{
return;
}
_spectatorVision = value;
if (_players.IsInGamePlayer(_players.DealerPosition))
{
_players.Dealer.NetState.SendMahjongGeneralInfo(this);
}
_players.SendTilesPacket(false, true);
_players.SendLocalizedMessage(value ? 1062715 : 1062716); // The dealer has enabled/disabled Spectator Vision.
InvalidateProperties();
this.MarkDirty();
_players.Dealer.NetState.SendMahjongGeneralInfo(this);
}
_players.SendTilesPacket(false, true);
_players.SendLocalizedMessage(newValue ? 1062715 : 1062716); // The dealer has enabled/disabled Spectator Vision.
}
private void BuildHorizontalWall(

View file

@ -93,16 +93,13 @@ public abstract partial class BaseJewel : Item, ICraftable, IAosItem
}
}
[SerializableProperty(2)]
[CommandProperty(AccessLevel.GameMaster)]
public CraftResource Resource
[SerializableField(2, fieldChanged: nameof(OnResourceChanged))]
[SerializedCommandProperty(AccessLevel.GameMaster)]
private CraftResource _resource;
private void OnResourceChanged(CraftResource oldValue, CraftResource newValue)
{
get => _resource;
set
{
_resource = value;
Hue = CraftResources.GetHue(_resource);
}
Hue = CraftResources.GetHue(_resource);
}
public override int PhysicalResistance => Resistances.Physical;

View file

@ -3,7 +3,7 @@ using ModernUO.Serialization;
namespace Server.Items;
[SerializationGenerator(1, false)]
[SerializationGenerator(2, false)]
public abstract partial class BaseLight : Item
{
public static readonly bool Burnout = false;
@ -16,11 +16,10 @@ public abstract partial class BaseLight : Item
[SerializedCommandProperty(AccessLevel.GameMaster)]
private bool _protected;
[TimerDrift]
[SerializableField(4, getter: "private", setter: "private")]
[DeserializeTimer(nameof(DeserializeTimer))]
private Timer _burnTimer;
[DeserializeTimerField(4)]
private void DeserializeTimer(TimeSpan delay)
{
if (_burning && _duration != TimeSpan.Zero)
@ -29,6 +28,19 @@ public abstract partial class BaseLight : Item
}
}
private void MigrateFrom(V1Content content)
{
_burntOut = content.BurntOut;
_burning = content.Burning;
_duration = content.Duration;
_protected = content.Protected;
if (content.BurnTimerDelay != TimeSpan.MinValue)
{
DeserializeTimer(content.BurnTimerDelay);
}
}
[Constructible]
public BaseLight(int itemID) : base(itemID)
{

View file

@ -275,8 +275,16 @@ public partial class BroadcastCrystal : Item
[SerializationGenerator(0)]
public partial class ReceiverCrystal : Item
{
[SerializableField(0, fieldChanged: nameof(OnSenderChanged))]
[SerializedCommandProperty(AccessLevel.GameMaster)]
private BroadcastCrystal _sender;
private void OnSenderChanged(BroadcastCrystal oldValue, BroadcastCrystal newValue)
{
oldValue?.RemoveReceiver(this);
newValue?.AddReceiver(this);
}
[Constructible]
public ReceiverCrystal() : base(0x1ED0) => Light = LightType.Circle150;
@ -297,20 +305,6 @@ public partial class ReceiverCrystal : Item
}
}
[SerializableProperty(0, useField: nameof(_sender))]
[CommandProperty(AccessLevel.GameMaster)]
public BroadcastCrystal Sender
{
get => _sender;
set
{
_sender?.RemoveReceiver(this);
_sender = value;
value?.AddReceiver(this);
this.MarkDirty();
}
}
public override void GetProperties(IPropertyList list)
{
base.GetProperties(list);

View file

@ -0,0 +1,179 @@
using System;
namespace Server.Items;
public partial class Corpse
{
// Decay timer and TimeOfDeath moved from delta time to anchored time
private void MigrateFrom(V18Content content)
{
_restoreEquip = content.RestoreEquip;
_flags = content.Flags;
_timeOfDeath = content.TimeOfDeath;
_restoreTable = content.RestoreTable;
_looters = content.Looters;
_killer = content.Killer;
_aggressors = content.Aggressors;
_owner = content.Owner;
_corpseName = content.CorpseName;
_accessLevel = content.AccessLevel;
_guild = content.Guild;
_equipItems = content.EquipItems;
_hairItemId = content.HairItemId;
_hairHue = content.HairHue;
_facialHairItemId = content.FacialHairItemId;
_facialHairHue = content.FacialHairHue;
if (content.DecayTimerDelay != TimeSpan.MinValue)
{
DeserializeDecayTimer(content.DecayTimerDelay);
}
}
// Decay timer moved from [TimerDrift]/[DeserializeTimerField] to [DeserializeTimer]
private void MigrateFrom(V17Content content)
{
_restoreEquip = content.RestoreEquip;
_flags = content.Flags;
_timeOfDeath = content.TimeOfDeath;
_restoreTable = content.RestoreTable;
_looters = content.Looters;
_killer = content.Killer;
_aggressors = content.Aggressors;
_owner = content.Owner;
_corpseName = content.CorpseName;
_accessLevel = content.AccessLevel;
_guild = content.Guild;
_equipItems = content.EquipItems;
_hairItemId = content.HairItemId;
_hairHue = content.HairHue;
_facialHairItemId = content.FacialHairItemId;
_facialHairHue = content.FacialHairHue;
if (content.DecayTimerDelay != TimeSpan.MinValue)
{
DeserializeDecayTimer(content.DecayTimerDelay);
}
}
// Decomposed VirtualHairInfo into discrete int fields (hair/facial hair item id + hue)
private void MigrateFrom(V16Content content)
{
_restoreEquip = content.RestoreEquip;
_flags = content.Flags;
_timeOfDeath = content.TimeOfDeath;
_restoreTable = content.RestoreTable;
_decayTimer = new InternalTimer(this, content.DecayTimerDelay);
_decayTimer.Start();
_looters = content.Looters;
_killer = content.Killer;
_aggressors = content.Aggressors;
_owner = content.Owner;
_corpseName = content.CorpseName;
_accessLevel = content.AccessLevel;
_guild = content.Guild;
_equipItems = content.EquipItems;
if (content.Hair != null)
{
_hairItemId = content.Hair.ItemId;
_hairHue = content.Hair.Hue;
}
if (content.FacialHair != null)
{
_facialHairItemId = content.FacialHair.ItemId;
_facialHairHue = content.FacialHair.Hue;
}
}
// Folded Murderer bool field into CorpseFlag.Murderer
private void MigrateFrom(V15Content content)
{
_restoreEquip = content.RestoreEquip;
_flags = content.Flags;
if (content.Murderer)
{
_flags |= CorpseFlag.Murderer;
}
_timeOfDeath = content.TimeOfDeath;
_restoreTable = content.RestoreTable;
_decayTimer = new InternalTimer(this, content.DecayTimerDelay);
_decayTimer.Start();
_looters = content.Looters;
_killer = content.Killer;
_aggressors = content.Aggressors;
_owner = content.Owner;
_corpseName = content.CorpseName;
_accessLevel = content.AccessLevel;
_guild = content.Guild;
_equipItems = content.EquipItems;
if (content.Hair != null)
{
_hairItemId = content.Hair.ItemId;
_hairHue = content.Hair.Hue;
}
if (content.FacialHair != null)
{
_facialHairItemId = content.FacialHair.ItemId;
_facialHairHue = content.FacialHair.Hue;
}
}
// Replaced int Kills snapshot with bool Murderer snapshot
private void MigrateFrom(V14Content content)
{
_restoreEquip = content.RestoreEquip;
_flags = content.Flags;
if (content.Kills >= 5)
{
_flags |= CorpseFlag.Murderer;
}
_timeOfDeath = content.TimeOfDeath;
_restoreTable = content.RestoreTable;
_decayTimer = new InternalTimer(this, content.DecayTimerDelay);
_decayTimer.Start();
_looters = content.Looters;
_killer = content.Killer;
_aggressors = content.Aggressors;
_owner = content.Owner;
_corpseName = content.CorpseName;
_accessLevel = content.AccessLevel;
_guild = content.Guild;
_equipItems = content.EquipItems;
if (content.Hair != null)
{
_hairItemId = content.Hair.ItemId;
_hairHue = content.Hair.Hue;
}
if (content.FacialHair != null)
{
_facialHairItemId = content.FacialHair.ItemId;
_facialHairHue = content.FacialHair.Hue;
}
}
// Added corpse hair and corpse facial hair
private void MigrateFrom(V13Content content)
{
_restoreEquip = content.RestoreEquip;
_flags = content.Flags;
if (content.Kills >= 5)
{
_flags |= CorpseFlag.Murderer;
}
_timeOfDeath = content.TimeOfDeath;
_restoreTable = content.RestoreTable;
_decayTimer = new InternalTimer(this, content.DecayTimerDelay);
_decayTimer.Start();
_looters = content.Looters;
_killer = content.Killer;
_aggressors = content.Aggressors;
_owner = content.Owner;
_corpseName = content.CorpseName;
_accessLevel = content.AccessLevel;
_guild = content.Guild;
_equipItems = content.EquipItems;
}
}

View file

@ -86,7 +86,7 @@ public enum CorpseFlag
OwnerWasAnimatedDead = 0x00000800
}
[SerializationGenerator(17, false)]
[SerializationGenerator(19, false)]
public partial class Corpse : Container, ICarvable
{
public static readonly TimeSpan MonsterLootRightSacrifice = TimeSpan.FromMinutes(2.0);
@ -106,7 +106,7 @@ public partial class Corpse : Container, ICarvable
[SerializableField(1)]
private CorpseFlag _flags;
[DeltaDateTime]
[AnchoredDateTime]
[SerializableField(2)]
[SerializedCommandProperty(AccessLevel.GameMaster)]
private DateTime _timeOfDeath;
@ -114,11 +114,10 @@ public partial class Corpse : Container, ICarvable
[SerializableField(3, getter: "private", setter: "private")]
private Dictionary<Item, Point3D> _restoreTable;
[TimerDrift]
[SerializableField(4, getter: "private", setter: "private")]
[DeserializeTimer(nameof(DeserializeDecayTimer))]
private Timer _decayTimer;
[DeserializeTimerField(4)]
private void DeserializeDecayTimer(TimeSpan delay) => BeginDecay(delay);
[SerializableField(5, setter: "private")]
@ -318,127 +317,6 @@ public partial class Corpse : Container, ICarvable
DevourCorpse();
}
// Decomposed VirtualHairInfo into discrete int fields (hair/facial hair item id + hue)
private void MigrateFrom(V16Content content)
{
_restoreEquip = content.RestoreEquip;
_flags = content.Flags;
_timeOfDeath = content.TimeOfDeath;
_restoreTable = content.RestoreTable;
_decayTimer = new InternalTimer(this, content.DecayTimerDelay);
_decayTimer.Start();
_looters = content.Looters;
_killer = content.Killer;
_aggressors = content.Aggressors;
_owner = content.Owner;
_corpseName = content.CorpseName;
_accessLevel = content.AccessLevel;
_guild = content.Guild;
_equipItems = content.EquipItems;
if (content.Hair != null)
{
_hairItemId = content.Hair.ItemId;
_hairHue = content.Hair.Hue;
}
if (content.FacialHair != null)
{
_facialHairItemId = content.FacialHair.ItemId;
_facialHairHue = content.FacialHair.Hue;
}
}
// Folded Murderer bool field into CorpseFlag.Murderer
private void MigrateFrom(V15Content content)
{
_restoreEquip = content.RestoreEquip;
_flags = content.Flags;
if (content.Murderer)
{
_flags |= CorpseFlag.Murderer;
}
_timeOfDeath = content.TimeOfDeath;
_restoreTable = content.RestoreTable;
_decayTimer = new InternalTimer(this, content.DecayTimerDelay);
_decayTimer.Start();
_looters = content.Looters;
_killer = content.Killer;
_aggressors = content.Aggressors;
_owner = content.Owner;
_corpseName = content.CorpseName;
_accessLevel = content.AccessLevel;
_guild = content.Guild;
_equipItems = content.EquipItems;
if (content.Hair != null)
{
_hairItemId = content.Hair.ItemId;
_hairHue = content.Hair.Hue;
}
if (content.FacialHair != null)
{
_facialHairItemId = content.FacialHair.ItemId;
_facialHairHue = content.FacialHair.Hue;
}
}
// Replaced int Kills snapshot with bool Murderer snapshot
private void MigrateFrom(V14Content content)
{
_restoreEquip = content.RestoreEquip;
_flags = content.Flags;
if (content.Kills >= 5)
{
_flags |= CorpseFlag.Murderer;
}
_timeOfDeath = content.TimeOfDeath;
_restoreTable = content.RestoreTable;
_decayTimer = new InternalTimer(this, content.DecayTimerDelay);
_decayTimer.Start();
_looters = content.Looters;
_killer = content.Killer;
_aggressors = content.Aggressors;
_owner = content.Owner;
_corpseName = content.CorpseName;
_accessLevel = content.AccessLevel;
_guild = content.Guild;
_equipItems = content.EquipItems;
if (content.Hair != null)
{
_hairItemId = content.Hair.ItemId;
_hairHue = content.Hair.Hue;
}
if (content.FacialHair != null)
{
_facialHairItemId = content.FacialHair.ItemId;
_facialHairHue = content.FacialHair.Hue;
}
}
// Added corpse hair and corpse facial hair
private void MigrateFrom(V13Content content)
{
_restoreEquip = content.RestoreEquip;
_flags = content.Flags;
if (content.Kills >= 5)
{
_flags |= CorpseFlag.Murderer;
}
_timeOfDeath = content.TimeOfDeath;
_restoreTable = content.RestoreTable;
_decayTimer = new InternalTimer(this, content.DecayTimerDelay);
_decayTimer.Start();
_looters = content.Looters;
_killer = content.Killer;
_aggressors = content.Aggressors;
_owner = content.Owner;
_corpseName = content.CorpseName;
_accessLevel = content.AccessLevel;
_guild = content.Guild;
_equipItems = content.EquipItems;
}
[CommandProperty(AccessLevel.GameMaster)]
public virtual bool InstancedCorpse => Core.SE && Core.Now < TimeOfDeath + InstancedCorpseTime;

View file

@ -3,18 +3,25 @@ using ModernUO.Serialization;
namespace Server.Items;
[SerializationGenerator(2, false)]
[SerializationGenerator(3, false)]
public partial class DecayedCorpse : Container
{
private static TimeSpan _defaultDecayTime = TimeSpan.FromMinutes(7.0);
[TimerDrift]
[SerializableField(0, getter: "private", setter: "private")]
[DeserializeTimer(nameof(DeserializeDecayTimer))]
private Timer _decayTimer;
[DeserializeTimerField(0)]
private void DeserializeDecayTimer(TimeSpan delay) => BeginDecay(delay);
private void MigrateFrom(V2Content content)
{
if (content.DecayTimerDelay != TimeSpan.MinValue)
{
DeserializeDecayTimer(content.DecayTimerDelay);
}
}
public DecayedCorpse(string name) : base(Utility.Random(0xECA, 9))
{
Movable = false;

View file

@ -30,20 +30,24 @@ public partial class MorphItem : Item
_outsideRange = outRange;
}
[SerializableProperty(0)]
[CommandProperty(AccessLevel.GameMaster)]
public int OutsideRange
[SerializableField(0, allowFieldChange: nameof(AllowOutsideRangeChange))]
[SerializedCommandProperty(AccessLevel.GameMaster)]
private int _outsideRange;
private bool AllowOutsideRangeChange(ref int value)
{
get => _outsideRange;
set => _outsideRange = Math.Clamp(value, 0, 18);
value = Math.Clamp(value, 0, 18);
return true;
}
[SerializableProperty(3)]
[CommandProperty(AccessLevel.GameMaster)]
public int InsideRange
[SerializableField(3, allowFieldChange: nameof(AllowInsideRangeChange))]
[SerializedCommandProperty(AccessLevel.GameMaster)]
private int _insideRange;
private bool AllowInsideRangeChange(ref int value)
{
get => _insideRange;
set => _insideRange = Math.Clamp(value, 0, 18);
value = Math.Clamp(value, 0, 18);
return true;
}
[CommandProperty(AccessLevel.GameMaster)]

View file

@ -14,8 +14,16 @@ public partial class WarningItem : Item
private TextDefinition _warningMessage;
// Field 1
[SerializableField(1, allowFieldChange: nameof(AllowRangeChange))]
[SerializedCommandProperty(AccessLevel.GameMaster)]
private int _range;
private bool AllowRangeChange(ref int value)
{
value = Math.Min(value, 18);
return true;
}
[SerializableField(2)]
private TimeSpan _resetDelay;
@ -39,18 +47,6 @@ public partial class WarningItem : Item
_range = Math.Min(range, 18);
}
[CommandProperty(AccessLevel.GameMaster)]
[SerializableProperty(1, useField: nameof(_range))]
public int Range
{
get => _range;
set
{
_range = Math.Min(value, 18);
this.MarkDirty();
}
}
public virtual bool OnlyToTriggerer => false;
public virtual int NeighborRange => 5;

View file

@ -11,64 +11,62 @@ public partial class BaseQuiver : Container, ICraftable, IAosItem
[SerializedIgnoreDupe]
[SerializableField(0, setter: "private")]
[SaveFlag(nameof(ShouldSerializeAosAttributes), nameof(AttributesDefaultValue))]
[SerializedCommandProperty(AccessLevel.GameMaster, canModify: true)]
private AosAttributes _attributes;
[SerializableFieldSaveFlag(0)]
private bool ShouldSerializeAosAttributes() => !_attributes.IsEmpty;
[SerializableFieldDefault(0)]
private AosAttributes AttributesDefaultValue() => new(this);
[InvalidateProperties]
[SerializableField(1)]
[SaveFlag(nameof(ShouldSerializeLowerAmmoCost))]
[SerializedCommandProperty(AccessLevel.GameMaster)]
private int _lowerAmmoCost;
[SerializableFieldSaveFlag(1)]
private bool ShouldSerializeLowerAmmoCost() => _lowerAmmoCost != 0;
[InvalidateProperties]
[SerializableField(2)]
[SaveFlag(nameof(ShouldSerializeWeightReduction))]
[SerializedCommandProperty(AccessLevel.GameMaster)]
private int _weightReduction;
[SerializableFieldSaveFlag(2)]
private bool ShouldSerializeWeightReduction() => _weightReduction != 0;
[InvalidateProperties]
[SerializableField(3)]
[SaveFlag(nameof(ShouldSerializeDamageIncrease))]
[SerializedCommandProperty(AccessLevel.GameMaster)]
private int _damageIncrease;
[SerializableFieldSaveFlag(3)]
private bool ShouldSerializeDamageIncrease() => _damageIncrease != 0;
[InvalidateProperties]
[SerializableField(4)]
[SaveFlag(nameof(ShouldSerializeCrafter))]
[SerializedCommandProperty(AccessLevel.GameMaster)]
private string _crafter;
[SerializableFieldSaveFlag(4)]
private bool ShouldSerializeCrafter() => !string.IsNullOrEmpty(_crafter);
[InvalidateProperties]
[SerializableField(5)]
[SaveFlag(nameof(ShouldSerializeQuality), nameof(QualityDefaultValue))]
[SerializedCommandProperty(AccessLevel.GameMaster)]
private ClothingQuality _quality;
[SerializableFieldSaveFlag(5)]
private bool ShouldSerializeQuality() => _quality != ClothingQuality.Regular;
[SerializableFieldDefault(5)]
private ClothingQuality QualityDefaultValue() => ClothingQuality.Regular;
[InvalidateProperties]
[SerializableField(6)]
[SaveFlag(nameof(ShouldSerializeCapacity))]
[SerializedCommandProperty(AccessLevel.GameMaster)]
private int _capacity;
[SerializableFieldSaveFlag(6)]
private bool ShouldSerializeCapacity() => _capacity != 0;
public BaseQuiver(int itemID = 0x2FB7) : base(itemID)

View file

@ -16,22 +16,14 @@ public abstract partial class BaseIngot : Item, ICommodity
public override double DefaultWeight => 0.1;
[SerializableProperty(0)]
[CommandProperty(AccessLevel.GameMaster)]
public CraftResource Resource
{
get => _resource;
set
{
if (_resource != value)
{
_resource = value;
Hue = CraftResources.GetHue(value);
[SerializableField(0, fieldChanged: nameof(OnResourceChanged))]
[SerializedCommandProperty(AccessLevel.GameMaster)]
[InvalidateProperties]
private CraftResource _resource;
InvalidateProperties();
this.MarkDirty();
}
}
private void OnResourceChanged(CraftResource oldValue, CraftResource newValue)
{
Hue = CraftResources.GetHue(newValue);
}
public override int LabelNumber

View file

@ -17,22 +17,14 @@ public abstract partial class BaseOre : Item
_resource = resource;
}
[SerializableProperty(0)]
[CommandProperty(AccessLevel.GameMaster)]
public CraftResource Resource
{
get => _resource;
set
{
if (_resource != value)
{
_resource = value;
Hue = CraftResources.GetHue(value);
[SerializableField(0, fieldChanged: nameof(OnResourceChanged))]
[SerializedCommandProperty(AccessLevel.GameMaster)]
[InvalidateProperties]
private CraftResource _resource;
InvalidateProperties();
this.MarkDirty();
}
}
private void OnResourceChanged(CraftResource oldValue, CraftResource newValue)
{
Hue = CraftResources.GetHue(newValue);
}
public override int LabelNumber

View file

@ -14,22 +14,14 @@ public abstract partial class BaseScales : Item, ICommodity
_resource = resource;
}
[SerializableProperty(0)]
[CommandProperty(AccessLevel.GameMaster)]
public CraftResource Resource
{
get => _resource;
set
{
if (_resource != value)
{
_resource = value;
Hue = CraftResources.GetHue(value);
[SerializableField(0, fieldChanged: nameof(OnResourceChanged))]
[SerializedCommandProperty(AccessLevel.GameMaster)]
[InvalidateProperties]
private CraftResource _resource;
InvalidateProperties();
this.MarkDirty();
}
}
private void OnResourceChanged(CraftResource oldValue, CraftResource newValue)
{
Hue = CraftResources.GetHue(newValue);
}
public override int LabelNumber => 1053139; // dragon scales

View file

@ -15,22 +15,14 @@ public abstract partial class BaseGranite : Item
public override double DefaultWeight => Core.ML ? 1.0 : 10.0;
[SerializableProperty(0)]
[CommandProperty(AccessLevel.GameMaster)]
public CraftResource Resource
{
get => _resource;
set
{
if (_resource != value)
{
_resource = value;
Hue = CraftResources.GetHue(value);
[SerializableField(0, fieldChanged: nameof(OnResourceChanged))]
[SerializedCommandProperty(AccessLevel.GameMaster)]
[InvalidateProperties]
private CraftResource _resource;
InvalidateProperties();
this.MarkDirty();
}
}
private void OnResourceChanged(CraftResource oldValue, CraftResource newValue)
{
Hue = CraftResources.GetHue(newValue);
}
public override int LabelNumber => 1044607; // high quality granite

Some files were not shown because too many files have changed in this diff Show more