Skip to content

Sub Commands

Leaf26 edited this page Jun 17, 2026 · 1 revision

LeafRTP ships its own platform-agnostic command framework (commands-api). One command tree is authored once and dispatched on every platform: Bukkit / Paper / Folia via the Bukkit command dispatcher, Fabric (and planned Velocity) via the Brigadier bridge. This means your addon can add a /rtp sub-verb that works - with tab-completion - on all of them, with no per-platform command code.

This page is the author-facing primer. For the deeper bridge contract and recursion rules, see the commands-api author guide.


Add your sub-command

RTP.baseCommand.addSubCommand(new FooCmd(RTP.baseCommand));

That is the whole registration. Parameter decoding and tab-completion are automatic.


The grammar (the most important thing to understand)

The dispatcher accepts only two token shapes - there is no third "free positional" form:

Token Meaning Routed via
<bare-literal> a sub-command (e.g. apply, confirm) getCommandLookup()
<name>=<value>[,<value>]* a typed parameter (e.g. id=42, region=R1,R2) getParameterLookup()

A bare token that does not match a registered sub-command triggers msgInvalidCommand and the verb returns false.

!!! warning "Never take an argument as a bare positional" /rtp foo low-performance does not work - low-performance is routed through sub-command lookup, fails to match, and never reaches your logic. Register a typed parameter instead and use /rtp foo id=low-performance. This is the #1 real-world regression the framework guards against.


Authoring a verb (cookbook)

You are adding /rtp foo bar id=<id> where bar is a leaf sub-verb and <id> enumerates against a live registry.

1. Write the parameter

A CommandParameter's values() is the tab-completion enumerator; isRelevant is the execute-time validator.

public class FooIdParameter extends CommandParameter {
    public FooIdParameter(String permission, String description,
                          BiFunction<UUID, String, Boolean> isRelevant) {
        super(permission, description, isRelevant);
    }

    @Override
    public Set<String> values() {                 // drives tab-complete
        return FooRegistry.list().stream().map(Foo::id).collect(Collectors.toSet());
    }
}

!!! tip "Opaque inputs" For a free-form input the user must type (a token, an arbitrary string), return Collections.emptySet() from values() and keep isRelevant permissive. Tab-complete then offers nothing but the parser still accepts whatever was typed.

2. Write the verb

public class FooBarCmd extends BaseRTPCmdImpl {
    public FooBarCmd(@Nullable CommandsAPICommand parent) {
        super(parent);
        addParameter("id",
            new FooIdParameter(FooCommand.PERMISSION, "registered foo id", (uuid, s) -> true));
    }

    @Override public String name()        { return "bar"; }
    @Override public String permission()  { return FooCommand.PERMISSION; }
    @Override public String description() { return "do the bar thing to a foo"; }

    @Override
    public boolean onCommand(UUID callerId,
                             Map<String, List<String>> parameterValues,
                             @Nullable CommandsAPICommand nextCommand) {
        if (nextCommand != null) return true;   // chained dispatch: let the child run

        if (callerId == null) {
            RTP.log(Level.WARNING, "/rtp foo bar rejected: no caller UUID");
            return false;
        }

        List<String> idValues = parameterValues == null ? null : parameterValues.get("id");
        String id = (idValues == null || idValues.isEmpty()) ? null : idValues.get(0);
        if (id == null || id.isEmpty()) {
            send(callerId, "&cUsage: &f/rtp foo bar id=<id>");
            return false;
        }

        // ... do the work ...
        return true;
    }
}

3. Wire it into the parent

// in FooCommand's constructor:
addSubCommand(new FooBarCmd(this));

4. Add user-facing strings to messages.yml

msgInvalidCommand / msgBadParameter must be configurable (S-007) and propagate through the locale pipeline. Do not hardcode error strings.


Anti-patterns (do not do these)

These come from real LeafRTP regressions.

Anti-pattern Why it breaks Do this instead
Hand-rolling positional parsing by overriding the args[]-form onCommand Bare tokens route through sub-command lookup; hyphenated ids hit msgInvalidCommand and break tab-completion Register a typed CommandParameter, use name=value
Calling nextCommand.onCommand(...) yourself The dispatcher already enqueued the child; you'd run it twice or on the wrong thread if (nextCommand != null) return true;
Returning false from the pre-pass when a child exists Short-circuits the chain before the child reads its own args Use the nextCommand != null guard above
Hardcoded user-facing error strings Violates S-007; ships untranslated and unconfigurable Route through msgInvalidCommand(...)
Letting values() / isRelevant throw Brigadier silently swallows suggestion exceptions, deleting the suggestion list Null-guard; return Collections.emptySet() if the registry is not yet bound

Tab-completion is free

CommandParameter.values() is the enumerator - override it to return a live set (world names, registered ids) and both Brigadier and Bukkit pick it up automatically. The Brigadier bridge attaches nested (subParams) and sibling parameters as child nodes so /rtp region=R world=W shape=S is reachable from any starting parameter.

!!! note "Suggestion vs validation" isRelevant(UUID, String) is the always-enforced execute-time gate. isSuggestionRelevant(UUID, String) is the tab-completion filter and defaults permissive. Never rely on suggestion filtering as a security boundary.


See also

Clone this wiki locally