refactor(commandhook)!: pass TextCommandCallingArgs directly, remove CommandData wrapper.

This commit is contained in:
2026-06-25 20:10:13 +02:00
parent de74d71f0f
commit d93e56c1d3
4 changed files with 54 additions and 108 deletions
+14 -24
View File
@@ -1,7 +1,6 @@
using System;
using HarmonyLib;
using Vintagestory.API.Common;
using Vintagestory.API.Server;
using Vintagestory.Common;
namespace CommandHook;
@@ -24,11 +23,12 @@ public static class ChatCommandApiPatch
/// typing in chat, since both go through <c>Execute</c> the same way
/// internally.
/// <para>
/// If a Before listener sets <see cref="CommandData.Cancel"/>, this returns
/// false to stop the original <c>Execute</c> from running at all, and
/// invokes <paramref name="onCommandComplete"/> directly with a
/// <see cref="EnumCommandStatus.Deferred"/> result so the caller doesn't see
/// a generic failure. After listeners are not fired in this case.
/// If a Before listener returns a non-null <see cref="TextCommandResult"/>,
/// this returns false to stop the original <c>Execute</c> from running at
/// all, and invokes <paramref name="onCommandComplete"/> directly with that
/// result so the caller sees whatever the listener intended (a real error
/// message, a silent <see cref="TextCommandResult.Deferred"/>, etc). After
/// listeners are not fired in this case.
/// </para>
/// <para>
/// If nothing cancels, this returns true and lets the original <c>Execute</c>
@@ -39,7 +39,10 @@ public static class ChatCommandApiPatch
/// </para>
/// </remarks>
/// <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">
/// The completion callback supplied by the game. Replaced with a wrapper that
/// fires After listeners after invoking the original.
@@ -66,19 +69,10 @@ public static class ChatCommandApiPatch
if (system == null)
return true;
var sender = args.Caller.Player as IServerPlayer;
// Console invocations have no IServerPlayer, the cast just yields null,
// which is exactly what CommandData(sender, ...) expects to mean "console".
var data = new CommandData(sender, commandName, "/" + commandName);
if (system.FireBefore(commandName, ref data))
var cancelResult = system.FireBefore(commandName, args);
if (cancelResult != null)
{
// Cancelled by a Before listener. Report Deferred rather than just
// 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 }
);
onCommandComplete?.Invoke(cancelResult);
return false;
}
@@ -89,11 +83,7 @@ public static class ChatCommandApiPatch
onCommandComplete = result =>
{
original?.Invoke(result);
// data is captured by the closure, copy it so FireAfter gets its
// 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);
system.FireAfter(commandName, args, result);
};
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;
/// <summary>
/// Invoked before a watched command runs. Set <c>data.Cancel = true</c> to stop
/// the command from executing.
/// Invoked before a watched command runs. Return a non-null
/// <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>
/// <param name="data">The command data, passed by ref so you can read or cancel it.</param>
public delegate void CommandBeforeDelegate(ref CommandData data);
/// <param name="callingArgs">
/// The live calling args for this invocation, supplied directly by the engine.
/// </param>
public delegate TextCommandResult? CommandBeforeDelegate(TextCommandCallingArgs callingArgs);
/// <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>
/// <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>
public delegate void CommandAfterDelegate(ref CommandData data, TextCommandResult result);
public delegate void CommandAfterDelegate(
TextCommandCallingArgs callingArgs,
TextCommandResult result
);
+20 -17
View File
@@ -147,41 +147,44 @@ public class CommandHookModSystem : ModSystem
}
// 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)
// 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
{
reg.Before?.Invoke(ref data);
result = reg.Before?.Invoke(callingArgs);
}
catch (Exception ex)
{
Mod.Logger.Error("[{0}] Before /{1} threw: {2}", modId, commandName, ex);
}
if (data.Cancel)
break;
if (result != null)
return result;
}
}
return data.Cancel;
return null;
}
// 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)
// 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))
{
@@ -189,7 +192,7 @@ public class CommandHookModSystem : ModSystem
{
try
{
reg.After?.Invoke(ref data, result);
reg.After?.Invoke(callingArgs, result);
}
catch (Exception ex)
{