feat(skills): SkillEvents.SkillUsed for cross-assembly subscribers; InternalsVisibleTo ModernSpawner.Tests (#2636)

## Summary

Two small additive changes that an external content assembly (ModernSpawner) needs, as separable commits.

**1. `SkillEvents.SkillUsed`** (`Projects/UOContent/Skills/SkillEvents.cs`, namespace `Server.Misc`): a plain C# event `Action<Mobile, Skill, bool success>` raised once per skill attempt from each of the four `Mobile_SkillCheck*` handlers, with the handler's own result. Attempts the handler resolves without a roll (too difficult, no challenge) raise too, so a grandmaster's trivial success and a guaranteed combat roll are observable. Not raised when the mobile lacks the skill. Each handler keeps its logic in a private core method and raises on the way out, so there is exactly one raise per attempt and `CheckSkill` itself is unchanged.

- **Why a plain event and not a `[GeneratedEvent]`:** generated events are compile-time static dispatch inside the UOContent compilation, so a subscriber in another assembly cannot use `[OnEvent]`. Shape follows `HelpEvents`.
- **Why "used", not "gained":** this is the XmlSpawner skill-trigger semantic (it wrapped the same four handlers and passed their result as `success`; its grammar was `Skill[+/-]` for success-only or failure-only). Gains are already observable through the existing skill-change notification on `Mobile`.
- **Cost:** one delegate null-check per attempt when nothing is subscribed; no boxing, no closure, no allocation. The handlers sit on the combat swing path.
- **Exception contract:** subscriber exceptions propagate, matching `EventSink`/`HelpEvents`; no try/catch by design.

**2. `InternalsVisibleTo("ModernSpawner.Tests")`** on `Server.csproj`, beside the existing `Server.Tests`/`UOContent.Tests` entries, so an external test host can seed `Core._now` the way the engine's own test initializers do. Separable; a public test seam on `Core` would serve the same need without naming a downstream assembly.

## Open question

The payload is the `Skill` object plus a positional `bool`. A `readonly struct` args type passed `in` would leave room to add `chance` or the target later without breaking subscribers. Happy to change before merge.

## Test plan

- [x] `UOContent.Tests`: 4 tests — a rolled attempt raises once with the returned outcome; each short-circuit path (no challenge, too difficult, on both the direct and value-window handlers) raises with the handler's result; a direct `CheckSkill` call does not raise; no subscriber does not throw. Full suite green.
- [x] `Server` and `UOContent` build clean with `TreatWarningsAsErrors`.
- [ ] CI
This commit is contained in:
Kamron Batman 2026-09-11 22:58:44 -07:00 committed by GitHub
parent 7deb8b082e
commit 309fcfeb27
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 189 additions and 0 deletions

View file

@ -48,6 +48,13 @@ public static class SkillCheck
return false;
}
var success = CheckLocation(from, skill, minSkill, maxSkill);
SkillEvents.InvokeSkillUsed(from, skill, success);
return success;
}
private static bool CheckLocation(Mobile from, Skill skill, double minSkill, double maxSkill)
{
var value = skill.Value;
if (value < minSkill)
@ -76,6 +83,13 @@ public static class SkillCheck
return false;
}
var success = CheckDirectLocation(from, skill, chance);
SkillEvents.InvokeSkillUsed(from, skill, success);
return success;
}
private static bool CheckDirectLocation(Mobile from, Skill skill, double chance)
{
if (chance < 0.0)
{
return false; // Too difficult
@ -156,6 +170,13 @@ public static class SkillCheck
return false;
}
var success = CheckTarget(from, skill, target, minSkill, maxSkill);
SkillEvents.InvokeSkillUsed(from, skill, success);
return success;
}
private static bool CheckTarget(Mobile from, Skill skill, object target, double minSkill, double maxSkill)
{
var value = skill.Value;
if (value < minSkill)
@ -182,6 +203,13 @@ public static class SkillCheck
return false;
}
var success = CheckDirectTarget(from, skill, target, chance);
SkillEvents.InvokeSkillUsed(from, skill, success);
return success;
}
private static bool CheckDirectTarget(Mobile from, Skill skill, object target, double chance)
{
if (chance < 0.0)
{
return false; // Too difficult