docs(CommandHookModSystem): add XML doc comments to Register/Unregister and internal comments to Rebuild, FireBefore, FireAfter, CommandListEquals

This commit is contained in:
2026-06-21 12:18:19 +02:00
parent 46dea83699
commit 398c9d01c1
+54
View File
@@ -7,6 +7,20 @@ using Vintagestory.API.Server;
namespace CommandHook;
/// <summary>
/// Server-side event bus for chat commands. Other mods register an
/// <see cref="ICommandHookListener"/> here to get Before/After callbacks on
/// specific command names, namespaced by <see cref="ICommandHookListener.ModId"/>
/// so multiple mods can watch the same command without stepping on each other.
/// </summary>
/// <remarks>
/// Patches <c>ChatCommandApi.Execute</c> via Harmony. Server only, see
/// <see cref="ShouldLoad"/>. A listener's <see cref="ICommandHookListener.Commands"/>
/// and <see cref="ICommandHookListener.Registration"/> are read at registration
/// time and cached in a <see cref="System.Collections.Frozen.FrozenDictionary{TKey,TValue}"/>
/// for fast lookup on the command dispatch path. If you change which commands a
/// listener watches, call <see cref="Register"/> again with the updated list.
/// </remarks>
public class CommandHookModSystem : ModSystem
{
internal static CommandHookModSystem? Instance;
@@ -38,6 +52,17 @@ public class CommandHookModSystem : ModSystem
Instance = null;
}
/// <summary>
/// Registers a listener for the commands it returns from <see cref="ICommandHookListener.Commands"/>.
/// </summary>
/// <remarks>
/// Calling this again for a <see cref="ICommandHookListener.ModId"/> that is already
/// registered replaces its previous registration. If the new <see cref="ICommandHookListener.Commands"/>
/// list is identical to the existing one this is a no-op, no rebuild happens.
/// If <paramref name="listener"/> has a null or empty <see cref="ICommandHookListener.Commands"/>
/// list, this just calls <see cref="Unregister"/> instead.
/// </remarks>
/// <param name="listener">The listener to register or update.</param>
public void Register(ICommandHookListener listener)
{
var commands = listener.Commands;
@@ -66,6 +91,11 @@ public class CommandHookModSystem : ModSystem
Rebuild();
}
/// <summary>
/// Removes a listener's registration entirely. Safe to call on a listener
/// that was never registered, this is a no-op in that case.
/// </summary>
/// <param name="listener">The listener to remove, matched by <see cref="ICommandHookListener.ModId"/>.</param>
public void Unregister(ICommandHookListener listener)
{
int index = listeners.FindIndex(l => l.ModId == listener.ModId);
@@ -77,6 +107,14 @@ public class CommandHookModSystem : ModSystem
Rebuild();
}
// Rebuilds the dispatch table from listeners. Runs unconditionally and
// immediately whenever Register/Unregister actually changes something,
// no batching or startup-phase awareness. Command list sizes here are
// small (a handful of mods, a handful of commands each), so the realloc
// on every change is cheap and not worth the complexity of an incremental
// update. The two-level structure (command -> ModId -> registration) is
// what FireBefore/FireAfter walk on the hot path, so it's built once here
// instead of being derived per-dispatch.
private void Rebuild()
{
var builder = new Dictionary<string, Dictionary<string, CommandRegistration>>();
@@ -108,6 +146,13 @@ public class CommandHookModSystem : ModSystem
registrations = pruned.ToFrozenDictionary();
}
// Called from ChatCommandApiPatch.Prefix before the real command executes.
// CommandData is passed by ref the whole way down, no allocation here.
// Each listener's Before is isolated in its own try/catch so one mod
// throwing doesn't stop the rest from running or break command dispatch
// for the server. If a listener sets data.Cancel, we stop walking the
// rest of the listeners immediately rather than letting them all run
// against an already-cancelled command.
internal bool FireBefore(string commandName, ref CommandData data)
{
if (registrations.TryGetValue(commandName, out var mods))
@@ -131,6 +176,11 @@ public class CommandHookModSystem : ModSystem
return data.Cancel;
}
// Called from the wrapped onCommandComplete in ChatCommandApiPatch.Prefix,
// only on the path where the command actually ran (Before didn't cancel).
// Same per-listener try/catch as FireBefore, but there's no early-out here
// since cancelling after the fact doesn't mean anything, the command
// already ran.
internal void FireAfter(string commandName, ref CommandData data, TextCommandResult result)
{
if (registrations.TryGetValue(commandName, out var mods))
@@ -149,6 +199,10 @@ public class CommandHookModSystem : ModSystem
}
}
// Order-sensitive on purpose. Register only treats an update as a no-op
// if the list is identical in order and content, a reordered list still
// triggers a rebuild. Cheap to check (just an index walk, no allocation)
// and correctness here matters more than being lenient about ordering.
private static bool CommandListEquals(IReadOnlyList<string> a, IReadOnlyList<string> b)
{
if (a.Count != b.Count)