Files
CommandHook/CommandHook/CommandHookModSystem.cs
T

221 lines
7.9 KiB
C#

using System;
using System.Collections.Frozen;
using System.Collections.Generic;
using HarmonyLib;
using Vintagestory.API.Common;
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;
private readonly List<ICommandHookListener> listeners = new();
private FrozenDictionary<string, FrozenDictionary<string, CommandRegistration>> registrations =
FrozenDictionary<string, FrozenDictionary<string, CommandRegistration>>.Empty;
private Harmony? harmony;
public override bool ShouldLoad(EnumAppSide forSide) => forSide == EnumAppSide.Server;
public override double ExecuteOrder() => 0.0;
public override void StartServerSide(ICoreServerAPI api)
{
Instance = this;
harmony = new Harmony(Mod.Info.ModID);
harmony.PatchAll();
Mod.Logger.Notification("Loaded");
}
public override void Dispose()
{
harmony?.UnpatchAll(Mod.Info.ModID);
listeners.Clear();
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;
if (commands == null || commands.Count == 0)
{
Unregister(listener);
return;
}
int index = listeners.FindIndex(l => l.ModId == listener.ModId);
bool changed = false;
if (index < 0)
{
listeners.Add(listener);
changed = true;
}
else if (!CommandListEquals(listeners[index].Commands, commands))
{
listeners[index] = listener;
changed = true;
}
if (changed)
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);
if (index < 0)
return;
listeners.RemoveAt(index);
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>>();
foreach (var listener in listeners)
{
var commands = listener.Commands;
if (commands == null || commands.Count == 0)
continue;
foreach (var cmd in commands)
{
if (!builder.TryGetValue(cmd, out var mods))
{
mods = new Dictionary<string, CommandRegistration>();
builder[cmd] = mods;
}
mods[listener.ModId] = listener.Registration;
}
}
var pruned = new Dictionary<string, FrozenDictionary<string, CommandRegistration>>(
builder.Count
);
foreach (var (cmd, mods) in builder)
if (mods.Count > 0)
pruned[cmd] = mods.ToFrozenDictionary();
registrations = pruned.ToFrozenDictionary();
}
// Called from ChatCommandApiPatch.Prefix before the real command executes.
// callingArgs is the engine's own live object, passed straight through,
// 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. The first listener to return a
// non-null result wins, we stop walking immediately rather than letting
// later listeners run against an already-cancelled command.
internal TextCommandResult? FireBefore(string commandName, TextCommandCallingArgs callingArgs)
{
if (registrations.TryGetValue(commandName, out var mods))
{
foreach (var (modId, reg) in mods)
{
TextCommandResult? result = null;
try
{
result = reg.Before?.Invoke(callingArgs);
}
catch (Exception ex)
{
Mod.Logger.Error("[{0}] Before /{1} threw: {2}", modId, commandName, ex);
}
if (result != null)
return result;
}
}
return null;
}
// Called from the wrapped onCommandComplete in ChatCommandApiPatch.Prefix,
// only on the path where the command actually ran (no Before listener
// cancelled it). Same per-listener try/catch as FireBefore.
internal void FireAfter(
string commandName,
TextCommandCallingArgs callingArgs,
TextCommandResult result
)
{
if (registrations.TryGetValue(commandName, out var mods))
{
foreach (var (modId, reg) in mods)
{
try
{
reg.After?.Invoke(callingArgs, result);
}
catch (Exception ex)
{
Mod.Logger.Error("[{0}] After /{1} threw: {2}", modId, commandName, ex);
}
}
}
}
// 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)
return false;
for (int i = 0; i < a.Count; i++)
if (a[i] != b[i])
return false;
return true;
}
}