Skip to content

feat(triggers): skill triggers fire from ModernUO SkillEvents.SkillUsed; XmlSpawner outcome and value-window semantics - #4

Merged
kamronbatman merged 9 commits into
mainfrom
feat/skill-trigger-wiring
Sep 12, 2026
Merged

kamronbatman merged 9 commits into
mainfrom
feat/skill-trigger-wiring

Conversation

@kamronbatman

@kamronbatman kamronbatman commented Sep 12, 2026 •

Copy link
Copy Markdown
Member

Summary

Wires skill: triggers to ModernUO's SkillEvents.SkillUsed (#2636) so they fire for real, and gives them XmlSpawner's semantics.

  • Submodule → 309fcfeb2 (post-#2636 main); test SDK 18.10.0 to match ModernUO's test projects.
  • Wiring. ModernSpawnerEvents.Configure() subscribes SkillEvents.SkillUsed and forwards player attempts (creatures are ignored; they roll skill checks every swing) to TriggerSystem.OnSkillUse(Mobile, Skill, bool success). TriggerContext carries SkillSuccess and SkillValue.
  • Semantics from XmlSpawner. SkillTrigger gains an outcome (+ success only, - failure only, none for either), a value window, and an AnySkill flag replacing the (SkillName)(-1) cast and the Alchemy sentinel. Grammar stays positional and grows compatibly: skill:<Skill>[+|-]:<range>:<min>[-<max>]:<los>:<cooldown>; every existing definition parses with unchanged meaning and Parse(Serialize(x)) round-trips every field. XmlSpawner's own skill trigger never fired in ServUO (its Skill[+/-][,min,max] parser was unwired), so only the intended semantics carry over.
  • Migrator maps XmlSpawner's SkillTrigger attribute onto the grammar, using the node's proximity range.
  • Test host seeds the clock to a fixed instant (Core._now, via the InternalsVisibleTo from #2636) and exposes a one-way AdvanceClock, so cooldown behaviour is testable and time-window triggers do not branch on the wall clock; end-to-end tests drive real SkillCheck handlers on a placed PlayerMobile.
  • Review fixes. RequireLOS on skill and proximity triggers now uses Mobile.InLOS (it used CanSee, which ends in Item.Visible, and spawners are invisible, so it could never pass for a player). The skill and time-of-day dispatch loops iterate a pooled snapshot of the registry, so a trigger action that deletes or registers a spawner cannot invalidate the enumeration. SkillTrigger.Parse rejects empty and out-of-range segments instead of throwing; TriggerSystem.ParseTrigger warns when a definition is rejected; the migrator warns on unknown skills and inverted windows.
  • Docs: D3 ruled; #2636 moved to the merged table; migration doc grammar updated.

Test plan

  • Parsing/round-trip tests for outcome suffix, value window, Any flag, and every existing definition.
  • World-backed: in-range player skill use fires; out of range does not; failure-only ignores success; creature use does not fire; cooldown suppresses until the clock advances. Mutation-checked against the subscription line.
  • Migrator mapping for Mining, Mining+, Magery-,50,90; unknown skill name and inverted window are skipped with a warning.
  • RequireLOS now calling Mobile.InLOS has no test: Map.LineOfSight needs the tile matrix the test host deliberately does not load. Verified by reading.
  • dotnet build ModernSpawner.slnx clean; full suite green (464).
  • CI

kamronbatman and others added 9 commits September 11, 2026 23:01
… SDK 18.10.0 to match

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KXJwRhwvHQXV2ABJHicwsr
Replace SkillTrigger's (SkillName)(-1) sentinel with an explicit AnySkill
flag, add SkillOutcome (Any/Success/Failure) and MaxSkillValue so the
grammar becomes skill:<Skill>[+|-]:<range>:<min>[-<max>]:<los>:<cooldown>.
Add a pure MatchesContext helper for skill/outcome/window checks, and
extend TriggerContext with SkillSuccess/SkillValue for Task 2 to populate.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KXJwRhwvHQXV2ABJHicwsr
…host seeds the clock

ModernSpawnerEvents becomes a one-way bridge: Configure() subscribes once to
Server.Misc.SkillEvents.SkillUsed and forwards player attempts to the trigger
system. The old C# event and the "call this from SkillCheck.cs" shim had no
callers and are gone. ModernSpawnerConfiguration.Configure() now wires it.

TriggerSystem.OnSkillUse takes (Mobile, Skill, bool) and fills the context with
UsedSkill, SkillValue and SkillSuccess, so Task 1's outcome filter and value
window are reachable end to end. Allocation is unchanged: the per-spawner
TriggerContext that was already built is the only one.

The test host seeds Core._now the way production does (Server.dll grants
ModernSpawner.Tests InternalsVisibleTo) and gains AdvanceClock, so cooldowns and
any absolute-clock comparison behave instead of reading as "never elapsed".
SkillTriggerTests drives the real SkillCheck.Mobile_SkillCheckDirectTarget
handler and covers range, outcome filtering, creatures, and the cooldown gate.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KXJwRhwvHQXV2ABJHicwsr
…context; OnSkillUse on ITriggerSystem

Review round 1 on the skill-trigger dispatch path, which runs on every player
skill attempt server-wide.

Skill.Value was read once per map-matching spawner; each read re-derives the
stat-scaled value through NonRacialValue and adds the racial bonus, so it is now
hoisted alongside skillName and read once for the whole dispatch.

Each map-matching spawner also allocated a TriggerContext before any trigger was
consulted. A cheap indexed pre-scan for MatchesSkill(skillName) now runs first,
so a spawner holding triggers for other skills allocates nothing. The pre-scan
records the first matching index and the evaluation loop resumes there rather
than rescanning from zero; both loops are indexed, with no enumerator and no
closure.

ITriggerSystem gains OnSkillUse(Mobile, Skill, bool) next to OnSpeech, so the
interface matches what TriggerSystem exposes. ModernSpawnerEvents.OnSkillUsed
uses `is not { Player: true }` instead of a lifted bool? comparison, and
ModernSpawnerTestServer.AdvanceClock documents that the clock only moves forward
and is shared, so tests must assert elapsed intervals rather than absolute
deadlines.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KXJwRhwvHQXV2ABJHicwsr
…ops iterate a snapshot; fixed test clock

SkillTrigger.Parse threw ArgumentOutOfRangeException on an empty value segment
("skill:Mining:10::false:5"): IndexOf('-', 1) rejects startIndex 1 on a
zero-length string. That took down the whole ActivateTriggers pass instead of
skipping one definition. An empty window is now malformed and returns null.

RequireLOS checked visibility, not line of sight. Mobile.CanSee(Item) ends in
item.Visible and BaseSpawner sets Visible = false, so a RequireLOS skill or
proximity trigger could never fire for a player. Both now use Mobile.InLOS.

SkillTrigger.Evaluate runs MatchesContext immediately after the spawner/Running
guard, so an attempt this trigger does not react to - the common case on a
server-wide dispatch - pays nothing for the cooldown, map, range and LOS checks.

OnSkillUse and CheckTimeOfDayTriggers called spawner.Trigger() while enumerating
their dictionaries; Trigger() reaches Spawn() and any attached script, and a
DESPAWN script or a spawned ModernSpawner can delete or register a spawner that
carries a trigger, invalidating the enumerator. Both now copy their entries into
an STArrayPool-rented KeyValuePair buffer, iterate it by index, skip entries
whose spawner has since been deleted or unregistered, and return the buffer
cleared in a finally. OnSkillUse also returns early when nothing is registered,
which is the common case on a shard with no skill triggers.

Parser hardening: Enum.TryParse<SkillName> accepts any numeric string, so both
SkillTrigger.Parse and XmlSpawnerMigrator.MapSkillTrigger now require
Enum.IsDefined; a failed range parse keeps the documented default of 10 rather
than falling to 0 (and so to 1 through Math.Max); MapSkillTrigger rejects an
inverted value window down the same warning path as an unknown skill name, and
documents that an absent min reads as 0. TriggerSystem.ParseTrigger logs a
warning when a registered factory returns null, which was silently swallowed,
and its three Console.WriteLine calls become Logger calls.

The test host seeds a fixed 2020-01-01T12:00:00Z rather than DateTime.UtcNow, so
wall-clock-sensitive triggers take the same branch on every run, and AdvanceClock
ignores a non-positive span per its documented forward-only contract.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KXJwRhwvHQXV2ABJHicwsr
The audit-status column in product-spec.md records what the audit at the pinned
commit found, not what this branch has since built. The skill row was flipped to
"Implemented" when the wiring landed, which erases the finding the column exists
to carry; it goes back to "Stubbed", with the as-built description staying in the
v1-target cell, now noting that RequireLOS is line of sight rather than
visibility.

architecture.md marks the skill trigger "yes, tested" alongside kill, and its
skill bullet says spawners with no matching trigger allocate nothing (they still
pay one map compare and a linear scan of their trigger list) rather than "pay
nothing"; the InLOS and snapshot-dispatch facts are recorded there too.

xmlspawner-migration.md keeps the "never fired" claim but states it as what was
verified: the XmlSpawner sources, ServUO and the ModernUO port alike, declare the
parsed skill-trigger fields and read them, but never assign them.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KXJwRhwvHQXV2ABJHicwsr
@kamronbatman
kamronbatman merged commit 0b45d64 into main Sep 12, 2026
1 check passed
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