9 Commits
8 changed files with 127 additions and 140 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"
+55 -21
View File
@@ -11,12 +11,35 @@ depends on it.
## For mod development ## 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`: Add it as a dependency in your `modinfo.json`:
```json ```json
"dependencies": { "dependencies": {
"game": "1.22.3", "game": "1.22.3",
"commandhook": "1.0.0" "commandhook": "2.1.0"
} }
``` ```
@@ -31,16 +54,24 @@ 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.
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 ### Example
@@ -56,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}");
} }
} }
``` ```
@@ -82,16 +116,16 @@ 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);
} }
``` ```