diff --git a/src/main/java/net/minestom/server/command/CommandManager.java b/src/main/java/net/minestom/server/command/CommandManager.java index 3106c09c6..e6bd16e88 100644 --- a/src/main/java/net/minestom/server/command/CommandManager.java +++ b/src/main/java/net/minestom/server/command/CommandManager.java @@ -157,7 +157,6 @@ public final class CommandManager { * @return the {@link DeclareCommandsPacket} for {@code player} */ public @NotNull DeclareCommandsPacket createDeclareCommandsPacket(@NotNull Player player) { - final Graph merged = Graph.merge(dispatcher.getCommands()); - return GraphConverter.createPacket(merged); + return GraphBuilder.forPlayer(this.dispatcher.getCommands(), player).createPacket(); } } diff --git a/src/main/java/net/minestom/server/command/Graph.java b/src/main/java/net/minestom/server/command/Graph.java deleted file mode 100644 index 95abb434c..000000000 --- a/src/main/java/net/minestom/server/command/Graph.java +++ /dev/null @@ -1,53 +0,0 @@ -package net.minestom.server.command; - -import net.minestom.server.command.builder.Command; -import net.minestom.server.command.builder.arguments.Argument; -import org.jetbrains.annotations.NotNull; - -import java.util.Collection; -import java.util.List; -import java.util.function.Consumer; - -sealed interface Graph permits GraphImpl { - static @NotNull Builder builder(@NotNull Argument argument) { - return new GraphImpl.BuilderImpl(argument); - } - - static @NotNull Graph fromCommand(@NotNull Command command) { - return GraphImpl.fromCommand(command); - } - - static @NotNull Graph merge(@NotNull Collection<@NotNull Command> commands) { - return GraphImpl.merge(commands); - } - - static @NotNull Graph merge(@NotNull List<@NotNull Graph> graphs) { - return GraphImpl.merge(graphs); - } - - static @NotNull Graph merge(@NotNull Graph @NotNull ... graphs) { - return merge(List.of(graphs)); - } - - @NotNull Node root(); - - boolean compare(@NotNull Graph graph, @NotNull Comparator comparator); - - sealed interface Node permits GraphImpl.NodeImpl { - @NotNull Argument argument(); - - @NotNull List<@NotNull Node> next(); - } - - sealed interface Builder permits GraphImpl.BuilderImpl { - @NotNull Builder append(@NotNull Argument argument, @NotNull Consumer consumer); - - @NotNull Builder append(@NotNull Argument argument); - - @NotNull Graph build(); - } - - enum Comparator { - TREE - } -} diff --git a/src/main/java/net/minestom/server/command/GraphBuilder.java b/src/main/java/net/minestom/server/command/GraphBuilder.java new file mode 100644 index 000000000..2a2f6bbc9 --- /dev/null +++ b/src/main/java/net/minestom/server/command/GraphBuilder.java @@ -0,0 +1,204 @@ +package net.minestom.server.command; + +import it.unimi.dsi.fastutil.objects.ObjectOpenHashSet; +import it.unimi.dsi.fastutil.objects.ObjectSet; +import net.minestom.server.command.builder.Command; +import net.minestom.server.command.builder.CommandSyntax; +import net.minestom.server.command.builder.arguments.*; +import net.minestom.server.command.builder.condition.CommandCondition; +import net.minestom.server.command.builder.exception.IllegalCommandStructureException; +import net.minestom.server.entity.Player; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.util.HashSet; +import java.util.Optional; +import java.util.Set; +import java.util.Stack; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.Supplier; +import java.util.stream.Stream; + +final class GraphBuilder { + private final AtomicInteger idSource = new AtomicInteger(); + private final ObjectSet nodes = new ObjectOpenHashSet<>(); + private final ObjectSet> redirectWaitList = new ObjectOpenHashSet<>(); + private final Node root = rootNode(); + + private GraphBuilder() { + //no instance + } + + private Node rootNode() { + final Node rootNode = new Node(idSource.getAndIncrement()); + nodes.add(rootNode); + return rootNode; + } + + private Node createLiteralNode(String name, @Nullable Node parent, boolean executable, @Nullable String[] aliases, @Nullable Integer redirectTo) { + if (aliases != null) { + final Node node = createLiteralNode(name, parent, executable, null, null); + for (String alias : aliases) { + createLiteralNode(alias, parent, executable, null, node.id()); + } + return node; + } else { + final Node literalNode = new Node(idSource.getAndIncrement(), name, redirectTo); + literalNode.setExecutable(executable); + nodes.add(literalNode); + if (parent != null) parent.addChild(literalNode); + return literalNode; + } + } + + private Node[] createArgumentNode(Argument argument, boolean executable) { + final Node[] nodes; + Integer overrideRedirectTarget = null; + if (argument instanceof ArgumentEnum argumentEnum) { + nodes = argumentEnum.entries().stream().map(x -> createLiteralNode(x, null, executable, null, null)).toArray(Node[]::new); + } else if (argument instanceof ArgumentGroup argumentGroup) { + nodes = argumentGroup.group().stream().map(x -> createArgumentNode(x, executable)).flatMap(Stream::of).toArray(Node[]::new); + } else if (argument instanceof ArgumentLoop argumentLoop) { + overrideRedirectTarget = idSource.get()-1; + nodes = argumentLoop.arguments().stream().map(x -> createArgumentNode(x, executable)).flatMap(Stream::of).toArray(Node[]::new); + } else { + if (argument instanceof ArgumentCommand) { + return new Node[]{createLiteralNode(argument.getId(), null, false, null, 0)}; + } + final int id = idSource.getAndIncrement(); + nodes = new Node[] {argument instanceof ArgumentLiteral ? new Node(id, argument.getId(), null) : new Node(id, argument)}; + } + for (Node node : nodes) { + node.setExecutable(executable); + this.nodes.add(node); + Integer finalOverrideRedirectTarget = overrideRedirectTarget; + if (finalOverrideRedirectTarget != null) { + redirectWaitList.add(() -> { + int target = finalOverrideRedirectTarget; + if (target != -1) { + node.setRedirectTarget(target); + return true; + } + return false; + }); + } + } + return nodes; + } + + private int tryResolveId(String[] path) { + if (path.length == 0) { + return root.id(); + } else { + Node target = root; + for (String next : path) { + Node finalTarget = target; + final Optional result = nodes.stream().filter(finalTarget::isParentOf) + .filter(x -> x.name().equals(next)).findFirst(); + if (result.isEmpty()) { + return -1; + } else { + target = result.get(); + } + } + return target.id(); + } + } + + private void finalizeStructure() { + redirectWaitList.removeIf(Supplier::get); + if (redirectWaitList.size() > 0) + throw new IllegalCommandStructureException("Could not set redirects for all arguments! Did you provide a " + + "correct id path which doesn't rely on redirects?"); + } + + + /** + * Creates the nodes for the given command + * + * @param command the command to add + * @param parent where to append the command's root (literal) node + * @param player a player if we should filter commands + */ + private void createCommand(Command command, Node parent, @Nullable Player player) { + if (player != null) { + // Check if user can use the command + final CommandCondition condition = command.getCondition(); + if (condition != null && !condition.canUse(player, null)) return; + } + + // Create the command's root node + final Node cmdNode = createLiteralNode(command.getName(), parent, + command.getDefaultExecutor() != null, command.getAliases(), null); + + // Add syntax to the command + for (CommandSyntax syntax : command.getSyntaxes()) { + if (player != null) { + // Check if user can use the syntax + final CommandCondition condition = syntax.getCommandCondition(); + if (condition != null && !condition.canUse(player, null)) continue; + } + + boolean executable = false; + Node[] lastArgNodes = new Node[] {cmdNode}; // First arg links to cmd root + @NotNull Argument[] arguments = syntax.getArguments(); + for (int i = 0; i < arguments.length; i++) { + Argument argument = arguments[i]; + // Determine if command is executable here + if (executable && argument.getDefaultValue() == null) { + // Optional arg was followed by a non-optional + throw new IllegalCommandStructureException("Optional argument was followed by a non-optional one."); + } + if (!executable && i < arguments.length-1 && arguments[i+1].getDefaultValue() != null || i+1 == arguments.length) { + executable = true; + } + // Append current node to previous + final Node[] argNodes = createArgumentNode(argument, executable); + for (Node lastArgNode : lastArgNodes) { + lastArgNode.addChild(argNodes); + } + lastArgNodes = argNodes; + } + } + + // Add subcommands + for (Command subcommand : command.getSubcommands()) { + createCommand(subcommand, cmdNode, player); + } + } + + public static NodeGraph forPlayer(@NotNull Set commands, Player player) { + final GraphBuilder builder = new GraphBuilder(); + + if (GraphBuilder.class.desiredAssertionStatus()) { + // Detect infinite recursion + for (Command command : commands) { + final HashSet processed = new HashSet<>(); + final Stack stack = new Stack<>(); + stack.push(command); + while (!stack.isEmpty()) { + final Command pop = stack.pop(); + if (!processed.add(pop)) { + throw new IllegalCommandStructureException("Infinite recursion detected in command: "+command.getName()); + } else { + stack.addAll(pop.getSubcommands()); + } + } + + builder.createCommand(command, builder.root, player); + } + } else { + for (Command command : commands) { + builder.createCommand(command, builder.root, player); + } + } + + builder.finalizeStructure(); + + return new NodeGraph(builder.nodes, builder.root.id()); + } + + public static NodeGraph forServer(@NotNull Set commands) { + return forPlayer(commands, null); + } +} diff --git a/src/main/java/net/minestom/server/command/GraphConverter.java b/src/main/java/net/minestom/server/command/GraphConverter.java deleted file mode 100644 index ab0ba19f1..000000000 --- a/src/main/java/net/minestom/server/command/GraphConverter.java +++ /dev/null @@ -1,158 +0,0 @@ -package net.minestom.server.command; - -import net.minestom.server.command.builder.arguments.*; -import net.minestom.server.network.packet.server.play.DeclareCommandsPacket; -import org.jetbrains.annotations.Contract; -import org.jetbrains.annotations.Nullable; - -import java.util.ArrayList; -import java.util.Arrays; -import java.util.List; -import java.util.concurrent.atomic.AtomicInteger; -import java.util.function.Consumer; - -final class GraphConverter { - private GraphConverter() { - //no instance - } - - @Contract("_ -> new") - public static DeclareCommandsPacket createPacket(Graph graph) { - List nodes = new ArrayList<>(); - List> rootRedirect = new ArrayList<>(); - final AtomicInteger idSource = new AtomicInteger(0); - final int rootId = append(graph.root(), nodes, rootRedirect, idSource, null, null)[0]; - for (var i : rootRedirect) { - i.accept(rootId); - } - return new DeclareCommandsPacket(nodes, rootId); - } - - private static int[] append(Graph.Node graphNode, List to, - List> rootRedirect, AtomicInteger id, @Nullable AtomicInteger redirect, - List redirectSetters) { - final Argument argument = graphNode.argument(); - final List children = graphNode.next(); - - final DeclareCommandsPacket.Node node = new DeclareCommandsPacket.Node(); - int[] packetNodeChildren = new int[children.size()]; - for (int i = 0; i < packetNodeChildren.length; i++) { - final int[] append = append(children.get(i), to, rootRedirect, id, redirect, redirectSetters); - if (append.length == 1) { - packetNodeChildren[i] = append[0]; - } else { - packetNodeChildren = Arrays.copyOf(packetNodeChildren, packetNodeChildren.length+append.length-1); - System.arraycopy(append, 0, packetNodeChildren, i, append.length); - i += append.length; - } - } - node.children = packetNodeChildren; - if (argument instanceof ArgumentLiteral literal) { - if (literal.getId().isEmpty()) { - node.flags = 0; //root - } else { - node.flags = literal(false, false); - node.name = argument.getId(); - if (redirect != null) { - node.flags |= 0x8; - redirectSetters.add(() -> node.redirectedNode = redirect.get()); - } - } - to.add(node); - return new int[]{id.getAndIncrement()}; - } else { - if (argument instanceof ArgumentCommand) { - node.flags = literal(false, true); - node.name = argument.getId(); - rootRedirect.add(i -> node.redirectedNode = i); - to.add(node); - - return new int[]{id.getAndIncrement()}; - } else if (argument instanceof ArgumentEnum || (argument instanceof ArgumentWord word && word.hasRestrictions())) { - List entries = argument instanceof ArgumentEnum ? ((ArgumentEnum) argument).entries() : Arrays.stream(((ArgumentWord) argument).getRestrictions()).toList(); - final int[] res = new int[entries.size()]; - for (int i = 0; i < res.length; i++) { - String entry = entries.get(i); - final DeclareCommandsPacket.Node subNode = new DeclareCommandsPacket.Node(); - subNode.children = node.children; - subNode.flags = literal(false, false); - subNode.name = entry; - if (redirect != null) { - subNode.flags |= 0x8; - redirectSetters.add(() -> subNode.redirectedNode = redirect.get()); - } - to.add(subNode); - res[i] = id.getAndIncrement(); - } - return res; - } else if (argument instanceof ArgumentGroup special) { - List> entries = special.group(); - int[] res = null; - int[] last = new int[0]; - for (int i = 0; i < entries.size(); i++) { - Argument entry = entries.get(i); - if (i == entries.size()-1) { - // Last will be the parent of next args - final int[] l = append(new GraphImpl.NodeImpl(entry, List.of()), to, rootRedirect, id, redirect, redirectSetters); - for (int n : l) { - to.get(n).children = node.children; - } - for (int n : last) { - to.get(n).children = l; - } - return res == null ? l : res; - } else if (i == 0) { - // First will be the children & parent of following - res = append(new GraphImpl.NodeImpl(entry, List.of()), to, rootRedirect, id, null, redirectSetters); - last = res; - } else { - final int[] l = append(new GraphImpl.NodeImpl(entry, List.of()), to, rootRedirect, id, null, redirectSetters); - for (int n : last) { - to.get(n).children = l; - } - last = l; - } - } - throw new RuntimeException("Arg group must have child args."); - } else if (argument instanceof ArgumentLoop special) { - AtomicInteger r = new AtomicInteger(); - List setters = new ArrayList<>(); - int[] res = new int[special.arguments().size()]; - List arguments = special.arguments(); - for (int i = 0; i < arguments.size(); i++) { - Object arg = arguments.get(i); - final int[] append = append(new GraphImpl.NodeImpl((Argument) arg, List.of()), to, rootRedirect, id, r, setters); - if (append.length == 1) { - res[i] = append[0]; - } else { - res = Arrays.copyOf(res, res.length+append.length-1); - System.arraycopy(append, 0, res, i, append.length); - i += append.length; - } - } - r.set(id.get()); - setters.forEach(Runnable::run); - return res; - } else { - node.flags = arg(false, argument.hasSuggestion()); - node.name = argument.getId(); - node.parser = argument.parser(); - node.properties = argument.nodeProperties(); - if (redirect != null) { - node.flags |= 0x8; - redirectSetters.add(() -> node.redirectedNode = redirect.get()); - } - to.add(node); - return new int[]{id.getAndIncrement()}; - } - } - } - - private static byte literal(boolean executable, boolean hasRedirect) { - return DeclareCommandsPacket.getFlag(DeclareCommandsPacket.NodeType.LITERAL, executable, hasRedirect, false); - } - - private static byte arg(boolean executable, boolean hasSuggestion) { - return DeclareCommandsPacket.getFlag(DeclareCommandsPacket.NodeType.ARGUMENT, executable, false, hasSuggestion); - } -} diff --git a/src/main/java/net/minestom/server/command/GraphImpl.java b/src/main/java/net/minestom/server/command/GraphImpl.java deleted file mode 100644 index d02de4225..000000000 --- a/src/main/java/net/minestom/server/command/GraphImpl.java +++ /dev/null @@ -1,107 +0,0 @@ -package net.minestom.server.command; - -import net.minestom.server.command.builder.Command; -import net.minestom.server.command.builder.arguments.Argument; -import org.jetbrains.annotations.NotNull; - -import java.util.*; -import java.util.function.Consumer; - -import static net.minestom.server.command.builder.arguments.ArgumentType.Literal; - -record GraphImpl(NodeImpl root) implements Graph { - static GraphImpl fromCommand(Command command) { - return new GraphImpl(NodeImpl.command(command)); - } - - static Graph merge(Collection commands) { - return new GraphImpl(NodeImpl.rootCommands(commands)); - } - - static GraphImpl merge(List graphs) { - final List children = graphs.stream().map(Graph::root).toList(); - final NodeImpl root = new NodeImpl(Literal(""), children); - return new GraphImpl(root); - } - - @Override - public boolean compare(@NotNull Graph graph, @NotNull Comparator comparator) { - // We currently do not include execution data in the graph - return equals(graph); - } - - record BuilderImpl(Argument argument, List children) implements Graph.Builder { - public BuilderImpl(Argument argument) { - this(argument, new ArrayList<>()); - } - - @Override - public Graph.@NotNull Builder append(@NotNull Argument argument, @NotNull Consumer consumer) { - BuilderImpl builder = new BuilderImpl(argument); - consumer.accept(builder); - this.children.add(builder); - return this; - } - - @Override - public Graph.@NotNull Builder append(@NotNull Argument argument) { - this.children.add(new BuilderImpl(argument, List.of())); - return this; - } - - @Override - public @NotNull GraphImpl build() { - return new GraphImpl(NodeImpl.fromBuilder(this)); - } - } - - record NodeImpl(Argument argument, List next) implements Graph.Node { - static NodeImpl fromBuilder(BuilderImpl builder) { - final List children = builder.children; - Node[] nodes = new NodeImpl[children.size()]; - for (int i = 0; i < children.size(); i++) nodes[i] = fromBuilder(children.get(i)); - return new NodeImpl(builder.argument, List.of(nodes)); - } - - static NodeImpl command(Command command) { - return ConversionNode.fromCommand(command).toNode(); - } - - static NodeImpl rootCommands(Collection commands) { - return ConversionNode.rootConv(commands).toNode(); - } - } - - private record ConversionNode(Argument argument, Map, ConversionNode> nextMap) { - ConversionNode(Argument argument) { - this(argument, new LinkedHashMap<>()); - } - - private NodeImpl toNode() { - Node[] nodes = new NodeImpl[nextMap.size()]; - int i = 0; - for (var entry : nextMap.values()) nodes[i++] = entry.toNode(); - return new NodeImpl(argument, List.of(nodes)); - } - - static ConversionNode fromCommand(Command command) { - ConversionNode root = new ConversionNode(Literal(command.getName())); - for (var syntax : command.getSyntaxes()) { - ConversionNode syntaxNode = root; - for (Argument arg : syntax.getArguments()) { - syntaxNode = syntaxNode.nextMap.computeIfAbsent(arg, ConversionNode::new); - } - } - return root; - } - - static ConversionNode rootConv(Collection commands) { - Map, ConversionNode> next = new LinkedHashMap<>(commands.size()); - for (Command command : commands) { - final ConversionNode conv = fromCommand(command); - next.put(conv.argument, conv); - } - return new ConversionNode(Literal(""), next); - } - } -} diff --git a/src/main/java/net/minestom/server/command/Node.java b/src/main/java/net/minestom/server/command/Node.java new file mode 100644 index 000000000..be4db7bc0 --- /dev/null +++ b/src/main/java/net/minestom/server/command/Node.java @@ -0,0 +1,105 @@ +package net.minestom.server.command; + +import it.unimi.dsi.fastutil.ints.IntOpenHashSet; +import it.unimi.dsi.fastutil.ints.IntSet; +import it.unimi.dsi.fastutil.ints.IntSets; +import net.minestom.server.command.builder.arguments.Argument; +import net.minestom.server.network.packet.server.play.DeclareCommandsPacket; + +final class Node { + private final int id; + private final IntSet children = new IntOpenHashSet(); + private final IntSet childrenView = IntSets.unmodifiable(children); + private final DeclareCommandsPacket.NodeType type; + private String name; + private Integer redirectTarget; + private Argument argument; + private boolean executable; + + Node(int id, DeclareCommandsPacket.NodeType type) { + this.id = id; + this.type = type; + } + + Node(int id) { + this(id, DeclareCommandsPacket.NodeType.ROOT); + } + + Node(int id, String name, Integer redirectTarget) { + this(id, DeclareCommandsPacket.NodeType.LITERAL); + setName(name); + setRedirectTarget(redirectTarget); + } + + Node(int id, Argument argument) { + this(id, DeclareCommandsPacket.NodeType.ARGUMENT); + setName(argument.getId()); + this.argument = argument; + } + + public void setExecutable(boolean executable) { + this.executable = executable; + } + + public String name() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public void setRedirectTarget(Integer redirectTarget) { + this.redirectTarget = redirectTarget; + } + + public void addChild(Node ...nodes) { + for (Node node : nodes) { + children.add(node.id); + } + } + + public boolean isParentOf(Node node) { + return children.contains(node.id()); + } + + public int id() { + return id; + } + + public DeclareCommandsPacket.NodeType type() { + return type; + } + + public IntSet children() { + return childrenView; + } + + public Integer redirectTarget() { + return redirectTarget; + } + + public boolean isRoot() { + return type == DeclareCommandsPacket.NodeType.ROOT; + } + + public DeclareCommandsPacket.Node getPacketNode() { + final DeclareCommandsPacket.Node node = new DeclareCommandsPacket.Node(); + node.children = children.toIntArray(); + node.flags = DeclareCommandsPacket.getFlag(type, executable, redirectTarget != null, + type == DeclareCommandsPacket.NodeType.ARGUMENT && argument.hasSuggestion()); + node.name = name; + if (redirectTarget != null) { + node.redirectedNode = redirectTarget; + } + if (type == DeclareCommandsPacket.NodeType.ARGUMENT) { + node.properties = argument.nodeProperties(); + node.parser = argument.parser(); + if (argument.hasSuggestion()) { + //noinspection ConstantConditions + node.suggestionsType = argument.suggestionType().getIdentifier(); + } + } + return node; + } +} diff --git a/src/main/java/net/minestom/server/command/NodeGraph.java b/src/main/java/net/minestom/server/command/NodeGraph.java new file mode 100644 index 000000000..548b9403b --- /dev/null +++ b/src/main/java/net/minestom/server/command/NodeGraph.java @@ -0,0 +1,41 @@ +package net.minestom.server.command; + +import it.unimi.dsi.fastutil.objects.ObjectImmutableList; +import it.unimi.dsi.fastutil.objects.ObjectList; +import it.unimi.dsi.fastutil.objects.ObjectSet; +import net.minestom.server.network.packet.server.play.DeclareCommandsPacket; +import org.jetbrains.annotations.Contract; +import org.jetbrains.annotations.Nullable; + +import java.util.Comparator; +import java.util.List; + +class NodeGraph { + private final ObjectList nodes; + private final Node root; + + NodeGraph(ObjectSet nodes, int rootId) { + this.nodes = new ObjectImmutableList<>(nodes.stream().sorted(Comparator.comparing(Node::id)).toList()); + this.root = this.nodes.get(rootId); + assert root.isRoot() : "rootId doesn't point to the root node"; + assert this.nodes.stream().filter(Node::isRoot).count() == 1 : "Invalid root node count!"; + } + + public Node resolveId(int id) { + return nodes.get(id); + } + + public List getChildren(Node node) { + return node.children().intStream().mapToObj(this::resolveId).toList(); + } + + public @Nullable Node getRedirectTarget(Node node) { + final Integer target = node.redirectTarget(); + return target == null ? null : resolveId(target); + } + + @Contract("-> new") + public DeclareCommandsPacket createPacket() { + return new DeclareCommandsPacket(nodes.stream().map(Node::getPacketNode).toList(), root.id()); + } +} diff --git a/src/test/java/net/minestom/server/command/CommandPacketTest.java b/src/test/java/net/minestom/server/command/CommandPacketTest.java deleted file mode 100644 index 83ff570ce..000000000 --- a/src/test/java/net/minestom/server/command/CommandPacketTest.java +++ /dev/null @@ -1,437 +0,0 @@ -package net.minestom.server.command; - -import net.minestom.server.command.builder.Command; -import net.minestom.server.command.builder.CommandContext; -import net.minestom.server.command.builder.arguments.ArgumentType; -import net.minestom.server.network.packet.server.play.DeclareCommandsPacket; -import org.jetbrains.annotations.NotNull; -import org.junit.jupiter.api.Test; -import org.opentest4j.AssertionFailedError; - -import java.math.BigInteger; -import java.util.*; -import java.util.concurrent.atomic.AtomicReference; -import java.util.function.Function; -import java.util.stream.Collectors; - -import static org.junit.jupiter.api.Assertions.*; - -public class CommandPacketTest { - @Test - public void singleCommandWithOneSyntax() { - final Command foo = new Command("foo"); - foo.addSyntax(CommandPacketTest::dummyExecutor, ArgumentType.Integer("bar")); - - final DeclareCommandsPacket packet = GraphConverter.createPacket(Graph.merge(Graph.fromCommand(foo))); - assertEquals(3, packet.nodes().size()); - final DeclareCommandsPacket.Node root = packet.nodes().get(packet.rootIndex()); - assertNotNull(root); - assertNodeType(DeclareCommandsPacket.NodeType.ROOT, root.flags); - assertEquals(1, root.children.length); - final DeclareCommandsPacket.Node cmd = packet.nodes().get(root.children[0]); - assertNotNull(cmd); - assertNodeType(DeclareCommandsPacket.NodeType.LITERAL, cmd.flags); - assertEquals(1, cmd.children.length); - assertEquals("foo", cmd.name); - final DeclareCommandsPacket.Node arg = packet.nodes().get(cmd.children[0]); - assertNotNull(arg); - assertNodeType(DeclareCommandsPacket.NodeType.ARGUMENT, arg.flags); - assertEquals(0, arg.children.length); - assertEquals("bar", arg.name); - } - - @Test - public void executeLike() { - enum Dimension {OVERWORLD, THE_NETHER, THE_END} - final Command execute = new Command("execute"); - execute.addSyntax(CommandPacketTest::dummyExecutor, ArgumentType.Loop("params", - ArgumentType.Group("facing", ArgumentType.Literal("facing"), ArgumentType.RelativeVec3("pos")), - ArgumentType.Group("at", ArgumentType.Literal("at"), ArgumentType.Entity("targets")), - ArgumentType.Group("as", ArgumentType.Literal("as"), ArgumentType.Entity("targets")), - ArgumentType.Group("in", ArgumentType.Literal("in"), ArgumentType.Enum("dimension", Dimension.class)), - ArgumentType.Group("run", ArgumentType.Command("run")) - )); - var graph = Graph.fromCommand(execute); - assertPacketGraph(""" - execute facing at as in run=% - overworld the_nether the_end=§ - 0->execute - atEnt asEnt=targets minecraft:entity 0 - execute->facing at as in run - at->atEnt - as->asEnt - in->overworld the_nether the_end - pos=! minecraft:vec3 - facing->pos - pos atEnt asEnt overworld the_nether the_end+>execute - run+>0 - """, graph); - } - - @Test - public void singleCommandTwoEnum() { - var graph = Graph.builder(ArgumentType.Literal("foo")) - .append(ArgumentType.Enum("bar", A.class), b -> b.append(ArgumentType.Enum("baz", B.class))) - .build(); - assertPacketGraph(""" - foo=% - a b c d e f=§ - 0->foo - foo->a b c - a b c->d e f - """, graph); - } - - @Test - public void singleCommandRestrictedWord() { - var graph = Graph.builder(ArgumentType.Literal("foo")) - .append(ArgumentType.Word("bar").from("A", "B", "C")) - .build(); - assertPacketGraph(""" - foo=% - a b c=§ - 0->foo - foo->a b c - """, graph); - } - - @Test - public void singleCommandWord() { - var graph = Graph.builder(ArgumentType.Literal("foo")) - .append(ArgumentType.Word("bar")) - .build(); - assertPacketGraph(""" - foo=% - bar=! brigadier:string 0 - 0->foo - foo->bar - """, graph); - } - - @Test - public void singleCommandCommandAfterEnum() { - var graph = Graph.builder(ArgumentType.Literal("foo")) - .append(ArgumentType.Enum("bar", A.class), b -> b.append(ArgumentType.Command("baz"))) - .build(); - assertPacketGraph(""" - foo baz=% - a b c=§ - 0->foo - foo->a b c - a b c->baz - baz+>0 - """, graph); - } - - @Test - public void twoCommandIntEnumInt() { - var graph = Graph.builder(ArgumentType.Literal("foo")) - .append(ArgumentType.Integer("int1"), b -> b.append(ArgumentType.Enum("test", A.class), c -> c.append(ArgumentType.Integer("int2")))) - .build(); - var graph2 = Graph.builder(ArgumentType.Literal("bar")) - .append(ArgumentType.Integer("int3"), b -> b.append(ArgumentType.Enum("test", B.class), c -> c.append(ArgumentType.Integer("int4")))) - .build(); - assertPacketGraph(""" - foo bar=% - 0->foo bar - a b c d e f=§ - int1 int2 int3 int4=! brigadier:integer 0 - foo->int1 - bar->int3 - int1->a b c - int3->d e f - a b c->int2 - d e f->int4 - """, graph, graph2); - } - - @Test - public void singleCommandTwoGroupOfIntInt() { - var graph = Graph.builder(ArgumentType.Literal("foo")) - .append(ArgumentType.Group("1", ArgumentType.Integer("int1"), ArgumentType.Integer("int2")), - b -> b.append(ArgumentType.Group("2", ArgumentType.Integer("int3"), ArgumentType.Integer("int4")))) - .build(); - assertPacketGraph(""" - foo=% - int1 int2 int3 int4=! brigadier:integer 0 - 0->foo - foo->int1 - int1->int2 - int2->int3 - int3->int4 - """, graph); - } - - static void assertPacketGraph(String expected, Graph... graphs) { - var packet = GraphConverter.createPacket(Graph.merge(graphs)); - final List expectedList = fromString("0\n0=$root$\n" + expected); - final List actualList = fromString(packetToString(packet)); - try { - assertEquals(expectedList.size(), actualList.size(), "Different node counts"); - assertTrue(actualList.containsAll(expectedList), "Packet doesn't contain all expected nodes."); - } catch (AssertionFailedError error) { - fail("Graphs didn't match. Actual graph from packet: " + exportGarphvizDot(packet, false)); - } - } - - private static String exportGarphvizDot(DeclareCommandsPacket packet, boolean prettyPrint) { - final StringBuilder builder = new StringBuilder(); - final char statementSeparator = ';'; - builder.append("digraph G {"); - builder.append("rankdir=LR"); - builder.append(statementSeparator); - builder.append(packet.rootIndex()); - builder.append(" [label=\"root\",shape=rectangle]"); - builder.append(statementSeparator); - @NotNull List nodes = packet.nodes(); - for (int i = 0; i < nodes.size(); i++) { - DeclareCommandsPacket.Node node = nodes.get(i); - if ((node.flags & 0x3) != 0) { - builder.append(i); - builder.append(" [label="); - builder.append('"'); - if ((node.flags & 0x3) == 1) { - builder.append("'"); - builder.append(node.name); - builder.append("'"); - } else { - builder.append(node.name); - } - builder.append('"'); - if ((node.flags & 0x4) == 0x4) { - builder.append(",bgcolor=gray,style=filled"); - } - builder.append("]"); - builder.append(statementSeparator); - } - if (node.children.length == 0 && (node.flags & 0x8) == 0) continue; - builder.append(i); - builder.append(" -> { "); - if ((node.flags & 0x8) == 0) { - builder.append(Arrays.stream(node.children).mapToObj(Integer::toString).collect(Collectors.joining(" "))); - builder.append(" }"); - builder.append(statementSeparator); - } else { - builder.append(node.redirectedNode); - builder.append(" } [style = dotted]"); - builder.append(statementSeparator); - } - } - builder.append("}"); - if (prettyPrint) - return builder.toString() - .replaceFirst("\\{r", "{\n r") - .replaceAll(";", "\n ") - .replaceFirst(" {2}}$", "}\n"); - else - return builder.toString(); - } - - private record TestNode(List children, String meta, AtomicReference redirect) { - @Override - public boolean equals(Object obj) { - if (obj instanceof TestNode that) { - return this.meta.equals(that.meta) && Objects.equals(this.redirect.get(), that.redirect.get()) && - this.children.containsAll(that.children) && this.children.size() == that.children.size(); - } else { - return false; - } - } - } - - private static String packetToString(DeclareCommandsPacket packet) { - final char lineSeparator = '\n'; - final StringBuilder builder = new StringBuilder(); - builder.append(packet.rootIndex()); - builder.append(lineSeparator); - @NotNull List nodes = packet.nodes(); - for (int i = 0; i < nodes.size(); i++) { - DeclareCommandsPacket.Node node = nodes.get(i); - builder.append(i); - builder.append('='); - // Meta - if ((node.flags & 0x3) == 0) { - builder.append("$root$"); - } else { - if ((node.flags & 0x3) == 1) { - builder.append("'"); - builder.append(node.name); - builder.append("'"); - } else { - builder.append(node.name); - builder.append(' '); - builder.append(node.parser); - - if (node.properties != null) { - builder.append(' '); - builder.append(new BigInteger(node.properties).toString(16)); - } - } - } - if ((node.flags & 0x4) == 0x4) { - builder.append(" executable"); - } - if ((node.flags & 0x10) == 0x10) { - builder.append(' '); - builder.append(node.suggestionsType); - } - builder.append(lineSeparator); - if (node.children.length > 0) { - builder.append(i); - builder.append("->"); - builder.append(Arrays.stream(node.children).mapToObj(String::valueOf).collect(Collectors.joining(" "))); - builder.append(lineSeparator); - } - if ((node.flags & 0x8) == 0x8) { - builder.append(i); - builder.append("+>"); - builder.append(node.redirectedNode); - builder.append(lineSeparator); - } - } - return builder.toString(); - } - - - private static final Map>> functions = Map.of( - '!', s -> { - final String[] strings = splitDeclaration(s); - final ArrayList result = new ArrayList<>(); - for (String s1 : strings[0].split(" ")) { - result.add(s1+"="+(strings[1].replaceAll("!", s1))); - } - return result; - }, - '%', s -> { - final String[] strings = splitDeclaration(s); - final ArrayList result = new ArrayList<>(); - for (String s1 : strings[0].split(" ")) { - result.add(s1+"="+(strings[1].replaceAll("%", "'"+s1+"'"))); - } - return result; - }, - '§', s -> { - final String[] strings = splitDeclaration(s); - final ArrayList result = new ArrayList<>(); - for (String s1 : strings[0].split(" ")) { - result.add(s1+"="+(strings[1].replaceAll("§", "'"+(s1.toUpperCase(Locale.ROOT))+"'"))); - } - return result; - } - ); - private static final Set placeholders = functions.keySet(); - - private static String[] splitDeclaration(String input) { - return input.split("=", 2); - } - - private static List preProcessString(String string) { - final List strings = Arrays.stream(string.split("\n")).toList(); - final ArrayList result = new ArrayList<>(); - for (String s : strings) { - if (s.indexOf('=') > -1) { - boolean match = false; - for (Character placeholder : placeholders) { - if (s.indexOf(placeholder) > -1) { - result.addAll(functions.get(placeholder).apply(s)); - match = true; - break; - } - } - if (!match) { - final int spaceIndex = s.indexOf(" "); - if (spaceIndex > -1 && spaceIndex < s.indexOf('=')) { - final String[] split = s.split("=", 2); - for (String s1 : split[0].split(" ")) { - result.add(s1+"="+split[1]); - } - } else { - result.add(s); - } - } - } else { - final int spaceIndex = s.indexOf(" "); - if (spaceIndex > -1 && spaceIndex < s.indexOf('-')) { - final String[] split = s.split("-", 2); - for (String s1 : split[0].split(" ")) { - result.add(s1+"-"+split[1]); - } - } else if (spaceIndex > -1 && spaceIndex < s.indexOf('+')) { - final String[] split = s.split("\\+", 2); - for (String s1 : split[0].split(" ")) { - result.add(s1+"+"+split[1]); - } - } else { - result.add(s); - } - } - } - return result; - } - - private static List fromString(String input) { - Map references = new HashMap<>(); - Map nodes = new HashMap<>(); - final List strings = preProcessString(input); - String rootId = strings.get(0); - - for (String s : strings.stream().skip(0).toList()) { - if (s.length() < 3) continue; //invalid line - final int declareSeparator = s.indexOf('='); - if (declareSeparator > -1) { - final String id = s.substring(0, declareSeparator); - final String meta = s.substring(declareSeparator + 1); - nodes.put(id, new TestNode(new ArrayList<>(), meta, new AtomicReference<>())); - } else { - final int childSeparator = s.indexOf('-'); - if (childSeparator > -1) { - references.put(s.substring(0, childSeparator), s.substring(childSeparator + 2).split(" ")); - } else { - final int redirectSeparator = s.indexOf('+'); - references.put(s.substring(0, redirectSeparator), new String[]{null, s.substring(redirectSeparator + 2)}); - } - } - } - final ArrayList result = new ArrayList<>(); - List redirectSetters = new ArrayList<>(); - resolveNode(rootId, references, nodes, result, new HashMap<>(), redirectSetters, ""); - redirectSetters.forEach(Runnable::run); - return result; - } - - private static String resolveNode(String id, Map references, - Map nodes, ArrayList result, - Map nameToMetaPath, - List redirectSetters, String metaPath) { - final TestNode node = nodes.get(id); - final String[] refs = references.get(id); - final String path = metaPath + "#" + node.meta; - if (refs == null) { - result.add(node); - nameToMetaPath.put(id, path); - return path; - } else if (refs[0] == null) { - redirectSetters.add(() -> node.redirect.set(nameToMetaPath.get(refs[1]))); - } else { - for (String ref : refs) { - node.children.add(resolveNode(ref, references, nodes, result, nameToMetaPath, redirectSetters, path)); - } - } - result.add(node); - nameToMetaPath.put(id, path); - return path; - } - - enum A {A, B, C} - - enum B {D, E, F} - - enum C {G, H, I, J, K} - - private static void assertNodeType(DeclareCommandsPacket.NodeType expected, byte flags) { - assertEquals(expected, DeclareCommandsPacket.NodeType.values()[flags & 0x03]); - } - - private static void dummyExecutor(CommandSender sender, CommandContext context) { - } -} diff --git a/src/test/java/net/minestom/server/command/GraphCommandConversionTest.java b/src/test/java/net/minestom/server/command/GraphCommandConversionTest.java deleted file mode 100644 index c2d410f0f..000000000 --- a/src/test/java/net/minestom/server/command/GraphCommandConversionTest.java +++ /dev/null @@ -1,93 +0,0 @@ -package net.minestom.server.command; - -import net.minestom.server.command.builder.Command; -import net.minestom.server.command.builder.CommandContext; -import org.junit.jupiter.api.Test; - -import static net.minestom.server.command.builder.arguments.ArgumentType.Enum; -import static net.minestom.server.command.builder.arguments.ArgumentType.Integer; -import static net.minestom.server.command.builder.arguments.ArgumentType.*; -import static org.junit.jupiter.api.Assertions.assertTrue; - -public class GraphCommandConversionTest { - @Test - public void empty() { - final Command foo = new Command("foo"); - var graph = Graph.builder(Literal("foo")).build(); - assertEqualsGraph(graph, Graph.fromCommand(foo)); - } - - @Test - public void singleLiteral() { - final Command foo = new Command("foo"); - var first = Literal("first"); - foo.addSyntax(GraphCommandConversionTest::dummyExecutor, first); - var graph = Graph.builder(Literal("foo")) - .append(first).build(); - assertEqualsGraph(graph, Graph.fromCommand(foo)); - } - - @Test - public void literalsPath() { - final Command foo = new Command("foo"); - var first = Literal("first"); - var second = Literal("second"); - - foo.addSyntax(GraphCommandConversionTest::dummyExecutor, first); - foo.addSyntax(GraphCommandConversionTest::dummyExecutor, second); - - var graph = Graph.builder(Literal("foo")) - .append(first).append(second) - .build(); - assertEqualsGraph(graph, Graph.fromCommand(foo)); - } - - @Test - public void doubleSyntax() { - enum A {A, B, C, D, E} - final Command foo = new Command("foo"); - - var bar = Literal("bar"); - - var baz = Literal("baz"); - var a = Enum("a", A.class); - - foo.addSyntax(GraphCommandConversionTest::dummyExecutor, bar); - foo.addSyntax(GraphCommandConversionTest::dummyExecutor, baz, a); - - var graph = Graph.builder(Literal("foo")) - .append(bar) - .append(baz, builder -> - builder.append(a)) - .build(); - assertEqualsGraph(graph, Graph.fromCommand(foo)); - } - - @Test - public void doubleSyntaxMerge() { - final Command foo = new Command("foo"); - - var bar = Literal("bar"); - var number = Integer("number"); - - foo.addSyntax(GraphCommandConversionTest::dummyExecutor, bar); - foo.addSyntax(GraphCommandConversionTest::dummyExecutor, bar, number); - - // The two syntax shall start from the same node - var graph = Graph.builder(Literal("foo")) - .append(bar, builder -> builder.append(number)) - .build(); - assertEqualsGraph(graph, Graph.fromCommand(foo)); - } - - private static void assertEqualsGraph(Graph expected, Graph actual) { - assertTrue(expected.compare(actual, Graph.Comparator.TREE), () -> { - System.out.println("Expected: " + expected); - System.out.println("Actual: " + actual); - return ""; - }); - } - - private static void dummyExecutor(CommandSender sender, CommandContext context) { - } -} diff --git a/src/test/java/net/minestom/server/command/GraphMergeTest.java b/src/test/java/net/minestom/server/command/GraphMergeTest.java deleted file mode 100644 index d257c0bd8..000000000 --- a/src/test/java/net/minestom/server/command/GraphMergeTest.java +++ /dev/null @@ -1,53 +0,0 @@ -package net.minestom.server.command; - -import net.minestom.server.command.builder.Command; -import org.junit.jupiter.api.Test; - -import java.util.List; - -import static net.minestom.server.command.builder.arguments.ArgumentType.Literal; -import static org.junit.jupiter.api.Assertions.assertTrue; - -public class GraphMergeTest { - - @Test - public void commands() { - var foo = new Command("foo"); - var bar = new Command("bar"); - var result = Graph.builder(Literal("")) - .append(Literal("foo")) - .append(Literal("bar")) - .build(); - assertEqualsGraph(result, Graph.merge(List.of(foo, bar))); - } - - @Test - public void empty() { - var graph1 = Graph.builder(Literal("foo")).build(); - var graph2 = Graph.builder(Literal("bar")).build(); - var result = Graph.builder(Literal("")) - .append(Literal("foo")) - .append(Literal("bar")) - .build(); - assertEqualsGraph(result, Graph.merge(graph1, graph2)); - } - - @Test - public void literals() { - var graph1 = Graph.builder(Literal("foo")).append(Literal("1")).build(); - var graph2 = Graph.builder(Literal("bar")).append(Literal("2")).build(); - var result = Graph.builder(Literal("")) - .append(Literal("foo"), builder -> builder.append(Literal("1"))) - .append(Literal("bar"), builder -> builder.append(Literal("2"))) - .build(); - assertEqualsGraph(result, Graph.merge(graph1, graph2)); - } - - private static void assertEqualsGraph(Graph expected, Graph actual) { - assertTrue(expected.compare(actual, Graph.Comparator.TREE), () -> { - System.out.println("Expected: " + expected); - System.out.println("Actual: " + actual); - return ""; - }); - } -} diff --git a/src/test/java/net/minestom/server/command/GraphTest.java b/src/test/java/net/minestom/server/command/GraphTest.java deleted file mode 100644 index 388768e3b..000000000 --- a/src/test/java/net/minestom/server/command/GraphTest.java +++ /dev/null @@ -1,79 +0,0 @@ -package net.minestom.server.command; - -import net.minestom.server.command.builder.Command; -import net.minestom.server.command.builder.CommandContext; -import org.junit.jupiter.api.Test; - -import java.util.List; - -import static net.minestom.server.command.builder.arguments.ArgumentType.Literal; -import static org.junit.jupiter.api.Assertions.*; - -public class GraphTest { - @Test - public void empty() { - var result = Graph.builder(Literal("")) - .build(); - var node = result.root(); - assertEquals(Literal(""), node.argument()); - assertTrue(node.next().isEmpty()); - } - - @Test - public void next() { - var result = Graph.builder(Literal("")) - .append(Literal("foo")) - .build(); - var node = result.root(); - assertEquals(Literal(""), node.argument()); - assertEquals(1, node.next().size()); - assertEquals(Literal("foo"), node.next().get(0).argument()); - } - - @Test - public void immutableNextBuilder() { - var result = Graph.builder(Literal("")) - .append(Literal("foo")) - .append(Literal("bar")) - .build(); - var node = result.root(); - assertThrows(Exception.class, () -> result.root().next().add(node)); - assertThrows(Exception.class, () -> result.root().next().get(0).next().add(node)); - } - - @Test - public void immutableNextCommand() { - final Command foo = new Command("foo"); - var first = Literal("first"); - foo.addSyntax(GraphTest::dummyExecutor, first); - var result = Graph.fromCommand(foo); - - var node = result.root(); - assertThrows(Exception.class, () -> result.root().next().add(node)); - assertThrows(Exception.class, () -> result.root().next().get(0).next().add(node)); - } - - @Test - public void immutableNextCommands() { - final Command foo, bar; - - { - var first = Literal("first"); - - foo = new Command("foo"); - foo.addSyntax(GraphTest::dummyExecutor, first); - - bar = new Command("foo"); - bar.addSyntax(GraphTest::dummyExecutor, first); - } - - var result = Graph.merge(List.of(foo, bar)); - - var node = result.root(); - assertThrows(Exception.class, () -> result.root().next().add(node)); - assertThrows(Exception.class, () -> result.root().next().get(0).next().add(node)); - } - - private static void dummyExecutor(CommandSender sender, CommandContext context) { - } -} diff --git a/src/test/java/net/minestom/server/command/NodeGraphTest.java b/src/test/java/net/minestom/server/command/NodeGraphTest.java new file mode 100644 index 000000000..4251c90a5 --- /dev/null +++ b/src/test/java/net/minestom/server/command/NodeGraphTest.java @@ -0,0 +1,47 @@ +package net.minestom.server.command; + +import net.minestom.server.command.builder.Command; +import net.minestom.server.command.builder.CommandContext; +import net.minestom.server.command.builder.arguments.ArgumentType; +import net.minestom.server.network.packet.server.play.DeclareCommandsPacket; +import net.minestom.server.network.packet.server.play.DeclareCommandsPacket.NodeType; +import org.junit.jupiter.api.Test; + +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +public class NodeGraphTest { + + @Test + public void singleCommandWithOneSyntax() { + final Command foo = new Command("foo"); + foo.addSyntax(NodeGraphTest::dummyExecutor, ArgumentType.Integer("bar")); + + final DeclareCommandsPacket packet = GraphBuilder.forServer(Set.of(foo)).createPacket(); + assertEquals(3, packet.nodes().size()); + final DeclareCommandsPacket.Node root = packet.nodes().get(packet.rootIndex()); + assertNotNull(root); + assertNodeType(NodeType.ROOT, root.flags); + assertEquals(1, root.children.length); + final DeclareCommandsPacket.Node cmd = packet.nodes().get(root.children[0]); + assertNotNull(cmd); + assertNodeType(NodeType.LITERAL, cmd.flags); + assertEquals(1, cmd.children.length); + assertEquals("foo", cmd.name); + final DeclareCommandsPacket.Node arg = packet.nodes().get(cmd.children[0]); + assertNotNull(arg); + assertNodeType(NodeType.ARGUMENT, arg.flags); + assertEquals(0, arg.children.length); + assertEquals("bar", arg.name); + } + + private static void assertNodeType(NodeType expected, byte flags) { + assertEquals(expected, NodeType.values()[flags & 0x03]); + } + + private static void dummyExecutor(CommandSender sender, CommandContext context) { + + } +} diff --git a/src/test/java/net/minestom/server/command/SubcommandTest.java b/src/test/java/net/minestom/server/command/SubcommandTest.java index 6ed762fa2..2d6b31a16 100644 --- a/src/test/java/net/minestom/server/command/SubcommandTest.java +++ b/src/test/java/net/minestom/server/command/SubcommandTest.java @@ -1,12 +1,14 @@ package net.minestom.server.command; import net.minestom.server.command.builder.Command; +import net.minestom.server.command.builder.exception.IllegalCommandStructureException; import org.junit.jupiter.api.Test; +import java.time.Duration; +import java.util.Set; import java.util.concurrent.atomic.AtomicBoolean; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.*; public class SubcommandTest { @@ -65,4 +67,17 @@ public class SubcommandTest { assertFalse(parentExecuted.get()); assertFalse(childExecuted.get()); } + + @Test + public void testRecursionDetection() { + final Command foo = new Command("foo"); + final Command bar = new Command("bar"); + bar.addSubcommand(foo); + assertDoesNotThrow(() -> GraphBuilder.forServer(Set.of(foo, bar))); + foo.addSubcommand(bar); + assertTimeout(Duration.ofSeconds(5), () -> assertThrows(IllegalCommandStructureException.class, + () -> GraphBuilder.forServer(Set.of(foo, bar)), "Builder didn't detect infinite recursion."), + "Is your stack fine?!"); + } + }