10 Commits
8 changed files with 142 additions and 142 deletions
+14 -24
View File
@@ -1,7 +1,6 @@
using System; using System;
using HarmonyLib; using HarmonyLib;
using Vintagestory.API.Common; using Vintagestory.API.Common;
using Vintagestory.API.Server;
using Vintagestory.Common; using Vintagestory.Common;
namespace CommandHook; namespace CommandHook;
@@ -24,11 +23,12 @@ public static class ChatCommandApiPatch
/// typing in chat, since both go through <c>Execute</c> the same way /// typing in chat, since both go through <c>Execute</c> the same way
/// internally. /// internally.
/// <para> /// <para>
/// If a Before listener sets <see cref="CommandData.Cancel"/>, this returns /// If a Before listener returns a non-null <see cref="TextCommandResult"/>,
/// false to stop the original <c>Execute</c> from running at all, and /// this returns false to stop the original <c>Execute</c> from running at
/// invokes <paramref name="onCommandComplete"/> directly with a /// all, and invokes <paramref name="onCommandComplete"/> directly with that
/// <see cref="EnumCommandStatus.Deferred"/> result so the caller doesn't see /// result so the caller sees whatever the listener intended (a real error
/// a generic failure. After listeners are not fired in this case. /// message, a silent <see cref="TextCommandResult.Deferred"/>, etc). After
/// listeners are not fired in this case.
/// </para> /// </para>
/// <para> /// <para>
/// If nothing cancels, this returns true and lets the original <c>Execute</c> /// If nothing cancels, this returns true and lets the original <c>Execute</c>
@@ -39,7 +39,10 @@ public static class ChatCommandApiPatch
/// </para> /// </para>
/// </remarks> /// </remarks>
/// <param name="commandName">The command name being executed, without the leading slash.</param> /// <param name="commandName">The command name being executed, without the leading slash.</param>
/// <param name="args">The calling args supplied by the game, used here to resolve the sender.</param> /// <param name="args">
/// The calling args supplied by the game. Passed straight through to listeners,
/// live and unmodified, the engine's own object for this invocation.
/// </param>
/// <param name="onCommandComplete"> /// <param name="onCommandComplete">
/// The completion callback supplied by the game. Replaced with a wrapper that /// The completion callback supplied by the game. Replaced with a wrapper that
/// fires After listeners after invoking the original. /// fires After listeners after invoking the original.
@@ -66,19 +69,10 @@ public static class ChatCommandApiPatch
if (system == null) if (system == null)
return true; return true;
var sender = args.Caller.Player as IServerPlayer; var cancelResult = system.FireBefore(commandName, args);
// Console invocations have no IServerPlayer, the cast just yields null, if (cancelResult != null)
// which is exactly what CommandData(sender, ...) expects to mean "console".
var data = new CommandData(sender, commandName, "/" + commandName);
if (system.FireBefore(commandName, ref data))
{ {
// Cancelled by a Before listener. Report Deferred rather than just onCommandComplete?.Invoke(cancelResult);
// swallowing it silently, so the original caller (console or player)
// sees something other than the command quietly doing nothing.
onCommandComplete?.Invoke(
new TextCommandResult { Status = EnumCommandStatus.Deferred }
);
return false; return false;
} }
@@ -89,11 +83,7 @@ public static class ChatCommandApiPatch
onCommandComplete = result => onCommandComplete = result =>
{ {
original?.Invoke(result); original?.Invoke(result);
// data is captured by the closure, copy it so FireAfter gets its system.FireAfter(commandName, args, result);
// own ref target instead of sharing one with whatever else might
// still be holding the original local.
var afterData = data;
system.FireAfter(commandName, ref afterData, result);
}; };
return true; return true;
-60
View File
@@ -1,60 +0,0 @@
using Vintagestory.API.Server;
namespace CommandHook;
/// <summary>
/// The data passed to Before/After listeners for a single command invocation.
/// Passed by ref through the hot path, no allocation per command.
/// </summary>
public struct CommandData
{
/// <summary>
/// The player who ran the command, or null if it came from the server console.
/// </summary>
public readonly IServerPlayer? Sender;
/// <summary>
/// The full command text as typed, including the leading slash.
/// </summary>
public readonly string FullCommand;
/// <summary>
/// The command name only, without the leading slash (matches what listeners
/// register in <see cref="ICommandHookListener.Commands"/>).
/// </summary>
public readonly string CommandName;
private byte flags;
private const byte FlagIsPlayerCommand = 1 << 0;
private const byte FlagCancel = 1 << 1;
/// <summary>
/// True if a player ran this command, false if it came from the server console.
/// Equivalent to <c>Sender != null</c>.
/// </summary>
public bool IsPlayerCommand => (flags & FlagIsPlayerCommand) != 0;
/// <summary>
/// Set this to true in a Before listener to stop the command from executing.
/// Stops any remaining Before listeners from running too, and skips After entirely.
/// </summary>
public bool Cancel
{
get => (flags & FlagCancel) != 0;
set => flags = value ? (byte)(flags | FlagCancel) : (byte)(flags & ~FlagCancel);
}
/// <summary>
/// Creates the command data for a single invocation.
/// </summary>
/// <param name="sender">The player who ran the command, or null for console.</param>
/// <param name="commandName">The command name without the leading slash.</param>
/// <param name="fullCommand">The full command text as typed, including the leading slash.</param>
public CommandData(IServerPlayer? sender, string commandName, string fullCommand)
{
Sender = sender;
CommandName = commandName;
FullCommand = fullCommand;
flags = sender != null ? FlagIsPlayerCommand : (byte)0;
}
}
+20 -7
View File
@@ -3,15 +3,28 @@ using Vintagestory.API.Common;
namespace CommandHook; namespace CommandHook;
/// <summary> /// <summary>
/// Invoked before a watched command runs. Set <c>data.Cancel = true</c> to stop /// Invoked before a watched command runs. Return a non-null
/// the command from executing. /// <see cref="TextCommandResult"/> to cancel the command and report that
/// result to the caller (e.g. <see cref="TextCommandResult.Error(string, string)"/>
/// for a visible reason, or <see cref="TextCommandResult.Deferred"/> to cancel
/// silently). Return null to let the command run normally.
/// </summary> /// </summary>
/// <param name="data">The command data, passed by ref so you can read or cancel it.</param> /// <param name="callingArgs">
public delegate void CommandBeforeDelegate(ref CommandData data); /// The live calling args for this invocation, supplied directly by the engine.
/// </param>
public delegate TextCommandResult? CommandBeforeDelegate(TextCommandCallingArgs callingArgs);
/// <summary> /// <summary>
/// Invoked after a watched command has run. Not invoked if a Before listener cancelled it. /// Invoked after a watched command has run. Not invoked if a Before listener
/// (for this command, any mod) cancelled it.
/// </summary> /// </summary>
/// <param name="data">The command data, passed by ref for consistency with <see cref="CommandBeforeDelegate"/>.</param> /// <param name="callingArgs">
/// The same live calling args object passed to Before. By now the real
/// command handler has had full access to it, and what's safe to read
/// depends on how that command is implemented internally.
/// </param>
/// <param name="result">The result the command produced.</param> /// <param name="result">The result the command produced.</param>
public delegate void CommandAfterDelegate(ref CommandData data, TextCommandResult result); public delegate void CommandAfterDelegate(
TextCommandCallingArgs callingArgs,
TextCommandResult result
);
+33 -21
View File
@@ -54,6 +54,7 @@ public class CommandHookModSystem : ModSystem
/// <summary> /// <summary>
/// Registers a listener for the commands it returns from <see cref="ICommandHookListener.Commands"/>. /// Registers a listener for the commands it returns from <see cref="ICommandHookListener.Commands"/>.
/// No-ops silently if CommandHook hasn't finished server-side startup yet.
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// Calling this again for a <see cref="ICommandHookListener.ModId"/> that is already /// Calling this again for a <see cref="ICommandHookListener.ModId"/> that is already
@@ -63,8 +64,11 @@ public class CommandHookModSystem : ModSystem
/// list, this just calls <see cref="Unregister"/> instead. /// list, this just calls <see cref="Unregister"/> instead.
/// </remarks> /// </remarks>
/// <param name="listener">The listener to register or update.</param> /// <param name="listener">The listener to register or update.</param>
public void Register(ICommandHookListener listener) public static void Register(ICommandHookListener listener)
{ {
if (Instance == null)
return;
var commands = listener.Commands; var commands = listener.Commands;
if (commands == null || commands.Count == 0) if (commands == null || commands.Count == 0)
@@ -73,6 +77,7 @@ public class CommandHookModSystem : ModSystem
return; return;
} }
var listeners = Instance.listeners;
int index = listeners.FindIndex(l => l.ModId == listener.ModId); int index = listeners.FindIndex(l => l.ModId == listener.ModId);
bool changed = false; bool changed = false;
@@ -88,7 +93,7 @@ public class CommandHookModSystem : ModSystem
} }
if (changed) if (changed)
Rebuild(); Instance.Rebuild();
} }
/// <summary> /// <summary>
@@ -96,15 +101,19 @@ public class CommandHookModSystem : ModSystem
/// that was never registered, this is a no-op in that case. /// that was never registered, this is a no-op in that case.
/// </summary> /// </summary>
/// <param name="listener">The listener to remove, matched by <see cref="ICommandHookListener.ModId"/>.</param> /// <param name="listener">The listener to remove, matched by <see cref="ICommandHookListener.ModId"/>.</param>
public void Unregister(ICommandHookListener listener) public static void Unregister(ICommandHookListener listener)
{ {
if (Instance == null)
return;
var listeners = Instance.listeners;
int index = listeners.FindIndex(l => l.ModId == listener.ModId); int index = listeners.FindIndex(l => l.ModId == listener.ModId);
if (index < 0) if (index < 0)
return; return;
listeners.RemoveAt(index); listeners.RemoveAt(index);
Rebuild(); Instance.Rebuild();
} }
// Rebuilds the dispatch table from listeners. Runs unconditionally and // Rebuilds the dispatch table from listeners. Runs unconditionally and
@@ -147,41 +156,44 @@ public class CommandHookModSystem : ModSystem
} }
// Called from ChatCommandApiPatch.Prefix before the real command executes. // Called from ChatCommandApiPatch.Prefix before the real command executes.
// CommandData is passed by ref the whole way down, no allocation here. // callingArgs is the engine's own live object, passed straight through,
// Each listener's Before is isolated in its own try/catch so one mod // no allocation here. Each listener's Before is isolated in its own
// throwing doesn't stop the rest from running or break command dispatch // try/catch so one mod throwing doesn't stop the rest from running or
// for the server. If a listener sets data.Cancel, we stop walking the // break command dispatch for the server. The first listener to return a
// rest of the listeners immediately rather than letting them all run // non-null result wins, we stop walking immediately rather than letting
// against an already-cancelled command. // later listeners run against an already-cancelled command.
internal bool FireBefore(string commandName, ref CommandData data) internal TextCommandResult? FireBefore(string commandName, TextCommandCallingArgs callingArgs)
{ {
if (registrations.TryGetValue(commandName, out var mods)) if (registrations.TryGetValue(commandName, out var mods))
{ {
foreach (var (modId, reg) in mods) foreach (var (modId, reg) in mods)
{ {
TextCommandResult? result = null;
try try
{ {
reg.Before?.Invoke(ref data); result = reg.Before?.Invoke(callingArgs);
} }
catch (Exception ex) catch (Exception ex)
{ {
Mod.Logger.Error("[{0}] Before /{1} threw: {2}", modId, commandName, ex); Mod.Logger.Error("[{0}] Before /{1} threw: {2}", modId, commandName, ex);
} }
if (data.Cancel) if (result != null)
break; return result;
} }
} }
return data.Cancel; return null;
} }
// Called from the wrapped onCommandComplete in ChatCommandApiPatch.Prefix, // Called from the wrapped onCommandComplete in ChatCommandApiPatch.Prefix,
// only on the path where the command actually ran (Before didn't cancel). // only on the path where the command actually ran (no Before listener
// Same per-listener try/catch as FireBefore, but there's no early-out here // cancelled it). Same per-listener try/catch as FireBefore.
// since cancelling after the fact doesn't mean anything, the command internal void FireAfter(
// already ran. string commandName,
internal void FireAfter(string commandName, ref CommandData data, TextCommandResult result) TextCommandCallingArgs callingArgs,
TextCommandResult result
)
{ {
if (registrations.TryGetValue(commandName, out var mods)) if (registrations.TryGetValue(commandName, out var mods))
{ {
@@ -189,7 +201,7 @@ public class CommandHookModSystem : ModSystem
{ {
try try
{ {
reg.After?.Invoke(ref data, result); reg.After?.Invoke(callingArgs, result);
} }
catch (Exception ex) catch (Exception ex)
{ {
+3 -3
View File
@@ -6,9 +6,9 @@ namespace CommandHook;
public struct CommandRegistration public struct CommandRegistration
{ {
/// <summary> /// <summary>
/// Called before the command runs. Set <see cref="CommandData.Cancel"/> on /// Called before the command runs. Return a non-null <see cref="TextCommandResult"/>
/// <paramref name="data"/> via the ref parameter inside your delegate to stop /// to cancel the command, report that result to the caller, and skip any
/// the command from executing and skip any remaining Before listeners. /// remaining Before listeners. Return null to let the command run.
/// </summary> /// </summary>
public readonly CommandBeforeDelegate? Before; public readonly CommandBeforeDelegate? Before;
+1 -3
View File
@@ -1,3 +1 @@
{ {}
"hello": "hello world!"
}
+1 -1
View File
@@ -6,7 +6,7 @@
"authors": [ "authors": [
"anth64" "anth64"
], ],
"version": "1.0.0", "version": "2.1.0",
"side": "Server", "side": "Server",
"dependencies": { "dependencies": {
"game": "1.22.3" "game": "1.22.3"
+70 -23
View File
@@ -4,7 +4,44 @@ Server-side mod for Vintage Story. Lets other mods hook into chat commands
before and after they run, namespaced per mod so multiple mods can watch before and after they run, namespaced per mod so multiple mods can watch
the same command without colliding. the same command without colliding.
## How it works ## Usage
Install it like any other mod. It has no effect unless something else
depends on it.
## For mod development
Set the `COMMANDHOOK` environment variable to the folder containing
`CommandHook.dll` (i.e. wherever you've built CommandHook), the same way
`VINTAGE_STORY` points at your game install.
**Linux/macOS** (add to `~/.bashrc`, `~/.zshrc`, etc.):
```bash
export COMMANDHOOK="/path/to/CommandHook/CommandHook/bin/Release"
```
**Windows (PowerShell):**
```powershell
[Environment]::SetEnvironmentVariable("COMMANDHOOK", "C:\path\to\CommandHook\CommandHook\bin\Release", "User")
```
Then add a `Reference` to your `.csproj` next to your other game references:
```xml
<Reference Include="CommandHook">
<HintPath>$(COMMANDHOOK)/CommandHook.dll</HintPath>
<Private>false</Private>
</Reference>
```
Add it as a dependency in your `modinfo.json`:
```json
"dependencies": {
"game": "1.22.3",
"commandhook": "2.1.0"
}
```
CommandHook patches `ChatCommandApi.Execute` and dispatches to whoever CommandHook patches `ChatCommandApi.Execute` and dispatches to whoever
has registered. Your mod doesn't patch anything itself, it just implements has registered. Your mod doesn't patch anything itself, it just implements
@@ -17,18 +54,26 @@ A listener provides:
- `Commands`, the command names you want to watch, without the leading slash - `Commands`, the command names you want to watch, without the leading slash
- `Registration`, a `Before` delegate, an `After` delegate, or both - `Registration`, a `Before` delegate, an `After` delegate, or both
`Before` runs before the command executes. Set `data.Cancel = true` inside it `Before` receives the engine's own `TextCommandCallingArgs` for the
to stop the command from running. If you cancel, any remaining `Before` invocation, live and unmodified. Return a non-null `TextCommandResult` to
listeners for that command are skipped, and `After` never fires for that cancel the command and report that result to the caller, use the engine's
invocation. own factories, e.g. `TextCommandResult.Error(...)` for a visible reason, or
`TextCommandResult.Deferred` to cancel silently (per its own doc comment,
this prints no output). Return `null` to let the command run normally. If
you cancel, any remaining `Before` listeners for that command are skipped,
and `After` never fires for that invocation.
`After` runs once the command has produced a result. It only fires on the `After` receives the same `TextCommandCallingArgs` object plus the
path where the command actually ran. `TextCommandResult` the command actually produced. It only fires on the
path where the command ran.
`CommandData` is passed by `ref` the whole way through, no allocation per ### A note on `TextCommandCallingArgs` at Before vs After
command.
## Example This is the engine's own live object, not a copy, what's safe to read
depends on timing and on how the specific command you're watching is
implemented internally.
### Example
```csharp ```csharp
public class MyListener : ICommandHookListener public class MyListener : ICommandHookListener
@@ -42,23 +87,26 @@ public class MyListener : ICommandHookListener
// Wire up Before, After, or both. // Wire up Before, After, or both.
public CommandRegistration Registration => new(Before, After); public CommandRegistration Registration => new(Before, After);
private void Before(ref CommandData data) private TextCommandResult? Before(TextCommandCallingArgs args)
{ {
// data.Sender is null for console invocations. // args.Caller.Player is null for console invocations.
if (data.Sender != null && !IsAllowed(data.Sender)) if (args.Caller.Player is IServerPlayer player && !IsAllowed(player))
{ {
// Stops the command from running and skips any remaining // Cancels the command, skips any remaining Before listeners,
// Before listeners. After never fires for this invocation. // and After never fires for this invocation. The caller sees
data.Cancel = true; // this exact result.
return TextCommandResult.Error("You're not allowed to do that", "notallowed");
} }
return null;
} }
private void After(ref CommandData data, TextCommandResult result) private void After(TextCommandCallingArgs args, TextCommandResult result)
{ {
// Only runs if nothing cancelled. result is whatever the command // Only runs if nothing cancelled. result is whatever the command
// actually produced, check result.Status for success/error/deferred. // actually produced, check result.Status for success/error/deferred.
if (result.Status != EnumCommandStatus.Success) if (result.Status != EnumCommandStatus.Success)
Logger.Warn($"/{data.CommandName} failed: {result.StatusMessage}"); Logger.Warn($"A watched command failed: {result.StatusMessage}");
} }
} }
``` ```
@@ -68,19 +116,18 @@ Register it once your mod starts, unregister it on dispose:
```csharp ```csharp
public override void StartServerSide(ICoreServerAPI api) public override void StartServerSide(ICoreServerAPI api)
{ {
// CommandHookModSystem.Instance is null if CommandHook isn't loaded, // Register your listener once, here. No-ops silently if CommandHook
// hence the ?. Register your listener once, here. // isn't loaded.
CommandHookModSystem.Instance?.Register(myListener); CommandHookModSystem.Register(myListener);
} }
public override void Dispose() public override void Dispose()
{ {
// Always unregister on dispose, otherwise a stale listener stays in // Always unregister on dispose, otherwise a stale listener stays in
// the dispatch table after your mod is gone. // the dispatch table after your mod is gone.
CommandHookModSystem.Instance?.Unregister(myListener); CommandHookModSystem.Unregister(myListener);
} }
``` ```
Calling `Register` again with the same `ModId` replaces your existing Calling `Register` again with the same `ModId` replaces your existing
registration. If your `Commands` list hasn't changed, it's a no-op. registration. If your `Commands` list hasn't changed, it's a no-op.