From 348bc71c2d003c80673abd781193016d55b37cab Mon Sep 17 00:00:00 2001 From: Michael Gilardi <11632652+mgilardi@users.noreply.github.com> Date: Tue, 10 Nov 2026 00:37:18 -0700 Subject: [PATCH] Release 0.3.0: /houserules --json Every /houserules command takes --json and answers with one ea-result line in the shared Signalbox format (0.3.1, shaded and relocated): status, reload, check, and the new describe. The command stays hidden from players without houserules.admin. check now takes names or UUIDs, not selectors. This is the arrival plan's last step for HouseRules. Tested on Paper 26.3: live suite 67/67 (7 new --json checks, 2 skipped as before), 8 new unit tests. Not run on 26.2 this time. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 6 + README.md | 3 + build.gradle.kts | 14 +- docs/test-audit.md | 19 +++ .../extantape/houserules/command/Console.java | 117 +++++++++++++++ .../command/HouseRulesCommands.java | 139 +++++++++++++++--- .../houserules/command/ConsoleTest.java | 82 +++++++++++ test/live/suite.js | 18 +++ 8 files changed, 372 insertions(+), 26 deletions(-) create mode 100644 src/main/java/com/extantape/houserules/command/Console.java create mode 100644 src/test/java/com/extantape/houserules/command/ConsoleTest.java diff --git a/CHANGELOG.md b/CHANGELOG.md index eacdb88..fb4397c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## 0.3.0 + +- Every `/houserules` command takes `--json ` and answers with one line in the shared Signalbox format, for scripts and the Leaf Juice app. `/houserules describe` lists the commands. The command is still hidden from players without `houserules.admin`. +- `/houserules check` takes player names or UUIDs. Selectors such as `@p` aren't accepted any more. +- Tested on throwaway Paper 26.3 with bots: 67/67 live checks, 7 of them new for `--json`, and 8 new unit tests. Not seen in a real game client. + ## 0.2.0 - Follows BlueMapHUD's rename to BlueMinimap (2026-11-08): coordinate shares from BlueMinimap 0.2 are filtered like chat. Shares from BlueMapHUD 0.1.x are still filtered for this one release, so the two can be updated in either order. Update both together: BlueMinimap 0.2 turns `/hud share` off when HouseRules is older than 0.2. diff --git a/README.md b/README.md index 3882b5f..a790385 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,9 @@ If the config can't be read when the server starts, there are no rules to follow | `/houserules reload` | Reads `config.yml`, `filter.yml` and `quips.yml` again. | | `/houserules status` | Shows the circles, whether the filter is on, and any config warnings. | | `/houserules check ` | Says whether the first can reach the second, and why. | +| `/houserules describe` | Lists these admin commands. | + +Every `/houserules` command also takes `--json ` at the end, for scripts and the Leaf Juice app. It then answers with one `ea-result {...}` line in the shared [Signalbox](https://github.com/ExtantApe/Signalbox) format: `status` gives the circles, the filter and any problems; `reload` whether the new rules were applied; `check` both players with their UUIDs, `reaches` and a reason code such as `shared-circle`; `describe` the commands. Players are named or given by UUID; selectors like `@p` aren't accepted. ## Permissions diff --git a/build.gradle.kts b/build.gradle.kts index 209d585..ef763dc 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,9 +1,10 @@ plugins { java + id("com.gradleup.shadow") version "9.6.1" } group = "com.extantape" -version = "0.2.0" +version = "0.3.0" java { toolchain.languageVersion.set(JavaLanguageVersion.of(25)) @@ -12,6 +13,7 @@ java { repositories { mavenCentral() maven("https://repo.papermc.io/repository/maven-public/") + maven("https://jitpack.io") } // Built against the oldest supported Paper, so nothing newer than 26.1 sneaks in. @@ -23,6 +25,8 @@ dependencies { compileOnly(paperApi) // BlueMapHUD's share event, for the optional hook. Supplied at runtime by BlueMapHUD itself. compileOnly(project(":bluemaphud-stub")) + // The shared command format (--json, describe), shaded in below. + implementation("com.github.ExtantApe:Signalbox:v0.3.1") testImplementation(paperApi) testImplementation(platform("org.junit:junit-bom:5.11.4")) @@ -49,7 +53,13 @@ tasks.test { useJUnitPlatform() } -tasks.jar { +// Signalbox goes inside the jar, relocated, so it can never clash with another plugin's copy. The shaded jar is the +// only jar built. +tasks.shadowJar { archiveBaseName.set("HouseRules") + archiveClassifier.set("") from("LICENSE") + relocate("com.extantape.signalbox", "com.extantape.houserules.libs.signalbox") } +tasks.jar { enabled = false } +tasks.assemble { dependsOn(tasks.shadowJar) } diff --git a/docs/test-audit.md b/docs/test-audit.md index a04cf57..55a6d54 100644 --- a/docs/test-audit.md +++ b/docs/test-audit.md @@ -2,6 +2,25 @@ Kept by this repo's owning session (Minecraft: Plugins Lead), per `~/Code/CLAUDE.md`, "Test self-audits". Newest first. +## 2026-11-10: the --json console + +What's new: every `/houserules` command takes `--json ` (Signalbox format 1), and there's `/houserules describe`. Since the first audit, only CI changes had landed, so this covers the console alone. + +| Promise | Covered by | +| --- | --- | +| One answer line per request, including unknown subcommands, missing or extra arguments and bad player names | unit `ConsoleTest.everyBadRequestStillGetsExactlyOneAnswer`; live "an unknown subcommand still gets one answer" | +| `describe` lists every command, format 1 | unit `describeListsEveryCommandWithFormatOne`; live | +| `check` gives both players with UUIDs, `reaches` and a stable reason code; someone who can't chat where they are reaches nobody | unit `checkGivesBothPlayersWithUuidsAndAStableReasonCode`, `someoneWhoCantSpeakThereReachesNobodyEvenInACircle`; live | +| While chat is paused, `check` refuses rather than guess | unit `whilePausedCheckRefusesInsteadOfGuessing` | +| An offline player is said plainly | unit `anOfflinePlayerIsSaidPlainly`; live | +| `status` and `reload` report circles, filter, problems and whether a reload applied | unit; live `status` | +| A player without `houserules.admin` gets nothing, `--json` or not | live "a player without houserules.admin gets no answer line" | +| Without `--json`, the commands read as before | the existing live checks (`check explains a shared circle`, the reload checks), unchanged | + +Live suite: 67/67 on Paper 26.3 (2 skipped, as before). The test bots' two-letter names aren't valid Minecraft names, so the `--json` checks name them by UUID, which the format allows. + +**Skipped:** tab completion for the new single argument. It's convenience; Brigadier draws it. + ## 2026-11-09: first full audit (at 0.2.0) Each promise in the README, and the test that holds it to it. "Live" is `test/live/suite.js` on throwaway Paper 26.2 and 26.3 with bots and LuckPerms; "unit" is `src/test`. diff --git a/src/main/java/com/extantape/houserules/command/Console.java b/src/main/java/com/extantape/houserules/command/Console.java new file mode 100644 index 0000000..14152e0 --- /dev/null +++ b/src/main/java/com/extantape/houserules/command/Console.java @@ -0,0 +1,117 @@ +package com.extantape.houserules.command; + +import com.extantape.signalbox.Answer; +import com.extantape.signalbox.Args; +import com.extantape.signalbox.Describe; +import com.extantape.signalbox.Errors; +import com.extantape.signalbox.Request; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +/** + * The machine-readable side of /houserules (Signalbox format 1): {@code status}, {@code reload}, {@code check} and + * {@code describe}, each answering exactly one {@code ea-result} line. It's worked out here without a server, so the + * answers can be tested on their own; the plugin supplies a {@link Host}. + */ +final class Console { + private Console() {} + + static final String ADMIN = "houserules.admin"; + + record Status(boolean paused, List circles, boolean filter, List problems) {} + record Reloaded(boolean applied, boolean paused, List problems) {} + record Who(String name, String uuid) {} + /** {@code why} is Reach.Why in lowercase with hyphens; {@code reason} is the same in plain words. */ + record Checked(Who from, Who to, boolean reaches, boolean maySpeak, String why, String reason, boolean filterBypass) {} + + interface Host { + String version(); + Status status(); + Reloaded reload(); + /** Null if either player isn't online. Names or uuids, already checked by {@link Args#player}. */ + Checked check(String from, String to); + } + + static List described() { + return List.of( + new Describe.Command("status", ADMIN, List.of(), List.of(), false), + new Describe.Command("reload", ADMIN, List.of(), List.of(), false), + new Describe.Command("check", ADMIN, List.of(Describe.Arg.of("from", "player"), Describe.Arg.of("to", "player")), List.of(), false), + new Describe.Command("describe", ADMIN, List.of(), List.of(), false)); + } + + /** The one answer line for a --json request. */ + static String answer(Host host, Request r) { + List t = r.tokens(); + String sub = t.isEmpty() ? "status" : t.get(0).toLowerCase(Locale.ROOT); + List rest = t.isEmpty() ? List.of() : t.subList(1, t.size()); + try { + return switch (sub) { + case "status" -> { none(rest, "status"); yield status(r.id(), host.status()); } + case "reload" -> { none(rest, "reload"); yield reloaded(r.id(), host.reload()); } + case "describe" -> { none(rest, "describe"); yield Answer.ok(r.id(), Describe.body("HouseRules", host.version(), described())); } + case "check" -> check(host, r.id(), rest); + default -> Answer.error(r.id(), Errors.UNKNOWN_COMMAND, "There's no /houserules " + sub + ". Try /houserules describe."); + }; + } catch (Request.Bad bad) { + return Answer.error(r.id(), bad.code, bad.getMessage()); + } catch (Refused no) { + return Answer.error(r.id(), no.code, no.getMessage()); + } + } + + /** A refusal with a base error code, for this class's own checks. */ + private static final class Refused extends Exception { + final String code; + Refused(String code, String message) { super(message); this.code = code; } + } + + private static void none(List rest, String sub) throws Refused { + if (!rest.isEmpty()) throw new Refused(Errors.BAD_ARGUMENT, "/houserules " + sub + " takes no arguments."); + } + + private static String status(String id, Status s) { + Map m = new LinkedHashMap<>(); + m.put("paused", s.paused()); + m.put("circles", s.circles()); + m.put("filter", s.filter()); + m.put("problems", s.problems()); + return Answer.ok(id, m); + } + + private static String reloaded(String id, Reloaded r) { + Map m = new LinkedHashMap<>(); + m.put("applied", r.applied()); + m.put("paused", r.paused()); + m.put("problems", r.problems()); + return Answer.ok(id, m); + } + + private static String check(Host host, String id, List rest) throws Request.Bad, Refused { + if (rest.size() != 2) throw new Refused(Errors.BAD_ARGUMENT, "Usage: /houserules check "); + String from = Args.player(rest.get(0)), to = Args.player(rest.get(1)); + Status s = host.status(); + if (s.paused()) return Answer.error(id, "paused", "Chat is paused until the config is fixed, so nobody reaches anyone. See /houserules status."); + Checked c = host.check(from, to); + if (c == null) return Answer.error(id, Errors.NEEDS_ONLINE_PLAYER, "Both players need to be online."); + Map m = new LinkedHashMap<>(); + m.put("from", who(c.from())); + m.put("to", who(c.to())); + m.put("reaches", c.reaches() && c.maySpeak()); + m.put("why", c.why()); + m.put("maySpeak", c.maySpeak()); + m.put("filterBypass", c.filterBypass()); + m.put("reason", c.reason()); + return Answer.ok(id, m); + } + + private static Map who(Who w) { + Map m = new LinkedHashMap<>(); + m.put("name", w.name()); + m.put("uuid", w.uuid()); + return m; + } +} diff --git a/src/main/java/com/extantape/houserules/command/HouseRulesCommands.java b/src/main/java/com/extantape/houserules/command/HouseRulesCommands.java index e725833..7b33501 100644 --- a/src/main/java/com/extantape/houserules/command/HouseRulesCommands.java +++ b/src/main/java/com/extantape/houserules/command/HouseRulesCommands.java @@ -4,22 +4,28 @@ import com.extantape.houserules.rules.Reach; import com.extantape.houserules.rules.Subject; import com.extantape.houserules.rules.WordFilter; +import com.extantape.signalbox.Answer; +import com.extantape.signalbox.Describe; +import com.extantape.signalbox.Request; import com.mojang.brigadier.Command; -import com.mojang.brigadier.context.CommandContext; -import com.mojang.brigadier.exceptions.CommandSyntaxException; -import io.papermc.paper.command.brigadier.CommandSourceStack; +import com.mojang.brigadier.arguments.StringArgumentType; +import com.mojang.brigadier.suggestion.Suggestions; +import com.mojang.brigadier.suggestion.SuggestionsBuilder; import io.papermc.paper.command.brigadier.Commands; -import io.papermc.paper.command.brigadier.argument.ArgumentTypes; -import io.papermc.paper.command.brigadier.argument.resolvers.selector.PlayerSelectorArgumentResolver; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.format.NamedTextColor; import org.bukkit.Bukkit; import org.bukkit.command.CommandSender; import org.bukkit.entity.Player; import java.util.ArrayList; import java.util.List; +import java.util.Locale; import java.util.Map; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; -/** /circles for players, and /houserules reload, status and check for admins. */ +/** /circles for players, and /houserules status, reload, check and describe for admins, each with a --json form. */ public final class HouseRulesCommands { public static final String CIRCLES = "houserules.circles"; public static final String CIRCLES_NAMES = "houserules.circles.names"; @@ -35,18 +41,102 @@ public void register(Commands commands) { .executes(ctx -> circles((Player) ctx.getSource().getSender())) .build(), "Shows your circles and who can hear you right now."); + // One greedy argument, read by Signalbox, so every subcommand also takes --json (format 1). Players without + // houserules.admin don't see the command at all; the console always can. commands.register(Commands.literal("houserules") .requires(src -> src.getSender().hasPermission(HouseRulesPlugin.ADMIN)) .executes(ctx -> status(ctx.getSource().getSender())) - .then(Commands.literal("reload").executes(ctx -> reload(ctx.getSource().getSender()))) - .then(Commands.literal("status").executes(ctx -> status(ctx.getSource().getSender()))) - .then(Commands.literal("check") - .then(Commands.argument("from", ArgumentTypes.player()) - .then(Commands.argument("to", ArgumentTypes.player()) - .executes(this::check)))) - .build(), "HouseRules admin: reload, status, and check whether one player can reach another."); + .then(Commands.argument("args", StringArgumentType.greedyString()) + .suggests((ctx, builder) -> suggest(builder)) + .executes(ctx -> run(ctx.getSource().getSender(), ctx.getArgument("args", String.class)))) + .build(), "HouseRules admin: status, reload, check whether one player can reach another, and describe."); } + private CompletableFuture suggest(SuggestionsBuilder builder) { + String[] words = builder.getRemaining().split(" ", -1); + String last = words[words.length - 1].toLowerCase(Locale.ROOT); + SuggestionsBuilder at = builder.createOffset(builder.getStart() + builder.getRemaining().length() - last.length()); + if (words.length == 1) { + for (String sub : List.of("status", "reload", "check", "describe")) if (sub.startsWith(last)) at.suggest(sub); + } else if (words[0].equalsIgnoreCase("check") && words.length <= 3) { + for (Player p : Bukkit.getOnlinePlayers()) if (p.getName().toLowerCase(Locale.ROOT).startsWith(last)) at.suggest(p.getName()); + } + return at.buildFuture(); + } + + private int run(CommandSender sender, String input) { + Request r; + try { + r = Request.parse(input.trim().isEmpty() ? new String[0] : input.trim().split(" +")); + } catch (Request.Bad bad) { + if (bad.id != null) Answer.send(sender::sendMessage, Answer.error(bad.id, bad.code, bad.getMessage())); + else plugin.tell(sender, Component.text(bad.getMessage(), NamedTextColor.RED)); + return Command.SINGLE_SUCCESS; + } + if (r.json()) { + Answer.send(sender::sendMessage, Console.answer(host, r)); + return Command.SINGLE_SUCCESS; + } + List t = r.tokens(); + String sub = t.isEmpty() ? "status" : t.get(0).toLowerCase(Locale.ROOT); + switch (sub) { + case "status" -> status(sender); + case "reload" -> reload(sender); + case "check" -> { + Player from = t.size() == 3 ? online(t.get(1)) : null, to = t.size() == 3 ? online(t.get(2)) : null; + if (t.size() != 3) plugin.tell(sender, Component.text("Usage: /houserules check ", NamedTextColor.RED)); + else if (from == null || to == null) plugin.tell(sender, Component.text("Both players need to be online.", NamedTextColor.RED)); + else check(sender, from, to); + } + case "describe" -> { + for (Describe.Command d : Console.described()) { + StringBuilder line = new StringBuilder("/houserules " + d.name()); + for (Describe.Arg a : d.args()) line.append(" <").append(a.name()).append('>'); + plugin.tell(sender, Component.text(line.toString(), NamedTextColor.GRAY)); + } + } + default -> plugin.tell(sender, Component.text("There's no /houserules " + sub + ". Try status, reload, check or describe.", NamedTextColor.RED)); + } + return Command.SINGLE_SUCCESS; + } + + /** A player by exact name or uuid, or null if they aren't online. */ + static Player online(String nameOrUuid) { + try { + return Bukkit.getPlayer(UUID.fromString(nameOrUuid)); + } catch (IllegalArgumentException notAUuid) { + return Bukkit.getPlayerExact(nameOrUuid); + } + } + + /** What the console answers from: the live rules and the online players. */ + private final Console.Host host = new Console.Host() { + @Override public String version() { return plugin.getPluginMeta().getVersion(); } + + @Override public Console.Status status() { + HouseRulesPlugin.State state = plugin.state(); + if (state == null) return new Console.Status(true, List.of(), false, plugin.problems()); + return new Console.Status(false, state.settings().reach().circles(), !state.settings().filter().isEmpty(), plugin.problems()); + } + + @Override public Console.Reloaded reload() { + boolean applied = plugin.reload(Bukkit.getConsoleSender()); + return new Console.Reloaded(applied, plugin.paused(), plugin.problems()); + } + + @Override public Console.Checked check(String fromArg, String toArg) { + Player from = online(fromArg), to = online(toArg); + HouseRulesPlugin.State state = plugin.state(); + if (from == null || to == null || state == null) return null; + Reach reach = state.settings().reach(); + Reach.Why why = reach.why(Subject.of(from), Subject.of(to)); + return new Console.Checked(who(from), who(to), why.reaches, reach.maySpeak(Subject.of(from)), + why.name().toLowerCase(Locale.ROOT).replace('_', '-'), reason(reach, why, from, to), from.hasPermission(WordFilter.BYPASS)); + } + + private Console.Who who(Player p) { return new Console.Who(p.getName(), p.getUniqueId().toString()); } + }; + private int circles(Player player) { HouseRulesPlugin.State state = plugin.state(); if (state == null) { @@ -105,19 +195,25 @@ private int status(CommandSender sender) { return Command.SINGLE_SUCCESS; } - private int check(CommandContext ctx) throws CommandSyntaxException { - Player from = ctx.getArgument("from", PlayerSelectorArgumentResolver.class).resolve(ctx.getSource()).getFirst(); - Player to = ctx.getArgument("to", PlayerSelectorArgumentResolver.class).resolve(ctx.getSource()).getFirst(); - CommandSender sender = ctx.getSource().getSender(); + private void check(CommandSender sender, Player from, Player to) { HouseRulesPlugin.State state = plugin.state(); if (state == null) { plugin.tell(sender, "admin-paused"); - return Command.SINGLE_SUCCESS; + return; } Reach reach = state.settings().reach(); Subject a = Subject.of(from), b = Subject.of(to); Reach.Why why = reach.why(a, b); - String reason = switch (why) { + String reason = reason(reach, why, from, to); + if (!reach.maySpeak(a)) reason += ", and " + from.getName() + " can't chat where they are"; + if (from.hasPermission(WordFilter.BYPASS)) reason += ". " + from.getName() + " skips the word filter"; + plugin.tell(sender, why.reaches && reach.maySpeak(a) ? "admin-check-yes" : "admin-check-no", + Map.of("from", from.getName(), "to", to.getName(), "reason", reason)); + } + + private static String reason(Reach reach, Reach.Why why, Player from, Player to) { + Subject a = Subject.of(from), b = Subject.of(to); + return switch (why) { case RULES_OFF -> "no circles are set up, so everyone reaches everyone"; case SELF -> "that's the same player"; case BYPASS -> "one of them has houserules.bypass"; @@ -126,10 +222,5 @@ private int check(CommandContext ctx) throws CommandSyntaxEx case CIRCLE_ONLY_WORLD -> "they're in a circle-only world and share no circle"; case NOT_SHARED -> "they're in different world groups and share no circle"; }; - if (!reach.maySpeak(a)) reason += ", and " + from.getName() + " can't chat where they are"; - if (from.hasPermission(WordFilter.BYPASS)) reason += ". " + from.getName() + " skips the word filter"; - plugin.tell(sender, why.reaches && reach.maySpeak(a) ? "admin-check-yes" : "admin-check-no", - Map.of("from", from.getName(), "to", to.getName(), "reason", reason)); - return Command.SINGLE_SUCCESS; } } diff --git a/src/test/java/com/extantape/houserules/command/ConsoleTest.java b/src/test/java/com/extantape/houserules/command/ConsoleTest.java new file mode 100644 index 0000000..99249f3 --- /dev/null +++ b/src/test/java/com/extantape/houserules/command/ConsoleTest.java @@ -0,0 +1,82 @@ +package com.extantape.houserules.command; + +import com.extantape.signalbox.Request; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +/** The --json answers for /houserules, with a stand-in for the server. */ +class ConsoleTest { + static final Console.Who ANN = new Console.Who("Ann", "00000000-0000-0000-0000-00000000000a"); + static final Console.Who BEN = new Console.Who("Ben", "00000000-0000-0000-0000-00000000000b"); + + final List asked = new ArrayList<>(); + boolean paused; + Console.Checked checked = new Console.Checked(ANN, BEN, true, true, "shared-circle", "they share the circle red", false); + + final Console.Host host = new Console.Host() { + @Override public String version() { return "0.3.0"; } + @Override public Console.Status status() { return new Console.Status(paused, paused ? List.of() : List.of("red", "blue"), true, List.of()); } + @Override public Console.Reloaded reload() { asked.add("reload"); return new Console.Reloaded(true, false, List.of("a warning")); } + @Override public Console.Checked check(String from, String to) { asked.add(from + ">" + to); return checked; } + }; + + String ask(String line) throws Request.Bad { return Console.answer(host, Request.parse(line.split(" "))); } + + @Test void describeListsEveryCommandWithFormatOne() throws Exception { + String a = ask("describe --json d1"); + assertTrue(a.startsWith("ea-result d1 {"), a); + for (String c : List.of("\"status\"", "\"reload\"", "\"check\"", "\"describe\"")) assertTrue(a.contains(c), c + " in " + a); + assertTrue(a.contains("\"format\":1") && a.contains("\"plugin\":\"HouseRules\"") && a.contains("\"version\":\"0.3.0\""), a); + assertTrue(a.contains("{\"name\":\"from\",\"type\":\"player\"}"), a); + } + + @Test void statusSaysPausedCirclesFilterAndProblems() throws Exception { + String a = ask("status --json s1"); + assertTrue(a.contains("\"ok\":true") && a.contains("\"paused\":false") && a.contains("\"circles\":[\"red\",\"blue\"]") && a.contains("\"filter\":true"), a); + assertEquals(a, ask("--json s1"), "no subcommand means status"); + } + + @Test void reloadAppliesAndListsWhatItFound() throws Exception { + String a = ask("reload --json r1"); + assertEquals(List.of("reload"), asked); + assertTrue(a.contains("\"applied\":true") && a.contains("\"problems\":[\"a warning\"]"), a); + } + + @Test void checkGivesBothPlayersWithUuidsAndAStableReasonCode() throws Exception { + String a = ask("check Ann Ben --json c1"); + assertTrue(a.contains("\"reaches\":true") && a.contains("\"why\":\"shared-circle\"") && a.contains("\"uuid\":\"" + ANN.uuid() + "\""), a); + } + + @Test void someoneWhoCantSpeakThereReachesNobodyEvenInACircle() throws Exception { + checked = new Console.Checked(ANN, BEN, true, false, "shared-circle", "they share the circle red", false); + String a = ask("check Ann Ben --json c2"); + assertTrue(a.contains("\"reaches\":false") && a.contains("\"maySpeak\":false"), a); + } + + @Test void anOfflinePlayerIsSaidPlainly() throws Exception { + checked = null; + assertTrue(ask("check Ann Zed --json c3").contains("\"code\":\"needs-online-player\"")); + } + + @Test void whilePausedCheckRefusesInsteadOfGuessing() throws Exception { + paused = true; + String a = ask("check Ann Ben --json c4"); + assertTrue(a.contains("\"ok\":false") && a.contains("\"code\":\"paused\""), a); + assertTrue(asked.isEmpty(), "nobody was looked up"); + } + + @Test void everyBadRequestStillGetsExactlyOneAnswer() throws Exception { + for (String line : List.of("nonsense --json e1", "check Ann --json e2", "check Ann B@d --json e3", "status extra --json e4", "reload now --json e5")) { + String a = ask(line); + assertTrue(a.startsWith("ea-result e") && a.contains("\"ok\":false") && !a.contains("\n"), line + " -> " + a); + } + assertTrue(ask("nonsense --json e1").contains("unknown-command")); + assertTrue(ask("check Ann --json e2").contains("bad-argument")); + assertTrue(ask("check Ann B@d --json e3").contains("bad-argument")); + assertTrue(asked.isEmpty(), "nothing was reloaded or looked up for a bad request"); + } +} diff --git a/test/live/suite.js b/test/live/suite.js index 4da13ec..3f6a7e6 100644 --- a/test/live/suite.js +++ b/test/live/suite.js @@ -271,6 +271,24 @@ async function adminCheck() { check('check explains a shared circle', m && /R1 can reach R2: they share the circle red/.test(m[0]), m && m[0]); m = await s.run('houserules check N1 N3', /\w+ can(?:'t)? reach N3: [^\n]*/); check('check explains different worlds', m && /can't reach N3: they're in different world groups/.test(m[0]), m && m[0]); + + section('/houserules --json (Signalbox format 1)'); + const json = (c, id) => s.run(`houserules ${c} --json ${id}`, new RegExp(`ea-result ${id} (\\{[^\\n]*\\})`)).then((x) => x && JSON.parse(x[1])); + const d = await json('describe', 'hr1'); + check('describe lists the four commands, format 1', d && d.ok && d.format === 1 && ['status', 'reload', 'check', 'describe'].every((n) => d.commands.some((c) => c.name === n)), JSON.stringify(d)); + // The test bots' two-letter names aren't valid Minecraft names (3 to 16), so --json gets their offline UUIDs. + const uuidOf = (name) => { const h = require('crypto').createHash('md5').update('OfflinePlayer:' + name).digest(); h[6] = (h[6] & 0x0f) | 0x30; h[8] = (h[8] & 0x3f) | 0x80; const x = h.toString('hex'); return `${x.slice(0, 8)}-${x.slice(8, 12)}-${x.slice(12, 16)}-${x.slice(16, 20)}-${x.slice(20)}`; }; + const c = await json(`check ${uuidOf('R1')} ${uuidOf('R2')}`, 'hr2'); + check('check answers with both players, uuids and a reason code', c && c.ok && c.reaches === true && c.why === 'shared-circle' && /^[0-9a-f-]{36}$/.test(c.from.uuid), JSON.stringify(c)); + const off = await json(`check ${uuidOf('R1')} Nobody`, 'hr3'); + check('an offline player gets needs-online-player', off && off.ok === false && off.error.code === 'needs-online-player', JSON.stringify(off)); + const st = await json('status', 'hr4'); + check('status lists the circles and no problems', st && st.ok && st.paused === false && st.circles.includes('red'), JSON.stringify(st)); + const bad = await json('frobnicate', 'hr5'); + check('an unknown subcommand still gets one answer', bad && bad.ok === false && bad.error.code === 'unknown-command', JSON.stringify(bad)); + // A player without houserules.admin: the command doesn't exist for them, --json or not. + const k = bots.N2.mark(); bots.N2.say('/houserules status --json hr6'); + check('a player without houserules.admin gets no answer line', await bots.N2.waitFor(/Unknown or incomplete command/, 3000, k) && !s.log.includes('ea-result hr6'), bots.N2.lines(k)); } async function brokenReload() {