Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
# Changelog

## 0.3.0

- Every `/houserules` command takes `--json <id>` 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.
Expand Down
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <player> <player>` | Says whether the first can reach the second, and why. |
| `/houserules describe` | Lists these admin commands. |

Every `/houserules` command also takes `--json <id>` at the end, for scripts and the Leaf Juice app. It then answers with one `ea-result <id> {...}` 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

Expand Down
14 changes: 12 additions & 2 deletions build.gradle.kts
Original file line number Diff line number Diff line change
@@ -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))
Expand All @@ -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.
Expand All @@ -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"))
Expand All @@ -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) }
19 changes: 19 additions & 0 deletions docs/test-audit.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <id>` (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`.
Expand Down
117 changes: 117 additions & 0 deletions src/main/java/com/extantape/houserules/command/Console.java
Original file line number Diff line number Diff line change
@@ -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<String> circles, boolean filter, List<String> problems) {}
record Reloaded(boolean applied, boolean paused, List<String> 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<Describe.Command> 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<String> t = r.tokens();
String sub = t.isEmpty() ? "status" : t.get(0).toLowerCase(Locale.ROOT);
List<String> 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<String> 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<String, Object> 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<String, Object> 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<String> rest) throws Request.Bad, Refused {
if (rest.size() != 2) throw new Refused(Errors.BAD_ARGUMENT, "Usage: /houserules check <from> <to>");
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<String, Object> 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<String, Object> who(Who w) {
Map<String, Object> m = new LinkedHashMap<>();
m.put("name", w.name());
m.put("uuid", w.uuid());
return m;
}
}
Loading
Loading