Skip to content

refactor(core): port ModernSpawner to the ModernUO entry-ownership contract - #1

Merged
kamronbatman merged 13 commits into
mainfrom
port/entry-contract
Sep 12, 2026
Merged

kamronbatman merged 13 commits into
mainfrom
port/entry-contract

Conversation

@kamronbatman

@kamronbatman kamronbatman commented Sep 12, 2026 •

Copy link
Copy Markdown
Member

Summary

Ports ModernSpawner onto the entry-ownership contract that merged in ModernUO #2621, and bumps the submodule to a52ce6ef7.

  • ModernSpawner : Spawner now owns List<ModernSpawnerEntry> and implements the base contract (Entries, EntrySpan, CreateEntry, AddEntryCore, RemoveEntryCore, ClearEntriesCore, AdoptEntries, CloneEntry). The parallel _modernSpawned map, the temp-entry spawn path, and the new hides of AddEntry/Start/Stop are gone.
  • ModernSpawnerEntry : SpawnerEntry keeps only its 11 extra fields and declares [DirtyTrackingEntity] private BaseSpawner Owner => Parent; for cross-assembly dirty tracking.
  • Modern behaviour moved into the lifecycle hooks: OnStarted/OnStopped (activate/deactivate scripts, trigger (de)activation), entry-aware GetSpawnPosition (positioning rule, spawn-area offset), OnSpawned (loot template, OnSpawn script), OnSpawnedDeath (kill triggers, OnDespawn script).
  • ModernSpawnerDto owns entries (typed) plus triggers and cycle-mode state; SpawnArea is replaced by Spawner.SpawnBounds.
  • Latent bugs removed by the port: the kill trigger and OnDespawn script never ran (their dispatcher had no caller); per-entry positioning never applied; Respawn() was a no-op; Disabled was ignored by the modern spawn path.
  • Trigger registration has a single guarded entry point (EnsureTriggersActive), so spawners built by the XmlSpawner migrator, the JSON/XML importers, [ImportSpawners, and [dupe register their triggers immediately instead of on the next restart; [dupe now deep-copies trigger definitions.
  • PropertyAccessorCache no longer tries to compile accessors for ref-returning properties, which made ModernSpawnerConfiguration.Configure() throw on a real server.
  • New ModernUO test-server fixture and end-to-end lifecycle tests (spawn/kill/respawn, start/stop, dupe, DTO and binary round trips, kill hook dispatch, trigger registration after import).

Test plan

  • dotnet build ModernSpawner.slnx clean, TreatWarningsAsErrors on
  • dotnet test Projects/ModernSpawner.Tests: 428 passed (419 existing + 9 lifecycle)
  • Mutation checks: removing OnSpawnedDeath, lines from CloneEntry, or the import-path EnsureTriggersActive() call fails the corresponding lifecycle test
  • CI on this PR

Follow-ups (tracked in dev-docs/modernuo-prerequisites.md)

  • ModernUO: InternalsVisibleTo("ModernSpawner.Tests") so the fixture can seed the test clock (Core.Now is DateTime.MinValue in the test host today).
  • TriggerConfigGump toggles TriggerActivated and adds definitions without calling EnsureTriggersActive, so gump-added triggers stay inert until the next stop/start (one-line follow-up).
  • OnDelete/OnStopped gate DeactivateTriggers on TriggerActivated; clearing the flag after registration can leave a deleted spawner registered in TriggerSystem.
  • XmlSpawnerMigrator ignores running="false" in the source file (spawners are constructed running and never stopped); XmlSpawnerImporter handles it correctly.
  • Pre-existing nullable value types outside this diff (XmlSpawnerImporter, SkillTrigger, ScriptContext) to be removed in a follow-up.
  • dotnet build -c Analyze is red inside the ModernUO submodule (CA1307/CA1310), so the analyzer gate gives no signal for this repo yet.

kamronbatman and others added 13 commits September 11, 2026 17:52
The build is red on this commit by design; the port that follows makes it green.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KXJwRhwvHQXV2ABJHicwsr
ChurnTick enumerated BaseSpawner.Spawned while killing from it. Deleting a
spawned entity routes through BaseSpawner.Remove, which removes it from that
same dictionary, so the first kill threw "Collection was modified". Snapshot
the candidates into a PooledRefList<ISpawnable> first, then kill from the
snapshot.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KXJwRhwvHQXV2ABJHicwsr
…ts for the ported spawner

Boots a real ModernUO world in the test host (ModernSpawnerTestServer, guarded to run
once, behind a non-parallel collection fixture) and exercises a live ModernSpawner end
to end over the entry-ownership contract: entry construction, spawn/kill/respawn against
the single Spawned registry, stop/start, Respawn idempotence, Dupe, the DTO round trip
and the binary round trip that proves [AfterDeserialization] rebuilds Spawned over the
modern entries.

The fixture reuses ModernUO's own Server.Tests.Maps.TestMapDefinitions rather than
copying the map table, so ModernSpawner.Tests now references Server.Tests.csproj and
copies Distribution/Data to its output.

Booting the fixture surfaced a startup defect in PropertyAccessorCache: PrewarmCache
compiles a getter for every property of Mobile, and ref-returning properties such as
Mobile.DamageEntries (ref ValueLinkList<DamageEntry>) cannot be expressed as
Func<object, object?>, so Expression.Lambda threw and ModernSpawnerConfiguration.Configure
crashed. FindProperty now reports ref, pointer and ref-struct properties as "not found",
the way indexers are already skipped.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KXJwRhwvHQXV2ABJHicwsr
Both tests passed with the behaviour they name deleted, so they guarded nothing.

Kill_DispatchesOnDespawnScriptAndKillTrigger now gives each half a real observable.
SETVAR wrote to a per-ScriptContext dictionary discarded at the end of execution, so
the despawn script left no trace; it now uses SET, which writes through
PropertyAccessorCache to the ScriptContext target that OnSpawnedDeath binds to the
dying creature, and the test asserts the creature's Name changed. The kill trigger
half registers a KillTrigger definition, sets TriggerActivated and cycles Stop/Start
so OnStarted runs ActivateTriggers, then asserts the spawner's Triggered flag was
false before the kill and true after -- a flag only TriggerSystem's Trigger() call
can set.

Dupe_ClonesModernFields asserted 2 of the 12 fields the clone path copies. It now
sets a distinct non-default value for all eleven ModernSpawner.CloneEntry copies plus
the base clone's Disabled, and asserts each on the copy.

Verified by mutation: gutting OnSpawnedDeath, removing only its
TriggerSystem.OnEntityKilled call, and dropping three CloneEntry lines each fail the
covering tests; the engine was restored afterwards.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KXJwRhwvHQXV2ABJHicwsr
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KXJwRhwvHQXV2ABJHicwsr
…trigger definitions

Trigger registration now goes through one guarded helper, ModernSpawner.
EnsureTriggersActive(), the only caller of TriggerSystem.ActivateTriggers outside
the trigger system. It deactivates first and re-registers only when the spawner is
running, is TriggerActivated and has definitions, so it is idempotent even though
ActivateTriggers appends rather than replaces.

BaseSpawner.Start() only reaches OnStarted when Running actually flips, and every
construction path builds an already-running spawner, so migrated and imported
spawners never registered the triggers they had just been given. ToSpawner(), both
XmlSpawnerMigrator paths, XmlSpawnerImporter and both SpawnerJsonImporter entry
points now end in EnsureTriggersActive(); OnStarted and [AfterDeserialization] use
it too.

[dupe also dropped triggers: _triggerDefinitions is [SerializedIgnoreDupe] with no
OnAfterDuped override, so a copy came back TriggerActivated with an empty list. The
new override copies the list through the generated setter and registers it.

Also: OnStopped skips the deactivate script once the item is flagged deleted (the
trigger deactivation still runs) and documents that deletion reaches it through
BaseSpawner.OnDelete -> Stop(); AdoptEntries returns early when handed its own list;
the after-spawn script doc records Spawn()'s early returns.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KXJwRhwvHQXV2ABJHicwsr
§2.2 describes trigger registration as it is: one guarded EnsureTriggersActive()
called from OnStarted, [AfterDeserialization] and every construction path, with
deactivation in OnStopped/OnDelete. The kill row is wired and tested - OnSpawnedDeath
via BaseSpawner.NotifySpawnedDeath from BaseCreature.OnDeath.

§2.4 drops SpawnArea, which no longer exists; Spawner.SpawnBounds is the one bounds
and HomeRange is a computed view over it. §2.5 applies loot and the entry spawn script
in OnSpawned, not SpawnFromEntry. §2.6 records that the DTO carries modern entries,
triggers and cycle state and that both round trips are tested. §3's hot-path bullet
drops the closure and temp-entry allocations, which the port removed.

A living doc must not point at a branch: §4.3 says "implemented" instead of naming
port/entry-contract and its commits, and the retired "support branch" model is gone
from §5, product-spec.md §6 and the prerequisites table.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KXJwRhwvHQXV2ABJHicwsr
…Deleted guard could never fire

Item.Delete() calls OnDelete() before it sets the Deleted flag, so on the deletion
path - Item.Delete -> BaseSpawner.OnDelete -> Stop() -> OnStopped - Deleted is still
false and the guard added in a8e5981 was dead code. Removed it and kept the doc
sentence: deleting a running spawner does run the OnDeactivate script, and nothing
in OnStopped's state distinguishes a stop from a deletion.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KXJwRhwvHQXV2ABJHicwsr
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KXJwRhwvHQXV2ABJHicwsr
@kamronbatman
kamronbatman merged commit 850f7de into main Sep 12, 2026
1 check passed
kamronbatman added a commit that referenced this pull request Sep 12, 2026
…tor honours Running=false; no nullable value types (#2)

## Summary

Closes the trigger-registration follow-ups parked in #1, and removes the last nullable value types from the engine.

- **Registration follows every flag and list change.** `TriggerActivated` is now a property whose setter calls `EnsureTriggersActive()` (serialized slot unchanged, layout byte-identical). `TriggerConfigGump` adds and removes through the generated helpers (`RemoveFromTriggerDefinitionsAt` for the clicked row) and re-registers after each edit; `SpawnerJsonImporter` clears through `ClearTriggerDefinitions()` so dirty tracking holds. `TriggerSystem.ActivateTriggers` still has exactly one caller.
- **Deactivation no longer gated on the flag.** `OnStopped` and `OnDelete` call `DeactivateTriggers` unconditionally (a no-op when nothing is registered), so clearing `TriggerActivated` after registration can no longer leave a deleted spawner in the trigger system.
- **XmlSpawner migrator honours `Running="false"`** on both node forms: the spawner is stopped (it is constructed running) and its triggers registered or not accordingly. Behaviour note: a `Running="true"` node with zero parsed entries now imports stopped (before, it was left in its constructed running state), which matches D9's "unreproducible encounters import stopped".
- **Perf seed and gump fixes found by review.** `[ModernSpawnerPerfSeed` now activates the proximity trigger it adds, so the proximity-dispatch benchmark measures something. `TriggerConfigGump` writes the time-window definitions with the trigger type names `TriggerSystem` actually registers (it wrote `walltime:`/`gametime:` before, which parsed as unknown while the gump reported success), and clamps the page after deleting the last trigger on a page.
- **No nullable value types:** `XmlSpawnerImporter` delays use the `TimeSpan.Zero` sentinel `AddModernEntry` already understands; `SkillTrigger` gets a `TimeSpan cooldown` overload; `ScriptContext.LocationOverride` becomes `HasLocationOverride` + `Point3D`. The remaining `Type?` members live only in the legacy YAML/JSON export models (`ScriptExportModels`, `SpawnerExportModels`), which the SpawnerDto-only format decision (D5/D6) retires; they are left for that work.
- Docs: `CLAUDE.md` rule for trigger mutations; `dev-docs/architecture.md` §2.2; `dev-docs/modernuo-prerequisites.md` records ModernUO #2636 (`SkillEvents.SkillChecked` + test-clock IVT) as open; `dev-docs/product-spec.md` corrects the D3 row: `skill:` definitions parse and register today but never fire, because nothing calls `ModernSpawnerEvents.OnSkillUsed` until the wiring PR subscribes to #2636's event.

## Test plan

- [x] New world-backed tests in the sequential collection: toggling `TriggerActivated` registers/unregisters; adding a definition to an activated running spawner registers immediately; deleting an activated spawner leaves nothing registered; stopping with a stale registration unregisters; deleting a stopped spawner with a stale registration unregisters; migrator `Running="false"` on both node forms. Each mutation-checked against its reverted behaviour.
- [x] `dotnet build ModernSpawner.slnx` clean; full suite green (438). The test fixture now runs `EventScheduler.Configure()` like production, since window triggers schedule through it.
- [ ] CI

## Follow-ups

- `TriggerSystem.OnMobileProximity` allocates a `TriggerContext` class per movement event per spawner; the D2 trigger-state-machine work should make it a `readonly struct` passed by `in`.
- Legacy export models keep their nullable members until the SpawnerDto-only format (D5/D6) retires them.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant