I thought I’d spend a little bit of time talking about one of the changes in Nucleus that is taking up the bulk of my time at the moment, commands. Commands are perhaps the biggest reason to use Nucleus, after all, it’s meant to be a handy utilities plugin that does a lot of stuff on demand - so it’s important that they are done right.
Before I continue, I should note that I’m not changing the commands from a user facing point of view, but from a developer point of view. If you’re just a Nucleus user, you may not care to continue reading, though you might learn how you can (at least in the future) read the code to find out what permissions a command might use!
Okay, if you’re still with me - let’s delve into how it works now.
I’m going to pull out one of the more, uh, confusing examples and show you the skeleton of the class. I give to you,
the horrible, confusing absolutely badly written /tpa
!
@Permissions(prefix = "teleport", suggestedLevel = SuggestedLevel.USER, supportsSelectors = true)
@NoWarmup(generateConfigEntry = true, generatePermissionDocs = true)
@RegisterCommand({"tpa", "teleportask", "call", "tpask"})
@NonnullByDefault
@EssentialsEquivalent({"tpa", "call", "tpask"})
@NotifyIfAFK(NucleusParameters.Keys.PLAYER)
@SetCooldownManually
public class TeleportAskCommand extends AbstractCommand<Player> implements Reloadable {
private boolean isCooldownOnAsk = false;
private final PlayerTeleporterService playerTeleporterService = getServiceUnchecked(PlayerTeleporterService.class);
@Override
public Map<String, PermissionInformation> permissionSuffixesToRegister() {
Map<String, PermissionInformation> m = new HashMap<>();
m.put("force", PermissionInformation.getWithTranslation("permission.teleport.force", SuggestedLevel.ADMIN));
return m;
}
@Override
public CommandElement[] getArguments() {
return new CommandElement[] {
GenericArguments.flags().permissionFlag(this.permissions.getPermissionWithSuffix("force"), "f").buildWith(NucleusParameters.ONE_PLAYER)
};
}
@Override protected ContinueMode preProcessChecks(Player source, CommandContext args) {
return this.playerTeleporterService
.canTeleportTo(source, args.requireOne(NucleusParameters.Keys.PLAYER)) ? ContinueMode.CONTINUE : ContinueMode.STOP;
}
@Override
public CommandResult executeCommand(Player src, CommandContext args, Cause cause) throws Exception {
....
}
@Override
public void onReload() throws Exception {
this.isCooldownOnAsk = getServiceUnchecked(TeleportConfigAdapter.class).getNodeOrDefault().isCooldownOnAsk();
}
The implementation in executeCommand
isn’t important. However, there is a lot of information above - some of it says
“this is what I am”, some of it says “this is what I am not”, and some of it is simply pointers to what conventions might
happen - and this makes it nigh on impossible for contributors to contribute effectively…
I’ll walk you through what is going on, starting with all the annotations:
@Permissions(prefix = "teleport", suggestedLevel = SuggestedLevel.USER, supportsSelectors = true)
This tells the command to create a permission set for the command that starts nucleus.teleport
, then adding the primary
alias of the command next in the chain to form nucleus.teleport.tpa
, and use that as a root on all permission from this
command. The base
permission as assigned the USER
role (for setupperms), and the command supports selectors (legacy,
means nothing to the codebase now).
@NoWarmup(generateConfigEntry = true, generatePermissionDocs = true)
Don’t try to run a warmup with this command automatically, but in this case, generate the config entry anyway, and add this information to the documentation generator.
In this case, we don’t add the warmup automatically because we actually put the warmup in from when a player accepts a request, rather than before the asking itself.
@RegisterCommand({"tpa", "teleportask", "call", "tpask"})
Register the command with these aliases. tpa
is the primary alias, used as a part of key throughout Nucleus. Not a
subcommand of any other commands.
@NonnullByDefault
Sponge annotation - things do not return or use nulls unless otherwise stated.
@EssentialsEquivalent({"tpa", "call", "tpask"})
For documentation purposes - what this command is equivalent to in Essentials.
@NotifyIfAFK(NucleusParameters.Keys.PLAYER)
Used with command interceptors, if the player specified by the parameter with this key is AFK, tell the sender this.
@SetCooldownManually
Cooldowns are not automatically controlled. Similarly to @NoWarmup
above.
What this doesn’t tell you is:
commands.properties
as tpa.desc
and, optionally, tpa.extended
So that’s the annotations. We then have the command itself, and there are various methods to point out:
permissionSuffixesToRegister
: permissions associated with the command, but done in a very roundabout way to try to
capture auto generated permissions.getArguments
: what the command parameters are - this is surprisingly okay!preProcessChecks
: what this actually does is run custom logic before any warmups, costs etc. take place. It allows
for extra conditions to be checked before firing a command. Most commands don’t use this.executeCommand
: executes the command!onReload
: actually part of the Reloadable
interface, allows for state changes to be cached when the plugin is
reloaded using /nucleus reload
or Sponge’s plugin reload event.There is a lot of scaffolding (which is fine), but it relies on the developer to know everything they need to do. I was forgetting things, and it constituted a high bar to entry. So, I am re-wiring commands to make it easier to contribute to.
In v2, the same command will look something like this:
/**
* Sends a request to a subject to teleport to them, using click handlers.
*/
@NonnullByDefault
@NotifyIfAFK(NucleusParameters.Keys.PLAYER)
@NucleusCommand(
aliases = {"tpa", "teleportask", "call", "tpask"},
basePermission = TeleportPermissions.BASE_TPA,
commandDescriptionKey = "tpa",
modifiers =
{
@CommandModifier(value = CommandModifiers.HAS_WARMUP, exemptPermission = TeleportPermissions.EXEMPT_WARMUP_TPA,
configGenerationOnly = true),
@CommandModifier(value = CommandModifiers.HAS_COOLDOWN, exemptPermission = TeleportPermissions.EXEMPT_COOLDOWN_TPA,
configGenerationOnly = true),
@CommandModifier(value = CommandModifiers.HAS_COST, exemptPermission = TeleportPermissions.EXEMPT_COST_TPA)
}
)
@EssentialsEquivalent({"tpa", "call", "tpask"})
public class TeleportAskCommand implements ICommandExecutor<Player>, Reloadable {
private boolean isCooldownOnAsk = false;
@Override
public CommandElement[] parameters() {
return new CommandElement[] {
GenericArguments.flags().permissionFlag(TeleportPermissions.TELEPORT_ASK_FORCE, "f").buildWith(NucleusParameters.ONE_PLAYER)
};
}
@Override public Optional<ICommandResult> preExecute(ICommandContext<? extends Player> context) throws CommandException {
boolean canTeleport = context.getServiceCollection().getServiceUnchecked(PlayerTeleporterService.class)
.canTeleportTo(
context.getIfPlayer(),
context.requireOne(NucleusParameters.Keys.PLAYER, Player.class)
);
if (canTeleport) {
return Optional.empty();
}
return Optional.of(context.failResult());
}
@Override
public ICommandResult execute(ICommandContext<? extends Player> context) throws CommandException {
...
return context.successResult();
}
@Override
public void onReload(INucleusServiceCollection serviceCollection) {
this.isCooldownOnAsk = serviceCollection.moduleConfigProvider()
.getModuleConfig(TeleportConfig.class)
.isCooldownOnAsk();
}
}
There are some major differences - let’s start with the annotations:
@NucleusCommand
is now the big “this is what defines the command”. Note that the @NotifyIfAFK
annotation has not
yet been folded in, I’ve not quite decided how to do this yet. However, you should be able to see that:
tpa
, teleportask
, etc.tpa.desc
and
the extended description should be at tpa.extended
(javadocs will tell you this part).@EssentialsEquivalent
remains as is, purely due to documentationTeleportPermissions.BASE_TPA
is simply a
string you can read that tells you what the permission is straight off)!The signatures of the methods have also changed (as well as being changed from extending AbstractCommand
to
implementing ICommandExecutor
):
getArguments
becomes parameters
preProcess
method is now preExecute
and simply can tell a command whether or not we should return early, rather
that giving ContinueMode
s. They can be thought of as mini command executors that run before modifiers.execute
now provides one parameter, ICommandContext<? extends CommandSource>
. This contains all the information
about the invocation that a command needs, as well as a INucleusServiceCollection
, which allows commands to do
everything they could before with the Nucleus
object. This will increase testability, and also (hopefully) enforce
better practices. Also, rather than making command inherit multiple traits, commands can simply use this object to get
at everything it requires. I will talk more about the INucleusServiceCollection
another time.AbstractCommand
are now (mostly) on the ICommandContext
.onReload
now includes the INucleusServiceCollection
for the same reason.This exercise is going to take a little bit of time, but the benefits are huge. Permissions being specified, commands not having to worry about clashes with the root object, no god object… these are things I’ve needed to do for some time, especially to support an upgrade to Sponge’s upcoming API 8, but this system will initially support Sponge API 7.2, that is, for Minecraft 1.12.2.
For those of you holding out for external modules - this is one piece of the puzzle. By having a set of interfaces control the system, I can pull these out into a sort of module API. Once v2 is up and running, work will be done to finalise this abstraction and enable you to make your own modules - these changes are for you guys too!