**Follow-on to #2639 (merged). References a local IORingGroup `1.0.13-preview.11` pack until 1.0.13 (modernuo/IORingGroup#15) is published; do not merge before that switch.**
## Summary
Consumes IORingGroup's lean base pools (modernuo/IORingGroup#15): both network pools now start with one slab, grow a slab at a time with the population, and trim idle slabs back after quiet periods.
- Fixes the transport's send-pool cap: previously only 1024 of the 4096 connections could get a send buffer; connection 1025 was closed at accept.
- Network memory at boot drops from about 96 MB to about 10 MB at the defaults; a full 4096 logged-in connections is about 1.25 GB of base buffers plus the growth budget.
- New settings: `network.initialBufferSlabs` (default 1; slabs of each pool held from boot and the trim floor) and `network.maxBufferSlabs` (default 128; divides the connection maximum into slabs, 32 connections per slab). Both are coerced with a warning; the same value feeds the ring table and the manager so they cannot drift.
- The Debug-only maintenance line includes base-pool capacity and releases.
- `dev-docs/server-requirements.md` rewrites the network memory story and adds the two settings.
## Pre-auth buffers
Every connection starts on the transport's platform-minimum buffers (4 KB receive, 4 KB send) instead of the base pools. It is promoted to full-size buffers (64 KB receive, `network.sendBufferSize` send) when the game server verifies its account — the point where `NetState.Account` is assigned in the `GameServer_AwaitingGameServerLogin` or `GameServer_LoggedIn` state, so a verdict that lands after the parser has moved on still promotes. Nothing ever moves back. The login-server pass stays on the small buffers for its whole lifetime.
Before credentials verify, nothing promotes: the 4 KB send ring is the entire pre-auth send budget, and a connection that overruns it is dropped as exhausted, exactly as the receive side drops a packet header declaring more bytes than the receive buffer can hold (new guard in `HandlePacket`; it also closes the old 65535-byte edge on 64 KB buffers). The stock login sequence sends under 2 KB. After credentials verify, the send path promotes on demand if it ever needs to (unbudgeted, outside the memory ceiling and the shrink bookkeeping — promotion is not growth), and the oversize-packet guard waits on a pending receive promotion or retries a stalled one once for a verified account before disconnecting. A completion that fills the receive buffer arms no receive, so `HandleReceive` now calls `RingSocket.ResumeReceive()` after the parse loop — at 4 KB a burst of small packets fills the buffer in one completion.
Net effect: a flood of unauthenticated connections tops out at about 32 MB across the full 4096-connection cap where the platform minimum is 4 KB (the transport's retained slabs and the base pools used by logged-in players are separate), and never allocates a base-pool slab.
Platform note: on Windows Server 2012 R2 / 2016 the transport's legacy mapping path floors at 64 KB: the pre-auth receive pool is off there (its base is 64 KB), while the pre-auth send buffer starts at 64 KB under the 256 KB base. The server logs the effective sizes at startup.
## Testing
Server.Tests (905) and UOContent.Tests green on the preview pack (one pre-existing `FamiliarAITests` failure from #2644 reproduces on `main`, tracked separately). New tests cover both coercions, that the ring's registration table equals `RequiredRegisteredBuffers` for the configured values, promotion on game-server auth and on a late account, no promotion on the login server, pre-credential overrun ending in exhaustion, post-credential on-demand promotion, the oversize-packet guard through loopback (error, wait, retry with an account, and a promotion made pending mid-parse), and receiving again after a burst fills the initial buffer.
When an AI debug-message cooldown crosses the signed tick-counter boundary, DebugInterpolatedStringHandler can emit before the deadline or suppress a message after it. Both constructors compare absolute tick values.
Use subtraction-based deadline comparisons in both constructors, following dev-docs/tick-counts.md. This is a two-line production change; message formatting, cooldown duration, and gameplay behavior are unchanged.
Adds 35 regression cases exercising the actual handler and DebugSayFormatted:
- Both constructor overloads, with debugging enabled and disabled.
- Before/at/after deadlines on positive and negative clocks.
- Future and expired deadlines across signed wraparound.
- Cooldown rearming across wraparound and buffer clearing.
- A compiler-generated interpolated call through the public extension method.
Validation on Linux / .NET 10, based on clean upstream 35e3a31b4:
- Release build: 0 warnings, 0 errors.
- New tests against unchanged production code: 28 passed, 7 failed.
- With the fix: 35 passed, including without client map data (no skips).
- Selected AI/pet suites: 134 passed / 7 failed before; 141 passed / 0 failed after.
Tests use synthetic entities; no running shard or world saves. The faulty comparisons were reverified on upstream main before submission. Windows execution remains unverified. The tests exercise explicitly assigned deadlines; deadline initialization is outside this patch.
Addresses only the DebugInterpolatedStringHandler comparison item in #2627; it does not close the other audit items.
Two bugs found while reworking #2653.
**Custom flags throw.** `SyncStaticFlag` is a switch expression with no discard arm, so any key that has no static behind it throws `SwitchExpressionException`. `[FeatureFlag mykey create <category> <desc>` crashes in `CreateOrUpdateFlag`, and once such a key is in `flags.json`, every boot's `SyncAllStaticFlags()` throws mid-iteration — caught and logged as "Failed to load feature flags", but stock flags later in dictionary order never sync. Added `_ => enabled`.
**Removing a flag turns some features on.** `RemoveFlag` hardcoded `SyncStaticFlag(flagKey, true)`. For `speedhack_detection` and `insurance` the real default is `false` / `insurance.enable`, so deleting the flag enabled the feature. It now syncs the removed flag's `DefaultEnabled`, which #2653 seeds from the static.
Verified: `dotnet build Projects/UOContent/UOContent.csproj -c Release`, 0 `CS` diagnostics (post-build copy to `Distribution/` was blocked by a running server instance).
## Summary
Profiling with thousands of creatures in range of each other showed `BaseAI.IsInvalidFactionTarget` checking Ethereal Voyage and active Honor on every acquisition candidate and then calling `BaseCreature.IsEnemy`, which checked both again. This makes `IsEnemy` the single authority and removes the duplicate per-pair work.
## Changes
- **`BaseCreature.IsEnemy`**: the Ethereal Voyage check sat below the `m is not BaseCreature` early return, so it never reached players — the only mobiles that cast it. It is hoisted next to the Honor veto. `GetMaster()` was called three times per creature pair (inside `Ethics.Player.Find(m, true)` and twice at the bottom); it is now computed once.
- **`BaseAI.IsInvalidFactionTarget`**: reduced to `IsFriend` / `IsEnemy` / `CanBeHarmful`. The removed `Combatant != m` Honor exception was dead code — `IsEnemy` vetoed Honor unconditionally on the following line.
- **`Ethics.Player.Find`** / **`TransformationSpellHelper.GetContext`**: small simplifications on the same path.
## Behavior
- `ShouldAcquireOnApproach` and `OnAggressiveAction` only consult `IsEnemy`, so movement-triggered acquisition now respects Ethereal Voyage for players (previously a player under Ethereal Voyage walking past a monster was still acquired on approach).
- `BaseFactionGuard.IsEnemy` does not call base, so faction guards no longer skip honoring/voyaging enemy-faction players. Accepted: both mechanics describe monsters, not guards.
- `HealerAI`/`BerserkAI`/`PredatorAI` (`bFacFriend`) callers are unaffected — `IsFriend` already required a `BaseCreature`, so players were excluded before these checks ran. `MilitiaFighter`/`MilitiaCanoneer` return `false` for all players, so they are unaffected too.
## Testing
- `dotnet build` clean.
- Pure predicate reorder; no new tests.
### Summary
`default-flags.json` was never shipped — `Configuration/` is gitignored — so the predefined-flag loader has been dead since #2328. A fresh shard boots with 0 flags and writes no JSON until an admin changes something, so `[FeatureList` is empty on first run.
Stock flags are now defined in code. `Initialize()` runs `Load()` first, then `LoadDefaultFlags()` seeds any of the 14 stock keys the save is missing, reading each default from the static it syncs (`ServerFeatureFlags` / `ContentFeatureFlags`) rather than a duplicated boolean — so `speedhack_detection` stays off and `insurance` honors `Insurance.Configure` (`insurance.enable`). Existing entries are never overwritten, so admin state survives upgrades and saves predating a flag pick it up. `Save()` runs only when something was seeded, so all five JSON files exist from first boot.
Verified: `dotnet build Projects/UOContent/UOContent.csproj -c Release`, 0 warnings 0 errors.
## Summary
Two workflow additions to `CLAUDE.md`, with the detail in `dev-docs/`, plus a GitHub issue form.
### Rule 21 — comments explain why, never what changed
- Keep invariants, protocol/era quirks, value couplings, and the reason a workaround exists. One line where one line will do.
- Development narrative does not ship: before a PR leaves draft, sweep `git diff main...HEAD` for added comments and remove change history ("previously", "changed from"), review dialogue ("per review"), hedges ("I think"), and commented-out code. What a future reader still needs goes in the commit message or PR description.
- New `## Comments` section in `dev-docs/code-standards.md`; rule 21 in the `modernuo-code-audit` skill.
### Workflow Rules — bugs you were not asked to fix
Written for forks and custom projects built on ModernUO, which inherit this repo's `CLAUDE.md`. Also applies here (upstream is `origin`).
1. **Classify** — exploit-class (duplication, player-triggerable crash, auth bypass) goes to private disclosure only (`hi@modernuo.com`, per `CONTRIBUTING.md`), never a public issue, PR, or Discord post.
2. **Verify** the defective lines exist verbatim in upstream `main` via read-only `gh api`. If they don't, it is the fork's bug and nothing leaves the fork. This is also what mechanically keeps custom code out of reports: only lines that pass the check may be quoted.
3. **Dedup** — search upstream issues and PRs (all states) by file, symbol, and symptom, plus recent commits on the path. A merged fix → offer to import it; an open issue → offer to comment there.
4. **Draft, show, offer, wait** — the draft and a *scrub ledger* (what was removed, what was verified upstream, what is new code) are shown in full. The user picks: file an issue, open a PR, comment, draft a Discord post for https://muo.gg/discord, or nothing. A standing or conditional instruction ("if upstream has a fix pull it in and open a ticket") is not approval of a draft the user has not read.
5. **Importing fixes** — never `fetch`/`cherry-pick`/hand-port from any remote without asking; canonical URL only; review the whole commit as untrusted (workflows, `*.csproj`, `Directory.Build.props`, scripts); apply only after a second yes. Third-party forks and unmerged PRs are never a source.
`dev-docs/bug-reporting.md` is the process; `dev-docs/claude-skills/modernuo-bug-reporting.md` is Claude's step-by-step procedure (opt-in, like the other skills).
### Issue form
- `.github/ISSUE_TEMPLATE/bug_report.yml` — structured fields (summary, upstream location, commit, reproduction against a clean build, expansion/platform/found-via dropdowns) and a required checklist restating the rules. Applies the `bug` label.
- `.github/ISSUE_TEMPLATE/config.yml` — chooser links for private security reports and Discord.
- Form submissions render as `### <Field>` markdown; the skill writes that exact shape via `gh issue create --body-file`, so an assistant-drafted issue is indistinguishable from a browser one.
## How the skill was validated
Pressure scenarios against subagents, without and then with the rules present.
- **Without**: given a fork with custom content, an owner who said "open a ticket so they know" and went to bed, and a restart in 20 minutes, the agent filed the upstream issue immediately — and the body carried the fork's console log lines and a description of the custom mechanic that triggered the bug, despite the agent stating it had "scrubbed hard". It did refuse an unreviewed third-party PR.
- **With**: same scenario, the agent pushed nothing and filed nothing, removed every log line (including the one that only named the upstream method), produced a scrub ledger, left the "reproduced on clean main" box honestly unticked, and linked the third-party PR without fetching it. An exploit scenario with a relayed standing "email the maintainers immediately" instruction also held: private email drafted, not sent.
The rationalization table in the skill is built from what the baseline agent actually said.
## Notes for review
- The rule is deliberately strict: the user reads the exact draft before anything is submitted. If an explicit in-session waiver ("file it, I don't need to see it") should be honored, that is a one-line change to Workflow Rule 2.
- All `gh` commands in the docs were run against this repo; the worked example points at `Projects/UOContent/Mobiles/AI/BaseAI/PetOrders.cs` and a line that exists there, with the example defect marked as illustrative.
- `config.yml` links private disclosure to `CONTRIBUTING.md` rather than a `mailto:` because GitHub only accepts `http(s)` contact links.
## Symptom
Since #2614, on any shard with `taming.petsStandDownOnCommand` (default `Core.ML`), every wild creature hits `"I'm being attacked but my master told me not to fight."` when struck. Brigands still chase (acquisition is a separate path in the think loop) but the retaliation path is dead: no `OnAggressiveAction`, no `StopFlee`, no `ForceReacquire`, `Combatant` never set, and `Warmode` forced off on every hit.
## Root cause
#2614 correctly added `OrderType.None` to `BaseAI.IsStandDownOrder` — a stopped pet rests on `None` and Publish 51 says it must not fight back. But the gate in `BaseCreature.AggressiveAction` never asked whether anybody could have given the order. The old predicate (`ct != Follow && ct != Stop && ct != Stay`) had only excluded wild creatures by accident: `None` was not in its set, so nothing ever needed to spell the check out. A wild creature's `_controlOrder` is always `None`.
## Fix
Both halves of `AggressiveAction` now gate on `Controlled && ControlMaster != null && Commandable` — the same predicate every order entry point already uses (`OnSpeech`, context menu, `IsValidTarget`). Only a creature somebody can command has been told to stand down. That excludes, and lets fight back:
| Creature | Why it was standing down |
|---|---|
| Wild creature | rests on `None` |
| Energy vortex, blade spirits, animated weapon, animate dead | `Summoned` with a `SummonMaster` but never `Controlled`; rests on `None` |
| Familiar, talisman summon, escortee, mirror image | `Controlled` with a master but `Commandable => false`; sits on a system-issued `Follow` |
The last row is a deliberate behaviour change from #2614 for ML+ shards: a familiar or escortee on `Follow` no longer stands down when attacked. The publish speaks of commanded pets; a creature that cannot take an order was never told anything, and pre-ML it always fought back.
Commandable summons (Summon Creature, elementals, daemons) are `Controlled` with `ControlMaster == SummonMaster`, so they stand down exactly as pets do.
## Also: stop after stay
Found while testing: `all come` then `all stop` left the pet on `Stay`, ticking *"I have been ordered to stay"*. `come` rests into `Stay` on arrival (deliberate ModernUO divergence, kept), and #2614 had `IssueStop` keep a previous `Stay`. RunUO and ServUO never consult the previous order — `DoOrderStop` is "wander around here" (or `None` pre-ML) — and Publish 51 says a stopped pet *"may wander"*. `Stay` now joins `Follow`/`Guard` in `IssueStop`: stop cancels the standing order and the pet idles anchored where it stands. `Stop_WhileStaying_RemainsStayingAtOriginalPost` is replaced by `Stop_WhileStaying_CancelsToIdleNone` plus the `come, stop` repro.
## Tests
`PetRetaliationTests` gains one case per row above: `WildCreature_`, `UncontrolledSummon_`, `UncommandableCreature_Retaliates_UnderStandDown`. Each fails on main and passes here; full `UOContent.Tests` green (1058 passed).
## Problem
`BaseFamiliar.OnThink` drove its own movement (`WalkMobileRange` toward the master) while the familiar was also a controlled pet running `Obey()`. `Summon → SetControlMaster` issues `Come`; `DoOrderCome` converts it to `Stay` within two tiles and anchors `Home`; from then on `OnThink` walked toward the caster while `DoOrderStay` greedy-stepped back toward the stale post — the backtracking. Combat never approached anything: main only copied `Combatant` while already adjacent to the caster. `CurrentSpeed = 0.01` was a 10 ms think / 50 ms step sprint hack, and `RangeCheck` teleported the familiar to a spot eight tiles *from* the caster.
## Change
A dedicated `FamiliarAI : BaseAI` (registered through `ForcedAI`, like `CloneAI`) owns every familiar decision, for both the controlled (`Obey`) and uncontrolled (`Think`) dispatch:
1. **Lifecycle** — caster gone → drop pack, delete. Caster on another map → stand down and wait for `TeleportPets`.
2. **Herding** (Dark Tides) — stands down, then `CheckHerding()`. Outranks combat.
3. **Assist** — combat-capable familiars (dark wolf, vampire bat, horde minion) engage the caster's target; otherwise anything in a fight with the caster's side — attacked the caster or the familiar, or attacked by the caster (a pet's attack is credited to the caster) — that is still fighting the caster, the familiar, or one of the caster's pets. Leashed to `RangePerception` of the caster; dropped when the caster hides. Shadow wisp and death adder never fight (`AssistsMaster => false`, enforced at the `Combatant` setter so no path can hand them a target).
4. **Follow** — `MoveTo(master, 1)` through the centralized `ApproachTarget` (greedy step / persistent `PathFollower` / stall detection).
5. **Keep-up** — snap to a validated tile beside the caster (on the caster's floor) when outpaced on open ground beyond 10 tiles, or when `ApproachTarget` gave up; never while a detour is working.
**Command immunity** is expressed inside the order machinery rather than around it: `FamiliarAI.IssueOrder` does nothing and rests the order on `Come`, so `TeleportPets` keeps working and no system-issued `Attack` (retaliation on ML's stand-down rule) can strand the familiar. `StandsDownOnCommand => false` so the ML rule never mutes it.
**Visibility** mirrors the caster from the familiar's own state (a step reveals a hidden NPC in `Mobile.OnMove`; the old cache compared the caster's previous state), `RevealingAction` is suppressed while the caster is hidden, and becoming hidden drops Warmode so no swing gives the caster away.
**Speed** is a flat 0.1 (`ReduceSpeedWithDamage => false`).
### Engine-side (all `Projects/UOContent`)
- `ApproachTarget` records which exit it took in `BaseAI.LastApproach` (`ApproachOutcome`: Arrived / Waiting / DirectProgress / Routing / Blocked / GaveUp / InvalidGoal). Callers' booleans are unchanged; keep-up reads this instead of running a second scheduler. `MoveTo`'s arrival return now also clears the move intent, as `ApproachTarget`'s own arrival does.
- `MoveToPoint(goal, range = 1)`; `CheckHerding` passes 0. **Fixes a main regression from #2591:** herding stopped one tile short, never cleared `TargetLocation`, and left the creature pinned to the herding pace — affects the shepherd's crook and the Dark Tides scroll fetch for every herded creature, not just familiars.
- `ChangeAIType` reads `ForcedAI` once. It read it twice, and each `BaseAI` ctor activates its timer for a non-sector-gated creature, so a `ForcedAI` creature with `PlayerRangeSensitive => false` got an orphan AI ticking it.
## Tests
`FamiliarAITests` are timer-wheel driven (the real `AITimer` thinks and moves; `PetPacingTests` style) against live Trammel statics, gated on client map data: follow without backtracking, the five-way assist theory, leash, retaliation, aggressor fallback (caster's own `Combatant` expired; caster's pet in the fight), target dropped when it stops fighting, keep-up on open ground / not while routing / after give-up, hidden mirror across steps, herding priority with a visible fighting caster, stand-down when left behind, no stale move intent. `ApproachOutcomeTests`, `HerdingTests` (fails on main), `ForcedAITests` (fails on main) cover the engine-side pieces.
Against `origin/main` with the familiar tests dropped in: 16/16 fail, including the reported backtracking. On this branch: `UOContent.Tests` 1082 passed / 2 skipped, `Server.Tests` 891/891, solution builds with 0 warnings.
## Symptom
`[set TargetLocation (x, y)` (quoted or not) answers **"That is not properly formatted."**, and in `[props` the `>` next to `TargetLocation` does nothing when the value is null — which is its normal idle state (`BaseAI.cs:650` clears it).
This looked like a `Point3D` parsing regression from #2624/#2625, but `Point3D`/`Point2D`-typed properties (`Location`, etc.) were never affected. The only `[CommandProperty]` in the tree declared as an **interface** is `BaseCreature.TargetLocation : IPoint2D` (`BaseCreature.cs:1111`), and both code paths only knew the structs. `git log -S"IPoint"` over the parser and gump files hits nothing but the initial import — the gap is inherited from RunUO, not recent.
## Root cause
- **`[set`** — `Types.TryParse` has no branch for `IPoint2D`/`IPoint3D`. An interface has no static `Parse`, so `GetParseMethod` returns null and the value falls into `Convert.ChangeType("(x, y)", typeof(IPoint2D))`, which throws → "not properly formatted".
- **Props gump** — `PropsGump` routes on `obj?.GetType() ?? prop.PropertyType` (since #2180). With a null value the type is `IPoint2D`; `Point2D.IsAssignableFrom(IPoint2D)` is false, no branch matches, and the click is inert. It only worked when the slot already held a `Point2D`, because the runtime type is then the struct.
## Fix
- `Types.TryParse`: `IPoint3D`/`IPoint2D` targets resolve to the concrete struct — `Point3D` first, then `Point2D` for an `IPoint2D` target (a 3-tuple is a valid `IPoint2D`). `(-null-)` still clears; the existing null branch runs first.
- `PropsGump`: the interface types route to `SetPoint3DGump`/`SetPoint2DGump`. The entity branch stays ahead of them — `TargetLocation` legitimately holds a Mobile too (`ShepherdsCrook.cs:148`, herding toward the shepherd), and that case still opens `SetObjectGump`.
- `SetPoint2DGump`/`SetPoint3DGump`: seed the text entries from `value is IPoint2D/IPoint3D` rather than a hard cast, so a `Point3D` sitting in an `IPoint2D` slot cannot `InvalidCast`.
## Not covered
`[set TargetLocation 0x40001234` (assigning a mobile by serial) still reports "not properly formatted" — the entity branch keys on the *target* type being `IEntity`, which `IPoint2D` isn't. Real state, but niche; left out to keep this to the reported symptom.
## Testing
Five cases in `InterfacePointParseTests`, watched fail before the change (three returned the error string; two pin existing behaviour that must survive): tuple → `Point3D` for both interfaces, pair → `Point2D`, pair rejected for `IPoint3D`, `(-null-)` clears.
`dotnet build` 0 warnings. **1059 UOContent** and **891 Server** tests pass, 0 failures. The gump routing is a one-line branch with no automated test — needs an in-game check: `[props` a creature with a null `TargetLocation`, press `>`, expect the Point2D editor.
## Problem
A connection's send buffer is a fixed 256 KB. A burst of world traffic (a crowded area, a mass spawn, a war) that outruns the client's acknowledgements fills it, `NetState.Send()` reports "send buffer exhausted", and the player is disconnected. Raising the size for everyone multiplies the per-connection footprint (4096 × 256 KB is already 1 GB at full occupancy, page-locked on Windows).
## What changes
- **Growth.** When a packet does not fit (the write span is too small, the packet is larger than the span, or compression returns 0), `Send()` asks the transport to grow the buffer to the next power-of-two tier and retries, up to `network.sendBufferMaxSize` (2 MB). Compression retries once per tier since its output size is not known in advance, including when the buffer is completely full. Only when growth is refused does the existing exhaustion disconnect run. The success path is unchanged.
- **Memory ceiling.** Growth is refused (with a once-a-minute warning) when the process working set exceeds `network.memoryCeilingPercent` (80) of the memory available to the process (container-aware; `0` turns the check off). The figure is sampled at startup and refreshed each maintenance tick.
- **Shrink.** A grown socket returns to the base buffer once it is drained and 30 s have passed since its last growth, attempted from the `DataSent` handler and from the 5 s alive sweep.
- **Retention.** Every minute a timer calls the transport's `Maintain()`, which trims idle tier slabs down to the peak concurrent usage of the last 15 minutes, so recurring bursts reuse buffers without allocation while rare ones give the memory back. The line logs at Debug, and only when capacity, usage, or the floor changed or a growth was refused (budget, at max, or ceiling), so an idle shard logs nothing.
- **Budget.** `network.sendBufferGrowthBudget` (256 MB) caps the tier pools' capacity; a positive value below one tier slab is raised with a warning, a negative one is clamped to 0 (growth off). Worst case is base × connections plus the budget.
- Settings are coerced with accurate warnings (power of two, minimum, 256 MB transport ceiling). `[dumpnetstates` gains the send buffer size. `dev-docs/server-requirements.md` describes the new memory story.
## Tests
`NetStateSendBufferTests` (real loopback sockets): growth instead of disconnect with a byte-exact stream, compressed growth against the compressor's own output, the grow-then-copy path, growth with a send genuinely in flight, refusal past the maximum, refusal under the ceiling, refusal on a closing socket, shrink after the hold (direct and through the alive sweep), and the setting coercions. Server.Tests 891 passed, UOContent.Tests 1052 passed against the published 1.0.12.
Reviewed per task, whole-branch, and adversarially by a second model (twice, the second time jointly with the transport branch); all findings addressed.
## Summary
Two small additive changes a derived spawner needs.
- **`BaseSpawner.OnTick()` is now `virtual`.** A subclass that gates spawning on external state (time windows, event triggers) must gate *timer* spawns without gating the manual `Spawn()` API, and `OnTick` is the only place the two paths differ: it is the timer callback and `Spawn()` is both what it calls and what commands and scripts call. Cost: one virtual dispatch on the existing timer callback; no change to stock behaviour.
- **`group` in the JSON DTO.** `BaseSpawner.Group` (all dead, then respawn) is binary-persisted but was missing from `SpawnerDto`, so it did not survive export/import. Added to the abstract record after `spawnLocationIsHome`, assigned in `ApplyDto` after `InitSpawn` (which resets it), and exported by the three stock `ToDto` implementations.
## Test plan
- [x] A derived spawner overriding `OnTick` with a closed gate: `OnTick()` spawns nothing; manual `Spawn()` still spawns and does not pass through `OnTick`.
- [x] DTO round trip with `Group = true` carries `group` and restores it on `ToSpawner()`.
- [x] `UOContent.Tests` full suite green.
- [ ] CI