From c63220e58ed707c64c75d5b15ec2be796f3290bd Mon Sep 17 00:00:00 2001 From: MD <1917406+mdcfe@users.noreply.github.com> Date: Tue, 6 Jun 2023 16:21:52 +0100 Subject: [PATCH 1/9] Implement rough command node parser --- .../commands/EssentialsCommandNode.java | 188 ++++++++++++++++++ .../commands/EssentialsCommandNodeTest.java | 83 ++++++++ 2 files changed, 271 insertions(+) create mode 100644 Essentials/src/main/java/com/earth2me/essentials/commands/EssentialsCommandNode.java create mode 100644 Essentials/src/test/java/com/earth2me/essentials/commands/EssentialsCommandNodeTest.java diff --git a/Essentials/src/main/java/com/earth2me/essentials/commands/EssentialsCommandNode.java b/Essentials/src/main/java/com/earth2me/essentials/commands/EssentialsCommandNode.java new file mode 100644 index 00000000000..b2df8c8dc56 --- /dev/null +++ b/Essentials/src/main/java/com/earth2me/essentials/commands/EssentialsCommandNode.java @@ -0,0 +1,188 @@ +package com.earth2me.essentials.commands; + +import com.earth2me.essentials.CommandSource; +import org.bukkit.Server; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.function.Consumer; +import java.util.function.Function; + +public abstract class EssentialsCommandNode { + private ArrayList> childNodes = new ArrayList<>(); + + protected EssentialsCommandNode(final Initializer initializer) { + initializer.init(new BuildContext<>(this)); + } + + protected void run(Context context) throws Exception { + for (EssentialsCommandNode node : childNodes) { + if (node.matches(context)) { + node.run(context); + return; + } + } + + // TODO: error message + throw new NoChargeException(); + } + + protected List tabComplete(Context context) throws Exception { + for (EssentialsCommandNode node : childNodes) { + if (node.matches(context)) { + return node.tabComplete(context); + } + } + + // TODO: error message + throw new NoChargeException(); + } + + public abstract boolean matches(final Context context); + + public static Root root(final Initializer initializer) { + return new Root<>(initializer); + } + + public interface Initializer { + void init(BuildContext node); + } + + public static class BuildContext { + private final EssentialsCommandNode node; + + protected BuildContext(EssentialsCommandNode node) { + this.node = node; + } + + public void literal(final String name, final Initializer initializer) { + node.childNodes.add(new Literal<>(name, initializer)); + } + + public void execute(final Consumer> runHandler) { + this.execute(runHandler, ctx -> new ArrayList<>()); + } + + public void execute(final Consumer> runHandler, final List tabValues) { + this.execute(runHandler, ctx -> tabValues); + } + + public void execute(final Consumer> runHandler, final Function, List> tabHandler) { + node.childNodes.add(new Execute<>(runHandler, tabHandler)); + } + } + + public static class Root extends EssentialsCommandNode { + protected Root(Initializer initializer) { + super(initializer); + } + + @Override + public boolean matches(Context context) { + throw new IllegalStateException("Root commands should not be placed in the tree"); + } + + public void run(Server server, T sender, String commandLabel, String[] args) throws Exception { + run(new Context<>(server, sender, commandLabel, args)); + } + + public List tabComplete(Server server, T sender, String commandLabel, String[] args) throws Exception { + return tabComplete(new Context<>(server, sender, commandLabel, args)); + } + + // run( ... args ...) + // tabComplete( ... args ...) + } + + public static class Literal extends EssentialsCommandNode { + private final String name; + + protected Literal(String name, Initializer initializer) { + super(initializer); + this.name = name; + } + + public boolean matches(Context context) { + return context.args.length > 0 && context.args[0].equalsIgnoreCase(name); + } + + @Override + protected void run(Context context) throws Exception { + // consume argument + context = context.next(); + super.run(context); + } + + @Override + protected List tabComplete(Context context) throws Exception { + // consume argument + context = context.next(); + return super.tabComplete(context); + } + } + + public static class Execute extends EssentialsCommandNode { + private final Consumer> runHandler; + private final Function, List> tabHandler; + + protected Execute(Consumer> runHandler, Function, List> tabHandler) { + super(ctx -> {}); + this.runHandler = runHandler; + this.tabHandler = tabHandler; + } + + @Override + public boolean matches(Context context) { + return true; + } + + @Override + protected void run(Context context) throws Exception { + runHandler.accept(context); + } + + @Override + protected List tabComplete(Context context) throws Exception { + return tabHandler.apply(context); + } + } + + public static class Context { + private final Server server; + private final T sender; + private final String label; + private final String[] args; + + protected Context(Server server, T sender, String label, String[] args) { + this.server = server; + this.sender = sender; + this.label = label; + this.args = args; + } + + protected Context next() { + String[] nextArgs = {}; + if (this.args.length > 1) { + nextArgs = Arrays.copyOfRange(this.args, 1, this.args.length); + } + return new Context<>(this.server, this.sender, this.label, nextArgs); + } + + public Server server() { + return server; + } + + public T sender() { + return sender; + } + + public String label() { + return label; + } + + public String[] args() { + return args; + } + } +} diff --git a/Essentials/src/test/java/com/earth2me/essentials/commands/EssentialsCommandNodeTest.java b/Essentials/src/test/java/com/earth2me/essentials/commands/EssentialsCommandNodeTest.java new file mode 100644 index 00000000000..392340c096e --- /dev/null +++ b/Essentials/src/test/java/com/earth2me/essentials/commands/EssentialsCommandNodeTest.java @@ -0,0 +1,83 @@ +package com.earth2me.essentials.commands; + +import com.earth2me.essentials.CommandSource; +import com.earth2me.essentials.FakeServer; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.function.Executable; +import org.mockito.InOrder; +import org.mockito.Mockito; + +import java.util.Arrays; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.*; + +class EssentialsCommandNodeTest { + private FakeServer fakeServer; + private CommandSource playerSource; + private CommandSource consoleSource; + + @BeforeEach + void setup() { + fakeServer = FakeServer.getServer(); + + playerSource = mock(CommandSource.class); + consoleSource = mock(CommandSource.class); + } + + @Test + void testNonTerminateThrow() { + final EssentialsCommandNode.Root rootNode = EssentialsCommandNode.root(root -> { + root.literal("hello", hello -> { + hello.execute(ctx -> { + if (ctx.args().length < 1) { + ctx.sender().sendMessage("hello to who?"); + } else if (ctx.args().length < 2) { + ctx.sender().sendMessage("hi there " + ctx.args()[0]); + } else { + ctx.sender().sendMessage("woah hi " + String.join(" and ", ctx.args())); + } + System.out.println(Arrays.toString(ctx.args())); + }); + }); + root.literal("bye", bye -> { + bye.literal("forever just kidding", bye1 -> { bye1.execute(ctx -> { throw new RuntimeException("this shouldn't happen"); }); }); + bye.literal("forever", bye2 -> bye2.execute(ctx -> ctx.sender().sendMessage(":(("))); + + bye.execute(ctx -> { + if (ctx.sender().isPlayer()) { + ctx.sender().sendMessage(":("); + } else { + ctx.sender().sendMessage("wait you can't leave"); + } + }); + }); + }); + + assertThrows(NoChargeException.class, () -> rootNode.run(fakeServer, playerSource, "test", new String[]{""}), "wrongly parsed empty arg"); + assertThrows(NoChargeException.class, () -> rootNode.run(fakeServer, playerSource, "test", new String[]{"wilkommen"}), "wrongly parsed unknown literal"); // wrongly parsed German + + Executable playerHelloNoArgs = () -> rootNode.run(fakeServer, playerSource, "test", new String[]{"hello"}); + Executable playerHelloOneArg = () -> rootNode.run(fakeServer, playerSource, "test", new String[]{"hello", "world"}); + Executable playerHelloManyArgs = () -> rootNode.run(fakeServer, playerSource, "test", new String[]{"hello", "jroy", "pop", "lax", "evident"}); + Executable playerBye = () -> rootNode.run(fakeServer, playerSource, "test", new String[]{"bye", "legacy", "code"}); + Executable consoleBye = () -> rootNode.run(fakeServer, consoleSource, "test", new String[]{"bye", "player", "data"}); + Executable consoleByeForeverJk = () -> rootNode.run(fakeServer, consoleSource, "test", new String[]{"bye", "forever", "just", "kidding"}); + + assertDoesNotThrow(playerHelloNoArgs, "parsing first level no-arg command"); + assertDoesNotThrow(playerHelloOneArg, "parsing first level 1 arg command"); + assertDoesNotThrow(playerHelloManyArgs, "parsing first level multi-arg command"); + assertDoesNotThrow(playerBye); + assertDoesNotThrow(consoleBye); + assertDoesNotThrow(consoleByeForeverJk); + + InOrder ordered = Mockito.inOrder(playerSource, consoleSource); + ordered.verify(playerSource).sendMessage("hello to who?"); + ordered.verify(playerSource).sendMessage("hi there world"); + ordered.verify(playerSource).sendMessage("woah hi jroy and pop and lax and evident"); + ordered.verify(consoleSource).sendMessage("wait you can't leave"); + ordered.verify(consoleSource).sendMessage(":(("); + } +} \ No newline at end of file From b8e33d995ece8bcb95e6de73b136de1bd9899d1e Mon Sep 17 00:00:00 2001 From: MD <1917406+mdcfe@users.noreply.github.com> Date: Tue, 6 Jun 2023 16:43:23 +0100 Subject: [PATCH 2/9] More sensible types --- .../commands/EssentialsCommandNode.java | 137 +++++++++--------- 1 file changed, 70 insertions(+), 67 deletions(-) diff --git a/Essentials/src/main/java/com/earth2me/essentials/commands/EssentialsCommandNode.java b/Essentials/src/main/java/com/earth2me/essentials/commands/EssentialsCommandNode.java index b2df8c8dc56..bad7e4ad5aa 100644 --- a/Essentials/src/main/java/com/earth2me/essentials/commands/EssentialsCommandNode.java +++ b/Essentials/src/main/java/com/earth2me/essentials/commands/EssentialsCommandNode.java @@ -6,17 +6,15 @@ import java.util.ArrayList; import java.util.Arrays; import java.util.List; -import java.util.function.Consumer; -import java.util.function.Function; -public abstract class EssentialsCommandNode { - private ArrayList> childNodes = new ArrayList<>(); +public abstract class EssentialsCommandNode { + private final ArrayList> childNodes = new ArrayList<>(); protected EssentialsCommandNode(final Initializer initializer) { initializer.init(new BuildContext<>(this)); } - protected void run(Context context) throws Exception { + protected void run(WalkContext context) throws Exception { for (EssentialsCommandNode node : childNodes) { if (node.matches(context)) { node.run(context); @@ -28,7 +26,7 @@ protected void run(Context context) throws Exception { throw new NoChargeException(); } - protected List tabComplete(Context context) throws Exception { + protected List tabComplete(WalkContext context) throws Exception { for (EssentialsCommandNode node : childNodes) { if (node.matches(context)) { return node.tabComplete(context); @@ -39,17 +37,17 @@ protected List tabComplete(Context context) throws Exception { throw new NoChargeException(); } - public abstract boolean matches(final Context context); + public abstract boolean matches(final WalkContext context); public static Root root(final Initializer initializer) { return new Root<>(initializer); } - public interface Initializer { + public interface Initializer { void init(BuildContext node); } - public static class BuildContext { + public static class BuildContext { private final EssentialsCommandNode node; protected BuildContext(EssentialsCommandNode node) { @@ -60,42 +58,77 @@ public void literal(final String name, final Initializer initializer) { node.childNodes.add(new Literal<>(name, initializer)); } - public void execute(final Consumer> runHandler) { + public void execute(final RunHandler runHandler) { this.execute(runHandler, ctx -> new ArrayList<>()); } - public void execute(final Consumer> runHandler, final List tabValues) { + public void execute(final RunHandler runHandler, final List tabValues) { this.execute(runHandler, ctx -> tabValues); } - public void execute(final Consumer> runHandler, final Function, List> tabHandler) { + public void execute(final RunHandler runHandler, final TabHandler tabHandler) { node.childNodes.add(new Execute<>(runHandler, tabHandler)); } } - public static class Root extends EssentialsCommandNode { + public static class WalkContext { + private final Server server; + private final T sender; + private final String label; + private final String[] args; + + protected WalkContext(Server server, T sender, String label, String[] args) { + this.server = server; + this.sender = sender; + this.label = label; + this.args = args; + } + + protected WalkContext next() { + String[] nextArgs = {}; + if (this.args.length > 1) { + nextArgs = Arrays.copyOfRange(this.args, 1, this.args.length); + } + return new WalkContext<>(this.server, this.sender, this.label, nextArgs); + } + + public Server server() { + return server; + } + + public T sender() { + return sender; + } + + public String label() { + return label; + } + + public String[] args() { + return args; + } + } + + public static class Root extends EssentialsCommandNode { protected Root(Initializer initializer) { super(initializer); } @Override - public boolean matches(Context context) { + public boolean matches(WalkContext context) { throw new IllegalStateException("Root commands should not be placed in the tree"); } public void run(Server server, T sender, String commandLabel, String[] args) throws Exception { - run(new Context<>(server, sender, commandLabel, args)); + run(new WalkContext<>(server, sender, commandLabel, args)); } public List tabComplete(Server server, T sender, String commandLabel, String[] args) throws Exception { - return tabComplete(new Context<>(server, sender, commandLabel, args)); + return tabComplete(new WalkContext<>(server, sender, commandLabel, args)); } - - // run( ... args ...) - // tabComplete( ... args ...) } - public static class Literal extends EssentialsCommandNode { + public static class Literal extends EssentialsCommandNode { private final String name; protected Literal(String name, Initializer initializer) { @@ -103,86 +136,56 @@ protected Literal(String name, Initializer initializer) { this.name = name; } - public boolean matches(Context context) { + public boolean matches(WalkContext context) { return context.args.length > 0 && context.args[0].equalsIgnoreCase(name); } @Override - protected void run(Context context) throws Exception { + protected void run(WalkContext context) throws Exception { // consume argument context = context.next(); super.run(context); } @Override - protected List tabComplete(Context context) throws Exception { + protected List tabComplete(WalkContext context) throws Exception { // consume argument context = context.next(); return super.tabComplete(context); } } - public static class Execute extends EssentialsCommandNode { - private final Consumer> runHandler; - private final Function, List> tabHandler; + public static class Execute extends EssentialsCommandNode { + private final RunHandler runHandler; + private final TabHandler tabHandler; - protected Execute(Consumer> runHandler, Function, List> tabHandler) { + protected Execute(RunHandler runHandler, TabHandler tabHandler) { super(ctx -> {}); this.runHandler = runHandler; this.tabHandler = tabHandler; } @Override - public boolean matches(Context context) { + public boolean matches(WalkContext context) { return true; } @Override - protected void run(Context context) throws Exception { - runHandler.accept(context); + protected void run(WalkContext context) throws Exception { + runHandler.handle(context); } @Override - protected List tabComplete(Context context) throws Exception { - return tabHandler.apply(context); + protected List tabComplete(WalkContext context) throws Exception { + return tabHandler.handle(context); } } - public static class Context { - private final Server server; - private final T sender; - private final String label; - private final String[] args; - - protected Context(Server server, T sender, String label, String[] args) { - this.server = server; - this.sender = sender; - this.label = label; - this.args = args; - } - - protected Context next() { - String[] nextArgs = {}; - if (this.args.length > 1) { - nextArgs = Arrays.copyOfRange(this.args, 1, this.args.length); - } - return new Context<>(this.server, this.sender, this.label, nextArgs); - } - - public Server server() { - return server; - } - - public T sender() { - return sender; - } - - public String label() { - return label; - } + public interface RunHandler { + void handle(WalkContext ctx) throws Exception; + } - public String[] args() { - return args; - } + public interface TabHandler { + List handle(WalkContext ctx) throws Exception; } } From c18f54bed1090c52bf2bc1658274d391b9fa78a5 Mon Sep 17 00:00:00 2001 From: MD <1917406+mdcfe@users.noreply.github.com> Date: Tue, 6 Jun 2023 17:11:41 +0100 Subject: [PATCH 3/9] Implement aliases, child validation and tree build tests --- .../commands/EssentialsCommandNode.java | 28 ++++++++-- .../commands/EssentialsCommandNodeTest.java | 54 +++++++++++-------- 2 files changed, 57 insertions(+), 25 deletions(-) diff --git a/Essentials/src/main/java/com/earth2me/essentials/commands/EssentialsCommandNode.java b/Essentials/src/main/java/com/earth2me/essentials/commands/EssentialsCommandNode.java index bad7e4ad5aa..be838704ae2 100644 --- a/Essentials/src/main/java/com/earth2me/essentials/commands/EssentialsCommandNode.java +++ b/Essentials/src/main/java/com/earth2me/essentials/commands/EssentialsCommandNode.java @@ -5,7 +5,10 @@ import java.util.ArrayList; import java.util.Arrays; +import java.util.Collections; +import java.util.HashSet; import java.util.List; +import java.util.Locale; public abstract class EssentialsCommandNode { private final ArrayList> childNodes = new ArrayList<>(); @@ -37,6 +40,10 @@ protected List tabComplete(WalkContext context) throws Exception { throw new NoChargeException(); } + protected List> getChildNodes() { + return Collections.unmodifiableList(childNodes); + } + public abstract boolean matches(final WalkContext context); public static Root root(final Initializer initializer) { @@ -54,8 +61,8 @@ protected BuildContext(EssentialsCommandNode node) { this.node = node; } - public void literal(final String name, final Initializer initializer) { - node.childNodes.add(new Literal<>(name, initializer)); + public void literal(final String name, final Initializer initializer, final String... aliases) { + node.childNodes.add(new Literal<>(name, aliases, initializer)); } public void execute(final RunHandler runHandler) { @@ -112,6 +119,9 @@ public String[] args() { public static class Root extends EssentialsCommandNode { protected Root(Initializer initializer) { super(initializer); + if (getChildNodes().isEmpty()) { + throw new RuntimeException("Root nodes must be initialised with at least one child"); + } } @Override @@ -130,14 +140,24 @@ public List tabComplete(Server server, T sender, String commandLabel, St public static class Literal extends EssentialsCommandNode { private final String name; + private final HashSet aliases; - protected Literal(String name, Initializer initializer) { + protected Literal(String name, String[] aliases, Initializer initializer) { super(initializer); + if (getChildNodes().isEmpty()) { + throw new RuntimeException("Literal nodes must be initialised with at least one child (node name: " + name + ")"); + } + this.name = name; + this.aliases = new HashSet<>(); + this.aliases.add(name.toLowerCase(Locale.ROOT)); + for (final String alias : aliases) { + this.aliases.add(alias.toLowerCase(Locale.ROOT)); + } } public boolean matches(WalkContext context) { - return context.args.length > 0 && context.args[0].equalsIgnoreCase(name); + return context.args.length > 0 && aliases.contains(context.args[0].toLowerCase(Locale.ROOT)); } @Override diff --git a/Essentials/src/test/java/com/earth2me/essentials/commands/EssentialsCommandNodeTest.java b/Essentials/src/test/java/com/earth2me/essentials/commands/EssentialsCommandNodeTest.java index 392340c096e..38d01ae52c3 100644 --- a/Essentials/src/test/java/com/earth2me/essentials/commands/EssentialsCommandNodeTest.java +++ b/Essentials/src/test/java/com/earth2me/essentials/commands/EssentialsCommandNodeTest.java @@ -6,13 +6,13 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.function.Executable; import org.mockito.InOrder; -import org.mockito.Mockito; import java.util.Arrays; import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.mockito.Mockito.*; +import static org.mockito.Mockito.inOrder; +import static org.mockito.Mockito.mock; class EssentialsCommandNodeTest { private FakeServer fakeServer; @@ -27,21 +27,18 @@ void setup() { consoleSource = mock(CommandSource.class); } - @Test - void testNonTerminateThrow() { - final EssentialsCommandNode.Root rootNode = EssentialsCommandNode.root(root -> { - root.literal("hello", hello -> { - hello.execute(ctx -> { - if (ctx.args().length < 1) { - ctx.sender().sendMessage("hello to who?"); - } else if (ctx.args().length < 2) { - ctx.sender().sendMessage("hi there " + ctx.args()[0]); - } else { - ctx.sender().sendMessage("woah hi " + String.join(" and ", ctx.args())); - } - System.out.println(Arrays.toString(ctx.args())); - }); - }); + EssentialsCommandNode.Root buildCommonTree() { + return EssentialsCommandNode.root(root -> { + root.literal("hello", hello -> hello.execute(ctx -> { + if (ctx.args().length < 1) { + ctx.sender().sendMessage("hello to who?"); + } else if (ctx.args().length < 2) { + ctx.sender().sendMessage("hi there " + ctx.args()[0]); + } else { + ctx.sender().sendMessage("woah hi " + String.join(" and ", ctx.args())); + } + System.out.println(Arrays.toString(ctx.args())); + })); root.literal("bye", bye -> { bye.literal("forever just kidding", bye1 -> { bye1.execute(ctx -> { throw new RuntimeException("this shouldn't happen"); }); }); bye.literal("forever", bye2 -> bye2.execute(ctx -> ctx.sender().sendMessage(":(("))); @@ -53,8 +50,23 @@ void testNonTerminateThrow() { ctx.sender().sendMessage("wait you can't leave"); } }); - }); + }, "farewell", "tschuss"); }); + } + + @Test + void testBuild() { + assertThrows(RuntimeException.class, () -> EssentialsCommandNode.root(root -> {}), "empty root"); + assertThrows(RuntimeException.class, () -> EssentialsCommandNode.root(root -> { + root.literal("potato", potato -> {}); + }), "empty literal"); + + assertDoesNotThrow(this::buildCommonTree, "build complete tree"); + } + + @Test + void testEval() { + final EssentialsCommandNode.Root rootNode = buildCommonTree(); assertThrows(NoChargeException.class, () -> rootNode.run(fakeServer, playerSource, "test", new String[]{""}), "wrongly parsed empty arg"); assertThrows(NoChargeException.class, () -> rootNode.run(fakeServer, playerSource, "test", new String[]{"wilkommen"}), "wrongly parsed unknown literal"); // wrongly parsed German @@ -63,17 +75,17 @@ void testNonTerminateThrow() { Executable playerHelloOneArg = () -> rootNode.run(fakeServer, playerSource, "test", new String[]{"hello", "world"}); Executable playerHelloManyArgs = () -> rootNode.run(fakeServer, playerSource, "test", new String[]{"hello", "jroy", "pop", "lax", "evident"}); Executable playerBye = () -> rootNode.run(fakeServer, playerSource, "test", new String[]{"bye", "legacy", "code"}); - Executable consoleBye = () -> rootNode.run(fakeServer, consoleSource, "test", new String[]{"bye", "player", "data"}); + Executable consoleFarewell = () -> rootNode.run(fakeServer, consoleSource, "test", new String[]{"fAREWELL", "player", "data"}); Executable consoleByeForeverJk = () -> rootNode.run(fakeServer, consoleSource, "test", new String[]{"bye", "forever", "just", "kidding"}); assertDoesNotThrow(playerHelloNoArgs, "parsing first level no-arg command"); assertDoesNotThrow(playerHelloOneArg, "parsing first level 1 arg command"); assertDoesNotThrow(playerHelloManyArgs, "parsing first level multi-arg command"); assertDoesNotThrow(playerBye); - assertDoesNotThrow(consoleBye); + assertDoesNotThrow(consoleFarewell, "parsing with literal alias"); assertDoesNotThrow(consoleByeForeverJk); - InOrder ordered = Mockito.inOrder(playerSource, consoleSource); + InOrder ordered = inOrder(playerSource, consoleSource); ordered.verify(playerSource).sendMessage("hello to who?"); ordered.verify(playerSource).sendMessage("hi there world"); ordered.verify(playerSource).sendMessage("woah hi jroy and pop and lax and evident"); From a4ef47b204884541f93fbdd1cad4432130cd63ab Mon Sep 17 00:00:00 2001 From: MD <1917406+mdcfe@users.noreply.github.com> Date: Tue, 6 Jun 2023 17:30:14 +0100 Subject: [PATCH 4/9] Start to reimplement /essentials --- .../commands/Commandessentials.java | 248 ++++++++++-------- 1 file changed, 138 insertions(+), 110 deletions(-) diff --git a/Essentials/src/main/java/com/earth2me/essentials/commands/Commandessentials.java b/Essentials/src/main/java/com/earth2me/essentials/commands/Commandessentials.java index 6fa2873afa2..4aaeedf74e5 100644 --- a/Essentials/src/main/java/com/earth2me/essentials/commands/Commandessentials.java +++ b/Essentials/src/main/java/com/earth2me/essentials/commands/Commandessentials.java @@ -113,67 +113,143 @@ public class Commandessentials extends EssentialsCommand { ); private transient TuneRunnable currentTune = null; + private final EssentialsCommandNode.Root tree = EssentialsCommandNode.root(root -> { + root.literal("debug", debug -> debug.execute(this::runDebug), "verbose"); + + root.literal("locale", locale -> { + // TODO + locale.execute(ctx -> {}, Arrays.asList("not", "yet", "implemented")); + }, "lang"); + + // TODO: start moving things to literals + + // fallback while we move things over + root.execute(ctx -> { + final Server server = ctx.server(); + final CommandSource sender = ctx.sender(); + final String commandLabel = ctx.label(); + final String[] args = ctx.args(); + + if (args.length == 0) { + showUsage(sender); + } + + switch (args[0]) { + // Info commands + case "ver": + case "version": + runVersion(server, sender, commandLabel, args); + break; + case "cmd": + case "commands": + runCommands(server, sender, commandLabel, args); + break; + case "dump": + runDump(server, sender, commandLabel, args); + break; + + // Data commands + case "reload": + runReload(server, sender, commandLabel, args); + break; + case "reset": + runReset(server, sender, commandLabel, args); + break; + case "cleanup": + runCleanup(server, sender, commandLabel, args); + break; + case "homes": + runHomes(server, sender, commandLabel, args); + break; + case "usermap": + runUserMap(sender, args); + break; + + case "itemtest": + runItemTest(server, sender, commandLabel, args); + break; + + // "#EasterEgg" + case "nya": + case "nyan": + runNya(server, sender, commandLabel, args); + break; + case "moo": + runMoo(server, sender, commandLabel, args); + break; + default: + showUsage(sender); + break; + } + }, ctx -> { + final Server server = ctx.server(); + final CommandSource sender = ctx.sender(); + final String commandLabel = ctx.label(); + final String[] args = ctx.args(); + + if (args.length == 1) { + final List options = Lists.newArrayList(); + options.add("reload"); + options.add("version"); + options.add("dump"); + options.add("commands"); + options.add("reset"); + options.add("cleanup"); + options.add("homes"); + //options.add("uuidconvert"); + //options.add("nya"); + //options.add("moo"); + return options; + } + + switch (args[0]) { + case "moo": + if (args.length == 2) { + return Lists.newArrayList("moo"); + } + break; + case "reset": + if (args.length == 2) { + return getPlayers(server, sender); + } + break; + case "cleanup": + if (args.length == 2) { + return COMMON_DURATIONS; + } else if (args.length == 3 || args.length == 4) { + return Lists.newArrayList("-1", "0"); + } + break; + case "homes": + if (args.length == 2) { + return Lists.newArrayList("fix", "delete"); + } else if (args.length == 3 && args[1].equalsIgnoreCase("delete")) { + return server.getWorlds().stream().map(World::getName).collect(Collectors.toList()); + } + break; + case "dump": + final List list = Lists.newArrayList("config", "kits", "log", "discord", "worth", "tpr", "spawns", "commands", "all"); + for (String arg : args) { + if (arg.equals("*") || arg.equalsIgnoreCase("all")) { + list.clear(); + return list; + } + list.remove(arg.toLowerCase(Locale.ENGLISH)); + } + return list; + } + + return Collections.emptyList(); + }); + }); + public Commandessentials() { super("essentials"); } @Override public void run(final Server server, final CommandSource sender, final String commandLabel, final String[] args) throws Exception { - if (args.length == 0) { - showUsage(sender); - } - - switch (args[0]) { - // Info commands - case "debug": - case "verbose": - runDebug(server, sender, commandLabel, args); - break; - case "ver": - case "version": - runVersion(server, sender, commandLabel, args); - break; - case "cmd": - case "commands": - runCommands(server, sender, commandLabel, args); - break; - case "dump": - runDump(server, sender, commandLabel, args); - break; - - // Data commands - case "reload": - runReload(server, sender, commandLabel, args); - break; - case "reset": - runReset(server, sender, commandLabel, args); - break; - case "cleanup": - runCleanup(server, sender, commandLabel, args); - break; - case "homes": - runHomes(server, sender, commandLabel, args); - break; - case "usermap": - runUserMap(sender, args); - break; - - case "itemtest": - runItemTest(server, sender, commandLabel, args); - break; - - // "#EasterEgg" - case "nya": - case "nyan": - runNya(server, sender, commandLabel, args); - break; - case "moo": - runMoo(server, sender, commandLabel, args); - break; - default: - showUsage(sender); - break; - } + tree.run(server, sender, commandLabel, args); } public void runItemTest(Server server, CommandSource sender, String commandLabel, String[] args) { @@ -474,9 +550,9 @@ private void runReset(final Server server, final CommandSource sender, final Str } // Toggles debug mode. - private void runDebug(final Server server, final CommandSource sender, final String commandLabel, final String[] args) throws Exception { + private void runDebug(final EssentialsCommandNode.WalkContext context) { ess.getSettings().setDebug(!ess.getSettings().isDebug()); - sender.sendMessage("Essentials " + ess.getDescription().getVersion() + " debug mode " + (ess.getSettings().isDebug() ? "enabled" : "disabled")); + context.sender().sendMessage("Essentials " + ess.getDescription().getVersion() + " debug mode " + (ess.getSettings().isDebug() ? "enabled" : "disabled")); } // Reloads all reloadable configs. @@ -820,60 +896,12 @@ private void runVersion(final Server server, final CommandSource sender, final S @Override protected List getTabCompleteOptions(final Server server, final CommandSource sender, final String commandLabel, final String[] args) { - if (args.length == 1) { - final List options = Lists.newArrayList(); - options.add("reload"); - options.add("version"); - options.add("dump"); - options.add("commands"); - options.add("debug"); - options.add("reset"); - options.add("cleanup"); - options.add("homes"); - //options.add("uuidconvert"); - //options.add("nya"); - //options.add("moo"); - return options; - } - - switch (args[0]) { - case "moo": - if (args.length == 2) { - return Lists.newArrayList("moo"); - } - break; - case "reset": - if (args.length == 2) { - return getPlayers(server, sender); - } - break; - case "cleanup": - if (args.length == 2) { - return COMMON_DURATIONS; - } else if (args.length == 3 || args.length == 4) { - return Lists.newArrayList("-1", "0"); - } - break; - case "homes": - if (args.length == 2) { - return Lists.newArrayList("fix", "delete"); - } else if (args.length == 3 && args[1].equalsIgnoreCase("delete")) { - return server.getWorlds().stream().map(World::getName).collect(Collectors.toList()); - } - break; - case "dump": - final List list = Lists.newArrayList("config", "kits", "log", "discord", "worth", "tpr", "spawns", "commands", "all"); - for (String arg : args) { - if (arg.equals("*") || arg.equalsIgnoreCase("all")) { - list.clear(); - return list; - } - list.remove(arg.toLowerCase(Locale.ENGLISH)); - } - return list; + try { + return tree.tabComplete(server, sender, commandLabel, args); + } catch (Exception e) { + // TODO: ??? + throw new RuntimeException(e); } - - return Collections.emptyList(); } private static class TuneRunnable extends BukkitRunnable { From a94f29d5b008af9d99f22970a863b8dbb2cc109c Mon Sep 17 00:00:00 2001 From: MD <1917406+mdcfe@users.noreply.github.com> Date: Tue, 6 Jun 2023 17:31:16 +0100 Subject: [PATCH 5/9] First attempt at tab completing literals --- .../commands/EssentialsCommandNode.java | 25 +++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/Essentials/src/main/java/com/earth2me/essentials/commands/EssentialsCommandNode.java b/Essentials/src/main/java/com/earth2me/essentials/commands/EssentialsCommandNode.java index be838704ae2..4d9b934a5bb 100644 --- a/Essentials/src/main/java/com/earth2me/essentials/commands/EssentialsCommandNode.java +++ b/Essentials/src/main/java/com/earth2me/essentials/commands/EssentialsCommandNode.java @@ -18,6 +18,7 @@ protected EssentialsCommandNode(final Initializer initializer) { } protected void run(WalkContext context) throws Exception { + // TODO: consider moving walk logic into non-terminal node subclass for (EssentialsCommandNode node : childNodes) { if (node.matches(context)) { node.run(context); @@ -25,19 +26,35 @@ protected void run(WalkContext context) throws Exception { } } + // we only want exact matches, so throw an error // TODO: error message throw new NoChargeException(); } protected List tabComplete(WalkContext context) throws Exception { + // TODO: consider moving walk logic into non-terminal node subclass + + // try and full match first for (EssentialsCommandNode node : childNodes) { if (node.matches(context)) { return node.tabComplete(context); } } - // TODO: error message - throw new NoChargeException(); + // try for partial matches + final ArrayList parts = new ArrayList<>(); + if (context.args.length == 1) { + for (EssentialsCommandNode node : childNodes) { + if (node instanceof Literal) { + final Literal literal = (Literal) node; + if (literal.name().startsWith(context.args[0])) { + parts.add(literal.name()); + } + } + } + } + + return parts; } protected List> getChildNodes() { @@ -156,6 +173,10 @@ protected Literal(String name, String[] aliases, Initializer initializer) { } } + public String name() { + return name; + } + public boolean matches(WalkContext context) { return context.args.length > 0 && aliases.contains(context.args[0].toLowerCase(Locale.ROOT)); } From c7a271b92b92447ceae0645c360fd4e6f45630ce Mon Sep 17 00:00:00 2001 From: MD <1917406+mdcfe@users.noreply.github.com> Date: Tue, 6 Jun 2023 18:08:57 +0100 Subject: [PATCH 6/9] Throw more specific arguments --- .../essentials/commands/EssentialsCommandNode.java | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/Essentials/src/main/java/com/earth2me/essentials/commands/EssentialsCommandNode.java b/Essentials/src/main/java/com/earth2me/essentials/commands/EssentialsCommandNode.java index 4d9b934a5bb..cd7c0a690c7 100644 --- a/Essentials/src/main/java/com/earth2me/essentials/commands/EssentialsCommandNode.java +++ b/Essentials/src/main/java/com/earth2me/essentials/commands/EssentialsCommandNode.java @@ -27,8 +27,7 @@ protected void run(WalkContext context) throws Exception { } // we only want exact matches, so throw an error - // TODO: error message - throw new NoChargeException(); + throw new NotEnoughArgumentsException(); } protected List tabComplete(WalkContext context) throws Exception { @@ -137,7 +136,7 @@ public static class Root extends EssentialsCommandNode { protected Root(Initializer initializer) { super(initializer); if (getChildNodes().isEmpty()) { - throw new RuntimeException("Root nodes must be initialised with at least one child"); + throw new IllegalStateException("Root nodes must be initialised with at least one child"); } } @@ -162,7 +161,7 @@ public static class Literal extends EssentialsCommandNode { protected Literal(String name, String[] aliases, Initializer initializer) { super(initializer); if (getChildNodes().isEmpty()) { - throw new RuntimeException("Literal nodes must be initialised with at least one child (node name: " + name + ")"); + throw new IllegalStateException("Literal nodes must be initialised with at least one child (node name: " + name + ")"); } this.name = name; From 6d9b04f0db0516056edfed79a4c4cd3bc2cf401e Mon Sep 17 00:00:00 2001 From: MD <1917406+mdcfe@users.noreply.github.com> Date: Wed, 7 Jun 2023 01:24:27 +0100 Subject: [PATCH 7/9] Add "legacy" wrapper Not sure if we want this long-term or not, but we'll keep it for now --- .../commands/EssentialsCommandNode.java | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/Essentials/src/main/java/com/earth2me/essentials/commands/EssentialsCommandNode.java b/Essentials/src/main/java/com/earth2me/essentials/commands/EssentialsCommandNode.java index cd7c0a690c7..25fb0779eaf 100644 --- a/Essentials/src/main/java/com/earth2me/essentials/commands/EssentialsCommandNode.java +++ b/Essentials/src/main/java/com/earth2me/essentials/commands/EssentialsCommandNode.java @@ -82,7 +82,11 @@ public void literal(final String name, final Initializer initializer, final S } public void execute(final RunHandler runHandler) { - this.execute(runHandler, ctx -> new ArrayList<>()); + this.execute(runHandler, ctx -> Collections.emptyList()); + } + + public void execute(final LegacyRunHandler runHandler) { + this.execute(runHandler, ctx -> Collections.emptyList()); } public void execute(final RunHandler runHandler, final List tabValues) { @@ -225,6 +229,17 @@ public interface RunHandler { void handle(WalkContext ctx) throws Exception; } + // todo: not sure whether to keep or to rewrite usages + @Deprecated + public interface LegacyRunHandler extends RunHandler { + @Override + default void handle(WalkContext ctx) throws Exception { + handle(ctx.server, ctx.sender, ctx.label, ctx.args); + } + + void handle(Server server, T sender, String label, String[] args) throws Exception; + } + public interface TabHandler { List handle(WalkContext ctx) throws Exception; } From 56973926d70b80f3192b2b54dd108ce9d26b63b2 Mon Sep 17 00:00:00 2001 From: MD <1917406+mdcfe@users.noreply.github.com> Date: Wed, 7 Jun 2023 01:25:14 +0100 Subject: [PATCH 8/9] Use tree for all /ess subcommands --- .../commands/Commandessentials.java | 117 +++++------------- 1 file changed, 30 insertions(+), 87 deletions(-) diff --git a/Essentials/src/main/java/com/earth2me/essentials/commands/Commandessentials.java b/Essentials/src/main/java/com/earth2me/essentials/commands/Commandessentials.java index 4aaeedf74e5..70e3c62c63e 100644 --- a/Essentials/src/main/java/com/earth2me/essentials/commands/Commandessentials.java +++ b/Essentials/src/main/java/com/earth2me/essentials/commands/Commandessentials.java @@ -15,7 +15,6 @@ import com.earth2me.essentials.utils.PasteUtil; import com.earth2me.essentials.utils.VersionUtil; import com.google.common.collect.ImmutableMap; -import com.google.common.collect.Lists; import com.google.gson.JsonArray; import com.google.gson.JsonNull; import com.google.gson.JsonObject; @@ -26,7 +25,6 @@ import org.bukkit.Material; import org.bukkit.Server; import org.bukkit.Sound; -import org.bukkit.World; import org.bukkit.command.Command; import org.bukkit.entity.Player; import org.bukkit.inventory.ItemStack; @@ -57,7 +55,6 @@ import java.util.concurrent.CompletableFuture; import java.util.function.Supplier; import java.util.logging.Level; -import java.util.stream.Collectors; import static com.earth2me.essentials.I18n.tl; @@ -114,94 +111,38 @@ public class Commandessentials extends EssentialsCommand { private transient TuneRunnable currentTune = null; private final EssentialsCommandNode.Root tree = EssentialsCommandNode.root(root -> { - root.literal("debug", debug -> debug.execute(this::runDebug), "verbose"); - + // Info commands + root.literal("debug", ctx -> ctx.execute(this::runDebug), "verbose"); + root.literal("version", ctx -> ctx.execute(this::runVersion), "ver"); + root.literal("commands", ctx -> ctx.execute(this::runCommands), "cmd"); + root.literal("dump", ctx -> ctx.execute(this::runDump)); + + // Config commands + root.literal("reload", ctx -> ctx.execute(this::runReload)); root.literal("locale", locale -> { // TODO - locale.execute(ctx -> {}, Arrays.asList("not", "yet", "implemented")); + locale.execute(ctx -> ctx.sender().sendMessage("Not yet implemented"), Arrays.asList("create", "export", "getraw")); }, "lang"); - // TODO: start moving things to literals - - // fallback while we move things over - root.execute(ctx -> { - final Server server = ctx.server(); - final CommandSource sender = ctx.sender(); - final String commandLabel = ctx.label(); - final String[] args = ctx.args(); - - if (args.length == 0) { - showUsage(sender); - } - - switch (args[0]) { - // Info commands - case "ver": - case "version": - runVersion(server, sender, commandLabel, args); - break; - case "cmd": - case "commands": - runCommands(server, sender, commandLabel, args); - break; - case "dump": - runDump(server, sender, commandLabel, args); - break; - - // Data commands - case "reload": - runReload(server, sender, commandLabel, args); - break; - case "reset": - runReset(server, sender, commandLabel, args); - break; - case "cleanup": - runCleanup(server, sender, commandLabel, args); - break; - case "homes": - runHomes(server, sender, commandLabel, args); - break; - case "usermap": - runUserMap(sender, args); - break; - - case "itemtest": - runItemTest(server, sender, commandLabel, args); - break; + // Data commands + root.literal("reset", ctx -> ctx.execute(this::runReset)); + root.literal("cleanup", ctx -> ctx.execute(this::runCleanup)); + root.literal("homes", ctx -> ctx.execute(this::runHomes)); + root.literal("usermap", usermap -> { + // TODO: split these out from #runUsermap + usermap.literal("full", ctx -> ctx.execute(TODO -> {})); + usermap.literal("purge", ctx -> ctx.execute(TODO -> {})); + usermap.literal("lookup", ctx -> ctx.execute(TODO -> {})); + }); - // "#EasterEgg" - case "nya": - case "nyan": - runNya(server, sender, commandLabel, args); - break; - case "moo": - runMoo(server, sender, commandLabel, args); - break; - default: - showUsage(sender); - break; - } - }, ctx -> { - final Server server = ctx.server(); - final CommandSource sender = ctx.sender(); - final String commandLabel = ctx.label(); - final String[] args = ctx.args(); - - if (args.length == 1) { - final List options = Lists.newArrayList(); - options.add("reload"); - options.add("version"); - options.add("dump"); - options.add("commands"); - options.add("reset"); - options.add("cleanup"); - options.add("homes"); - //options.add("uuidconvert"); - //options.add("nya"); - //options.add("moo"); - return options; - } + // Internal debugging and #EasterEgg + root.literal("itemtest", ctx -> ctx.execute(this::runItemTest)); + // TODO: hide from tab-complete + root.literal("moo", ctx -> ctx.execute(this::runMoo)); // todo: moo moo + root.literal("nyan", ctx -> ctx.execute(this::runNya), "nya"); + // TODO: missing tab completions + /* switch (args[0]) { case "moo": if (args.length == 2) { @@ -240,7 +181,8 @@ public class Commandessentials extends EssentialsCommand { } return Collections.emptyList(); - }); + + */ }); public Commandessentials() { @@ -252,7 +194,7 @@ public void run(final Server server, final CommandSource sender, final String co tree.run(server, sender, commandLabel, args); } - public void runItemTest(Server server, CommandSource sender, String commandLabel, String[] args) { + public void runItemTest(final Server server, final CommandSource sender, final String commandLabel, final String[] args) { if (!sender.isAuthorized("essentials.itemtest", ess) || args.length < 2 || !sender.isPlayer()) { return; } @@ -303,6 +245,7 @@ public void runItemTest(Server server, CommandSource sender, String commandLabel } // Displays the command's usage. + // todo: remove private void showUsage(final CommandSource sender) throws Exception { throw new NotEnoughArgumentsException(); } From 03c7e1298dbb29f86d1e19d08ba7de072eda3c3f Mon Sep 17 00:00:00 2001 From: MD <1917406+mdcfe@users.noreply.github.com> Date: Wed, 7 Jun 2023 02:12:37 +0100 Subject: [PATCH 9/9] Fix tests and checkstyle --- .../commands/EssentialsCommandNodeTest.java | 32 ++++++++----------- 1 file changed, 13 insertions(+), 19 deletions(-) diff --git a/Essentials/src/test/java/com/earth2me/essentials/commands/EssentialsCommandNodeTest.java b/Essentials/src/test/java/com/earth2me/essentials/commands/EssentialsCommandNodeTest.java index 38d01ae52c3..b5c68e6f558 100644 --- a/Essentials/src/test/java/com/earth2me/essentials/commands/EssentialsCommandNodeTest.java +++ b/Essentials/src/test/java/com/earth2me/essentials/commands/EssentialsCommandNodeTest.java @@ -4,7 +4,6 @@ import com.earth2me.essentials.FakeServer; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.function.Executable; import org.mockito.InOrder; import java.util.Arrays; @@ -40,7 +39,9 @@ EssentialsCommandNode.Root buildCommonTree() { System.out.println(Arrays.toString(ctx.args())); })); root.literal("bye", bye -> { - bye.literal("forever just kidding", bye1 -> { bye1.execute(ctx -> { throw new RuntimeException("this shouldn't happen"); }); }); + bye.literal("forever just kidding", bye1 -> bye1.execute(ctx -> { + throw new RuntimeException("this shouldn't happen"); + })); bye.literal("forever", bye2 -> bye2.execute(ctx -> ctx.sender().sendMessage(":(("))); bye.execute(ctx -> { @@ -68,28 +69,21 @@ void testBuild() { void testEval() { final EssentialsCommandNode.Root rootNode = buildCommonTree(); - assertThrows(NoChargeException.class, () -> rootNode.run(fakeServer, playerSource, "test", new String[]{""}), "wrongly parsed empty arg"); - assertThrows(NoChargeException.class, () -> rootNode.run(fakeServer, playerSource, "test", new String[]{"wilkommen"}), "wrongly parsed unknown literal"); // wrongly parsed German + assertThrows(NotEnoughArgumentsException.class, () -> rootNode.run(fakeServer, playerSource, "test", new String[]{""}), "wrongly parsed empty arg"); + assertThrows(NotEnoughArgumentsException.class, () -> rootNode.run(fakeServer, playerSource, "test", new String[]{"wilkommen"}), "wrongly parsed unknown literal"); // wrongly parsed German - Executable playerHelloNoArgs = () -> rootNode.run(fakeServer, playerSource, "test", new String[]{"hello"}); - Executable playerHelloOneArg = () -> rootNode.run(fakeServer, playerSource, "test", new String[]{"hello", "world"}); - Executable playerHelloManyArgs = () -> rootNode.run(fakeServer, playerSource, "test", new String[]{"hello", "jroy", "pop", "lax", "evident"}); - Executable playerBye = () -> rootNode.run(fakeServer, playerSource, "test", new String[]{"bye", "legacy", "code"}); - Executable consoleFarewell = () -> rootNode.run(fakeServer, consoleSource, "test", new String[]{"fAREWELL", "player", "data"}); - Executable consoleByeForeverJk = () -> rootNode.run(fakeServer, consoleSource, "test", new String[]{"bye", "forever", "just", "kidding"}); + assertDoesNotThrow(() -> rootNode.run(fakeServer, playerSource, "test", new String[]{"hello"}), "parsing first level no-arg command"); + assertDoesNotThrow(() -> rootNode.run(fakeServer, playerSource, "test", new String[]{"hello", "world"}), "parsing first level 1 arg command"); + assertDoesNotThrow(() -> rootNode.run(fakeServer, playerSource, "test", new String[]{"hello", "jroy", "pop", "lax", "evident"}), "parsing first level multi-arg command"); + assertDoesNotThrow(() -> rootNode.run(fakeServer, playerSource, "test", new String[]{"bye", "legacy", "code"})); + assertDoesNotThrow(() -> rootNode.run(fakeServer, consoleSource, "test", new String[]{"fAREWELL", "player", "data"}), "parsing with literal alias"); + assertDoesNotThrow(() -> rootNode.run(fakeServer, consoleSource, "test", new String[]{"bye", "forever", "just", "kidding"})); - assertDoesNotThrow(playerHelloNoArgs, "parsing first level no-arg command"); - assertDoesNotThrow(playerHelloOneArg, "parsing first level 1 arg command"); - assertDoesNotThrow(playerHelloManyArgs, "parsing first level multi-arg command"); - assertDoesNotThrow(playerBye); - assertDoesNotThrow(consoleFarewell, "parsing with literal alias"); - assertDoesNotThrow(consoleByeForeverJk); - - InOrder ordered = inOrder(playerSource, consoleSource); + final InOrder ordered = inOrder(playerSource, consoleSource); ordered.verify(playerSource).sendMessage("hello to who?"); ordered.verify(playerSource).sendMessage("hi there world"); ordered.verify(playerSource).sendMessage("woah hi jroy and pop and lax and evident"); ordered.verify(consoleSource).sendMessage("wait you can't leave"); ordered.verify(consoleSource).sendMessage(":(("); } -} \ No newline at end of file +}