diff --git a/Projects/Server/Server.csproj b/Projects/Server/Server.csproj
index dfef0af46..8ec416218 100644
--- a/Projects/Server/Server.csproj
+++ b/Projects/Server/Server.csproj
@@ -50,5 +50,8 @@
<_Parameter1>UOContent.Tests
+
+ <_Parameter1>ModernSpawner.Tests
+
diff --git a/Projects/UOContent.Tests/Tests/Skills/SkillEventsTests.cs b/Projects/UOContent.Tests/Tests/Skills/SkillEventsTests.cs
new file mode 100644
index 000000000..91fba77bc
--- /dev/null
+++ b/Projects/UOContent.Tests/Tests/Skills/SkillEventsTests.cs
@@ -0,0 +1,124 @@
+using Server;
+using Server.Misc;
+using Xunit;
+
+namespace UOContent.Tests;
+
+[Collection("Sequential UOContent Tests")]
+public class SkillEventsTests
+{
+ private sealed class Recorder
+ {
+ public Mobile From;
+ public Skill Skill;
+ public bool Success;
+ public int Calls;
+
+ public void Handle(Mobile from, Skill skill, bool success)
+ {
+ From = from;
+ Skill = skill;
+ Success = success;
+ Calls++;
+ }
+ }
+
+ [Fact]
+ public void DirectTarget_RolledAttempt_RaisesOnceWithTheReturnedOutcome()
+ {
+ var from = new Mobile();
+ var skill = from.Skills[SkillName.Mining];
+ var recorder = new Recorder();
+
+ SkillEvents.SkillUsed += recorder.Handle;
+ try
+ {
+ var rolled = SkillCheck.Mobile_SkillCheckDirectTarget(from, SkillName.Mining, null, 0.5);
+ Assert.Equal(1, recorder.Calls);
+ Assert.Same(from, recorder.From);
+ Assert.Same(skill, recorder.Skill);
+ Assert.Equal(rolled, recorder.Success);
+
+ Assert.False(SkillCheck.Mobile_SkillCheckDirectTarget(from, SkillName.Mining, null, 0.0));
+ Assert.Equal(2, recorder.Calls);
+ Assert.False(recorder.Success);
+ }
+ finally
+ {
+ SkillEvents.SkillUsed -= recorder.Handle;
+ from.Delete();
+ }
+ }
+
+ [Fact]
+ public void ShortCircuits_StillRaise_WithTheHandlerOutcome()
+ {
+ var from = new Mobile();
+ var recorder = new Recorder();
+
+ SkillEvents.SkillUsed += recorder.Handle;
+ try
+ {
+ Assert.True(SkillCheck.Mobile_SkillCheckDirectLocation(from, SkillName.Mining, 1.0));
+ Assert.Equal(1, recorder.Calls);
+ Assert.True(recorder.Success);
+
+ Assert.False(SkillCheck.Mobile_SkillCheckDirectTarget(from, SkillName.Mining, null, -0.1));
+ Assert.Equal(2, recorder.Calls);
+ Assert.False(recorder.Success);
+
+ Assert.False(SkillCheck.Mobile_SkillCheckLocation(from, SkillName.Mining, 50.0, 100.0));
+ Assert.Equal(3, recorder.Calls);
+ Assert.False(recorder.Success);
+
+ Assert.True(SkillCheck.Mobile_SkillCheckTarget(from, SkillName.Mining, null, 0.0, 0.0));
+ Assert.Equal(4, recorder.Calls);
+ Assert.True(recorder.Success);
+ }
+ finally
+ {
+ SkillEvents.SkillUsed -= recorder.Handle;
+ from.Delete();
+ }
+ }
+
+ [Fact]
+ public void CheckSkill_Direct_DoesNotRaise()
+ {
+ var from = new Mobile();
+ var skill = from.Skills[SkillName.Mining];
+ var recorder = new Recorder();
+
+ SkillEvents.SkillUsed += recorder.Handle;
+ try
+ {
+ SkillCheck.CheckSkill(from, skill, null, 1.0);
+ Assert.Equal(0, recorder.Calls);
+ }
+ finally
+ {
+ SkillEvents.SkillUsed -= recorder.Handle;
+ from.Delete();
+ }
+ }
+
+ [Fact]
+ public void NoSubscriber_DoesNotThrow()
+ {
+ var from = new Mobile();
+ var recorder = new Recorder();
+
+ try
+ {
+ SkillEvents.SkillUsed += recorder.Handle;
+ SkillEvents.SkillUsed -= recorder.Handle;
+
+ Assert.True(SkillCheck.Mobile_SkillCheckDirectLocation(from, SkillName.Mining, 1.0));
+ Assert.Equal(0, recorder.Calls);
+ }
+ finally
+ {
+ from.Delete();
+ }
+ }
+}
diff --git a/Projects/UOContent/Skills/SkillCheck.cs b/Projects/UOContent/Skills/SkillCheck.cs
index 5913b0a7f..59f8ebb6a 100644
--- a/Projects/UOContent/Skills/SkillCheck.cs
+++ b/Projects/UOContent/Skills/SkillCheck.cs
@@ -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
diff --git a/Projects/UOContent/Skills/SkillEvents.cs b/Projects/UOContent/Skills/SkillEvents.cs
new file mode 100644
index 000000000..dec624c45
--- /dev/null
+++ b/Projects/UOContent/Skills/SkillEvents.cs
@@ -0,0 +1,23 @@
+using System;
+using System.Runtime.CompilerServices;
+
+namespace Server.Misc;
+
+///
+/// Skill system events. Plain C# events so other assemblies can subscribe; generated events cannot be
+/// subscribed across assemblies.
+///
+public static class SkillEvents
+{
+ ///
+ /// Raised once per skill attempt from the four Mobile_SkillCheck* handlers with the attempt's
+ /// outcome, including attempts the handler resolves without a roll (too difficult, no challenge).
+ /// Not raised when the mobile lacks the skill. Fires for every , including
+ /// creatures. Subscribers must not block or allocate.
+ ///
+ public static event Action SkillUsed;
+
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ public static void InvokeSkillUsed(Mobile from, Skill skill, bool success) =>
+ SkillUsed?.Invoke(from, skill, success);
+}
diff --git a/dev-docs/events.md b/dev-docs/events.md
index 2fcbeda68..589860db6 100644
--- a/dev-docs/events.md
+++ b/dev-docs/events.md
@@ -242,6 +242,17 @@ public static void HandlePlayerLogin(PlayerMobile player)
---
+## Static Content Events
+
+Generated events dispatch statically inside `UOContent`; content that other assemblies must observe exposes a
+plain `static event` instead (shape: `Projects/UOContent/Engines/Help/HelpEvents.cs`).
+
+- `SkillEvents.SkillUsed` -- `Action`, raised once per skill attempt from the
+ four `Mobile_SkillCheck*` handlers with the attempt's outcome, including attempts resolved without a roll
+ (too difficult, no challenge). Not raised when the mobile lacks the skill. Fires for every `Mobile`.
+
+---
+
## Event Args Pooling Pattern
Some EventArgs use object pooling to avoid allocation in hot paths: