From 243df653be64ccb2c56e9c4e5232cae9146d0584 Mon Sep 17 00:00:00 2001
From: Ross <2086824-trashp@users.noreply.gitlab.com>
Date: Fri, 28 Nov 2025 13:19:05 +0100
Subject: [PATCH 1/4] also forward on Unknown Argument error
---
.../velocitypowered/proxy/command/VelocityCommandManager.java | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/proxy/src/main/java/com/velocitypowered/proxy/command/VelocityCommandManager.java b/proxy/src/main/java/com/velocitypowered/proxy/command/VelocityCommandManager.java
index 49dceb319d..716d1e13cc 100644
--- a/proxy/src/main/java/com/velocitypowered/proxy/command/VelocityCommandManager.java
+++ b/proxy/src/main/java/com/velocitypowered/proxy/command/VelocityCommandManager.java
@@ -238,8 +238,8 @@ private boolean executeImmediately0(final CommandSource source, final ParseResul
result = executed ? CommandResult.EXECUTED : CommandResult.FORWARDED;
return executed;
} catch (final CommandSyntaxException e) {
- boolean isSyntaxError = !e.getType().equals(
- CommandSyntaxException.BUILT_IN_EXCEPTIONS.dispatcherUnknownCommand());
+ boolean isSyntaxError = !e.getType().equals(CommandSyntaxException.BUILT_IN_EXCEPTIONS.dispatcherUnknownCommand())
+ && !e.getType().equals(CommandSyntaxException.BUILT_IN_EXCEPTIONS.dispatcherUnknownArgument());
if (isSyntaxError) {
final Message message = e.getRawMessage();
if (message instanceof ComponentLike componentLike) {
From 7068d0a76c8021bf0d7316b9e7142845a1f81208 Mon Sep 17 00:00:00 2001
From: Ross <2086824-trashp@users.noreply.gitlab.com>
Date: Fri, 28 Nov 2025 15:32:23 +0100
Subject: [PATCH 2/4] make forwarding behaviour of commands configurable
through CommandMeta
---
.../api/command/CommandMeta.java | 19 ++++
.../proxy/command/VelocityCommandManager.java | 39 ++++++---
.../proxy/command/VelocityCommandMeta.java | 20 ++++-
.../proxy/command/BrigadierCommandTests.java | 87 +++++++++++++++++++
4 files changed, 152 insertions(+), 13 deletions(-)
diff --git a/api/src/main/java/com/velocitypowered/api/command/CommandMeta.java b/api/src/main/java/com/velocitypowered/api/command/CommandMeta.java
index b2612c4b68..693f0f8aee 100644
--- a/api/src/main/java/com/velocitypowered/api/command/CommandMeta.java
+++ b/api/src/main/java/com/velocitypowered/api/command/CommandMeta.java
@@ -41,6 +41,13 @@ public interface CommandMeta {
*/
@Nullable Object getPlugin();
+ /**
+ * Returns whether partial invocations of this command are forwarded to the backend.
+ *
+ * @return whether to forward partial invocations
+ */
+ boolean forwardPartial();
+
/**
* Provides a fluent interface to create {@link CommandMeta}s.
*/
@@ -73,6 +80,18 @@ interface Builder {
*/
Builder plugin(Object plugin);
+ /**
+ * Specifies whether partial matches to this command are forwarded to the backend.
+ *
+ *
For example with the registered command "rootcommand -> subcommand" where only the subcommand is executable, this
+ * specifies whether invocations such as "/rootcommand" or "/rootcommand nonexistant" should be forwarded to the
+ * backend, or be handled on the proxy.
+ *
+ * @param fowardPartial whether to forward partial matches
+ * @return this builder, for chaining
+ */
+ Builder forwardPartial(boolean fowardPartial);
+
/**
* Returns a newly-created {@link CommandMeta} based on the specified parameters.
*
diff --git a/proxy/src/main/java/com/velocitypowered/proxy/command/VelocityCommandManager.java b/proxy/src/main/java/com/velocitypowered/proxy/command/VelocityCommandManager.java
index 716d1e13cc..480aeb3c9e 100644
--- a/proxy/src/main/java/com/velocitypowered/proxy/command/VelocityCommandManager.java
+++ b/proxy/src/main/java/com/velocitypowered/proxy/command/VelocityCommandManager.java
@@ -239,21 +239,29 @@ private boolean executeImmediately0(final CommandSource source, final ParseResul
return executed;
} catch (final CommandSyntaxException e) {
boolean isSyntaxError = !e.getType().equals(CommandSyntaxException.BUILT_IN_EXCEPTIONS.dispatcherUnknownCommand())
- && !e.getType().equals(CommandSyntaxException.BUILT_IN_EXCEPTIONS.dispatcherUnknownArgument());
+ && !e.getType().equals(CommandSyntaxException.BUILT_IN_EXCEPTIONS.dispatcherUnknownArgument());
+
if (isSyntaxError) {
- final Message message = e.getRawMessage();
- if (message instanceof ComponentLike componentLike) {
- source.sendMessage(componentLike.asComponent().applyFallbackStyle(NamedTextColor.RED));
- } else {
- source.sendMessage(Component.text(e.getMessage(), NamedTextColor.RED));
- }
- result = com.velocitypowered.api.command.CommandResult.SYNTAX_ERROR;
+ sendError(source, e);
+ result = CommandResult.SYNTAX_ERROR;
// This is, of course, a lie, but the API will need to change...
return true;
- } else {
- result = CommandResult.FORWARDED;
- return false;
}
+
+ boolean existsOnProxy = !parsed.getContext().getNodes().isEmpty();
+ if (existsOnProxy) {
+ String invokedAlias = parsed.getContext().getNodes().get(0).getRange().get(parsed.getReader());
+ CommandMeta meta = this.commandMetas.get(invokedAlias);
+ if (meta != null && !meta.forwardPartial()) {
+ // mark command handled and send error to source if command meta specifies partial matches should not be forwarded
+ sendError(source, e);
+ result = CommandResult.SYNTAX_ERROR;
+ return true;
+ }
+ }
+ // Command does not exist or was not handled on the proxy, let the backend server handle it
+ result = CommandResult.FORWARDED;
+ return false;
} catch (final Throwable e) {
// Ugly, ugly swallowing of everything Throwable, because plugins are naughty.
throw new RuntimeException("Unable to invoke command " + parsed.getReader().getString() + " for " + source, e);
@@ -262,6 +270,15 @@ private boolean executeImmediately0(final CommandSource source, final ParseResul
}
}
+ private void sendError(CommandSource source, CommandSyntaxException e) {
+ final Message message = e.getRawMessage();
+ if (message instanceof ComponentLike componentLike) {
+ source.sendMessage(componentLike.asComponent().applyFallbackStyle(NamedTextColor.RED));
+ } else {
+ source.sendMessage(Component.text(e.getMessage(), NamedTextColor.RED));
+ }
+ }
+
@Override
public CompletableFuture executeAsync(final CommandSource source, final String cmdLine) {
Preconditions.checkNotNull(source, "source");
diff --git a/proxy/src/main/java/com/velocitypowered/proxy/command/VelocityCommandMeta.java b/proxy/src/main/java/com/velocitypowered/proxy/command/VelocityCommandMeta.java
index 39b2439999..4d5aac3ba1 100644
--- a/proxy/src/main/java/com/velocitypowered/proxy/command/VelocityCommandMeta.java
+++ b/proxy/src/main/java/com/velocitypowered/proxy/command/VelocityCommandMeta.java
@@ -43,12 +43,14 @@ static final class Builder implements CommandMeta.Builder {
private final ImmutableSet.Builder aliases;
private final ImmutableList.Builder> hints;
private @MonotonicNonNull Object plugin;
+ private boolean forwardPartial;
public Builder(final String alias) {
Preconditions.checkNotNull(alias, "alias");
this.aliases = ImmutableSet.builder()
.add(alias.toLowerCase(Locale.ENGLISH));
this.hints = ImmutableList.builder();
+ this.forwardPartial = false;
this.plugin = null;
}
@@ -83,9 +85,15 @@ public CommandMeta.Builder plugin(Object plugin) {
return this;
}
+ @Override
+ public CommandMeta.Builder forwardPartial(boolean fowardPartial) {
+ this.forwardPartial = fowardPartial;
+ return this;
+ }
+
@Override
public CommandMeta build() {
- return new VelocityCommandMeta(this.aliases.build(), this.hints.build(), this.plugin);
+ return new VelocityCommandMeta(this.aliases.build(), this.hints.build(), this.plugin, this.forwardPartial);
}
}
@@ -126,15 +134,18 @@ public static Stream> copyHints(final CommandMeta met
private final Set aliases;
private final List> hints;
private final Object plugin;
+ private final boolean forwardPartial;
private VelocityCommandMeta(
final Set aliases,
final List> hints,
- final @Nullable Object plugin
+ final @Nullable Object plugin,
+ final boolean forwardPartial
) {
this.aliases = aliases;
this.hints = hints;
this.plugin = plugin;
+ this.forwardPartial = forwardPartial;
}
@Override
@@ -152,6 +163,11 @@ public Collection> getHints() {
return plugin;
}
+ @Override
+ public boolean forwardPartial() {
+ return forwardPartial;
+ }
+
@Override
public boolean equals(final Object o) {
if (this == o) {
diff --git a/proxy/src/test/java/com/velocitypowered/proxy/command/BrigadierCommandTests.java b/proxy/src/test/java/com/velocitypowered/proxy/command/BrigadierCommandTests.java
index abbfbc422f..f05e47d9c7 100644
--- a/proxy/src/test/java/com/velocitypowered/proxy/command/BrigadierCommandTests.java
+++ b/proxy/src/test/java/com/velocitypowered/proxy/command/BrigadierCommandTests.java
@@ -26,6 +26,7 @@
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.fail;
+import com.mojang.brigadier.arguments.IntegerArgumentType;
import com.mojang.brigadier.arguments.StringArgumentType;
import com.mojang.brigadier.builder.LiteralArgumentBuilder;
import com.mojang.brigadier.builder.RequiredArgumentBuilder;
@@ -233,6 +234,92 @@ void testExecuteAsyncCompletesExceptionallyOnRequirementException() {
assertSame(expected, wrapper.getCause());
}
+ @Test
+ void testForwardPartialMatch() {
+ final var node = LiteralArgumentBuilder
+ .literal("hello")
+ .executes(context -> fail())
+ .then(LiteralArgumentBuilder
+ .literal("world")
+ .executes(context -> fail())
+ )
+ .build();
+
+ var meta = manager.metaBuilder(new BrigadierCommand(node)).forwardPartial(true).build();
+ manager.register(meta, new BrigadierCommand(node));
+
+ assertForwarded("hello badargument");
+ }
+
+ @Test
+ void testForwardMissingRequirement() {
+ final var node = LiteralArgumentBuilder
+ .literal("hello")
+ .executes(context -> fail())
+ .then(LiteralArgumentBuilder
+ .literal("world")
+ .executes(context -> fail())
+ .requires(src -> true)
+ )
+ .build();
+
+ var meta = manager.metaBuilder(new BrigadierCommand(node)).forwardPartial(true).build();
+ manager.register(meta, new BrigadierCommand(node));
+
+ assertForwarded("hello world");
+ }
+
+ @Test
+ void testDontForwardHasRequirement() {
+ final var callCount = new AtomicInteger();
+
+ final var node = LiteralArgumentBuilder
+ .literal("hello")
+ .executes(context -> fail())
+ .then(LiteralArgumentBuilder
+ .literal("world")
+ .executes(context -> {
+ callCount.incrementAndGet();
+ return 1;
+ })
+ .requires(src -> true)
+ )
+ .build();
+
+ var meta = manager.metaBuilder(new BrigadierCommand(node)).forwardPartial(true).build();
+ manager.register(meta, new BrigadierCommand(node));
+
+ assertHandled("hello world");
+ assertEquals(1, callCount.get());
+ }
+
+ @Test
+ void testDontForwardArgumentParseException() {
+ final var callCount = new AtomicInteger();
+
+ final var node = LiteralArgumentBuilder
+ .literal("number")
+ .executes(context -> fail())
+ .then(RequiredArgumentBuilder
+ .argument("numberInRange", IntegerArgumentType.integer(1, 5))
+ .executes(context -> {
+ callCount.incrementAndGet();
+ return 1;
+ })
+ .requires(src -> true)
+ )
+ .build();
+
+ var meta = manager.metaBuilder(new BrigadierCommand(node)).forwardPartial(true).build();
+ manager.register(meta, new BrigadierCommand(node));
+
+ assertHandled("number 1");
+ assertHandled("number 235");
+ assertHandled("number -5");
+ assertHandled("number word"); // Is it desirable for this to be forwarded?
+ assertEquals(1, callCount.get());
+ }
+
// Suggestions
@Test
From dd654301c4805e1976343bcda6e47d5c0704b79c Mon Sep 17 00:00:00 2001
From: Ross <2086824-trashp@users.noreply.gitlab.com>
Date: Fri, 28 Nov 2025 16:05:12 +0100
Subject: [PATCH 3/4] fix test
---
.../velocitypowered/proxy/command/BrigadierCommandTests.java | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/proxy/src/test/java/com/velocitypowered/proxy/command/BrigadierCommandTests.java b/proxy/src/test/java/com/velocitypowered/proxy/command/BrigadierCommandTests.java
index f05e47d9c7..28dacbf2ba 100644
--- a/proxy/src/test/java/com/velocitypowered/proxy/command/BrigadierCommandTests.java
+++ b/proxy/src/test/java/com/velocitypowered/proxy/command/BrigadierCommandTests.java
@@ -259,7 +259,7 @@ void testForwardMissingRequirement() {
.then(LiteralArgumentBuilder
.literal("world")
.executes(context -> fail())
- .requires(src -> true)
+ .requires(src -> false)
)
.build();
From c452b60b3d7949c90f321b7cc71def66fec76433 Mon Sep 17 00:00:00 2001
From: Ross <2086824-trashp@users.noreply.gitlab.com>
Date: Fri, 28 Nov 2025 17:34:07 +0100
Subject: [PATCH 4/4] attempt to align formatting with vanilla
---
.../proxy/command/VelocityCommandManager.java | 28 +++++++++++++++++--
1 file changed, 26 insertions(+), 2 deletions(-)
diff --git a/proxy/src/main/java/com/velocitypowered/proxy/command/VelocityCommandManager.java b/proxy/src/main/java/com/velocitypowered/proxy/command/VelocityCommandManager.java
index 480aeb3c9e..0cebd233cd 100644
--- a/proxy/src/main/java/com/velocitypowered/proxy/command/VelocityCommandManager.java
+++ b/proxy/src/main/java/com/velocitypowered/proxy/command/VelocityCommandManager.java
@@ -59,7 +59,10 @@
import java.util.stream.Collectors;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.ComponentLike;
+import net.kyori.adventure.text.TextComponent;
+import net.kyori.adventure.text.event.ClickEvent;
import net.kyori.adventure.text.format.NamedTextColor;
+import net.kyori.adventure.text.format.TextDecoration;
import org.checkerframework.checker.lock.qual.GuardedBy;
import org.checkerframework.checker.nullness.qual.Nullable;
import org.jetbrains.annotations.VisibleForTesting;
@@ -271,12 +274,33 @@ private boolean executeImmediately0(final CommandSource source, final ParseResul
}
private void sendError(CommandSource source, CommandSyntaxException e) {
+ TextComponent.Builder error = Component.text();
final Message message = e.getRawMessage();
if (message instanceof ComponentLike componentLike) {
- source.sendMessage(componentLike.asComponent().applyFallbackStyle(NamedTextColor.RED));
+ error.append(componentLike.asComponent().applyFallbackStyle(NamedTextColor.RED));
} else {
- source.sendMessage(Component.text(e.getMessage(), NamedTextColor.RED));
+ error.append(Component.text(message.toString(), NamedTextColor.RED));
}
+
+ if (e.getInput() != null && e.getCursor() > 0) {
+ int min = Math.min(e.getInput().length(), e.getCursor());
+ TextComponent.Builder details = Component.text()
+ .color(NamedTextColor.GRAY)
+ .clickEvent(ClickEvent.suggestCommand("/" + e.getInput()));
+
+ if (min > 10) {
+ details.append(Component.text("..."));
+ }
+ details.append(Component.text(e.getInput().substring(Math.max(0, min - 10), min)));
+
+ if (e.getInput().length() > min) {
+ details.append(Component.text(e.getInput().substring(min), NamedTextColor.RED, TextDecoration.UNDERLINED));
+ }
+ details.append(Component.translatable("command.context.here", NamedTextColor.RED, TextDecoration.ITALIC));
+ error.append(Component.newline(), details);
+ }
+
+ source.sendMessage(error);
}
@Override